fix(codex): bound user hook trust rebase retries per host

The rebase lane ran a codex app-server session on every launch prep while a
host was stuck (CLI without app-server support, or keys hooks/list cannot
match). Gate the transaction on the shared capability cache and add the same
5-minute transient cooldown the grant lane uses, so sweep and legacy-cleanup
retries cost plain fs reads instead of a codex session per pane spawn.
This commit is contained in:
Brennan Benson
2026-07-16 01:34:40 -07:00
parent f5cf6ea53d
commit a04fa8259b
2 changed files with 115 additions and 6 deletions
@@ -8,6 +8,8 @@ import type { CodexUserHookTrustRebaseRequest } from './codex-user-hook-trust-re
const resolveCodexCommandMock = vi.hoisted(() => vi.fn(() => process.execPath))
vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock }))
import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache'
import { CodexAppServerUnsupportedError } from './codex-app-server-session'
import {
_internals,
getMovedCodexUserHookTrust,
@@ -26,6 +28,8 @@ beforeEach(() => {
afterEach(() => {
_internals.setSessionRunnerSync(null)
_internals.resetRetryState()
codexAppServerCapabilityCache.clear()
rmSync(root, { recursive: true, force: true })
})
@@ -142,6 +146,69 @@ describe('real-home user hook trust rebasing', () => {
}
})
it('marks the host unsupported and skips further codex sessions', () => {
const orca = command('orca-hook')
const user = command('user-hook')
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
const after = { Stop: [{ hooks: [user] }] }
writeFileSync(configPath, '# config\n')
let sessions = 0
_internals.setSessionRunnerSync(() => {
sessions += 1
throw new CodexAppServerUnsupportedError('unrecognized subcommand app-server')
})
const args = {
sourcePath: hooksPath,
runtimeHomePath: root,
tomlPath: configPath,
beforeHooks: before,
afterHooks: after,
writeHooks: () => {
throw new Error('write must not run')
},
restoreHooks: () => {
throw new Error('restore must not run')
}
}
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('unrecognized subcommand')
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('marked unsupported')
expect(sessions).toBe(1)
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(false)
})
it('cools down after a transient session failure instead of retrying every launch prep', () => {
const orca = command('orca-hook')
const user = command('user-hook')
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
const after = { Stop: [{ hooks: [user] }] }
writeFileSync(configPath, '# config\n')
let sessions = 0
_internals.setSessionRunnerSync(() => {
sessions += 1
throw new Error('pre-mutation hooks/list reported 0 of 1 moved user hooks')
})
const args = {
sourcePath: hooksPath,
runtimeHomePath: root,
tomlPath: configPath,
beforeHooks: before,
afterHooks: after,
writeHooks: () => {
throw new Error('write must not run')
},
restoreHooks: () => {
throw new Error('restore must not run')
}
}
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('0 of 1 moved user hooks')
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('cooling down')
expect(sessions).toBe(1)
// Why: a transient failure must not poison the shared capability signal.
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true)
})
it('restores both files byte-exactly when post-mutation repair fails', () => {
const orca = command('orca-hook')
const user = command('user-hook')
+48 -6
View File
@@ -1,5 +1,12 @@
import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils'
import {
codexAppServerCapabilityCache,
getCodexAppServerHostKey,
type CodexAppServerHostKey
} from './codex-app-server-capability-cache'
import { runCodexUserHookTrustRebaseSessionSync } from './codex-app-server-grant-bridge'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } from './codex-hook-trust-grant'
import { createCodexHookTrustEntry } from './codex-hook-identity'
import { resolveCodexTrustGrantHost } from './codex-trust-grant-host'
import {
@@ -22,6 +29,19 @@ type RebaseSessionRunnerSync = (
let runSessionSync: RebaseSessionRunnerSync = runCodexUserHookTrustRebaseSessionSync
// Why: launch prep re-runs the callers on every pane spawn. A host stuck
// without a usable rebase lane (old CLI, unmatched keys) must not pay a codex
// session each time — bound retries like the grant lane does.
const rebaseRetryAfterByHost = new Map<CodexAppServerHostKey, number>()
function rememberRebaseSessionFailure(hostKey: CodexAppServerHostKey, error: unknown): void {
if (isCodexAppServerUnsupportedError(error)) {
codexAppServerCapabilityCache.rememberUnsupported(hostKey)
return
}
rebaseRetryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS)
}
function entriesByHookObject(
sourcePath: string,
hooksByEvent: HooksByEvent
@@ -115,6 +135,17 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
args.writeHooks()
return null
}
const hostKey = getCodexAppServerHostKey({ kind: 'native' })
if (!codexAppServerCapabilityCache.shouldTry(hostKey)) {
throw new Error('codex app-server is marked unsupported on this host; trust rebase skipped')
}
const retryAfterMs = rebaseRetryAfterByHost.get(hostKey)
if (retryAfterMs !== undefined) {
if (Date.now() < retryAfterMs) {
throw new Error('Codex user hook trust rebase is cooling down after a recent failure')
}
rebaseRetryAfterByHost.delete(hostKey)
}
const snapshot = captureCodexTrustConfig(args.tomlPath)
const baseRequest = resolveCodexTrustGrantHost({ kind: 'native' }).buildRequest({
@@ -124,12 +155,19 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
})
// Why: inspection happens before the write, so an unavailable RPC aborts
// without shifting a user's positional trust key.
const inspected: CodexUserHookTrustRebaseResult = runSessionSync({
operation: 'inspect-user-hook-trust',
invocation: baseRequest.invocation,
hooksListCwd: baseRequest.hooksListCwd,
moves
})
let inspected: CodexUserHookTrustRebaseResult
try {
inspected = runSessionSync({
operation: 'inspect-user-hook-trust',
invocation: baseRequest.invocation,
hooksListCwd: baseRequest.hooksListCwd,
moves
})
} catch (error) {
rememberRebaseSessionFailure(hostKey, error)
throw error
}
codexAppServerCapabilityCache.rememberSupported(hostKey)
if (inspected.outcome !== 'inspected') {
throw new Error('Unexpected Codex user hook trust inspection result')
}
@@ -149,6 +187,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
}
return snapshot
} catch (error) {
rememberRebaseSessionFailure(hostKey, error)
if (hooksWritten) {
return rollbackMutation(args.restoreHooks, args.tomlPath, snapshot, error)
}
@@ -159,5 +198,8 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
export const _internals = {
setSessionRunnerSync(runner: RebaseSessionRunnerSync | null): void {
runSessionSync = runner ?? runCodexUserHookTrustRebaseSessionSync
},
resetRetryState(): void {
rebaseRetryAfterByHost.clear()
}
}