Dennis Alexis Valin Dittrich 47f83beb62 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>
2026-09-06 17:07:25 -04:00
2026-09-06 02:09:28 +00:00
2026-09-06 02:09:28 +00:00
2026-09-06 02:09:28 +00:00
2026-09-06 02:09:28 +00:00

GSD Core

Git. Ship. Done.

English · Português · 简体中文 · 日本語 · 한국어

A light-weight meta-prompting, context engineering, and spec-driven development system for Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more.

npm version npm downloads Tests Discord GitHub stars License


What is GSD Core

GSD Core is a context-engineering and spec-driven development framework that drives AI coding agents (Claude Code, Codex, Antigravity CLI, Kimi CLI, Copilot, Cursor, and more) through a disciplined phase loop. It solves context rot — the quality degradation that accumulates as an AI fills its context window — by running all heavy research, planning, and execution work in fresh-context subagents while keeping your main session lean.


How it works

Each milestone repeats the same five-step loop, one phase at a time:

  1. Discuss — capture implementation decisions before anything is planned
  2. Plan — research, decompose, and verify the plan fits a fresh context window
  3. Execute — run plans in parallel waves; each executor starts with a clean 200k-token context
  4. Verify — walk through what was built; diagnose and fix before declaring done
  5. Ship — create the PR, archive the phase, repeat for the next one

Quickstart

npx @opengsd/gsd-core@latest

The installer prompts for your runtime (Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more) and whether to install globally or locally. The installer is required for cross-runtime compatibility — do not copy files from agents/ or commands/ directly.

On another runtime or without Node.js? See Install on your runtime.

Once installed, start a new project or onboard an existing repo:

/gsd-new-project   # greenfield project
/gsd-onboard       # existing codebase

New here? Follow Your first project for a guided walkthrough from install to first shipped phase, or Onboarding an existing codebase for brownfield setup.


Documentation

What's new in 1.7.0 → docs/whats-new-1.7.0.md

Tutorials — learning by doing:

How-to guides — task-focused recipes:

Reference — authoritative facts:

Explanation — concepts and design decisions:

Full index: docs/README.md. Other languages: 日本語 · 한국어 · Português · 简体中文.


Why it works

Most AI-coding setups fail at scale because context bloat silently degrades output quality, there is no shared memory between sessions, and nothing verifies that code actually works. GSD Core solves all three: heavy work runs in fresh subagents, structured artifacts like STATE.md and CONTEXT.md survive session boundaries, and the verify step walks through what was built and generates fix plans before a phase is declared done. See docs/explanation/context-engineering.md for the full reasoning.

Troubleshooting? See docs/how-to/recover-and-troubleshoot.md.


Community

Project Platform
gsd-opencode Original OpenCode port
Discord Community support

Star History

Star History Chart

License

MIT License. See LICENSE for details.


Claude Code is powerful. GSD Core makes it reliable.

Description
No description provided
Readme MIT 77 MiB
Languages
JavaScript 82.3%
TypeScript 17.4%
Shell 0.3%