* 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:
5
.changeset/clever-hawks-wave.md
Normal file
5
.changeset/clever-hawks-wave.md
Normal 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)
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
76
hooks/gsd-node-runner.sh
Executable 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" "$@"
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/pi.json
vendored
1
tests/fixtures/install-tree/pi.json
vendored
@@ -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",
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -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",
|
||||
|
||||
555
tests/portable-node-runner.install.test.cjs
Normal file
555
tests/portable-node-runner.install.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user