mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user