From 7b5f50d9170b30e42ea07b7e48c0ad85fd811838 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:41:29 -0700 Subject: [PATCH] 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 --- ...ex-runtime-home-launch-preparation.test.ts | 37 ++++ .../codex-runtime-home-launch-preparation.ts | 6 +- .../codex-windows-sandbox-read-grant.test.ts | 106 ++++++++++++ .../codex/codex-windows-sandbox-read-grant.ts | 158 ++++++++++++++++++ src/main/win32-utils.ts | 8 +- 5 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 src/main/codex/codex-runtime-home-launch-preparation.test.ts create mode 100644 src/main/codex/codex-windows-sandbox-read-grant.test.ts create mode 100644 src/main/codex/codex-windows-sandbox-read-grant.ts diff --git a/src/main/codex/codex-runtime-home-launch-preparation.test.ts b/src/main/codex/codex-runtime-home-launch-preparation.test.ts new file mode 100644 index 00000000000..f09a4111c96 --- /dev/null +++ b/src/main/codex/codex-runtime-home-launch-preparation.test.ts @@ -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 = { + 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']]) + }) +}) diff --git a/src/main/codex/codex-runtime-home-launch-preparation.ts b/src/main/codex/codex-runtime-home-launch-preparation.ts index 1b2a46b0c71..cfa14f3c73a 100644 --- a/src/main/codex/codex-runtime-home-launch-preparation.ts +++ b/src/main/codex/codex-runtime-home-launch-preparation.ts @@ -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 | 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() } diff --git a/src/main/codex/codex-windows-sandbox-read-grant.test.ts b/src/main/codex/codex-windows-sandbox-read-grant.test.ts new file mode 100644 index 00000000000..9fddfd3e7c0 --- /dev/null +++ b/src/main/codex/codex-windows-sandbox-read-grant.test.ts @@ -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')]) + }) +}) diff --git a/src/main/codex/codex-windows-sandbox-read-grant.ts b/src/main/codex/codex-windows-sandbox-read-grant.ts new file mode 100644 index 00000000000..410ae82f499 --- /dev/null +++ b/src/main/codex/codex-windows-sandbox-read-grant.ts @@ -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() +const pendingGrants = new Map>() +const markerPolls = new Set() + +/** + * 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 { + 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((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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 0bc129db678..099f5d7553a 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -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(