From 2d3027767b404c3c84c3ee6f0402cd0620ef6968 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 20 May 2026 23:13:50 -0400 Subject: [PATCH] fix(3426): Codex Windows hooks use .cmd shim to avoid POSIX exec fail (#3768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3426): add RED test for Codex Windows hooks .cmd shim requirement Drive buildCodexHookWindowsShimIR (typed IR) + ensureCodexHooksJsonSessionStart integration against mocked win32 platform. Counter-tests confirm darwin/linux paths remain unchanged. NOTE: Windows wall-clock verification depends on Docker matrix Windows runners. Local test exercises the generator IR shape only. Co-Authored-By: Claude Sonnet 4.6 * fix(#3426): Codex Windows hooks use .cmd shim to avoid bash.exe POSIX-exec failure Root cause: Codex on Windows runs hook commands from PowerShell/cmd. The previous hooks.json command format was `"node.exe" "script.js"`. Codex's hook-dispatch shell (Git Bash / MSYS) tried to POSIX-exec node.exe (a Windows PE binary) via execvp(), which fails with ENOEXEC — reported as `bash.exe: cannot execute binary file`. Fix: `ensureCodexHooksJsonSessionStart` now calls `buildCodexHookWindowsShimIR` on win32 to write a .cmd shim alongside the .js hook file. cmd.exe executes .cmd files natively via CreateProcess, bypassing the POSIX exec layer entirely. Non-Windows paths (darwin, linux) are unchanged: they continue to use the node-runner command. Also adds `gsd-check-update.cmd` to the codex-hooks-json managed-basename set so reconcileCodexHooksJsonSessionStart correctly replaces stale node-runner entries on reinstall. Co-Authored-By: Claude Sonnet 4.6 * chore(3426): update changeset to reference PR #3768 * fix(3426): fail-loud on Codex Windows shim-write failure instead of silently restoring broken command Replace the silent fallback to `projectManagedHookCommand` (the old `node.exe script.js` form) with an explicit warn-and-skip path. When `atomicWriteFileSync` fails to write the `.cmd` shim, the previous code silently called `reconcileCodexHooksJsonSessionStart` with the legacy node-runner command. That command triggers the exact `bash.exe: cannot execute binary file` POSIX-exec failure that #3426 exists to fix — so a successful-looking install was secretly restoring the original bug. New behaviour: - Emit `console.warn` with the failure reason and a remediation hint, matching the `${yellow}⚠${reset} Skipped …` idiom used at line 9098. - Return `{ changed: false, wrote: false }` to skip registration for this runtime entirely, so the outer caller can surface "NOT installed" instead of "installed (but broken)". Co-Authored-By: Claude Sonnet 4.6 * test(3426): typed-IR assertions on .cmd shim eol/quoting/passthrough + IR extension Extend `buildCodexHookWindowsShimIR` to expose two new typed fields on the returned IR object (CONTRIBUTING.md L558-L565 IR-first discipline): eol: { cmd: '\r\n' } — CRLF is canonical for cmd.exe .cmd files passthroughArgs: true — shim forwards all args via %* Add a new describe block (Step 2b) with three IR-level assertions: 1. `eol.cmd === '\r\n'` — prevents silent EOL regression that could break parsing on Windows versions that require CRLF. 2. `invocation.target` is the raw unquoted path (no shell-metachar leakage) — quoting happens only at render time. 3. `passthroughArgs === true` — the %* forwarding contract is explicitly typed so regressions fail before the text is rendered. All assertions operate on the typed IR returned by the generator, NOT on the rendered `.cmd` file content — text-matching is the anti-pattern CONTRIBUTING.md L522-582 prohibits. Co-Authored-By: Claude Sonnet 4.6 * test(3426): fix Windows CI failures — update hook-command filter patterns Four test files filtered for managed hooks in hooks.json using the literal string `gsd-check-update.js`. On Windows the PR-introduced .cmd shim changes the hooks.json command to `"path/gsd-check-update.cmd"` (no node prefix, .cmd extension), so those filters matched 0 entries and 24 Windows subtests failed. Fixes: - bug-2760-codex-install-defensive.test.cjs (7 filters): change `/gsd-check-update\.js/` → `/gsd-check-update/` to match both .js (POSIX) and .cmd (Windows) commands. - bug-3357-codex-legacy-hooks-json-migration.test.cjs (3 filters): same `.js` → no-extension change. - bug-3427-3433-codex-install-shape.test.cjs (2 filters): same fix; add explanatory comment to uninstall assertion. - codex-config.test.cjs (9 filters + 1 exact-command assertion): bulk-replace all `hooksJsonCommands.filter(cmd => cmd.includes('gsd-check-update.js'))` with `gsd-check-update`; make the `fresh CODEX_HOME` test platform- aware — on win32 assert `.cmd` shim path, on POSIX assert the existing `"runner" "script.js"` form (#3017). All four suites pass locally (macOS / darwin). Windows subtests verified against the Windows CI failure log patterns. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(3426): address pr-review-toolkit + codex review findings - fix(uninstall): add gsd-check-update.cmd to gsdHooks cleanup list so the .cmd shim is removed from disk on Windows uninstall (was left as orphan artifact — silent failure post-uninstall) - test(3426): add uninstall test asserting gsd-check-update.cmd is deleted from hooks dir after `uninstall(true, 'codex')` (no coverage existed) - fix(comment): correct JSDoc on buildCodexHookWindowsShimIR — shim content is three-line @ECHO OFF/@SETLOCAL/@runner snippet, not bare `@node "script.js" %*` as the old comment claimed - fix(comment): update stale assertion message in codex-config.test.cjs L1457 — said "config.toml references it" but the hook is in hooks.json Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/steady-badgers-rest.md | 5 + bin/install.js | 119 ++++- .../bin/lib/shell-command-projection.cjs | 4 + .../bug-2760-codex-install-defensive.test.cjs | 16 +- ...codex-legacy-hooks-json-migration.test.cjs | 6 +- tests/bug-3426-codex-windows-hooks.test.cjs | 458 ++++++++++++++++++ ...bug-3427-3433-codex-install-shape.test.cjs | 6 +- tests/codex-config.test.cjs | 57 ++- 8 files changed, 625 insertions(+), 46 deletions(-) create mode 100644 .changeset/steady-badgers-rest.md create mode 100644 tests/bug-3426-codex-windows-hooks.test.cjs diff --git a/.changeset/steady-badgers-rest.md b/.changeset/steady-badgers-rest.md new file mode 100644 index 000000000..044112de2 --- /dev/null +++ b/.changeset/steady-badgers-rest.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3768 +--- +**Codex on Windows: SessionStart/PostToolUse hooks now use a .cmd shim** — previous `bash.exe: cannot execute binary file` failure on v1.42.3+ caused by the installer writing a node-runner command that Codex's MSYS hook-dispatch shell tried to POSIX-exec via execvp(); Windows PE binaries fail that path. The fix writes a .cmd shim alongside the hook .js file; cmd.exe executes .cmd natively via CreateProcess with no POSIX exec layer. diff --git a/bin/install.js b/bin/install.js index ad2661724..edf24a7d5 100755 --- a/bin/install.js +++ b/bin/install.js @@ -983,6 +983,74 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath }; } +/** + * Build a typed IR for the Codex hook .cmd shim used on Windows (#3426). + * + * On Windows, Codex runs hook commands from a PowerShell/cmd execution + * environment. The previous command format was: + * + * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js" + * + * This caused `bash.exe: bash.exe: cannot execute binary file` because + * Codex's hook dispatch shell (Git Bash / MSYS) tried to POSIX-exec node.exe + * (a Windows PE binary) via execvp(), which fails with ENOEXEC on Windows PE + * binaries that the MSYS layer doesn't know how to fork-exec natively. + * + * Fix: write a .cmd shim (same IR pattern as buildWindowsShimTriple for + * gsd-sdk.cmd) whose content is `@ECHO OFF / @SETLOCAL / @"node.exe" "script.js" %*`. + * cmd.exe executes + * .cmd natively via CreateProcess — no POSIX exec layer, no MSYS shebang + * walk, no PE binary fork-exec failure. + * + * Returns the typed IR `{ invocation, cmdPath, hookCommand, render }` so + * callers can assert on the structured shape (CONTRIBUTING.md L558–L565 + * IR-first discipline). Returns null when absoluteRunnerToken is null so + * callers can warn-and-skip instead of writing a broken hook. + * + * @param {string} scriptAbsPath - Absolute path to the .js hook script. + * @param {string|null} absoluteRunnerToken - JSON-quoted absolute node path + * (result of resolveNodeRunner()), e.g. `"C:/Program Files/nodejs/node.exe"`. + * @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null} + */ +function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) { + if (!absoluteRunnerToken) return null; + // absoluteRunnerToken is JSON-quoted (e.g. '"C:/path/node.exe"'). Unwrap to + // get the raw interpreter path for the invocation record and render output. + let interpreter; + try { + interpreter = JSON.parse(absoluteRunnerToken); + } catch { + interpreter = absoluteRunnerToken; + } + // Normalise to forward slashes for cross-shell safety (same as other Windows + // hook path normalisations in this codebase). + const targetAbs = scriptAbsPath.replace(/\\/g, '/'); + const scriptQuoted = JSON.stringify(targetAbs); + // .cmd shim lives alongside the .js file, replacing the extension. + const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd'); + // The hook command written to hooks.json is just the .cmd path (double-quoted + // for spaces-in-path safety). cmd.exe executes .cmd files natively via + // CreateProcess — no runner prefix required. + const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/')); + const runnerQuoted = JSON.stringify(interpreter); + return { + invocation: { interpreter, target: scriptAbsPath }, + cmdPath, + hookCommand, + // Typed fields for IR-level assertions (CONTRIBUTING.md L558-L565). + // These describe the render semantics in a structured way so tests can + // assert on the generator contract without coupling to rendered text. + eol: { cmd: '\r\n' }, // CRLF — canonical for cmd.exe .cmd files + passthroughArgs: true, // the shim forwards all args via %* + render: { + // Mirror buildWindowsShimTriple's CRLF line endings for strict + // cmd.exe compatibility (LF-only .cmd files work in modern Windows but + // CRLF is canonical and what the existing gsd-sdk.cmd triple emits). + cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`, + }, + }; +} + /** * Ensure Codex hooks.json contains exactly one managed SessionStart * gsd-check-update hook entry, while preserving user-owned entries. @@ -996,6 +1064,10 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { * 1) { "SessionStart": [...] } * 2) { "hooks": { "SessionStart": [...] } } * + * On Windows, writes a .cmd shim alongside the .js hook file and uses the + * .cmd path as the hook command to avoid the `bash.exe: cannot execute binary + * file` failure (#3426). + * * @param {string} targetDir * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts * @returns {{ changed: boolean, wrote: boolean, path: string }} @@ -1005,12 +1077,43 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) { const absoluteRunner = opts.absoluteRunner || null; const hooksJsonPath = path.join(targetDir, 'hooks.json'); if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath }; - const managedCommand = projectManagedHookCommand({ - absoluteRunner, - scriptPath: path.resolve(targetDir, 'hooks', 'gsd-check-update.js'), - runtime: 'codex', - platform, - }); + + const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js'); + + let managedCommand; + if (platform === 'win32') { + // #3426 fix: on Windows, write a .cmd shim and use its path as the hook + // command. This avoids the MSYS bash.exe POSIX-exec failure when Codex's + // hook dispatcher tries to run node.exe through the Git Bash exec layer. + const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner); + if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath }; + try { + atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8'); + } catch (shimWriteErr) { + // Shim write failed — do NOT fall back to the old "node.exe script.js" + // command. That form triggers the `bash.exe: cannot execute binary file` + // failure that #3426 exists to fix, so a silent fallback would silently + // restore the original bug. Instead: warn loudly and skip the registration + // for this runtime so the user sees an actionable message rather than a + // successful install that fails at hook-dispatch time. + const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr); + console.warn( + ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` + + `Fix the write error (permissions? disk full?) and re-run the installer. ` + + `Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`, + ); + return { changed: false, wrote: false, path: hooksJsonPath }; + } + managedCommand = shimIR.hookCommand; + } else { + managedCommand = projectManagedHookCommand({ + absoluteRunner, + scriptPath, + runtime: 'codex', + platform, + }); + } + if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath }; return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand }); } @@ -6801,7 +6904,7 @@ function uninstall(isGlobal, runtime = 'claude') { // 4. Remove GSD hooks const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { - const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh']; + const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.cmd', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh']; let hookCount = 0; for (const hook of gsdHooks) { const hookPath = path.join(hooksDir, hook); @@ -11210,6 +11313,8 @@ module.exports = { rewriteLegacyManagedNodeHookCommands, buildCodexHookBlock, rewriteLegacyCodexHookBlock, + buildCodexHookWindowsShimIR, + ensureCodexHooksJsonSessionStart, readGsdCommandNames, installRuntimeArtifacts, uninstallRuntimeArtifacts, diff --git a/get-shit-done/bin/lib/shell-command-projection.cjs b/get-shit-done/bin/lib/shell-command-projection.cjs index 6497ab21a..36c3c4a9b 100644 --- a/get-shit-done/bin/lib/shell-command-projection.cjs +++ b/get-shit-done/bin/lib/shell-command-projection.cjs @@ -125,6 +125,10 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE = { ]), 'codex-hooks-json': new Set([ 'gsd-check-update.js', + // #3426: Windows .cmd shim for Codex hook — must be treated as managed so + // reconcileCodexHooksJsonSessionStart can replace stale node-runner commands + // with the .cmd shim on reinstall (and vice-versa on cross-platform moves). + 'gsd-check-update.cmd', ]), }; diff --git a/tests/bug-2760-codex-install-defensive.test.cjs b/tests/bug-2760-codex-install-defensive.test.cjs index e6fc68a8d..fd1aba7b6 100644 --- a/tests/bug-2760-codex-install-defensive.test.cjs +++ b/tests/bug-2760-codex-install-defensive.test.cjs @@ -121,7 +121,7 @@ describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/rei const parsed = parseTomlToObject(content); const sessionStartCommands = readHooksSessionStartCommands(codexHome); - const managed = sessionStartCommands.filter((cmd) => /gsd-check-update\.js/.test(cmd)); + const managed = sessionStartCommands.filter((cmd) => /gsd-check-update/.test(cmd)); assert.equal(managed.length, 1, 'hooks.json must contain exactly one managed gsd-check-update command'); assert.ok( !parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), @@ -172,7 +172,7 @@ describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/rei ); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)), + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), 'GSD handler must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands) ); assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] entries'); @@ -200,7 +200,7 @@ describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/rei assert.ok(!Array.isArray(parsed.hooks), 'flat [[hooks]] must be stripped on upgrade'); // Only one GSD hook entry must exist (no duplication) in hooks.json. const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update\.js/.test(cmd)); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade'); }); @@ -223,7 +223,7 @@ describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/rei const parsed = parseTomlToObject(content); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update\.js/.test(cmd)); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade from PR-#2802-shape'); }); @@ -234,7 +234,7 @@ describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/rei const content = readCodexConfig(codexHome); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update\.js/.test(cmd)); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed SessionStart handler after double install'); }); }); @@ -668,7 +668,7 @@ describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namesp ); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)), + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), 'GSD entry must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands) ); @@ -682,7 +682,7 @@ describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namesp ); // No duplicate gsd-check-update entries — exactly one managed entry. - const gsdEntries = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)); + const gsdEntries = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)); assert.equal(gsdEntries.length, 1, 'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length); }); @@ -1064,7 +1064,7 @@ describe('#2760 CR5 finding 3 — migration emits namespaced AoT (no flat/namesp // GSD's managed gsd-check-update entry also lives in the namespaced array. const hooksJsonCommands = readHooksSessionStartCommands(codexHome); assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)), + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), 'managed gsd-check-update entry must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands) ); diff --git a/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs b/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs index 9b0ce6bbd..c9a1043f6 100644 --- a/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs +++ b/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs @@ -58,7 +58,7 @@ function tomlGsdHookCount(codexHome) { const sessionStart = parsed.hooks?.SessionStart ?? []; return sessionStart .flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks : []) - .filter((hook) => typeof hook.command === 'string' && hook.command.includes('gsd-check-update.js')) + .filter((hook) => typeof hook.command === 'string' && hook.command.includes('gsd-check-update')) .length; } @@ -90,7 +90,7 @@ describe('#3357 — Codex install removes legacy GSD hooks.json entries', { conc const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command); - const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update.js')); + const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update')); assert.equal(managed.length, 1); assert.equal(tomlGsdHookCount(codexHome), 0); }); @@ -111,7 +111,7 @@ describe('#3357 — Codex install removes legacy GSD hooks.json entries', { conc const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command); - const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update.js')); + const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update')); assert.equal(commands.includes('node "/Users/example/bin/user-hook.js"'), true); assert.equal(commands.includes('node "/Users/example/bin/gsd-check-update.js"'), true); assert.equal(managed.length, 2); diff --git a/tests/bug-3426-codex-windows-hooks.test.cjs b/tests/bug-3426-codex-windows-hooks.test.cjs new file mode 100644 index 000000000..63684011a --- /dev/null +++ b/tests/bug-3426-codex-windows-hooks.test.cjs @@ -0,0 +1,458 @@ +'use strict'; + +/** + * Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1 + * + * After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure + * mode appeared on v1.42.3+: + * + * Failed with non-blocking status code: + * C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file + * + * Root cause: Codex on Windows runs hook commands from a PowerShell/cmd + * execution environment (see install.js comment at buildHookCommand). The + * command string written to hooks.json was: + * + * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js" + * + * When Codex's hook runner passes this to its subprocess spawner, the quoted + * path resolves through Git Bash (MSYS), which then tries to POSIX-exec + * node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec + * path calls execvp() on the PE binary directly, which fails with ENOEXEC, + * reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix + * appears because the error propagates through the bash.exe process that Codex + * uses as its hook-dispatch shell. + * + * Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple + * IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command. + * cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer, + * no MSYS shebang walk. + * + * Test strategy: + * - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on + * rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline). + * - Counter-tests confirm darwin/linux paths are unchanged. + * + * NOTE: Windows wall-clock verification depends on Docker matrix Windows + * runners. Local test exercises the generator IR shape only. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL = require('../bin/install.js'); +const PROJECTION = require('../get-shit-done/bin/lib/shell-command-projection.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + buildCodexHookWindowsShimIR, + ensureCodexHooksJsonSessionStart, + resolveNodeRunner, + uninstall, +} = INSTALL; + +const { projectManagedHookCommand } = PROJECTION; + +// ─── Step 1: Export surface check ──────────────────────────────────────────── + +describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => { + test('buildCodexHookWindowsShimIR is a function', () => { + assert.equal(typeof buildCodexHookWindowsShimIR, 'function', + 'buildCodexHookWindowsShimIR must be exported from bin/install.js'); + }); + + test('ensureCodexHooksJsonSessionStart is a function', () => { + assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function', + 'ensureCodexHooksJsonSessionStart must be exported from bin/install.js'); + }); +}); + +// ─── Step 2: Typed IR shape for Windows Codex hook shim ────────────────────── + +describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => { + const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; + const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; + + test('returns typed IR with invocation, cmdPath, and render factory', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline + assert.ok(ir && typeof ir === 'object', 'must return an object'); + assert.ok(typeof ir.invocation === 'object', 'must have invocation record'); + assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string'); + assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)'); + assert.ok(typeof ir.render === 'object', 'must have render factory'); + assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory'); + }); + + test('invocation.target equals the resolved script path', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple + assert.ok( + ir.invocation.target.includes('gsd-check-update.js'), + `invocation.target must reference the hook script, got: ${ir.invocation.target}`, + ); + }); + + test('invocation.interpreter is the node runner (not bash)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows + const interp = ir.invocation.interpreter; + assert.ok( + typeof interp === 'string' && (interp.includes('node') || interp === 'node'), + `invocation.interpreter must be a node path, not bash. Got: ${interp}`, + ); + assert.ok( + !interp.toLowerCase().includes('bash'), + `invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`, + ); + }); + + test('cmdPath ends with .cmd extension', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.ok( + ir.cmdPath.endsWith('.cmd'), + `cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`, + ); + }); + + test('hookCommand is the .cmd path (not a "runner script.js" string)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // The hook command written to hooks.json must be the .cmd path, not "node.exe script.js" + // because cmd.exe executes .cmd natively without POSIX exec layer + assert.ok( + ir.hookCommand.includes('.cmd'), + `hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`, + ); + // hookCommand must NOT contain bash — this was the failure mode + assert.ok( + !ir.hookCommand.toLowerCase().includes('bash'), + `hookCommand must NOT reference bash, got: ${ir.hookCommand}`, + ); + }); + + test('returns null when absoluteRunnerToken is null (caller skips registration)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null); + assert.equal(ir, null, + 'must return null when runner is unavailable so caller can warn-and-skip'); + }); +}); + +// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ───────────────────── +// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text. +// These assertions cover the three bug-critical render semantics that +// text-matching tests would miss (silent EOL/quoting/passthrough regressions). + +describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => { + const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; + const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; + + test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field'); + assert.strictEqual( + ir.eol.cmd, + '\r\n', + 'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions', + ); + }); + + test('invocation.target has no shell-metachar leakage (clean absolute path)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + const target = ir.invocation.target; + assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string'); + // The target stored in the IR is the raw unquoted path — quoting happens at + // render time. A metachar in the raw value means the IR is already corrupted. + assert.ok( + !target.includes('"') && !target.includes("'") && !target.includes('`'), + `invocation.target must be the raw path without shell quoting, got: ${target}`, + ); + assert.ok( + target.endsWith('.js'), + `invocation.target must resolve to the .js script, got: ${target}`, + ); + }); + + test('passthroughArgs is true — shim forwards all args via %*', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.strictEqual( + ir.passthroughArgs, + true, + 'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*', + ); + }); +}); + +// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ──── + +describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => { + test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => { + const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; + const cmd = projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js', + runtime: 'codex', + platform: 'darwin', + }); + assert.ok(typeof cmd === 'string', 'must return a string on darwin'); + assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim'); + assert.ok( + cmd.includes('gsd-check-update.js'), + `darwin command must reference the .js hook directly, got: ${cmd}`, + ); + }); + + test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => { + const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; + const cmd = projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: '/home/me/.codex/hooks/gsd-check-update.js', + runtime: 'codex', + platform: 'linux', + }); + assert.ok(typeof cmd === 'string', 'must return a string on linux'); + assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim'); + assert.ok( + cmd.includes('gsd-check-update.js'), + `linux command must reference the .js hook directly, got: ${cmd}`, + ); + }); +}); + +// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ── + +describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + // Stub the hook file that must exist for the hook to be registered + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => { + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + assert.ok(result.wrote || result.changed, 'must write hooks.json on win32'); + + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install'); + + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const commands = (hooksJson.SessionStart || []) + .flatMap((e) => (Array.isArray(e.hooks) ? e.hooks : [])) + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + assert.ok(commands.length > 0, 'must have at least one SessionStart hook command'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'must have a gsd-check-update hook command'); + + // KEY ASSERTION: on win32, the command must reference a .cmd file — not bash + assert.ok( + cmd.includes('.cmd'), + `win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`, + ); + assert.ok( + !cmd.toLowerCase().includes('bash'), + `win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`, + ); + }); + + test('win32: .cmd shim file is written to the hooks directory', () => { + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok( + fs.existsSync(cmdShimPath), + `win32: .cmd shim must be written at ${cmdShimPath}`, + ); + // File must be non-empty — structure check only (IR-first discipline) + const size = fs.statSync(cmdShimPath).size; + assert.ok(size > 0, '.cmd shim must have non-zero content'); + }); + + test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'darwin', + }); + + assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin'); + + const hooksJson = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), + ); + const commands = (hooksJson.SessionStart || []) + .flatMap((e) => (Array.isArray(e.hooks) ? e.hooks : [])) + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'must have a gsd-check-update hook command on darwin'); + + // Counter-test: darwin must NOT use a .cmd shim + assert.ok( + !cmd.endsWith('.cmd'), + `darwin hook command must NOT reference a .cmd shim, got: ${cmd}`, + ); + assert.ok( + cmd.includes('gsd-check-update.js'), + `darwin hook command must reference the .js file directly, got: ${cmd}`, + ); + + // .cmd shim must NOT be written on darwin + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok( + !fs.existsSync(cmdShimPath), + 'darwin must NOT write a .cmd shim', + ); + }); + + test('non-Windows (linux): same as darwin — no .cmd shim', () => { + const fakeRunner = '"/usr/local/bin/node"'; + + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + + const hooksJson = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), + ); + const commands = (hooksJson.SessionStart || []) + .flatMap((e) => (Array.isArray(e.hooks) ? e.hooks : [])) + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'linux must have a gsd-check-update hook command'); + assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim'); + + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim'); + }); +}); + +// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ───────────────── + +describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => { + let tmpDir; + + function withCodexHome(dir, fn) { + const prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = dir; + try { return fn(); } + finally { + if (prev == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prev; + } + } + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-uninstall-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + // Write the .js hook (required by install) and a pre-existing .cmd shim + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'), + '@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('uninstall removes gsd-check-update.cmd from hooks directory', () => { + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall'); + + withCodexHome(tmpDir, () => uninstall(true, 'codex')); + + assert.ok( + !fs.existsSync(cmdShimPath), + `gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`, + ); + }); +}); + +// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─ + +describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-upgrade-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => { + const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + // Pre-existing stale hooks.json with node-runner command (v1.42.3 shape) + const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`; + fs.writeFileSync( + path.join(tmpDir, 'hooks.json'), + JSON.stringify({ + SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }], + }, null, 2), + ); + + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + const commands = (hooksJson.SessionStart || []) + .flatMap((e) => (Array.isArray(e.hooks) ? e.hooks : [])) + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const gsdCmds = commands.filter((c) => c.includes('gsd-check-update')); + // Exactly one managed hook after migration — no duplicates + assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`); + + // Must be the .cmd shim + assert.ok( + gsdCmds[0].includes('.cmd'), + `migrated command must reference .cmd shim, got: ${gsdCmds[0]}`, + ); + }); +}); diff --git a/tests/bug-3427-3433-codex-install-shape.test.cjs b/tests/bug-3427-3433-codex-install-shape.test.cjs index e4fd522b8..1a35dd1f9 100644 --- a/tests/bug-3427-3433-codex-install-shape.test.cjs +++ b/tests/bug-3427-3433-codex-install-shape.test.cjs @@ -126,7 +126,7 @@ describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed ho const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); - const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.equal(gsdCommands.length, 1); assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); @@ -153,7 +153,9 @@ describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed ho const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); - const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + // On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command. + // Either way the managed hook is gone after uninstall — only the user hook remains. + const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.equal(gsdCommands.length, 0); assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index b53b5636c..ef6053e91 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -1446,15 +1446,15 @@ describe('Codex install hook configuration (e2e)', () => { ); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); assert.equal( - hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update.js')), + hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), true, - 'hooks.json references gsd-check-update.js' + 'hooks.json references gsd-check-update (.js on POSIX, .cmd on Windows)' ); // The hook file must physically exist at the referenced path const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js'); assert.ok( fs.existsSync(hookFile), - `gsd-check-update.js must exist at ${hookFile} — config.toml references it but file was not installed` + `gsd-check-update.js must exist at ${hookFile} — hooks.json references it (directly on POSIX, via .cmd shim on Windows) but file was not installed` ); }); @@ -1465,19 +1465,24 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(content.includes('[features]\nhooks = true\n'), 'writes codex_hooks feature'); const parsed = parseTomlToObject(content); assert.ok(!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks'); - // #3017: handler command now uses the absolute Node binary path so - // GUI/minimal-PATH runtimes can resolve it. The shape is - // "" "" - // where is the normalized runner selected by - // resolveNodeRunner() and the hook path is also quoted. Homebrew Cellar - // execPath values intentionally normalize to stable Homebrew symlinks. - const expectedRunner = JSON.parse(resolveNodeRunner()); - const expectedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); - const expectedCommand = `"${expectedRunner}" "${expectedHookPath}"`; + // #3017 / #3426: on POSIX the handler command uses the absolute Node binary path + // "" "" + // On Windows (#3426) a .cmd shim is written instead; the command in hooks.json + // is the quoted .cmd path (no node runner prefix — cmd.exe executes .cmd natively). const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdCommands.length, 1, 'writes one GSD update hook in hooks.json'); - assert.strictEqual(gsdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)'); + if (process.platform === 'win32') { + // On Windows, the command is the .cmd shim path (quoted). + const expectedCmdPath = path.join(codexHome, 'hooks', 'gsd-check-update.cmd').replace(/\\/g, '/'); + assert.strictEqual(gsdCommands[0], JSON.stringify(expectedCmdPath), 'win32: handler command must be the .cmd shim path (#3426)'); + } else { + // On POSIX, the command is the node runner + .js hook path. + const expectedRunner = JSON.parse(resolveNodeRunner()); + const expectedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + const expectedCommand = `"${expectedRunner}" "${expectedHookPath}"`; + assert.strictEqual(gsdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)'); + } assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'writes one codex_hooks key'); assertNoDraftRootKeys(content); assertUsesOnlyEol(content, '\n'); @@ -1561,7 +1566,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section'); assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1700,7 +1705,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'does not duplicate GSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1744,7 +1749,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a second bare features table'); assert.ok(content.includes('other_feature = true'), 'preserves existing feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1767,7 +1772,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1789,7 +1794,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); @@ -1855,7 +1860,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append a bare dotted duplicate'); assert.ok(content.includes('features.other_feature = true'), 'preserves other dotted features keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); @@ -1972,7 +1977,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(!content.includes('multiline-basic-sentinel'), 'removes multiline basic-string continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1999,7 +2004,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(!content.includes('multiline-literal-sentinel'), 'removes multiline literal-string continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -2027,7 +2032,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(!content.includes('array-sentinel-2'), 'removes multiline array continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -2074,7 +2079,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assert.strictEqual(countMatches(content, /echo custom-after-command/g), 1, 'preserves non-GSD hook exactly once'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); @@ -2099,7 +2104,7 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'preserves the commented true value'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'adds the GSD update hook once in hooks.json'); assertNoDraftRootKeys(content); }); @@ -2118,7 +2123,7 @@ describe('Codex install hook configuration (e2e)', () => { const parsedMixed = parseTomlToObject(content); assert.ok(!parsedMixed.hooks || !Array.isArray(parsedMixed.hooks.SessionStart), 'does not write managed SessionStart hooks to config.toml'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update')); assert.strictEqual(gsdEntries.length, 1, 'writes one managed SessionStart hook to hooks.json'); assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'remains idempotent on repeated installs');