fix(codex): let Codex's elevated Windows sandbox read Orca's managed Codex home (#33748)

* fix(codex): let Codex's elevated Windows sandbox read Orca's managed Codex home

Fixes #24306

* fix(codex): run the sandbox read grant off the launch path, once per home

* fix(codex): skip the sandbox read grant when the home already has it

* fix(codex): grant the sandbox only resource folders, retry failures, cover first elevated session

* fix(codex): machine-qualify the sandbox group and verify full propagation
This commit is contained in:
Neil
2026-10-25 20:41:29 -07:00
committed by GitHub
parent ddcedee37c
commit 7b5f50d917
5 changed files with 312 additions and 3 deletions
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { grantMock } = vi.hoisted(() => ({ grantMock: vi.fn() }))
vi.mock('./codex-windows-sandbox-read-grant', () => ({
grantCodexSandboxReadOnManagedHome: grantMock
}))
vi.mock('./hook-service', () => ({
codexHookService: { prepareRuntimeHomeForLaunch: vi.fn(async () => ({ state: 'ok' })) }
}))
vi.mock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu' }))
vi.mock('./codex-hook-reconcile', () => ({ reconcileCodexHooksForLaunch: vi.fn() }))
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
import { createCodexRuntimeHomeLaunchPreparation } from './codex-runtime-home-launch-preparation'
function prepareWithHome(homePath: string) {
const runtimeHome: Pick<CodexRuntimeHomeService, 'prepareForCodexLaunchAsync'> = {
prepareForCodexLaunchAsync: async () => homePath
}
return createCodexRuntimeHomeLaunchPreparation({
getRuntimeHome: () => runtimeHome,
getSettings: () => undefined
})
}
beforeEach(() => {
grantMock.mockReset()
})
describe('Codex launch preparation sandbox read grant', () => {
it('grants on host managed homes but not on WSL homes', async () => {
await prepareWithHome('C:\\orca\\codex-runtime-home\\home')({ runtime: 'host' })
await prepareWithHome('/home/me/.codex')({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(grantMock.mock.calls).toEqual([['C:\\orca\\codex-runtime-home\\home']])
})
})
@@ -6,9 +6,10 @@ import { codexHookService } from './hook-service'
import { getDefaultWslDistro } from '../wsl'
import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls'
import { reconcileCodexHooksForLaunch } from './codex-hook-reconcile'
import { grantCodexSandboxReadOnManagedHome } from './codex-windows-sandbox-read-grant'
export function createCodexRuntimeHomeLaunchPreparation(deps: {
getRuntimeHome: () => CodexRuntimeHomeService | null
getRuntimeHome: () => Pick<CodexRuntimeHomeService, 'prepareForCodexLaunchAsync'> | null
getSettings: () => GlobalSettings | undefined
}) {
return async (
@@ -32,6 +33,9 @@ export function createCodexRuntimeHomeLaunchPreparation(deps: {
}
return null
}
if (runtimeHomePath !== null && target?.runtime !== 'wsl') {
await grantCodexSandboxReadOnManagedHome(runtimeHomePath)
}
const hookTarget =
target?.runtime === 'wsl'
? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() }
@@ -0,0 +1,106 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
import { grantCodexSandboxReadOnManagedHome } from './codex-windows-sandbox-read-grant'
const originalPlatform = process.platform
const originalComputerName = process.env.COMPUTERNAME
let home: string
function processResult(code: number, stdout = '') {
return { code, signal: null, stdout, stderr: '', timedOut: false }
}
function grantCalls(): string[] {
return runProcessMock.mock.calls
.map(([spec]) => spec.args)
.filter((args: string[]) => args[1] === '/grant')
.map((args: string[]) => args[0])
}
beforeEach(() => {
runProcessMock.mockReset()
runProcessMock.mockResolvedValue(processResult(0))
home = mkdtempSync(join(tmpdir(), 'codex-sandbox-read-grant-'))
mkdirSync(join(home, '.sandbox'))
writeFileSync(join(home, '.sandbox', 'setup_marker.json'), '{}')
Object.defineProperty(process, 'platform', { value: 'win32' })
process.env.COMPUTERNAME = 'PC'
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
process.env.COMPUTERNAME = originalComputerName
vi.useRealTimers()
rmSync(home, { recursive: true, force: true })
})
describe('grantCodexSandboxReadOnManagedHome', () => {
it('grants the sandbox group read on the resource folders, never the home root', async () => {
mkdirSync(join(home, 'skills'))
await grantCodexSandboxReadOnManagedHome(home)
await grantCodexSandboxReadOnManagedHome(home)
expect(existsSync(join(home, 'generated_images'))).toBe(true)
expect(grantCalls().sort()).toEqual([join(home, 'generated_images'), join(home, 'skills')])
expect(runProcessMock).toHaveBeenCalledWith(
expect.objectContaining({
program: expect.stringMatching(/System32[\\/]icacls\.exe$/),
args: [join(home, 'skills'), '/grant', 'PC\\CodexSandboxUsers:(OI)(CI)RX', '/Q']
})
)
})
it('retries a failed grant on the next launch', async () => {
runProcessMock.mockImplementation(async ({ args }) =>
args[1] === '/grant' ? processResult(1332) : processResult(0)
)
await grantCodexSandboxReadOnManagedHome(home)
runProcessMock.mockResolvedValue(processResult(0))
await grantCodexSandboxReadOnManagedHome(home)
expect(grantCalls()).toEqual([join(home, 'generated_images'), join(home, 'generated_images')])
})
it('does nothing before Codex set up its elevated sandbox for the home', async () => {
rmSync(join(home, '.sandbox'), { recursive: true })
await grantCodexSandboxReadOnManagedHome(home)
expect(runProcessMock).not.toHaveBeenCalled()
})
it('skips linked skills and plugins so the grant never reaches the system Codex home', async () => {
const systemHome = mkdtempSync(join(tmpdir(), 'codex-system-home-'))
try {
mkdirSync(join(systemHome, 'skills'))
mkdirSync(join(systemHome, 'plugins'))
symlinkSync(join(systemHome, 'skills'), join(home, 'skills'), 'junction')
symlinkSync(join(systemHome, 'plugins'), join(home, 'plugins'), 'junction')
await grantCodexSandboxReadOnManagedHome(home)
expect(grantCalls()).toEqual([join(home, 'generated_images')])
} finally {
rmSync(systemHome, { recursive: true, force: true })
}
})
it('grants once Codex writes its setup marker during the first elevated session', async () => {
vi.useFakeTimers()
rmSync(join(home, '.sandbox'), { recursive: true })
writeFileSync(join(home, 'config.toml'), '[windows]\nsandbox = "elevated"\n')
await grantCodexSandboxReadOnManagedHome(home)
expect(runProcessMock).not.toHaveBeenCalled()
mkdirSync(join(home, '.sandbox'))
writeFileSync(join(home, '.sandbox', 'setup_marker.json'), '{}')
await vi.advanceTimersByTimeAsync(5_000)
expect(grantCalls()).toEqual([join(home, 'generated_images')])
})
})
@@ -0,0 +1,158 @@
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
import { hostname } from 'node:os'
import { join } from 'node:path'
import { runProcess } from '@orca/process-host'
import { getSystem32ExePath } from '../win32-utils'
// Codex's elevated sandbox runs commands as members of this local group (codex-rs windows-sandbox-rs/src/winutil.rs).
const CODEX_SANDBOX_USERS_GROUP = 'CodexSandboxUsers'
// Never the home root: the group is machine-wide and the root holds auth.json.
const SANDBOX_READABLE_ENTRIES = ['generated_images', 'skills', 'plugins'] as const
const LAUNCH_WAIT_MS = 2_000
const MARKER_POLL_MS = 5_000
const MARKER_POLL_LIMIT_MS = 30 * 60_000
// Keyed by path and inode so a recreated directory is granted again.
const grantedDirs = new Set<string>()
const pendingGrants = new Map<string, Promise<void>>()
const markerPolls = new Set<string>()
/**
* Codex's elevated setup grants its sandbox group read on every USERPROFILE child, so ~/.codex is
* readable; Orca's managed home sits under a protected userData DACL and never gets that ACE.
*/
export async function grantCodexSandboxReadOnManagedHome(codexHome: string): Promise<void> {
if (process.platform !== 'win32') {
return
}
if (!hasSetupMarker(codexHome)) {
// Codex provisions its sandbox users mid-session, after this launch.
if (requestsElevatedSandbox(codexHome)) {
pollForSetupMarker(codexHome)
}
return
}
// A large existing tree keeps propagating in the background instead of delaying the terminal.
await Promise.race([
grantEntries(codexHome),
new Promise<void>((resolve) => setTimeout(resolve, LAUNCH_WAIT_MS).unref())
])
}
function hasSetupMarker(codexHome: string): boolean {
return existsSync(join(codexHome, '.sandbox', 'setup_marker.json'))
}
function requestsElevatedSandbox(codexHome: string): boolean {
try {
const config = readFileSync(join(codexHome, 'config.toml'), 'utf8')
// `[windows] sandbox = "elevated"` or the legacy `elevated_windows_sandbox` feature flag.
return /\bsandbox\s*=\s*["']elevated["']|\belevated_windows_sandbox\s*=\s*true\b/.test(config)
} catch {
return false
}
}
function pollForSetupMarker(codexHome: string): void {
if (markerPolls.has(codexHome)) {
return
}
markerPolls.add(codexHome)
const startedAt = Date.now()
const timer = setInterval(() => {
if (hasSetupMarker(codexHome)) {
void grantEntries(codexHome)
} else if (Date.now() - startedAt < MARKER_POLL_LIMIT_MS) {
return
}
clearInterval(timer)
markerPolls.delete(codexHome)
}, MARKER_POLL_MS)
timer.unref()
}
async function grantEntries(codexHome: string): Promise<void> {
try {
// Created before Codex writes images so they inherit the grant.
mkdirSync(join(codexHome, 'generated_images'), { recursive: true })
} catch {
// Codex creates it itself; the grant then applies on the next launch.
}
await Promise.all(SANDBOX_READABLE_ENTRIES.map((entry) => grantDir(join(codexHome, entry))))
}
function grantDir(dir: string): Promise<void> {
let ino: number
try {
const stat = lstatSync(dir)
// Links point into the system Codex home, which Codex already opened; icacls would follow them.
if (!stat.isDirectory()) {
return Promise.resolve()
}
ino = stat.ino
} catch {
return Promise.resolve()
}
const key = `${dir}|${ino}`
if (grantedDirs.has(key)) {
return Promise.resolve()
}
let pending = pendingGrants.get(key)
if (!pending) {
// Failures stay uncached so the next launch retries.
pending = runIcaclsGrant(dir)
.then((granted) => {
if (granted) {
grantedDirs.add(key)
}
})
.finally(() => pendingGrants.delete(key))
pendingGrants.set(key, pending)
}
return pending
}
function sandboxGroupAccount(): string {
// Machine-qualified so a same-named domain group can never match.
return `${process.env.COMPUTERNAME || hostname()}\\${CODEX_SANDBOX_USERS_GROUP}`
}
async function hasSandboxAce(icacls: string, path: string, flags: string): Promise<boolean> {
const result = await runProcess({ program: icacls, args: [path], timeoutMs: 10_000 })
return (
result.code === 0 &&
result.stdout.toLowerCase().includes(`${sandboxGroupAccount()}:${flags}`.toLowerCase())
)
}
/** The last entry propagates last, so its inherited ACE shows an earlier grant finished. */
async function hasCompletedGrant(icacls: string, dir: string): Promise<boolean> {
if (!(await hasSandboxAce(icacls, dir, '(OI)(CI)(RX)'))) {
return false
}
const lastEntry = readdirSync(dir).at(-1)
return lastEntry === undefined || (await hasSandboxAce(icacls, join(dir, lastEntry), '(I)'))
}
async function runIcaclsGrant(dir: string): Promise<boolean> {
const icacls = getSystem32ExePath('icacls.exe')
try {
// A repeat grant would re-propagate through the whole tree on every Orca start.
if (await hasCompletedGrant(icacls, dir)) {
return true
}
// No timeout: killing icacls mid-propagation would leave part of the tree unreadable.
const result = await runProcess({
program: icacls,
args: [dir, '/grant', `${sandboxGroupAccount()}:(OI)(CI)RX`, '/Q'],
timeoutMs: null
})
if (result.code === 0) {
return true
}
console.warn('[codex] sandbox read grant failed', dir, result.stderr || result.stdout)
} catch (error) {
console.warn('[codex] sandbox read grant failed', dir, error)
}
return false
}
+6 -2
View File
@@ -14,10 +14,14 @@ export {
} from '../shared/windows-batch-spawn'
/** Absolute path because service-launched Electron can omit System32 from PATH. */
export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string {
export function getSystem32ExePath(exe: string, env: NodeJS.ProcessEnv = process.env): string {
const systemRoot = env.SystemRoot?.trim()
const root = systemRoot && /^[a-z]:[\\/]/i.test(systemRoot) ? systemRoot : 'C:\\Windows'
return win32.join(root, 'System32', 'reg.exe')
return win32.join(root, 'System32', exe)
}
export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string {
return getSystem32ExePath('reg.exe', env)
}
export function resolveWindowsCommand(