fix(#3662): resolve managed hook node runners at hook-fire time (#3790)

* test(#3662): failing-first suite for runtime-resolving hook runners

* fix(#3662): resolve managed hook node runners at hook-fire time

* fix(#3662): close review findings and document the resolver

* fix(#3662): close adversarial and security review findings

* chore(#3662): backfill changeset pr number

* test(#3662): honor win32 skip return and platform-aware sh runner pin

* test(#3662): pin the bare win32-claude sh-hook shape omitting the bash runner

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 00:07:06 -04:00
committed by GitHub
parent cf15682d1c
commit 4af59f8dd3
25 changed files with 896 additions and 46 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3790
---
**Managed hooks now resolve the node binary at hook-fire time** — a config root shared across environments (WSL/Docker bind-mounts, mounted or synced `~/.claude`) no longer fails every managed hook with `node: not found` outside the machine that ran the installer, and updates from any environment converge stale runners instead of creating a mixed state where no environment works. `--portable-hooks` installs route through a staged `hooks/gsd-node-runner.sh` resolver (install-time path first, then `command -v node`, then well-known layouts); other installs carry an equivalent inline fallback chain. (#3662)

View File

@@ -407,7 +407,7 @@ A per-agent narrative entry written by `mempalace_diary_write`. GSD's `gsd-mempa
The `mempalace.memory_mode` config key controlling how authoritative MemPalace is during recall/capture relative to GSD's native memory. Three wired values: `augment` (default — palace is an additive recall layer; native memory stays authoritative; lowest coupling), `kg_backend` (knowledge-graph queries resolve against MemPalace's temporal graph as the primary source, `.planning/graphs/` as fallback; non-KG drawer recall stays additive), `replace` (recall resolves through the palace as the source of truth, native artifacts as fallback). Every mode is `onError:skip` and default-resilient — an unreachable palace degrades to native memory and GSD keeps writing `.planning/graphs/`, so no mode loses memory. Read at hook-render time; switching is a config change, not a reinstall. Cross-mode migration of existing `.planning/graphs/` into the palace is a separate, not-yet-implemented concern (PRD/ADR §17 open question). See MemPalace Settings in `docs/CONFIGURATION.md`.
### Runtime Hooks Surface Module
Standalone hook-surface writer module extracted from `bin/install.js` as ADR-857 phase 5f-1 (behavior-preserving relocation, no logic change). Owns: Cline rules-body/agents-md/pre-tool-use hook generation (`buildClineRulesBody`, `buildClineAgentsMdBody`, `buildClinePreToolUseHook`, `mergeGsdAgentsMd`, `writeClineArtifacts`); Cursor `hooks.json` lifecycle (`buildCursorHookEntry`, `isManagedCursorHookEntry`, `reconcileCursorHooksJson`, `writeCursorHooksJson`, `removeCursorHooksJson`); Copilot session-hook config (`buildCopilotHookConfig`, `writeCopilotHookConfig`); Codex hook-block and event management (`buildCodexHookBlock`, `rewriteLegacyCodexHookBlock`, `reconcileCodexHooksJsonEvent`, `reconcileCodexHooksJsonSessionStart`, `ensureCodexHooksJsonSessionStart`, `ensureCodexHooksJsonEvent`, `removeCodexHooksJsonEvent`, `removeCodexHooksJsonSessionStart`, `buildCodexHookWindowsShimIR`); Kimi native config.toml `[[hooks]]` lifecycle (`buildKimiHooksTomlBlock`, `stripKimiHooksTomlBlock`, `writeKimiHooksToml`, `removeKimiHooksToml` — #2095 EoS/kimi Upgrade 1, the first genuinely NEW hook surface added post-relocation rather than a behavior-preserving move: kimi's `[[hooks]]` array lives in its own native `config.toml`, resolved by `resolveKimiHooksTomlDir` in Runtime Homes Module to a directory deliberately separate from kimi's GSD configDir, wrapped in `# GSD Hooks BEGIN`/`END` marker comments for idempotent reinstall); and shared hook command helpers (`buildHookCommand`, `rewriteLegacyManagedNodeHookCommands`, `normalizeNodePath`, `resolveNodeRunner`). `bin/install.js` delegates to this module via thin wrappers and re-exports its functions unchanged so existing tests require no modification. Source: `src/runtime-hooks-surface.cts`. Built output: `gsd-core/bin/lib/runtime-hooks-surface.cjs`.
Standalone hook-surface writer module extracted from `bin/install.js` as ADR-857 phase 5f-1 (behavior-preserving relocation, no logic change). Owns: Cline rules-body/agents-md/pre-tool-use hook generation (`buildClineRulesBody`, `buildClineAgentsMdBody`, `buildClinePreToolUseHook`, `mergeGsdAgentsMd`, `writeClineArtifacts`); Cursor `hooks.json` lifecycle (`buildCursorHookEntry`, `isManagedCursorHookEntry`, `reconcileCursorHooksJson`, `writeCursorHooksJson`, `removeCursorHooksJson`); Copilot session-hook config (`buildCopilotHookConfig`, `writeCopilotHookConfig`); Codex hook-block and event management (`buildCodexHookBlock`, `rewriteLegacyCodexHookBlock`, `reconcileCodexHooksJsonEvent`, `reconcileCodexHooksJsonSessionStart`, `ensureCodexHooksJsonSessionStart`, `ensureCodexHooksJsonEvent`, `removeCodexHooksJsonEvent`, `removeCodexHooksJsonSessionStart`, `buildCodexHookWindowsShimIR`); Kimi native config.toml `[[hooks]]` lifecycle (`buildKimiHooksTomlBlock`, `stripKimiHooksTomlBlock`, `writeKimiHooksToml`, `removeKimiHooksToml` — #2095 EoS/kimi Upgrade 1, the first genuinely NEW hook surface added post-relocation rather than a behavior-preserving move: kimi's `[[hooks]]` array lives in its own native `config.toml`, resolved by `resolveKimiHooksTomlDir` in Runtime Homes Module to a directory deliberately separate from kimi's GSD configDir, wrapped in `# GSD Hooks BEGIN`/`END` marker comments for idempotent reinstall); and shared hook command helpers (`buildHookCommand`, `rewriteLegacyManagedNodeHookCommands`, `normalizeNodePath`, `resolveNodeRunner`, and — #3662 — `buildNodeRunnerChainToken`, the POSIX-sh runner token that resolves node at hook-fire time for non-portable managed JS hooks, plus the `NODE_RUNNER_RESOLVER_HOOK` basename of the staged `hooks/gsd-node-runner.sh` resolver that portable installs route through with the baked node path as its first argument). `bin/install.js` delegates to this module via thin wrappers and re-exports its functions unchanged so existing tests require no modification. Source: `src/runtime-hooks-surface.cts`. Built output: `gsd-core/bin/lib/runtime-hooks-surface.cjs`.
### Runtime Config Adapter Registry
Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `cursor-hooks-json` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | `antigravity` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Also exports `resolveInstallPlan(runtime)` — the ADR-58 `InstallPlan` capstone — which collects the install-level descriptor axes (`installSurface`, `writesSharedSettings`, `finishPermissionWriter`, `hookEvents`, `extendedHookEvents`, `hooksSurface`, `sandboxTier`) into one typed `InstallPlan` value consumed by `install()` and `finishInstall()` in `bin/install.js`. `sandboxTier` (`none` | `codex-agent-sandbox`) gates per-agent `sandbox_mode` emission in the codex TOML path and fails loud on a missing/invalid value (#1151). The spatial axes (`configHome`, `artifactLayout`, `commandStyle`) remain behind their self-resolving adapter modules and are not part of the plan; they are the execution adapters. Realizes both the adapter-selection and plan-collection halves of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60.

File diff suppressed because one or more lines are too long

View File

@@ -550,6 +550,7 @@
"gsd-cursor-subagent-stop.js",
"gsd-ensure-canonical-path.js",
"gsd-graphify-update.sh",
"gsd-node-runner.sh",
"gsd-phase-boundary.sh",
"gsd-prompt-guard.js",
"gsd-read-guard.js",

View File

@@ -706,6 +706,7 @@ Full listing: `hooks/`.
| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement |
| `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions |
| `gsd-graphify-update.sh` | `PostToolUse` | Auto-rebuild knowledge graph after main HEAD advances (opt-in, default off — #3347) |
| `gsd-node-runner.sh` | (helper) | Portable node resolver managed JS hook commands route through under `--portable-hooks`: install-time node path first, then `command -v node`, then well-known layouts — resolves at hook-fire time so a shared config root works in every environment (#3662) |
---

View File

@@ -525,6 +525,33 @@ Select the corresponding stable runtime in the installer prompt. GSD does not en
---
## Sharing one config root across environments
When one config root (`~/.claude`, `~/.config/opencode`, …) is mounted or synced into
machines with different Node.js layouts — a host plus Docker containers bind-mounting the
same directory, or WSL and Windows sharing a drive — install with `--portable-hooks`
(or `GSD_PORTABLE_HOOKS=1`):
```bash
npx @opengsd/gsd-core@latest --claude --global --portable-hooks
```
Hook script paths are emitted `$HOME`-relative, and every managed JavaScript hook command
resolves its node binary at hook-fire time instead of depending on whichever machine ran
the installer (#3662): portable installs route through a staged
`hooks/gsd-node-runner.sh` resolver (the install-time node path first, then `command -v
node`, then the well-known layouts); non-portable installs carry an equivalent inline
fallback chain. The install-time path is always tried first, so GUI launches with a
minimal `PATH` keep working, and a bare `node` lookup is never depended on. Re-running
install or update from any of the environments converges stale runner paths — no mixed
state where some hooks work in one environment and the rest in another.
To pin down which node a given environment picks, run the resolver directly with
`GSD_NODE_RUNNER_NO_FALLBACKS=1` (first-argument-only resolution) — it prints a stderr
diagnostic and exits non-zero when nothing resolves.
---
## Installing without Node.js
If you cannot run `npx` (for example, on a Windows machine without Node.js), you have two options.

76
hooks/gsd-node-runner.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/bin/sh
# gsd-node-runner.sh — GSD portable node resolver (#3662).
#
# Managed JS hook commands under --portable-hooks route through this script:
#
# bash "<hooks>/gsd-node-runner.sh" "<baked-node-path>" "<script.js>" [args...]
#
# so a config root shared across environments (mounted ~/.claude, shared
# containers) resolves node at hook-fire time instead of depending on the
# absolute path of whichever environment ran the installer. Candidates, in
# order — the first executable one wins:
#
# 1. the first argument — the install-time node path, tried FIRST and by
# absolute path so the #2979/#3002/#3017/#3022 minimal-PATH guarantee
# holds (GUI launches with a stripped PATH still resolve where the
# baked path exists);
# 2. `command -v node`, accepted only when it yields an absolute path;
# 3. the well-known stable layouts: $HOME-derived mise/volta shims, the
# Homebrew prefixes, /usr/local/bin/node, /usr/bin/node.
#
# No bare `node` lookup is ever depended on: a candidate is used only after
# an explicit executable check, and when nothing resolves this script fails
# visibly (stderr diagnostic + exit 127) rather than emitting a half-resolved
# invocation.
#
# The candidate list below is a SUPERSET of the inline chain token emitted by
# buildNodeRunnerChainToken (src/runtime-hooks-surface.cts, #3662) — keep the
# two lists consistent.
#
# Diagnostic escape: GSD_NODE_RUNNER_NO_FALLBACKS=1 disables candidates 2-3
# (first-argument-only resolution) — used by the test suite and useful to
# pin down which node a given environment picks.
set -u
preferred=${1:-}
script=${2:-}
if [ -n "$script" ]; then
shift 2
elif [ -n "$preferred" ]; then
shift 1
preferred=
fi
found=''
# check <path> — record <path> if it is an absolute, executable file.
# Absolute = POSIX root (/*) or a win32 drive-letter path (C:/…), which is
# what the installer bakes on Windows; anything else (a relative `command -v`
# hit under a relative PATH entry, a bare name) is rejected so repo-cwd
# content can never reach the runner slot.
check() {
case "$1" in
/*|[A-Za-z]:/*) if [ -x "$1" ]; then found=$1; fi ;;
esac
[ -n "$found" ]
}
check "$preferred" || {
if [ "${GSD_NODE_RUNNER_NO_FALLBACKS:-0}" != "1" ]; then
path_node=$(command -v node 2>/dev/null || true)
check "$path_node" ||
check "${HOME:-}/.local/share/mise/shims/node" ||
check "${HOME:-}/.volta/bin/node" ||
check /opt/homebrew/bin/node ||
check /usr/local/bin/node ||
check /usr/bin/node ||
true
fi
}
if [ -z "$found" ]; then
echo "gsd-node-runner: no usable node found (preferred: ${preferred:-<none>})" >&2
exit 127
fi
exec "$found" "$script" "$@"

View File

@@ -29,6 +29,9 @@ const MANAGED_HOOKS = [
'gsd-cursor-subagent-stop.js',
'gsd-ensure-canonical-path.js',
'gsd-graphify-update.sh',
// #3662: portable node resolver (helper staged in hooks/; managed JS hook
// commands route through it under --portable-hooks).
'gsd-node-runner.sh',
'gsd-phase-boundary.sh',
'gsd-prompt-guard.js',
'gsd-read-guard.js',

View File

@@ -71,6 +71,11 @@ const HOOKS_TO_COPY = [
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
// Portable node resolver (#3662). Not a registered hook itself: managed JS
// hook commands under --portable-hooks route through it (bash <resolver>
// <baked-node> <script>) so node resolves at hook-fire time in every
// environment sharing the config root. Staged verbatim — no templating.
'gsd-node-runner.sh',
// Graphify auto-update hook (#3347 / PR #3557 / #3579). Opt-in via
// .planning/config.json graphify.auto_update; off by default.
'gsd-graphify-update.sh'

View File

@@ -43,6 +43,9 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
'hooks/gsd-windsurf-pre-command.js',
'hooks/gsd-ensure-canonical-path.js',
'hooks/gsd-graphify-update.sh',
// #3662: portable node resolver staged into hooks/ (not itself a lifecycle
// hook — managed JS hook commands route through it under --portable-hooks).
'hooks/gsd-node-runner.sh',
'hooks/gsd-phase-boundary.sh',
'hooks/gsd-prompt-guard.js',
'hooks/gsd-read-guard.js',

View File

@@ -55,15 +55,17 @@ const {
projectCodexHookTomlCommand,
shellHookOmitsBashRunner,
escapeTomlDoubleQuotedString,
escapePosixDoubleQuoted,
} = shellCmdProjection as {
isManagedHookBasename: (scriptPath: string, opts?: { surface?: string }) => boolean;
isManagedHookCommand: (cmd: string | null | undefined, opts?: { surface?: string; includeLegacyAliases?: boolean; configDir?: string }) => boolean;
projectLegacySettingsHookCommand: (opts: { absoluteRunner: string; scriptPath: string; scriptToken: string; runtime: string; platform: string }) => string | null;
projectLegacySettingsHookCommand: (opts: { runnerToken: string; scriptPath: string; scriptToken: string; runtime: string; platform: string }) => string | null;
projectManagedHookCommand: (opts: { absoluteRunner: string; scriptPath: string; runtime: string; platform: string; hookShell?: string }) => string | null;
projectPortableHookBaseDir: (opts: { configDir: string; homeDir: string }) => string;
projectCodexHookTomlCommand: (opts: { absoluteRunner: string; scriptPath: string; platform: string }) => string;
shellHookOmitsBashRunner: (opts: { platform: string; runtime: string; isShellHook: boolean }) => boolean;
escapeTomlDoubleQuotedString: (value: unknown) => string;
escapePosixDoubleQuoted: (value: unknown) => string;
};
// ---------------------------------------------------------------------------
@@ -380,6 +382,12 @@ function parseTomlValue(text: string, i: number): { value: unknown; end: number
interface NodeNormOpts {
env?: NodeJS.ProcessEnv;
existsSync?: (p: string) => boolean;
/**
* #3662: the process path to normalize instead of `process.execPath`.
* Production callers omit it; tests use it to simulate an install baked by
* a different environment (a foreign absolute node path).
*/
execPath?: string;
}
function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string {
@@ -455,12 +463,77 @@ function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string {
}
function resolveNodeRunner(opts?: NodeNormOpts): string | null {
const execPath = typeof process.execPath === 'string' ? process.execPath : '';
const execPath = (opts && opts.execPath) || (typeof process.execPath === 'string' ? process.execPath : '');
if (!execPath) return null;
const stablePath = normalizeNodePath(execPath, opts);
return JSON.stringify(shellCmdProjection.posixNormalize(stablePath));
}
/**
* #3662 — the runtime-resolving node runner token for managed JS hooks.
*
* A bake-time absolute runner (`resolveNodeRunner`) only works in the
* environment that ran the installer; a config root shared across
* environments (the `--portable-hooks` scenario, or any settings.json under a
* mounted `$HOME`) carries a path that 404s with exit 127 everywhere else.
* This token is a POSIX `sh` command substitution that resolves node at
* hook-fire time, trying IN ORDER:
*
* 1. the baked installer path (absolute — keeps the #2979/#3002/#3017/#3022
* minimal-PATH guarantee: where the baked path exists it still wins,
* under any PATH, GUI launch included);
* 2. `command -v node` (quoted — one word even with spaces in the result);
* 3. the well-known stable layouts (`/usr/local/bin/node`, `/usr/bin/node`).
*
* The FIRST executable candidate wins; if none resolves the substitution
* yields an empty word and the hook fails exactly as a stale absolute path
* does today — no bare `node` token is ever emitted or depended on.
*
* One shape for every platform: emitted hook commands execute via POSIX `sh`
* (Claude-on-win32 runs Git Bash per #166/#580; `hookCommandNeedsPowerShellCallOperator`
* is an unused opt-in), and the baked path is posixNormalize'd before escaping
* (escapePosixDoubleQuoted — the Shell Command Projection seam owns quoting).
* The portable resolver script (hooks/gsd-node-runner.sh) resolves through a
* SUPERSET of this candidate list — keep the two lists consistent.
*/
function buildNodeRunnerChainToken(opts?: NodeNormOpts): string | null {
const execPath = (opts && opts.execPath) || (typeof process.execPath === 'string' ? process.execPath : '');
if (!execPath) return null;
const stablePath = shellCmdProjection.posixNormalize(normalizeNodePath(execPath, opts));
const baked = escapePosixDoubleQuoted(stablePath);
// Absolute candidates only (leading / or a win32 drive letter): a relative
// `command -v node` hit (legal under a relative PATH entry) must never
// promote repo-cwd content into the runner slot. The gate uses parameter
// expansion + [ ] — deliberately NO `case` (its `)` terminates the command
// substitution under macOS's stock bash 3.2 /bin/sh, breaking the hook).
// \${…} below stays a literal shell parameter expansion, not TS interpolation.
return `"$(for n in "${baked}" "$(command -v node)" /usr/local/bin/node /usr/bin/node; do [ -x "$n" ] && { [ "\${n#/}" != "$n" ] || [ "\${n#?:}" != "$n" ]; } && printf '%s' "$n" && break; done)"`;
}
/**
* #3662 — the install-time node path as a shell-safe QUOTED token, carrying
* the same double-quote escaping as the chain token (`escapePosixDoubleQuoted`
* — $ ` " \), NOT bare JSON quoting. The portable resolver's first argument
* is executed by the host shell before the resolver sees argv, so a path
* containing shell metacharacters must arrive escaped.
*/
function buildBakedNodeToken(opts?: NodeNormOpts): string | null {
const execPath = (opts && opts.execPath) || (typeof process.execPath === 'string' ? process.execPath : '');
if (!execPath) return null;
const stablePath = shellCmdProjection.posixNormalize(normalizeNodePath(execPath, opts));
return `"${escapePosixDoubleQuoted(stablePath)}"`;
}
/**
* #3662 — basename of the portable node resolver staged into the install's
* hooks/ directory. Under `--portable-hooks`, managed JS hook commands route
* through it (`bash "<hooks>/gsd-node-runner.sh" "<baked-node>" "<script>.js"`)
* so the SAME staged file works for every install: the install-time node path
* travels as the resolver's first argument (tried first — the minimal-PATH
* guarantee), ahead of `command -v node` and the well-known fallback list.
*/
const NODE_RUNNER_RESOLVER_HOOK = 'gsd-node-runner.sh';
interface BashRunnerOpts {
platform?: string;
env?: NodeJS.ProcessEnv;
@@ -516,8 +589,14 @@ interface RewriteOpts {
runtime?: string;
}
function rewriteLegacyManagedNodeHookCommands(settings: Settings, absoluteRunner: string, opts?: RewriteOpts): boolean {
if (!settings || !settings.hooks || !absoluteRunner) return false;
// #3662 — recognize the two runtime-resolving command shapes the installer
// emits, so the rewriter never churns (or un-does) an entry that already
// works in every environment sharing the config root.
const CHAIN_RUNNER_COMMAND = /^"\$\(for n in [\s\S]*?printf '%s' "\$n" && break; done\)"\s+\S/;
const RESOLVER_RUNNER_COMMAND = /^(?:"[^"]*bash(\.exe)?"|bash)\s+"[^"]*gsd-node-runner\.sh"\s+"[^"]*"\s+\S/;
function rewriteLegacyManagedNodeHookCommands(settings: Settings, runnerToken: string, opts?: RewriteOpts): boolean {
if (!settings || !settings.hooks || !runnerToken) return false;
if (!opts) opts = {};
const platform = opts.platform || process.platform;
let changed = false;
@@ -533,20 +612,23 @@ function rewriteLegacyManagedNodeHookCommands(settings: Settings, absoluteRunner
if (hadPowerShellCallOperator) {
trimmed = trimmed.replace(/^&\s+/, '').trim();
}
if (CHAIN_RUNNER_COMMAND.test(trimmed) || RESOLVER_RUNNER_COMMAND.test(trimmed)) continue;
const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
if (!m) continue;
let _runnerToken: string, scriptToken: string, scriptPath: string;
let scriptToken: string, scriptPath: string;
if (/^node\s+/.test(trimmed)) {
_runnerToken = 'node';
scriptToken = m[1];
scriptPath = m[2] || m[3] || m[4] || '';
} else {
_runnerToken = m[1];
const runnerPath = shellCmdProjection.posixNormalize(m[2] || m[3] || m[4] || '');
const stableRunner = normalizeNodePath(runnerPath);
if (stableRunner === runnerPath && platform !== 'win32') continue;
// #3662: the pre-fix two-token shape baked an absolute node path at
// install time. A foreign-but-stable runner (valid in the
// environment that wrote it, absent here) used to be SKIPPED — the
// exact mechanism behind the mixed state where no environment can
// run all hooks. Every two-token managed entry now re-projects onto
// the runtime-resolving runner, whatever environment baked it.
scriptToken = m[5];
scriptPath = m[6] || m[7] || m[8] || '';
}
@@ -554,7 +636,7 @@ function rewriteLegacyManagedNodeHookCommands(settings: Settings, absoluteRunner
if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue;
const projectedCommand = projectLegacySettingsHookCommand({
absoluteRunner,
runnerToken,
scriptPath,
scriptToken,
runtime: opts.runtime || 'generic',
@@ -1035,28 +1117,77 @@ function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookC
return JSON.stringify(shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName);
}
const nodeRunner = resolveNodeRunner();
const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
if (runner === null) return null;
// .sh hooks keep the pre-#3662 shape everywhere: the bash runner resolves
// at install time like today, and `bash` itself is a PATH-stable binary
// (the absolute Git-Bash discovery covers win32 — #580/#3393).
if (isShellHook) {
const runner = resolveBashRunner(opts);
if (runner === null) return null;
if (opts.portableHooks) {
const portableBaseDir = projectPortableHookBaseDir({
configDir,
homeDir: os.homedir(),
});
if (opts.portableHooks) {
const portableBaseDir = projectPortableHookBaseDir({
configDir,
homeDir: os.homedir(),
});
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
runtime: opts.runtime || 'generic',
platform,
hookShell,
});
}
const hooksPath = shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName;
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
runtime: opts.runtime || 'generic',
scriptPath: hooksPath,
runtime,
platform,
hookShell,
});
}
// JS hooks (#3662): the node runner is resolved at hook-fire time, never
// baked as a bare absolute path — an install-environment absolute path is
// exactly what breaks with exit 127 when the config root is shared across
// environments with different node layouts.
if (opts.portableHooks) {
// Portable installs route through the staged resolver: the baked absolute
// path travels as the resolver's FIRST argument (tried first, so the
// minimal-PATH guarantee holds), then `command -v node`, then the
// well-known list — one staged file, no per-install templating. The
// token is shell-escaped like the chain (the host shell expands the
// argument before bash sees argv), not merely JSON-quoted.
const bakedToken = buildBakedNodeToken(opts);
if (bakedToken === null) return null;
const portableBaseDir = projectPortableHookBaseDir({
configDir,
homeDir: os.homedir(),
});
// Absolute Git-Bash discovery on win32 when available (#580); `bash` on
// PATH otherwise — the same assumption .sh hooks already make.
const resolverRunner = resolveBashRunner(opts) || 'bash';
return shellCmdProjection.projectShellCommandText({
runnerToken: resolverRunner,
argTokens: [
JSON.stringify(`${portableBaseDir}/hooks/${NODE_RUNNER_RESOLVER_HOOK}`),
bakedToken,
JSON.stringify(`${portableBaseDir}/hooks/${hookName}`),
],
runtime,
platform,
hookShell,
});
}
const chainRunner = buildNodeRunnerChainToken(opts);
if (chainRunner === null) return null;
const hooksPath = shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName;
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: hooksPath,
return shellCmdProjection.projectShellCommandText({
runnerToken: chainRunner,
argTokens: [JSON.stringify(hooksPath)],
runtime,
platform,
hookShell,
@@ -2671,7 +2802,9 @@ export = {
reconcileManagedShellHookCommands,
normalizeNodePath,
resolveNodeRunner,
buildNodeRunnerChainToken,
resolveBashRunner,
NODE_RUNNER_RESOLVER_HOOK,
// Atomic write seam (shared with bin/install.js so all writes participate
// in install.js's _cleanTmpFiles() scoped temp-cleanup).

View File

@@ -205,6 +205,13 @@ const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
'gsd-read-injection-scanner.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
// #3662: the three PreToolUse guards are GSD-managed JS hooks (registered
// only-if-absent by applySettingsJsonHooks) but sat outside this set, so
// the #2979 runner rewriter never reached them — the issue's reported
// mixed state lived on exactly these hooks.
'gsd-write-guard.js',
'gsd-agent-isolation-guard.js',
'gsd-worktree-path-guard.js',
]),
'codex-toml': new Set([
'gsd-check-update.js',
@@ -225,6 +232,14 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
// #3662: same three guards as MANAGED_HOOK_BASENAMES_BY_SURFACE above —
// their absence here meant isManagedHookCommand never recognized them, so
// the settings.json→settings.local.json migration filter (and uninstall
// cleanup) skipped GSD's own guard entries. Folded-in fix, surfaced by the
// #3662 issue thread.
'gsd-write-guard.js',
'gsd-agent-isolation-guard.js',
'gsd-worktree-path-guard.js',
]),
'codex-toml': new Set([
'gsd-check-update.js',
@@ -320,21 +335,27 @@ const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
* Non-Windows keeps the original script token shape when provided (single
* quote / bareword / quoted), while Windows normalizes to double-quoted
* forward-slash path tokens for stable cross-shell behavior.
*
* #3662: `runnerToken` is the runner the current install would emit — since
* #3662 that is the runtime-resolving chain token (see
* buildNodeRunnerChainToken), not a bare absolute path, so re-projected
* entries converge onto a runner that resolves in every environment sharing
* the config root.
*/
export function projectLegacySettingsHookCommand({
absoluteRunner,
runnerToken,
scriptPath,
scriptToken,
runtime = 'generic',
platform = process.platform,
}: {
absoluteRunner?: string | null;
runnerToken?: string | null;
scriptPath?: string | null;
scriptToken?: string | null;
runtime?: string;
platform?: string;
}): string | null {
if (!absoluteRunner || !scriptPath) return null;
if (!runnerToken || !scriptPath) return null;
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
// quoted prefix (local installs) is already a valid shell token — only the
@@ -352,7 +373,7 @@ export function projectLegacySettingsHookCommand({
: JSON.stringify(normalizedScriptPath))
: (scriptToken || JSON.stringify(normalizedScriptPath));
return projectShellCommandText({
runnerToken: absoluteRunner,
runnerToken,
argTokens: [commandScriptToken],
runtime,
platform,

View File

@@ -385,6 +385,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -456,6 +456,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -456,6 +456,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -385,6 +385,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -456,6 +456,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -385,6 +385,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -456,6 +456,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -386,6 +386,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -456,6 +456,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -352,6 +352,7 @@
"gsd-hooks/gsd-cursor-subagent-stop.js",
"gsd-hooks/gsd-ensure-canonical-path.js",
"gsd-hooks/gsd-graphify-update.sh",
"gsd-hooks/gsd-node-runner.sh",
"gsd-hooks/gsd-phase-boundary.sh",
"gsd-hooks/gsd-prompt-guard.js",
"gsd-hooks/gsd-read-guard.js",

View File

@@ -385,6 +385,7 @@
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-node-runner.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",

View File

@@ -0,0 +1,555 @@
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runNode, runHook } = require('./helpers/process-seam.cjs');
const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
const REPO_ROOT = path.join(__dirname, '..');
const RESOLVER_HOOK = 'gsd-node-runner.sh';
const FOREIGN_NODE = '/nonexistent-envA/bin/node';
const STUB_HOOK_BODY = ['#!/usr/bin/env node', 'process.stdout.write("hook-ran-ok");', ''].join('\n');
const MANAGED_JS_HOOKS = [
'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-statusline.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',
];
const GUARD_HOOKS = [
'gsd-write-guard.js',
'gsd-agent-isolation-guard.js',
'gsd-worktree-path-guard.js',
];
// Every quoted absolute-node token (POSIX-form as emitted, .exe for win32
// projections) becomes the foreign environment's nonexistent path — the exact
// string a shared config root holds in environment B.
function foreignizeNodeTokens(command) {
return command.replace(/"(\/[^"]*\/node(\.exe)?)"/g, `"${FOREIGN_NODE}"`);
}
function isAbsoluteNodeTokenQuoted(token) {
return /^"(\/[^"]*\/node(\.exe)?)?"$/.test(token);
}
function makeHookTree(t, homeDirName) {
const home = createTempDir(homeDirName);
const configDir = path.join(home, '.claude');
fs.mkdirSync(path.join(configDir, 'hooks'), { recursive: true });
for (const hook of [...MANAGED_JS_HOOKS, ...GUARD_HOOKS]) {
fs.writeFileSync(path.join(configDir, 'hooks', hook), STUB_HOOK_BODY);
}
fs.writeFileSync(path.join(configDir, 'hooks', 'gsd-session-state.sh'), '#!/bin/sh\nexit 0\n');
const repoResolver = path.join(REPO_ROOT, 'hooks', RESOLVER_HOOK);
if (fs.existsSync(repoResolver)) {
fs.copyFileSync(repoResolver, path.join(configDir, 'hooks', RESOLVER_HOOK));
}
t.after(() => cleanup(home));
return { home, configDir };
}
function withHome(t, home) {
const origHome = process.env.HOME;
const origProfile = process.env.USERPROFILE;
process.env.HOME = home;
process.env.USERPROFILE = home;
t.after(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = origProfile;
});
}
function makeNodeStub(t, label) {
const dir = createTempDir(`gsd3662-stub-${label}`);
const stubPath = path.join(dir, 'node');
fs.writeFileSync(stubPath, ['#!/bin/sh', `echo "STUB-NODE:$1"`, ''].join('\n'));
fs.chmodSync(stubPath, 0o755);
t.after(() => cleanup(dir));
return stubPath;
}
function posixRealNodeDir() {
return process.execPath.replace(/\\/g, '/').replace(/\/[^/]*$/, '');
}
function writeCommandFile(t, command, name) {
const file = path.join(createTempDir('gsd3662-cmd'), `${name}.cmd`);
fs.writeFileSync(file, command + '\n');
t.after(() => cleanup(path.dirname(file)));
return file;
}
// Local class departure (CONTRIBUTING "Class-norm timeouts"): each execution
// row spans a bash spawn + a node child against a tiny script; 20s absorbs
// cold CI-runner startup for both without approaching the 600s ceiling. The
// existing classes (PROBE/GIT/BUILD/INSTALL) each describe a single-process
// shape, not this bash+node pair.
const RUN_TIMEOUT_MS = 20000;
// POSIX-sh execution semantics (the emitted command grammar) are proven on the
// POSIX lanes; win32 lanes prove the structural projection (see the win32
// describe below) — Git Bash availability is not guaranteed on every runner.
function skipOnWin32(t, reason) {
if (process.platform === 'win32') {
t.skip(reason);
return true;
}
return false;
}
describe('#3662 runtime-resolving managed hook runners', () => {
describe('emitted commands resolve node at run time (criteria 1+2)', () => {
test('portable hook command resolves node when baked path is absent', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'portable-run');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
portableHooks: true,
runtime: 'claude',
});
assert.ok(typeof emitted === 'string' && emitted.length > 0);
const foreign = foreignizeNodeTokens(emitted);
const file = writeCommandFile(t, foreign, 'portable');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${foreign}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
test('plain hook command resolves node when baked path is absent', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'plain-run');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
});
assert.ok(typeof emitted === 'string' && emitted.length > 0);
const foreign = foreignizeNodeTokens(emitted);
const file = writeCommandFile(t, foreign, 'plain');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${foreign}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
test('chain prefers the baked absolute runner first', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'chain-order');
withHome(t, home);
const stub = makeNodeStub(t, 'chain-order');
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
execPath: stub,
});
assert.ok(typeof emitted === 'string', `no command emitted: ${emitted}`);
const file = writeCommandFile(t, emitted, 'chain-order');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /^STUB-NODE:\S+gsd-statusline\.js/);
});
test('resolver prefers its first argument', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'resolver-order');
withHome(t, home);
const stub = makeNodeStub(t, 'resolver-order');
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
portableHooks: true,
runtime: 'claude',
execPath: stub,
});
assert.ok(typeof emitted === 'string', `no command emitted: ${emitted}`);
const file = writeCommandFile(t, emitted, 'resolver-order');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /^STUB-NODE:\S+gsd-statusline\.js/);
});
test('chain resolves with the real execPath under a minimal PATH', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'minimal-path');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
});
assert.ok(typeof emitted === 'string');
const file = writeCommandFile(t, emitted, 'minimal-path');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: '/usr/bin:/bin' },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
test('no bare node token in any emitted hook command', (t) => {
const { home, configDir } = makeHookTree(t, 'bare-node');
withHome(t, home);
for (const hook of MANAGED_JS_HOOKS) {
for (const portableHooks of [false, true]) {
const emitted = hooksSurface.buildHookCommand(configDir, hook, {
portableHooks,
runtime: 'claude',
});
assert.ok(typeof emitted === 'string', `${hook} (portable=${portableHooks}) emitted nothing`);
assert.ok(
!/(^|\s)["']?node["']?(\s|$)/.test(emitted),
`${hook} (portable=${portableHooks}) carries a bare node token: ${emitted}`,
);
}
}
});
test('every managed js hook emits a resolving runner', (t) => {
const { home, configDir } = makeHookTree(t, 'all-managed');
withHome(t, home);
for (const hook of MANAGED_JS_HOOKS) {
const plain = hooksSurface.buildHookCommand(configDir, hook, { runtime: 'claude' });
assert.ok(
typeof plain === 'string' && plain.startsWith('"$(for n in '),
`${hook} plain runner is not the resolve chain: ${plain}`,
);
const portable = hooksSurface.buildHookCommand(configDir, hook, {
portableHooks: true,
runtime: 'claude',
});
assert.ok(
typeof portable === 'string' && portable.includes(RESOLVER_HOOK),
`${hook} portable command does not route through the resolver: ${portable}`,
);
}
});
});
describe('update convergence — the mixed state cannot persist (criterion 3)', () => {
function settingsWith(entries) {
return {
hooks: {
PreToolUse: [
{
matcher: 'Write',
hooks: entries.map((command) => ({ type: 'command', command })),
},
],
},
};
}
test('update converges the mixed-state foreign runners from the issue', () => {
const settings = settingsWith([
`"${FOREIGN_NODE}" "/home/u/.claude/hooks/gsd-write-guard.js"`,
`"/usr/bin/node" "/home/u/.claude/hooks/gsd-read-guard.js"`,
]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, true, 'rewriter reported no change over a mixed-state install');
for (const entry of settings.hooks.PreToolUse[0].hooks) {
assert.ok(
entry.command.startsWith('"$(for n in '),
`entry not converged to the resolve chain: ${entry.command}`,
);
assert.ok(
!isAbsoluteNodeTokenQuoted(entry.command.split(' ')[0] || ''),
`absolute runner survived: ${entry.command}`,
);
}
});
test('rewriter converges the three guard hooks', () => {
const settings = settingsWith(
GUARD_HOOKS.map((hook) => `"${FOREIGN_NODE}" "/home/u/.claude/hooks/${hook}"`),
);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, true);
for (const entry of settings.hooks.PreToolUse[0].hooks) {
assert.ok(
entry.command.startsWith('"$(for n in '),
`guard entry not converged: ${entry.command}`,
);
}
});
test('user and args-form entries are never rewritten', () => {
const userCommand = `"/usr/bin/node" "/home/u/own-tools/not-gsd.js"`;
const argsFormCommand = `"${FOREIGN_NODE}" "/home/u/.claude/hooks/gsd-statusline.js"`;
const settings = {
hooks: {
PreToolUse: [
{
matcher: 'Write',
hooks: [
{ type: 'command', command: userCommand },
{ type: 'command', command: argsFormCommand, args: ['extra'] },
],
},
],
},
};
hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, hooksSurface.buildNodeRunnerChainToken(), {
platform: 'darwin',
runtime: 'claude',
});
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, userCommand);
assert.equal(settings.hooks.PreToolUse[0].hooks[1].command, argsFormCommand);
});
test('unmanaged basenames are not converged', () => {
const command = `"${FOREIGN_NODE}" "/home/u/.claude/hooks/my-own-hook.js"`;
const settings = settingsWith([command]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, false);
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, command);
});
test('legacy bare-node entries are still converged', () => {
const settings = settingsWith([`node "/home/u/.claude/hooks/gsd-statusline.js"`]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, true);
assert.ok(
settings.hooks.PreToolUse[0].hooks[0].command.startsWith('"$(for n in '),
`bare-node entry not converged: ${settings.hooks.PreToolUse[0].hooks[0].command}`,
);
});
test('rewriter is idempotent and leaves resolving shapes alone', () => {
const chainCommand = `"$(for n in "${FOREIGN_NODE}" "$(command -v node)" /usr/local/bin/node /usr/bin/node; do [ -x "$n" ] && { [ "\${n#/}" != "$n" ] || [ "\${n#?:}" != "$n" ]; } && printf '%s' "$n" && break; done)" "/home/u/.claude/hooks/gsd-statusline.js"`;
const resolverCommand = `bash "/home/u/.claude/hooks/${RESOLVER_HOOK}" "${FOREIGN_NODE}" "/home/u/.claude/hooks/gsd-statusline.js"`;
const settings = settingsWith([chainCommand, resolverCommand]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, false, 'already-resolving entries were churned');
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, chainCommand);
assert.equal(settings.hooks.PreToolUse[0].hooks[1].command, resolverCommand);
});
});
describe('resolver script (hooks/gsd-node-runner.sh)', () => {
test('installer stages the node resolver script', () => {
const repoResolver = path.join(REPO_ROOT, 'hooks', RESOLVER_HOOK);
assert.ok(fs.existsSync(repoResolver), `${RESOLVER_HOOK} missing from hooks/`);
assert.ok(
HOOKS_TO_COPY.includes(RESOLVER_HOOK),
`${RESOLVER_HOOK} missing from HOOKS_TO_COPY (build-hooks.js)`,
);
const build = runNode([path.join(REPO_ROOT, 'scripts', 'build-hooks.js')], {
timeoutMs: BUILD_TIMEOUT_MS,
});
assert.equal(build.exitCode, 0, `build-hooks failed: ${build.stderr}`);
assert.ok(
fs.existsSync(path.join(REPO_ROOT, 'hooks', 'dist', RESOLVER_HOOK)),
`${RESOLVER_HOOK} not copied to hooks/dist/`,
);
});
test('resolver fails visibly when no node can be found', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'resolver-none');
const resolver = path.join(configDir, 'hooks', RESOLVER_HOOK);
assert.ok(fs.existsSync(resolver), 'resolver not staged into fixture');
const emptyBin = createTempDir('gsd3662-emptybin');
t.after(() => cleanup(emptyBin));
const result = runHook(resolver, [FOREIGN_NODE, path.join(configDir, 'hooks', 'gsd-statusline.js')], {
interpreter: 'bash',
cwd: home,
env: {
...process.env,
HOME: home,
PATH: `${emptyBin}:/usr/bin:/bin`,
GSD_NODE_RUNNER_NO_FALLBACKS: '1',
},
timeoutMs: RUN_TIMEOUT_MS,
});
assert.notEqual(result.exitCode, 0, 'resolver silently succeeded with no node anywhere');
assert.ok(result.stderr.length > 0, 'resolver produced no stderr diagnostic');
assert.ok(!/\bnode\b\s+\S*gsd-statusline/.test(result.stderr + result.stdout), 'resolver leaked a bare-node invocation');
});
});
describe('unchanged surfaces (criterion 5)', () => {
test('sh hook commands are unchanged', (t) => {
const { home, configDir } = makeHookTree(t, 'sh-pins');
withHome(t, home);
const plain = hooksSurface.buildHookCommand(configDir, 'gsd-session-state.sh', {
runtime: 'claude',
});
// The pre-#3662 .sh shapes, byte for byte: claude-on-win32 omits the
// bash runner entirely (shellHookOmitsBashRunner, #580/#3393) and emits
// the bare script token; POSIX prefixes resolveBashRunner's answer.
const bareSh = process.platform === 'win32';
const shRunner = hooksSurface.resolveBashRunner({ platform: process.platform }) || 'bash';
const plainScript = JSON.stringify(path.join(configDir, 'hooks', 'gsd-session-state.sh').replace(/\\/g, '/'));
assert.equal(plain, bareSh ? plainScript : `${shRunner} ${plainScript}`);
const portable = hooksSurface.buildHookCommand(configDir, 'gsd-session-state.sh', {
portableHooks: true,
runtime: 'claude',
});
assert.equal(portable, bareSh ? '"$HOME/.claude/hooks/gsd-session-state.sh"' : `${shRunner} "$HOME/.claude/hooks/gsd-session-state.sh"`);
});
test('chain embeds shell-hostile baked paths safely', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'hostile-path');
withHome(t, home);
const hostileDir = createTempDir('gsd3662-hostile');
const weird = path.join(hostileDir, 'sp ace$dollar');
fs.mkdirSync(weird, { recursive: true });
t.after(() => cleanup(hostileDir));
const stubPath = path.join(weird, 'node');
fs.writeFileSync(stubPath, ['#!/bin/sh', 'echo "STUB-NODE:$1"', ''].join('\n'));
fs.chmodSync(stubPath, 0o755);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
execPath: stubPath,
});
assert.ok(typeof emitted === 'string');
const file = writeCommandFile(t, emitted, 'hostile');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /^STUB-NODE:\S*gsd-statusline\.js/, 'script path split or expanded by the shell');
});
test('chain stays valid sh when no candidate resolves', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'sh-valid');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
execPath: FOREIGN_NODE,
});
assert.ok(typeof emitted === 'string');
const foreign = foreignizeNodeTokens(emitted);
const file = writeCommandFile(t, foreign, 'sh-valid');
const dir = createTempDir('gsd3662-shn');
const checker = path.join(dir, 'syntax-check.sh');
fs.writeFileSync(checker, ['#!/bin/sh', 'exec bash -n "$1"', ''].join('\n'));
fs.chmodSync(checker, 0o755);
t.after(() => cleanup(dir));
const result = runHook(checker, [file], {
interpreter: 'bash',
env: { ...process.env, HOME: home, PATH: '/usr/bin:/bin' },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `bash -n rejected the emitted command (${foreign}): ${result.stderr}`);
});
test('win32 projection keeps forward-slash paths in the chain', () => {
const emitted = hooksSurface.buildHookCommand('C:\\Users\\u\\.claude', 'gsd-statusline.js', {
runtime: 'claude',
platform: 'win32',
execPath: 'C:\\Program Files\\nodejs\\node.exe',
});
assert.ok(typeof emitted === 'string', `no command emitted: ${emitted}`);
assert.ok(emitted.includes('"$(for n in '), `win32 chain shape missing: ${emitted}`);
assert.ok(emitted.includes('C:/Program Files/nodejs/node.exe'), `forward-slash baked path missing: ${emitted}`);
assert.ok(!emitted.includes('\\'), `backslash survived win32 projection: ${emitted}`);
});
});
describe('managed-set parity (RULESET.GENERATIVE-FIX)', () => {
// #3662: the guard basenames live in TWO parallel surfaces — the runner
// rewriter's gate (MANAGED_HOOK_BASENAMES_BY_SURFACE, via
// isManagedHookBasename) and the command recognizer
// (MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE, via isManagedHookCommand —
// uninstall + settings-migration recognition). The two lists must agree
// on every JS hook; this row fails the moment one gains a basename the
// other lacks.
test('every managed js basename is recognized by both managed sets', () => {
const projection = require('../gsd-core/bin/lib/shell-command-projection.cjs');
for (const basename of [...MANAGED_JS_HOOKS, ...GUARD_HOOKS]) {
const scriptPath = `/home/u/.claude/hooks/${basename}`;
assert.ok(
projection.isManagedHookBasename(scriptPath, { surface: 'settings-json' }),
`${basename} missing from the runner-rewriter gate set (isManagedHookBasename)`,
);
const command = `"/usr/bin/node" "${scriptPath}"`;
assert.ok(
projection.isManagedHookCommand(command, { surface: 'settings-json' }),
`${basename} missing from the command recognizer set (isManagedHookCommand)`,
);
}
});
});
describe('kimi config.toml surface', () => {
test('kimi toml hook commands resolve node at run time', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'kimi-run');
withHome(t, home);
const block = hooksSurface.buildKimiHooksTomlBlock(configDir, {
hookOpts: { portableHooks: false, runtime: 'kimi' },
});
assert.ok(typeof block === 'string' && block.length > 0, 'kimi hooks block not built');
const commands = [...block.matchAll(/^command = "(.*)"$/gm)].map((m) =>
m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'),
);
assert.ok(commands.length > 0, 'no command entries found in kimi block');
const jsCommand = commands.find((c) => c.includes('gsd-prompt-guard.js'));
assert.ok(jsCommand, `no gsd-prompt-guard.js command in block: ${commands.join(' | ')}`);
const foreign = foreignizeNodeTokens(jsCommand);
const file = writeCommandFile(t, foreign, 'kimi');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${foreign}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
});
});

