fix(#4087): stage the hook helpers the Codex bundle's hooks require (#4117)

* fix(#4087): stage the hook helpers the Codex bundle's hooks require

CODEX_HOOKS_TO_COPY is a flat, hand-maintained filename allowlist that never
recursed, and Codex is excluded from installSharedHooksBundle() — the path that
stages hooks/lib/ for full-bundle runtimes — by an !isCodex gate. Excluding
hooks/lib/ was a correct scoped decision for #3579 until #3911 (2ea5efc15) gave
gsd-context-monitor.js a real require('./lib/hook-exit.js'). From then on every
fresh --codex install staged the hook without its helper and the hook died with
MODULE_NOT_FOUND at module load, before its own try/catch, on every event Codex
registers it for. The install still exited 0, so nothing surfaced it.

Reproduced before changing anything, in a sandboxed CODEX_HOME: four hooks
staged, no lib/, and the installed hook exiting 1 on "Cannot find module
'./lib/hook-exit.js'".

Rather than hand-add today's three helpers — which re-breaks the next time a
Codex-bundled hook grows a lib dependency, exactly how this regressed — the
transitive-require walk already written for Cursor in 704859e9c is extracted out
of writeCursorHooksJson into an exported stageTransitiveHookLibs(), Cursor is
rewired onto it, and the Codex copy loop calls it. bin/install.js already
required that module, so this adds no new seam. Cursor's staged set is
byte-identical to base, compared file by file.

Extraction surfaced a latent defect in that walker, fixed here: its regex read
`./X` and `./lib/X` identically, but from a hook SCRIPT a bare `./X` is a
sibling in hooks/ — gsd-check-update-worker.js requires
`./managed-hooks-registry.cjs`, which is not a lib — so it demanded
hooks/lib/managed-hooks-registry.cjs and the fail-loud guard threw. Seeds now
match only `./lib/X`; lib files still match both, which is the
sibling-within-lib case 704859e9c exists for. Cursor never exposed it because
none of its scripts carries a bare sibling require.

Three further grammar gaps closed after review, each in the fail-closed
direction: an extensionless `require('./lib/x')` is valid CommonJS and was
resolved literally, failing the install on a legitimate require — now resolved
through .js/.cjs and written under its resolved name; a NESTED `./lib/sub/x.js`
could not be expressed by the character class and was a SILENT miss, the one
failure mode this function exists to remove — now refused loudly; and a capture
carrying no alphanumeric character is prose, not a module name — hooks/lib/
injection-patterns.js documents this very mechanism with the literal string
require('./lib/...'), which captured `...` and sent the resolver hunting for
hooks/lib/... . The scan is still not comment-aware, which is disclosed at the
call site rather than papered over.

Seeded from the entries THIS invocation staged rather than probing the
destination, so a file left by an earlier install whose source is no longer
allowlisted cannot contribute helpers for a hook that is no longer shipped.

The #3579 boundary holds: three of ten helpers ship, gsd-graphify-rebuild.sh
among those correctly absent. Seven rows — three driving a real install into a
sandboxed config dir (with HOME sandboxed for the child, since Codex's skills
kind resolves from os.homedir() and the #3712 guard rightly refuses otherwise)
and four pinning the discovery grammar directly. All proven fail-first; the
set-equality row also reds on over-staging, which the count-based version it
replaced did not catch.

Fixes #4087
Fixes #4098

Emitted-Drift-Ack-Hash: hooks/lib/hook-exit.js — newly emitted for codex because the installer now stages the helpers its hooks require; the helper's own content is unchanged
Emitted-Drift-Ack-Hash: hooks/lib/cli-exit.js — newly emitted for codex as hook-exit.js's transitive require; the helper's own content is unchanged
Emitted-Drift-Ack-Hash: hooks/lib/exit-code-registry.js — newly emitted for codex as cli-exit.js's transitive require; the helper's own content is unchanged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9

* chore(#4087): add changeset

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9

* fix(#4087): stage the hooks/lib helpers the Windsurf guards require

Review of #4117, verified as asked and reproduced against a real install.

Windsurf sets hostBehaviors.skipSharedHooksInstall, so like Cursor it never
reaches installSharedHooksBundle -- the only other stager of hooks/lib --
and writeWindsurfHooksJson staged its two Cascade guards without the
helpers both require at module load: gsd-windsurf-pre-write.js requires
./lib/hook-exit.js and ./lib/git-probe.js, gsd-windsurf-pre-command.js
requires ./lib/hook-exit.js. stageTransitiveHookLibs had one call site,
Cursor's.

Measured on a fresh `--windsurf --global` install into a sandboxed HOME:
the installer exited 0, hooks/ held only the two scripts and package.json,
and executing either installed guard exited 1 with "Cannot find module
'./lib/hook-exit.js'" -- so every pre_write_code and pre_run_command event
failed at load while the install reported success. The same command with
`--cursor` staged four helpers and its hook ran, which is the control.

Pre-existing rather than introduced here: at merge-base 05092ff36 the same
three require lines exist and writeWindsurfHooksJson already staged no
lib/, and this PR's diff carried no reference to Windsurf. Fixed here
anyway because the helper this PR extracted is the right tool and a second
runtime is a few lines onto it.

writeWindsurfHooksJson now calls stageTransitiveHookLibs after staging its
scripts, with the same gsd: -> gsd- transform the scripts receive, so a
helper is rewritten the same way as its caller. The install-tree fixture
regenerates with exactly hook-exit.js, git-probe.js, cli-exit.js and
exit-code-registry.js added and no other fixture moved. Two rows execute
the INSTALLED guards, beside the Codex rows they mirror; the existing
windsurf-hooks-bridge rows run the guards from source and test behaviour,
a different question, and are left as they are. Both new rows fail-first
against the unfixed compiled artifact -- gsd-core/bin/lib, which is what
bin/install.js loads -- on the MODULE_NOT_FOUND assertion.

Emitted-Drift-Ack-Hash: hooks/lib/git-probe.js — first staged for Windsurf, whose pre-write guard requires it; the file itself is unchanged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Behruz Nassre Esfahani
2026-09-04 22:49:37 -07:00
committed by GitHub
parent 0ea012c519
commit c6efe2905c
6 changed files with 558 additions and 43 deletions

View File

@@ -1521,6 +1521,135 @@ interface WriteCursorHooksJsonOpts {
managedHookEvents?: readonly string[];
}
/**
* Stage the `hooks/lib/` helpers a set of staged hook scripts require, walking
* the require graph TRANSITIVELY to a fixed point.
*
* Extracted from writeCursorHooksJson (#3911 review, commit 704859e9c) so the
* reduced Codex hook bundle can share one implementation instead of growing a
* second, divergent copy (#4087 / #4098). Both reduced bundles hand-pick which
* hook SCRIPTS they ship, and neither can hand-pick their helpers correctly for
* long: `hooks/lib/hook-exit.js` requires `./cli-exit.js`, which requires
* `./exit-code-registry.js` — with NO `./lib/` prefix, because from inside
* `lib/` the sibling is already local. A single-pass scan for the `./lib/…`
* spelling used FROM a hook script stages hook-exit.js and stops, and the
* installed hook then dies on MODULE_NOT_FOUND at load, before its own
* try/catch, on every event it is registered for.
*
* Scans CONTENT rather than paths so a caller can seed from whatever it staged,
* transformed or not, without this helper knowing the caller's layout.
*
* @returns the lib filenames actually staged, in staging order.
*/
function stageTransitiveHookLibs(opts: {
seedSources: string[];
srcLibDir: string;
destLibDir: string;
runtimeLabel: string;
transform?: (content: string) => string;
}): string[] {
const { seedSources, srcLibDir, destLibDir, runtimeLabel, transform } = opts;
const requiredLibFiles = new Set<string>();
const scannedLibFiles = new Set<string>();
const staged: string[] = [];
// `./X` means DIFFERENT things depending on where the scanned file lives, and
// conflating them stages the wrong file. From a hook SCRIPT in hooks/, a bare
// `./X` is a sibling hook-level artifact — Codex's gsd-check-update-worker.js
// requires `./managed-hooks-registry.cjs`, which lives in hooks/, not
// hooks/lib/ — so only the explicit `./lib/X` spelling is a lib requirement.
// From inside a LIB file, the sibling is already local, so `./X` IS a lib
// requirement (hook-exit.js -> ./cli-exit.js -> ./exit-code-registry.js); that
// is the case 704859e9c added and it must keep working. Cursor never exposed
// the difference because none of its staged scripts has a bare sibling
// require; Codex's does, and the fail-loud guard below caught it immediately
// by demanding managed-hooks-registry.cjs out of hooks/dist/lib.
// Fresh per call: a module-level /g regex carries lastIndex across calls and
// would silently skip matches on the second install in one process.
const seedRequireRe = /require\(\s*['"]\.\/lib\/([A-Za-z0-9._-]+)['"]\s*\)/g;
const libRequireRe = /require\(\s*['"]\.\/(?:lib\/)?([A-Za-z0-9._-]+)['"]\s*\)/g;
// A NESTED helper path is outside the flat layout hooks/lib/ has and the
// build emits, and the character classes above cannot express it — so it
// would be a SILENT miss, staging nothing and shipping a hook that dies at
// load. Detected separately and refused loudly instead: a silent miss is the
// failure mode this whole function exists to remove (review of #4087).
const nestedRequireRe = /require\(\s*['"]\.\/lib\/[A-Za-z0-9._-]+\/[^'"]*['"]\s*\)/;
const scanForLibRequires = (source: string, fromLib: boolean): void => {
if (nestedRequireRe.test(source)) {
throw new Error(
`A staged ${runtimeLabel} hook requires a NESTED hooks/lib path. hooks/lib/ is flat and `
+ 'this stager only resolves flat helper names, so the nested helper would never be '
+ 'staged and the hook would throw MODULE_NOT_FOUND at load. Flatten the helper or '
+ 'extend this stager deliberately.',
);
}
const re = fromLib ? libRequireRe : seedRequireRe;
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(source)) !== null) {
const candidate = m[1];
// A capture with no alphanumeric character is not a module name — it is
// prose. This scan reads whole file text, comments included, and
// hooks/lib/injection-patterns.js's own header documents this mechanism
// with the literal string `require('./lib/...')`, which captures `...`
// and would send the resolver hunting for `hooks/lib/...` and fail the
// install (measured; that helper is not staged for either reduced bundle
// today, so it is latent rather than live).
//
// KNOWN LIMIT, disclosed rather than papered over: this does NOT make the
// scan comment-aware. A comment naming a REAL helper — `require(
// './lib/git-cmd.js')` in prose — still registers it and would over-stage
// that helper. Closing that needs a comment-stripping pass; the
// line-based stripper in scripts/lint-hooks-runtime-build-seam.cjs is the
// precedent (its header explains why the naive two-regex strip corrupts
// these very files), but promoting a lint-script helper into installer
// runtime code is a larger change than this fix.
if (!/[A-Za-z0-9]/.test(candidate)) continue;
requiredLibFiles.add(candidate);
}
};
for (const source of seedSources) scanForLibRequires(source, false);
if (requiredLibFiles.size === 0) return staged;
fs.mkdirSync(destLibDir, { recursive: true });
// Iterate to a fixed point: staging a lib file can add MORE required lib
// files (its own requires), which must themselves be staged and scanned.
let libFile: string | undefined = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
while (libFile !== undefined) {
scannedLibFiles.add(libFile);
// Node's own extension resolution: `require('./lib/x')` is a valid, working
// CommonJS spelling today, and matching only the extension-bearing form
// resolved `x` literally, found nothing, and failed the install on a
// legitimate require (review of #4087). Try the bare name first so an
// extension-bearing capture still wins, then .js/.cjs.
let resolvedName: string | undefined;
for (const candidate of [libFile, `${libFile}.js`, `${libFile}.cjs`]) {
if (fs.existsSync(path.join(srcLibDir, candidate))) { resolvedName = candidate; break; }
}
const libSrc = path.join(srcLibDir, resolvedName ?? libFile);
if (resolvedName === undefined) {
// FAIL LOUD. Skipping here would ship hook scripts whose top-level
// require() throws before their own try/catch, wedging every session —
// and the install would still exit 0, so nobody would know until a user
// hit it. A missing helper source is a packaging bug; surface it.
throw new Error(
`hooks/lib/${libFile} is required by a staged ${runtimeLabel} hook but is missing from ${srcLibDir}. `
+ 'Installing would ship a hook that throws MODULE_NOT_FOUND at load.',
);
}
let libContent = fs.readFileSync(libSrc, 'utf8');
if (transform) libContent = transform(libContent);
// Written under its RESOLVED name so an extensionless require still lands a
// file Node can resolve at the destination.
fs.writeFileSync(path.join(destLibDir, resolvedName), libContent);
staged.push(resolvedName);
scanForLibRequires(libContent, true);
libFile = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
}
return staged;
}
function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
opts = opts || {};
const hooksDir = path.join(targetDir, 'hooks');
@@ -1557,47 +1686,13 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
// "./lib/X" requires, and every lib file staged is itself scanned for further
// "./lib/X" OR bare "./X" (sibling-within-lib) requires, so the requirement
// graph is derived to a fixed point instead of one hand-tuned level deep.
const requiredLibFiles = new Set<string>();
const scannedLibFiles = new Set<string>();
const libRequireRe = /require\(\s*['"]\.\/(?:lib\/)?([A-Za-z0-9._-]+)['"]\s*\)/g;
function scanForLibRequires(source: string): void {
libRequireRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = libRequireRe.exec(source)) !== null) requiredLibFiles.add(m[1]);
}
for (const script of installedScripts) {
scanForLibRequires(fs.readFileSync(path.join(hooksDir, script), 'utf8'));
}
if (requiredLibFiles.size > 0) {
const srcLibDir = path.join(srcHooksDir, 'lib');
const destLibDir = path.join(hooksDir, 'lib');
fs.mkdirSync(destLibDir, { recursive: true });
// Iterate to a fixed point: staging a lib file can add MORE required lib
// files (its own requires), which must themselves be staged and scanned.
let libFile: string | undefined = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
while (libFile !== undefined) {
scannedLibFiles.add(libFile);
const libSrc = path.join(srcLibDir, libFile);
if (!fs.existsSync(libSrc)) {
// FAIL LOUD. Skipping here would ship hook scripts whose top-level
// require() throws before their own try/catch, wedging every session —
// and the install would still exit 0, so nobody would know until a user
// hit it. A missing helper source is a packaging bug; surface it.
throw new Error(
`hooks/lib/${libFile} is required by a staged Cursor hook but is missing from ${srcLibDir}. `
+ 'Installing would ship a hook that throws MODULE_NOT_FOUND at load.',
);
}
let libContent = fs.readFileSync(libSrc, 'utf8');
libContent = libContent.replace(/gsd:/gi, 'gsd-');
fs.writeFileSync(path.join(destLibDir, libFile), libContent);
scanForLibRequires(libContent);
libFile = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
}
}
stageTransitiveHookLibs({
seedSources: [...installedScripts].map((script) => fs.readFileSync(path.join(hooksDir, script), 'utf8')),
srcLibDir: path.join(srcHooksDir, 'lib'),
destLibDir: path.join(hooksDir, 'lib'),
runtimeLabel: 'Cursor',
transform: (content) => content.replace(/gsd:/gi, 'gsd-'),
});
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
// scripts. Cursor sets skipSharedHooksInstall, so it never reaches
@@ -1820,6 +1915,27 @@ function writeWindsurfHooksJson(targetDir: string, src: string, opts?: WriteWind
}
}
// Stage the hooks/lib/ helpers these scripts require (#4087 review). Windsurf
// sets hostBehaviors.skipSharedHooksInstall, so like Cursor it never reaches
// installSharedHooksBundle — the only other stager of hooks/lib — and it was
// staging neither. Both Cascade guards require helpers at module load:
// gsd-windsurf-pre-write.js requires ./lib/hook-exit.js and ./lib/git-probe.js,
// gsd-windsurf-pre-command.js requires ./lib/hook-exit.js. Measured against a
// real `--windsurf --global` install before this call existed: the installer
// exited 0, hooks/ held only the two scripts, and running either one exited 1
// with "Cannot find module './lib/hook-exit.js'" — the same failure #4087
// reports for Codex, on every pre_write_code / pre_run_command event.
//
// The transform matches the one applied to the scripts above: a helper must be
// rewritten the same way as its caller or the two disagree on the spelling.
stageTransitiveHookLibs({
seedSources: [...installedScripts].map((script) => fs.readFileSync(path.join(hooksDir, script), 'utf8')),
srcLibDir: path.join(srcHooksDir, 'lib'),
destLibDir: path.join(hooksDir, 'lib'),
runtimeLabel: 'Windsurf',
transform: (content) => content.replace(/gsd:/gi, 'gsd-'),
});
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
// scripts. Windsurf sets skipSharedHooksInstall, so it never reaches
// installSharedHooksBundle (the only other writer of this marker); without
@@ -2937,6 +3053,7 @@ export = {
KIMI_HOOKS_TOML_MARKER_END,
// Shared
stageTransitiveHookLibs,
buildHookCommand,
applySettingsJsonHooks,
referencesHook,