From bfea35fbb5b82da508e47e35a6d1cedaa6c78ba5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:22:14 -0700 Subject: [PATCH 1/4] fix(codex): sanitize managed account config trust --- src/main/codex-accounts/service.test.ts | 183 +++++++++++++++++- src/main/codex-accounts/service.ts | 27 ++- .../codex-managed-trust-reconciliation.ts | 35 +++- src/main/codex/config-toml-trust.ts | 21 +- 4 files changed, 251 insertions(+), 15 deletions(-) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 9b185e4c357..032a79668af 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { GlobalSettings } from '../../shared/types' import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' +import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust' const testState = { userDataDir: '', @@ -220,6 +221,65 @@ function createCodexAuthJson(email: string, accountId: string, refreshToken: str )}\n` } +async function createCanonicalHookTrustFixture(): Promise<{ + config: string + orcaKeys: string[] + userKey: string +}> { + const { MANAGED_HOOK_TIMEOUT_SECONDS } = await import('../agent-hooks/installer-utils') + const { getCodexManagedHookInstallMaterial } = await import('../codex/hook-service') + const { computeTrustKey, computeTrustedHash, escapeTomlString, normalizeHookTrustKeyForLookup } = + await import('../codex/config-toml-trust') + const { getCodexHookTrustSignature } = await import('../codex/codex-hook-identity') + const { writeCodexTrustGrantLedgerHome } = await import('../codex/codex-trust-grant-ledger') + const sourceHomePath = join(testState.fakeHomeDir, '.codex') + const sourcePath = join(sourceHomePath, 'hooks.json') + const material = getCodexManagedHookInstallMaterial() + const expectedHashEntry = { + sourcePath, + eventLabel: 'stop' as const, + groupIndex: 0, + handlerIndex: 0, + command: material.command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } + const ledgerHashEntry = { + ...expectedHashEntry, + eventLabel: 'session_start' as const, + groupIndex: 1 + } + const userEntry = { + ...expectedHashEntry, + groupIndex: 2, + command: 'user-authored-hook' + } + const expectedHashKey = computeTrustKey(expectedHashEntry) + const ledgerHashKey = computeTrustKey(ledgerHashEntry) + const userKey = computeTrustKey(userEntry) + const ledgerTrustedHash = 'sha256:codex-granted-orca-hook' + writeCodexTrustGrantLedgerHome(sourceHomePath, { + binary: null, + entries: { + [normalizeHookTrustKeyForLookup(ledgerHashKey)]: { + signature: getCodexHookTrustSignature(ledgerHashEntry), + trustedHash: ledgerTrustedHash + } + } + }) + const block = (key: string, trustedHash: string): string => + `[hooks.state."${escapeTomlString(key)}"]\ntrusted_hash = "${trustedHash}"\nenabled = true` + return { + config: [ + 'approval_policy = "never"', + block(expectedHashKey, computeTrustedHash(expectedHashEntry)), + block(ledgerHashKey, ledgerTrustedHash), + block(userKey, computeTrustedHash(userEntry)) + ].join('\n\n'), + orcaKeys: [expectedHashKey, ledgerHashKey], + userKey + } +} + describe('CodexAccountService config sync', () => { beforeEach(() => { vi.resetModules() @@ -280,6 +340,60 @@ describe('CodexAccountService config sync', () => { ) }) + it('strips only Orca-owned source-home hook trust on startup and account selection', async () => { + const fixture = await createCanonicalHookTrustFixture() + const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + writeFileSync(canonicalConfigPath, fixture.config, 'utf-8') + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + 'approval_policy = "on-request"\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + const { readHookTrustEntries } = await import('../codex/config-toml-trust') + const { readCodexTrustGrantLedgerHome } = await import('../codex/codex-trust-grant-ledger') + + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + const expectSanitizedManagedConfig = (): void => { + const entries = readHookTrustEntries(join(managedHomePath, 'config.toml')) + for (const key of fixture.orcaKeys) { + expect(entries.has(key)).toBe(false) + } + expect(entries.has(fixture.userKey)).toBe(true) + } + + expectSanitizedManagedConfig() + expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config) + expect(readCodexTrustGrantLedgerHome(join(testState.fakeHomeDir, '.codex'))).not.toBeNull() + + writeFileSync(join(managedHomePath, 'config.toml'), 'approval_policy = "untrusted"\n', 'utf-8') + await service.selectAccount('account-1') + + expectSanitizedManagedConfig() + }) + it('rewrites relative path config values when syncing into managed homes', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync( @@ -325,7 +439,15 @@ describe('CodexAccountService config sync', () => { it('does not rewrite managed configs that already match canonical config', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + const { escapeTomlString } = await import('../codex/config-toml-trust') + const userHookKey = `${join(testState.fakeHomeDir, '.codex', 'user-hooks.json')}:stop:0:0` + const canonicalConfig = [ + 'approval_policy = "never"', + 'sandbox_mode = "danger-full-access"', + `[hooks.state."${escapeTomlString(userHookKey)}"]`, + 'trusted_hash = "sha256:user-owned"', + '' + ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') const managedHomePath = createManagedHome( testState.userDataDir, @@ -564,6 +686,65 @@ describe('CodexAccountService config sync', () => { expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) + it('does not seed Orca-owned source-home trust when adding an account', async () => { + vi.resetModules() + let fixture: Awaited> + let readHookTrustEntries: typeof ReadHookTrustEntries + const spawnMock = vi.fn( + (_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + + const loginHome = options.env.CODEX_HOME + expect(loginHome).toBeTruthy() + const entries = readHookTrustEntries(join(loginHome!, 'config.toml')) + for (const key of fixture.orcaKeys) { + expect(entries.has(key)).toBe(false) + } + expect(entries.has(fixture.userKey)).toBe(true) + writeFileSync( + join(loginHome!, 'auth.json'), + createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'), + 'utf-8' + ) + + queueMicrotask(() => child.emit('close', 0)) + return child + } + ) + + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: spawnMock + })) + vi.doMock('../codex-cli/command', () => ({ + resolveCodexCommand: () => 'codex' + })) + fixture = await createCanonicalHookTrustFixture() + readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries + writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8') + + const store = createStore(createSettings()) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await service.addAccount() + + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + it('recreates the expected missing managed home before reauthenticating', async () => { vi.resetModules() diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 2a0fb54bdc4..ff56fb4d036 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -16,6 +16,9 @@ import type { import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' +import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' +import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' +import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' @@ -58,6 +61,7 @@ type CanonicalCodexConfig = { /** Home the config was read from, in the path style Codex sees at runtime * (Linux-side for WSL); relative path-valued settings resolve against it. */ sourceHomePath: string + sourceHooksPath: string } export type CodexAccountAddTarget = { @@ -468,9 +472,18 @@ export class CodexAccountService { // account while preserving consistent Codex behavior. Managed homes are // real CODEX_HOMEs for `codex login`, so relative path-valued settings // must keep resolving against the home the config was read from. + const material = getCodexManagedHookInstallMaterial() + // Why: source-home Orca trust is foreign to each managed home's hooks.json. + const sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { + runtimeHomePath: canonicalConfig.sourceHomePath, + sourcePath: canonicalConfig.sourceHooksPath, + command: material.command, + managedEventLabels: new Set(Object.values(material.eventLabel)), + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) this.writeManagedConfig( trustedManagedHomePath, - rewriteRelativePathConfigValues(canonicalConfig.contents, canonicalConfig.sourceHomePath) + rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath) ) } @@ -482,7 +495,11 @@ export class CodexAccountService { } try { - return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath } + return { + contents: readFileSync(primaryConfigPath, 'utf-8'), + sourceHomePath, + sourceHooksPath: join(sourceHomePath, 'hooks.json') + } } catch (error) { console.warn('[codex-accounts] Failed to read canonical config:', error) return null @@ -509,7 +526,11 @@ export class CodexAccountService { try { // Why: the config is read over UNC but consumed by Codex inside WSL, so // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. - return { contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex` } + return { + contents: readFileSync(configPath, 'utf-8'), + sourceHomePath: `${wslHome}/.codex`, + sourceHooksPath: `${wslHome}/.codex/hooks.json` + } } catch (error) { console.warn('[codex-accounts] Failed to read WSL canonical config:', error) return null diff --git a/src/main/codex/codex-managed-trust-reconciliation.ts b/src/main/codex/codex-managed-trust-reconciliation.ts index b6c9e9d5d3a..78567134f98 100644 --- a/src/main/codex/codex-managed-trust-reconciliation.ts +++ b/src/main/codex/codex-managed-trust-reconciliation.ts @@ -6,8 +6,11 @@ import { normalizeCodexHookSourcePath, parseTrustKey, readHookTrustEntries, + readHookTrustEntriesFromContent, removeHookTrustEntries, + removeHookTrustEntriesFromContent, type CodexEventLabel, + type CodexHookTrustState, type CodexTrustEntry } from './config-toml-trust' import { getCodexHookTrustSignature } from './codex-hook-identity' @@ -52,8 +55,7 @@ function addLedgerRecognizedHashes( } } -export function removeCodexManagedHookTrustEntries(options: { - tomlPath: string +type CodexManagedHookTrustOwnershipOptions = { runtimeHomePath: string sourcePath: string command: string @@ -61,8 +63,12 @@ export function removeCodexManagedHookTrustEntries(options: { timeoutSec: number /** Explicit native homes resolve their parent before hook discovery. */ sourceUsesExplicitCodexHome?: boolean -}): void { - const existingEntries = readHookTrustEntries(options.tomlPath) +} + +function getCodexManagedHookTrustEntryKeys( + existingEntries: ReadonlyMap, + options: CodexManagedHookTrustOwnershipOptions +): string[] { const ledgerHome = readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath) const expectedSourcePath = options.sourceUsesExplicitCodexHome ? getCodexExplicitHomeHookSourcePath(options.sourcePath) @@ -94,6 +100,27 @@ export function removeCodexManagedHookTrustEntries(options: { ownedKeys.push(key) } } + return ownedKeys +} + +export function stripCodexManagedHookTrustEntriesFromConfig( + contents: string, + options: CodexManagedHookTrustOwnershipOptions +): string { + const ownedKeys = getCodexManagedHookTrustEntryKeys( + readHookTrustEntriesFromContent(contents), + options + ) + return removeHookTrustEntriesFromContent(contents, ownedKeys) +} + +export function removeCodexManagedHookTrustEntries( + options: CodexManagedHookTrustOwnershipOptions & { tomlPath: string } +): void { + const ownedKeys = getCodexManagedHookTrustEntryKeys( + readHookTrustEntries(options.tomlPath), + options + ) if (ownedKeys.length > 0) { removeHookTrustEntries(options.tomlPath, ownedKeys) } diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 63a7f7555e8..28445d40490 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -876,16 +876,20 @@ export function removeHookTrustEntries(configPath: string, keys: readonly string return } const existing = readTomlFile(configPath) - let updated = existing - for (const key of keys) { - updated = removeTrustBlock(updated, key) - } + const updated = removeHookTrustEntriesFromContent(existing, keys) if (updated === existing) { return } writeConfigAtomically(configPath, updated) } +export function removeHookTrustEntriesFromContent( + content: string, + keys: readonly string[] +): string { + return keys.reduce((updated, key) => removeTrustBlock(updated, key), content) +} + function removeTrustBlock(content: string, key: string): string { const ranges = findTrustBlockRanges(content, key) if (ranges.length === 0) { @@ -902,11 +906,14 @@ function removeTrustBlock(content: string, key: string): string { } export function readHookTrustEntries(configPath: string): Map { - const result = new HookTrustEntryMap() if (!existsSync(configPath)) { - return result + return new HookTrustEntryMap() } - const content = readTomlFile(configPath) + return readHookTrustEntriesFromContent(readTomlFile(configPath)) +} + +export function readHookTrustEntriesFromContent(content: string): Map { + const result = new HookTrustEntryMap() // Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or // `'''...'''` multi-line string isn't mistaken for a real header. let cursor = 0 From efaa61d596b3472bfd9b38d6764ce27ccd54e08a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:39:16 -0700 Subject: [PATCH 2/4] fix(codex): harden managed trust sanitization --- src/main/codex-accounts/service.test.ts | 37 ++++++++++ src/main/codex-accounts/service.ts | 22 +++--- src/main/codex/config-toml-trust.test.ts | 66 +++++++++++++++++ src/main/codex/config-toml-trust.ts | 94 +++++++++++++++++++----- 4 files changed, 193 insertions(+), 26 deletions(-) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 032a79668af..6b2e6ec360e 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -394,6 +394,43 @@ describe('CodexAccountService config sync', () => { expectSanitizedManagedConfig() }) + it('keeps flag-off config mirroring byte-identical', async () => { + const fixture = await createCanonicalHookTrustFixture() + const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + writeFileSync(canonicalConfigPath, fixture.config, 'utf-8') + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + 'approval_policy = "on-request"\n' + ) + const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: false, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + + const { CodexAccountService } = await import('./service') + new CodexAccountService( + createStore(settings) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(fixture.config) + expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config) + }) + it('rewrites relative path config values when syncing into managed homes', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync( diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index ff56fb4d036..0fecd8e923d 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -17,6 +17,7 @@ import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' +import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { resolveCodexCommand } from '../codex-cli/command' @@ -472,15 +473,18 @@ export class CodexAccountService { // account while preserving consistent Codex behavior. Managed homes are // real CODEX_HOMEs for `codex login`, so relative path-valued settings // must keep resolving against the home the config was read from. - const material = getCodexManagedHookInstallMaterial() - // Why: source-home Orca trust is foreign to each managed home's hooks.json. - const sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { - runtimeHomePath: canonicalConfig.sourceHomePath, - sourcePath: canonicalConfig.sourceHooksPath, - command: material.command, - managedEventLabels: new Set(Object.values(material.eventLabel)), - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - }) + let sanitizedConfig = canonicalConfig.contents + if (isCodexSystemDefaultRealHomeEnabled(this.store.getSettings())) { + const material = getCodexManagedHookInstallMaterial() + // Why: source-home Orca trust is foreign to each managed home's hooks.json. + sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { + runtimeHomePath: canonicalConfig.sourceHomePath, + sourcePath: canonicalConfig.sourceHooksPath, + command: material.command, + managedEventLabels: new Set(Object.values(material.eventLabel)), + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } this.writeManagedConfig( trustedManagedHomePath, rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath) diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index e36e7a1a8d7..2ee270e0cbc 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -26,7 +26,9 @@ import { normalizeCodexProjectPathForRevocationLookup, parseTrustKey, readHookTrustEntries, + readHookTrustEntriesFromContent, removeHookTrustEntries, + removeHookTrustEntriesFromContent, upsertHookTrustEntries, upsertProjectTrustLevel, upsertProjectTrustLevelInContent, @@ -1664,6 +1666,70 @@ describe('readHookTrustEntries', () => { expect(result.get(keyB)?.enabled).toBe(true) }) + it('fails closed when normalized duplicate blocks have conflicting hashes', () => { + const key = '/x/hooks.json:stop:0:0' + writeFileSync( + configPath, + [ + `[hooks.state."${key}"]`, + 'trusted_hash = "sha256:USER"', + '', + `[hooks.state.'${key}']`, + 'trusted_hash = "sha256:ORCA"', + '' + ].join('\n'), + 'utf-8' + ) + + expect(readHookTrustEntries(configPath).get(key)?.trustedHash).toBeUndefined() + }) + + it('ignores trust-looking fields inside multiline strings', () => { + const key = '/x/hooks.json:stop:0:0' + writeFileSync( + configPath, + [ + `[hooks.state."${key}"]`, + 'note = """', + 'trusted_hash = "sha256:NOT-A-FIELD"', + 'enabled = false', + '"""', + '' + ].join('\n'), + 'utf-8' + ) + + expect(readHookTrustEntries(configPath).get(key)).toEqual({ + trustedHash: undefined, + enabled: undefined + }) + }) + + it('does not accept an unterminated trusted_hash string', () => { + const key = '/x/hooks.json:stop:0:0' + writeFileSync( + configPath, + `[hooks.state."${key}"]\ntrusted_hash = "sha256:UNTERMINATED\n`, + 'utf-8' + ) + + expect(readHookTrustEntries(configPath).get(key)?.trustedHash).toBeUndefined() + }) + + it('recognizes and removes a first trust block after a leading BOM', () => { + const key = '/x/hooks.json:stop:0:0' + const content = [ + `\uFEFF[hooks.state."${key}"]`, + 'trusted_hash = "sha256:ORCA"', + '[other]', + 'value = true', + '' + ].join('\n') + + expect(readHookTrustEntriesFromContent(content).get(key)?.trustedHash).toBe('sha256:ORCA') + expect(removeHookTrustEntriesFromContent(content, [key])).toBe('[other]\nvalue = true\n') + }) + it('does not let triple quotes in comments hide later trust entries', () => { const key = '/x/hooks.json:pre_tool_use:0:0' const original = [ diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 28445d40490..aa8bcec2497 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -599,18 +599,34 @@ export function normalizeHookTrustKeyForLookup(key: string): string { } function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { + return findTrustBlockRangesForNormalizedKeys( + content, + new Set([normalizeHookTrustKeyForLookup(key)]) + ) +} + +function findTrustBlockRangesForNormalizedKeys( + content: string, + normalizedKeys: ReadonlySet +): TrustBlockRange[] { const ranges: TrustBlockRange[] = [] - const normalizedKey = normalizeHookTrustKeyForLookup(key) + if (normalizedKeys.size === 0) { + return ranges + } let cursor = 0 let scanState = createTomlLineScanState() while (cursor < content.length) { const newlineIdx = content.indexOf('\n', cursor) const lineEnd = newlineIdx === -1 ? content.length : newlineIdx const rawLine = content.slice(cursor, lineEnd) - const line = rawLine.replace(/\r$/, '') + const lineWithoutCr = rawLine.replace(/\r$/, '') + const line = + cursor === 0 && lineWithoutCr.charCodeAt(0) === 0xfeff + ? lineWithoutCr.slice(1) + : lineWithoutCr const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null - if (headerKey !== null && normalizeHookTrustKeyForLookup(headerKey) === normalizedKey) { + if (headerKey !== null && normalizedKeys.has(normalizeHookTrustKeyForLookup(headerKey))) { const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd const after = content.slice(headerLineEnd) const nextHeaderRel = findNextTableHeader(after) @@ -887,11 +903,8 @@ export function removeHookTrustEntriesFromContent( content: string, keys: readonly string[] ): string { - return keys.reduce((updated, key) => removeTrustBlock(updated, key), content) -} - -function removeTrustBlock(content: string, key: string): string { - const ranges = findTrustBlockRanges(content, key) + const normalizedKeys = new Set(keys.map(normalizeHookTrustKeyForLookup)) + const ranges = findTrustBlockRangesForNormalizedKeys(content, normalizedKeys) if (ranges.length === 0) { return content } @@ -912,8 +925,39 @@ export function readHookTrustEntries(configPath: string): Map + enabled?: boolean +} { + const trustedHashes = new Set() + let enabled: boolean | undefined + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < block.length) { + const newlineIdx = block.indexOf('\n', cursor) + const lineEnd = newlineIdx === -1 ? block.length : newlineIdx + const line = block.slice(cursor, lineEnd).replace(/\r$/, '') + if (isTomlStructuralLine(scanState)) { + const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"[ \t]*(?:#.*)?$/.exec( + line + ) + if (hashMatch) { + trustedHashes.add(unescapeTomlString(hashMatch[1])) + } + const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t]*(?:#.*)?$/.exec(line) + if (enabledMatch) { + enabled = enabled !== false && enabledMatch[1] === 'true' + } + } + scanState = updateTomlLineScanState(scanState, line) + cursor = newlineIdx === -1 ? block.length : newlineIdx + 1 + } + return { trustedHashes, enabled } +} + export function readHookTrustEntriesFromContent(content: string): Map { const result = new HookTrustEntryMap() + const conflictingTrustedHashKeys = new Set() // Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or // `'''...'''` multi-line string isn't mistaken for a real header. let cursor = 0 @@ -922,7 +966,11 @@ export function readHookTrustEntriesFromContent(content: string): Map 1 || + (trustedHash !== undefined && + existingState?.trustedHash !== undefined && + existingState.trustedHash !== trustedHash) + ) { + // Why: conflicting normalized duplicates are malformed and cannot prove + // which block is owned, so trust cleanup must preserve them all. + conflictingTrustedHashKeys.add(normalizedKey) + } result.set(normalizedKey, { - trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : existingState?.trustedHash, + trustedHash: conflictingTrustedHashKeys.has(normalizedKey) + ? undefined + : (trustedHash ?? existingState?.trustedHash), // Why: Windows writes both slash variants for one hook; a disabled copy // must remain authoritative regardless of which variant appears last. enabled: - existingState?.enabled === false || enabled === false + existingState?.enabled === false || blockState.enabled === false ? false - : (enabled ?? existingState?.enabled) + : (blockState.enabled ?? existingState?.enabled) }) cursor = nextCursor continue 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 3/4] 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 ${ From 433356adcfef25ef0ab3958fca19649d3aa6e9fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:17:07 -0700 Subject: [PATCH 4/4] fix(codex): validate per-account home ownership --- .../host-codex-managed-home-ownership.test.ts | 86 +++++++++++++++++ .../host-codex-managed-home-ownership.ts | 96 +++++++++++++++++++ .../codex-accounts/runtime-home-service.ts | 54 +++++++++-- src/main/codex-accounts/service.test.ts | 12 ++- src/main/codex-accounts/service.ts | 50 ++-------- 5 files changed, 240 insertions(+), 58 deletions(-) create mode 100644 src/main/codex-accounts/host-codex-managed-home-ownership.test.ts create mode 100644 src/main/codex-accounts/host-codex-managed-home-ownership.ts diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.test.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.test.ts new file mode 100644 index 00000000000..d5edda449a9 --- /dev/null +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' + +describe('assertOwnedHostCodexManagedHomePath', () => { + let rootDir: string + let userDataDir: string + let systemHomePath: string + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), 'orca-managed-home-ownership-')) + userDataDir = join(rootDir, 'user-data') + systemHomePath = join(rootDir, 'home', '.codex') + mkdirSync(join(userDataDir, 'codex-accounts'), { recursive: true }) + mkdirSync(systemHomePath, { recursive: true }) + }) + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }) + }) + + it('accepts the expected marked account home', () => { + const accountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home') + mkdirSync(accountHome, { recursive: true }) + writeFileSync(join(accountHome, '.orca-managed-home'), 'account-1\n', 'utf-8') + + expect( + assertOwnedHostCodexManagedHomePath({ + candidatePath: accountHome, + managedAccountsRoot: join(userDataDir, 'codex-accounts'), + systemCodexHomePath: systemHomePath, + expectedAccountId: 'account-1' + }) + ).toBe(realpathSync(accountHome)) + }) + + it('rejects an account path redirected into the sandboxed system Codex home', () => { + const systemAccountHome = join(systemHomePath, 'account-1', 'home') + mkdirSync(systemAccountHome, { recursive: true }) + writeFileSync(join(systemAccountHome, '.orca-managed-home'), 'account-1\n', 'utf-8') + const sentinelPath = join(systemHomePath, 'auth.json') + writeFileSync(sentinelPath, 'system-auth\n', 'utf-8') + + rmSync(join(userDataDir, 'codex-accounts'), { recursive: true, force: true }) + symlinkSync( + systemHomePath, + join(userDataDir, 'codex-accounts'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + expect(() => + assertOwnedHostCodexManagedHomePath({ + candidatePath: join(userDataDir, 'codex-accounts', 'account-1', 'home'), + managedAccountsRoot: join(userDataDir, 'codex-accounts'), + systemCodexHomePath: systemHomePath, + expectedAccountId: 'account-1' + }) + ).toThrow('resolves inside the system Codex home') + expect(readFileSync(sentinelPath, 'utf-8')).toBe('system-auth\n') + }) + + it('rejects a persisted home or marker belonging to another account', () => { + const accountHome = join(userDataDir, 'codex-accounts', 'account-2', 'home') + mkdirSync(accountHome, { recursive: true }) + writeFileSync(join(accountHome, '.orca-managed-home'), 'account-2\n', 'utf-8') + + expect(() => + assertOwnedHostCodexManagedHomePath({ + candidatePath: accountHome, + managedAccountsRoot: join(userDataDir, 'codex-accounts'), + systemCodexHomePath: systemHomePath, + expectedAccountId: 'account-1' + }) + ).toThrow('does not match its persisted account ID') + }) +}) diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.ts new file mode 100644 index 00000000000..6f0603c73f4 --- /dev/null +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.ts @@ -0,0 +1,96 @@ +import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' + +type HostCodexManagedHomeOwnershipOptions = { + candidatePath: string + managedAccountsRoot: string + systemCodexHomePath: string + expectedAccountId?: string +} + +function pathsEqual(left: string, right: string): boolean { + const resolvedLeft = resolve(left) + const resolvedRight = resolve(right) + return process.platform === 'win32' + ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + : resolvedLeft === resolvedRight +} + +function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean { + const relativePath = relative(rootPath, candidatePath) + return ( + relativePath === '' || + (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`)) + ) +} + +function canonicalizeIfPresent(candidatePath: string): string { + const resolvedPath = resolve(candidatePath) + return existsSync(resolvedPath) ? realpathSync(resolvedPath) : resolvedPath +} + +/** + * Proves a host managed home is an Orca-owned account directory and cannot + * resolve into the user's real CODEX_HOME before callers write launch state. + */ +export function assertOwnedHostCodexManagedHomePath({ + candidatePath, + managedAccountsRoot, + systemCodexHomePath, + expectedAccountId +}: HostCodexManagedHomeOwnershipOptions): string { + const resolvedCandidate = resolve(candidatePath) + const resolvedRoot = resolve(managedAccountsRoot) + if (!existsSync(resolvedCandidate)) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + // Why: macOS can spell one path as /var and /private/var, while a replaced + // path component must still be caught as a canonical containment escape. + const canonicalCandidate = realpathSync(resolvedCandidate) + const canonicalRoot = realpathSync(resolvedRoot) + const canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath) + if (expectedAccountId !== undefined) { + const candidateUsesManagedRootSpelling = + pathIsInsideOrEqual(resolvedRoot, resolvedCandidate) || + pathIsInsideOrEqual(canonicalRoot, resolvedCandidate) + const canonicalExpectedHome = canonicalizeIfPresent( + join(canonicalRoot, expectedAccountId, 'home') + ) + if ( + !candidateUsesManagedRootSpelling || + !pathsEqual(canonicalCandidate, canonicalExpectedHome) + ) { + throw new Error('Managed Codex home does not match its persisted account ID.') + } + } + // Why: a replaced codex-accounts directory could otherwise redirect config, + // hook, or resource writes into the user's real ~/.codex tree. + if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) { + throw new Error('Managed Codex home resolves inside the system Codex home.') + } + if ( + !pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) || + canonicalRoot === canonicalCandidate + ) { + throw new Error( + `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` + ) + } + + const markerPath = join(canonicalCandidate, '.orca-managed-home') + let markerIsRegularFile: boolean + try { + markerIsRegularFile = lstatSync(markerPath).isFile() + } catch (error) { + throw new Error('Managed Codex home is missing Orca ownership marker.', { cause: error }) + } + if (!markerIsRegularFile) { + throw new Error('Managed Codex home ownership marker is not a regular file.') + } + const markerContents = readFileSync(markerPath, 'utf-8') + if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) { + throw new Error('Managed Codex home ownership marker does not match its account ID.') + } + + return canonicalCandidate +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index d3952aedca5..7535b1109e2 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -65,6 +65,7 @@ import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-fl import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' type CodexAuthIdentity = { email: string | null @@ -230,11 +231,11 @@ export class CodexRuntimeHomeService { } private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null { - const perAccountHome = account.managedHomePath - if (!existsSync(join(perAccountHome, 'auth.json'))) { + const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) + if (!perAccountHome || !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) + this.clearSelfContainedManagedSelection(account) return null } // Why: link the user's real ~/.codex resources and mirror config into THIS @@ -254,14 +255,39 @@ export class CodexRuntimeHomeService { // 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'))) { + const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) + if (perAccountHome && existsSync(join(perAccountHome, 'auth.json'))) { this.lastSyncedAccountId = account.id return } + this.clearSelfContainedManagedSelection(account) + } + + private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + try { + assertOwnedHostCodexManagedHomePath({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + // Preserve the persisted path spelling (notably /var vs /private/var on + // macOS) so injected CODEX_HOME stays stable across the rollout. + return account.managedHomePath + } catch (error) { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', error) + return null + } + } + + private clearSelfContainedManagedSelection(account: CodexManagedAccount): void { console.warn( - '[codex-runtime-home] Active managed account is missing auth.json, clearing selection' + '[codex-runtime-home] Active managed account home is invalid or missing auth.json, clearing selection' ) const settings = this.store.getSettings() + if (normalizeCodexRuntimeSelection(settings).host !== account.id) { + return + } this.store.updateSettings({ activeCodexManagedAccountId: null, activeCodexManagedAccountIdsByRuntime: { @@ -316,9 +342,10 @@ export class CodexRuntimeHomeService { getHostCodexHomePathsForSessionDiscovery(): string[] { const homes = [this.getRuntimeHomePath()] - if (this.isHostSystemDefaultRealHome()) { - // Why: nested Orca processes can retain an ambient managed CODEX_HOME; - // explicitly include the real lane so its sessions remain discoverable. + if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { + // Why: nested Orca processes can retain an ambient managed CODEX_HOME. + // Per-account lanes no longer bridge real-home history into the shared + // mirror, so include the real root for both directly-routed host lanes. homes.push(getSystemCodexHomePath()) } // Why: flag ON routes each managed host account to its own self-contained @@ -434,14 +461,21 @@ export class CodexRuntimeHomeService { return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } const selfContainedAccount = this.getSelfContainedManagedHostAccount() + const selfContainedHome = selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null if ( selfContainedAccount && - existsSync(join(selfContainedAccount.managedHomePath, 'auth.json')) + selfContainedHome && + existsSync(join(selfContainedHome, '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 + return selfContainedHome + } + if (selfContainedAccount) { + this.clearSelfContainedManagedSelection(selfContainedAccount) } if (this.isHostSystemDefaultRealHome()) { // Why: null lets the fetcher fall back to the main process's inherited diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 0f990c5b2ff..846469efab2 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -344,7 +344,7 @@ describe('CodexAccountService config sync', () => { ) }) - it('strips only Orca-owned source-home hook trust on startup and account selection', async () => { + it('does not seed source-home hook trust into a self-contained account home', async () => { const fixture = await createCanonicalHookTrustFixture() const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync(canonicalConfigPath, fixture.config, 'utf-8') @@ -354,6 +354,7 @@ describe('CodexAccountService config sync', () => { 'approval_policy = "on-request"\n' ) const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: true, codexManagedAccounts: [ { id: 'account-1', @@ -385,7 +386,8 @@ describe('CodexAccountService config sync', () => { for (const key of fixture.orcaKeys) { expect(entries.has(key)).toBe(false) } - expect(entries.has(fixture.userKey)).toBe(true) + // The launch-time hook mirror remaps user trust to this home's hooks.json. + expect(entries.has(fixture.userKey)).toBe(false) } expectSanitizedManagedConfig() @@ -727,7 +729,7 @@ describe('CodexAccountService config sync', () => { expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) - it('does not seed Orca-owned source-home trust when adding an account', async () => { + it('does not seed source-home hook trust when adding a self-contained account', async () => { vi.resetModules() let fixture: Awaited> let readHookTrustEntries: typeof ReadHookTrustEntries @@ -748,7 +750,7 @@ describe('CodexAccountService config sync', () => { for (const key of fixture.orcaKeys) { expect(entries.has(key)).toBe(false) } - expect(entries.has(fixture.userKey)).toBe(true) + expect(entries.has(fixture.userKey)).toBe(false) writeFileSync( join(loginHome!, 'auth.json'), createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'), @@ -771,7 +773,7 @@ describe('CodexAccountService config sync', () => { readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8') - const store = createStore(createSettings()) + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() const { CodexAccountService } = await import('./service') diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 90ab4f3d4bc..fc892f8abd6 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -4,7 +4,7 @@ main-process module so the managed-account boundary stays explicit. */ import { randomUUID } from 'node:crypto' import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join, relative, resolve, sep } from 'node:path' +import { dirname, join, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' @@ -21,7 +21,6 @@ import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-fl 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' @@ -44,6 +43,7 @@ import { setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' +import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -727,47 +727,11 @@ export class CodexAccountService { return candidatePath } - const rootPath = this.getManagedAccountsRoot() - const resolvedCandidate = resolve(candidatePath) - const resolvedRoot = resolve(rootPath) - - if (!existsSync(resolvedCandidate)) { - throw new Error('Managed Codex home directory does not exist on disk.') - } - - // realpath() requires the leaf to exist. For pre-login add flow we create - // the home directory first so the containment check still verifies the - // canonical on-disk target rather than trusting persisted text blindly. - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(resolvedRoot) - - // Why: the prefix check must compare canonical paths on both sides. On - // macOS, userData sits under /var/folders/... which realpath resolves to - // /private/var/folders/...; comparing a canonical candidate against a - // non-canonical root would spuriously reject every managed home. In dev - // mode (orca-dev/ vs orca/) this check also filters out production-rooted - // paths before downstream sync runs. - if ( - canonicalCandidate !== canonicalRoot && - !canonicalCandidate.startsWith(canonicalRoot + sep) - ) { - throw new Error( - `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` - ) - } - const relativePath = relative(canonicalRoot, canonicalCandidate) - const escaped = - relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`..${sep}`) - - if (escaped) { - throw new Error('Managed Codex home escaped Orca account storage.') - } - - if (!existsSync(join(canonicalCandidate, '.orca-managed-home'))) { - throw new Error('Managed Codex home is missing Orca ownership marker.') - } - - return canonicalCandidate + return assertOwnedHostCodexManagedHomePath({ + candidatePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath() + }) } private safeRemoveWslManagedHomeCandidate(