View File

@@ -1685,7 +1685,7 @@ describe('bug #3439: shell projection module owns managed-hook policy and legacy
test('projectLegacySettingsHookCommand preserves non-Windows script token shape', () => {
const command = projectLegacySettingsHookCommand({
absoluteRunner: '"/usr/local/bin/node"',
runnerToken: '"/usr/local/bin/node"',
scriptPath: '/x/hooks/gsd-statusline.js',
scriptToken: "'/x/hooks/gsd-statusline.js'",
platform: 'linux',
@@ -1699,7 +1699,7 @@ describe('bug #3439: shell projection module owns managed-hook policy and legacy
// inert for every runtime (including the former 'gemini' string and its
// Gemini-backend successor 'antigravity'). No `& ` prefix is ever added.
const command = projectLegacySettingsHookCommand({
absoluteRunner: '"C:/nvm4w/nodejs/node.exe"',
runnerToken: '"C:/nvm4w/nodejs/node.exe"',
scriptPath: 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js',
scriptToken: "'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'",
platform: 'win32',
@@ -1809,7 +1809,7 @@ describe('#1693 regression: Windows legacy-node rewrite must not double-quote a
test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => {
const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js';
const command = projectLegacySettingsHookCommand({
absoluteRunner: winRunner,
runnerToken: winRunner,
scriptPath: anchored,
scriptToken: anchored,
platform: 'win32',
@@ -1827,7 +1827,7 @@ describe('#1693 regression: Windows legacy-node rewrite must not double-quote a
test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => {
const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js';
const command = projectLegacySettingsHookCommand({
absoluteRunner: winRunner,
runnerToken: winRunner,
scriptPath: abs,
scriptToken: JSON.stringify(abs),
platform: 'win32',
@@ -1847,7 +1847,7 @@ describe('#1693 regression: Windows legacy-node rewrite must not double-quote a
// and this assertion would fail — that is what pins the gate.
test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => {
const command = projectLegacySettingsHookCommand({
absoluteRunner: '"/usr/local/bin/node"',
runnerToken: '"/usr/local/bin/node"',
scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js',
scriptToken: "'/x/hooks/gsd-statusline.js'",
platform: 'linux',