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..65155732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.12-beta.0 — 2026-07-09 + +### Fixes +- Codex hooks: drop the invalid top-level `version` field from `.codex/hooks.json` (Codex CLI v0.142+ rejects it with `unknown field 'version'`, refusing to start any session), and strip any leftover `version` on the next install/uninstall so previously-broken configs self-heal. Also correct the Codex `timeout` unit from `60000` to `60` — Codex reads `timeout` in seconds (its `timeout_sec` field), so the old value meant ~16.7h instead of 60s. Copilot and Cursor legitimately carry `version: 1` in their own schemas and are untouched. + ## 0.0.11 — 2026-06-26 ### Breaking diff --git a/__tests__/e2e/hooks/codex-integration.e2e.test.ts b/__tests__/e2e/hooks/codex-integration.e2e.test.ts index 85204d90..f3b573dd 100644 --- a/__tests__/e2e/hooks/codex-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/codex-integration.e2e.test.ts @@ -177,7 +177,7 @@ 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); + expect(settings.version).toBeUndefined(); const hooks = settings.hooks as Record; // Codex stores under PascalCase keys expect(hooks.PreToolUse).toBeDefined(); diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index 4bf98d37..b955ace8 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -204,6 +204,12 @@ describe("OpenAI Codex integration", () => { expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); }); + it("buildHookEntry sets timeout in SECONDS (60), not milliseconds", () => { + // Codex reads `timeout` as seconds (its `timeout_sec` field); 60000 would be ~16.7h. + const entry = codex.buildHookEntry("/usr/bin/failproofai", "pre_tool_use", "user"); + expect(entry.timeout).toBe(60); + }); + it("project scope uses npx -y failproofai", () => { const entry = codex.buildHookEntry("/usr/bin/failproofai", "pre_tool_use", "project"); expect(entry.command).toBe("npx -y failproofai --hook pre_tool_use --cli codex"); @@ -220,16 +226,23 @@ 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); + // Settings file does NOT carry version (Codex strictly expects only `hooks`) + expect(settings.version).toBeUndefined(); }); - it("readSettings backfills version: 1 on existing files without it", () => { + it("writeHookEntries removes version on existing files if present", () => { + const settings: Record = { version: 1, hooks: {} }; + codex.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + expect(settings.version).toBeUndefined(); + }); + + it("removeHooksFromFile removes version on existing files even if no failproofai hooks are present", () => { 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); + writeFileSync(settingsPath, JSON.stringify({ version: 1, hooks: {} })); + codex.removeHooksFromFile(settingsPath); + const read = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(read.version).toBeUndefined(); }); it("re-running writeHookEntries is idempotent", () => { diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index 7a9a9817..99638229 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -59,6 +59,14 @@ function isMarkedHook(hook: unknown): boolean { return cmd.includes("failproofai") && cmd.includes("--hook"); } +function stripLegacyVersion(settings: Record): boolean { + if ("version" in settings) { + delete settings.version; + return true; + } + return false; +} + function binaryExists(name: string): boolean { try { const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`; @@ -232,10 +240,9 @@ export const claudeCode: Integration = { // • 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 +// • Settings file does NOT carry a top-level "version" marker (Codex strictly expects only `hooks`) interface CodexSettingsFile { - version?: number; hooks?: Record; [key: string]: unknown; } @@ -261,9 +268,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) { @@ -280,8 +285,10 @@ export const codex: Integration = { : `"${binaryPath}" --hook ${eventType} --cli codex`; return { type: "command", + // Codex reads `timeout` in SECONDS (its `timeout_sec` field, default 600) — + // NOT milliseconds like Claude/Cursor/Gemini. 60 = 60s, not 60000ms (~16.7h). command, - timeout: 60_000, + timeout: 60, [FAILPROOFAI_HOOK_MARKER]: true, }; }, @@ -290,7 +297,7 @@ export const codex: Integration = { writeHookEntries(settings, binaryPath, scope) { const s = settings as CodexSettingsFile; - if (s.version === undefined) s.version = 1; + stripLegacyVersion(s as Record); if (!s.hooks) s.hooks = {}; for (const eventType of CODEX_HOOK_EVENT_TYPES) { @@ -315,7 +322,11 @@ export const codex: Integration = { removeHooksFromFile(settingsPath) { const settings = this.readSettings(settingsPath) as CodexSettingsFile; - if (!settings.hooks) return 0; + const hadVersion = stripLegacyVersion(settings as Record); + if (!settings.hooks) { + if (hadVersion) this.writeSettings(settingsPath, settings as Record); + return 0; + } let removed = 0; for (const eventType of Object.keys(settings.hooks)) { @@ -333,7 +344,9 @@ export const codex: Integration = { } if (Object.keys(settings.hooks).length === 0) delete settings.hooks; - this.writeSettings(settingsPath, settings as Record); + if (removed > 0 || hadVersion) { + this.writeSettings(settingsPath, settings as Record); + } return removed; }, diff --git a/src/hooks/types.ts b/src/hooks/types.ts index cc71009b..f9483b7e 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 `version: 1`. 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 `version: 1` like Copilot. export const CURSOR_HOOK_SCOPES = ["user", "project"] as const; export type CursorHookScope = (typeof CURSOR_HOOK_SCOPES)[number];