Skip to content
17 changes: 13 additions & 4 deletions src/main/agent-trust-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe('markCodexProjectTrusted', () => {
const held = runExclusivelyForCodexTrustConfig(configPath, () => grantHoldingTheFile)
const marked = markCodexProjectTrusted(
workspace,
getLocalCodexTrustConfigFiles(testState.fakeHomeDir)
getLocalCodexTrustConfigFiles(join(testState.fakeHomeDir, '.codex', 'config.toml'))
)
await Promise.resolve()
expect(existsSync(configPath)).toBe(false)
Expand Down Expand Up @@ -316,7 +316,10 @@ describe('markCodexProjectTrusted', () => {
}

async function expectWorkspaceTrusted(workspace: string): Promise<void> {
await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir))
await markCodexProjectTrusted(
workspace,
getLocalCodexTrustConfigFiles(join(testState.fakeHomeDir, '.codex', 'config.toml'))
)
const runtimeHome = join(testState.userDataDir, 'codex-runtime-home', 'home')
const [system, runtime] = [
join(testState.fakeHomeDir, '.codex', 'config.toml'),
Expand Down Expand Up @@ -410,7 +413,10 @@ describe('markCodexProjectTrusted', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
try {
const realpath = realpathSync.native(workspace)
await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir))
await markCodexProjectTrusted(
workspace,
getLocalCodexTrustConfigFiles(join(testState.fakeHomeDir, '.codex', 'config.toml'))
)
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
const runtimeConfigPath = join(
testState.userDataDir,
Expand Down Expand Up @@ -464,7 +470,10 @@ describe('markCodexProjectTrusted', () => {
'utf-8'
)

await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir))
await markCodexProjectTrusted(
workspace,
getLocalCodexTrustConfigFiles(join(testState.fakeHomeDir, '.codex', 'config.toml'))
)

const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8')
const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8')
Expand Down
4 changes: 2 additions & 2 deletions src/main/agent-trust-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ export function markAntigravityWorkspaceTrusted(workspacePath: string, home: str
* Verified against codex-rs/tui/src/onboarding/trust_directory.rs and
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
*
* `configFiles` names every config.toml the launched Codex may read, in the
* hook installer's lock order (an Orca-owned CODEX_HOME before the system one).
* `configFiles` is Orca's shared home config plus the one config.toml the launch
* reads, in the hook installer's lock order.
*/
export function markCodexProjectTrusted(
workspacePath: string,
Expand Down
115 changes: 115 additions & 0 deletions src/main/agent-workspace-trust-codex-home.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const state = vi.hoisted(() => ({ home: '' }))

vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return { ...actual, homedir: () => state.home }
})

import { applyAgentWorkspaceTrust, type AgentTrustLaunchContext } from './agent-workspace-trust'
import { resolveOrcaManagedCodexHomePath } from './codex/codex-home-paths'

const USER_CONFIG = '# the user\'s own Codex config\nmodel = "o3"\n'

let root: string
let workspace: string
let previousUserDataPath: string | undefined

function userCodexConfig(): string {
return join(state.home, '.codex', 'config.toml')
}

function mirrorConfig(): string {
return join(resolveOrcaManagedCodexHomePath(), 'config.toml')
}

function trustedProjects(configFile: string): string[] {
return [...readFileSync(configFile, 'utf-8').matchAll(/^\[projects\."(.*)"\]$/gm)].map((match) =>
match[1].replaceAll('\\\\', '\\')
)
}

function launchOn(codexHome: string | null): Promise<unknown> {
const context: AgentTrustLaunchContext = {
// Why HOME too: an explicit CODEX_HOME must leave this home's .codex alone; without one, trust goes there.
env: { HOME: state.home, USERPROFILE: state.home },
claudeAuth: null,
wslDistro: null,
connectionId: null,
codexHome
}
return applyAgentWorkspaceTrust('codex', workspace, context)
}

beforeEach(() => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-codex-trust-home-')))
state.home = join(root, 'home')
workspace = join(root, 'projects', 'app')
mkdirSync(workspace, { recursive: true })
mkdirSync(join(state.home, '.codex'), { recursive: true })
writeFileSync(userCodexConfig(), USER_CONFIG)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = join(root, 'user-data')
})

afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(root, { recursive: true, force: true })
})

