fix(codex): harden real-home routing cleanup and retries

This commit is contained in:
Brennan Benson
2026-07-15 13:59:52 -07:00
parent ca1389b4aa
commit 5eb0750bf9
5 changed files with 185 additions and 55 deletions
@@ -991,14 +991,39 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
})
it('routes host system default to the real home (null) when the flag is ON', async () => {
it('routes host system default to the real home when the flag is ON', async () => {
const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true }))
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.isHostSystemDefaultRealHome()).toBe(true)
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.prepareForRateLimitFetch()).toBeNull()
const previousCodexHome = process.env.CODEX_HOME
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
process.env.CODEX_HOME = getRuntimeCodexHomePath()
process.env.ORCA_CODEX_HOME = getRuntimeCodexHomePath()
try {
// Background fetchers prefer ambient CODEX_HOME when passed null, so an
// explicit path proves nested Orca launches cannot poll the managed home.
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
process.env.CODEX_HOME = getSystemCodexHomePath()
delete process.env.ORCA_CODEX_HOME
expect(service.isHostSystemDefaultRealHome()).toBe(true)
process.env.CODEX_HOME = join(testState.fakeHomeDir, 'user-owned-codex-home')
expect(service.isHostSystemDefaultRealHome()).toBe(false)
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
} 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
}
}
})
it('keeps the managed home for a host MANAGED account even when the flag is ON', async () => {
@@ -61,6 +61,7 @@ import {
} from './runtime-selection'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path'
type CodexAuthIdentity = {
email: string | null
@@ -210,13 +211,14 @@ export class CodexRuntimeHomeService {
}
// Why: real-home routing applies only to the host system-default selection
// (no managed account chosen for host) with the staged flag ON. Managed host
// accounts keep the isolated runtime home for hot-swap and token persistence.
// with the staged flag ON. Managed accounts keep hot-swap isolation; custom
// CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes.
isHostSystemDefaultRealHomeSelected(): boolean {
const settings = this.store.getSettings()
return (
isCodexSystemDefaultRealHomeEnabled(settings) &&
normalizeCodexRuntimeSelection(settings).host === null
normalizeCodexRuntimeSelection(settings).host === null &&
!hasCustomCodexHomeOverride()
)
}
@@ -288,11 +290,11 @@ export class CodexRuntimeHomeService {
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
}
if (this.isHostSystemDefaultRealHome()) {
// Why (flag ON, system default): read usage/auth from the user's own
// ~/.codex. Returning null makes the fetcher fall back to ~/.codex and
// its auth-presence gate check the real auth.json, so the background
// poller never spawns Codex against the managed home (the #5370 auth war).
return null
// Why: null lets the fetcher fall back to the main process's inherited
// CODEX_HOME before ~/.codex. Nested Orca launches can inherit the
// managed home, restarting the background OAuth conflict (#5370), so
// pin this non-interactive lane to the native home explicitly.
return getSystemCodexHomePath()
}
this.syncForCurrentSelection()
syncSystemCodexResourcesIntoManagedHome()
@@ -4,6 +4,12 @@ import type * as NodeOs from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant'
import {
computeTrustKey,
readHookTrustEntries,
upsertHookTrustEntriesInContent,
type CodexTrustEntry
} from './config-toml-trust'
const { homedirMock, grantMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>(),
@@ -16,6 +22,7 @@ vi.mock('node:os', async () => {
})
vi.mock('./codex-hook-trust-grant', () => ({
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS: 300_000,
grantManagedCodexHookTrust: grantMock
}))
@@ -34,6 +41,10 @@ function getRealHooksJsonPath(): string {
return join(fakeHomeDir, '.codex', 'hooks.json')
}
function getRealConfigTomlPath(): string {
return join(fakeHomeDir, '.codex', 'config.toml')
}
function readRealHooksJson(): {
hooks?: Record<string, { hooks?: { command?: string }[] }[]>
[key: string]: unknown
@@ -148,6 +159,21 @@ describe('ensureRealHomeCodexHookState (install)', () => {
expect(existsSync(getRealHooksJsonPath())).toBe(false)
})
it('does no hook-file or grant work on repeated unsupported launches', () => {
grantUnavailable()
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
'unavailable'
)
expect(existsSync(getRealHooksJsonPath())).toBe(false)
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
'unavailable'
)
expect(grantMock).toHaveBeenCalledTimes(1)
expect(existsSync(getRealHooksJsonPath())).toBe(false)
})
it('leaves an unparseable hooks.json untouched and keeps the managed lane', () => {
writeFileSync(getRealHooksJsonPath(), '{not json', 'utf-8')
@@ -205,4 +231,58 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
expect(lane).toBe('removed')
expect(existsSync(getRealHooksJsonPath())).toBe(false)
})
it('removes only hash-proven Orca trust from a mixed hook group', () => {
const material = getCodexManagedHookInstallMaterial()
const userCommand = 'my-user-hook.sh'
writeFileSync(
getRealHooksJsonPath(),
`${JSON.stringify(
{
hooks: {
Stop: [
{
hooks: [
{ type: 'command', command: userCommand },
{ type: 'command', command: material.command, timeout: 10 }
]
}
]
}
},
null,
2
)}\n`,
'utf-8'
)
const entries: CodexTrustEntry[] = [
{
sourcePath: getRealHooksJsonPath(),
eventLabel: 'stop',
groupIndex: 0,
handlerIndex: 0,
command: userCommand
},
{
sourcePath: getRealHooksJsonPath(),
eventLabel: 'stop',
groupIndex: 0,
handlerIndex: 1,
command: material.command,
timeoutSec: 10
}
]
writeFileSync(getRealConfigTomlPath(), upsertHookTrustEntriesInContent('', entries), 'utf-8')
expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe(
'removed'
)
expect(readRealHooksJson().hooks?.Stop).toEqual([
{ hooks: [{ type: 'command', command: userCommand }] }
])
const trust = readHookTrustEntries(getRealConfigTomlPath())
expect(trust.has(computeTrustKey(entries[0]!))).toBe(true)
expect(trust.has(computeTrustKey(entries[1]!))).toBe(false)
})
})
+48 -45
View File
@@ -1,17 +1,9 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
hookDefinitionHasManagedCommand,
MANAGED_HOOK_TIMEOUT_SECONDS,
readHooksJson,
removeManagedCommands,
@@ -20,12 +12,16 @@ import {
type HookDefinition,
type HooksConfig
} from '../agent-hooks/installer-utils'
import { getSystemCodexHomePath } from './codex-home-paths'
import { getCodexManagedScriptFileName } from './codex-hook-identity'
import { grantManagedCodexHookTrust } from './codex-hook-trust-grant'
import { removeCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
import {
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
grantManagedCodexHookTrust,
type CodexTrustGrantFallbackReason
} from './codex-hook-trust-grant'
import { removeCodexManagedHookTrustEntries } from './codex-managed-trust-reconciliation'
import { getCodexManagedHookInstallMaterial } from './hook-service'
import { computeTrustKey, removeHookTrustEntries, type CodexTrustEntry } from './config-toml-trust'
import { getSystemCodexHomePath } from './codex-home-paths'
import type { CodexTrustEntry } from './config-toml-trust'
/**
* Real-home Codex hook lane for the system-default selection (flag ON).
@@ -42,6 +38,7 @@ import { computeTrustKey, removeHookTrustEntries, type CodexTrustEntry } from '.
export type RealHomeCodexHookLane = 'pending' | 'installed' | 'unavailable' | 'removed'
let currentLane: RealHomeCodexHookLane = 'pending'
let installRetryAfterMs = 0
export function getRealHomeCodexHookLane(): RealHomeCodexHookLane {
return currentLane
@@ -80,13 +77,24 @@ export function ensureRealHomeCodexHookState(args: {
hooksEnabled: boolean
userDataPath: string
}): RealHomeCodexHookLane {
// Why: the grant client caches failed probes, but mutating and rolling back
// hooks.json before consulting it still adds synchronous work to every pane.
if (args.hooksEnabled && currentLane === 'unavailable' && Date.now() < installRetryAfterMs) {
return currentLane
}
try {
currentLane = args.hooksEnabled
? installRealHomeCodexHook(args.userDataPath)
: sweepRealHomeCodexHook()
if (!args.hooksEnabled || currentLane === 'installed') {
installRetryAfterMs = 0
}
} catch (error) {
console.warn('[codex-real-home-hooks] ensure failed; staying on managed lane:', error)
currentLane = args.hooksEnabled ? 'unavailable' : currentLane
if (args.hooksEnabled) {
installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
}
}
return currentLane
}
@@ -99,6 +107,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
// Why: an unparseable user file must never be clobbered; without a hook
// entry the managed lane keeps status working for this host.
console.warn('[codex-real-home-hooks] could not parse', hooksJsonPath, '- managed lane kept')
installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
return 'unavailable'
}
@@ -161,12 +170,19 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
// bytes and keep this host on the managed-home lane; the grant client
// already logged the fallback reason.
restoreRealHomeHooksJson(hooksJsonPath, previousRaw)
installRetryAfterMs = getInstallRetryAfterMs(grant.reason)
console.warn(
`[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept`
)
return 'unavailable'
}
function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number {
return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled'
? Number.POSITIVE_INFINITY
: Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
}
function sweepRealHomeCodexHook(): RealHomeCodexHookLane {
const hooksJsonPath = getRealHomeHooksJsonPath()
const config = readHooksJson(hooksJsonPath)
@@ -176,33 +192,16 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane {
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const material = getCodexManagedHookInstallMaterial()
const nextHooks: Record<string, HookDefinition[]> = { ...config.hooks }
const removedTrustKeys: string[] = []
let removedAny = false
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
continue
}
definitions.forEach((definition, groupIndex) => {
if (!hookDefinitionHasManagedCommand(definition, isManagedCommand)) {
return
}
const eventLabel = material.eventLabel[eventName as (typeof material.events)[number]]
if (!eventLabel) {
return
}
removedTrustKeys.push(
computeTrustKey({
sourcePath: hooksJsonPath,
eventLabel,
groupIndex,
handlerIndex: 0,
command: material.command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
)
})
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length !== definitions.length) {
if (
cleaned.length !== definitions.length ||
cleaned.some((definition, index) => definition !== definitions[index])
) {
removedAny = true
}
if (cleaned.length === 0) {
@@ -215,17 +214,20 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane {
writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig)
// Why: dead [hooks.state] blocks for a removed hook are Orca-owned records;
// dropping them keeps the user's config.toml from accumulating orphans.
// Their removal never shifts user trust keys because Orca appends last.
// Verify ownership by the expected hash or grant ledger: stale/mixed hook
// groups must never make Orca delete a user's trust record at the same key.
try {
removeHookTrustEntries(getRealHomeConfigTomlPath(), removedTrustKeys)
removeCodexManagedHookTrustEntries({
tomlPath: getRealHomeConfigTomlPath(),
runtimeHomePath: getSystemCodexHomePath(),
sourcePath: hooksJsonPath,
command: material.command,
managedEventLabels: new Set(Object.values(material.eventLabel)),
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
} catch (error) {
console.warn('[codex-real-home-hooks] failed to drop Orca trust entries:', error)
}
try {
removeCodexTrustGrantLedgerHome(getSystemCodexHomePath())
} catch {
// Ledger cleanup is bookkeeping only; the next grant rebuilds it.
}
}
return 'removed'
}
@@ -260,9 +262,9 @@ function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | n
}
return
}
const tmpPath = `${hooksJsonPath}.${process.pid}.rollback.tmp`
writeFileSync(tmpPath, previousRaw, 'utf-8')
renameSync(tmpPath, hooksJsonPath)
// Why: rollback is part of the safety boundary. Use the shared atomic
// writer so Windows file-lock retries and failed-temp cleanup are covered.
writeFileAtomically(hooksJsonPath, previousRaw)
} catch (error) {
console.warn('[codex-real-home-hooks] failed to roll back hooks.json:', error)
}
@@ -271,5 +273,6 @@ function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | n
export const _internals = {
setLaneForTesting(lane: RealHomeCodexHookLane): void {
currentLane = lane
installRetryAfterMs = 0
}
}
+20
View File
@@ -0,0 +1,20 @@
import { resolve } from 'node:path'
import { getSystemCodexHomePath } from './codex-home-paths'
/** True when the user points Codex outside its standard native home. */
export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean {
const codexHome = env.CODEX_HOME?.trim()
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim()
// Why: phase 1 owns only ~/.codex and can clean that path on downgrade. A
// custom home needs cross-home ownership tracking before Orca may mutate it.
return Boolean(
codexHome &&
codexHome !== orcaCodexHome &&
normalizePathForComparison(codexHome) !== normalizePathForComparison(getSystemCodexHomePath())
)
}
function normalizePathForComparison(value: string): string {
const normalized = resolve(value)
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}