fix(3426): Codex Windows hooks use .cmd shim to avoid POSIX exec fail (#3768)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
119
bin/install.js
119
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,
|
||||
|
||||
Reference in New Issue
Block a user