describe('local Codex trust follows the CODEX_HOME the launch runs on', () => {
it("writes a per-account home and Orca's mirror, never the user's ~/.codex", async () => {
const accountHome = join(root, 'user-data', 'codex-accounts', 'a', 'home')
mkdirSync(accountHome, { recursive: true })

await launchOn(accountHome)

expect(trustedProjects(join(accountHome, 'config.toml'))).toEqual([workspace])
expect(trustedProjects(mirrorConfig())).toEqual([workspace])
expect(readFileSync(userCodexConfig(), 'utf-8')).toBe(USER_CONFIG)
})

it("writes only Orca's mirror when the launch runs on it", async () => {
await launchOn(resolveOrcaManagedCodexHomePath())

expect(trustedProjects(mirrorConfig())).toEqual([workspace])
expect(readFileSync(userCodexConfig(), 'utf-8')).toBe(USER_CONFIG)
})

it('writes ~/.codex when the launch sets no CODEX_HOME, since that is the home Codex reads', async () => {
rmSync(userCodexConfig())

await launchOn(null)

expect(trustedProjects(userCodexConfig())).toEqual([workspace])
expect(trustedProjects(mirrorConfig())).toEqual([workspace])
})

it('creates no ~/.codex config for a launch on another home', async () => {
rmSync(join(state.home, '.codex'), { recursive: true })
const accountHome = join(root, 'accounts', 'b')
mkdirSync(accountHome, { recursive: true })

await launchOn(accountHome)

expect(existsSync(join(state.home, '.codex'))).toBe(false)
expect(trustedProjects(join(accountHome, 'config.toml'))).toEqual([workspace])
})
})
3 changes: 2 additions & 1 deletion src/main/agent-workspace-trust-guard-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ describe('applyAgentWorkspaceTrust when the breadth guard fails', () => {
env: {},
claudeAuth: null,
wslDistro: null,
connectionId: null
connectionId: null,
codexHome: null
})
).resolves.toEqual({})
expect(mocks.copilot).not.toHaveBeenCalled()
Expand Down
4 changes: 3 additions & 1 deletion src/main/agent-workspace-trust-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function spawnArgs(overrides: Partial<Parameters<typeof applyAgentWorkspaceTrust
claudeAuth: null,
wslDistro: null,
connectionId: null,
codexHome: '/orca/codex-home',
spawnOptions: {},
...overrides
}
Expand Down Expand Up @@ -72,7 +73,8 @@ describe('applyAgentWorkspaceTrustToSpawn', () => {
env: { CLAUDE_CONFIG_DIR: '/cfg' },
claudeAuth: null,
wslDistro: null,
connectionId: null
connectionId: null,
codexHome: '/orca/codex-home'
})
})

