diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index e0052aa8b456..73b94ca12067 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -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) @@ -316,7 +316,10 @@ describe('markCodexProjectTrusted', () => { } async function expectWorkspaceTrusted(workspace: string): Promise { - 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'), @@ -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, @@ -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') diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index eac0591728bb..a6972f55c5fb 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -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, diff --git a/src/main/agent-workspace-trust-codex-home.test.ts b/src/main/agent-workspace-trust-codex-home.test.ts new file mode 100644 index 000000000000..c65755f2a226 --- /dev/null +++ b/src/main/agent-workspace-trust-codex-home.test.ts @@ -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() + 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 { + 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]) + }) +}) diff --git a/src/main/agent-workspace-trust-guard-failure.test.ts b/src/main/agent-workspace-trust-guard-failure.test.ts index 9f5e1ab7d9ba..862ef2353dca 100644 --- a/src/main/agent-workspace-trust-guard-failure.test.ts +++ b/src/main/agent-workspace-trust-guard-failure.test.ts @@ -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() diff --git a/src/main/agent-workspace-trust-spawn.test.ts b/src/main/agent-workspace-trust-spawn.test.ts index b87d58ff855c..11593948ed93 100644 --- a/src/main/agent-workspace-trust-spawn.test.ts +++ b/src/main/agent-workspace-trust-spawn.test.ts @@ -24,6 +24,7 @@ function spawnArgs(overrides: Partial { env: { CLAUDE_CONFIG_DIR: '/cfg' }, claudeAuth: null, wslDistro: null, - connectionId: null + connectionId: null, + codexHome: '/orca/codex-home' }) }) diff --git a/src/main/agent-workspace-trust-spawn.ts b/src/main/agent-workspace-trust-spawn.ts index f893529b53b8..043ba5ae526a 100644 --- a/src/main/agent-workspace-trust-spawn.ts +++ b/src/main/agent-workspace-trust-spawn.ts @@ -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 @@ -74,16 +75,18 @@ export function applyAgentWorkspaceTrustToSpawn( */ export async function applyStructuredCodexWorkspaceTrust(args: { workspacePath: string - launchEnv: Record + /** The account home the chat's Codex runs on as its CODEX_HOME. */ + accountHomePath: string settings: AgentWorkspaceTrustSetting }): Promise { 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 }) } diff --git a/src/main/agent-workspace-trust.test.ts b/src/main/agent-workspace-trust.test.ts index 8aead85ff78e..100e64cc7d57 100644 --- a/src/main/agent-workspace-trust.test.ts +++ b/src/main/agent-workspace-trust.test.ts @@ -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() })) @@ -52,7 +52,8 @@ const local: AgentTrustLaunchContext = { env: {}, claudeAuth: null, wslDistro: null, - connectionId: null + connectionId: null, + codexHome: null } function pending(): { promise: Promise; release: () => void } { @@ -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')) diff --git a/src/main/agent-workspace-trust.ts b/src/main/agent-workspace-trust.ts index 0fc52991e3c2..9ea727adde3f 100644 --- a/src/main/agent-workspace-trust.ts +++ b/src/main/agent-workspace-trust.ts @@ -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' @@ -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. */ @@ -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 diff --git a/src/main/codex/codex-home-paths.test.ts b/src/main/codex/codex-home-paths.test.ts index 26053b8786c9..cc9f9374e901 100644 --- a/src/main/codex/codex-home-paths.test.ts +++ b/src/main/codex/codex-home-paths.test.ts @@ -67,6 +67,7 @@ vi.mock('node:os', async () => { }) import { + getLocalCodexTrustConfigFiles, syncCodexGlobalInstructionsIntoManagedHome, syncSystemCodexResourcesIntoManagedHome } from './codex-home-paths' @@ -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() diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index e838d11fe6b2..3e3844356660 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -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' @@ -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 { diff --git a/src/main/codex/codex-trust-config-concurrent-launch.test.ts b/src/main/codex/codex-trust-config-concurrent-launch.test.ts index f12273218310..7373333feb8c 100644 --- a/src/main/codex/codex-trust-config-concurrent-launch.test.ts +++ b/src/main/codex/codex-trust-config-concurrent-launch.test.ts @@ -211,7 +211,10 @@ describe('two Codex pane launches against one config.toml', () => { await tick() await tick() // Why: the grant holds no lane across its session, so a launch's write lands at once. - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir)) + await markCodexProjectTrusted( + workspace, + getLocalCodexTrustConfigFiles(join(testState.fakeHomeDir, '.codex', 'config.toml')) + ) expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"') releaseSession() @@ -399,7 +402,7 @@ describe('reentrancy under concurrency', () => { runExclusivelyForCodexTrustConfig(systemToml, async () => { await markCodexProjectTrusted( workspace, - getLocalCodexTrustConfigFiles(testState.fakeHomeDir) + getLocalCodexTrustConfigFiles(join(testState.fakeHomeDir, '.codex', 'config.toml')) ) return grantManagedCodexHookTrust(buildPlan(entries)) }) diff --git a/src/main/execution-host-workspace-trust.test.ts b/src/main/execution-host-workspace-trust.test.ts index a9603190923b..3987a24ef15c 100644 --- a/src/main/execution-host-workspace-trust.test.ts +++ b/src/main/execution-host-workspace-trust.test.ts @@ -24,6 +24,7 @@ vi.mock('node:os', async (importOriginal) => { import { AGENT_TRUST_INHERITS_FROM_A_HOME, applyWorkspaceTrustOnThisHost, + launchedCodexConfigFile, type WorkspaceTrustHost } from './execution-host-workspace-trust' @@ -199,3 +200,22 @@ describe('applyWorkspaceTrustOnThisHost', () => { warn.mockRestore() }) }) + +describe('launchedCodexConfigFile', () => { + it("is the config.toml in the launch's CODEX_HOME", () => { + expect(launchedCodexConfigFile(join(root, 'account'), state.home)).toBe( + join(root, 'account', 'config.toml') + ) + }) + + it.each([ + ['unset', undefined], + ['null', null], + // Why: Codex treats an empty CODEX_HOME as unset. + ['empty', ''] + ])('falls back to .codex under the home when CODEX_HOME is %s', (_label, codexHome) => { + expect(launchedCodexConfigFile(codexHome, state.home)).toBe( + join(state.home, '.codex', 'config.toml') + ) + }) +}) diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts index acb84ea7ef1d..2555f75bdbd3 100644 --- a/src/main/execution-host-workspace-trust.ts +++ b/src/main/execution-host-workspace-trust.ts @@ -1,6 +1,6 @@ import { realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { resolve } from 'node:path' +import { join, resolve } from 'node:path' import { markQoderWorkspaceTrusted } from './qoder/workspace-trust' import { type AgentTrustPreset, @@ -27,7 +27,7 @@ export type WorkspaceTrustHost = { agentHome: string /** The config Claude reads on this host, or null when this host cannot tell. */ claudeConfig: () => ClaudeTrustConfigTarget | null - /** Every config.toml the launched Codex may read, in the hook installer's lock order. */ + /** Every config.toml Codex trust is written to, in the hook installer's lock order. */ codexConfigFiles: () => readonly string[] deadlineMs: number } @@ -39,6 +39,14 @@ export function launchedAgentHome( return (process.platform === 'win32' ? launchEnv?.USERPROFILE : launchEnv?.HOME) || homedir() } +/** The config.toml a Codex launch reads: its CODEX_HOME, else `.codex` under the home it resolves `~` to. */ +export function launchedCodexConfigFile( + codexHome: string | null | undefined, + home: string +): string { + return join(codexHome || join(home, '.codex'), 'config.toml') +} + /** * Whether trust on a home or a disk root would also trust the folders below it, per each agent's * own lookup; such trust is never pre-written. Claude walks up parent folders (to the repo root, diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 8adec688a678..4e3a612b3391 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -132,6 +132,7 @@ export async function buildPtyIpcSpawnOptions( claudeAuth: ctx.claudeAuth, wslDistro: ctx.expectedWslDistro, connectionId: args.connectionId ?? null, + codexHome: ctx.selectedCodexHomePath, spawnOptions: ctx.spawnOptions }) if (trustWrite) { diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 26c3856b45ee..3f6fccfcb809 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -155,6 +155,7 @@ export async function buildRuntimePtySpawnOptions( claudeAuth: ctx.claudeAuth, wslDistro: ctx.expectedWslDistro, connectionId: args.connectionId ?? null, + codexHome: ctx.selectedCodexHomePath, spawnOptions: ctx.spawnOptions }) if (trustWrite) { diff --git a/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts b/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts index 3a8096c53096..7ffd53ad2420 100644 --- a/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts +++ b/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts @@ -24,6 +24,7 @@ type BuildInput = { restored?: boolean claudeAuth?: ClaudeRuntimeAuthPreparation | null wslDistro?: string | null + codexHome?: string | null } const RESTORED_PANE: AdoptStablePaneResult = { @@ -68,7 +69,13 @@ function makeDeps(): PtySpawnIpcDeps & PtyRuntimeControllerDeps { function seed( ctx: Pick< PtyIpcSpawnState, - 'env' | 'cwd' | 'launchCommand' | 'claudeAuth' | 'expectedWslDistro' | 'preAdoptedStablePane' + | 'env' + | 'cwd' + | 'launchCommand' + | 'claudeAuth' + | 'expectedWslDistro' + | 'preAdoptedStablePane' + | 'selectedCodexHomePath' >, input: BuildInput ): void { @@ -78,6 +85,7 @@ function seed( ctx.claudeAuth = input.claudeAuth ?? null ctx.expectedWslDistro = input.wslDistro ?? null ctx.preAdoptedStablePane = input.restored ? RESTORED_PANE : null + ctx.selectedCodexHomePath = input.codexHome ?? null } async function build(route: 'renderer' | 'runtime', input: BuildInput) { @@ -113,13 +121,16 @@ describe.each(['renderer', 'runtime'] as const)('%s spawn builder agent trust', await build(route, { launchAgent: 'codex', command: 'codex', - wslDistro: 'Ubuntu' + wslDistro: 'Ubuntu', + codexHome: '/orca/accounts/a/home' }) expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/repo/wt', { env: expect.objectContaining({ CLAUDE_CONFIG_DIR: '/cfg' }), claudeAuth: null, wslDistro: 'Ubuntu', - connectionId: null + connectionId: null, + // Why: the provider sets this as CODEX_HOME only after the builder, so trust must take it here. + codexHome: '/orca/accounts/a/home' }) }) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 0fcdaae95323..8f4c5345a2d7 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -57,36 +57,49 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR protected structuredAgentAccountHomePathResolver( agent: StructuredAgentId, worktree: string, - purpose: 'launch' | 'read', - hostLaunchDirectory?: string + purpose: 'launch' | 'read' ) { const registration = structuredAgentRuntimeRegistration(agent) if (!registration) { return null } - const services = { - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target), - prepareCodexLaunchHome: this.prepareCodexStructuredLaunchFn, - readCodexLaunchHome: this.resolveCodexStructuredLaunchHomeFn, - workspaceTrustSettings: () => this.requireStore().getSettings() - } return async ({ launchEnv, location }) => registration.resolveAccountHomePath( + { launchEnv, location: location ?? null, purpose }, + this.structuredAgentAccountHomeServices() + ) + } + + /** The registration's step for a launch whose account home is final; undefined when it has none. */ + protected structuredAgentAccountHomePinnedHook( + agent: StructuredAgentId, + worktree: string, + hostLaunchDirectory?: string + ) { + const afterAccountHomePinned = structuredAgentRuntimeRegistration(agent)?.afterAccountHomePinned + if (!afterAccountHomePinned) { + return undefined + } + return (accountHomePath: string) => + afterAccountHomePinned( { - launchEnv, - location: location ?? null, - purpose, - workspacePath: - purpose === 'launch' - ? async () => - hostLaunchDirectory ?? - (await this.resolveRuntimeFileTarget(worktree)).worktree.path - : null + accountHomePath, + workspacePath: async () => + hostLaunchDirectory ?? (await this.resolveRuntimeFileTarget(worktree)).worktree.path }, - services + this.structuredAgentAccountHomeServices() ) } + protected structuredAgentAccountHomeServices() { + return { + getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target), + prepareCodexLaunchHome: this.prepareCodexStructuredLaunchFn, + readCodexLaunchHome: this.resolveCodexStructuredLaunchHomeFn, + workspaceTrustSettings: () => this.requireStore().getSettings() + } + } + /** The definition this runtime registers for `agent`: what its account home pins. Read from the * registration list, so neither create nor a catalog read installs the host to learn it. */ protected requireRegisteredStructuredAgent(agent: StructuredAgentId) { @@ -163,15 +176,18 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( input.agent, input.worktree, - 'launch', - hostLaunchDirectory + 'launch' ) if (!resolveAccountHomePath) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + const resolved = await this.resolveStructuredAgentSessionIntent( + input, + resolveAccountHomePath, + this.structuredAgentAccountHomePinnedHook(input.agent, input.worktree, hostLaunchDirectory) + ) return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } @@ -213,7 +229,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceId: string workspaceKind: 'folder' | 'git-worktree' } - }) => string | Promise + }) => string | Promise, + /** Runs once the account home this chat will run on is final, before the intent returns. */ + afterAccountHomePinned?: (accountHomePath: string) => Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) // Adopting a conversation reads the agent's own transcript, which only Claude and Codex have @@ -253,6 +271,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR selectedAccountHomePath }) : null + const accountHomePath = adoption ? adoption.accountHomePath : selectedAccountHomePath + await afterAccountHomePinned?.(accountHomePath) return { envelope: { sessionId: input.envelope.sessionId, @@ -263,10 +283,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: agentSessionAccountHome( - definition, - adoption ? adoption.accountHomePath : selectedAccountHomePath - ), + accountHome: agentSessionAccountHome(definition, accountHomePath), ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts index 6e313e111af6..16aa699ff241 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -71,7 +71,7 @@ describe('the structured agent registration list', () => { path: '/accounts/claude' }) expect(claude.resolveAccountHomePath).toHaveBeenCalledWith( - expect.objectContaining({ purpose: 'read', location: null, workspacePath: null }), + expect.objectContaining({ purpose: 'read', location: null }), expect.anything() ) expect(installHost).not.toHaveBeenCalled() diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index c6e75cd5a230..333ff764bb60 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -2,13 +2,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const applyAgentWorkspaceTrust = vi.hoisted(() => vi.fn(async () => ({}))) vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) +const adoption = vi.hoisted(() => ({ + committedReplay: vi.fn<() => unknown>(() => null), + forCreate: vi.fn<() => Promise>(async () => null) +})) +vi.mock('./structured-agent-session-create-adoption', () => ({ + resolveCommittedStructuredAgentSessionAdoptionIntent: adoption.committedReplay, + resolveStructuredAgentSessionAdoptionForCreate: adoption.forCreate +})) import { OrcaRuntimeService } from './orca-runtime' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + structuredAgentRuntimeRegistration +} from './structured-agent-runtime-registrations' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' beforeEach(() => { applyAgentWorkspaceTrust.mockClear() + adoption.committedReplay.mockReset().mockReturnValue(null) + adoption.forCreate.mockReset().mockResolvedValue(null) }) function createCodexIntentRuntime( @@ -17,6 +31,7 @@ function createCodexIntentRuntime( workspacePath = '/repos/workspace-1' ) { const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') + const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { path: workspacePath } })) const runtime = new OrcaRuntimeService( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. { getSettings: () => ({ agentDefaultEnv: { codex: {} }, ...settings }) } as never, @@ -33,15 +48,16 @@ function createCodexIntentRuntime( workspaceId, workspaceKind: 'git-worktree' as const })), - resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: workspacePath } })) + resolveRuntimeFileTarget }) - const createIntent = () => + const createIntent = (resumeFrom?: { providerSessionId: string }) => runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, worktree: `id:${workspaceId}`, - agent: 'codex' + agent: 'codex', + ...(resumeFrom ? { resumeFrom, callerKey: 'caller-1' } : {}) }) - return { prepareCodexStructuredLaunch, createIntent } + return { prepareCodexStructuredLaunch, resolveRuntimeFileTarget, createIntent } } describe('structured Codex folder trust', () => { @@ -58,27 +74,81 @@ describe('structured Codex folder trust', () => { expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( 'codex', '/host/floating-folder', - expect.any(Object) + expect.objectContaining({ codexHome: '/accounts/selected/home' }) ) expect(attachFingerprintFields(intent)).not.toHaveProperty('hostLaunchDirectory') }) - it('pre-trusts the chat folder before launch preparation, as a Codex terminal launch does', async () => { + it('trusts the floating host launch directory, and a worktree chat its resolved path', async () => { + const floating = createCodexIntentRuntime({}, FLOATING_TERMINAL_WORKTREE_ID, '/repos/later') + // Why distinct: a hook that re-resolved the selector instead of using the host directory would pass otherwise. + floating.resolveRuntimeFileTarget.mockResolvedValueOnce({ + worktree: { path: '/host/floating-folder' } + }) + + expect((await floating.createIntent()).hostLaunchDirectory).toBe('/host/floating-folder') + expect(applyAgentWorkspaceTrust).toHaveBeenCalledTimes(1) + expect(applyAgentWorkspaceTrust).toHaveBeenLastCalledWith( + 'codex', + '/host/floating-folder', + expect.objectContaining({ codexHome: '/accounts/selected/home' }) + ) + + const worktree = createCodexIntentRuntime({}, 'workspace-2', '/repos/workspace-2') + expect((await worktree.createIntent()).hostLaunchDirectory).toBeUndefined() + expect(applyAgentWorkspaceTrust).toHaveBeenCalledTimes(2) + expect(applyAgentWorkspaceTrust).toHaveBeenLastCalledWith( + 'codex', + '/repos/workspace-2', + expect.objectContaining({ codexHome: '/accounts/selected/home' }) + ) + }) + + it('pre-trusts the chat folder in the account home launch preparation picks', async () => { const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({}) await createIntent() expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/repos/workspace-1', { - env: expect.any(Object), + env: undefined, claudeAuth: null, wslDistro: null, - connectionId: null + connectionId: null, + codexHome: '/accounts/selected/home' + }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledTimes(1) + expect(prepareCodexStructuredLaunch.mock.invocationCallOrder[0]).toBeLessThan( + applyAgentWorkspaceTrust.mock.invocationCallOrder[0] + ) + }) + + it('pre-trusts a resumed chat in the home its conversation lives in', async () => { + adoption.forCreate.mockResolvedValue({ + accountHomePath: '/accounts/original/home', + transcriptPath: null }) - expect(applyAgentWorkspaceTrust.mock.invocationCallOrder[0]).toBeLessThan( - prepareCodexStructuredLaunch.mock.invocationCallOrder[0] + const { createIntent } = createCodexIntentRuntime({}) + + const intent = await createIntent({ providerSessionId: 'thread-1' }) + + expect(intent.accountHome).toEqual({ variable: 'CODEX_HOME', path: '/accounts/original/home' }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledTimes(1) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( + 'codex', + '/repos/workspace-1', + expect.objectContaining({ codexHome: '/accounts/original/home' }) ) }) + it('writes nothing for a replay of a create that already committed', async () => { + const replay = { replayed: true } + adoption.committedReplay.mockReturnValue(replay) + const { createIntent } = createCodexIntentRuntime({}) + + await expect(createIntent({ providerSessionId: 'thread-1' })).resolves.toBe(replay) + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + }) + it('writes nothing with the setting off, and still prepares the launch', async () => { const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({ agentWorkspaceTrustEnabled: false @@ -91,6 +161,47 @@ describe('structured Codex folder trust', () => { }) }) +describe('the Codex runtime registration', () => { + const services = { + getClaudeConfigDirectory: vi.fn(() => null), + prepareCodexLaunchHome: vi.fn(() => '/accounts/selected/home'), + readCodexLaunchHome: vi.fn(() => '/accounts/selected/home'), + workspaceTrustSettings: () => ({ agentWorkspaceTrustEnabled: true }) + } + + it.each(['launch', 'read'] as const)( + 'resolves a %s account home without writing trust, which waits for the pinned home', + async (purpose) => { + const codex = structuredAgentRuntimeRegistration('codex')! + await expect( + codex.resolveAccountHomePath({ launchEnv: {}, location: null, purpose }, services) + ).resolves.toBe('/accounts/selected/home') + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + } + ) + + it('writes trust into the home its post-pin step is given', async () => { + const codex = structuredAgentRuntimeRegistration('codex')! + await codex.afterAccountHomePinned!( + { accountHomePath: '/accounts/pinned/home', workspacePath: async () => '/repos/workspace-1' }, + services + ) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledTimes(1) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( + 'codex', + '/repos/workspace-1', + expect.objectContaining({ codexHome: '/accounts/pinned/home' }) + ) + }) + + it('gives no other agent a post-pin step', () => { + const withStep = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter( + (registration) => registration.afterAccountHomePinned + ).map((registration) => registration.definition.agent) + expect(withStep).toEqual(['codex']) + }) +}) + describe('structured agent-session create intent', () => { it('pins the selected Codex launch home after normal launch preparation', async () => { const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') @@ -170,6 +281,8 @@ describe('structured agent-session create intent', () => { // A read must not run launch preparation: no home sync, no session bridge, // no cleared account selection — the read-only sibling answers instead. expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() + // A read pins no launch, so it writes no folder trust either. + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() expect(resolveCodexStructuredLaunchHome).toHaveBeenCalledWith({ launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) }) diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 5dfc2f7404d2..2928118ffe26 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -45,10 +45,7 @@ describe('ACP agents in the runtime registrations', () => { workspaceTrustSettings: unused } const resolve = (launchEnv: NodeJS.ProcessEnv) => - resolveAccountHomePath( - { launchEnv, location: null, purpose: 'read', workspacePath: null }, - services - ) + resolveAccountHomePath({ launchEnv, location: null, purpose: 'read' }, services) await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toBe('/data/grok') await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatch(/\.grok$/) }) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index a0c0089c8d33..eb106ad53994 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -63,8 +63,13 @@ export type StructuredAgentAccountHomeRequest = { location: AgentSessionExecutionLocation | null /** A `read` has no side effects: it syncs no home, starts no bridge, clears no selection. */ purpose: 'launch' | 'read' - /** The launch's workspace directory on this host; a read has none. */ - workspacePath: (() => Promise) | null +} + +/** A launch whose account home is final: the selected one, or the one a resume adopted. */ +export type StructuredAgentPinnedAccountHome = { + accountHomePath: string + /** The launch's workspace directory on this host. */ + workspacePath: () => Promise } /** What resolving an account may ask of the runtime around it. */ @@ -86,6 +91,11 @@ export type StructuredAgentRuntimeRegistration = { request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices ) => Promise + /** Runs once the account home a launch will run on is final; never for a read. */ + afterAccountHomePinned?: ( + launch: StructuredAgentPinnedAccountHome, + services: StructuredAgentAccountHomeServices + ) => Promise } function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { @@ -193,14 +203,7 @@ async function resolveCodexAccountHomePath( request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices ): Promise { - const { launchEnv, purpose, workspacePath } = request - if (purpose === 'launch' && workspacePath) { - await applyStructuredCodexWorkspaceTrust({ - workspacePath: await workspacePath(), - launchEnv, - settings: services.workspaceTrustSettings() - }) - } + const { launchEnv, purpose } = request return resolveStructuredCodexAccountHomePath({ launchEnv, resolveLaunchHome: @@ -214,7 +217,14 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, supportsLocation: (location) => supportsCodexStructuredLocation(location), - resolveAccountHomePath: resolveCodexAccountHomePath + resolveAccountHomePath: resolveCodexAccountHomePath, + // Why after pinning: trust goes into the home this chat's Codex runs on, which a resume repins. + afterAccountHomePinned: async ({ accountHomePath, workspacePath }, services) => + applyStructuredCodexWorkspaceTrust({ + workspacePath: await workspacePath(), + accountHomePath, + settings: services.workspaceTrustSettings() + }) }, { definition: CLAUDE_STRUCTURED_AGENT, diff --git a/src/relay/agent-workspace-trust-spawn.ts b/src/relay/agent-workspace-trust-spawn.ts index b8af0dc7fe75..57a72de11b43 100644 --- a/src/relay/agent-workspace-trust-spawn.ts +++ b/src/relay/agent-workspace-trust-spawn.ts @@ -1,6 +1,5 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' import { parseAgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request' import { TUI_AGENT_CONFIG } from '../shared/tui-agent-config' import type { TuiAgent } from '../shared/tui-agent' @@ -8,7 +7,8 @@ import { resolveClaudeGlobalConfigFile } from '../main/claude/claude-folder-trus import { SHORT_AGENT_TRUST_WRITE_DEADLINE_MS } from '../main/agent-trust-write-deadline' import { applyWorkspaceTrustOnThisHost, - launchedAgentHome + launchedAgentHome, + launchedCodexConfigFile } from '../main/execution-host-workspace-trust' /** @@ -43,7 +43,7 @@ export async function applyRelayAgentWorkspaceTrust( }), keyStyle }), - codexConfigFiles: () => [join(spawnEnv.CODEX_HOME || join(homeDir, '.codex'), 'config.toml')], + codexConfigFiles: () => [launchedCodexConfigFile(spawnEnv.CODEX_HOME, homeDir)], // Why: every write here is on the relay's own disk, so each preset gets the local budget. deadlineMs: SHORT_AGENT_TRUST_WRITE_DEADLINE_MS }