From 2fe1a2ed45fb71293c3b286aad404b8139d5b1ba Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 8 May 2026 19:09:38 +0800 Subject: [PATCH] fix(cli): trust injected Codex session hook Codex now requires hook trust before non-managed hooks can run. HAPI relies on the runtime-injected SessionStart hook to receive the Codex thread/session id, so leaving that hook untrusted breaks local Codex startup without manual /hooks review.\n\nGenerate the same trusted_hash Codex derives for the injected SessionStart command and pass it through the runtime hooks.state override. The trust is scoped to the synthetic session-flags hook key and the exact generated command, so user, project, and plugin hooks still go through Codex review normally.\n\nAlso cover the generated config args so future changes keep both the hook declaration and its trust state together.\n\nValidation:\n- bun test cli/src/codex/utils/codexMcpConfig.test.ts\n- bun typecheck --- cli/src/codex/utils/codexMcpConfig.test.ts | 4 ++ cli/src/codex/utils/codexMcpConfig.ts | 47 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/cli/src/codex/utils/codexMcpConfig.test.ts b/cli/src/codex/utils/codexMcpConfig.test.ts index e91d5f2f..6f99ac37 100644 --- a/cli/src/codex/utils/codexMcpConfig.test.ts +++ b/cli/src/codex/utils/codexMcpConfig.test.ts @@ -103,6 +103,10 @@ describe('codexMcpConfig', () => { 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[2]).toBe('-c'); + expect(args[3]).toContain('hooks.state={'); + expect(args[3]).toContain(':session_start:0:0'); + expect(args[3]).toContain('trusted_hash="sha256:'); }); }); }); diff --git a/cli/src/codex/utils/codexMcpConfig.ts b/cli/src/codex/utils/codexMcpConfig.ts index 5228703f..bdf17c1b 100644 --- a/cli/src/codex/utils/codexMcpConfig.ts +++ b/cli/src/codex/utils/codexMcpConfig.ts @@ -7,6 +7,7 @@ * configuration and developer instructions at runtime. */ +import { createHash } from 'node:crypto'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; /** @@ -51,6 +52,47 @@ function shellJoin(parts: string[]): string { return parts.map(shellQuote).join(' '); } +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalJson); + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entryValue]) => [key, canonicalJson(entryValue)]); + return Object.fromEntries(entries); + } + + return value; +} + +function versionForTomlLikeValue(value: unknown): string { + const serialized = JSON.stringify(canonicalJson(value)); + return `sha256:${createHash('sha256').update(serialized).digest('hex')}`; +} + +function buildSessionStartHookTrustedHash(command: string): string { + return versionForTomlLikeValue({ + event_name: 'session_start', + hooks: [ + { + async: false, + command, + timeout: 600, + type: 'command' + } + ] + }); +} + +function sessionFlagsHookStateKey(): string { + const sourcePath = process.platform === 'win32' + ? 'C:\\\\config.toml' + : '//config.toml'; + return `${sourcePath}:session_start:0:0`; +} + export function buildSessionStartHookConfigArgs(port: number, token: string): string[] { const { command, args } = getHappyCliCommand([ 'hook-forwarder', @@ -62,7 +104,10 @@ export function buildSessionStartHookConfigArgs(port: number, token: string): st const hookCommand = shellJoin([command, ...args]); const escapedHookCommand = escapeTomlString(hookCommand); const hookConfig = `hooks.SessionStart=[{ hooks = [{ type = "command", command = "${escapedHookCommand}" }] }]`; - return ['-c', hookConfig]; + const trustedHash = buildSessionStartHookTrustedHash(hookCommand); + const escapedStateKey = escapeTomlString(sessionFlagsHookStateKey()); + const hookState = `hooks.state={"${escapedStateKey}"={trusted_hash="${trustedHash}"}}`; + return ['-c', hookConfig, '-c', hookState]; } /**