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:
Tom Boucher
2026-05-20 23:13:50 -04:00
committed by GitHub
parent a7abc6df2f
commit 2d3027767b
8 changed files with 625 additions and 46 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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',
]),
};

View File

@@ -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)
);

View File

@@ -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);

View File

@@ -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]}`,
);
});
});

View File

@@ -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);

View File

@@ -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
// "<absolute-node-path>" "<hook-path>"
// where <absolute-node-path> 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
// "<absolute-node-path>" "<hook-path.js>"
// 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');