diff --git a/src/main/codex/codex-user-hook-trust-rebase.test.ts b/src/main/codex/codex-user-hook-trust-rebase.test.ts index 411af224969..6e409487d6d 100644 --- a/src/main/codex/codex-user-hook-trust-rebase.test.ts +++ b/src/main/codex/codex-user-hook-trust-rebase.test.ts @@ -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') diff --git a/src/main/codex/codex-user-hook-trust-rebase.ts b/src/main/codex/codex-user-hook-trust-rebase.ts index 5da7fa1d148..17ea73ab52a 100644 --- a/src/main/codex/codex-user-hook-trust-rebase.ts +++ b/src/main/codex/codex-user-hook-trust-rebase.ts @@ -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() + +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() } }