diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a4df2dab4a9..c2dff7a1c0d 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,7 @@ "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", "../src/main/codex/codex-app-server-capability-cache.ts", + "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", "../src/main/codex/codex-app-server-grant-bridge.ts", "../src/main/codex/codex-app-server-grant-envelope.ts", diff --git a/src/main/codex/codex-app-server-capability-signal.ts b/src/main/codex/codex-app-server-capability-signal.ts new file mode 100644 index 00000000000..a35518f0ded --- /dev/null +++ b/src/main/codex/codex-app-server-capability-signal.ts @@ -0,0 +1,7 @@ +// Why: only an argv parse failure that names app-server proves the RPC surface +// is absent; unrelated stderr must remain transient rather than poison the cache. +export function stderrIndicatesMissingAppServer(stderrTail: string): boolean { + return /^(?=.*\bapp-server\b).*(?:unrecognized subcommand|unexpected argument|invalid subcommand)/im.test( + stderrTail + ) +} diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 5bb01ae5614..804cd50ae82 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -49,6 +49,11 @@ if (config.scenario === 'no-subcommand') { process.stderr.write("error: unrecognized subcommand 'app-server'\\n") process.exit(2) } +if (config.scenario === 'transient-stderr') { + process.stderr.write('config loader: unexpected argument in value\\n') + process.exit(1) +} +if (config.scenario === 'silent-exit-0') process.exit(0) process.stdin.setEncoding('utf8') process.stdin.on('data', (chunk) => { buffer += chunk @@ -278,6 +283,32 @@ describe('runCodexHookTrustGrantSession', () => { expect(isCodexAppServerUnsupportedError(error)).toBe(true) }) + it('does not classify unrelated stderr as a missing app-server capability', async () => { + const { request } = createStubRequest({ + scenario: 'transient-stderr', + hooks: [], + expectedTrustKeys: ['k'], + managedCommand: MANAGED_COMMAND + }) + + const error = await runCodexHookTrustGrantSession(request).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(Error) + expect(isCodexAppServerUnsupportedError(error)).toBe(false) + }) + + it('treats a silent exit zero before initialize as transient', async () => { + const { request } = createStubRequest({ + scenario: 'silent-exit-0', + hooks: [], + expectedTrustKeys: ['k'], + managedCommand: MANAGED_COMMAND + }) + + const error = await runCodexHookTrustGrantSession(request).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(Error) + expect(isCodexAppServerUnsupportedError(error)).toBe(false) + }) + it('kills a hung server at the session deadline', async () => { const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] const { request, pidFile } = createStubRequest({ diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index ab5dced425c..4e58c8348cc 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { normalizeHookTrustKeyForLookup } from './config-toml-trust' import { waitForProcessExitUntil } from './codex-process-exit-deadline' +import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' // Why: Codex gates hooks on a `trusted_hash` it computes from a private // canonical-JSON identity. Orca used to replicate that algorithm @@ -94,12 +95,6 @@ function isMethodNotFoundError(error: { code?: number; message?: string }): bool return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') } -// Why: a CLI predating the app-server subcommand fails argv parsing before -// speaking any JSON-RPC; that shape is a capability signal, not a transient. -function stderrIndicatesMissingAppServer(stderrTail: string): boolean { - return /unrecognized subcommand|unexpected argument|invalid subcommand/i.test(stderrTail) -} - function collectHookListings(result: unknown): CodexHookListing[] { const data = result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data)