From c6ab5195e73fac7281ae9aa724850dde6c8ebf9d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:02:20 -0700 Subject: [PATCH] feat(codex): give each managed account a self-contained CODEX_HOME; retire shared mirror (PR-E) With the real-home flag ON, a host managed account now launches directly against its own codex-accounts//home instead of the shared runtime mirror + auth.json hot-swap: - codex-home-paths: syncSystemCodexResourcesIntoManagedHome links system resources into any managed home (ownership-marker discipline; never symlinks into / mutates ~/.codex). - runtime-home-service: prepareForCodexLaunch / prepareForRateLimitFetch / syncForCurrentSelection route the per-account home directly and skip the shared-home hot-swap + token read-back; each home keeps its own auth in place (fixes GAP-5 concurrent auth race). Session discovery scans every per-account home. - hook-service / hook-trust-promotion: install/getStatus/refresh accept a runtimeHomePath so hooks + RPC-granted trust land in the per-account home. - service: config mirror into a self-contained home uses the trust- preserving merge so granted hook/project trust survives account switches. - codex-session-root-dedup: rank codex-accounts//home as canonical managed alongside the shared runtime home. Flag-OFF and the system-default real-home (null) lane are unchanged; the nested-Orca CODEX_HOME===ORCA_CODEX_HOME daemon strip (#5370) is preserved. Sandboxed tests only; ~/.codex is never mutated. --- .../ai-vault/codex-session-root-dedup.test.ts | 35 +++ src/main/ai-vault/codex-session-root-dedup.ts | 12 +- .../runtime-home-service.test.ts | 209 +++++++++++++++++- .../codex-accounts/runtime-home-service.ts | 107 +++++++++ src/main/codex-accounts/service.test.ts | 4 + src/main/codex-accounts/service.ts | 23 ++ src/main/codex/codex-home-paths.test.ts | 17 ++ src/main/codex/codex-home-paths.ts | 10 +- src/main/codex/hook-service.test.ts | 27 +++ src/main/codex/hook-service.ts | 72 +++--- src/main/codex/hook-trust-promotion.ts | 14 +- src/main/daemon/pty-subprocess.test.ts | 37 ++++ src/main/index.ts | 6 +- 13 files changed, 527 insertions(+), 46 deletions(-) diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts index 9121249ad5e..1f9acbc3cc6 100644 --- a/src/main/ai-vault/codex-session-root-dedup.test.ts +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -167,6 +167,41 @@ describe('dedupeCodexRolloutFileAliases', () => { expect(dedupeCodexRolloutFileAliases([host, wsl], accessors)).toEqual([host, wsl]) }) + + it('prefers a per-account self-contained home over other non-default homes', () => { + const perAccount = { + agent: 'codex', + path: '/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', + codexHome: + '/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home', + hardlinkIdentity: '3:71' + } + const custom = { + agent: 'codex', + path: '/Users/ada/custom-codex/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', + codexHome: '/Users/ada/custom-codex', + hardlinkIdentity: '3:71' + } + expect(dedupeCodexRolloutFileAliases([custom, perAccount], accessors)).toEqual([perAccount]) + expect(dedupeCodexRolloutFileAliases([perAccount, custom], accessors)).toEqual([perAccount]) + }) + + it('keeps the real home over a per-account home when they alias', () => { + const perAccount = { + agent: 'codex', + path: '/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', + codexHome: + '/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home', + hardlinkIdentity: '1:42' + } + const real = { + agent: 'codex', + path: REAL_HOME_ROLLOUT, + codexHome: null, + hardlinkIdentity: '1:42' + } + expect(dedupeCodexRolloutFileAliases([perAccount, real], accessors)).toEqual([real]) + }) }) describe('dedupeCodexSessionsBySessionId', () => { diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts index 0f4a39cf8d2..f40d29c1b14 100644 --- a/src/main/ai-vault/codex-session-root-dedup.ts +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -53,16 +53,20 @@ export function codexRolloutHardlinkIdentity(file: { * * Host real home (null) is canonical: after the real-home flip the managed * home's auth.json is no longer refreshed, so resume must not stamp it. The - * Orca managed runtime home still beats other homes (WSL/remote real homes, - * custom CODEX_HOMEs) because those lanes have not flipped — their launches - * keep managed auth, so resume keeps the managed stamp as today. + * Orca managed runtime home and each per-account self-contained home + * (codex-accounts//home) beat other homes (WSL/remote real homes, custom + * CODEX_HOMEs) because those are the roots codex actually refreshes for their + * lane — the shared runtime home for the managed mirror, and the per-account + * home once a managed account launches directly against it. */ function codexSessionRootRank(codexHome: string | null): number { if (codexHome === null) { return 0 } const segments = codexHome.split(/[\\/]/).filter(Boolean) - return segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home' ? 1 : 2 + const isSharedRuntimeHome = segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home' + const isPerAccountManagedHome = segments.at(-3) === 'codex-accounts' && segments.at(-1) === 'home' + return isSharedRuntimeHome || isPerAccountManagedHome ? 1 : 2 } /** diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 513385b5f24..96f5005a54f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1069,7 +1069,9 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps the managed home for a host MANAGED account even when the flag is ON', async () => { + it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -1098,8 +1100,213 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + // Flag ON + host managed account = the account's own home is CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.prepareForCodexLaunch()).toBe(managedHomePath) + // The per-account home keeps its own auth in place; the shared mirror's + // auth.json is never hot-swapped, so two accounts cannot race one file. + expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"managed"}\n' + ) + expect(existsSync(runtimeAuthPath)).toBe(false) + // Session discovery includes the per-account home so its rollouts surface. + expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(managedHomePath) + }) + + it('gives two managed accounts distinct homes without racing one auth.json (flag ON)', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') + const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') + const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: home1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: home2, + providerAccountId: 'acct-2', + workspaceLabel: null, + workspaceAccountId: 'acct-2', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // A pane for account-1 launches, then the user switches and a second pane + // for account-2 launches concurrently — each gets its OWN CODEX_HOME. + expect(service.prepareForCodexLaunch()).toBe(home1) + settings.activeCodexManagedAccountId = 'account-2' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } + expect(service.prepareForCodexLaunch()).toBe(home2) + + // Nothing is hot-swapped, so the still-running account-1 pane keeps seeing + // account-1's credentials — the single-auth.json race (GAP-5) is gone. + expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(account1Auth) + expect(readFileSync(join(home2, 'auth.json'), 'utf-8')).toBe(account2Auth) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + }) + + it('materializes resources and config into the per-account home on launch (flag ON)', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true }) + writeFileSync( + join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'), + 'skill\n', + 'utf-8' + ) + writeFileSync( + join(getSystemCodexHomePath(), 'config.toml'), + 'approval_policy = "never"\n', + 'utf-8' + ) + const home1 = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('one@example.com', 'acct-1', 'one') + ) + const store = createStore( + createSettings({ + codexSystemDefaultRealHomeEnabled: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: home1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch()).toBe(home1) + // Resources link/copy into THIS home; config mirrors into it. + expectResourceLinkedOrCopied(join(home1, 'skills'), join(getSystemCodexHomePath(), 'skills')) + expect(readFileSync(join(home1, 'skills', 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n') + expect(readFileSync(join(home1, 'config.toml'), 'utf-8')).toContain('approval_policy = "never"') + // ~/.codex is never mutated: no auth churn, no per-account dir written back. + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"system"}\n') + }) + + it('points the rate-limit fetch at the per-account home when the flag is ON', async () => { + const home1 = createManagedAuth(testState.userDataDir, 'account-1', '{"account":"managed"}\n') + const store = createStore( + createSettings({ + codexSystemDefaultRealHomeEnabled: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: home1, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForRateLimitFetch()).toBe(home1) + }) + + it('drops a managed selection whose auth.json vanished and resolves the real home (flag ON)', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + // A managed home that has lost its auth.json (only the marker remains). + const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home') + mkdirSync(brokenHome, { recursive: true }) + writeFileSync(join(brokenHome, '.orca-managed-home'), 'account-1\n', 'utf-8') + const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: brokenHome, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // The broken account is dropped and the launch resolves to the real home. + expect(service.prepareForCodexLaunch()).toBeNull() + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + }) + + it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') + const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const store = createStore( + createSettings({ + codexSystemDefaultRealHomeEnabled: false, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: home1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Flag OFF is byte-identical to today: shared mirror home + hot-swapped auth. expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Auth) }) it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 355052b5568..d3952aedca5 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -169,6 +169,15 @@ export class CodexRuntimeHomeService { this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(selfContainedAccount) + if (perAccountHome) { + return perAccountHome + } + // Why: the account's home lost its auth.json, so the selection was just + // dropped. Fall through and resolve this launch as the system default. + } if (this.isHostSystemDefaultRealHome(launchEnv)) { // Why (flag ON, system default): run Codex on the user's own ~/.codex. // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; @@ -189,6 +198,80 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + // Why: with the real-home flag ON, a managed HOST account runs against its own + // self-contained CODEX_HOME (codex-accounts//home) instead of the shared + // runtime mirror. Its auth.json lives there and codex refreshes it in place, + // so two accounts never race one auth.json (GAP-5) and the mirror can be + // deleted once no lane still injects it (GAP-1). WSL accounts keep their + // per-distro lane; the flag-OFF opt-out keeps the shared-home hot-swap. + private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { + const settings = this.store.getSettings() + if (!isCodexSystemDefaultRealHomeEnabled(settings)) { + return null + } + const account = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (!account || this.getWslManagedHomePath(account)) { + return null + } + return account + } + + private getSelfContainedManagedHostAccountHomes(): string[] { + const settings = this.store.getSettings() + if (!isCodexSystemDefaultRealHomeEnabled(settings)) { + return [] + } + return settings.codexManagedAccounts + .filter((account) => !this.getWslManagedHomePath(account)) + .map((account) => account.managedHomePath) + } + + private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null { + const perAccountHome = account.managedHomePath + if (!existsSync(join(perAccountHome, 'auth.json'))) { + // Why: drop the selection so this and future launches resolve to the + // system default rather than a home codex cannot authenticate against. + this.syncSelfContainedManagedSelection(account) + return null + } + // Why: link the user's real ~/.codex resources and mirror config into THIS + // home (never symlinking into or mutating ~/.codex), so the per-account home + // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. + this.lastSyncedAccountId = account.id + syncSystemCodexResourcesIntoManagedHome(perAccountHome) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: perAccountHome, + systemHomePath: getSystemCodexHomePath() + }) + return perAccountHome + } + + // Why: the per-account home is both the launch CODEX_HOME and the credential + // store, so codex reads/refreshes auth.json in place — there is no shared-home + // hot-swap or token read-back to reconcile. Only validate the credential + // survives; a vanished auth.json drops the selection to the system default. + private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { + if (existsSync(join(account.managedHomePath, 'auth.json'))) { + this.lastSyncedAccountId = account.id + return + } + console.warn( + '[codex-runtime-home] Active managed account is missing auth.json, clearing selection' + ) + const settings = this.store.getSettings() + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + this.lastSyncedAccountId = null + } + private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void { const settings = this.store.getSettings() if (normalizeCodexRuntimeSelection(settings).host !== null) { @@ -238,6 +321,12 @@ export class CodexRuntimeHomeService { // explicitly include the real lane so its sessions remain discoverable. homes.push(getSystemCodexHomePath()) } + // Why: flag ON routes each managed host account to its own self-contained + // home, so its rollouts live there rather than in the shared mirror. Scan + // every such home so account-scoped sessions still surface in the AI Vault. + for (const perAccountHome of this.getSelfContainedManagedHostAccountHomes()) { + homes.push(perAccountHome) + } return homes.filter((home, index) => homes.indexOf(home) === index) } @@ -344,6 +433,16 @@ export class CodexRuntimeHomeService { const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if ( + selfContainedAccount && + existsSync(join(selfContainedAccount.managedHomePath, 'auth.json')) + ) { + // Why: the quota fetch reads the account's own auth.json in place; no + // shared-home hot-swap, and no per-poll resource relink (that is launch + // prep). Config was mirrored on add/select and refreshed at launch. + return selfContainedAccount.managedHomePath + } if (this.isHostSystemDefaultRealHome()) { // Why: null lets the fetcher fall back to the main process's inherited // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the @@ -363,6 +462,14 @@ export class CodexRuntimeHomeService { return } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + // Why: self-contained managed homes hold their own auth, so the shared + // runtime home's snapshot/hot-swap/read-back machinery below must not run. + this.syncSelfContainedManagedSelection(selfContainedAccount) + return + } + const settings = this.store.getSettings() const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 6b2e6ec360e..0f990c5b2ff 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -48,6 +48,10 @@ function createSettings(overrides: Partial = {}): GlobalSettings const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false return { + // Config-sync/hot-swap tests assert the shared-mirror path; the RC defaults + // the real-home flag ON (per-account self-contained homes), so opt these + // managed cases out unless a test overrides it. + codexSystemDefaultRealHomeEnabled: overrides.codexSystemDefaultRealHomeEnabled ?? false, workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: false, diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 0fecd8e923d..90ab4f3d4bc 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -19,6 +19,9 @@ import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-refe import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' @@ -458,6 +461,15 @@ export class CodexAccountService { } } + private isSelfContainedHostManagedHome(managedHomePath: string): boolean { + // Why: flag ON makes each host account home its own launch CODEX_HOME. WSL + // homes keep their distro-local seed lane; the flag-OFF opt-out is unchanged. + return ( + isCodexSystemDefaultRealHomeEnabled(this.store.getSettings()) && + !parseWslUncPath(managedHomePath) + ) + } + private syncCanonicalConfigIntoManagedHome( managedHomePath: string, canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) @@ -467,6 +479,17 @@ export class CodexAccountService { } const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath) + if (this.isSelfContainedHostManagedHome(trustedManagedHomePath)) { + // Why: this home is codex's live CODEX_HOME, so mirror config with the + // trust-preserving merge — the plain overwrite below would wipe the + // hook/project trust codex granted in this home, forcing a re-approval and + // an app-server re-grant on every account switch. + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: trustedManagedHomePath, + systemHomePath: getSystemCodexHomePath() + }) + return + } // Why: Orca account switching is meant to swap Codex credentials and quota // identity, not silently fork the user's sandbox/config defaults. Syncing // one canonical config into every managed home keeps auth isolated per diff --git a/src/main/codex/codex-home-paths.test.ts b/src/main/codex/codex-home-paths.test.ts index 54860946c22..26053b8786c 100644 --- a/src/main/codex/codex-home-paths.test.ts +++ b/src/main/codex/codex-home-paths.test.ts @@ -182,6 +182,23 @@ describe('syncSystemCodexResourcesIntoManagedHome', () => { expect(existsSync(join(getRuntimeCodexHomePath(), 'history.jsonl'))).toBe(false) }) + it('mirrors resources into an explicit per-account home without mutating ~/.codex', () => { + mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true }) + writeFileSync(join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'), 'skill\n') + const perAccountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home') + mkdirSync(perAccountHome, { recursive: true }) + + syncSystemCodexResourcesIntoManagedHome(perAccountHome) + + const perAccountSkillsPath = join(perAccountHome, 'skills') + expect(readFileSync(join(perAccountSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n') + expectSymbolicLinkTargetIfLinked(perAccountSkillsPath, join(getSystemCodexHomePath(), 'skills')) + // The shared runtime mirror is untouched by an explicit per-account sync. + expect(existsSync(join(getRuntimeCodexHomePath(), 'skills'))).toBe(false) + // ~/.codex gains nothing — resources are only read from it, never written back. + expect(existsSync(join(getSystemCodexHomePath(), 'codex-accounts'))).toBe(false) + }) + it('does not replace an existing runtime-owned resource entry', () => { mkdirSync(join(getSystemCodexHomePath(), 'skills'), { recursive: true }) mkdirSync(join(getRuntimeCodexHomePath(), 'skills'), { recursive: true }) diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 7c267c5a9ed..1a6a8ba0b8b 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -57,11 +57,15 @@ function getOrcaUserDataPath(): string { return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'orca') } -export function syncSystemCodexResourcesIntoManagedHome(): void { +// Why: each managed home (the shared runtime mirror, or a per-account +// self-contained CODEX_HOME that the caller has already created) links the same +// system resources with its own ownership markers, so a per-account launch home +// is complete without ever symlinking into or mutating the user's real ~/.codex. +export function syncSystemCodexResourcesIntoManagedHome(managedHomePath?: string): void { + const targetHome = managedHomePath ?? getOrcaManagedCodexHomePath() const systemHomePath = getSystemCodexHomePath() - const managedHomePath = getOrcaManagedCodexHomePath() for (const entryName of CODEX_SYSTEM_RESOURCE_ENTRIES) { - linkSystemCodexResource(systemHomePath, managedHomePath, entryName) + linkSystemCodexResource(systemHomePath, targetHome, entryName) } } diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 348a62a11f8..4546f9047cb 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -157,6 +157,33 @@ describe('CodexHookService', () => { expect(trustConfig).toContain(':permission_request:0:0') }) + it('installs managed hooks + trust into a per-account self-contained home, not the shared mirror', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8') + + const perAccountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home') + mkdirSync(perAccountHome, { recursive: true }) + writeFileSync(join(perAccountHome, '.orca-managed-home'), 'account-1\n', 'utf-8') + + const status = new CodexHookService().install(perAccountHome) + expect(status.state).toBe('installed') + + // Hooks + trust land in THIS account's home. + const hooksConfig = JSON.parse(readFileSync(join(perAccountHome, 'hooks.json'), 'utf-8')) as { + hooks: Record + } + expect(Object.keys(hooksConfig.hooks).sort()).toEqual(localManagedCodexEvents()) + const trustConfig = readFileSync(join(perAccountHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('approval_policy = "on-request"') + expect(trustConfig).toContain(':permission_request:0:0') + + // The shared runtime mirror is never touched by a per-account install. + expect(existsSync(join(userDataDir, 'codex-runtime-home', 'home', 'hooks.json'))).toBe(false) + // ~/.codex is only read for canonical config, never mutated with hooks. + expect(existsSync(join(systemCodexHome, 'hooks.json'))).toBe(false) + }) + it('drops plugin manager metadata from runtime hooks.json during install', () => { const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') mkdirSync(managedCodexHome, { recursive: true }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 9feec736280..95b5dc10b3e 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -95,8 +95,8 @@ const CODEX_EVENTS = [ 'Stop' ] as const -function getConfigPath(): string { - return join(getOrcaManagedCodexHomePath(), 'hooks.json') +function getConfigPath(runtimeHomePath: string = getOrcaManagedCodexHomePath()): string { + return join(runtimeHomePath, 'hooks.json') } function writeCodexHooksJson(configPath: string, hooks: Record): void { @@ -105,8 +105,8 @@ function writeCodexHooksJson(configPath: string, hooks: Record | undefined, - isManagedCommand: (command: string | undefined) => boolean + isManagedCommand: (command: string | undefined) => boolean, + runtimeConfigPath: string = getConfigPath() ): { hooks: Record trustEntries: MirroredRuntimeUserHookTrustEntry[] } { const systemConfigPath = getSystemConfigPath() - const runtimeConfigPath = getConfigPath() - if (systemConfigPath === getConfigPath()) { + if (systemConfigPath === runtimeConfigPath) { return { hooks: { ...runtimeHooks }, trustEntries: [] } } @@ -1140,14 +1140,15 @@ export class CodexHookService { return wslPlan ? refreshWslRuntimeUserHooks(wslPlan) : null } - getStatus(): AgentHookInstallStatus { - return this.getStatusAfterInstall(null) + getStatus(runtimeHomePath: string = getOrcaManagedCodexHomePath()): AgentHookInstallStatus { + return this.getStatusAfterInstall(null, runtimeHomePath) } private getStatusAfterInstall( - recentGrantEntries: readonly CodexTrustEntry[] | null + recentGrantEntries: readonly CodexTrustEntry[] | null, + runtimeHomePath: string = getOrcaManagedCodexHomePath() ): AgentHookInstallStatus { - const configPath = getConfigPath() + const configPath = getConfigPath(runtimeHomePath) const scriptPath = getManagedScriptPath() const config = readHooksJson(configPath) if (!config) { @@ -1164,7 +1165,7 @@ export class CodexHookService { // trust entries are missing/stale. Codex 0.129+ silently drops untrusted // hooks, so a green status without trust verification is misleading. const command = getManagedCommand(scriptPath) - const tomlPath = getCodexConfigTomlPath() + const tomlPath = getCodexConfigTomlPath(runtimeHomePath) // Why: an unreadable config.toml (EACCES/EIO) is distinct from "file // absent" (which returns an empty Map without throwing). Hooks.json may // still be fine, so report partial with a specific reason rather than @@ -1184,7 +1185,7 @@ export class CodexHookService { // hashes or wrote fallback hashes. Re-resolving PATH here doubles sync launch work. const ledgerHome = recentGrantEntries === null - ? readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), { kind: 'native' }) + ? readCurrentCodexTrustGrantLedgerHome(runtimeHomePath, { kind: 'native' }) : null const recentGrantHashes = new Map() for (const entry of recentGrantEntries ?? []) { @@ -1295,14 +1296,17 @@ export class CodexHookService { return { agent: 'codex', state, configPath, managedHooksPresent, detail } } - install(): AgentHookInstallStatus { - const configPath = getConfigPath() + // Why: runtimeHomePath defaults to the shared managed mirror, but a managed + // account launching against its own self-contained CODEX_HOME passes that + // per-account home so hooks.json/config.toml/trust land where codex reads. + install(runtimeHomePath: string = getOrcaManagedCodexHomePath()): AgentHookInstallStatus { + const configPath = getConfigPath(runtimeHomePath) const scriptPath = getManagedScriptPath() // Why: must run before this install rewrites hooks.json/config.toml — // approvals the user made inside Orca-launched Codex are keyed to the // previous launch's runtime layout, and stale-trust cleanup below would // delete them once the system config stops backing them. - promoteCodexRuntimeHookApprovalsToSystem() + promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath) const config = readHooksJson(configPath) if (!config) { return { @@ -1320,7 +1324,7 @@ export class CodexHookService { // accumulate duplicate hook entries pointing at defunct scripts. const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const command = getManagedCommand(scriptPath) - const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath) const nextHooks = hookPlan.hooks const managedEvents = new Set(CODEX_EVENTS) @@ -1390,8 +1394,11 @@ export class CodexHookService { // pointing at a hook that doesn't exist. Surface failures — without this, // getStatus would report green for a hook Codex won't actually fire. try { - const tomlPath = getCodexConfigTomlPath() - syncSystemConfigIntoManagedCodexHome() + const tomlPath = getCodexConfigTomlPath(runtimeHomePath) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath: getSystemCodexHomePath() + }) // Why: Codex is the only authority on its trust-hash algorithm, so the // managed entries are granted through codex app-server RPCs (verified by // re-list) whenever the installed CLI supports them; the granted entries @@ -1399,7 +1406,7 @@ export class CodexHookService { // delete what Codex just wrote. Mirrored user trust keeps its existing // verbatim-carry lane either way. const grant = grantManagedCodexHookTrust({ - runtimeHomePath: getOrcaManagedCodexHomePath(), + runtimeHomePath, tomlPath, managedCommand: command, managedEntries: managedTrustEntries, @@ -1431,14 +1438,14 @@ export class CodexHookService { detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` } } - snapshotCodexRuntimeHookTrustProvenance() + snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) try { cleanupLegacySystemManagedHooks() cleanupLegacyCodexProfileHooks() } catch (error) { console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) } - return this.getStatusAfterInstall(recentGrantEntries) + return this.getStatusAfterInstall(recentGrantEntries, runtimeHomePath) } async installRemote( @@ -1563,11 +1570,13 @@ export class CodexHookService { } } - refreshRuntimeUserHooks(): AgentHookInstallStatus { - const configPath = getConfigPath() + refreshRuntimeUserHooks( + runtimeHomePath: string = getOrcaManagedCodexHomePath() + ): AgentHookInstallStatus { + const configPath = getConfigPath(runtimeHomePath) // Why: same as install() — capture in-Orca approvals before this refresh // rewrites the runtime files they are keyed against. - promoteCodexRuntimeHookApprovalsToSystem() + promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath) const config = readHooksJson(configPath) if (!config) { // Why: disabled launch prep used to call remove(); preserve its legacy @@ -1583,14 +1592,17 @@ export class CodexHookService { } const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) - const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath) config.hooks = hookPlan.hooks writeCodexHooksJson(configPath, hookPlan.hooks) try { - const tomlPath = getCodexConfigTomlPath() + const tomlPath = getCodexConfigTomlPath(runtimeHomePath) const trustEntries = hookPlan.trustEntries.map(({ entry }) => entry) - syncSystemConfigIntoManagedCodexHome() + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath: getSystemCodexHomePath() + }) // Why: this path is used when Orca status hooks are disabled. The // runtime CODEX_HOME should keep user hooks, but not Orca-managed trust. // Write current mirrored user trust first so stale cleanup compares @@ -1607,10 +1619,10 @@ export class CodexHookService { detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` } } - snapshotCodexRuntimeHookTrustProvenance() + snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) cleanupLegacyManagedHookRepresentations() - return this.getStatus() + return this.getStatus(runtimeHomePath) } remove(): AgentHookInstallStatus { diff --git a/src/main/codex/hook-trust-promotion.ts b/src/main/codex/hook-trust-promotion.ts index 8e4a4a58668..0f8c0e6a48f 100644 --- a/src/main/codex/hook-trust-promotion.ts +++ b/src/main/codex/hook-trust-promotion.ts @@ -78,9 +78,10 @@ function readHookTrustProvenance( * install/refresh, so the next launch can tell "entry Orca wrote" apart from * "entry Codex wrote after a user approval". Call after all trust writes. */ -export function snapshotCodexRuntimeHookTrustProvenance(): void { +export function snapshotCodexRuntimeHookTrustProvenance( + runtimeHomePath: string = getOrcaManagedCodexHomePath() +): void { try { - const runtimeHomePath = getOrcaManagedCodexHomePath() const runtimeHooksPath = join(runtimeHomePath, 'hooks.json') const canonicalRuntimeHooksPath = getCodexExplicitHomeHookSourcePath(runtimeHooksPath) const entries: Record = {} @@ -115,9 +116,11 @@ export function snapshotCodexRuntimeHookTrustProvenance(): void { * the user's own hooks.json. Runs before the config mirror so the promoted * trust is mirrored back on the same launch. */ -export function promoteCodexRuntimeHookApprovalsToSystem(): void { +export function promoteCodexRuntimeHookApprovalsToSystem( + runtimeHomePath: string = getOrcaManagedCodexHomePath() +): void { try { - promoteCodexRuntimeHookApprovalsToSystemUnsafe() + promoteCodexRuntimeHookApprovalsToSystemUnsafe(runtimeHomePath) } catch (error) { // Why: promotion is best-effort launch prep; a malformed runtime file // must not block hook install or the Codex launch itself. @@ -125,8 +128,7 @@ export function promoteCodexRuntimeHookApprovalsToSystem(): void { } } -function promoteCodexRuntimeHookApprovalsToSystemUnsafe(): void { - const runtimeHomePath = getOrcaManagedCodexHomePath() +function promoteCodexRuntimeHookApprovalsToSystemUnsafe(runtimeHomePath: string): void { const systemHomePath = getSystemCodexHomePath() const runtimeHooksPath = join(runtimeHomePath, 'hooks.json') const systemHooksPath = join(systemHomePath, 'hooks.json') diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 2a2f5a1bdd4..eef959cb6ee 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1950,6 +1950,43 @@ describe('createPtySubprocess', () => { expect(env.ORCA_CODEX_HOME).toBeUndefined() }) + it('strips an inherited per-account self-contained CODEX_HOME overlay in a nested Orca (#5370)', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previousCodexHome = process.env.CODEX_HOME + const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME + // A per-account home is injected as CODEX_HOME === ORCA_CODEX_HOME, so the + // nested-Orca strip must clear it exactly as it does the shared mirror. + const perAccountHome = '/daemon/managed/codex-accounts/019f0000-aaaa/home' + process.env.CODEX_HOME = perAccountHome + process.env.ORCA_CODEX_HOME = perAccountHome + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { SHELL: '/bin/bash' }, + envToDelete: ['ORCA_CODEX_HOME'] + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + if (previousOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = previousOrcaCodexHome + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(env.CODEX_HOME).toBeUndefined() + expect(env.ORCA_CODEX_HOME).toBeUndefined() + }) + it('preserves a daemon-owned custom Codex home while deleting a stale private marker', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/index.ts b/src/main/index.ts index c4a8b1ea877..c1961e4a6df 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -792,9 +792,11 @@ function prepareCodexRuntimeHomeForLaunch( // the persisted off switch so those launches cannot reinstall removed hooks. const status = hooksEnabled ? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ?? - codexHookService.install()) + // Why: a managed account's launch home is its own self-contained + // CODEX_HOME, so hooks/trust must install there, not the shared mirror. + codexHookService.install(runtimeHomePath ?? undefined)) : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? - codexHookService.refreshRuntimeUserHooks()) + codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined)) if (status.state === 'error') { console.warn( `[codex-hook-service] failed to ${