From afe3eebceabd28b83014efbdeae80ff14629d666 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 3 Jul 2026 05:01:17 -0700 Subject: [PATCH] [failproofai-473] Fix Codex hooks.json: drop invalid top-level `version` field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI v0.142+ refuses to start a session when ~/.codex/hooks.json carries a top-level `version` field. Codex's HooksFile config struct is #[serde(deny_unknown_fields)] and only permits `hooks` (plus an optional `description`), so the `version: 1` marker our Codex integration wrote — an unverified assumption — produced: failed to parse hooks config: unknown field `version`, expected `hooks` at line 2 column 11 The `codex` integration now writes only `hooks`, and strips any leftover `version` on the next install/uninstall so a previously-broken config self-heals. Also correct the hook `timeout` from 60000 to 60: Codex reads `timeout` in seconds (its `timeout_sec` field, default 600), so the old value meant ~16.7h instead of 60s. The `__failproofai_hook__` marker is kept — Codex's internally-tagged HookHandlerConfig::Command tolerates unknown fields, and it is the primary install-detection key shared with every other integration. Scope is Codex-only; Copilot and Cursor legitimately carry `version: 1` in their own schemas. Regenerated the dogfood .codex/hooks.json to match. Verified against real Codex CLI v0.142.5: the old config reproduces the exact parse error, the fixed config parses cleanly and runs the hooks, and the marker is tolerated. Unit (1907), e2e (298), typecheck, lint, build, and Docker clean-install smoke all green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X6cq77Ku4Zg7Dx6z57hNzr --- .codex/hooks.json | 13 +++---- CHANGELOG.md | 5 +++ CLAUDE.md | 15 ++++++++ .../e2e/hooks/codex-integration.e2e.test.ts | 16 +++++++- __tests__/hooks/integrations.test.ts | 36 ++++++++++++++++-- src/hooks/integrations.ts | 37 +++++++++++++------ src/hooks/types.ts | 4 +- 7 files changed, 100 insertions(+), 26 deletions(-) diff --git a/.codex/hooks.json b/.codex/hooks.json index 8314f336..20bc2fc8 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -1,5 +1,4 @@ { - "version": 1, "hooks": { "SessionStart": [ { @@ -7,7 +6,7 @@ { "type": "command", "command": "bun bin/failproofai.mjs --hook SessionStart --cli codex", - "timeout": 60000, + "timeout": 60, "__failproofai_hook__": true } ] @@ -19,7 +18,7 @@ { "type": "command", "command": "bun bin/failproofai.mjs --hook PreToolUse --cli codex", - "timeout": 60000, + "timeout": 60, "__failproofai_hook__": true } ] @@ -31,7 +30,7 @@ { "type": "command", "command": "bun bin/failproofai.mjs --hook PermissionRequest --cli codex", - "timeout": 60000, + "timeout": 60, "__failproofai_hook__": true } ] @@ -43,7 +42,7 @@ { "type": "command", "command": "bun bin/failproofai.mjs --hook PostToolUse --cli codex", - "timeout": 60000, + "timeout": 60, "__failproofai_hook__": true } ] @@ -55,7 +54,7 @@ { "type": "command", "command": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli codex", - "timeout": 60000, + "timeout": 60, "__failproofai_hook__": true } ] @@ -67,7 +66,7 @@ { "type": "command", "command": "bun bin/failproofai.mjs --hook Stop --cli codex", - "timeout": 60000, + "timeout": 60, "__failproofai_hook__": true } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index b12cba8c..5dbcc5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.12-beta.0 — 2026-07-03 + +### Fixes +- Stop writing an invalid top-level `version` field into OpenAI Codex `hooks.json`. Codex's `HooksFile` config struct is `#[serde(deny_unknown_fields)]`, so the `version: 1` marker made Codex v0.142+ refuse to start every session with ``unknown field `version`, expected `hooks` ``. failproofai now writes only `hooks`, strips any leftover `version` on the next install/uninstall so previously-broken configs self-heal, and corrects the hook `timeout` from `60000` to `60` — Codex reads `timeout` in seconds (`timeout_sec`), so the old value meant ~16.7h instead of 60s (#473). + ## 0.0.11 — 2026-06-26 ### Breaking diff --git a/CLAUDE.md b/CLAUDE.md index 4bcb7d96..f9e4c78b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,21 @@ cwd (where `codex` was launched), so we use a relative `bun bin/failproofai.mjs` path. If Codex ever changes that behavior and the hook fails to find the binary, switch to an absolute path. +**Schema (verified against Codex CLI v0.142.5 / `codex-rs/config/src/hook_config.rs`):** +Codex's top-level `HooksFile` struct is `#[serde(deny_unknown_fields)]` and allows +**only** `hooks` (plus an optional `description`), so the config must **not** carry a +top-level `version` field — an earlier build wrote `version: 1` (an unverified +assumption), which made Codex v0.142+ refuse to start every session with +``unknown field `version`, expected `hooks` ``. The `codex` integration now writes +only `hooks` and strips any leftover `version` on the next install/uninstall so a +previously-broken config self-heals. Two more schema notes: `timeout` is in **seconds** +(Codex's `timeout_sec`, default 600), not the milliseconds Claude/Cursor use — hook +entries use `timeout: 60`. And the `__failproofai_hook__` marker is safe to keep: +Codex's `HookHandlerConfig::Command` is an internally-tagged enum (`#[serde(tag = +"type")]`) with **no** `deny_unknown_fields`, so the extra key is tolerated, not +rejected. **Do not** add a `version` field to `.codex/hooks.json` (unlike Copilot and +Cursor, whose own schemas legitimately require one). + For production users (outside this repo), the recommended Codex install is: ```bash failproofai policies --install --cli codex --scope project diff --git a/__tests__/e2e/hooks/codex-integration.e2e.test.ts b/__tests__/e2e/hooks/codex-integration.e2e.test.ts index 85204d90..96a68e20 100644 --- a/__tests__/e2e/hooks/codex-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/codex-integration.e2e.test.ts @@ -177,8 +177,15 @@ describe("E2E: Codex integration — install/uninstall", () => { const hooksPath = resolve(env.cwd, ".codex", "hooks.json"); expect(existsSync(hooksPath)).toBe(true); const settings = JSON.parse(readFileSync(hooksPath, "utf-8")) as Record; - expect(settings.version).toBe(1); - const hooks = settings.hooks as Record; + // Codex's HooksFile is #[serde(deny_unknown_fields)] — the file must NOT + // carry a top-level `version` (it would make Codex refuse to start with + // "unknown field `version`, expected `hooks`"). Fresh install → only `hooks`. + expect(settings.version).toBeUndefined(); + expect(Object.keys(settings)).toEqual(["hooks"]); + const hooks = settings.hooks as Record< + string, + Array<{ hooks: Array> }> + >; // Codex stores under PascalCase keys expect(hooks.PreToolUse).toBeDefined(); expect(hooks.PostToolUse).toBeDefined(); @@ -188,6 +195,11 @@ describe("E2E: Codex integration — install/uninstall", () => { expect(hooks.UserPromptSubmit).toBeDefined(); // Snake-case keys should not be present expect(hooks.pre_tool_use).toBeUndefined(); + // Each entry matches Codex's HookHandlerConfig::Command shape: type + // "command" + `timeout` in SECONDS (60, not 60_000 ms). + const entry = hooks.PreToolUse[0].hooks[0]; + expect(entry.type).toBe("command"); + expect(entry.timeout).toBe(60); } finally { env.cleanup(); } diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index 4bf98d37..ad261d1a 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -202,6 +202,9 @@ describe("OpenAI Codex integration", () => { expect(entry.command).toContain("--cli codex"); expect(entry.command).toContain("--hook pre_tool_use"); expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); + // Codex reads `timeout` in SECONDS (its `timeout_sec` field), not the + // milliseconds Claude uses — 60_000 would be ~16.7h. + expect(entry.timeout).toBe(60); }); it("project scope uses npx -y failproofai", () => { @@ -220,16 +223,41 @@ describe("OpenAI Codex integration", () => { // Snake-case keys must NOT be present (Codex stores under Pascal) expect(hooks[snake]).toBeUndefined(); } - // Settings file carries version: 1 - expect(settings.version).toBe(1); + // Codex's HooksFile is #[serde(deny_unknown_fields)] — a top-level `version` + // makes Codex refuse to start, so we must NOT write one. + expect(settings.version).toBeUndefined(); }); - it("readSettings backfills version: 1 on existing files without it", () => { + it("readSettings does not inject a top-level version", () => { const settingsPath = resolve(tempDir, ".codex", "hooks.json"); mkdirSync(resolve(tempDir, ".codex"), { recursive: true }); writeFileSync(settingsPath, JSON.stringify({ hooks: {} })); const read = codex.readSettings(settingsPath); - expect(read.version).toBe(1); + expect(read.version).toBeUndefined(); + }); + + it("writeHookEntries strips a legacy top-level version (migration)", () => { + // A hooks.json written by an older failproofai build carried version: 1, + // which Codex v0.142+ rejects. Re-installing must remove it, not just stop + // adding it, so a previously-broken file self-heals. + const settings: Record = { version: 1, hooks: {} }; + codex.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + expect(settings.version).toBeUndefined(); + expect(settings.hooks).toBeDefined(); + }); + + it("removeHooksFromFile strips a legacy top-level version (migration)", () => { + const settingsPath = resolve(tempDir, ".codex", "hooks.json"); + mkdirSync(resolve(tempDir, ".codex"), { recursive: true }); + const settings: Record = {}; + codex.writeHookEntries(settings, "/usr/bin/failproofai", "project"); + (settings as { version?: number }).version = 1; // simulate old build's field + codex.writeSettings(settingsPath, settings); + + codex.removeHooksFromFile(settingsPath); + const after = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; + expect(after.version).toBeUndefined(); + expect(after.hooks).toBeUndefined(); }); it("re-running writeHookEntries is idempotent", () => { diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index 7a9a9817..0f67b293 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -226,16 +226,20 @@ export const claudeCode: Integration = { // ── OpenAI Codex integration ──────────────────────────────────────────────── // -// Codex's hook protocol is Claude-compatible by design (see the parity matrix -// in plans/great-in-failproofai-i-vectorized-treasure.md). The only material +// Codex's hook protocol is Claude-compatible by design. The only material // differences are: // • Settings paths: ~/.codex/hooks.json (user) and /.codex/hooks.json (project) // • Stdin event names arrive snake_case (pre_tool_use); we canonicalize to PascalCase before policy lookup // • No "local" scope -// • Settings file carries a top-level "version": 1 marker +// • Schema: the top-level object allows ONLY `hooks` (plus an optional +// `description`). Codex's `HooksFile` struct is #[serde(deny_unknown_fields)], +// so a stray `version` key makes Codex refuse to start the session with +// "unknown field `version`, expected `hooks`". We must not write one — and we +// strip any left over by older builds on the next install/uninstall. +// • `timeout` is in SECONDS (Codex's `timeout_sec`, default 600), not the +// milliseconds Claude/Cursor use. interface CodexSettingsFile { - version?: number; hooks?: Record; [key: string]: unknown; } @@ -261,9 +265,7 @@ export const codex: Integration = { }, readSettings(settingsPath) { - const raw = readJsonFile(settingsPath); - if (raw.version === undefined) raw.version = 1; - return raw; + return readJsonFile(settingsPath); }, writeSettings(settingsPath, settings) { @@ -281,7 +283,9 @@ export const codex: Integration = { return { type: "command", command, - timeout: 60_000, + // Codex reads `timeout` in SECONDS (its `timeout_sec` field, default 600), + // not the milliseconds Claude uses. 60_000 would mean ~16.7h, not 60s. + timeout: 60, [FAILPROOFAI_HOOK_MARKER]: true, }; }, @@ -290,7 +294,10 @@ export const codex: Integration = { writeHookEntries(settings, binaryPath, scope) { const s = settings as CodexSettingsFile; - if (s.version === undefined) s.version = 1; + // Migration: older builds wrote a top-level `version: 1` that Codex now + // rejects (deny_unknown_fields). Strip it so re-installing self-heals a + // previously broken hooks.json. + delete (s as Record).version; if (!s.hooks) s.hooks = {}; for (const eventType of CODEX_HOOK_EVENT_TYPES) { @@ -315,7 +322,14 @@ export const codex: Integration = { removeHooksFromFile(settingsPath) { const settings = this.readSettings(settingsPath) as CodexSettingsFile; - if (!settings.hooks) return 0; + // Migration: drop any legacy top-level `version` (Codex rejects it) so an + // uninstall also leaves a parseable file behind. + const hadVersion = (settings as Record).version !== undefined; + delete (settings as Record).version; + if (!settings.hooks) { + if (hadVersion) this.writeSettings(settingsPath, settings as Record); + return 0; + } let removed = 0; for (const eventType of Object.keys(settings.hooks)) { @@ -374,7 +388,8 @@ export const codex: Integration = { // Hook entries differ from Claude/Codex: each entry uses OS-keyed `bash` and // `powershell` command fields and a `timeoutSec` (seconds) instead of Claude's // single `command` field with `timeout` (milliseconds). Top-level wrapper is -// `{ "version": 1, "hooks": {...} }`, mirroring Codex. +// `{ "version": 1, "hooks": {...} }` (Copilot's own VS Code-compatible schema; +// unlike Codex, whose HooksFile rejects a top-level `version`). interface CopilotHookEntry { type: "command"; diff --git a/src/hooks/types.ts b/src/hooks/types.ts index cc71009b..e31e9475 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -79,7 +79,7 @@ export const CODEX_TOOL_MAP: Record = { // Settings paths: // user → ~/.copilot/hooks/failproofai.json // project → /.github/hooks/failproofai.json (also where the cloud agent reads) -// Settings file carries `version: 1` like Codex's hooks.json. +// Settings file carries a top-level `version: 1` (Copilot's own VS Code-compatible schema). export const COPILOT_HOOK_SCOPES = ["user", "project"] as const; export type CopilotHookScope = (typeof COPILOT_HOOK_SCOPES)[number]; @@ -159,7 +159,7 @@ export const COPILOT_TOOL_MAP: Record = { // Settings paths: // user → ~/.cursor/hooks.json // project → /.cursor/hooks.json -// Settings file carries `version: 1` like Codex/Copilot. +// Settings file carries a top-level `version: 1` (Cursor's own hooks.json schema). export const CURSOR_HOOK_SCOPES = ["user", "project"] as const; export type CursorHookScope = (typeof CURSOR_HOOK_SCOPES)[number];