Expand Down
11 changes: 7 additions & 4 deletions src/main/agent-workspace-trust-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export function applyAgentWorkspaceTrustToSpawn(
env: args.env,
claudeAuth: args.claudeAuth,
wslDistro: args.wslDistro,
connectionId: args.connectionId
connectionId: args.connectionId,
codexHome: args.codexHome
}).then((fields) => {
if (fields.agentWorkspaceTrust) {
args.spawnOptions.agentWorkspaceTrust = fields.agentWorkspaceTrust
Expand All @@ -74,16 +75,18 @@ export function applyAgentWorkspaceTrustToSpawn(
*/
export async function applyStructuredCodexWorkspaceTrust(args: {
workspacePath: string
launchEnv: Record<string, string | undefined>
/** The account home the chat's Codex runs on as its CODEX_HOME. */
accountHomePath: string
settings: AgentWorkspaceTrustSetting
}): Promise<void> {
if (!isAgentWorkspaceTrustOn(args.settings)) {
return
}
await applyAgentWorkspaceTrust('codex', args.workspacePath, {
env: args.launchEnv,
env: undefined,
claudeAuth: null,
wslDistro: null,
connectionId: null
connectionId: null,
codexHome: args.accountHomePath
})
}
27 changes: 23 additions & 4 deletions src/main/agent-workspace-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({
copilot: vi.fn<(path: string, home: string) => void>(),
antigravity: vi.fn<(path: string, home: string) => void>(),
qoder: vi.fn<(path: string, home: string) => void>(),
codexConfigFiles: vi.fn<(agentHome: string) => string[]>(() => CODEX_CONFIG_FILES),
codexConfigFiles: vi.fn<(launchedConfigFile: string) => string[]>(() => CODEX_CONFIG_FILES),
claudeGrant: vi.fn<typeof ClaudeFolderTrustFile.grantClaudeWorkspaceTrust>()
}))

Expand Down Expand Up @@ -52,7 +52,8 @@ const local: AgentTrustLaunchContext = {
env: {},
claudeAuth: null,
wslDistro: null,
connectionId: null
connectionId: null,
codexHome: null
}

function pending(): { promise: Promise<void>; release: () => void } {
Expand Down Expand Up @@ -89,15 +90,33 @@ describe('applyAgentWorkspaceTrust on this machine', () => {

it('writes per-user trust under the home the launch env names, where the agent reads it', async () => {
const context = { ...local, env: { HOME: '/home/agent', USERPROFILE: '/home/agent' } }
for (const preset of ['codex', 'cursor', 'copilot', 'qoder', 'antigravity'] as const) {
for (const preset of ['cursor', 'copilot', 'qoder', 'antigravity'] as const) {
await applyAgentWorkspaceTrust(preset, WORKSPACE, context)
}
expect(mocks.codexConfigFiles).toHaveBeenCalledWith('/home/agent')
for (const writer of [mocks.cursor, mocks.copilot, mocks.qoder, mocks.antigravity]) {
expect(writer).toHaveBeenCalledWith(WORKSPACE, '/home/agent')
}
})

it.each([
// Why: an explicit CODEX_HOME wins over whatever HOME the launch env names.
['the CODEX_HOME Orca gives the launch', '/orca/accounts/a/home', '/orca/accounts/a/home'],
['.codex under the launch env home without one', null, join('/home/agent', '.codex')]
])('writes Codex trust into %s', async (_label, codexHome, expectedHome) => {
const context = {
...local,
env: { HOME: '/home/agent', USERPROFILE: '/home/agent' },
codexHome
}
await applyAgentWorkspaceTrust('codex', WORKSPACE, context)
expect(mocks.codexConfigFiles).toHaveBeenCalledWith(join(expectedHome, 'config.toml'))
})

it('falls back to the home folder when the launch env names no home', async () => {
await applyAgentWorkspaceTrust('codex', WORKSPACE, local)
expect(mocks.codexConfigFiles).toHaveBeenCalledWith(join(homedir(), '.codex', 'config.toml'))
})

it('contains a rejected or throwing write so the launch proceeds', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mocks.codex.mockRejectedValueOnce(new Error('write failed'))
Expand Down
12 changes: 10 additions & 2 deletions src/main/agent-workspace-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { resolveLocalClaudeTrustConfig } from './claude/claude-folder-trust-file
import type { ClaudeRuntimeAuthPreparation } from './claude-accounts/runtime-auth/runtime-auth-types'
import type { AgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request'
import { parseWslUncPath } from '../shared/wsl-paths'
import { applyWorkspaceTrustOnThisHost, launchedAgentHome } from './execution-host-workspace-trust'
import {
applyWorkspaceTrustOnThisHost,
launchedAgentHome,
launchedCodexConfigFile
} from './execution-host-workspace-trust'
import { getLocalCodexTrustConfigFiles } from './codex/codex-home-paths'
import { getCachedWslHome } from './wsl-home-cache'

Expand All @@ -21,6 +25,8 @@ export type AgentTrustLaunchContext = {
wslDistro: string | null
/** SSH connection that runs the agent; null means this machine. */
connectionId: string | null
/** The CODEX_HOME Orca gives this launch's Codex; null means Codex's default home. */
codexHome: string | null
}

/** Spawn fields the dispatcher asks the caller to forward to the process owner. */
Expand Down Expand Up @@ -74,7 +80,9 @@ export async function applyAgentWorkspaceTrust(
claudeAuth: context.claudeAuth,
wslDistro: context.wslDistro
}),
codexConfigFiles: () => getLocalCodexTrustConfigFiles(agentHome),
// Why agentHome: with no CODEX_HOME, Codex reads `.codex` under the home it resolves `~` to.
codexConfigFiles: () =>
getLocalCodexTrustConfigFiles(launchedCodexConfigFile(context.codexHome, agentHome)),
// Why: Codex queues behind a config lane it shares with Orca's hook installs.
deadlineMs:
preset === 'codex' ? AGENT_TRUST_WRITE_DEADLINE_MS : SHORT_AGENT_TRUST_WRITE_DEADLINE_MS
Expand Down
16 changes: 16 additions & 0 deletions src/main/codex/codex-home-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ vi.mock('node:os', async () => {
})

import {
getLocalCodexTrustConfigFiles,
syncCodexGlobalInstructionsIntoManagedHome,
syncSystemCodexResourcesIntoManagedHome
} from './codex-home-paths'
Expand Down Expand Up @@ -135,6 +136,21 @@ afterEach(() => {
vi.clearAllMocks()
})

describe('getLocalCodexTrustConfigFiles', () => {
it('puts the mirror first, then the config the launched Codex reads', () => {
const accountConfig = join(userDataDir, 'codex-accounts', 'a', 'home', 'config.toml')
expect(getLocalCodexTrustConfigFiles(accountConfig)).toEqual([
join(getRuntimeCodexHomePath(), 'config.toml'),
accountConfig
])
})

it('names the mirror once when the launch runs on it', () => {
const mirrorConfig = join(getRuntimeCodexHomePath(), 'config.toml')
expect(getLocalCodexTrustConfigFiles(mirrorConfig)).toEqual([mirrorConfig])
})
})

describe('syncSystemCodexResourcesIntoManagedHome', () => {
it('uses ORCA_USER_DATA_PATH when Electron cannot be required', async () => {
vi.resetModules()
Expand Down
17 changes: 11 additions & 6 deletions src/main/codex/codex-home-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
targetIsOwnedFallbackCopy
} from './codex-managed-home-resource-copy-marker'
import { observe, observeResolvedPathEntry } from './codex-path-observation'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'

const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md'

Expand Down Expand Up @@ -47,12 +48,16 @@ export function getOrcaManagedCodexHomePath(): string {
return managedHomePath
}

/** Config files an Orca-launched local Codex reads trust from, in the hook installer's lock order. */
export function getLocalCodexTrustConfigFiles(agentHome: string): string[] {
return [
join(getOrcaManagedCodexHomePath(), 'config.toml'),
join(agentHome, '.codex', 'config.toml')
]
/**
* Where a local Codex launch's trust goes, in the hook installer's lock order: Orca's shared
* mirror, then `launchedConfigFile`, the one config the launched Codex reads.
*/
export function getLocalCodexTrustConfigFiles(launchedConfigFile: string): string[] {
const mirrorConfigFile = join(getOrcaManagedCodexHomePath(), 'config.toml')
return normalizeRuntimePathForComparison(launchedConfigFile) ===
normalizeRuntimePathForComparison(mirrorConfigFile)
? [mirrorConfigFile]
: [mirrorConfigFile, launchedConfigFile]
}

export function getCodexSessionBackfillStateDirPath(): string {
Expand Down
Loading
Loading