fix(codex): reconcile hooks for restored shells

This commit is contained in:
Brennan Benson
2026-08-10 22:03:34 -07:00
parent 600b0b1c15
commit da0213fe1d
7 changed files with 248 additions and 5 deletions
@@ -1206,6 +1206,44 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('resolves only Orca-owned homes used by live retained host shells', async () => {
const accountHome = createManagedAuth(
testState.userDataDir,
'account-1',
createCodexAuthJson('managed@example.com', 'acct-managed', 'managed')
)
const unownedHome = join(testState.fakeHomeDir, 'unowned-codex-home')
mkdirSync(unownedHome, { recursive: true })
writeFileSync(join(unownedHome, '.orca-managed-home'), 'account-2\n', 'utf-8')
writePaneRegistry({
'shared-pane': { selectionKey: 'host', accountId: null, homeRoute: 'shared-home' },
'account-pane': { selectionKey: 'host', accountId: 'account-1', homeRoute: 'account-home' },
'unowned-pane': { selectionKey: 'host', accountId: 'account-2', homeRoute: 'account-home' },
'real-pane': { selectionKey: 'host', accountId: null, homeRoute: 'real-home' },
'wsl-pane': { selectionKey: 'wsl:Ubuntu', accountId: null, homeRoute: 'wsl-home' }
})
const settings = createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-1', 'managed@example.com', 'acct-managed', accountHome),
createCodexAccountRecord('account-2', 'other@example.com', 'acct-other', unownedHome)
]
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(
service.getRetainedHostCodexHookHomePaths([
'shared-pane',
'account-pane',
'unowned-pane',
'real-pane',
'wsl-pane',
'unknown-pane'
])
).toEqual([getRuntimeCodexHomePath(), accountHome])
})
it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => {
const oldSystemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-system')
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
@@ -83,6 +83,7 @@ import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-mi
import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace'
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
import {
getCodexPaneAccount,
hasRecordedLegacySharedCodexPane,
type CodexPaneHomeRoute
} from '../codex/codex-pane-account-registry'
@@ -545,6 +546,40 @@ export class CodexRuntimeHomeService {
return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home'
}
getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] {
const settings = this.store.getSettings()
const homes = new Map<string, string>()
for (const ptyId of ptyIds) {
const record = getCodexPaneAccount(ptyId)
if (!record || record.selectionKey !== 'host') {
continue
}
if (
record.homeRoute === undefined ||
record.homeRoute === 'shared-home' ||
record.homeRoute === 'custom-home'
) {
const homePath = this.getRuntimeHomePath()
homes.set(normalizeRuntimePathForComparison(homePath), homePath)
continue
}
if (record.homeRoute !== 'account-home' || !record.accountId) {
continue
}
const account = settings.codexManagedAccounts.find(
(candidate) => candidate.id === record.accountId
)
if (!account || this.getWslManagedHomePath(account)) {
continue
}
const homePath = this.getTrustedSelfContainedManagedHomePath(account)
if (homePath) {
homes.set(normalizeRuntimePathForComparison(homePath), homePath)
}
}
return [...homes.values()]
}
// Why: the real-home hook installer flips this gate off when the trust-grant
// client reports the host incapable, keeping that host byte-identical to the
// managed lane instead of shipping status-blind panes.
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { reconcileRetainedCodexHookHomes } from './retained-codex-hook-state'
function status(state: 'installed' | 'not_installed' | 'error'): AgentHookInstallStatus {
return {
agent: 'codex',
state,
configPath: '/runtime/hooks.json',
managedHooksPresent: state === 'installed',
detail: state === 'error' ? 'failed' : null
}
}
describe('retained Codex hook state', () => {
it('repairs Orca hooks before a retained shell can launch Codex', () => {
const install = vi.fn(() => status('installed'))
const refreshRuntimeUserHooks = vi.fn(() => status('not_installed'))
reconcileRetainedCodexHookHomes({
hookService: { install, refreshRuntimeUserHooks },
hooksEnabled: true,
runtimeHomePaths: ['/orca/shared-home', '/orca/account-home']
})
expect(install).toHaveBeenCalledTimes(2)
expect(install).toHaveBeenNthCalledWith(1, '/orca/shared-home')
expect(install).toHaveBeenNthCalledWith(2, '/orca/account-home')
expect(refreshRuntimeUserHooks).not.toHaveBeenCalled()
})
it('removes only Orca hooks from retained homes when hooks are disabled', () => {
const install = vi.fn(() => status('installed'))
const refreshRuntimeUserHooks = vi.fn(() => status('not_installed'))
reconcileRetainedCodexHookHomes({
hookService: { install, refreshRuntimeUserHooks },
hooksEnabled: false,
runtimeHomePaths: ['/orca/shared-home']
})
expect(refreshRuntimeUserHooks).toHaveBeenCalledWith('/orca/shared-home')
expect(install).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,26 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
type RetainedCodexHookService = {
install: (runtimeHomePath: string) => AgentHookInstallStatus
refreshRuntimeUserHooks: (runtimeHomePath: string) => AgentHookInstallStatus
}
export function reconcileRetainedCodexHookHomes(args: {
hookService: RetainedCodexHookService
hooksEnabled: boolean
runtimeHomePaths: readonly string[]
}): void {
for (const runtimeHomePath of args.runtimeHomePaths) {
try {
const status = args.hooksEnabled
? args.hookService.install(runtimeHomePath)
: args.hookService.refreshRuntimeUserHooks(runtimeHomePath)
if (status.state === 'error') {
console.warn('[codex-hook-service] failed to reconcile retained Codex home', status.detail)
}
} catch (error) {
// Why: a retained home repair is best-effort; daemon availability must not depend on a writable Codex config.
console.warn('[codex-hook-service] failed to reconcile retained Codex home', error)
}
}
}
+13 -3
View File
@@ -216,6 +216,7 @@ import {
} from './codex-accounts/runtime-selection'
import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection'
import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service'
import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state'
import {
ensureRealHomeCodexHookState,
isRealHomeCodexHookLaneUsable
@@ -917,11 +918,20 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
await initDaemonPtyProvider(signal, {
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
})
if (codexRuntimeHome?.isHostSystemDefaultRealHome() && hasRecordedLegacySharedCodexPane()) {
const livePtyIds = await listLiveDaemonPtyIds()
if (livePtyIds) {
// Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes.
const livePtyIds = await listLiveDaemonPtyIds()
if (livePtyIds && codexRuntimeHome) {
if (codexRuntimeHome.isHostSystemDefaultRealHome() && hasRecordedLegacySharedCodexPane()) {
reconcileCodexPaneAccountsWithLivePtys(livePtyIds)
}
const settings = store?.getSettings()
reconcileRetainedCodexHookHomes({
hookService: codexHookService,
hooksEnabled:
isAgentStatusHooksEnabled(settings) &&
settings?.disabledTuiAgents.includes('codex') !== true,
runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds)
})
}
// Why: retained shells can invoke Codex immediately after the startup gate.
codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()
@@ -105,12 +105,18 @@ describe('startup ordering', () => {
const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex)
const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()'
const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex)
const hookReconciliationIndex = source.indexOf(
'reconcileRetainedCodexHookHomes({',
inventoryIndex
)
const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex)
const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex)
expect(daemonInitIndex).toBeGreaterThanOrEqual(0)
expect(routeGateIndex).toBeGreaterThan(daemonInitIndex)
expect(inventoryIndex).toBeGreaterThan(routeGateIndex)
expect(inventoryIndex).toBeGreaterThan(daemonInitIndex)
expect(routeGateIndex).toBeGreaterThan(inventoryIndex)
expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex)
expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex)
expect(reconciliationIndex).toBeGreaterThan(inventoryIndex)
expect(serveIndex).toBeGreaterThan(reconciliationIndex)
expect(desktopIndex).toBeGreaterThan(serveIndex)
@@ -0,0 +1,83 @@
# Restored-shell Codex hook-trust validation
## Root cause
Restored daemon shells became interactive after the daemon and hook server started, while managed Codex hook reconciliation remained an unawaited background task. A retained shell could therefore launch manually typed `codex` against its launch-time managed home before Orca refreshed that home's hook definitions and trusted hashes. The background repair then made persisted state and later `hooks/list` checks look healthy.
The retained shell's launch-time home is independent of the currently selected Codex routing lane. Startup now inventories authoritative live daemon PTYs on every retained-shell startup, resolves only registered Orca-managed host homes, and reconciles those homes before the startup gate completes.
## Security boundary
- Shared and legacy managed homes resolve only to Orca's private runtime home.
- Per-account homes pass existing Orca ownership validation.
- Real-home, WSL, SSH/non-host, unknown, missing-account, and unowned paths are skipped.
- Enabled settings reinstall/repair only Orca-managed hooks.
- Disabled settings remove only Orca-managed entries while preserving user hooks.
## Electron/CDP reproduction
Build:
```bash
pnpm run build:electron-vite
```
Isolated launch (the shortened profile path avoids the Unix-socket path limit):
```bash
/usr/bin/env -u ELECTRON_RUN_AS_NODE -u CODEX_HOME -u ORCA_CODEX_HOME \
PATH='/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' \
HOME='/tmp/orca-rce-060679/home' \
USERPROFILE='/tmp/orca-rce-060679/home' \
ORCA_E2E_USER_DATA_DIR='/tmp/orca-rce-060679' \
ORCA_E2E_HOME_DIR='/tmp/orca-rce-060679/home' \
ORCA_E2E_HEADFUL=1 NODE_ENV=development \
'<worktree>/node_modules/.pnpm/electron@43.1.0/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron' \
out/main/index.js --remote-debugging-port=9460
```
Validation used `agent-browser --cdp 9460` only. A real floating-shell PTY was created, the Electron main process was terminated without stopping the daemon, its pane registry retained the managed shared-home route, and an Orca lifecycle-hook `trusted_hash` was made stale. A deterministic Codex fixture was added only after startup so it reported the same review/ready state from the managed config without allowing generic startup CLI detection to hide the race.
Local capture filenames (uploaded to the PR as GitHub attachments; intentionally not committed):
- Before: `restored-codex-hook-review-before.png`
- After: `restored-codex-hook-trusted-after.png`
- Fresh pane: `fresh-codex-hook-trusted.png`
- Disabled hooks after retained restart: `restored-codex-hooks-disabled.png`
The pre-fix restart retained the stale hash and showed `Hooks need review`. The fixed restart repaired it before `awaitFirstWindowStartupServices()` resolved and the same retained PTY showed trusted/ready. A fresh pane reached normal Codex onboarding without a hook-review prompt. With `agentStatusHooksEnabled: false`, a retained restart left `hooks.json` as `{ "hooks": {} }` and `config.toml` with an empty `[hooks.state]`.
## Automated validation
```bash
pnpm exec vitest run \
src/main/codex/retained-codex-hook-state.test.ts \
src/main/startup/desktop-startup-ordering.test.ts \
src/main/codex-accounts/runtime-home-service.test.ts
pnpm run typecheck:node
pnpm exec oxfmt --check \
src/main/index.ts \
src/main/codex-accounts/runtime-home-service.ts \
src/main/codex-accounts/runtime-home-service.test.ts \
src/main/codex/retained-codex-hook-state.ts \
src/main/codex/retained-codex-hook-state.test.ts \
src/main/startup/desktop-startup-ordering.test.ts
pnpm exec oxlint \
src/main/index.ts \
src/main/codex-accounts/runtime-home-service.ts \
src/main/codex-accounts/runtime-home-service.test.ts \
src/main/codex/retained-codex-hook-state.ts \
src/main/codex/retained-codex-hook-state.test.ts \
src/main/startup/desktop-startup-ordering.test.ts
git diff --check
```
Results: 113 focused tests passed; node typecheck, formatting, direct oxlint, diff check, and Electron build passed. The aggregate changed-code script was not used because Node 26's unsupported-engine warning breaks its JSON parser; direct oxlint covered all changed source files.
## Remaining risk
The repair deliberately covers only host-side Orca-managed runtime homes recorded for live PTYs. Folder workspaces use the same verified host-home path; real-home, WSL, SSH, and arbitrary user or repository hook paths are not auto-trusted, and their existing launch-specific flows remain unchanged.