Files
msd-core/tests/bug-3427-3433-codex-install-shape.test.cjs
Tom Boucher 2d3027767b 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>
2026-05-20 23:13:50 -04:00

164 lines
6.9 KiB
JavaScript

'use strict';
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 crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
function withCodexHome(codexHome, fn) {
const prev = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
try {
return fn();
} finally {
if (prev == null) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prev;
}
}
function extractSessionStartCommandsFromHooksJson(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks))
? value.hooks
: value;
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
return sessionStart.flatMap((entry) => {
const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string');
});
}
describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { concurrency: false }, () => {
let tmpRoot;
let codexHome;
beforeEach(() => {
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
}
tmpRoot = createTempDir('gsd-3427-3433-');
codexHome = path.join(tmpRoot, '.codex');
fs.mkdirSync(codexHome, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => {
// Stale legacy body — fresh install must overwrite this so Codex sees the
// current SKILL.md, not whatever was last on disk.
const legacySkillBody = '# old managed\n';
fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true });
fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody);
const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex');
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({
version: 1,
files: {
'skills/gsd-help/SKILL.md': legacyHash,
},
}, null, 2));
fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true });
fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n');
withCodexHome(codexHome, () => install(true, 'codex'));
const skillsDir = path.join(codexHome, 'skills');
const entries = fs.existsSync(skillsDir)
? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
: [];
// #3562: $gsd-* commands are discoverable only when skills/gsd-*/SKILL.md
// exists. The installer must regenerate (not remove) the managed gsd-*
// directories.
assert.equal(entries.includes('gsd-help'), true);
const refreshedBody = fs.readFileSync(path.join(skillsDir, 'gsd-help', 'SKILL.md'), 'utf8');
assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten');
const frontmatter = parseFrontmatter(refreshedBody);
assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help');
// Unrelated user skills are preserved — the regen scope is `gsd-*` only.
assert.equal(entries.includes('custom-user-skill'), true);
});
test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => {
const configToml = [
'[features]',
'codex_hooks = true',
'',
'[[hooks.SessionStart]]',
'[[hooks.SessionStart.hooks]]',
'type = "command"',
'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"',
'',
].join('\n');
fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml);
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
SessionStart: [
{
hooks: [
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
],
},
],
}, null, 2));
withCodexHome(codexHome, () => install(true, 'codex'));
const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
const tomlSessionStart = parsedToml.hooks?.SessionStart ?? [];
const tomlCommands = tomlSessionStart.flatMap((entry) =>
(Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string')
);
assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false);
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'));
assert.equal(gsdCommands.length, 1);
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
});
test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => {
const hooksDir = path.join(codexHome, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n');
const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
SessionStart: [
{
hooks: [
{ type: 'command', command: `node "${managedHookPath}"` },
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
],
},
],
}, null, 2));
withCodexHome(codexHome, () => uninstall(true, 'codex'));
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
// 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);
});
});