From 97c7412434095aa98684d9b6bd7ca889ded6c769 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 5 Aug 2026 22:22:29 +0800 Subject: [PATCH] fix(codex): forward lifecycle hooks without allow decision --- .../claude/utils/sessionHookForwarder.test.ts | 25 ++++++++++++++++ cli/src/claude/utils/sessionHookForwarder.ts | 30 ++++++++++++++++--- cli/src/codex/utils/codexMcpConfig.test.ts | 2 +- cli/src/codex/utils/codexMcpConfig.ts | 2 ++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/cli/src/claude/utils/sessionHookForwarder.test.ts b/cli/src/claude/utils/sessionHookForwarder.test.ts index 35c5fc76..14d3f149 100644 --- a/cli/src/claude/utils/sessionHookForwarder.test.ts +++ b/cli/src/claude/utils/sessionHookForwarder.test.ts @@ -212,6 +212,31 @@ describe('runSessionHookForwarder — PreToolUse routing', () => { }); }); +describe('runSessionHookForwarder — codex flavor', () => { + it('forwards PreToolUse as a lifecycle event without writing a permission decision', async () => { + let hitPath = ''; + const port = await startStub((path) => { + hitPath = path; + return { status: 200, body: 'ok' }; + }); + + const out = captureStdout(); + try { + await withStdin( + JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'exec-1' }), + () => runSessionHookForwarder([ + '--port', String(port), '--token', 'tok', '--flavor', 'codex' + ]) + ); + } finally { + out.restore(); + } + + expect(hitPath).toBe('/hook/session-start'); + expect(out.get()).toBe(''); + }); +}); + describe('runSessionHookForwarder — agy flavor', () => { it('reads the agy hook endpoint from env without embedding secrets in hooks.json', async () => { const port = await startStub(() => ({ status: 200, body: JSON.stringify({ permissionDecision: 'allow' }) })); diff --git a/cli/src/claude/utils/sessionHookForwarder.ts b/cli/src/claude/utils/sessionHookForwarder.ts index d89d3347..542c9a6d 100644 --- a/cli/src/claude/utils/sessionHookForwarder.ts +++ b/cli/src/claude/utils/sessionHookForwarder.ts @@ -138,7 +138,7 @@ function parsePort(value: string | undefined): number | null { function parseArgs(args: string[]): { port: number | null; token: string | null; - flavor: 'claude' | 'agy'; + flavor: 'claude' | 'codex' | 'agy'; event: 'pre-tool-use' | 'pre-invocation'; /** * The raw --event value as given, or undefined if the flag was omitted @@ -151,7 +151,7 @@ function parseArgs(args: string[]): { } { let port: number | null = null; let token: string | null = null; - let flavor: 'claude' | 'agy' = 'claude'; + let flavor: 'claude' | 'codex' | 'agy' = 'claude'; let eventRaw: string | undefined; let fromEnv = false; @@ -190,14 +190,14 @@ function parseArgs(args: string[]): { if (arg === '--flavor') { const next = args[i + 1]; - if (next === 'agy') flavor = 'agy'; + if (next === 'codex' || next === 'agy') flavor = next; i += 1; continue; } if (arg.startsWith('--flavor=')) { const val = arg.slice('--flavor='.length); - if (val === 'agy') flavor = 'agy'; + if (val === 'codex' || val === 'agy') flavor = val; continue; } @@ -277,6 +277,28 @@ export async function runSessionHookForwarder(args: string[]): Promise { const body = Buffer.concat(chunks); + // Codex hooks are lifecycle observers here, not permission gates. An + // empty stdout means proceed; emitting Claude's permissionDecision + // "allow" is explicitly unsupported by Codex. Route every Codex event + // through the generic lifecycle endpoint so PreToolUse reaches the + // same onSessionHook callback as PostToolUse and SessionStart. + if (flavor === 'codex') { + const response = await postHook( + port, + token, + '/hook/session-start', + body, + SESSION_HOOK_FORWARD_TIMEOUT_MS + ); + if (response.error || (response.statusCode && response.statusCode >= 400)) { + if (response.statusCode && response.statusCode >= 400) { + logError(`Hook server responded with status ${response.statusCode}`); + } + process.exitCode = 1; + } + return; + } + // agy PreInvocation: discovery-ONLY bridge, fail-OPEN. Unlike // PreToolUse (a permission gate that must fail-closed), a dead or // slow bridge here must never block or degrade the model call — the diff --git a/cli/src/codex/utils/codexMcpConfig.test.ts b/cli/src/codex/utils/codexMcpConfig.test.ts index 1513f17a..f094fffd 100644 --- a/cli/src/codex/utils/codexMcpConfig.test.ts +++ b/cli/src/codex/utils/codexMcpConfig.test.ts @@ -120,7 +120,7 @@ describe('codexMcpConfig', () => { expect(args[0]).toBe('-c'); expect(args[1]).toContain('hooks.SessionStart=['); expect(args[1]).toContain('type = "command"'); - expect(args[1]).toContain('hook-forwarder --port 4312 --token secret-token'); + expect(args[1]).toContain('hook-forwarder --flavor codex --port 4312 --token secret-token'); expect(args[2]).toBe('-c'); expect(args[3]).toContain('hooks.PreToolUse=['); expect(args[3]).toContain('matcher = "*"'); diff --git a/cli/src/codex/utils/codexMcpConfig.ts b/cli/src/codex/utils/codexMcpConfig.ts index aad4bffb..7b33db3c 100644 --- a/cli/src/codex/utils/codexMcpConfig.ts +++ b/cli/src/codex/utils/codexMcpConfig.ts @@ -105,6 +105,8 @@ function sessionFlagsHookStateKey(stateName: string): string { export function buildCodexHookConfigArgs(port: number, token: string): string[] { const { command, args } = getHappyCliCommand([ 'hook-forwarder', + '--flavor', + 'codex', '--port', String(port), '--token',