fix(#4205): filter gsd_run, not gsd-tools, when isolating PATH in launcher fixtures (#4337)

* fix(#4205): filter gsd_run, not gsd-tools, when isolating PATH in launcher fixtures

The runtime launcher's PATH-fallback arm probes `command -v gsd_run`
(renamed from gsd-tools in #3146), but every PATH-isolation fixture in
runtime-launcher-parity.test.cjs filtered on the pre-rename name. A real
installed gsd_run reachable on PATH survived the filter and got invoked
in place of the fixture's runtime-home stub, so negative tests passed
without proving PATH was actually empty and positive home-fallback tests
failed with "Unknown command" errors from the unrelated real CLI.

Adds (B1), a regression test that plants a sentinel gsd_run on PATH and
asserts the resolver still falls through to the HERMES_HOME stub instead
of invoking it.

* docs(#4205): fix stale gsd-tools references in PATH-probe doc comments

Addresses agy adversarial review nits on PR #4205: several doc comments
and JSDoc blocks still described the launcher's PATH-fallback probe as
`gsd-tools` after the filter fix. Updates them to `gsd_run` to match the
actual `command -v gsd_run` probe and the corrected filters. No test
logic changes.

* test(#4205): tighten (B1) assertions and dedupe rationale comments

Addresses opus critical-code-reviewer/ponytail findings on PR #27:
- (B1): split the collapsed && assertion into two, matching neighbor
  (B)'s style, for clearer failure diagnostics.
- (B1): drop the dead `if (nodeBinDir)` guard — cleanup() already
  no-ops on a non-string argument (tests/helpers.cjs:452).
- (B1): drop the dead backslash-path normalization — the test is
  win32-skipped, so stdout paths are always POSIX.
- Six near-identical "#4205: probe target is gsd_run, not gsd-tools"
  comments collapsed to pointers at the one canonical explanation in
  buildIsolatedPath().

No behavior change; 31/31 tests still pass.

* fix(#4205): scrub ambient config-dir env vars leaking into launcher fixtures

Same class of bug as the PATH leak this issue reports, different vector:
the resolver's runtime-home elif chain checks CLAUDE_CONFIG_DIR before
HERMES_HOME, CURSOR_CONFIG_DIR, CODEX_HOME, etc., but the fixtures
targeting those later arms never cleared the earlier ones from the
spread process.env. An ambient CLAUDE_CONFIG_DIR pointing at a real
install silently wins over the fixture's intended stub, exactly like
the reported gsd_run PATH leak. Confirmed with a real leaked install:
red on tests (D)/(H)/bug-211 (C)/(D)/(B1)/(B)/(C) without the fix,
green with it.

Also removes bug-891's (B) test, now a strict subset of (B1): once the
sentinel is filtered by buildIsolatedPath(), both tests exercise the
identical HERMES_HOME resolution with the identical script and env —
(B1) already asserts everything (B) did, plus the sentinel-not-invoked
check. Updated the block's header docblock to match.

30/30 tests pass (31 minus the removed duplicate).

* fix(#4205): scrub CODEX_HOME/XDG_CONFIG_HOME, restore (B) on Windows

Adversarial review of this PR found three more instances of the exact
leak class the PR exists to close.

(H) asserts the $HOME/.codex fallback but never cleared an ambient
CODEX_HOME, which overrides that default outright. Proven load-bearing:
with a fake install planted at CODEX_HOME the test fails without this
scrub and the leaked install's own output appears in stdout.

The two "every arm must miss" hard-error fixtures cleared all 16
config-dir vars but not XDG_CONFIG_HOME, which the resolver's opencode
and kilo arms fall back through as
${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode} — a
host XDG_CONFIG_HOME leaks past a fake HOME.

Restore bug-891's (B), deleted here as a subset of (B1). It is not one
on Windows: (B) ran cross-platform, (B1) is POSIX-only because it plants
an executable sh sentinel, so the deletion left the HERMES_HOME arm with
no Windows coverage. Restored with the CLAUDE_CONFIG_DIR scrub its
sibling fixtures already carry.

* docs(#4205): correct makeIsolatedPath's docblock and the bug-891 header

The doc-fix commit earlier in this branch rewrote makeIsolatedPath's
docblock from "strips gsd-tools" to "strips gsd_run". Both are false:
the function filters nothing, returns process.env.PATH whole, and the
"noToolsBin dir that shadows gsd_run with a sentinel" it describes does
not exist — every caller passes an empty directory. Its isolation comes
from resolution order, since the RUNTIME_DIR/.claude arm fires before
the PATH arm. Say that instead, and point anyone who needs the PATH arm
itself to miss at buildIsolatedPath(), which does filter.

Add (B) to the bug-891 asserts list; restoring it in 48cf0b917 left the
header describing a test set the file no longer has. Mark (B) and (B1)
cross-platform and POSIX-only respectively, which is why both exist.

Drop one more stale "remove gsd-tools" comment the doc pass missed.

* fix(#4205): derive the fixture env scrub, stop writing to CLAUDE_ENV_FILE

Two findings from review, both measured.

CLAUDE_ENV_FILE was never scrubbed. The snippet's tail appends
`export PATH='<dir>'` to it whenever it is set, so running this suite on
a host that exports it wrote 11 lines into the developer's real env
file, each naming a /tmp fixture directory the test had already deleted
— they accumulate per run and prepend dead entries to the PATH of every
later shell. The reported bug was fixtures READING developer state; this
was them writing to it. Now 0 lines.

The 17-key scrub list was hand-written, which tests/helpers.cjs already
warns against: "#2665: this list is DERIVED, not hand-maintained. A
hand-written list is exactly what reopened this bug twice". It was right
— the hand list here missed CODEX_HOME and XDG_CONFIG_HOME until review
caught them, and a 17th runtime home would have left it silently stale.
Replace both copies with TEST_ENV_BASE, derived from the registry the
resolver itself reads, applied at runBashFile/runResolver so every
fixture that sources the snippet is covered rather than the two that
remembered to ask. GEMINI_CONFIG_DIR is added explicitly: the runtime is
retired (#1928) so the registry no longer carries it, but the snippet
still probes its arm.

Verified by pointing all 16 config-dir vars plus XDG_CONFIG_HOME at a
real install tree: 30/30 pass.

Fold (B1) into (B). Reverting the filter under a clean PATH left the old
(B) green — it only caught the bug on an already-leaking machine — while
(B1) caught it anywhere but was skipped on Windows. One test now does
both: it plants the PATH sentinel on POSIX and still exercises the
HERMES_HOME arm on Windows. Mutation-checked both ways on a PATH with no
real gsd_run. Assert the hermes dir itself rather than "gsd-core/bin/",
which every resolver arm ends in and so cannot tell them apart.

* fix(#4205): scrub BASH_ENV and reject an empty RUNTIME_DIR in runResolver

BASH_ENV defeated the whole scrub. Non-interactive bash sources it
before the script runs, which is after the env: object is applied, so a
single inherited var re-injects any of the others. Measured: a BASH_ENV
exporting CODEX_HOME turned (H) red; blanked, 30/30.

runResolver passed RUNTIME_DIR: runtimeDir || '', and '' is
indistinguishable from unset to ${RUNTIME_DIR:-$(git rev-parse
--show-toplevel)} — an empty value falls back to the real repo root and
resolves its real install, the leak this issue is about. Both callers
already pass one, so require it rather than paper over it.

* test(#4344): plant the leaked gsd_run sentinel on Windows too

(B) planted its gsd_run sentinel only on POSIX, so the Windows shards
proved nothing about buildIsolatedPath()'s PATH filter — the exact gap
#4344 recorded. npm's global installs write an extensionless Bourne shim
beside gsd_run.cmd, and fs.constants.X_OK behaves like F_OK on Windows,
so the existing probe already sees the leak there; only the fixture was
POSIX-gated.

Plant the sentinel on every platform and assert that buildIsolatedPath()
strips its directory from the returned PATH. That assertion is red on
both platforms when the filter probes the wrong name, and unlike the
stdout assertions it does not depend on the MSYS mount's exec
heuristics. Restore process.env.PATH before the child spawns rather than
in a t.after hook: on Windows process.env spreads as 'Path', so a
still-live leak would compete with snippetEnv()'s 'PATH' override for
the casing the child receives.

Refs #4205

* fix(#4205): stop the host PATH leaking past snippetEnv on Windows

Adversarial review (agy, gemini-3.8-flash-high) found that the fixtures'
PATH isolation is defeatable on Windows regardless of which name the
filter probes. Windows environment variables are case-insensitive but a
spread of process.env is not: the host PATH enumerates as 'Path', so
'{ ...process.env, PATH: isolated }' yields both keys, and libuv's
make_program_env sorts the child's environment block case-insensitively
without ever dropping duplicates. The child could therefore resolve the
host PATH. snippetEnv() now drops every other casing whenever a caller
supplies its own PATH.

Also from that review:

- buildIsolatedPath() takes the PATH to filter as a parameter, so (B0)
  and (B) no longer mutate process.env.PATH and no longer need
  try/finally restores.
- The two loud-guard fixtures asserted 'not found' OR 'ERROR', which
  bash's own 'node: command not found' satisfies; they now assert the
  launcher's 'ERROR: gsd-tools.cjs not found'.
- Corrected a comment counting three scrub keys as two, and two comments
  crediting a removed env argument for clearing ambient config dirs
  rather than snippetEnv()'s derived TEST_ENV_BASE.

Refs #4344

* fix(#4205): make the fixtures' node shim work on Windows

bug-211 (C) located node with `which node` through the process seam.
`which` is not a Windows binary; the fixture only survived CI because
Git Bash ships one. process.execPath is the same answer without the
spawn, and (H) already used it.

Both fixtures then built their node shim with fs.symlinkSync, which
raises EPERM on Windows without developer mode or elevation — the same
reason buildIsolatedPath() skips its own symlink step there. The shared
linkNodeShim() helper hard-links instead on that platform (no privilege
required) and falls back to a copy across volumes.

Found by adversarial review (agy, gemini-3.8-flash-high).

Refs #4344

* fix(#4205): make the launcher PATH isolation extension-aware and Windows-safe

trek-e's review asks for a Windows-safe node fallback, an extension-aware
filter, and a Windows regression test, in that order: broadening the
filter first can strip the directory node itself lives in.

buildIsolatedPath() now always prepends a directory holding node, on
every platform, via the linkExecutable() helper (hard link on Windows,
where symlinks need elevation). nodeBinDir is no longer nullable and the
win32 early return is gone, so the fallback exists before the filter
widens. (B0), the co-location invariant, therefore runs on Windows
instead of being skipped on the one platform that had no fallback.

The filter probes every name the launcher's `command -v gsd_run` arm can
resolve. msys bash appends an executable extension during PATH lookup,
so a directory holding only gsd_run.exe is reachable on Windows although
gsd_run is absent. PATHEXT is folded in as well; it over-matches, which
costs nothing now that node is always supplied separately. (B0) asserts
every name in that set is filtered, and (B) plants a gsd_run.exe sentinel
on Windows beside the extensionless one npm installs.

The predicate lived in five hand-maintained copies — the drift that
caused #4205 in the first place, and four of the copies pointed readers
at a buildIsolatedPath() that was block-scoped out of their reach.
buildIsolatedPath() moves to module scope and the four inline copies call
it. The three shadow runBashFile() declarations this PR had to edit
identically go with them.

Red-proved both ways: probing 'gsd-tools' again turns (B0) and (B) red;
making the node prepend conditional turns (B0)(ii) red.

Refs #4344

* fix(#4205): drop empty PATH elements from the isolated PATH

A POSIX shell reads an empty PATH element as the current directory, so
an isolated PATH carrying one still lets the launcher's `command -v
gsd_run` arm resolve a gsd_run from the fixture's own working directory
— the leak class this file exists to close.

Two ways one appeared. An ambient PATH containing `::` survived the
filter, because `path.join('', 'gsd_run')` probes the working directory
rather than a directory entry, so hasGsdRun could not see what it was
admitting. And a PATH whose every entry was filtered joined to an empty
string, leaving the returned value ending in a delimiter, which means
the same thing.

Empty entries are now dropped alongside the gsd_run-bearing ones, and
the surviving directories are joined as a list, so a fully-filtered PATH
yields the node shim dir alone. (B0) asserts both cases.

Found by CodeRabbit on the fork rehearsal PR.

Refs #4344

* fix(#4205): keep only absolute PATH dirs, and assert the sentinel by basename

Adversarial review (agy, gemini-3.8-flash-high) on the previous head.

An empty PATH element was dropped, but `.` and any other relative entry
say the same thing explicitly and survived. hasGsdRun() cannot see what
it would admit either: `path.join('.', 'gsd_run')` probes the runner's
working directory, not the child's, so isolation also varied by where
the suite was started from. Only absolute directories survive now, which
can only tighten the isolation. (B0) asserts it over an empty element, a
`.`, a relative entry, and a fully-filtered PATH — the previous
empty-string assertion passed on the `.` case.

(B)'s GSD_TOOLS assertion compared an absolute os.tmpdir() path against
launcher output, which the file already documents as a mismatch on
Windows: git-bash prints /c/Users/... where Node gives C:\Users\....
It never matched there, so it asserted nothing on the platform it was
added for. It matches the mkdtemp basename now, which both path forms
share. (B) also plants ONLY gsd_run.exe on Windows: with an extensionless
sibling present, an extension-blind filter would strip the directory for
the wrong reason and pass.

snippetEnv() deduped case-variant keys for PATH alone. On Windows every
scrubbed key has the same exposure — an ambient `bash_env` reaches the
child beside the blanked `BASH_ENV`, and BASH_ENV re-injects the rest.
Every key the function sets now wins over other casings of itself; keys
it does not set are untouched, so a caller passing no PATH override still
gets the host PATH.

Refs #4344

* test(#4205): plant probe fixtures as files, not interpreter links

Review follow-ups on 776e9371f.

(B0)'s co-location fixture and its GSD_RUN_NAMES sweep only ever probe
the planted names with accessSync; nothing executes them. They used
linkExecutable, so on Windows the sweep hard-linked node.exe once per
PATHEXT entry — a dozen on a stock runner, and a full copy each when
os.tmpdir() and process.execPath sit on different volumes. plantExecutable
writes a zero-byte 0o755 file instead. linkExecutable keeps the two
callers that need a real executable: the node buildIsolatedPath prepends,
and the Windows sentinel.

The '/usr/bin:/bin' fallback formatted POSIX paths with the platform
delimiter, yielding '/usr/bin;/bin' on Windows, which path.isAbsolute
accepts and no Windows shell would ever produce. It was also unreachable:
basePath defaults to process.env.PATH. An unset PATH now yields the node
shim dir alone and fails loudly at spawn rather than being papered over.

(B)'s header said the sentinel is planted in both forms; the code plants
one per platform, and planting both on Windows is what the branch below
it exists to avoid. Also names which assertion carries the Windows
guarantee, since SENTINEL_INVOKED cannot fire there.

The shared helper's temp dirs were prefixed gsd-891-, attributing every
fixture's leftovers to one of the four bugs it now serves.

Refs #4344

* fix(#4205): model bash's PATH lookup, not cmd.exe's, and give (B0) its own oracle

Ponytail review on aec6012fc.

GSD_RUN_NAMES expanded PATHEXT, which describes cmd.exe rather than the
shell the launcher's `command -v gsd_run` arm runs under. The Cygwin/msys
rule is that .exe may be omitted from a command while '.bat and .com ...
you cannot omit the extension', so gsd_run.exe is reachable for a bare
gsd_run and gsd_run.cmd/.ps1 are not, whatever PATHEXT lists. The comment
claimed the resulting over-match was free. It was not:
buildIsolatedPath() restores node to the isolated PATH, but nothing
restores bash, which the fixtures spawn by name — so every extra dropped
directory was another chance to remove the one bash lives in and fail
with ENOENT instead of an assertion. Narrowed to gsd_run and
gsd_run.exe. (Aside, the PATHEXT default does not even contain .PS1.)

(B0)'s name-sweep took its list from GSD_RUN_NAMES, so it swept the
constant under test with itself and could only catch that constant being
deleted, never being wrong. Its relative-element case re-ran the
implementation's own filter predicate over that filter's output, which is
true for any predicate. Both now assert against written-out expectations:
the reachable names per platform, and the exact directories that must
survive each case.

Also: the test name covered two of its four assertions, and the case
table's prose counted three of its four entries.

Red-proved three ways: dropping the gsd_run filter, dropping the
absoluteness filter, and claiming a name the filter does not cover each
turn (B0) red.

Refs #4344

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Dennis Alexis Valin Dittrich
2026-09-06 23:07:25 +02:00
committed by GitHub
parent f09e7ed08c
commit 47f83beb62

View File

@@ -13,11 +13,11 @@
* (D) Loud guard behavioral: missing gsd-tools.cjs exits non-zero and emits
* "not found" to stderr.
* (E) PATH fallback behavioral: when no local gsd-tools.cjs, the elif branch
* resolves to the gsd-tools binary on PATH (#3668).
* resolves to the gsd_run binary on PATH (#3668).
* (F) Regression locks: the snippet file contains no /gsd-tools substring; and
* no line in workflows/do.md matches /\/gsd[:-][a-z]/ (dispatcher-parity
* scanner must not read the preamble as a slash-command stub).
* (H) Codex shim fallback: when PATH has no gsd-tools, $HOME/.codex/gsd-core/bin
* (H) Codex shim fallback: when PATH has no gsd_run, $HOME/.codex/gsd-core/bin
* can satisfy gsd_run for Codex shim-only installs.
*/
@@ -31,24 +31,173 @@ const path = require('node:path');
const os = require('node:os');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
/**
* Env for any fixture that sources the snippet (#4205).
*
* TEST_ENV_BASE is DERIVED from the same capability registry the resolver
* reads (see tests/helpers.cjs, #2665), so a new runtime home cannot leave it
* silently stale — the shape of #4205.
*
* Three keys the derived set cannot supply:
* - GEMINI_CONFIG_DIR: the gemini runtime is retired (#1928) so the registry
* no longer carries it, but the snippet still probes its arm.
* - CLAUDE_ENV_FILE: a WRITE sink, not a read path. Left ambient, every
* fixture that exits 0 appends `export PATH='<temp dir>'` to the
* developer's real env file, each line naming a /tmp dir the fixture has
* already deleted.
* - BASH_ENV: sourced by non-interactive bash BEFORE the script, which is
* after this scrub is applied — so one inherited var re-injects any of
* the others. Measured: a BASH_ENV exporting CODEX_HOME turns (H) red.
*/
const SNIPPET_SCRUB = { GEMINI_CONFIG_DIR: '', CLAUDE_ENV_FILE: '', BASH_ENV: '' };
function snippetEnv(overrides = {}) {
const env = { ...process.env, ...TEST_ENV_BASE, ...SNIPPET_SCRUB, ...overrides };
// Windows env vars are case-insensitive; a spread of process.env is not. The
// host PATH enumerates as `Path` there, so `{ ...process.env, PATH: x }`
// yields BOTH keys — and libuv's make_program_env sorts the child's block
// case-insensitively but never drops duplicates, so the ambient twin of
// anything scrubbed here still reaches the child and defeats the isolation
// this whole file rests on. Every key this function sets wins over any other
// casing of itself; keys it does not set are left alone, so a caller that
// passes no PATH override still gets the host PATH.
const canonical = new Map(
[...Object.keys(TEST_ENV_BASE), ...Object.keys(SNIPPET_SCRUB), ...Object.keys(overrides)]
.map((key) => [key.toUpperCase(), key]),
);
for (const key of Object.keys(env)) {
const owner = canonical.get(key.toUpperCase());
if (owner !== undefined && owner !== key) delete env[key];
}
return env;
}
/**
* Run a bash script FILE via the process seam, preserving the throw-on-
* nonzero-exit semantics of the execFileSync('bash', [path], ...) idiom
* this replaces.
*/
function runBashFile(scriptPath, options = {}) {
const r = runHookSeam(scriptPath, [], { interpreter: 'bash', ...options });
const r = runHookSeam(scriptPath, [], {
interpreter: 'bash', ...options, env: snippetEnv(options.env),
});
throwIfFailed(r, `bash ${scriptPath}`);
return r.stdout;
}
const NODE_BIN = process.platform === 'win32' ? 'node.exe' : 'node';
/**
* Put an executable link to this interpreter in `dir` under `name`, and return
* `dir` so a caller can prepend it to a PATH. Callers use it for both halves of
* a fixture: the node the launcher needs, and the gsd_run sentinel it must not
* reach. The content never matters, only that the name resolves.
*
* Windows symlinks need elevation, so a hard link is used there instead: it
* needs no privilege, but it cannot cross volumes, hence the copy fallback.
*/
function linkExecutable(dir, name) {
fs.mkdirSync(dir, { recursive: true });
const target = path.join(dir, name);
if (process.platform !== 'win32') {
fs.symlinkSync(process.execPath, target);
return dir;
}
try {
fs.linkSync(process.execPath, target);
} catch (err) {
if (err.code !== 'EXDEV') throw err;
fs.copyFileSync(process.execPath, target);
}
return dir;
}
/**
* Plant `dir/name` as a file an X_OK probe accepts, and return `dir`. For
* fixtures that only need a name to be *found*: nothing ever executes these,
* so they cost a zero-byte write instead of a link to (or, across volumes, a
* copy of) the whole interpreter.
*/
function plantExecutable(dir, name) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, name), '', { mode: 0o755 });
return dir;
}
/**
* Every filename the launcher's `command -v gsd_run` arm could resolve.
*
* The arm runs under bash, so this models bash's lookup, not cmd.exe's. The
* Cygwin/msys rule is that `.exe` may be omitted from a command while ".bat and
* .com ... you cannot omit the extension" — so `gsd_run.exe` is reachable for a
* bare `gsd_run` and `gsd_run.cmd`/`.ps1` are not, whatever PATHEXT says.
*
* Matching PATHEXT instead would drop more directories than bash can reach, and
* that is not free: buildIsolatedPath() restores node to the isolated PATH but
* nothing restores bash, which the fixtures spawn by name. A wider drop set is
* a wider chance of removing the directory bash itself lives in and failing the
* fixture with ENOENT instead of an assertion.
*/
const GSD_RUN_NAMES = process.platform === 'win32'
? ['gsd_run', 'gsd_run.exe']
: ['gsd_run'];
/** True when `dir` holds a gsd_run the launcher's PATH arm could resolve. */
function hasGsdRun(dir) {
return GSD_RUN_NAMES.some((name) => {
try { fs.accessSync(path.join(dir, name), fs.constants.X_OK); return true; }
catch { return false; }
});
}
/**
* Build a PATH the launcher's `command -v gsd_run` arm cannot resolve anything
* from, while a bare `node` lookup still succeeds — the precondition every
* runtime-home fallback fixture needs.
*
* Directories holding a resolvable gsd_run are dropped (#4205: the arm probes
* `gsd_run`, not `gsd-tools`), then a dedicated dir carrying only node is
* prepended. The prepend is unconditional because dropping the directory node
* itself lives in is a normal outcome, not an exotic one: fnm, nvm, Homebrew
* and Windows global installs all co-locate the two.
*
* The caller cleans up `result.nodeBinDir` (pass it to `cleanup()` in a
* `t.after` or `finally` block).
*
* @param {string} [basePath] PATH to filter. Callers planting a leaked gsd_run
* pass their own string rather than mutating `process.env.PATH`.
* @returns {{ isolatedPath: string, nodeBinDir: string }}
*/
function buildIsolatedPath(basePath = process.env.PATH) {
// Only absolute directories survive. An empty element means "the current
// directory" to a POSIX shell and `.` says so explicitly, so either one puts
// the child's cwd on PATH — and hasGsdRun cannot see what it would admit,
// since `path.join('.', 'gsd_run')` probes the *runner's* cwd instead of the
// child's. Joining the survivors (rather than the filtered string) also keeps
// a fully-filtered PATH from ending in a delimiter, which means the same
// thing. Dropping a relative entry can only tighten the isolation, never
// loosen it.
const filteredDirs = (basePath ?? '')
.split(path.delimiter)
.filter((p) => path.isAbsolute(p) && !hasGsdRun(p));
const nodeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-node-'));
try {
linkExecutable(nodeBinDir, NODE_BIN);
} catch (err) {
cleanup(nodeBinDir);
throw err;
}
return { isolatedPath: [nodeBinDir, ...filteredDirs].join(path.delimiter), nodeBinDir };
}
/**
* Read the canonical preamble from the snippet file (all lines, no trailing newline).
*/
@@ -360,12 +509,12 @@ describe('runtime-launcher-parity (#373)', () => {
});
// ─── (D) Loud guard: missing runtime is fatal ─────────────────────────────
test('(D) missing gsd-tools.cjs and no PATH gsd-tools causes loud non-zero exit with "not found" on stderr', () => {
test('(D) missing gsd-tools.cjs and no PATH gsd_run causes loud non-zero exit with "not found" on stderr', (t) => {
// Create temp dir with a space in the name, but NO gsd-tools.cjs.
// We ensure gsd-tools is not on PATH by prepending a dir that has no
// gsd-tools binary (system binaries remain on PATH so bash/node work).
// We ensure gsd_run is not on PATH by prepending a dir that has no
// gsd_run binary (system binaries remain on PATH so bash/node work).
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 notools '));
// Place a no-op dir first in PATH; no gsd-tools stub there.
// Place a no-op dir first in PATH; no gsd_run stub there.
const noToolsBin = path.join(base, 'nobin');
fs.mkdirSync(noToolsBin, { recursive: true });
try {
@@ -380,27 +529,30 @@ describe('runtime-launcher-parity (#373)', () => {
const scriptPath = path.join(base, 'test-guard.sh');
fs.writeFileSync(scriptPath, scriptContent);
// Build a PATH that has noToolsBin first (no gsd-tools stub there) but retains
// system paths needed for bash. Exclude any PATH entry that contains a gsd-tools binary.
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try { fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK); return false; }
catch { return true; }
});
const isolatedPath = [noToolsBin, ...systemPaths].join(path.delimiter);
// noToolsBin first (no gsd_run stub there), then a PATH no gsd_run is
// resolvable from.
const isolated = buildIsolatedPath();
t.after(() => cleanup(isolated.nodeBinDir));
const isolatedPath = [noToolsBin, isolated.isolatedPath].join(path.delimiter);
const r = runHookSeam(scriptPath, [], {
interpreter: 'bash',
env: { ...process.env, PATH: isolatedPath, HOME: base },
// Loud-guard requires every runtime-home arm to genuinely miss, not
// just PATH (#4205 class: an ambient config-dir var pointing at a
// real install would resolve here instead of the hard error).
env: snippetEnv({ PATH: isolatedPath, HOME: base }),
});
const threw = r.exitCode !== 0;
const stderrOutput = r.stderr || '';
assert.ok(threw, 'Expected the script to exit non-zero when gsd-tools.cjs is missing and gsd-tools is not on PATH');
assert.ok(threw, 'Expected the script to exit non-zero when gsd-tools.cjs is missing and gsd_run is not on PATH');
// Match the launcher's own diagnostic, not a bare "not found": when node
// itself is missing from the isolated PATH, bash's own
// `bash: node: command not found` satisfies the loose form and a
// regressed guard passes.
assert.ok(
stderrOutput.includes('not found') || stderrOutput.includes('ERROR'),
`Expected stderr to contain "not found" or "ERROR", got: ${stderrOutput.trim()}`,
stderrOutput.includes('ERROR: gsd-tools.cjs not found'),
`Expected stderr to contain "ERROR: gsd-tools.cjs not found", got: ${stderrOutput.trim()}`,
);
} finally {
cleanup(base);
@@ -439,7 +591,7 @@ describe('runtime-launcher-parity (#373)', () => {
fs.writeFileSync(scriptPath, scriptContent);
const stdout = runBashFile(scriptPath, {
env: { ...process.env, PATH: `${pathBinDir}${path.delimiter}${process.env.PATH || ''}` },
env: { PATH: `${pathBinDir}${path.delimiter}${process.env.PATH || ''}` },
});
// The PATH fallback must have resolved GSD_TOOLS to the stub binary.
@@ -474,7 +626,7 @@ describe('runtime-launcher-parity (#373)', () => {
// The resolution order must be:
// (1) local/RUNTIME_DIR → (2) PATH → (3) $HOME/.claude/gsd-core/bin → (4) hard error
// We probe for .claude/gsd-core/bin (using ${_GSD_SHIM_NAME} indirection)
// between the `command -v gsd-tools` elif and the hard-error else branch.
// between the `command -v gsd_run` elif and the hard-error else branch.
const CLAUDE_HOME_PROBE = '.claude/gsd-core/bin/';
// Assert snippet itself contains the probe
@@ -518,7 +670,7 @@ describe('runtime-launcher-parity (#373)', () => {
});
// ─── (H) Codex shim fallback behavioral ------------------------------------
test('(H) gsd_run resolves $HOME/.codex/gsd-core/bin/ shim when PATH has no gsd-tools', () => {
test('(H) gsd_run resolves $HOME/.codex/gsd-core/bin/ shim when PATH has no gsd_run', (t) => {
const CODEX_HOME_PROBE = '.codex/gsd-core/bin/';
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
@@ -571,26 +723,17 @@ describe('runtime-launcher-parity (#373)', () => {
const scriptPath = path.join(fakeRuntime, 'test-codex-home-fb.sh');
fs.writeFileSync(scriptPath, scriptContent);
const hasExecutable = (dir, name) => {
try {
fs.accessSync(path.join(dir, name), fs.constants.X_OK);
return true;
} catch {
return false;
}
};
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => !hasExecutable(p, 'gsd-tools'));
if (!systemPaths.some((p) => hasExecutable(p, 'node'))) {
const nodeShimDir = path.join(fakeRuntime, 'node-shim');
fs.mkdirSync(nodeShimDir, { recursive: true });
fs.symlinkSync(process.execPath, path.join(nodeShimDir, 'node'));
systemPaths.unshift(nodeShimDir);
}
const { isolatedPath, nodeBinDir } = buildIsolatedPath();
t.after(() => cleanup(nodeBinDir));
const stdout = runBashFile(scriptPath, {
env: { ...process.env, PATH: systemPaths.join(path.delimiter), HOME: fakeHome },
// Ambient CLAUDE_CONFIG_DIR/HERMES_HOME/CURSOR_CONFIG_DIR resolve arms
// checked before CODEX_HOME, and an ambient CODEX_HOME overrides the
// $HOME/.codex default this test asserts (#4205 class: same env-leak
// bug, this time via config-dir vars rather than PATH). snippetEnv()'s
// derived TEST_ENV_BASE clears all four, so only HOME's default
// $HOME/.codex fallback can win.
env: { PATH: isolatedPath, HOME: fakeHome },
});
const normStdout = stdout.replace(/\\/g, '/');
@@ -880,7 +1023,7 @@ describe('runtime-launcher-parity — agents (#1041)', () => {
* Asserts:
* (A) The canonical snippet file contains the ~/.claude fallback arm.
* (B) A representative propagated workflow file contains the ~/.claude fallback arm.
* (C) Behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on PATH,
* (C) Behavioral: when RUNTIME_DIR misses and gsd_run is NOT on PATH,
* a stub at $HOME/.claude/gsd-core/bin/gsd-tools.cjs is resolved and invoked.
* (D) The resolution order is preserved: local -> PATH -> ~/.claude -> hard error.
* When all three miss, exit non-zero.
@@ -897,7 +1040,6 @@ const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
@@ -905,16 +1047,6 @@ const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
// Representative propagated workflow file (has a gsd_run call):
const REPRESENTATIVE_FILE = path.join(WORKFLOWS_DIR, 'add-backlog.md');
/**
* Run a bash script FILE via the process seam, preserving the throw-on-
* nonzero-exit semantics of the execFileSync('bash', [path], ...) idiom
* this replaces.
*/
function runBashFile(scriptPath, options = {}) {
const r = runHookSeam(scriptPath, [], { interpreter: 'bash', ...options });
throwIfFailed(r, `bash ${scriptPath}`);
return r.stdout;
}
const CLAUDE_HOME_PROBE = '.claude/gsd-core/bin/';
@@ -940,7 +1072,7 @@ describe('bug-211: launcher ~/.claude home fallback', () => {
});
// --- (C) Behavioral: ~/.claude stub is resolved when local and PATH both miss
test('(C) gsd_run resolves $HOME/.claude/gsd-core/bin/ stub when no local install and gsd-tools not on PATH', () => {
test('(C) gsd_run resolves $HOME/.claude/gsd-core/bin/ stub when no local install and gsd_run not on PATH', (t) => {
// Build a fake $HOME with a stub at .claude/gsd-core/bin/gsd-tools.cjs
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-home-'));
// RUNTIME_DIR points to a directory with no gsd-tools.cjs
@@ -969,37 +1101,15 @@ describe('bug-211: launcher ~/.claude home fallback', () => {
const scriptPath = path.join(fakeRuntime, 'test-home-fb.sh');
fs.writeFileSync(scriptPath, scriptContent);
// Build a PATH with no gsd-tools binary to force the ~/.claude arm.
// Filter out directories that contain a gsd-tools executable. If node lives
// in the same directory as gsd-tools, create a dedicated shim dir with a
// symlink to node only (no gsd-tools there).
const nodeBinResult = runHookSeam('node', [], { interpreter: 'which' });
throwIfFailed(nodeBinResult, 'which node');
const nodeBin = nodeBinResult.stdout.trim();
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try {
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
return false;
} catch {
return true;
}
});
// If node's dir was filtered (it contained gsd-tools), create a shim dir
// with just a node symlink so the stub's shebang (#!/usr/bin/env node) resolves.
const nodeShimDir = path.join(fakeRuntime, 'node-shim');
if (!systemPaths.some((p) => {
try { fs.accessSync(path.join(p, 'node'), fs.constants.X_OK); return true; }
catch { return false; }
})) {
fs.mkdirSync(nodeShimDir, { recursive: true });
fs.symlinkSync(nodeBin, path.join(nodeShimDir, 'node'));
systemPaths.unshift(nodeShimDir);
}
// A PATH with no resolvable gsd_run, to force the ~/.claude arm. The
// helper also supplies node, which the stub's #!/usr/bin/env node needs.
const { isolatedPath, nodeBinDir } = buildIsolatedPath();
t.after(() => cleanup(nodeBinDir));
const stdout = runBashFile(scriptPath, {
env: { ...process.env, PATH: systemPaths.join(path.delimiter), HOME: fakeHome },
// Ambient CLAUDE_CONFIG_DIR would override the $HOME/.claude default
// this test relies on (#4205 class: env-leak, not PATH-leak).
env: { PATH: isolatedPath, HOME: fakeHome },
});
// GSD_TOOLS must point into the fake ~/.claude dir
@@ -1020,7 +1130,7 @@ describe('bug-211: launcher ~/.claude home fallback', () => {
});
// --- (D) All three miss -> hard error -------------------------------------
test('(D) hard error when local, PATH, and ~/.claude all miss', () => {
test('(D) hard error when local, PATH, and ~/.claude all miss', (t) => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nohome-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nort-'));
// noToolsBin so PATH check finds nothing
@@ -1039,29 +1149,28 @@ describe('bug-211: launcher ~/.claude home fallback', () => {
const scriptPath = path.join(fakeRuntime, 'test-allfail.sh');
fs.writeFileSync(scriptPath, scriptContent);
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try {
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
return false;
} catch {
return true;
}
});
const isolatedPath = [noToolsBin, ...systemPaths].join(path.delimiter);
const isolated = buildIsolatedPath();
t.after(() => cleanup(isolated.nodeBinDir));
const isolatedPath = [noToolsBin, isolated.isolatedPath].join(path.delimiter);
const r = runHookSeam(scriptPath, [], {
interpreter: 'bash',
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
// "All three miss" requires every runtime-home arm to genuinely miss,
// not just the first (#4205 class: an ambient config-dir var pointing
// at a real install would resolve here instead of the hard error).
env: snippetEnv({ PATH: isolatedPath, HOME: fakeHome }),
});
const threw = r.exitCode !== 0;
const stderrOutput = r.stderr || '';
assert.ok(threw, 'Expected non-zero exit when all three resolution arms miss');
// Match the launcher's own diagnostic, not a bare "not found": when node
// itself is missing from the isolated PATH, bash's own
// `bash: node: command not found` satisfies the loose form and a
// regressed guard passes.
assert.ok(
stderrOutput.includes('not found') || stderrOutput.includes('ERROR'),
`Expected stderr to contain "not found" or "ERROR", got: ${stderrOutput.trim()}`,
stderrOutput.includes('ERROR: gsd-tools.cjs not found'),
`Expected stderr to contain "ERROR: gsd-tools.cjs not found", got: ${stderrOutput.trim()}`,
);
} finally {
cleanup(fakeHome);
@@ -1088,12 +1197,15 @@ describe('bug-211: launcher ~/.claude home fallback', () => {
* Every non-Claude runtime (Hermes, Cursor, Codex, Copilot, Windsurf, …)
* installs gsd-core into a *different* directory that the shim never tried,
* causing a false-positive fatal ERROR on all non-Claude runtimes when
* RUNTIME_DIR is not set and gsd-tools is not on PATH.
* RUNTIME_DIR is not set and gsd_run is not on PATH.
*
* Asserts:
* (A) Snippet contains all expected non-Claude runtime home probes (structural).
* (B) HERMES_HOME behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on
* PATH, a stub at ${HERMES_HOME}/gsd-core/bin/gsd-tools.cjs is invoked.
* (B0) buildIsolatedPath() co-location invariant (see below).
* (B) HERMES_HOME behavioral: when RUNTIME_DIR misses and gsd_run is NOT on
* PATH, the stub at ${HERMES_HOME}/gsd-core/bin/gsd-tools.cjs is invoked.
* It plants a leaked gsd_run on PATH first (#4205), on every platform,
* which is what makes it fail on a clean machine as well as a leaking one.
* (C) Default Hermes path behavioral: stub at $HOME/.hermes/gsd-core/bin/
* gsd-tools.cjs is invoked when HERMES_HOME is not set.
* (D) Resolution order: non-Claude homes are probed BEFORE the hard error,
@@ -1113,24 +1225,12 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
/**
* Run a bash script FILE via the process seam, preserving the throw-on-
* nonzero-exit semantics of the execFileSync('bash', [path], ...) idiom
* this replaces.
*/
function runBashFile(scriptPath, options = {}) {
const r = runHookSeam(scriptPath, [], { interpreter: 'bash', ...options });
throwIfFailed(r, `bash ${scriptPath}`);
return r.stdout;
}
// Every non-Claude runtime home probe the snippet must contain.
// Key: runtime name (for diagnostics). Value: the substring that must appear
@@ -1215,48 +1315,6 @@ function extractShellBlocks(content) {
return blocks;
}
/**
* Build a PATH with no gsd-tools binary so the PATH fallback branch is skipped,
* while guaranteeing that a bare `node` lookup still resolves regardless of whether
* the real node binary co-locates with a global gsd-tools shim (e.g. fnm/nvm/Homebrew).
*
* Strategy (POSIX only): create a temp dir containing only a `node` symlink →
* process.execPath, prepend it to the gsd-tools-filtered PATH. The filtered
* PATH excludes any directory that contains an executable `gsd-tools`.
*
* On Windows the co-location bug does not apply (gsd-tools resolves via .cmd/.ps1,
* not the bare binary probed here), and symlinks may require elevated privileges,
* so we skip the symlink step entirely on that platform.
*
* The caller is responsible for cleaning up `result.nodeBinDir` when non-null
* (pass it to `cleanup()` in a `t.after` or `finally` block).
*
* @returns {{ isolatedPath: string, nodeBinDir: string|null }}
*/
function buildIsolatedPath() {
const filteredPath = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try { fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK); return false; }
catch { return true; }
})
.join(path.delimiter);
// Windows: no symlink (see JSDoc above); callers must handle nodeBinDir === null.
if (process.platform === 'win32') {
return { isolatedPath: filteredPath, nodeBinDir: null };
}
const nodeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-node-'));
try {
fs.symlinkSync(process.execPath, path.join(nodeBinDir, 'node'));
} catch (err) {
cleanup(nodeBinDir);
throw err;
}
return { isolatedPath: nodeBinDir + path.delimiter + filteredPath, nodeBinDir };
}
describe('bug-891: non-Claude runtime home fallback arms', () => {
@@ -1304,66 +1362,53 @@ describe('bug-891: non-Claude runtime home fallback arms', () => {
});
// ── (B0) Regression: buildIsolatedPath keeps node resolvable when node and ──
// gsd-tools co-locate in the same PATH directory. ─
// gsd_run co-locate in the same PATH directory. ─
//
// Machine-independence guarantee: PATH is set to ONLY two controlled dirs —
// fakeBinDir (holds both fake gsd-tools AND a node symlink) plus a fresh
// empty dir (no executables at all). The real system PATH is NOT appended.
// Machine-independence guarantee: the filtered PATH is ONLY two controlled
// dirs — fakeBinDir (holds both fake gsd_run AND node) plus a fresh empty dir
// (no executables at all). The real system PATH is NOT appended.
//
// Old logic: filters out fakeBinDir → only the empty dir remains → node
// UNresolvable → assertion (ii) FAILS (true-red on any machine).
// New logic: prepends its own nodeBinDir → node resolvable despite fakeBinDir
// being filtered → both assertions pass.
// Filtering fakeBinDir out is correct and unavoidable, so the only thing that
// keeps node reachable is the nodeBinDir buildIsolatedPath() prepends. This
// test is that prepend's guard: make it conditional again — as it was on
// Windows, where the helper returned `nodeBinDir: null` — and (ii) goes red.
test(
'(B0) buildIsolatedPath: node is resolvable and gsd-tools is not when they share a PATH dir',
{ skip: process.platform === 'win32' ? 'POSIX-only co-location scenario' : false },
'(B0) buildIsolatedPath invariants: node survives, every reachable gsd_run name and every relative dir does not',
(t) => {
// Build a fake bin dir that contains BOTH a gsd-tools executable and a node
// symlink, simulating a dev setup (fnm/nvm/Homebrew) where both land in the
// same bin directory.
// Build a fake bin dir that contains BOTH a gsd_run executable and node,
// simulating a dev setup (fnm/nvm/Homebrew) where both land in the same
// bin directory.
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-colocated-'));
// A second fresh empty dir — contains neither gsd-tools nor node.
// A second fresh empty dir — contains neither gsd_run nor node.
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-empty-'));
t.after(() => cleanup(fakeBinDir));
t.after(() => cleanup(emptyDir));
// Fake gsd-tools shim (executable file)
const fakeGsdTools = path.join(fakeBinDir, 'gsd-tools');
fs.writeFileSync(fakeGsdTools, '#!/bin/sh\necho fake-gsd-tools\n');
fs.chmodSync(fakeGsdTools, 0o755);
// gsd_run co-located with node — the fnm/nvm/Homebrew layout, and the
// Windows global-install layout the same helper has to survive. Both are
// probed, never run.
plantExecutable(fakeBinDir, 'gsd_run');
plantExecutable(fakeBinDir, NODE_BIN);
// node symlink pointing at the real interpreter (co-located with gsd-tools)
fs.symlinkSync(process.execPath, path.join(fakeBinDir, 'node'));
// Set PATH to ONLY the two controlled dirs (no real system dirs).
// This makes the test machine-independent: on any machine, the only place
// node *could* come from before the fix is fakeBinDir — which gets filtered.
const origPath = process.env.PATH;
process.env.PATH = fakeBinDir + path.delimiter + emptyDir;
let result;
try {
result = buildIsolatedPath();
} finally {
process.env.PATH = origPath;
}
// Filter ONLY the two controlled dirs (no real system dirs). This makes
// the test machine-independent: on any machine, the only place node
// *could* come from before the fix is fakeBinDir — which gets filtered.
const result = buildIsolatedPath(fakeBinDir + path.delimiter + emptyDir);
t.after(() => cleanup(result.nodeBinDir));
const returnedDirs = result.isolatedPath.split(path.delimiter);
// (i) gsd-tools must NOT be resolvable on the returned PATH
const gsdToolsResolvable = returnedDirs.some((dir) => {
try { fs.accessSync(path.join(dir, 'gsd-tools'), fs.constants.X_OK); return true; }
catch { return false; }
});
// (i) gsd_run must NOT be resolvable on the returned PATH
const gsdRunResolvable = returnedDirs.some(hasGsdRun);
assert.equal(
gsdToolsResolvable,
gsdRunResolvable,
false,
'gsd-tools must not be resolvable on the isolated PATH (home-fallback would be bypassed)',
'gsd_run must not be resolvable on the isolated PATH (home-fallback would be bypassed)',
);
// (ii) node must BE resolvable on the returned PATH (the new nodeBinDir makes it so)
const nodeResolvable = returnedDirs.some((dir) => {
try { fs.accessSync(path.join(dir, 'node'), fs.constants.X_OK); return true; }
try { fs.accessSync(path.join(dir, NODE_BIN), fs.constants.X_OK); return true; }
catch { return false; }
});
assert.equal(
@@ -1371,60 +1416,165 @@ describe('bug-891: non-Claude runtime home fallback arms', () => {
true,
'node must be resolvable on the isolated PATH (launcher runs: node "$GSD_TOOLS" "$@")',
);
// (iii) every name the launcher's PATH arm can resolve must be filtered,
// not just the extensionless one — a gsd_run.exe-only directory is the
// reachable Windows leak an extensionless probe misses (#4344). The list
// is written out rather than taken from GSD_RUN_NAMES: sweeping the
// constant under test with itself cannot catch that constant being wrong.
const reachableNames = process.platform === 'win32'
? ['gsd_run', 'gsd_run.exe']
: ['gsd_run'];
const survived = reachableNames.filter((name) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-ext-'));
t.after(() => cleanup(dir));
plantExecutable(dir, name);
const probe = buildIsolatedPath(dir);
t.after(() => cleanup(probe.nodeBinDir));
return probe.isolatedPath.split(path.delimiter).includes(dir);
});
assert.deepStrictEqual(
survived,
[],
'every name bash can resolve for a bare gsd_run must be filtered out of the isolated PATH',
);
// (iv) every surviving element is absolute. A shell resolves an empty
// element, `.`, or any relative entry against the *child's* working
// directory, so each one is a way to put a cwd gsd_run back on PATH —
// and hasGsdRun cannot see any of them, because it probes relative to the
// runner's cwd instead. Four ways one arrives: an ambient `::`, an
// explicit `.`, a relative entry, and a PATH every entry of which was
// filtered (which used to leave a trailing delimiter, meaning the same
// thing).
// Each case names the dirs that must survive, so the assertion has an
// oracle of its own rather than re-running the implementation's filter.
const relativeElementCases = {
emptyElement: [`${emptyDir}${path.delimiter}${path.delimiter}${emptyDir}`, [emptyDir, emptyDir]],
dotElement: [`${emptyDir}${path.delimiter}.${path.delimiter}${emptyDir}`, [emptyDir, emptyDir]],
relativeElement: [`${emptyDir}${path.delimiter}sub/dir`, [emptyDir]],
fullyFiltered: [fakeBinDir, []],
};
for (const [label, [basePath, expected]] of Object.entries(relativeElementCases)) {
const probe = buildIsolatedPath(basePath);
t.after(() => cleanup(probe.nodeBinDir));
assert.deepStrictEqual(
probe.isolatedPath.split(path.delimiter),
[probe.nodeBinDir, ...expected],
`${label}: only absolute, gsd_run-free dirs may survive (a relative one resolves the child's cwd), got: ${probe.isolatedPath}`,
);
}
},
);
// ── (B) Behavioral: HERMES_HOME stub is resolved ──────────────────────────
test('(B) gsd_run resolves ${HERMES_HOME}/gsd-core/bin/ stub when set and local+PATH both miss', () => {
// ── (B) Behavioral: HERMES_HOME stub is resolved, even past a leaked PATH ──
//
// #4205 regression, and the reason this asserts the sentinel rather than just
// the stub: on a CLEAN machine the bare HERMES_HOME assertion passes whether
// buildIsolatedPath() filters gsd_run or gsd-tools, so it only catches the bug
// on a machine that already has the leak. Planting the sentinel makes it fail
// on any machine, and on either platform: the sentinel is planted in the one
// form that platform's shell resolves — the extensionless shim npm installs
// on POSIX, `gsd_run.exe` alone on Windows (see below for why alone). Note
// that only the POSIX sentinel can print SENTINEL_INVOKED; on Windows the
// basename assertion is what carries the guarantee (#4344).
test('(B) buildIsolatedPath strips a leaked PATH gsd_run; the ${HERMES_HOME} stub wins', (t) => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-home-b-'));
const fakeHermesHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-hermes-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-rt-'));
const { isolatedPath, nodeBinDir } = buildIsolatedPath();
try {
const hermesBinDir = path.join(fakeHermesHome, 'gsd-core', 'bin');
fs.mkdirSync(hermesBinDir, { recursive: true });
t.after(() => cleanup(fakeHome));
t.after(() => cleanup(fakeHermesHome));
t.after(() => cleanup(fakeRuntime));
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("HERMES_HOME_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// Export HOME to an isolated temp dir (no .claude install there) so the
// $HOME/.claude arm is skipped and we fall through to the HERMES_HOME arm.
const scriptContent =
`unset GSD_TOOLS\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HERMES_HOME=${JSON.stringify(fakeHermesHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run ping test\n`;
const scriptPath = path.join(fakeRuntime, 'test-hermes-home.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = runBashFile(scriptPath, {
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome, HERMES_HOME: fakeHermesHome },
});
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('gsd-core/bin/'),
`Expected GSD_TOOLS to resolve into hermes gsd-core/bin/, got:\n${stdout.trim()}`,
);
assert.ok(
stdout.includes('HERMES_HOME_STUB:ping,test'),
`Expected stub output "HERMES_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeHermesHome);
cleanup(fakeRuntime);
if (nodeBinDir) cleanup(nodeBinDir);
const sentinelBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4205-sentinel-'));
t.after(() => cleanup(sentinelBinDir));
if (process.platform === 'win32') {
// What msys bash resolves for a bare `gsd_run`: it appends `.exe` during
// PATH lookup. Planted ALONE, so the leak this fixture proves is the
// extension-only one — an extensionless sibling would let an
// extension-blind filter strip the directory for the wrong reason and
// pass. It cannot print SENTINEL_INVOKED (it is this interpreter under
// another name), which is what the GSD_TOOLS assertion below is for.
linkExecutable(sentinelBinDir, 'gsd_run.exe');
} else {
const sentinelPath = path.join(sentinelBinDir, 'gsd_run');
fs.writeFileSync(sentinelPath, '#!/bin/sh\necho "SENTINEL_INVOKED:$*"\n');
fs.chmodSync(sentinelPath, 0o755);
}
// Simulate the reported leak: a real gsd_run reachable on PATH. Passed in
// rather than assigned to process.env.PATH — the runner's own environment
// stays untouched, so no other fixture can observe the leak.
const { isolatedPath, nodeBinDir } = buildIsolatedPath(
`${sentinelBinDir}${path.delimiter}${process.env.PATH}`,
);
t.after(() => cleanup(nodeBinDir));
// Leak assertion that needs no subprocess: a filter probing the wrong name
// leaves sentinelBinDir on the isolated PATH. Deterministic on both
// platforms, so it stays red even where the stdout assertions below depend
// on the mount's exec heuristics rather than on the filter under test.
assert.ok(
!isolatedPath.split(path.delimiter).includes(sentinelBinDir),
`Expected buildIsolatedPath to strip the leaked ${sentinelBinDir}, got:\n${isolatedPath}`,
);
const hermesBinDir = path.join(fakeHermesHome, 'gsd-core', 'bin');
fs.mkdirSync(hermesBinDir, { recursive: true });
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("HERMES_HOME_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// HOME points at an isolated temp dir with no .claude install, so the
// $HOME/.claude arm misses and the HERMES_HOME arm is the one under test.
const scriptContent =
`unset GSD_TOOLS\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HERMES_HOME=${JSON.stringify(fakeHermesHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run ping test\n`;
const scriptPath = path.join(fakeRuntime, 'test-hermes-home.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = runBashFile(scriptPath, {
env: { PATH: isolatedPath, HOME: fakeHome, HERMES_HOME: fakeHermesHome },
});
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
!normStdout.includes('SENTINEL_INVOKED'),
`Expected the leaked PATH gsd_run to never be invoked, got:\n${stdout.trim()}`,
);
// The same claim without depending on the sentinel producing output:
// whatever the resolver picked, it did not come from the leaked directory.
// Matched by basename, not by absolute path — git-bash prints `/c/Users/…`
// where os.tmpdir() gives `C:\Users\…` (see the note above the (E) PATH
// fallback assertion), so an absolute-path comparison never matches on
// Windows and would assert nothing there. The mkdtemp suffix keeps the
// basename unique.
assert.ok(
!normStdout.includes(path.basename(sentinelBinDir)),
`Expected GSD_TOOLS to resolve outside the leaked ${sentinelBinDir}, got:\n${stdout.trim()}`,
);
// Assert the hermes dir itself: every arm of the resolver ends in
// gsd-core/bin/, so that substring alone cannot tell them apart.
assert.ok(
normStdout.includes(fakeHermesHome.replace(/\\/g, '/')),
`Expected GSD_TOOLS to resolve into ${fakeHermesHome}, got:\n${stdout.trim()}`,
);
assert.ok(
normStdout.includes('HERMES_HOME_STUB:ping,test'),
`Expected stub output "HERMES_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
);
});
// ── (C) Behavioral: default .hermes path used when HERMES_HOME not set ────
@@ -1456,7 +1606,11 @@ describe('bug-891: non-Claude runtime home fallback arms', () => {
fs.writeFileSync(scriptPath, scriptContent);
const stdout = runBashFile(scriptPath, {
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
// CLAUDE_CONFIG_DIR resolves before the HERMES_HOME arm under test
// (#4205 class, env vector); script-level `unset HERMES_HOME` above
// already handles that var, and snippetEnv()'s derived TEST_ENV_BASE
// clears CLAUDE_CONFIG_DIR before bash ever sees it.
env: { PATH: isolatedPath, HOME: fakeHome },
});
const normStdout = stdout.replace(/\\/g, '/');
@@ -1471,7 +1625,7 @@ describe('bug-891: non-Claude runtime home fallback arms', () => {
} finally {
cleanup(fakeHome);
cleanup(fakeRuntime);
if (nodeBinDir) cleanup(nodeBinDir);
cleanup(nodeBinDir);
}
});
@@ -1540,7 +1694,7 @@ describe('bug-891: non-Claude runtime home fallback arms', () => {
*
* A user project normally does not contain gsd-core/bin/gsd-tools.cjs.
* The snippets should still prefer RUNTIME_DIR for local/dev installs, then
* fall back to the installed gsd-tools binary on PATH.
* fall back to the installed gsd_run binary on PATH.
*/
'use strict';
@@ -1620,6 +1774,11 @@ function makeTempDir() {
}
function runResolver({ cwd, runtimeDir, pathDir }) {
// RUNTIME_DIR='' is INDISTINGUISHABLE from unset to the resolver's
// ${RUNTIME_DIR:-$(git rev-parse --show-toplevel)} — an empty value silently
// falls back to the real repo root and resolves its real install. Fail loudly
// instead; every caller passes one.
if (!runtimeDir) throw new Error('runResolver requires runtimeDir: an empty value resolves the real repo root');
const script = [
'set -e',
extractResolverSnippet(),
@@ -1628,7 +1787,7 @@ function runResolver({ cwd, runtimeDir, pathDir }) {
].join('\n');
// Consolidation #1969: POSIX-shell resolver. These tests create an
// extension-less `gsd-tools` PATH stub (mode 0o755) and exec it via `bash -c`;
// extension-less `gsd_run` PATH stub (mode 0o755) and exec it via `bash -c`;
// Windows Git Bash ignores the exec bit for extension-less PATH scripts, so the
// suite is guarded to POSIX (matches the host suite's own bash -c guard).
if (process.platform === 'win32') return '';
@@ -1636,11 +1795,10 @@ function runResolver({ cwd, runtimeDir, pathDir }) {
const r = runHookSeam('-c', [script], {
interpreter: 'bash',
cwd,
env: {
...process.env,
env: snippetEnv({
PATH: `${pathDir}${path.delimiter}${process.env.PATH || ''}`,
RUNTIME_DIR: runtimeDir || '',
},
RUNTIME_DIR: runtimeDir,
}),
});
throwIfFailed(r, 'bash -c <runtime resolver snippet>');
return r.stdout;
@@ -1745,54 +1903,37 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
/**
* Run a bash script FILE via the process seam, preserving the throw-on-
* nonzero-exit semantics of the execFileSync('bash', [path], ...) idiom
* this replaces.
*/
function runBashFile(scriptPath, options = {}) {
const r = runHookSeam(scriptPath, [], { interpreter: 'bash', ...options });
throwIfFailed(r, `bash ${scriptPath}`);
return r.stdout;
}
// The probe string that must appear in the snippet for the new repo-local check.
// The snippet uses _GSD_RUNTIME_ROOT as the intermediate variable.
const LOCAL_CLAUDE_PROBE = '_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/';
/**
* Build a PATH that strips gsd-tools but keeps node and system binaries.
* Accepts additional bin dirs to prepend.
* Return the full system PATH with extra bin dirs prepended. Deliberately does
* NOT filter gsd_run — unlike buildIsolatedPath() above, which does.
*
* We cannot simply remove the whole directory that contains gsd-tools because
* that directory may also contain node (e.g. /opt/homebrew/bin on macOS).
* Instead, we keep the system PATH as-is and rely on the test's RUNTIME_DIR
* having no gsd-core/bin/ sub-path, so the resolver's first two checks
* (RUNTIME_DIR/gsd-core/bin/ and RUNTIME_DIR/.claude/gsd-core/bin/)
* are the only ones exercised before we hit our stub.
* The isolation here comes from resolution ORDER, not from the PATH contents.
* Callers give RUNTIME_DIR a .claude/gsd-core/bin/ stub, and the resolver
* checks RUNTIME_DIR/gsd-core/bin/ then RUNTIME_DIR/.claude/gsd-core/bin/
* before it ever reaches `command -v gsd_run`, so an ambient gsd_run on PATH
* is unreachable for these tests. Keeping PATH whole is what keeps node
* resolvable when node co-locates with a global gsd_run (e.g. /opt/homebrew/bin).
*
* The extra extraBefore dirs (e.g. noToolsBin) sit first but have no gsd-tools
* binary, so command -v gsd-tools still falls back to PATH lookup. However,
* the snippet's elif arm that uses `command -v gsd-tools` will find the real
* installed one unless we mask it. To mask it without losing node, we create
* a noToolsBin dir that shadows gsd-tools with a sentinel that must NOT be
* called — and we only call makeIsolatedPath for tests where the .claude stub
* must win before PATH is consulted (i.e. the elif PATH arm is never reached).
* That makes this helper safe ONLY for tests whose stub wins before the PATH
* arm. Any test that must prove the PATH arm itself misses needs
* buildIsolatedPath(), which excludes gsd_run-bearing directories outright.
*
* For B: stub is at RUNTIME_DIR/.claude/... so resolver picks it at elif-1 (before command -v).
* For C: same — local .claude/ is checked before command -v and before $HOME/.claude.
* For B and C: the stub sits at RUNTIME_DIR/.claude/..., picked at elif-1.
*/
function makeIsolatedPath(extraBefore = []) {
// Keep full system PATH so node remains accessible.
// Tests B and C exercise only the RUNTIME_DIR/.claude arm which fires
// before command -v gsd-tools — so the real gsd-tools on PATH is never reached.
// before command -v gsd_run — so the real gsd_run on PATH is never reached.
const systemPaths = (process.env.PATH || '/usr/bin:/bin').split(path.delimiter);
return [...extraBefore, ...systemPaths].join(path.delimiter);
}
@@ -1867,11 +2008,12 @@ describe('bug-444: resolver finds repo-local .claude install', () => {
const scriptPath = path.join(fakeRoot, 'test-local-claude.sh');
fs.writeFileSync(scriptPath, scriptContent);
// Keep node in PATH (needed to run the .cjs stub); remove gsd-tools
// Keep node in PATH (needed to run the .cjs stub); the .claude arm
// resolves before the PATH arm, so gsd_run is never probed here.
const isolatedPath = makeIsolatedPath([noToolsBin]);
const stdout = runBashFile(scriptPath, {
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
env: { PATH: isolatedPath, HOME: fakeHome },
});
// Must have resolved to the local .claude stub
@@ -1934,7 +2076,7 @@ describe('bug-444: resolver finds repo-local .claude install', () => {
const isolatedPath = makeIsolatedPath([noToolsBin]);
const stdout = runBashFile(scriptPath, {
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
env: { PATH: isolatedPath, HOME: fakeHome },
});
assert.ok(