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];