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

@@ -12064,8 +12064,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// require()s managed-hooks-registry.cjs for MANAGED_HOOKS — so all four must be
// installed/refreshed together for every profile, or Codex is wired to a dependency
// chain the same installer never delivers.
// We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
// in this change (graphify auto-update support for Codex is out of scope for #3579).
// We deliberately do *not* copy gsd-graphify-update.sh for Codex in this
// change (graphify auto-update support for Codex is out of scope for #3579).
// hooks/lib/ WAS excluded here for the same reason, and that stopped being
// correct when #3911 (2ea5efc15) gave gsd-context-monitor.js a real
// `require('./lib/hook-exit.js')`: the allowlist below is flat and never
// recursed, so the hook shipped without its helper and died with
// MODULE_NOT_FOUND at load, before its own try/catch, on every registered
// event (#4087, #4098). The libs are now derived from what the staged
// scripts actually require rather than hand-listed — see the
// stageTransitiveHookLibs call after the copy loop. The #3579 boundary is
// preserved: helpers no staged Codex hook requires (graphify tooling among
// them) are still not shipped.
const CODEX_HOOKS_TO_COPY = [
'gsd-check-update.js',
'gsd-check-update-worker.js',
@@ -12081,6 +12091,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// is not the same as an allowlisted file landing in it — see the marker
// gate below.
let codexStagedHooks = false;
// The entries THIS invocation actually staged. Seeding the lib scan from
// `existsSync` over the destination instead would also pick up a file
// left by a PREVIOUS install whose source is no longer staged — e.g. a
// name dropped from the allowlist — and derive helpers for a hook that is
// no longer shipped (review of #4087).
const codexStagedEntries = [];
for (const entry of fs.readdirSync(codexHooksSrc)) {
if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
const srcFile = path.join(codexHooksSrc, entry);
@@ -12115,8 +12131,43 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
}
codexStagedHooks = true;
codexStagedEntries.push(entry);
}
// Stage the hooks/lib/ helpers the staged scripts require, transitively
// (#4087, #4098). Shares writeCursorHooksJson's walker rather than a
// second copy: both reduced bundles hand-pick SCRIPTS, and the identical
// MODULE_NOT_FOUND was already fixed once for Cursor in 704859e9c. A flat
// list of today's three helpers would re-break the next time a
// Codex-bundled hook grows a lib dependency, which is exactly how this
// regressed. Gated on codexStagedHooks for the same reason the CommonJS
// marker below is: hooks/ is shared space, and staging nothing must not
// leave a GSD-owned lib/ behind in a directory GSD created but did not
// fill (#2544). Seeded from the copies staged by THIS invocation, so the
// scan sees the same bytes Node will load and never derives helpers for a
// hook left behind by an earlier install.
let codexStagedLibs = [];
if (codexStagedHooks) {
codexStagedLibs = hooksSurface.stageTransitiveHookLibs({
seedSources: codexStagedEntries
.map((entry) => fs.readFileSync(path.join(codexHooksDest, entry), 'utf8')),
srcLibDir: path.join(codexHooksSrc, 'lib'),
destLibDir: path.join(codexHooksDest, 'lib'),
runtimeLabel: 'Codex',
// Same substitutions the .js branch above applies to hook scripts, so
// a helper that ever gains a runtime path or version token is
// rewritten identically instead of shipping a Claude-shaped path.
// No-ops on today's helpers, which carry neither.
transform: (content) => content
.replace(/'\.claude'/g, configDirReplacement)
.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`)
.replace(/\.claude\//g, `${getDirName(runtime)}/`)
.replace(/\{\{GSD_VERSION\}\}/g, pkg.version),
});
}
console.log(` ${green}✓${reset} Installed hooks (Codex)`);
if (codexStagedLibs.length > 0) {
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (${codexStagedLibs.join(', ')})`);
}
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
// scripts. Codex is excluded from installSharedHooksBundle by the
// !isCodex gate, so it never received the marker the shared-bundle path