Merge pull request #2677 from 0xdhx/fix/2665-test-env-base-config-location-vars
fix(#3156): derive the config-location scrub set and close the leaks it cannot reach
This commit is contained in:
7
.changeset/quiet-moons-derive.md
Normal file
7
.changeset/quiet-moons-derive.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 2677
|
||||
---
|
||||
**`runtime-homes` now exports its non-registry config-home descriptors** — `KIMI_HOOKS_TOML_DESCRIPTOR`, `NON_REGISTRY_CONFIG_HOME_DESCRIPTORS`, `GSD_LOCATION_ENV_KEYS`, and the `ConfigHomeDescriptor` type are public, so consumers that need the *set* of config-location env vars (rather than a single resolved path) can derive it instead of hand-maintaining a copy. `resolveKimiHooksTomlDir()` behaviour is unchanged; its descriptor is simply named rather than inline (#3156).
|
||||
|
||||
**The test-instrumentation scripts no longer ship in the npm package** — `scripts/run-tests.cjs`, `scripts/live-config-guard.cjs`, `scripts/affected-tests-lib.cjs`, and `scripts/run-affected-tests.cjs` are now excluded from the tarball (they are one closed require chain of repo-only test tooling). `npm test` in an installed package was already inoperable (`tests/` has never shipped); a deep import of `scripts/run-tests.cjs` from the published package — an unsupported surface — will now be `MODULE_NOT_FOUND` (#3156).
|
||||
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
@@ -140,6 +140,11 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
|
||||
# #2665: a live-config leak fails the run on Linux/macOS lanes. Windows
|
||||
# stays report-only: the guard's first run found PRE-EXISTING USERPROFILE
|
||||
# leaks there (~190 test sites sandbox HOME alone), a documented separate
|
||||
# class — promote once that sweep lands (see live-config-guard.cjs SEVERITY).
|
||||
GSD_STRICT_LIVE_CONFIG_GUARD: ${{ matrix.os != 'windows-latest' && '1' || '' }}
|
||||
# #2854: pin the emitted gate's baseline to the SAME commit the tree was merged
|
||||
# with. "Rebase check" merges `pull_request.base.sha` (pinned by #2472 so all 12
|
||||
# matrix jobs agree on one tree), but `resolveBase()` otherwise falls through to
|
||||
@@ -333,6 +338,8 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
|
||||
# #2665: ubuntu-only lane — strict unconditionally (see the `test` job note).
|
||||
GSD_STRICT_LIVE_CONFIG_GUARD: '1'
|
||||
# #2854: same pin as the other rebase-merged lanes. This lane runs a targeted
|
||||
# list that can include the emitted gate, and the invariant is easier to keep
|
||||
# with no exceptions: if a job merges a pinned base, the gate uses that base.
|
||||
@@ -410,6 +417,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
|
||||
# #2665: strict on Linux/macOS, report-only on Windows (see the `test` job note).
|
||||
GSD_STRICT_LIVE_CONFIG_GUARD: ${{ matrix.os != 'windows-latest' && '1' || '' }}
|
||||
# #2854: pin the emitted gate's baseline to the SAME commit the tree was merged
|
||||
# with. "Rebase check" merges `pull_request.base.sha` (pinned by #2472 so all 12
|
||||
# matrix jobs agree on one tree), but `resolveBase()` otherwise falls through to
|
||||
@@ -610,6 +619,10 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
|
||||
# `npm run test:qa` is `run-tests.cjs --suite qa`, so this lane runs the
|
||||
# live-config guard like every other suite lane. ubuntu-only, so strict
|
||||
# unconditionally — the Windows carve-out does not apply here.
|
||||
GSD_STRICT_LIVE_CONFIG_GUARD: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
10
CONTEXT.md
10
CONTEXT.md
@@ -588,6 +588,16 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
|
||||
`WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation`
|
||||
`WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream`
|
||||
`CONFIG.SEAM.loadConfig-context=loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites`
|
||||
`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal`
|
||||
`CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2`
|
||||
`CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second`
|
||||
`CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST`
|
||||
`LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite`
|
||||
`LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled`
|
||||
`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates <root>/hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot`
|
||||
`LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing`
|
||||
`LIVE-CONFIG.GUARD.SEAM.severity=reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1`
|
||||
`LIVE-CONFIG.GUARD.SEAM.ci-blind=the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"count": 416,
|
||||
"count": 426,
|
||||
"classes": {
|
||||
"ARCH": 1,
|
||||
"CI": 2,
|
||||
"CONFIG": 1,
|
||||
"CONFIG": 5,
|
||||
"DEFECT": 168,
|
||||
"EXEC": 8,
|
||||
"GSD-RESEARCH": 6,
|
||||
"LEARNING": 1,
|
||||
"LIVE-CONFIG": 6,
|
||||
"META": 4,
|
||||
"PLANNING": 3,
|
||||
"PR": 2,
|
||||
@@ -39,6 +40,26 @@
|
||||
"klass": "CI",
|
||||
"value": "hard-fail if PR body lacks closes/fixes/resolves #<issue>"
|
||||
},
|
||||
{
|
||||
"id": "CONFIG.LOCATION.SEAM.in-process-scrub",
|
||||
"klass": "CONFIG",
|
||||
"value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST"
|
||||
},
|
||||
{
|
||||
"id": "CONFIG.LOCATION.SEAM.kimi-two-homes",
|
||||
"klass": "CONFIG",
|
||||
"value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second"
|
||||
},
|
||||
{
|
||||
"id": "CONFIG.LOCATION.SEAM.scrub-set",
|
||||
"klass": "CONFIG",
|
||||
"value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal"
|
||||
},
|
||||
{
|
||||
"id": "CONFIG.LOCATION.SEAM.two-families",
|
||||
"klass": "CONFIG",
|
||||
"value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2"
|
||||
},
|
||||
{
|
||||
"id": "CONFIG.SEAM.loadConfig-context",
|
||||
"klass": "CONFIG",
|
||||
@@ -959,6 +980,36 @@
|
||||
"klass": "LEARNING",
|
||||
"value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures"
|
||||
},
|
||||
{
|
||||
"id": "LIVE-CONFIG.GUARD.SEAM.ci-blind",
|
||||
"klass": "LIVE-CONFIG",
|
||||
"value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes"
|
||||
},
|
||||
{
|
||||
"id": "LIVE-CONFIG.GUARD.SEAM.module",
|
||||
"klass": "LIVE-CONFIG",
|
||||
"value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite"
|
||||
},
|
||||
{
|
||||
"id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets",
|
||||
"klass": "LIVE-CONFIG",
|
||||
"value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates <root>/hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot"
|
||||
},
|
||||
{
|
||||
"id": "LIVE-CONFIG.GUARD.SEAM.scope",
|
||||
"klass": "LIVE-CONFIG",
|
||||
"value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled"
|
||||
},
|
||||
{
|
||||
"id": "LIVE-CONFIG.GUARD.SEAM.severity",
|
||||
"klass": "LIVE-CONFIG",
|
||||
"value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1"
|
||||
},
|
||||
{
|
||||
"id": "LIVE-CONFIG.GUARD.SEAM.truncation",
|
||||
"klass": "LIVE-CONFIG",
|
||||
"value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing"
|
||||
},
|
||||
{
|
||||
"id": "META.RULE.brief-must-cite-doc",
|
||||
"klass": "META",
|
||||
|
||||
@@ -219,6 +219,47 @@ disagree, trust (and fix) the rule table.
|
||||
|
||||
Unknown suites exit non-zero with the list of valid suites. Empty suites (e.g. `--suite security` before any security-tagged file exists) exit `0` with a `no tests in suite "..."` notice on stderr so CI lanes don't go red while a suite is being populated.
|
||||
|
||||
## The live-config hermeticity guard
|
||||
|
||||
Every `run-tests.cjs` invocation snapshots GSD's own install footprint in each
|
||||
live runtime config directory before the suite and re-checks it afterwards. It
|
||||
exists because the failure it catches is silent by construction: a test that
|
||||
resolves a config directory from the ambient environment instead of a sandbox
|
||||
writes into *your real* `~/.claude` (or `$GSD_HOME/.gsd`, or a Kimi
|
||||
`config.toml`), and nothing reports it. CI cannot catch this class at all —
|
||||
CI never has `CLAUDE_CONFIG_DIR` and friends set.
|
||||
|
||||
The guard watches only what GSD unambiguously owns — its top-level install
|
||||
footprint plus `gsd-`-prefixed children of directories shared with the host
|
||||
agent — never whole config roots, because a host agent legitimately writing
|
||||
`history.jsonl` mid-run would make the guard cry wolf, and a guard that cries
|
||||
wolf gets switched off.
|
||||
|
||||
Two environment variables control it:
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `GSD_STRICT_LIVE_CONFIG_GUARD=1` | A detected write **fails the run**. Set on the Linux/macOS lanes of every CI job that runs the suite. |
|
||||
| `GSD_SKIP_LIVE_CONFIG_GUARD=1` | Skips the check entirely. |
|
||||
|
||||
Unset, the guard **reports and does not fail** — deliberately, not timidly. On
|
||||
its first CI run it surfaced pre-existing leaks on the Windows lane, where
|
||||
`os.homedir()` reads `USERPROFILE` and ~190 test sites sandbox `HOME` alone.
|
||||
Those are real and worth fixing, but they are a different defect class, and a
|
||||
brand-new gate that instantly reds an unrelated lane gets reverted rather than
|
||||
obeyed. Windows lanes therefore stay report-only until that sweep lands; this
|
||||
repo has the pattern already, in the `local/no-source-grep` ESLint rule that
|
||||
shipped at `warn` and was promoted to `error` after its cleanup (ADR 452).
|
||||
|
||||
`GSD_SKIP_LIVE_CONFIG_GUARD` is a bypass on a safety check, so it is documented
|
||||
here rather than left to be discovered in the source: an undocumented bypass is
|
||||
one people eventually set without knowing what they turned off. If you need it
|
||||
routinely, that is a bug report, not a workflow.
|
||||
|
||||
Reported paths are labelled `CREATED`, `MODIFIED`, `DELETED`, or `UNVERIFIED`.
|
||||
`UNVERIFIED` means a scan bound was hit and the path could not be attested
|
||||
either way — it is never the same as clean.
|
||||
|
||||
## CI matrix
|
||||
|
||||
The `Tests` workflow runs every PR through a scoped gate generated by
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,6 +23,10 @@
|
||||
"scripts",
|
||||
"!scripts/gen-emitted-baseline.cjs",
|
||||
"!scripts/qa-smell-ratchet.cjs",
|
||||
"!scripts/live-config-guard.cjs",
|
||||
"!scripts/run-tests.cjs",
|
||||
"!scripts/affected-tests-lib.cjs",
|
||||
"!scripts/run-affected-tests.cjs",
|
||||
"pi",
|
||||
"vscode"
|
||||
],
|
||||
|
||||
640
scripts/live-config-guard.cjs
Normal file
640
scripts/live-config-guard.cjs
Normal file
@@ -0,0 +1,640 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* #2665 — post-suite hermeticity guard.
|
||||
*
|
||||
* The test suite must never write into a runtime's LIVE config directory. Two
|
||||
* mechanisms defend that, and neither one can see this failure:
|
||||
*
|
||||
* - `TEST_ENV_BASE` (tests/helpers.cjs) blanks every config-location env var,
|
||||
* but only for CHILD processes. A test that calls the installer IN-PROCESS
|
||||
* is untouched by it.
|
||||
* - CI is blind to the ambient-env half of the class outright, because CI
|
||||
* never has `CLAUDE_CONFIG_DIR` and friends set.
|
||||
*
|
||||
* So the class is silent by construction: it damages the developer's machine and
|
||||
* reports nothing. #2665 records two prior authors each diagnosing it and fixing
|
||||
* only the instance in front of them. This module converts it from silent to
|
||||
* loud by snapshotting GSD's own install footprint before the suite and
|
||||
* re-checking it after.
|
||||
*
|
||||
* LOCATION — `scripts/`, deliberately NOT `scripts/lib/`. The installer copies
|
||||
* `scripts/lib/` into every user's config dir wholesale (readdirSync), while
|
||||
* uninstall removes only an explicit allowlist, so a test-only module placed
|
||||
* there would ship to users AND survive uninstall. This file is also excluded
|
||||
* from the npm tarball (`package.json` `files[]` `!scripts/live-config-guard.cjs`,
|
||||
* alongside its whole require chain: run-tests.cjs, affected-tests-lib.cjs,
|
||||
* run-affected-tests.cjs — excluding one link alone would trip the #2858
|
||||
* shipped-requires-only-shipped gate on the links that still shipped).
|
||||
*
|
||||
* SCOPE — ownership-based, not whole-root. It watches entries GSD unambiguously
|
||||
* owns: the top-level install footprint (`GSD_OWNED_ENTRIES`) plus `gsd-`-prefixed
|
||||
* children of the dirs GSD shares with the host agent (`GSD_PREFIXED_PARENTS`).
|
||||
* It does NOT watch whole config roots. A root such as `~/.claude` is shared with
|
||||
* the host agent, which may legitimately write `history.jsonl`, `todos/`, or
|
||||
* `settings.json` while the suite runs; watching the root would turn that into a
|
||||
* false failure, and a guard that cries wolf gets disabled — after which it
|
||||
* catches nothing at all.
|
||||
*
|
||||
* The ownership test is the prefix, not the location. That distinction is load
|
||||
* bearing: the first version of this guard watched only the three top-level
|
||||
* entries and MISSED a real leak into `<live>/skills/gsd-dev-preferences/`.
|
||||
*
|
||||
* KNOWN GAP — a leak into a file GSD does not own (e.g. mutating the host's own
|
||||
* `.claude.json`, `settings.json`, `hooks.json`, `kilo.json`, `opencode.json`) is
|
||||
* outside this guard by construction. Closing it would require watching shared
|
||||
* files, which is the false-positive trap above.
|
||||
*
|
||||
* NAMED RESIDUALS — stated rather than implied, because two successive rounds
|
||||
* asserted this list was complete and both were refuted. Still NOT watched:
|
||||
* - `agents/subagents/**` (kimi stages `subagents/gsd-executor.yaml` under an
|
||||
* UNPREFIXED intermediate dir, so no prefix scan of `agents/` reaches it);
|
||||
* - the loose capability generators copied to `<root>/scripts/*.cjs`
|
||||
* (`fix-slash-commands.cjs` is watched by name; the generators are not);
|
||||
* - `extensions/package.json` and `plugins/package.json` — CommonJS markers in
|
||||
* dirs GSD fills but does not own, so they fall under the shared-ground rule
|
||||
* below rather than being watched;
|
||||
* - the shared-hooks bundle in a NON-registry root's `<root>/hooks/` (kimi) —
|
||||
* see resolveExtraWatchTargets; closing it is a layout decision.
|
||||
* DELIBERATELY not watched, which is a different thing from missed: `hooks/lib`,
|
||||
* `hooks/package.json`, `scripts/lib` and `scripts/changeset`. The installer
|
||||
* preserves foreign files in each, so watching them wholesale produces false
|
||||
* positives — and a guard that cries wolf gets switched off.
|
||||
* Every item above under-watches, which fails quiet: a missed leak, never a
|
||||
* false alarm.
|
||||
*
|
||||
* SEVERITY — reports by default, fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1.
|
||||
* Not timidity: on its first CI run this guard found PRE-EXISTING leaks on the
|
||||
* Windows lane (`C:\Users\runneradmin\.claude\gsd-core` and
|
||||
* `skills\gsd-dev-preferences`), because os.homedir() reads USERPROFILE there and
|
||||
* ~190 test sites sandbox HOME alone. Those are real and worth fixing, but they
|
||||
* are a different defect class from the one #2665 closes, and a brand-new gate
|
||||
* that immediately reds an unrelated lane gets bypassed or reverted rather than
|
||||
* obeyed. This repo already has the pattern: the local/no-source-grep ESLint rule
|
||||
* shipped at `warn` and was promoted to `error` after its cleanup sweep (ADR 452).
|
||||
* Promote this the same way once the USERPROFILE sweep lands.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Top-level entries only a GSD install creates. See SCOPE above before widening.
|
||||
*
|
||||
* `.gsd-source` and `.gsd-profile` were added by the round-5 census (see below):
|
||||
* bin/install.js writes both at the config ROOT for a global install, and an
|
||||
* exact-name list does not match a dot-prefixed name by the `gsd-` prefix rule.
|
||||
*/
|
||||
const GSD_OWNED_ENTRIES = [
|
||||
'gsd-core',
|
||||
'gsd-file-manifest.json',
|
||||
'gsd-pristine',
|
||||
'.gsd-source',
|
||||
'.gsd-profile',
|
||||
];
|
||||
|
||||
/**
|
||||
* Directories GSD SHARES with the host agent. Watching them wholesale would
|
||||
* false-positive on the host's own writes, so only `gsd-`-prefixed children are
|
||||
* watched — those are unambiguously ours.
|
||||
*
|
||||
* Added after the first version of this guard MISSED a real leak: a raw
|
||||
* `spawnSync` that sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR
|
||||
* wrote `<live>/skills/gsd-dev-preferences/SKILL.md`, which sits under none of
|
||||
* the three top-level entries above.
|
||||
*
|
||||
* `hooks` joined them in round 5, found by re-deriving the census rather than by
|
||||
* a review finding — the SAME shape one parent over. bin/install.js writes
|
||||
* `hooks/gsd-check-update.js`, `hooks/gsd-context-monitor.js` and
|
||||
* `hooks/gsd-update-banner.js` into the config root, and with `hooks` absent from
|
||||
* this list a leak of any of them passed the guard silently. The lesson the first
|
||||
* miss taught is that this list is the weak point, so it is re-derived from the
|
||||
* installer's own write sites each round rather than trusted.
|
||||
*/
|
||||
const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills', 'hooks'];
|
||||
const GSD_ARTIFACT_PREFIX = 'gsd-';
|
||||
|
||||
/**
|
||||
* Artifact parents that are NOT registry-declared — the installer writes these
|
||||
* directly rather than through a capability's artifactLayout.
|
||||
*/
|
||||
const NON_REGISTRY_ARTIFACT_PARENTS = ['hooks', 'plugins', 'scripts', 'extensions'];
|
||||
|
||||
/**
|
||||
* Prefixes used for a parent with no registry-declared one. BOTH forms are the
|
||||
* point: GSD writes `gsd-`-hyphen artifacts (`hooks/gsd-check-update.js`) AND
|
||||
* bare `gsd.`-dotted ones (pi's `extensions/gsd.js`), and a lone `gsd-` sees
|
||||
* only the first.
|
||||
*/
|
||||
const DEFAULT_ARTIFACT_PREFIXES = ['gsd-', 'gsd.'];
|
||||
|
||||
/**
|
||||
* Files GSD owns by EXACT NAME inside a directory it shares — deliberately NOT
|
||||
* the directories themselves.
|
||||
*
|
||||
* `hooks/lib`, `hooks/package.json`, `scripts/lib` and `scripts/changeset` were
|
||||
* watched wholesale for exactly one commit, and that was wrong: the installer's
|
||||
* own uninstall path preserves foreign files in every one of them (it removes the
|
||||
* CommonJS marker only on an exact content match — "a user-authored package.json
|
||||
* is never deleted"). Watching them wholesale turns a user editing their own
|
||||
* helper into a violation, which is the false-positive trap the SCOPE note above
|
||||
* exists to refuse. Under-watching fails quiet; over-watching disarms the guard.
|
||||
*/
|
||||
const GSD_OWNED_NESTED = [
|
||||
'scripts/fix-slash-commands.cjs',
|
||||
'hooks/managed-hooks-registry.cjs',
|
||||
];
|
||||
|
||||
/**
|
||||
* DERIVE the artifact parents from the capability registry rather than naming
|
||||
* them, for the same reason TEST_ENV_BASE derives its keys: a hand-list is only
|
||||
* ever as complete as its author's recall, and this one was measurably not.
|
||||
*
|
||||
* Round 5's adversarial review found the hand-list missing Kilo's SINGULAR
|
||||
* `command/`, `workflows/`, and Hermes' `skills/gsd` -- the last being a whole
|
||||
* directory whose name carries no `gsd-` prefix, so no prefix rule reaches it.
|
||||
* A capability that declares a new destSubpath now extends this set in the same
|
||||
* commit that declares it.
|
||||
*
|
||||
* @returns {{parents: string[], owned: string[]}} parents = watch gsd-prefixed
|
||||
* children only; owned = watch the path wholesale (its own name is GSD's).
|
||||
*/
|
||||
function deriveArtifactTargets(runtimes) {
|
||||
const parents = new Map();
|
||||
const addParent = (dest, prefixes) => {
|
||||
if (!parents.has(dest)) parents.set(dest, new Set());
|
||||
for (const pre of prefixes) parents.get(dest).add(pre);
|
||||
};
|
||||
for (const dest of [...GSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS]) {
|
||||
addParent(dest, DEFAULT_ARTIFACT_PREFIXES);
|
||||
}
|
||||
const owned = new Set(GSD_OWNED_NESTED);
|
||||
for (const entry of Object.values(runtimes || {})) {
|
||||
for (const layout of entry?.runtime?.artifactLayout?.global ?? []) {
|
||||
const dest = layout?.destSubpath;
|
||||
if (typeof dest !== 'string' || !dest) continue;
|
||||
const last = dest.split('/').pop() || '';
|
||||
// A destination whose own final segment is GSD's (hermes' `skills/gsd`) is
|
||||
// owned wholesale — that directory is ours, not shared.
|
||||
if (last.startsWith('gsd')) { owned.add(dest); continue; }
|
||||
// The layout declares its OWN prefix, and it varies: kimi's `kimi-agents`
|
||||
// layout declares `gsd` (no hyphen) and writes `agents/gsd.yaml` +
|
||||
// `agents/gsd.md`, invisible to a fixed `gsd-` scan. The same destSubpath
|
||||
// also carries different prefixes across runtimes, so a parent maps to a SET.
|
||||
const declared = typeof layout?.prefix === 'string' && layout.prefix
|
||||
? [layout.prefix]
|
||||
: DEFAULT_ARTIFACT_PREFIXES;
|
||||
addParent(dest, declared);
|
||||
}
|
||||
}
|
||||
const out = {};
|
||||
for (const [dest, set] of [...parents.entries()].sort()) out[dest] = [...set].sort();
|
||||
return { parents: out, owned: [...owned].sort() };
|
||||
}
|
||||
|
||||
/** Memoized registry-derived targets; falls back to the static lists unbuilt. */
|
||||
let _artifactTargets = null;
|
||||
function artifactTargets(deps = {}) {
|
||||
if (_artifactTargets && !deps.libDir) return _artifactTargets;
|
||||
const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
let runtimes;
|
||||
try {
|
||||
({ runtimes } = require(path.join(libDir, 'capability-registry.cjs')));
|
||||
} catch {
|
||||
// Unbuilt tree: the static lists are a strict subset, never a wrong answer.
|
||||
return deriveArtifactTargets(null);
|
||||
}
|
||||
const derived = deriveArtifactTargets(runtimes);
|
||||
if (!deps.libDir) _artifactTargets = derived;
|
||||
return derived;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file GSD writes into a NON-REGISTRY config home.
|
||||
*
|
||||
* Both current descriptors are Kimi's — Kimi CLI's `~/.kimi` (KIMI_SHARE_DIR) and
|
||||
* Kimi Code's `~/.kimi-code` (KIMI_CODE_HOME) — and GSD writes its native
|
||||
* `[[hooks]]` block into `config.toml` in each, so the single filename below holds
|
||||
* for both. NAMED RESIDUAL: this assumes every non-registry descriptor is written
|
||||
* the same way. That assumption is now load-bearing rather than vacuous — it is
|
||||
* carrying two descriptors, not one — and a future descriptor whose owned file
|
||||
* differs needs a per-descriptor mapping here. The consequence of getting it wrong
|
||||
* is under-watching (a missed leak), not a false positive, so it fails in the quiet
|
||||
* direction and is called out rather than left to be discovered.
|
||||
*/
|
||||
const NON_REGISTRY_OWNED_FILE = 'config.toml';
|
||||
|
||||
/**
|
||||
* Bounds on the recursive walk, so a pathological tree cannot stall the suite.
|
||||
*
|
||||
* MAX_ENTRIES is PER WATCH TARGET, not per snapshot. It was a single running
|
||||
* budget threaded across every target, which made the guard's verdict depend on
|
||||
* directory ORDER and on unrelated local state: one large early target exhausted
|
||||
* it, and every target scanned afterwards reported `truncated` -> `unverified`,
|
||||
* which under GSD_STRICT_LIVE_CONFIG_GUARD=1 is a failed run. Per-target means a
|
||||
* pathological tree truncates ITSELF and nothing else.
|
||||
*
|
||||
* MAX_TOTAL_ENTRIES keeps the aggregate bounded, which is what the single budget
|
||||
* was really for. It engages only when the per-target bounds together exceed it;
|
||||
* when it does, the targets it curtails are reported `unverified` -- never
|
||||
* silently clean.
|
||||
*
|
||||
* NAMED RESIDUAL, because the obvious stronger claim is FALSE: order-independence
|
||||
* holds BELOW the global ceiling, not above it. Once MAX_TOTAL_ENTRIES is
|
||||
* exhausted, which targets get curtailed still depends on iteration order -- that
|
||||
* is inherent to any shared aggregate bound, and the fix here removes the ordinary
|
||||
* case (one large target cascading over everything after it) rather than the
|
||||
* pathological one. The curtailed targets are reported `unverified`, so the
|
||||
* residual costs legibility, never a false clean.
|
||||
*/
|
||||
const MAX_ENTRIES = 20000;
|
||||
const MAX_TOTAL_ENTRIES = 200000;
|
||||
const MAX_DEPTH = 12;
|
||||
|
||||
/**
|
||||
* Resolve every runtime config root the product could write to, using the REAL
|
||||
* resolver rather than a reimplementation — the guard must watch wherever the
|
||||
* product actually points, including through an ambient env var.
|
||||
*
|
||||
* TWO resolutions, unioned, because the parent and its children do not resolve
|
||||
* the same way: the AMBIENT one (what this process sees, env-first) and the
|
||||
* FALLBACK one (what a child that BLANKED the config-location vars resolves to,
|
||||
* i.e. HOME-derived). Watching only the first leaves the second unwatched, which
|
||||
* is where a child that scrubs the var but not HOME actually writes.
|
||||
*
|
||||
* @returns {string[]} deduped, sorted roots; empty if the built lib is absent.
|
||||
*/
|
||||
function resolveLiveConfigRoots(deps = {}) {
|
||||
const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
const homedir = (deps.os || os).homedir;
|
||||
let getGlobalConfigDir;
|
||||
let resolveConfigHomeFromDescriptor;
|
||||
let runtimes;
|
||||
try {
|
||||
({ getGlobalConfigDir, resolveConfigHomeFromDescriptor } =
|
||||
require(path.join(libDir, 'runtime-homes.cjs')));
|
||||
({ runtimes } = require(path.join(libDir, 'capability-registry.cjs')));
|
||||
} catch {
|
||||
// Unbuilt tree: the guard is advisory infrastructure and must never be the
|
||||
// reason a test run cannot start. Callers treat [] as "guard unavailable".
|
||||
return [];
|
||||
}
|
||||
|
||||
const roots = new Set();
|
||||
|
||||
// ── The FALLBACK roots, which the ambient resolution above cannot reach ────
|
||||
//
|
||||
// #2665 round 5: getGlobalConfigDir is env-first, so the loop below resolves
|
||||
// whatever THIS process sees. A spawned child does not see that — TEST_ENV_BASE
|
||||
// blanks the config-location vars precisely so the child cannot follow them —
|
||||
// and a blanked var is falsy, so the child falls back to its HOME-derived root
|
||||
// instead. A child that blanks the var and does NOT also sandbox HOME therefore
|
||||
// writes into the developer's real ~/.claude while the guard is watching the
|
||||
// ambient path, one process shallower. That is the exact escape route this PR
|
||||
// exists to close, taken one layer down.
|
||||
//
|
||||
// Derived, never re-listed: passing an EMPTY env to the real descriptor resolver
|
||||
// IS "what a child with no config-location vars resolves to". Deriving it this
|
||||
// way keeps the guard from carrying a second copy of the scrub set to drift
|
||||
// against -- the defect this PR spent three rounds closing one layer up.
|
||||
for (const entry of Object.values(runtimes || {})) {
|
||||
const descriptor = entry?.runtime?.configHome;
|
||||
if (!descriptor) continue;
|
||||
try {
|
||||
const dir = resolveConfigHomeFromDescriptor(descriptor, { env: {}, home: homedir() });
|
||||
if (typeof dir === 'string' && dir.length > 0) roots.add(path.resolve(dir));
|
||||
} catch {
|
||||
// Same posture as the ambient loop below.
|
||||
}
|
||||
}
|
||||
// grok resolves through a hardcoded branch rather than a descriptor, so its
|
||||
// fallback is stated here for the same reason it is named in the loop below.
|
||||
roots.add(path.resolve(path.join(homedir(), '.agents')));
|
||||
// 'grok' is a hardcoded branch of getGlobalConfigDir with no registry entry.
|
||||
//
|
||||
// DELIBERATE NON-ROOT: getGlobalSkillsBase(runtime) is NOT added here. The
|
||||
// skills base (e.g. codex's ~/.agents/skills) is not a config ROOT, and the
|
||||
// snapshot applies the config-root layout (GSD_OWNED_ENTRIES x
|
||||
// GSD_PREFIXED_PARENTS) beneath every root it is given — measured on a
|
||||
// sandboxed HOME, adding it both false-positives on `<skillsBase>/gsd-core`
|
||||
// and misses a real `<skillsBase>/gsd-help` write. Watching skills bases
|
||||
// needs its own layout, like resolveExtraWatchTargets — a separate change.
|
||||
for (const runtime of [...Object.keys(runtimes || {}), 'grok']) {
|
||||
try {
|
||||
const dir = getGlobalConfigDir(runtime);
|
||||
if (typeof dir === 'string' && dir.length > 0) roots.add(path.resolve(dir));
|
||||
} catch {
|
||||
// A descriptor the resolver cannot satisfy is not this module's problem.
|
||||
}
|
||||
}
|
||||
return [...roots].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch targets that are NOT runtime config roots, and so cannot be expressed as
|
||||
* `root x GSD_OWNED_ENTRIES`.
|
||||
*
|
||||
* #2665 round 3: resolveLiveConfigRoots enumerates getGlobalConfigDir per registry
|
||||
* runtime plus grok. A live write surface that is not a config ROOT is invisible to
|
||||
* that shape, so a leak on one passed through this guard — the PR's own safety net —
|
||||
* silently. There are THREE today ($GSD_HOME/.gsd, plus one config.toml per entry in
|
||||
* NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, which #2755 took from one entry to two):
|
||||
*
|
||||
* $GSD_HOME/.gsd — GSD's user-owned store (consent.json, defaults.json, capability
|
||||
* overlays). Watched WHOLESALE: unlike ~/.claude this root is
|
||||
* exclusively ours, so the shared-root false-positive trap in
|
||||
* SCOPE above does not apply and an ownership filter would only
|
||||
* narrow the guard for nothing.
|
||||
* <non-registry home>/config.toml — the file GSD writes its native [[hooks]] block
|
||||
* into, one per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry: Kimi
|
||||
* CLI's ~/.kimi (KIMI_SHARE_DIR) and, since #2755, Kimi Code's
|
||||
* ~/.kimi-code (KIMI_CODE_HOME). The INVERSE case: those roots
|
||||
* belong to their products, so the root is never watched
|
||||
* wholesale. This is the KNOWN GAP above accepted deliberately
|
||||
* in one direction — GSD demonstrably writes these files
|
||||
* (bin/install.js resolves the hooks-toml dir at two sites), so
|
||||
* a concurrent write by those products is the only false
|
||||
* positive, and neither runs during the suite. NOT watched, and
|
||||
* it is a real residual rather than a bound: <root>/hooks/, the
|
||||
* bundle installSharedHooksBundle writes into the same roots —
|
||||
* see resolveExtraWatchTargets for why closing it is a layout
|
||||
* decision.
|
||||
*
|
||||
* @returns {string[]} absolute paths; empty if the built lib is absent.
|
||||
*/
|
||||
function resolveExtraWatchTargets(deps = {}) {
|
||||
const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
const env = deps.env || process.env;
|
||||
const homedir = (deps.os || os).homedir;
|
||||
|
||||
// BOTH resolutions, exactly as resolveLiveConfigRoots does — the ambient one and
|
||||
// the one a child that BLANKED the var falls back to. Watching only the ambient
|
||||
// path leaves the fallback unwatched, which is the same defect B3 closed for the
|
||||
// registry roots; it lived here too until round 5's adversarial review found it.
|
||||
const targets = [
|
||||
path.resolve(path.join(env.GSD_HOME || homedir(), '.gsd')),
|
||||
path.resolve(path.join(homedir(), '.gsd')),
|
||||
];
|
||||
|
||||
try {
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
resolveConfigHomeFromDescriptor,
|
||||
} = require(path.join(libDir, 'runtime-homes.cjs'));
|
||||
|
||||
// ITERATE the descriptor array rather than naming one resolver. Calling
|
||||
// resolveKimiHooksTomlDir directly would cover one of today's two entries and
|
||||
// silently miss tomorrow's — the same partial-enumeration defect that put
|
||||
// KIMI_SHARE_DIR outside the scrub set in the first place, reintroduced one
|
||||
// layer over. TEST_ENV_BASE derives its keys from this array; deriving the
|
||||
// guard's paths from it keeps the two halves from drifting apart.
|
||||
//
|
||||
// Thread the SAME injected env/home used above: resolving bare would read
|
||||
// process.env and os.homedir() regardless of `deps`, leaving the seam
|
||||
// untestable and the targets resolved against different worlds.
|
||||
for (const descriptor of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) {
|
||||
// The fallback leg, per the note above: a child that blanks KIMI_SHARE_DIR /
|
||||
// KIMI_CODE_HOME writes to the HOME-derived root instead of the ambient one.
|
||||
const fallbackDir = resolveConfigHomeFromDescriptor(descriptor, { env: {}, home: homedir() });
|
||||
if (typeof fallbackDir === 'string' && fallbackDir.length > 0) {
|
||||
targets.push(path.resolve(path.join(fallbackDir, NON_REGISTRY_OWNED_FILE)));
|
||||
}
|
||||
const dir = resolveConfigHomeFromDescriptor(descriptor, { env, home: homedir() });
|
||||
// The root belongs to the runtime, so it is never watched wholesale — only
|
||||
// the named file below.
|
||||
//
|
||||
// NAMED RESIDUAL (#2665, found pre-push while rebasing): config.toml is NOT
|
||||
// the only thing GSD writes here. bin/install.js also calls
|
||||
// installSharedHooksBundle(kimiHooksRoot), which populates <root>/hooks/
|
||||
// with GSD's hook scripts and a CommonJS marker. That subtree is UNWATCHED,
|
||||
// so a suite-produced leak of a hook bundle into a developer's real ~/.kimi
|
||||
// or ~/.kimi-code passes this guard silently. Closing it needs a layout
|
||||
// decision, not one more path: the same reason getGlobalSkillsBase is a
|
||||
// deliberate non-target above. Under-watching fails quiet, like the
|
||||
// NON_REGISTRY_OWNED_FILE residual it sits beside.
|
||||
targets.push(path.resolve(path.join(dir, NON_REGISTRY_OWNED_FILE)));
|
||||
}
|
||||
} catch {
|
||||
// Unbuilt tree — same posture as resolveLiveConfigRoots: advisory, never fatal.
|
||||
}
|
||||
// Both legs can coincide when no override is set; the snapshot keys on path,
|
||||
// but dedupe anyway so the RV-facing target count means what it says.
|
||||
return [...new Set(targets)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest mtime within a tree, bounded. Returns `truncated: true` when a bound
|
||||
* was hit — the caller must NOT report such a result as clean, on the same
|
||||
* principle that an existence probe passing vacuously is worse than no probe.
|
||||
*/
|
||||
function newestMtime(target, budget) {
|
||||
let newest = 0;
|
||||
let truncated = false;
|
||||
|
||||
const walk = (current, depth) => {
|
||||
if (budget.remaining <= 0) { truncated = true; return; }
|
||||
if (depth > MAX_DEPTH) { truncated = true; return; }
|
||||
let st;
|
||||
try {
|
||||
st = fs.lstatSync(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
budget.remaining -= 1;
|
||||
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
||||
if (!st.isDirectory()) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
// RETURN, not continue: without this the loop keeps invoking walk() for
|
||||
// every remaining sibling after the budget is gone, so the ceiling bounds
|
||||
// what is RECORDED but not the work done getting there.
|
||||
if (budget.remaining <= 0) { truncated = true; return; }
|
||||
walk(path.join(current, entry), depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
walk(target, 0);
|
||||
return { newest, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot GSD-owned entries under each root.
|
||||
*
|
||||
* @returns {Record<string, {exists: boolean, newest: number, truncated: boolean}>}
|
||||
* keyed by absolute entry path.
|
||||
*/
|
||||
function snapshotLiveConfig(roots, extraTargets = [], limits = {}) {
|
||||
// Clamp at 0: a negative injected limit would start the ceiling below empty and
|
||||
// make every target report truncated for a reason that is not a scan bound.
|
||||
// Number.isFinite, NOT Math.max: `Math.max(0, NaN)` is NaN, and every budget
|
||||
// comparison against NaN is false — the bound then fails OPEN and the walk is
|
||||
// unbounded, which is the single thing these constants exist to prevent.
|
||||
const finite = (v, fallback) => (Number.isFinite(v) && v >= 0 ? v : fallback);
|
||||
const perTarget = finite(limits.perTarget, MAX_ENTRIES);
|
||||
const total = { remaining: finite(limits.total, MAX_TOTAL_ENTRIES) };
|
||||
const snap = {};
|
||||
|
||||
const record = (target) => {
|
||||
if (!fs.existsSync(target)) {
|
||||
snap[target] = { exists: false, newest: 0, truncated: false };
|
||||
return;
|
||||
}
|
||||
// A FRESH budget per target, drawn against the global ceiling. See the
|
||||
// MAX_ENTRIES docblock: a shared running budget let one large early target
|
||||
// cascade `unverified` over every target scanned after it, so the verdict
|
||||
// depended on iteration order rather than on what the run actually touched.
|
||||
const budget = { remaining: Math.min(perTarget, total.remaining) };
|
||||
const allotted = budget.remaining;
|
||||
const { newest, truncated } = newestMtime(target, budget);
|
||||
total.remaining -= allotted - budget.remaining;
|
||||
snap[target] = { exists: true, newest, truncated };
|
||||
};
|
||||
|
||||
// Non-root targets (resolveExtraWatchTargets) are recorded verbatim — they are
|
||||
// already the exact path to watch, whole-dir or single-file. Passed explicitly
|
||||
// rather than resolved here so a caller testing a fixture root does not silently
|
||||
// pull the developer's real ~/.gsd into its snapshot.
|
||||
for (const target of extraTargets) record(path.resolve(target));
|
||||
|
||||
const { parents: watchParents, owned: watchOwned } = artifactTargets();
|
||||
|
||||
for (const root of roots) {
|
||||
for (const entry of GSD_OWNED_ENTRIES) record(path.join(root, entry));
|
||||
// Paths whose own name is GSD's, nested inside a shared root (hermes'
|
||||
// `skills/gsd`, `hooks/lib`, `scripts/lib`, …) — no prefix rule sees these.
|
||||
for (const entry of watchOwned) record(path.join(root, ...entry.split('/')));
|
||||
|
||||
// Shared dirs: enumerate only gsd-prefixed children. A child that appears
|
||||
// between the two snapshots is absent from `before` entirely — diffLiveConfig
|
||||
// treats after-only paths as created, which is exactly the leak signal.
|
||||
for (const [parent, prefixes] of Object.entries(watchParents)) {
|
||||
const parentDir = path.join(root, ...parent.split('/'));
|
||||
let children;
|
||||
try {
|
||||
children = fs.readdirSync(parentDir);
|
||||
} catch {
|
||||
continue; // parent absent — nothing of ours can be in it yet
|
||||
}
|
||||
for (const child of children) {
|
||||
if (prefixes.some((pre) => child.startsWith(pre))) record(path.join(parentDir, child));
|
||||
}
|
||||
}
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two snapshots. A path is a violation when it was created during the
|
||||
* run, when its newest mtime advanced, or when it was DELETED by the run.
|
||||
*
|
||||
* Iterate the UNION of both key sets, never `after` alone. Deletion reaches this
|
||||
* function in two shapes and an after-only walk sees neither:
|
||||
*
|
||||
* - A FIXED owned entry (GSD_OWNED_ENTRIES x roots, and every extra target) is
|
||||
* recorded at both ends whether or not it exists, so a deletion reads
|
||||
* {exists:true} -> {exists:false} and falls through every branch — silently.
|
||||
* - A gsd-prefixed child is DISCOVERED by readdir, so a deleted one is absent
|
||||
* from `after` entirely and never enters an after-keyed loop at all.
|
||||
*
|
||||
* The second shape is why adding a `pre.exists && !post.exists` branch is not on
|
||||
* its own sufficient: that branch is unreachable for exactly the discovered
|
||||
* children the prefix scan exists to catch.
|
||||
*
|
||||
* @returns {{path: string, kind: 'created'|'modified'|'deleted'|'unverified'}[]}
|
||||
*/
|
||||
function diffLiveConfig(before, after) {
|
||||
const violations = [];
|
||||
const targets = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
for (const target of targets) {
|
||||
const pre = before[target];
|
||||
const post = after[target];
|
||||
// Absent from `before` entirely: a gsd-prefixed child that did not exist
|
||||
// when the run started. Both snapshots cover the same roots, so an
|
||||
// after-only path was created BY the run — never skip it.
|
||||
if (!pre) {
|
||||
if (post && post.exists) violations.push({ path: target, kind: 'created' });
|
||||
continue;
|
||||
}
|
||||
// Absent from `after` entirely: a discovered child that existed when the run
|
||||
// started and does not now. Deletion is the least recoverable outcome in this
|
||||
// threat model, so it is never inferred as clean.
|
||||
if (!post) {
|
||||
if (pre.exists) violations.push({ path: target, kind: 'deleted' });
|
||||
continue;
|
||||
}
|
||||
if (!pre.exists && post.exists) {
|
||||
violations.push({ path: target, kind: 'created' });
|
||||
} else if (pre.exists && !post.exists) {
|
||||
violations.push({ path: target, kind: 'deleted' });
|
||||
} else if (pre.exists && post.exists && post.newest > pre.newest) {
|
||||
violations.push({ path: target, kind: 'modified' });
|
||||
} else if (pre.truncated || post.truncated) {
|
||||
// Bound hit: we cannot attest this path either way, and saying nothing
|
||||
// would let a truncated scan read as a clean one.
|
||||
violations.push({ path: target, kind: 'unverified' });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** Human-facing report for a non-empty violation set. */
|
||||
function formatViolations(violations) {
|
||||
const lines = [
|
||||
'',
|
||||
'run-tests: HERMETICITY WARNING — the suite wrote into a LIVE config directory.',
|
||||
'',
|
||||
'A test resolved a runtime config dir from the ambient environment instead of a',
|
||||
'sandbox. The usual cause is an IN-PROCESS install() call: tests/helpers.cjs',
|
||||
'TEST_ENV_BASE only scrubs CHILD process env, so an in-process caller must also',
|
||||
'use scrubConfigLocationEnv() (see tests/install.test.cjs) alongside its HOME',
|
||||
'sandbox. CI cannot catch this class — it never has these env vars set.',
|
||||
'',
|
||||
];
|
||||
for (const v of violations) {
|
||||
const label = v.kind === 'unverified'
|
||||
? 'UNVERIFIED (scan bound hit — not attested clean)'
|
||||
: v.kind.toUpperCase();
|
||||
lines.push(` ${label}: ${v.path}`);
|
||||
}
|
||||
lines.push('');
|
||||
// The footer must state the mode the run is ACTUALLY in — a strict-mode
|
||||
// failure captioned "Reporting only" sends the reader away from the very
|
||||
// violation that just reddened their run.
|
||||
lines.push(
|
||||
process.env.GSD_STRICT_LIVE_CONFIG_GUARD === '1'
|
||||
? 'STRICT MODE (GSD_STRICT_LIVE_CONFIG_GUARD=1): these violations fail the run. ' +
|
||||
'GSD_SKIP_LIVE_CONFIG_GUARD=1 skips the check entirely.'
|
||||
: 'Reporting only. Set GSD_STRICT_LIVE_CONFIG_GUARD=1 to make this fail the run, ' +
|
||||
'or GSD_SKIP_LIVE_CONFIG_GUARD=1 to skip the check entirely.',
|
||||
);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GSD_OWNED_ENTRIES,
|
||||
GSD_PREFIXED_PARENTS,
|
||||
GSD_OWNED_NESTED,
|
||||
NON_REGISTRY_ARTIFACT_PARENTS,
|
||||
DEFAULT_ARTIFACT_PREFIXES,
|
||||
deriveArtifactTargets,
|
||||
artifactTargets,
|
||||
GSD_ARTIFACT_PREFIX,
|
||||
MAX_ENTRIES,
|
||||
MAX_TOTAL_ENTRIES,
|
||||
MAX_DEPTH,
|
||||
resolveLiveConfigRoots,
|
||||
resolveExtraWatchTargets,
|
||||
snapshotLiveConfig,
|
||||
diffLiveConfig,
|
||||
formatViolations,
|
||||
newestMtime,
|
||||
os, // exported for test seams only
|
||||
};
|
||||
@@ -39,6 +39,13 @@ const { readdirSync, readFileSync } = require('fs');
|
||||
const { join, basename } = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const {
|
||||
resolveLiveConfigRoots,
|
||||
resolveExtraWatchTargets,
|
||||
snapshotLiveConfig,
|
||||
diffLiveConfig,
|
||||
formatViolations,
|
||||
} = require('./live-config-guard.cjs');
|
||||
|
||||
const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow', 'qa'];
|
||||
|
||||
@@ -965,6 +972,29 @@ function main() {
|
||||
// job cap. Operator/test override via RUN_TESTS_CHUNK_TIMEOUT_MS.
|
||||
const chunkTimeoutMs = positiveNumberEnv(process.env.RUN_TESTS_CHUNK_TIMEOUT_MS, 600000);
|
||||
|
||||
// #2665: snapshot GSD's install footprint in every LIVE runtime config dir
|
||||
// before a single test runs. The suite must not write there; the check after
|
||||
// the chunk loop is what makes a violation loud instead of silent. See
|
||||
// scripts/live-config-guard.cjs for why the scope is narrow (it is deliberately
|
||||
// NOT under scripts/lib/, which the installer copies to users wholesale).
|
||||
const liveConfigGuardEnabled = process.env.GSD_SKIP_LIVE_CONFIG_GUARD !== '1';
|
||||
let liveConfigRoots = [];
|
||||
let liveConfigExtras = [];
|
||||
let liveConfigBefore = null;
|
||||
if (liveConfigGuardEnabled) {
|
||||
liveConfigRoots = resolveLiveConfigRoots();
|
||||
// #2665 round 3: $GSD_HOME/.gsd, and one native config.toml per non-registry
|
||||
// config-home descriptor (Kimi CLI's and, since #2755, Kimi Code's), are live
|
||||
// write surfaces that are not runtime config ROOTS, so they are invisible to the
|
||||
// line above. Watched independently — and note the OR: the extras alone are
|
||||
// reason enough to snapshot, so an unbuilt tree that yields zero roots no
|
||||
// longer silently disables the whole guard.
|
||||
liveConfigExtras = resolveExtraWatchTargets();
|
||||
if (liveConfigRoots.length > 0 || liveConfigExtras.length > 0) {
|
||||
liveConfigBefore = snapshotLiveConfig(liveConfigRoots, liveConfigExtras);
|
||||
}
|
||||
}
|
||||
|
||||
let firstFailureExit = 0;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (chunks.length > 1) {
|
||||
@@ -1030,6 +1060,24 @@ function main() {
|
||||
// and the first non-zero exit is reported at the end.
|
||||
}
|
||||
}
|
||||
// #2665: post-suite hermeticity check. Runs even when tests failed — a leaked
|
||||
// global install is worth reporting alongside the failure that hid it, and
|
||||
// suppressing it on red would hide it exactly when the suite is least trusted.
|
||||
if (liveConfigBefore) {
|
||||
const violations = diffLiveConfig(
|
||||
liveConfigBefore,
|
||||
snapshotLiveConfig(liveConfigRoots, liveConfigExtras),
|
||||
);
|
||||
if (violations.length > 0) {
|
||||
console.error(formatViolations(violations));
|
||||
// Reports by default; fails only under opt-in strict mode. See the
|
||||
// SEVERITY note in scripts/live-config-guard.cjs for why.
|
||||
if (process.env.GSD_STRICT_LIVE_CONFIG_GUARD === '1' && firstFailureExit === 0) {
|
||||
firstFailureExit = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstFailureExit !== 0) return firstFailureExit;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ interface NoneDescriptor {
|
||||
skillsHome?: ConfigHomeDescriptor;
|
||||
}
|
||||
|
||||
type ConfigHomeDescriptor =
|
||||
export type ConfigHomeDescriptor =
|
||||
| DotHomeDescriptor
|
||||
| DotHomeNestedDescriptor
|
||||
| XdgDescriptor
|
||||
@@ -480,6 +480,76 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kimi CLI's own native config.toml home. Hoisted out of resolveKimiHooksTomlDir
|
||||
* so it is ENUMERABLE, not merely resolvable.
|
||||
*
|
||||
* #2665 round 3: a config-location var that lives only inside a function body is
|
||||
* invisible to every consumer that needs the SET rather than the path — the test
|
||||
* scrub list and the hermeticity guard both derive from descriptors, and this one
|
||||
* reached neither. `kimi` is the sharp case precisely because it owns TWO config
|
||||
* homes: KIMI_CONFIG_DIR (registry-visible, already covered) and KIMI_SHARE_DIR
|
||||
* (this one), so a derivation keyed only on the registry looks complete and is not.
|
||||
*/
|
||||
export const KIMI_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = {
|
||||
kind: 'dot-home',
|
||||
name: '.kimi',
|
||||
env: ['KIMI_SHARE_DIR'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Kimi Code's native config.toml home — the `kimi-code` counterpart of the
|
||||
* descriptor above, hoisted for exactly the same reason.
|
||||
*
|
||||
* #2755 landed kimi-code hooks support on `next` while this PR was open, and
|
||||
* declared this descriptor as an inline object literal inside
|
||||
* resolveKimiHooksTomlDir's body — the same resolvable-but-not-enumerable shape
|
||||
* round 3 hoisted KIMI_SHARE_DIR out of. Hoisting it puts `KIMI_CODE_HOME` into
|
||||
* the derived scrub set and the hermeticity guard's watch roots in the SAME
|
||||
* commit, which is the property NON_REGISTRY_CONFIG_HOME_DESCRIPTORS exists to
|
||||
* guarantee. Each product's env var stays scoped to that product (#2755).
|
||||
*/
|
||||
export const KIMI_CODE_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = {
|
||||
kind: 'dot-home',
|
||||
name: '.kimi-code',
|
||||
env: ['KIMI_CODE_HOME'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Config-home descriptors resolved OUTSIDE the capability registry.
|
||||
*
|
||||
* Anything added here is picked up by every derived consumer in the same commit —
|
||||
* which is the property that makes the derivation structurally incapable of being
|
||||
* narrower than the surface it guards. Adding a hardcoded resolver WITHOUT adding
|
||||
* its descriptor here is the defect this array exists to make hard.
|
||||
*/
|
||||
export const NON_REGISTRY_CONFIG_HOME_DESCRIPTORS: ConfigHomeDescriptor[] = [
|
||||
KIMI_HOOKS_TOML_DESCRIPTOR,
|
||||
KIMI_CODE_HOOKS_TOML_DESCRIPTOR,
|
||||
];
|
||||
|
||||
/**
|
||||
* GSD's OWN location vars — a second family, not runtime configHomes.
|
||||
*
|
||||
* #2665 round 3: the registry describes where each *third-party runtime* keeps its
|
||||
* config. It says nothing about where GSD keeps its own user-owned state, and that
|
||||
* is a separate env-first surface:
|
||||
*
|
||||
* GSD_HOME — `process.env['GSD_HOME'] || os.homedir()`, the root of
|
||||
* `$GSD_HOME/.gsd/` (consent.json, defaults.json, capability
|
||||
* overlays). Read env-first by capability-loader, capability-consent,
|
||||
* capability-state, capability-writer, config-loader, install-profiles
|
||||
* and bin/install.js. A WRITE surface.
|
||||
* GSD_AGENTS_DIR — `if (process.env['GSD_AGENTS_DIR']) return it`, priority 1 in
|
||||
* getAgentsDir. Misdirects a READ rather than a write, hence lower
|
||||
* severity — but it is env-first and unconditional, so it belongs
|
||||
* to the same class.
|
||||
*
|
||||
* Deliberately NOT folded into the descriptor array above: these do not resolve
|
||||
* through resolveConfigHomeFromDescriptor and have no `kind`/`name` shape.
|
||||
*/
|
||||
export const GSD_LOCATION_ENV_KEYS: readonly string[] = ['GSD_HOME', 'GSD_AGENTS_DIR'];
|
||||
|
||||
/**
|
||||
* Resolve the directory holding the Kimi product's OWN native config.toml —
|
||||
* the file that product itself reads for providers/models/hooks/etc, and the
|
||||
@@ -517,8 +587,8 @@ export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): st
|
||||
// value originates from argv, and an index would resolve inherited keys
|
||||
// (`constructor`, `__proto__`) to something that is not a descriptor.
|
||||
const descriptor: DotHomeDescriptor = opts.runtime === 'kimi-code'
|
||||
? { kind: 'dot-home', name: '.kimi-code', env: ['KIMI_CODE_HOME'] }
|
||||
: { kind: 'dot-home', name: '.kimi', env: ['KIMI_SHARE_DIR'] };
|
||||
? KIMI_CODE_HOOKS_TOML_DESCRIPTOR
|
||||
: KIMI_HOOKS_TOML_DESCRIPTOR;
|
||||
return resolveConfigHomeFromDescriptor(descriptor, { env, home });
|
||||
}
|
||||
|
||||
|
||||
@@ -16,26 +16,9 @@ const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
GSD_WORKSTREAM: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools and capture BOTH stdout and stderr on success.
|
||||
|
||||
@@ -20,7 +20,7 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
|
||||
// In-process seam for the fail-closed read-injection tests at the bottom of this
|
||||
// file (#2365 review): readPhaseScope is the pure phase-scope reader behind the
|
||||
@@ -29,23 +29,6 @@ const { readPhaseScope } = require('../gsd-core/bin/lib/check-command-router.cjs
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
|
||||
@@ -21,27 +21,10 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
|
||||
@@ -2108,11 +2108,27 @@ describe('regressions: --runtime override bypasses persisted runtime (#2003)', (
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-'));
|
||||
try {
|
||||
writePersistedRuntime(tmpDir, 'codex');
|
||||
const result = runGsdTools('capability state --runtime claude --raw', tmpDir);
|
||||
// #2665: sandbox HOME and let the config-location vars stay BLANK (helpers'
|
||||
// TEST_ENV_BASE zeroes them), so the child resolves through the home-derived
|
||||
// FALLBACK branch of getGlobalConfigDir. The original test compared the
|
||||
// child's answer against the PARENT process's getGlobalConfigDir() -- two
|
||||
// different environments, agreeing only because the child inherited the
|
||||
// developer's ambient CLAUDE_CONFIG_DIR.
|
||||
//
|
||||
// Injecting CLAUDE_CONFIG_DIR here instead would fix that leak but move the
|
||||
// test onto the env-FIRST branch, silently dropping the only coverage this
|
||||
// #2003 regression has of the fallback branch. Sandboxing HOME keeps the
|
||||
// expectation test-controlled AND keeps the branch under test unchanged.
|
||||
const result = runGsdTools('capability state --runtime claude --raw', tmpDir, {
|
||||
HOME: tmpDir,
|
||||
USERPROFILE: tmpDir,
|
||||
});
|
||||
assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`);
|
||||
const parsed = JSON.parse(result.output);
|
||||
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
assert.strictEqual(parsed.runtimeConfigDir, runtimeHomes.getGlobalConfigDir('claude'),
|
||||
// Home-derived Claude dir. That this is NOT <tmpDir>/.codex is the #2003
|
||||
// assertion; a separate notStrictEqual against the codex dir would be dead,
|
||||
// since it cannot fail whenever this strictEqual passes.
|
||||
assert.strictEqual(parsed.runtimeConfigDir, path.join(tmpDir, '.claude'),
|
||||
'`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
|
||||
@@ -30,7 +30,7 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
@@ -136,23 +136,6 @@ function commitFile(git, tmpDir, filename, commitMessage) {
|
||||
|
||||
// ─── Helpers for subprocess invocation ────────────────────────────────────────
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
|
||||
@@ -740,27 +740,10 @@ const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools and return { stdout, stderr, status }.
|
||||
* Always captures stderr even when exit code is 0.
|
||||
|
||||
@@ -15,27 +15,10 @@ const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runMigrateConfig(cwd, extraArgs = [], env = {}) {
|
||||
const result = runNode([TOOLS_PATH, 'migrate-config', ...extraArgs], {
|
||||
cwd,
|
||||
|
||||
@@ -1362,7 +1362,10 @@ const EXPECTED_AGENTS = listAgentFiles().length;
|
||||
const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
function runCopilotInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
@@ -1374,7 +1377,10 @@ function runCopilotInstall(cwd) {
|
||||
}
|
||||
|
||||
function runCopilotUninstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--uninstall', '--no-sdk'], {
|
||||
cwd,
|
||||
@@ -1673,7 +1679,10 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
// ─── E2E: Copilot global scope (#786) ──────────────────────────────────────────
|
||||
|
||||
function runCopilotInstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode(
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'],
|
||||
@@ -1684,7 +1693,10 @@ function runCopilotInstallGlobal(cwd, configDir) {
|
||||
}
|
||||
|
||||
function runCopilotUninstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode(
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'],
|
||||
@@ -1734,7 +1746,10 @@ describe('E2E: Copilot global install (#786)', () => {
|
||||
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
|
||||
|
||||
function runClaudeInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
@@ -1746,7 +1761,10 @@ function runClaudeInstall(cwd) {
|
||||
}
|
||||
|
||||
function runClaudeUninstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--claude', '--local', '--uninstall', '--no-sdk'], {
|
||||
cwd,
|
||||
|
||||
@@ -1,8 +1,45 @@
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { withIsolatedProcessState } = require('./helpers.cjs');
|
||||
const {
|
||||
withIsolatedProcessState,
|
||||
TEST_ENV_BASE,
|
||||
CONFIG_LOCATION_ENV_KEYS,
|
||||
scrubConfigLocationEnv,
|
||||
} = require('./helpers.cjs');
|
||||
|
||||
describe('#2665: the built-lib require is deferred', () => {
|
||||
// The scrub set derives from gsd-core/bin/lib, which is BUILT. Requiring it at
|
||||
// module scope made an unbuilt tree throw inside `require('./helpers.cjs')` —
|
||||
// before any test() registered — so one missing `npm run build:lib` became a
|
||||
// whole-suite crash in the file ~370 test files import. A cold child is the only
|
||||
// honest probe: this process has already loaded everything.
|
||||
const probe = (touch) => {
|
||||
const src = [
|
||||
"const path = require('node:path');",
|
||||
`require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});`,
|
||||
touch,
|
||||
"const needle = path.join('gsd-core', 'bin', 'lib', 'capability-registry.cjs');",
|
||||
'process.stdout.write(String(Object.keys(require.cache).some((m) => m.endsWith(needle))));',
|
||||
].join('\n');
|
||||
// Bounded per local/no-unbounded-spawn (#3143): a cold require is sub-second,
|
||||
// so 30s is ~30x headroom and still fails loudly instead of hanging a lane.
|
||||
const r = spawnSync(process.execPath, ['-e', src], { encoding: 'utf8', timeout: 30_000 });
|
||||
assert.strictEqual(r.status, 0, `probe failed: ${r.stderr}`);
|
||||
return r.stdout === 'true';
|
||||
};
|
||||
|
||||
test('requiring helpers.cjs alone does NOT load the built runtime lib', () => {
|
||||
assert.strictEqual(probe(''), false, 'the built lib was loaded at import time');
|
||||
});
|
||||
|
||||
test('reading TEST_ENV_BASE is what loads it', () => {
|
||||
const touch = `require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))}).TEST_ENV_BASE;`;
|
||||
assert.strictEqual(probe(touch), true, 'reading the scrub set must resolve the built lib');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withIsolatedProcessState', () => {
|
||||
test('restores env, cwd, and exitCode after callback', () => {
|
||||
@@ -39,3 +76,337 @@ describe('withIsolatedProcessState', () => {
|
||||
assert.strictEqual(process.env.PATH, originalPath);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2665: the config-location scrub is DERIVED, and stays that way ──────────
|
||||
//
|
||||
// The recurrence guard. #2665 documents two prior authors independently
|
||||
// diagnosing this class and each fixing only the instance in front of them;
|
||||
// this is the third pass. A hand-maintained scrub list cannot be defended by
|
||||
// review alone, so the invariant is asserted instead of trusted.
|
||||
//
|
||||
// SCOPE BOUNDARY — read this before trusting a green run here.
|
||||
//
|
||||
// Every test below asserts that TEST_ENV_BASE covers some ENUMERATION (the
|
||||
// capability registry, the non-registry descriptor set, GSD's own location
|
||||
// keys). Each therefore proves only that the scrub set is not narrower than the
|
||||
// enumeration it derives from. NONE of them can prove the enumeration is itself
|
||||
// complete: a config-location var that no enumeration carries is invisible to
|
||||
// all of them, and they stay green.
|
||||
//
|
||||
// That is not hypothetical — it is how round 2 found GSD_HOME and
|
||||
// KIMI_SHARE_DIR while this block was fully green. GSD_HOME belonged to no
|
||||
// enumeration at all (it is GSD's own store root, not a runtime configHome);
|
||||
// KIMI_SHARE_DIR sat inside a function body where nothing could enumerate it.
|
||||
// Round 3's fix was to make both enumerable rather than to add two assertions,
|
||||
// precisely because an assertion added per reviewer-named var is the
|
||||
// hand-maintained list wearing a test's clothes.
|
||||
//
|
||||
// The completeness question — "is every env-first first-party location var in
|
||||
// SOME enumeration?" — is answered by a source census re-derived each round
|
||||
// (see the PR discussion), and by scripts/live-config-guard.cjs at runtime,
|
||||
// which observes actual writes rather than reasoning about names. Neither lives
|
||||
// here, and this block should not be read as standing in for them.
|
||||
describe('#2665: TEST_ENV_BASE config-location coverage', () => {
|
||||
test('every runtime configHome env var in the registry is scrubbed', () => {
|
||||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
const declared = [
|
||||
...new Set(
|
||||
Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
|
||||
),
|
||||
].sort();
|
||||
|
||||
// Guards the guard: an empty/renamed registry shape would make the
|
||||
// assertion below vacuously true and silently retire this test.
|
||||
assert.ok(
|
||||
declared.length >= 15,
|
||||
`expected the registry to declare many configHome env vars, got ${declared.length} — ` +
|
||||
'if the registry shape changed, this derivation needs updating, not deleting',
|
||||
);
|
||||
|
||||
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`config-location env vars reachable by the resolver but not scrubbed: ${missing.join(', ')}. ` +
|
||||
'TEST_ENV_BASE derives this set from the capability registry — a gap here means the ' +
|
||||
'derivation broke, not that the list needs a manual entry.',
|
||||
);
|
||||
});
|
||||
|
||||
test('every scrubbed config-location var is blanked, not merely present', () => {
|
||||
for (const key of CONFIG_LOCATION_ENV_KEYS) {
|
||||
assert.strictEqual(
|
||||
TEST_ENV_BASE[key],
|
||||
'',
|
||||
`${key} must be blanked ('') so the child sees a falsy value on the env-first branch`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the non-registry config-location vars are covered too', () => {
|
||||
// These have no capability descriptor, so the registry derivation alone
|
||||
// cannot reach them: GROK_AGENTS_HOME is a hardcoded branch of
|
||||
// getGlobalConfigDir, GSD_RUNTIME selects which runtime home resolves, and
|
||||
// GSD_PROJECT / GSD_WORKSTREAM move a child's .planning root
|
||||
// (src/planning-workspace.cts). Named explicitly so deleting one from the
|
||||
// helper is a test failure rather than a silent narrowing.
|
||||
for (const key of ['GROK_AGENTS_HOME', 'GSD_RUNTIME', 'GSD_PROJECT', 'GSD_WORKSTREAM']) {
|
||||
assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('descriptor-shaped config homes OUTSIDE the registry are derived, not listed', () => {
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
// Round 3. kimi owns TWO config homes: KIMI_CONFIG_DIR (registry-visible) and
|
||||
// KIMI_SHARE_DIR (a hardcoded descriptor inside resolveKimiHooksTomlDir, which
|
||||
// decides where its native config.toml — carrying GSD's [[hooks]] block — is
|
||||
// written). The registry-only derivation reached the first and not the second,
|
||||
// so it looked structurally complete while missing a live write surface.
|
||||
const declared = [
|
||||
...new Set(NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => d?.env ?? [])),
|
||||
];
|
||||
assert.ok(
|
||||
declared.length >= 1,
|
||||
'expected at least one non-registry descriptor — an empty array makes this vacuous',
|
||||
);
|
||||
assert.ok(
|
||||
declared.includes('KIMI_SHARE_DIR'),
|
||||
`KIMI_SHARE_DIR must come from the descriptor set, got ${declared.join(', ')}`,
|
||||
);
|
||||
|
||||
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`descriptor-declared config-location vars not scrubbed: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('skillsHome env vars are walked on BOTH descriptor rungs', () => {
|
||||
// Round 4. A configHome descriptor can nest a second, independently-resolved
|
||||
// descriptor (skillsHome → resolveSkillsBaseFromDescriptor), which carries
|
||||
// its own env array. Walking configHome.env alone is the identical
|
||||
// walk-one-field gap-shape rounds 2-3 closed for the registry and the
|
||||
// non-registry set. Inert today — only kilo declares skillsHome, with
|
||||
// env: [] — so this asserts the DERIVATION reaches the field, not that any
|
||||
// var currently flows from it: every skillsHome-declared var (registry and
|
||||
// non-registry alike) must land in TEST_ENV_BASE the moment one exists.
|
||||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
const declared = [
|
||||
...new Set([
|
||||
...Object.values(runtimes).flatMap(
|
||||
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
|
||||
),
|
||||
...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap(
|
||||
(d) => d?.skillsHome?.env ?? [],
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
// Anti-vacuity: at least one runtime must actually DECLARE skillsHome, or a
|
||||
// registry reshape could rename the field and retire this test silently.
|
||||
const declaringRuntimes = Object.values(runtimes).filter(
|
||||
(r) => r?.runtime?.configHome?.skillsHome !== undefined,
|
||||
);
|
||||
assert.ok(
|
||||
declaringRuntimes.length >= 1,
|
||||
'expected at least one registry runtime to declare configHome.skillsHome — ' +
|
||||
'if the field moved, this derivation needs updating, not deleting',
|
||||
);
|
||||
|
||||
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`skillsHome-declared config-location vars not scrubbed: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("GSD's OWN location vars are scrubbed (a second family, not a registry gap)", () => {
|
||||
const { GSD_LOCATION_ENV_KEYS } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
// GSD_HOME decides where GSD keeps user-owned state ($GSD_HOME/.gsd/ —
|
||||
// consent.json, defaults.json, capability overlays) and is read env-FIRST,
|
||||
// ahead of os.homedir(), across capability-loader / capability-consent /
|
||||
// capability-state / capability-writer / config-loader / install-profiles /
|
||||
// bin/install.js. GSD_AGENTS_DIR is priority 1 in getAgentsDir. Neither is a
|
||||
// runtime configHome, so no amount of registry derivation reaches them.
|
||||
assert.ok(GSD_LOCATION_ENV_KEYS.includes('GSD_HOME'));
|
||||
for (const key of GSD_LOCATION_ENV_KEYS) {
|
||||
assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('write-escape PERMISSIONS are scrubbed (a fifth family — not a location var)', () => {
|
||||
// #2665 round 5. GSD_ALLOW_SYMLINKED_DEST names no path, so every rung of the
|
||||
// derivation above is structurally incapable of reaching it — it is not a
|
||||
// registry configHome, not descriptor-shaped, not one of GSD's own location
|
||||
// vars. It is still a #2665 leak vector: install-engine.cts reads it env-first
|
||||
// and threads it into the symlink-escape guard that stops a write leaving the
|
||||
// install root, so an ambient `=1` disarms that guard for the whole suite.
|
||||
//
|
||||
// Named literally rather than derived from the family constant on purpose: a
|
||||
// test that reads WRITE_ESCAPE_PERMISSION_ENV_KEYS and asserts over it shrinks
|
||||
// its own expectation when the family is emptied — the enumeration-relative
|
||||
// failure this suite already documents, and the one that let the kimi-code
|
||||
// descriptor go unwatched. Naming it is what makes removal fail loudly.
|
||||
assert.strictEqual(
|
||||
TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST,
|
||||
'',
|
||||
'GSD_ALLOW_SYMLINKED_DEST must be blanked: ambient =1 disarms the symlink-escape guard',
|
||||
);
|
||||
// Blanking must be fail-SAFE — '' is neither '1' nor 'true', so the guard gets
|
||||
// stricter, never looser. This is what licenses scrubbing it wholesale.
|
||||
assert.ok(!['1', 'true'].includes(TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST));
|
||||
});
|
||||
|
||||
test('scrubConfigLocationEnv clears and restores the parent process env', () => {
|
||||
// The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches
|
||||
// children, so a test calling install() in-process needs the PARENT's env
|
||||
// cleared. Round-trip both states — set and unset — because restoring an
|
||||
// originally-unset var as '' rather than deleting it is itself a leak.
|
||||
withIsolatedProcessState(() => {
|
||||
process.env.CLAUDE_CONFIG_DIR = '/tmp/ambient-claude';
|
||||
delete process.env.CODEX_HOME;
|
||||
|
||||
const restore = scrubConfigLocationEnv();
|
||||
assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, undefined,
|
||||
'a set config-location var must be deleted, not blanked, on the parent');
|
||||
assert.strictEqual(process.env.CODEX_HOME, undefined);
|
||||
|
||||
restore();
|
||||
assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, '/tmp/ambient-claude',
|
||||
'restore must put back the original value');
|
||||
assert.ok(!('CODEX_HOME' in process.env),
|
||||
'restore must leave an originally-unset var unset, not set it to empty string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2665 round 4: the skillsHome walk is reversion-sensitive', () => {
|
||||
// The coverage tests above are enumeration-relative, and skillsHome.env is
|
||||
// empty everywhere today — so reverting the skillsHome rungs from the
|
||||
// derivation leaves every one of them green (measured by this round's
|
||||
// pre-push adversarial review). This test closes that: it cold-requires
|
||||
// helpers.cjs in a child process after injecting sentinel skillsHome env
|
||||
// vars into BOTH enumerations (registry and non-registry), so the walk
|
||||
// itself is what is under test, not today's empty declarations.
|
||||
test('sentinel skillsHome vars flow into TEST_ENV_BASE on both rungs', () => {
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const regPath = require.resolve('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const rhPath = require.resolve('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
const helpersPath = require.resolve('./helpers.cjs');
|
||||
|
||||
const script = `
|
||||
'use strict';
|
||||
const reg = require(${JSON.stringify(regPath)});
|
||||
const rh = require(${JSON.stringify(rhPath)});
|
||||
// Rung 1 (registry): give one runtime a skillsHome env var. kilo already
|
||||
// declares skillsHome (env: []); push a sentinel into whichever runtime
|
||||
// declares it, or graft one onto the first runtime if none does.
|
||||
const declaring = Object.values(reg.runtimes).find(
|
||||
(r) => r?.runtime?.configHome?.skillsHome,
|
||||
) ?? Object.values(reg.runtimes)[0];
|
||||
if (!declaring.runtime.configHome.skillsHome) {
|
||||
declaring.runtime.configHome.skillsHome = { kind: 'dot-home', name: '.x', env: [] };
|
||||
}
|
||||
declaring.runtime.configHome.skillsHome.env = ['GSD_TEST_SENTINEL_REGISTRY_SKILLS'];
|
||||
// Rung 2 (non-registry): graft a skillsHome onto the first descriptor.
|
||||
rh.NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[0].skillsHome = {
|
||||
kind: 'dot-home', name: '.x', env: ['GSD_TEST_SENTINEL_NONREG_SKILLS'],
|
||||
};
|
||||
const { TEST_ENV_BASE } = require(${JSON.stringify(helpersPath)});
|
||||
const missing = [
|
||||
'GSD_TEST_SENTINEL_REGISTRY_SKILLS',
|
||||
'GSD_TEST_SENTINEL_NONREG_SKILLS',
|
||||
].filter((k) => TEST_ENV_BASE[k] !== '');
|
||||
if (missing.length > 0) {
|
||||
console.error('skillsHome walk missed: ' + missing.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
`;
|
||||
|
||||
const out = execFileSync(process.execPath, ['-e', script], {
|
||||
cwd: __dirname,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 30_000,
|
||||
});
|
||||
void out; // exit 0 is the assertion; execFileSync throws on nonzero
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3156: a raw installer spawn cannot write into the ambient HOME', () => {
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { installSpawnEnv, cleanup } = require('./helpers.cjs');
|
||||
const { installerEnv } = require('./helpers/install-shared.cjs');
|
||||
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
// Contract half — cheap, and it names the precedence the callers depend on.
|
||||
test('the sandbox HOME replaces the ambient one, but an explicit override still wins', () => {
|
||||
for (const build of [installSpawnEnv, installerEnv]) {
|
||||
const env = build();
|
||||
assert.notStrictEqual(env.HOME, process.env.HOME,
|
||||
'a raw installer spawn must not inherit the ambient HOME');
|
||||
assert.strictEqual(env.USERPROFILE, env.HOME,
|
||||
'USERPROFILE must track HOME — os.homedir() reads it on Windows');
|
||||
assert.strictEqual(build({ HOME: '/explicit', USERPROFILE: '/explicit' }).HOME, '/explicit',
|
||||
'an explicit HOME override must still win (overrides spread last)');
|
||||
}
|
||||
});
|
||||
|
||||
// Behavioural half — the one that actually fails pre-fix.
|
||||
//
|
||||
// bin/install.js writeNonClaudeDefaults() (#2834) writes
|
||||
// <os.homedir()>/.gsd/defaults.json for every NON-Claude runtime, reading no
|
||||
// GSD variable at all. So this is deliberately driven through the real
|
||||
// installer against a real ambient HOME: no assertion about the scrub set can
|
||||
// stand in for it, because no scrub set can reach os.homedir().
|
||||
//
|
||||
// Negative control: revert installerEnv() to `{ ...process.env, ...overrides }`
|
||||
// and the canary gains .gsd/defaults.json.
|
||||
test('installing a non-Claude runtime leaves the ambient HOME untouched', () => {
|
||||
const canaryHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-canary-home-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-project-'));
|
||||
const realHome = process.env.HOME;
|
||||
const realUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
// Make the AMBIENT home the canary — the vector is the parent process's
|
||||
// own HOME, exactly as on a developer machine or a CI runner.
|
||||
process.env.HOME = canaryHome;
|
||||
process.env.USERPROFILE = canaryHome;
|
||||
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--cursor', '--local', '--no-sdk'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: installerEnv(),
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(canaryHome, '.gsd')),
|
||||
`the installer wrote GSD's user store into the ambient HOME: ${
|
||||
fs.existsSync(path.join(canaryHome, '.gsd'))
|
||||
? fs.readdirSync(path.join(canaryHome, '.gsd')).join(', ')
|
||||
: ''
|
||||
}`);
|
||||
} finally {
|
||||
if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome;
|
||||
if (realUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = realUserProfile;
|
||||
cleanup(canaryHome);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,29 +10,177 @@ const { createFixture } = require('./fixtures/index.cjs');
|
||||
const processSeam = require('./helpers/process-seam.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
// #2665: blank config-LOCATION vars so npm test never writes into the developer's
|
||||
// live config directory. The resolver consults these before HOME, so an ambient
|
||||
// value wins unconditionally over a sandboxed HOME. Per-site overrides still win
|
||||
// because env is spread last in the child-env merge.
|
||||
CLAUDE_CONFIG_DIR: '',
|
||||
GSD_RUNTIME: '',
|
||||
CODEX_HOME: '',
|
||||
};
|
||||
|
||||
// Session-IDENTITY vars. Blanked so a child cannot inherit the developer's
|
||||
// terminal/agent session and key shared state off it.
|
||||
const SESSION_IDENTITY_ENV_KEYS = [
|
||||
'GSD_SESSION_KEY',
|
||||
'CODEX_THREAD_ID',
|
||||
'CLAUDE_SESSION_ID',
|
||||
'CLAUDE_CODE_SSE_PORT',
|
||||
'OPENCODE_SESSION_ID',
|
||||
'GEMINI_SESSION_ID',
|
||||
'CURSOR_SESSION_ID',
|
||||
'WINDSURF_SESSION_ID',
|
||||
'TERM_SESSION_ID',
|
||||
'WT_SESSION',
|
||||
'TMUX_PANE',
|
||||
'ZELLIJ_SESSION_NAME',
|
||||
'TTY',
|
||||
'SSH_TTY',
|
||||
];
|
||||
|
||||
// LAZY, and memoized. These live in the BUILT runtime lib, so requiring them at
|
||||
// module scope made an unbuilt tree throw during `require('./helpers.cjs')` —
|
||||
// before a single test() had registered — which turns one missing
|
||||
// `npm run build:lib` into a whole-suite crash with no actionable message, in the
|
||||
// file ~370 test files import. `npm test` builds via its pretest hook, so the
|
||||
// shape that hits this is a direct `node --test` invocation.
|
||||
//
|
||||
// Deferring the require means only the tests that actually need the derived scrub
|
||||
// set pay for the build, and they fail with a message that names the remedy.
|
||||
let _builtLib = null;
|
||||
function builtLib() {
|
||||
if (_builtLib) return _builtLib;
|
||||
try {
|
||||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
GSD_LOCATION_ENV_KEYS,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
_builtLib = { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS };
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
'tests/helpers.cjs derives the config-location scrub set from the built runtime '
|
||||
+ 'lib (gsd-core/bin/lib), which is not present. Run `npm run build:lib` first — '
|
||||
+ '`npm test` does this for you via its pretest script.',
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
return _builtLib;
|
||||
}
|
||||
|
||||
// Config-location vars that are neither in the registry nor descriptor-shaped,
|
||||
// each with its reader:
|
||||
// GROK_AGENTS_HOME — hardcoded `grok` branch in getGlobalConfigDir (src/runtime-homes.cts)
|
||||
// GSD_RUNTIME — selects WHICH runtime home resolves (src/model-resolver.cts)
|
||||
// GSD_PROJECT — planningDir() project segment (src/planning-workspace.cts)
|
||||
// GSD_WORKSTREAM — planningDir() workstream segment (src/planning-workspace.cts)
|
||||
//
|
||||
// #2665 round 3: this list shrinks as sources become enumerable, and that direction
|
||||
// is the point. KIMI_SHARE_DIR was NOT added here — it now derives from
|
||||
// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, because hand-adding each var a reviewer
|
||||
// names is precisely what reopened this bug three times.
|
||||
const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
|
||||
'GROK_AGENTS_HOME',
|
||||
'GSD_RUNTIME',
|
||||
'GSD_PROJECT',
|
||||
'GSD_WORKSTREAM',
|
||||
];
|
||||
|
||||
// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT
|
||||
// folded into any of the four rungs below.
|
||||
//
|
||||
// #2665 round 5: GSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so it is
|
||||
// not a config-location var by any honest reading. But install-engine.cts reads it
|
||||
// env-first (`:214`) and threads it as `allowOptInFollow` into the symlink-escape
|
||||
// guard at four call sites, each gating a write (`:361/:367`, `:416/:424`,
|
||||
// `:785/:790`, `:927/:932`). That guard is what stops a write leaving the install
|
||||
// root, so an ambient `=1` disarms it for the whole suite — the #2665 hazard
|
||||
// exactly, arriving through a permission rather than a path.
|
||||
//
|
||||
// Blanking is fail-safe in the only direction that matters: '' is neither '1' nor
|
||||
// 'true', so a blanked value makes the guard STRICTER, never looser. That asymmetry
|
||||
// is why this can be scrubbed wholesale without reasoning about each call site.
|
||||
const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['GSD_ALLOW_SYMLINKED_DEST'];
|
||||
|
||||
// Config-LOCATION vars — distinct in kind from the session-identity vars above:
|
||||
// these decide WHERE a child writes, so leaving one ambient lets a test that
|
||||
// sandboxes HOME still escape into the developer's real config dir.
|
||||
//
|
||||
// #2665: this list is DERIVED, not hand-maintained. A hand-written list is
|
||||
// exactly what reopened this bug twice — it can only ever be as complete as the
|
||||
// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a
|
||||
// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing
|
||||
// it from the same registry the resolver reads makes the scrub list structurally
|
||||
// incapable of being narrower than the surface it guards: adding a capability
|
||||
// that declares a new configHome env var extends this set in the same commit.
|
||||
let _configLocationEnvKeys = null;
|
||||
function configLocationEnvKeys() {
|
||||
if (_configLocationEnvKeys) return _configLocationEnvKeys;
|
||||
const { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS } = builtLib();
|
||||
_configLocationEnvKeys = [
|
||||
...new Set([
|
||||
// 1. Every runtime descriptor the capability registry carries — including
|
||||
// the nested skillsHome descriptor, which resolves independently of
|
||||
// configHome (resolveSkillsBaseFromDescriptor) and can carry its own
|
||||
// env array. Inert today (only kilo declares skillsHome, with env: []),
|
||||
// but walking configHome.env alone is the identical gap-shape this PR
|
||||
// closed twice already, one field over. (#2665 round 4)
|
||||
...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
|
||||
...Object.values(runtimes).flatMap(
|
||||
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
|
||||
),
|
||||
// 2. Descriptor-shaped config homes resolved OUTSIDE the registry (kimi's
|
||||
// native config.toml home via KIMI_SHARE_DIR). Derived, not hand-listed.
|
||||
// Same skillsHome walk as rung 1 — a descriptor is a descriptor.
|
||||
...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => [
|
||||
...(d?.env ?? []),
|
||||
...(d?.skillsHome?.env ?? []),
|
||||
]),
|
||||
// 3. GSD's OWN location vars — a different family: they decide where GSD keeps
|
||||
// user-owned state ($GSD_HOME/.gsd/), not where a runtime keeps its config.
|
||||
...GSD_LOCATION_ENV_KEYS,
|
||||
// 4. The residue that is neither registry-carried nor descriptor-shaped.
|
||||
...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS,
|
||||
// 5. Write-escape permissions — NOT locations. Same mechanism because the
|
||||
// hazard is identical (ambient env lets a suite write outside the sandbox);
|
||||
// named separately above so the list does not misdescribe what they are.
|
||||
...WRITE_ESCAPE_PERMISSION_ENV_KEYS,
|
||||
]),
|
||||
].sort();
|
||||
return _configLocationEnvKeys;
|
||||
}
|
||||
|
||||
let _testEnvBase = null;
|
||||
function testEnvBase() {
|
||||
if (_testEnvBase) return _testEnvBase;
|
||||
_testEnvBase = Object.fromEntries(
|
||||
[...SESSION_IDENTITY_ENV_KEYS, ...configLocationEnvKeys()].map((k) => [k, '']),
|
||||
);
|
||||
return _testEnvBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save + clear every config-LOCATION env var on THIS process; returns a restorer.
|
||||
*
|
||||
* #2665: TEST_ENV_BASE only reaches CHILD processes. A test that calls the real
|
||||
* installer IN-PROCESS — `install(true, 'claude')` — resolves through the same
|
||||
* env-first `getGlobalConfigDir`, so an ambient CLAUDE_CONFIG_DIR beats a
|
||||
* sandboxed `process.env.HOME` and a complete global install (agents/, commands/,
|
||||
* skills/, gsd-core/, manifest, settings) lands in the developer's live config
|
||||
* dir. No child-env scrub can reach that call; only clearing the parent's env can.
|
||||
*
|
||||
* Pair with a HOME sandbox, not instead of one: HOME covers the home-derived
|
||||
* fallback, this covers the env-first branch that overrides it.
|
||||
*
|
||||
* @returns {() => void} restorer — call in afterEach to put the env back exactly
|
||||
* as it was (deleting keys that were previously unset, rather than setting '').
|
||||
*/
|
||||
function scrubConfigLocationEnv() {
|
||||
const saved = {};
|
||||
const keys = configLocationEnvKeys();
|
||||
for (const key of keys) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
return function restoreConfigLocationEnv() {
|
||||
for (const key of keys) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gsd-tools command.
|
||||
@@ -46,7 +194,7 @@ const TEST_ENV_BASE = {
|
||||
*/
|
||||
function runGsdTools(args, cwd = process.cwd(), env = {}) {
|
||||
// Resolve argv once so both the first attempt and the retry use the same vector.
|
||||
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env };
|
||||
const childEnv = { ...process.env, ...testEnvBase(), ...env };
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
@@ -763,4 +911,57 @@ function clearSessionEnv() {
|
||||
for (const k of SESSION_ENV_KEYS) delete process.env[k];
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH };
|
||||
/**
|
||||
* #3156: env for a RAW installer spawn — one that bypasses runGsdTools and so
|
||||
* never receives TEST_ENV_BASE on its own.
|
||||
*
|
||||
* Blanking config-LOCATION vars is necessary but NOT sufficient here.
|
||||
* bin/install.js writes GSD's own user-owned store through os.homedir()
|
||||
* DIRECTLY (writeNonClaudeDefaults -> <home>/.gsd/defaults.json, #2834), and
|
||||
* os.homedir() consults no GSD variable at all — so nothing in
|
||||
* CONFIG_LOCATION_ENV_KEYS can reach it, and blanking GSD_HOME does not reach
|
||||
* it either, because a blank GSD_HOME falls back to exactly that homedir().
|
||||
* Only a sandboxed HOME/USERPROFILE contains it.
|
||||
*
|
||||
* HOME stays deliberately OUT of TEST_ENV_BASE — blanking it would break far
|
||||
* more than it fixed — so it is sandboxed per spawn instead, which is the
|
||||
* discipline the suite already applies by hand elsewhere. USERPROFILE is set
|
||||
* with it because os.homedir() reads that one on Windows.
|
||||
*
|
||||
* The sandbox home is per-process and removed on exit, so a caller gets
|
||||
* containment without having to own a lifecycle.
|
||||
*
|
||||
* SCOPE, stated because it is a real residual rather than an oversight: this is
|
||||
* one home per test-FILE process, not one per spawn. Two installer spawns in the
|
||||
* same file therefore share `.gsd` state, so a prior non-Claude install can be
|
||||
* observed by a later spawn. That is strictly better than the status quo it
|
||||
* replaces -- which shared the developer's REAL home, and all of its state --
|
||||
* and it closes the leak this helper exists for; it does not claim isolation
|
||||
* BETWEEN spawns. A test needing that passes its own { HOME, USERPROFILE }.
|
||||
*/
|
||||
let installSpawnHomeDir = null;
|
||||
function installSpawnHome() {
|
||||
if (installSpawnHomeDir === null) {
|
||||
installSpawnHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-install-home-'));
|
||||
process.on('exit', () => {
|
||||
try { fs.rmSync(installSpawnHomeDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
});
|
||||
}
|
||||
return installSpawnHomeDir;
|
||||
}
|
||||
|
||||
function installSpawnEnv(overrides = {}) {
|
||||
const home = installSpawnHome();
|
||||
return { ...process.env, ...testEnvBase(), HOME: home, USERPROFILE: home, ...overrides };
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome };
|
||||
|
||||
// Lazy, for the reason builtLib() is lazy: reading either of these is what
|
||||
// forces the built-lib require, so a test file that needs neither can still
|
||||
// import this helper on an unbuilt tree. Enumerable, so destructuring and
|
||||
// Object.keys() behave exactly as they did when these were plain properties.
|
||||
Object.defineProperties(module.exports, {
|
||||
TEST_ENV_BASE: { enumerable: true, get: testEnvBase },
|
||||
CONFIG_LOCATION_ENV_KEYS: { enumerable: true, get: configLocationEnvKeys },
|
||||
});
|
||||
|
||||
@@ -520,7 +520,15 @@ function simulateHookCopy(hooksSrc, hooksDest) {
|
||||
/** Build a clean env for spawned installer processes.
|
||||
* Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */
|
||||
function installerEnv(overrides = {}) {
|
||||
const env = { ...process.env, ...overrides };
|
||||
// #3156: delegate to the ONE canonical raw-installer-spawn env rather than
|
||||
// carrying a second shape of it. The installer writes GSD's own user store to
|
||||
// <home>/.gsd/defaults.json through os.homedir() DIRECTLY
|
||||
// (bin/install.js writeNonClaudeDefaults, #2834), which reads no GSD variable,
|
||||
// so no config-location scrub can reach it — only a sandboxed HOME can. Every
|
||||
// caller that already passes an explicit { HOME, USERPROFILE } still wins:
|
||||
// overrides spread last.
|
||||
const { installSpawnEnv } = require('../helpers.cjs');
|
||||
const env = installSpawnEnv(overrides);
|
||||
delete env.GSD_TEST_MODE;
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -4646,7 +4646,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const PROFILE_OUTPUT = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'profile-output.cjs');
|
||||
@@ -4672,7 +4672,12 @@ describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-prefe
|
||||
m.cmdGenerateDevPreferences(${JSON.stringify(tmpHome)}, { analysis: ${JSON.stringify(analysisPath)} }, false);
|
||||
`);
|
||||
const result = cp.spawnSync(process.execPath, [driver], {
|
||||
env: Object.assign({}, process.env, { HOME: tmpHome, USERPROFILE: tmpHome }),
|
||||
// #2665: TEST_ENV_BASE must be merged in explicitly here. This is a RAW
|
||||
// spawn, not runGsdTools, so nothing scrubs the config-location vars for
|
||||
// it -- and the writer under test resolves them env-FIRST. Sandboxing
|
||||
// HOME alone let an ambient CLAUDE_CONFIG_DIR win, and the SKILL.md
|
||||
// landed in the developer's live config dir instead of tmpHome.
|
||||
env: Object.assign({}, process.env, TEST_ENV_BASE, { HOME: tmpHome, USERPROFILE: tmpHome }),
|
||||
encoding: 'utf-8',
|
||||
// Bound the subprocess so a regression that hangs the writer
|
||||
// (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback).
|
||||
|
||||
@@ -3827,7 +3827,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = () => createTempDir('gsd-3571-');
|
||||
|
||||
function silenceConsole(fn) {
|
||||
@@ -3853,6 +3853,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir();
|
||||
@@ -3861,6 +3862,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
savedUserProfile = process.env.USERPROFILE;
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -3872,6 +3879,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -3984,7 +3992,7 @@ const { install } = require('../bin/install.js');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = createTempDir;
|
||||
|
||||
const rmTmpDir = cleanup;
|
||||
@@ -4026,6 +4034,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir('gsd-3288-');
|
||||
@@ -4040,6 +4049,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
// and target a different directory than tmpRoot (CR finding, PR #3293).
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -4051,6 +4066,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
rmTmpDir(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -5521,7 +5537,10 @@ function ensureHooksDist() {
|
||||
* GSD_TEST_MODE is cleared so the install() main block executes.
|
||||
*/
|
||||
function runInstall(cwd, args) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
// 120s, not 60s. A full install copies and converts the whole shipped
|
||||
// payload (117 workflows, 100 references, 34 agents, ~71 skills) and
|
||||
@@ -7873,7 +7892,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = () => createTempDir('gsd-3571-');
|
||||
|
||||
function silenceConsole(fn) {
|
||||
@@ -7899,6 +7918,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir();
|
||||
@@ -7907,6 +7927,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
savedUserProfile = process.env.USERPROFILE;
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -7918,6 +7944,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -8030,7 +8057,7 @@ const { install } = require('../bin/install.js');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = createTempDir;
|
||||
|
||||
const rmTmpDir = cleanup;
|
||||
@@ -8072,6 +8099,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir('gsd-3288-');
|
||||
@@ -8086,6 +8114,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
// and target a different directory than tmpRoot (CR finding, PR #3293).
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -8097,6 +8131,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
rmTmpDir(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -9567,7 +9602,10 @@ function ensureHooksDist() {
|
||||
* GSD_TEST_MODE is cleared so the install() main block executes.
|
||||
*/
|
||||
function runInstall(cwd, args) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
// 120s, not 60s. A full install copies and converts the whole shipped
|
||||
// payload (117 workflows, 100 references, 34 agents, ~71 skills) and
|
||||
@@ -9992,7 +10030,10 @@ const {
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runClaudeLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
|
||||
@@ -24,7 +24,7 @@ const ROOT = path.resolve(__dirname, '..');
|
||||
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
||||
const pkg = require(path.join(ROOT, 'package.json'));
|
||||
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
||||
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
|
||||
@@ -359,9 +359,36 @@ describe('C: plugin.json schema validation', () => {
|
||||
|
||||
// ── C2: Opportunistic CLI integration (skipped when claude not on PATH) ──────
|
||||
|
||||
// #2665: the `claude` CLI is a THIRD-PARTY binary that bootstraps its own
|
||||
// config (.claude.json plus a backups/ dir) into whatever CLAUDE_CONFIG_DIR
|
||||
// names. Two things follow, and only the first is obvious:
|
||||
//
|
||||
// 1. Inheriting the developer's ambient CLAUDE_CONFIG_DIR makes even a bare
|
||||
// `--version` probe write into their live config dir. That alone kept
|
||||
// `CLAUDE_CONFIG_DIR=<dir> npm run test:unit; find <dir> -type f` from
|
||||
// returning empty after every GSD-side leak was closed.
|
||||
// 2. BLANKING it (TEST_ENV_BASE's '' convention) is not enough here. GSD's
|
||||
// own resolvers treat '' as falsy and fall back to the home dir, but this
|
||||
// binary is not ours and gives no such guarantee -- blanking it produced a
|
||||
// stray backups/ directory in the REPO ROOT.
|
||||
//
|
||||
// So point it at a real throwaway dir rather than at nothing.
|
||||
const claudeCliHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2665-claude-cli-'));
|
||||
const claudeCliEnv = () => ({
|
||||
...process.env,
|
||||
...TEST_ENV_BASE,
|
||||
HOME: claudeCliHome,
|
||||
USERPROFILE: claudeCliHome,
|
||||
CLAUDE_CONFIG_DIR: path.join(claudeCliHome, '.claude'),
|
||||
});
|
||||
|
||||
const claudeAvailable = (() => {
|
||||
try {
|
||||
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
|
||||
const result = spawnSync('claude', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
env: claudeCliEnv(),
|
||||
});
|
||||
return result.status === 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
@@ -384,6 +411,7 @@ describe('C: plugin.json schema validation', () => {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: claudeCliEnv(),
|
||||
});
|
||||
assert.equal(
|
||||
result.status,
|
||||
|
||||
861
tests/live-config-guard.test.cjs
Normal file
861
tests/live-config-guard.test.cjs
Normal file
@@ -0,0 +1,861 @@
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
const {
|
||||
GSD_OWNED_ENTRIES,
|
||||
artifactTargets,
|
||||
MAX_DEPTH,
|
||||
resolveLiveConfigRoots,
|
||||
resolveExtraWatchTargets,
|
||||
snapshotLiveConfig,
|
||||
diffLiveConfig,
|
||||
formatViolations,
|
||||
newestMtime,
|
||||
} = require('../scripts/live-config-guard.cjs');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function tmpRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'live-config-guard-'));
|
||||
}
|
||||
|
||||
/** Create `n` flat files under a fresh dir; returns [dir, entryCount-including-dir]. */
|
||||
function treeWithEntries(n) {
|
||||
const dir = tmpRoot();
|
||||
for (let i = 0; i < n; i++) fs.writeFileSync(path.join(dir, `f${i}`), 'x');
|
||||
return [dir, n + 1]; // +1: the directory itself is lstat'd and costs budget
|
||||
}
|
||||
|
||||
describe('#2665: live-config hermeticity guard', () => {
|
||||
test('resolves real runtime config roots via the product resolver', () => {
|
||||
const roots = resolveLiveConfigRoots();
|
||||
// Guards the guard: an empty set would make every downstream assertion
|
||||
// vacuous, and run-tests.cjs would silently skip the check.
|
||||
assert.ok(roots.length > 5, `expected many runtime config roots, got ${roots.length}`);
|
||||
for (const root of roots) {
|
||||
assert.ok(path.isAbsolute(root), `root must be absolute: ${root}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('watches the HOME-derived fallback root, not only the ambient one', () => {
|
||||
// #2665 round 5: the guard resolves env-first, so it sees the AMBIENT root.
|
||||
// A child that blanks CLAUDE_CONFIG_DIR (which is exactly what TEST_ENV_BASE
|
||||
// does) falls back to <HOME>/.claude instead. Watching only the ambient path
|
||||
// leaves that fallback unwatched -- the escape route this PR closes, one
|
||||
// process deeper.
|
||||
const ambient = tmpRoot();
|
||||
const fakeHome = tmpRoot();
|
||||
const saved = process.env.CLAUDE_CONFIG_DIR;
|
||||
try {
|
||||
process.env.CLAUDE_CONFIG_DIR = ambient;
|
||||
const roots = resolveLiveConfigRoots({ os: { homedir: () => fakeHome } });
|
||||
assert.ok(
|
||||
roots.includes(path.resolve(ambient)),
|
||||
`ambient root missing from ${JSON.stringify(roots)}`,
|
||||
);
|
||||
assert.ok(
|
||||
roots.includes(path.resolve(path.join(fakeHome, '.claude'))),
|
||||
`HOME-derived fallback root missing from ${JSON.stringify(roots)}`,
|
||||
);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = saved;
|
||||
cleanup(ambient);
|
||||
cleanup(fakeHome);
|
||||
}
|
||||
});
|
||||
|
||||
test('a clean run produces no violations', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'gsd-core', 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'gsd-core', 'bin', 'x.cjs'), 'x');
|
||||
|
||||
const before = snapshotLiveConfig([root]);
|
||||
const after = snapshotLiveConfig([root]);
|
||||
assert.deepStrictEqual(diffLiveConfig(before, after), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a global install CREATED during the run', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// Exactly the Blocker 1 shape: an in-process install(true, …) landing a
|
||||
// full global install in a live config dir that was previously empty.
|
||||
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'gsd-file-manifest.json'), '{}');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const kinds = Object.fromEntries(violations.map((v) => [path.basename(v.path), v.kind]));
|
||||
assert.strictEqual(kinds['gsd-core'], 'created');
|
||||
assert.strictEqual(kinds['gsd-file-manifest.json'], 'created');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects an existing install MODIFIED during the run', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const target = path.join(root, 'gsd-core', 'bin');
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
const file = path.join(target, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(file, 'original');
|
||||
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// mtime resolution is coarse on some filesystems; set it forward explicitly
|
||||
// rather than racing the clock with a sleep.
|
||||
const future = new Date(Date.now() + 10000);
|
||||
fs.writeFileSync(file, 'clobbered');
|
||||
fs.utimesSync(file, future, future);
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'modified');
|
||||
assert.strictEqual(path.basename(violations[0].path), 'gsd-core');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores non-GSD writes in a shared config root', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// A concurrent host-agent session writing its own state must NOT trip the
|
||||
// guard — a guard that cries wolf gets disabled, and then catches nothing.
|
||||
fs.writeFileSync(path.join(root, 'history.jsonl'), '{}');
|
||||
fs.mkdirSync(path.join(root, 'todos'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'settings.json'), '{}');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('watches exactly the GSD-owned entry set', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const snap = snapshotLiveConfig([root]);
|
||||
const watched = Object.keys(snap).map((p) => path.relative(root, p)).sort();
|
||||
// Top-level owned entries PLUS the nested paths GSD owns wholesale inside a
|
||||
// shared root; prefixed children contribute nothing in an empty root.
|
||||
const expected = [
|
||||
...GSD_OWNED_ENTRIES,
|
||||
...artifactTargets().owned.map((o) => path.join(...o.split('/'))),
|
||||
].sort();
|
||||
assert.deepStrictEqual(watched, expected);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a gsd-prefixed artifact written into a SHARED dir', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'skills'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// The exact leak the first version of this guard MISSED: a writer that
|
||||
// sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR landed
|
||||
// <live>/skills/gsd-dev-preferences/SKILL.md, outside the three
|
||||
// top-level GSD entries.
|
||||
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'created');
|
||||
assert.strictEqual(path.basename(violations[0].path), 'gsd-dev-preferences');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores NON-gsd artifacts in a shared dir', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'skills'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// The host agent's own skills must not trip the guard.
|
||||
fs.mkdirSync(path.join(root, 'skills', 'my-personal-skill'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'skills', 'my-personal-skill', 'SKILL.md'), '# mine');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a leaked hook script and the install marker files', () => {
|
||||
// Self-found by re-deriving the census at round 5 rather than by a review
|
||||
// finding. bin/install.js writes hooks/gsd-*.js, .gsd-source and .gsd-profile
|
||||
// into the config ROOT; `hooks` was absent from GSD_PREFIXED_PARENTS and the
|
||||
// two dot-prefixed markers from GSD_OWNED_ENTRIES, so all three leaked past
|
||||
// the guard silently -- the same shape as the skills/gsd-dev-preferences miss
|
||||
// that motivated the prefixed-parent scan in the first place.
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
fs.writeFileSync(path.join(root, 'hooks', 'gsd-check-update.js'), '// x');
|
||||
fs.writeFileSync(path.join(root, '.gsd-source'), 'npm');
|
||||
fs.writeFileSync(path.join(root, '.gsd-profile'), 'default');
|
||||
|
||||
const created = diffLiveConfig(before, snapshotLiveConfig([root]))
|
||||
.filter((v) => v.kind === 'created')
|
||||
.map((v) => path.basename(v.path))
|
||||
.sort();
|
||||
assert.deepStrictEqual(created, ['.gsd-profile', '.gsd-source', 'gsd-check-update.js']);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('a NON-gsd hook belonging to the host agent is still ignored', () => {
|
||||
// Widening GSD_PREFIXED_PARENTS must not widen ownership: `hooks/` is shared
|
||||
// with the host agent, and a guard that flags its files gets switched off.
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
fs.writeFileSync(path.join(root, 'hooks', 'my-own-hook.js'), '// mine');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('artifact parents are DERIVED from the registry, not hand-listed', () => {
|
||||
// Round 5's adversarial review refuted the completeness claim of the
|
||||
// hand-list: it missed Kilo's SINGULAR `command/`, `workflows/`, and hermes'
|
||||
// `skills/gsd` -- a whole directory whose name carries no `gsd-` prefix, so
|
||||
// no prefix rule could ever reach it.
|
||||
const { parents, owned } = artifactTargets();
|
||||
// NB: `workflows` is declared only in LOCAL scope (windsurf), so it is
|
||||
// deliberately NOT a global config-root parent -- the derivation walks
|
||||
// artifactLayout.global only.
|
||||
for (const p of ['agents', 'commands', 'command', 'skills', 'hooks', 'plugins', 'scripts', 'extensions']) {
|
||||
assert.ok(p in parents, `expected derived parent ${p} in ${JSON.stringify(Object.keys(parents))}`);
|
||||
}
|
||||
// kimi's kimi-agents layout declares prefix `gsd` (no hyphen); a fixed `gsd-`
|
||||
// scan cannot see agents/gsd.yaml, which is the defect this derivation closes.
|
||||
assert.ok(
|
||||
parents.agents.includes('gsd'),
|
||||
`agents must carry kimi's bare 'gsd' prefix; got ${JSON.stringify(parents.agents)}`,
|
||||
);
|
||||
assert.ok(owned.includes('skills/gsd'), `expected hermes skills/gsd in ${JSON.stringify(owned)}`);
|
||||
// Exact GSD filenames only — never the shared directories that contain them.
|
||||
for (const o of ['scripts/fix-slash-commands.cjs', 'hooks/managed-hooks-registry.cjs']) {
|
||||
assert.ok(owned.includes(o), `expected owned nested ${o} in ${JSON.stringify(owned)}`);
|
||||
}
|
||||
for (const shared of ['hooks/lib', 'hooks/package.json', 'scripts/lib', 'scripts/changeset']) {
|
||||
assert.ok(!owned.includes(shared), `${shared} is shared ground and must NOT be watched wholesale`);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects leaks a single hardcoded gsd- prefix cannot see', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
for (const d of ['skills/gsd', 'agents', 'plugins', 'command', 'extensions']) {
|
||||
fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true });
|
||||
}
|
||||
const before = snapshotLiveConfig([root]);
|
||||
const future = new Date(Date.now() + 10000);
|
||||
// kimi declares prefix `gsd` (no hyphen) and writes agents/gsd.yaml;
|
||||
// pi writes extensions/gsd.js. A fixed `gsd-` scan sees neither.
|
||||
const leaks = [
|
||||
['skills', 'gsd', 'executor.md'],
|
||||
['agents', 'gsd.yaml'],
|
||||
['extensions', 'gsd.js'],
|
||||
['plugins', 'gsd-core.js'],
|
||||
['command', 'gsd-plan.md'],
|
||||
];
|
||||
for (const seg of leaks) {
|
||||
const f = path.join(root, ...seg);
|
||||
fs.writeFileSync(f, '// leaked');
|
||||
fs.utimesSync(f, future, future);
|
||||
fs.utimesSync(path.dirname(f), future, future);
|
||||
}
|
||||
|
||||
const hit = diffLiveConfig(before, snapshotLiveConfig([root])).map((v) => v.path);
|
||||
for (const expected of ['skills/gsd', 'agents/gsd.yaml', 'extensions/gsd.js', 'plugins/gsd-core.js', 'command/gsd-plan.md']) {
|
||||
const abs = path.join(root, ...expected.split('/'));
|
||||
assert.ok(hit.includes(abs), `${expected} leaked undetected; got ${JSON.stringify(hit)}`);
|
||||
}
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('a user editing their OWN files in a shared dir is not a violation', () => {
|
||||
// The false-positive case a prior commit shipped: hooks/lib, hooks/package.json,
|
||||
// scripts/lib and scripts/changeset were watched WHOLESALE, so touching a
|
||||
// user-authored helper in any of them tripped the guard. The installer itself
|
||||
// preserves foreign files in all four, so they are not GSD's to watch.
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
for (const d of ['hooks/lib', 'scripts/lib', 'scripts/changeset']) {
|
||||
fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true });
|
||||
}
|
||||
const foreign = [
|
||||
['hooks', 'package.json'],
|
||||
['hooks', 'lib', 'user-helper.js'],
|
||||
['scripts', 'lib', 'user-helper.cjs'],
|
||||
['scripts', 'changeset', 'user-tool.cjs'],
|
||||
];
|
||||
for (const seg of foreign) fs.writeFileSync(path.join(root, ...seg), 'mine');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
const future = new Date(Date.now() + 10000);
|
||||
for (const seg of foreign) {
|
||||
const f = path.join(root, ...seg);
|
||||
fs.writeFileSync(f, 'mine, edited');
|
||||
fs.utimesSync(f, future, future);
|
||||
fs.utimesSync(path.dirname(f), future, future);
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
diffLiveConfig(before, snapshotLiveConfig([root])),
|
||||
[],
|
||||
'editing user-owned files in a shared dir must not trip the guard',
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('extra watch targets cover the fallback root as well as the ambient one', () => {
|
||||
// The MISSED finding from round 5's review: B3 closed this for the registry
|
||||
// roots and left the identical hole in resolveExtraWatchTargets.
|
||||
const targets = resolveExtraWatchTargets({
|
||||
env: { GSD_HOME: '/ambient-gsd-home', KIMI_SHARE_DIR: '/ambient-kimi' },
|
||||
os: { homedir: () => '/fallback-home' },
|
||||
});
|
||||
assert.ok(targets.includes(path.resolve('/ambient-gsd-home/.gsd')), 'ambient $GSD_HOME/.gsd');
|
||||
assert.ok(targets.includes(path.resolve('/fallback-home/.gsd')), 'HOME-derived .gsd fallback');
|
||||
assert.ok(
|
||||
targets.some((t) => t === path.resolve('/fallback-home/.kimi/config.toml')),
|
||||
`HOME-derived kimi fallback missing from ${JSON.stringify(targets)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects a DELETED top-level GSD entry', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
// A fixed owned entry is recorded at BOTH ends whether or not it exists,
|
||||
// so a deletion reads {exists:true} -> {exists:false}. Before the union
|
||||
// walk that pair matched no branch at all and the run passed silently.
|
||||
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'gsd-core', 'x'), 'x');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
cleanup(path.join(root, 'gsd-core'));
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const deleted = violations.filter((v) => v.kind === 'deleted');
|
||||
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
|
||||
assert.strictEqual(path.basename(deleted[0].path), 'gsd-core');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a DELETED gsd-prefixed child of a shared dir', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
// The shape a `pre.exists && !post.exists` branch cannot reach on its own:
|
||||
// prefixed children are DISCOVERED by readdir, so a deleted one is absent
|
||||
// from the `after` snapshot entirely and never enters an after-keyed loop.
|
||||
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
cleanup(path.join(root, 'skills', 'gsd-dev-preferences'));
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const deleted = violations.filter((v) => v.kind === 'deleted');
|
||||
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
|
||||
assert.strictEqual(path.basename(deleted[0].path), 'gsd-dev-preferences');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('the scan budget is PER TARGET, so one big tree cannot cascade unverified', () => {
|
||||
// #2665 round 5: with a single running budget, target A exhausting it made
|
||||
// target B report `truncated` -> `unverified` -- a strict-mode FAILURE caused
|
||||
// by an unrelated directory. Each target now gets its own allotment.
|
||||
const [big] = treeWithEntries(8);
|
||||
const [small] = treeWithEntries(2);
|
||||
try {
|
||||
const snap = snapshotLiveConfig([], [big, small], { perTarget: 9, total: 1000 });
|
||||
assert.strictEqual(snap[path.resolve(big)].truncated, false, 'big target should fit its own budget');
|
||||
assert.strictEqual(
|
||||
snap[path.resolve(small)].truncated,
|
||||
false,
|
||||
'small target must NOT inherit exhaustion from a target scanned before it',
|
||||
);
|
||||
} finally {
|
||||
cleanup(big);
|
||||
cleanup(small);
|
||||
}
|
||||
});
|
||||
|
||||
test('the truncation verdict does not depend on target ORDER (below the global ceiling)', () => {
|
||||
const [big] = treeWithEntries(8);
|
||||
const [small] = treeWithEntries(2);
|
||||
try {
|
||||
const limits = { perTarget: 9, total: 1000 };
|
||||
const a = snapshotLiveConfig([], [big, small], limits);
|
||||
const b = snapshotLiveConfig([], [small, big], limits);
|
||||
assert.strictEqual(a[path.resolve(small)].truncated, b[path.resolve(small)].truncated);
|
||||
assert.strictEqual(a[path.resolve(big)].truncated, b[path.resolve(big)].truncated);
|
||||
} finally {
|
||||
cleanup(big);
|
||||
cleanup(small);
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-finite injected limit falls back to the real bound, never fails open', () => {
|
||||
// Math.max(0, NaN) is NaN, and every budget comparison against NaN is false,
|
||||
// so the walk becomes unbounded — the one thing the bound exists to prevent.
|
||||
// The discriminator has to be a case where the two behaviours DIFFER: pair a
|
||||
// NaN perTarget with a small finite ceiling. Fixed, perTarget falls back to
|
||||
// MAX_ENTRIES and the ceiling still bites (truncated). Broken, min(NaN, 3) is
|
||||
// NaN and nothing truncates at all.
|
||||
const [dir] = treeWithEntries(6);
|
||||
try {
|
||||
const snap = snapshotLiveConfig([], [dir], { perTarget: NaN, total: 3 });
|
||||
assert.strictEqual(
|
||||
snap[path.resolve(dir)].truncated,
|
||||
true,
|
||||
'a NaN perTarget must fall back to a real bound, not disable budgeting',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('the GLOBAL ceiling still bounds the aggregate, and reports unverified', () => {
|
||||
// The bound the single budget was really for is kept -- but when it engages,
|
||||
// the curtailed target is reported rather than silently attested clean.
|
||||
const [a] = treeWithEntries(5);
|
||||
const [b] = treeWithEntries(5);
|
||||
try {
|
||||
const snap = snapshotLiveConfig([], [a, b], { perTarget: 6, total: 6 });
|
||||
assert.strictEqual(snap[path.resolve(a)].truncated, false);
|
||||
assert.strictEqual(snap[path.resolve(b)].truncated, true, 'ceiling-curtailed target must be truncated');
|
||||
const violations = diffLiveConfig(snap, snap);
|
||||
assert.ok(
|
||||
violations.some((v) => v.kind === 'unverified' && v.path === path.resolve(b)),
|
||||
`expected an unverified violation for the curtailed target: ${JSON.stringify(violations)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(a);
|
||||
cleanup(b);
|
||||
}
|
||||
});
|
||||
|
||||
test('the report names the path and the remedy', () => {
|
||||
const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]);
|
||||
assert.match(out, /HERMETICITY WARNING/);
|
||||
assert.match(out, /\/live\/\.claude\/gsd-core/);
|
||||
assert.match(out, /scrubConfigLocationEnv/);
|
||||
assert.match(out, /GSD_SKIP_LIVE_CONFIG_GUARD/);
|
||||
assert.match(out, /GSD_STRICT_LIVE_CONFIG_GUARD/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Round 3: the write surfaces that are not runtime config ROOTS ───────────
|
||||
describe('#2665: guard watches non-root write surfaces', () => {
|
||||
test('resolveExtraWatchTargets covers $GSD_HOME/.gsd and kimi config.toml', () => {
|
||||
const home = tmpRoot();
|
||||
const share = tmpRoot();
|
||||
try {
|
||||
const targets = resolveExtraWatchTargets({
|
||||
env: { GSD_HOME: home, KIMI_SHARE_DIR: share },
|
||||
os: { homedir: () => home },
|
||||
});
|
||||
assert.ok(
|
||||
targets.includes(path.resolve(path.join(home, '.gsd'))),
|
||||
`expected $GSD_HOME/.gsd in ${JSON.stringify(targets)}`,
|
||||
);
|
||||
assert.ok(
|
||||
targets.some((t) => t === path.resolve(path.join(share, 'config.toml'))),
|
||||
`expected kimi config.toml in ${JSON.stringify(targets)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
cleanup(share);
|
||||
}
|
||||
});
|
||||
|
||||
test('extra targets are DERIVED from the descriptor array, not a named resolver', () => {
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
resolveConfigHomeFromDescriptor,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const env = { GSD_HOME: home };
|
||||
const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } });
|
||||
|
||||
// Every descriptor in the array must contribute a target. Calling one
|
||||
// named resolver instead would cover one of today's two entries and silently
|
||||
// miss tomorrow's — the same partial-enumeration defect that put
|
||||
// KIMI_SHARE_DIR outside the scrub set, one layer over.
|
||||
//
|
||||
// SCOPE BOUNDARY (per round-2 Nit 7, and it bites here): this asserts one
|
||||
// target PER DESCRIPTOR and nothing about whether one target per descriptor
|
||||
// is ENOUGH. It is not — <root>/hooks/ is also GSD-written and unwatched
|
||||
// (named residual in resolveExtraWatchTargets). A test whose expectation is
|
||||
// derived from the same array it checks cannot see that class.
|
||||
for (const d of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) {
|
||||
const dir = resolveConfigHomeFromDescriptor(d, { env, home });
|
||||
assert.ok(
|
||||
targets.includes(path.resolve(path.join(dir, 'config.toml'))),
|
||||
`descriptor ${JSON.stringify(d.env)} contributed no watch target`,
|
||||
);
|
||||
}
|
||||
// The count is what actually catches a regression to a hardcoded call:
|
||||
// it fails the moment the array grows and the guard does not follow.
|
||||
assert.strictEqual(
|
||||
targets.length,
|
||||
1 + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.length,
|
||||
'expected the GSD store root plus exactly one target per descriptor',
|
||||
);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('#2755: kimi-code config.toml is watched — named, not enumeration-relative', () => {
|
||||
// The test above derives its expectation FROM the descriptor array, so it
|
||||
// passes for whatever that array happens to contain and cannot see a
|
||||
// descriptor that was never added — the enumeration-relative scope boundary
|
||||
// this suite already calls out one layer down. #2755 landed kimi-code's
|
||||
// `~/.kimi-code` (KIMI_CODE_HOME) on `next` as an inline literal inside
|
||||
// resolveKimiHooksTomlDir's body; until it was hoisted into
|
||||
// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS the guard watched Kimi CLI's
|
||||
// config.toml and not Kimi Code's. Naming the path is what makes dropping
|
||||
// the descriptor fail loudly instead of quietly shrinking the expectation.
|
||||
const home = tmpRoot();
|
||||
const codeHome = tmpRoot();
|
||||
try {
|
||||
const env = { GSD_HOME: home, KIMI_CODE_HOME: codeHome };
|
||||
const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } });
|
||||
assert.ok(
|
||||
targets.includes(path.resolve(path.join(codeHome, 'config.toml'))),
|
||||
`expected kimi-code config.toml in ${JSON.stringify(targets)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
cleanup(codeHome);
|
||||
}
|
||||
});
|
||||
|
||||
test('GSD_HOME falls back to homedir when unset', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const targets = resolveExtraWatchTargets({ env: {}, os: { homedir: () => home } });
|
||||
assert.ok(targets.includes(path.resolve(path.join(home, '.gsd'))));
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a consent/defaults write into $GSD_HOME/.gsd', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const target = path.join(home, '.gsd');
|
||||
const before = snapshotLiveConfig([], [target]);
|
||||
// The Blocker-1 shape one family over: an ambient GSD_HOME sends real
|
||||
// consent records and defaults.json into the developer's own store.
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
fs.writeFileSync(path.join(target, 'consent.json'), '{}');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([], [target]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'created');
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a [[hooks]] write into kimi config.toml', () => {
|
||||
const share = tmpRoot();
|
||||
try {
|
||||
const target = path.join(share, 'config.toml');
|
||||
const before = snapshotLiveConfig([], [target]);
|
||||
fs.writeFileSync(target, '[[hooks]]\n');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([], [target]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'created');
|
||||
} finally {
|
||||
cleanup(share);
|
||||
}
|
||||
});
|
||||
|
||||
test('NEGATIVE CONTROL: without the extras both leaks are silent', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
// This is the pre-round-3 guard shape — roots only. It is what let a leak
|
||||
// on either variable pass through the PR's own safety net unreported.
|
||||
const before = snapshotLiveConfig([]);
|
||||
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, '.gsd', 'consent.json'), '{}');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([])), []);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('a whole-dir extra target does not watch unrelated siblings', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const target = path.join(home, '.gsd');
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
const before = snapshotLiveConfig([], [target]);
|
||||
// A sibling of .gsd is outside the watched target entirely.
|
||||
fs.writeFileSync(path.join(home, 'unrelated.json'), '{}');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([], [target])), []);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Round 3: the truncation budget — the module's own safety-critical case ───
|
||||
describe('#2665: scan-budget truncation', () => {
|
||||
test('boundary: limit-1 truncates, limit and limit+1 do not', () => {
|
||||
const [dir, entries] = treeWithEntries(24);
|
||||
try {
|
||||
// RULESET.TESTS.boundary-coverage: N in {limit-1, limit, limit+1}. The
|
||||
// budget is injected, so the boundary is exercised at a real threshold
|
||||
// without materialising MAX_ENTRIES files.
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: entries - 1 }).truncated,
|
||||
true,
|
||||
'one entry short of the tree size MUST truncate',
|
||||
);
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: entries }).truncated,
|
||||
false,
|
||||
'a budget exactly equal to the tree size must NOT truncate',
|
||||
);
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: entries + 1 }).truncated,
|
||||
false,
|
||||
'a budget above the tree size must NOT truncate',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// RULESET.TESTS.boundary-coverage asks for {limit-1, limit, limit+1}. This was
|
||||
// exercised only at limit+2, which pins neither side of the edge: an off-by-one
|
||||
// that truncated a legal depth would have passed. `nestedDepth(n)` builds a tree
|
||||
// whose deepest entry sits at walk-depth n below the scanned root, and
|
||||
// newestMtime truncates iff that depth EXCEEDS MAX_DEPTH.
|
||||
const nestedDepth = (n) => {
|
||||
const dir = tmpRoot();
|
||||
let deep = dir;
|
||||
for (let i = 0; i < n; i++) deep = path.join(deep, `d${i}`);
|
||||
fs.mkdirSync(deep, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH - 1} (limit-1) does NOT truncate`, () => {
|
||||
const dir = nestedDepth(MAX_DEPTH - 1);
|
||||
try {
|
||||
assert.strictEqual(newestMtime(dir, { remaining: 1e6 }).truncated, false);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH} (limit) does NOT truncate`, () => {
|
||||
const dir = nestedDepth(MAX_DEPTH);
|
||||
try {
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: 1e6 }).truncated,
|
||||
false,
|
||||
'a tree exactly at MAX_DEPTH is within bounds and must be attested',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH + 1} (limit+1) truncates`, () => {
|
||||
const dir = nestedDepth(MAX_DEPTH + 1);
|
||||
try {
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: 1e6 }).truncated,
|
||||
true,
|
||||
'a tree deeper than MAX_DEPTH must truncate',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('a truncated scan reports UNVERIFIED, never clean', () => {
|
||||
const [dir, entries] = treeWithEntries(10);
|
||||
try {
|
||||
// The safety-critical branch named in this module's own docstring: a scan
|
||||
// that hit a bound must not read as an attestation of cleanliness.
|
||||
const snap = { [dir]: { exists: true, newest: 1, truncated: true } };
|
||||
const violations = diffLiveConfig(snap, {
|
||||
[dir]: { exists: true, newest: 1, truncated: true },
|
||||
});
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'unverified');
|
||||
assert.match(formatViolations(violations), /UNVERIFIED \(scan bound hit/);
|
||||
assert.ok(entries > 0);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('a modified path outranks unverified (a real leak is never downgraded)', () => {
|
||||
const p = '/live/.claude/gsd-core';
|
||||
const violations = diffLiveConfig(
|
||||
{ [p]: { exists: true, newest: 1, truncated: true } },
|
||||
{ [p]: { exists: true, newest: 2, truncated: true } },
|
||||
);
|
||||
assert.strictEqual(violations[0].kind, 'modified');
|
||||
});
|
||||
|
||||
test('property: truncation is monotone in the budget (boundary containment)', () => {
|
||||
const [dir, entries] = treeWithEntries(12);
|
||||
try {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: entries * 3 }), (budget) => {
|
||||
const { truncated } = newestMtime(dir, { remaining: budget });
|
||||
// The invariant: a budget at or above the tree size never truncates,
|
||||
// and one below it always does. A regression flipping `truncated` to
|
||||
// false on an exhausted budget — the exact silent-clean failure the
|
||||
// module warns about — breaks this for every budget < entries.
|
||||
return budget >= entries ? truncated === false : truncated === true;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('property: newest mtime never exceeds the true maximum', () => {
|
||||
const [dir, entries] = treeWithEntries(8);
|
||||
try {
|
||||
const trueMax = Math.max(
|
||||
...fs.readdirSync(dir).map((f) => fs.lstatSync(path.join(dir, f)).mtimeMs),
|
||||
fs.lstatSync(dir).mtimeMs,
|
||||
);
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: entries * 2 }), (budget) => {
|
||||
const { newest } = newestMtime(dir, { remaining: budget });
|
||||
return newest <= trueMax;
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2665 round 4: CI wires the guard to strict mode', () => {
|
||||
// The reversion this guards: dropping GSD_STRICT_LIVE_CONFIG_GUARD from
|
||||
// test.yml silently demotes the guard back to report-only, and a future
|
||||
// leak of exactly the class #2665 closes prints a warning and CI stays
|
||||
// green. Windows lanes are deliberately report-only until the documented
|
||||
// pre-existing USERPROFILE leak class is swept (SEVERITY note in
|
||||
// scripts/live-config-guard.cjs) — so the assertion is per-OS, not global.
|
||||
test('both guard env vars are documented for humans, not just in the source', () => {
|
||||
// A skip-switch on a safety guard has to be discoverable: an undocumented
|
||||
// bypass is one people eventually set without knowing what they turned off.
|
||||
// The Docs Required gate gets satisfied by ANY docs/ file in the diff --
|
||||
// including a generated index -- so it cannot stand in for this.
|
||||
const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'TESTING-SUITES.md'), 'utf8');
|
||||
for (const v of ['GSD_STRICT_LIVE_CONFIG_GUARD', 'GSD_SKIP_LIVE_CONFIG_GUARD']) {
|
||||
assert.ok(doc.includes(v), `${v} must be documented in docs/TESTING-SUITES.md`);
|
||||
}
|
||||
});
|
||||
|
||||
// DERIVED, not hand-listed. The previous version named three jobs as literals,
|
||||
// so it could not see a FOURTH lane that runs the suite — and there was one:
|
||||
// qa-loop-walk reaches run-tests.cjs through `npm run test:qa` and escaped
|
||||
// strict mode entirely while this test stayed green. A hand-list that certifies
|
||||
// its own completeness is the exact defect this PR exists to fix, reproduced in
|
||||
// the test that guards the fix.
|
||||
test('EVERY job that runs the suite wires GSD_STRICT_LIVE_CONFIG_GUARD', () => {
|
||||
const yaml = require('js-yaml');
|
||||
const root = path.join(__dirname, '..');
|
||||
const wf = yaml.load(fs.readFileSync(path.join(root, '.github', 'workflows', 'test.yml'), 'utf8'));
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
|
||||
// A step reaches the runner directly OR through an npm script, transitively.
|
||||
// Grepping the filename alone misses the indirection that hid qa-loop-walk.
|
||||
const scriptRunsSuite = (name, seen = new Set()) => {
|
||||
if (seen.has(name)) return false;
|
||||
seen.add(name);
|
||||
const body = pkg.scripts?.[name];
|
||||
if (!body) return false;
|
||||
if (/run-tests\.cjs/.test(body)) return true;
|
||||
return [...body.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1], seen));
|
||||
};
|
||||
const runsSuite = (run) =>
|
||||
/run-tests\.cjs/.test(run)
|
||||
|| [...run.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1]));
|
||||
|
||||
const suiteJobs = Object.entries(wf.jobs ?? {})
|
||||
.filter(([, job]) => (job?.steps ?? []).some((s) => typeof s?.run === 'string' && runsSuite(s.run)))
|
||||
.map(([name]) => name)
|
||||
.sort();
|
||||
|
||||
// Guards the guard: an empty derivation would make every assertion below
|
||||
// vacuously true, which is the failure mode of the literal list it replaces.
|
||||
assert.ok(
|
||||
suiteJobs.length >= 4,
|
||||
`expected at least 4 suite-running jobs, derived ${JSON.stringify(suiteJobs)}`,
|
||||
);
|
||||
|
||||
const windowsMatrix = (job) => JSON.stringify(job?.strategy?.matrix ?? {}).includes('windows');
|
||||
const problems = [];
|
||||
for (const name of suiteJobs) {
|
||||
const job = wf.jobs[name];
|
||||
const v = String(job?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? '');
|
||||
// Windows lanes stay report-only until the pre-existing USERPROFILE leak
|
||||
// class is swept (SEVERITY note in scripts/live-config-guard.cjs), so a
|
||||
// job whose matrix includes Windows carries the conditional; an
|
||||
// ubuntu/macOS-only lane must be strict outright.
|
||||
const ok = windowsMatrix(job)
|
||||
// The WHOLE expression, anchored — a prefix match accepted both
|
||||
// `&& '1' || '1'` (Windows silently strict) and a malformed tail.
|
||||
? /^\$\{\{\s*matrix\.os\s*!=\s*'windows-latest'\s*&&\s*'1'\s*\|\|\s*''\s*\}\}$/.test(v)
|
||||
: v === '1';
|
||||
if (!ok) problems.push(`jobs.${name}: ${JSON.stringify(v)}`);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
problems,
|
||||
[],
|
||||
`every job running run-tests.cjs must wire the guard to strict mode `
|
||||
+ `(Windows matrices carved out). Derived jobs: ${JSON.stringify(suiteJobs)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -389,6 +389,9 @@ test('installer copies plugin as .js, records it in the manifest, and removes it
|
||||
const run = (args) => {
|
||||
const result = runNode([installer, '--opencode', '--global', '--config-dir', cfg, ...args], {
|
||||
timeoutMs: 120000,
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
env: require('./helpers.cjs').installSpawnEnv(),
|
||||
});
|
||||
result.status = result.exitCode;
|
||||
return result;
|
||||
|
||||
@@ -135,6 +135,27 @@ describe('#2858 — shipped scripts require only shipped paths', () => {
|
||||
.sort();
|
||||
});
|
||||
|
||||
test('#2665: the test-instrumentation chain does not ship', () => {
|
||||
// These four are one closed require chain of test instrumentation
|
||||
// (run-tests -> live-config-guard, affected-tests-lib -> run-tests,
|
||||
// run-affected-tests -> affected-tests-lib). Excluding a strict subset
|
||||
// re-trips the shipped-requires-only-shipped gate above on whichever links
|
||||
// still ship, so the exclusion set and this assertion cover the chain.
|
||||
const TEST_INSTRUMENTATION = [
|
||||
'scripts/live-config-guard.cjs',
|
||||
'scripts/run-tests.cjs',
|
||||
'scripts/affected-tests-lib.cjs',
|
||||
'scripts/run-affected-tests.cjs',
|
||||
];
|
||||
for (const f of TEST_INSTRUMENTATION) {
|
||||
assert.ok(
|
||||
!shippedFiles.has(f),
|
||||
`${f} is test instrumentation and must not ship — restore its ` +
|
||||
"package.json files[] '!'-exclusion (and keep the whole chain excluded)",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every shipped scripts/*.{cjs,js} is require-able from a shipped-only tree', () => {
|
||||
assert.ok(shippedScripts.length > 0, 'expected at least one shipped script');
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
runGsdTools,
|
||||
createTempProject,
|
||||
createTempGitProject,
|
||||
cleanup,
|
||||
withIsolatedProcessState,
|
||||
} = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
PROFILING_QUESTIONS,
|
||||
@@ -184,6 +190,50 @@ describe('write-profile command', () => {
|
||||
assert.strictEqual(out.profile_path, path.join(codexHome, 'gsd-core', 'USER-PROFILE.md'));
|
||||
});
|
||||
|
||||
test('#2665: ambient CLAUDE_CONFIG_DIR cannot escape a HOME-only sandbox', () => {
|
||||
// The defect: TEST_ENV_BASE blanked session-identity vars but none of the
|
||||
// config-LOCATION vars, and the dot-home resolver is env-first. So an
|
||||
// ambient CLAUDE_CONFIG_DIR in the DEVELOPER'S shell beat `{ HOME: tmpDir }`
|
||||
// and the suite wrote into their real config directory. Setting it on the
|
||||
// PARENT process is the actual vector — passing it in the per-call env
|
||||
// argument would test nothing, because that path was never broken.
|
||||
const analysis = {
|
||||
profile_version: '1.0',
|
||||
dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } },
|
||||
};
|
||||
const analysisPath = path.join(tmpDir, 'analysis.json');
|
||||
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
|
||||
|
||||
const ambientConfigDir = path.join(tmpDir, 'ambient-live-config');
|
||||
fs.mkdirSync(ambientConfigDir, { recursive: true });
|
||||
|
||||
const out = withIsolatedProcessState(() => {
|
||||
process.env.CLAUDE_CONFIG_DIR = ambientConfigDir;
|
||||
const result = runGsdTools(
|
||||
['write-profile', '--input', analysisPath, '--raw'],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir }
|
||||
);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
return JSON.parse(result.output);
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(
|
||||
fs.readdirSync(ambientConfigDir),
|
||||
[],
|
||||
'a call site that sandboxes HOME must not write into an ambient CLAUDE_CONFIG_DIR'
|
||||
);
|
||||
// Containment via path.relative, not startsWith: startsWith(ambientConfigDir)
|
||||
// also matches a SIBLING like `<tmp>/ambient-live-config-2`, so it can report
|
||||
// a false leak. A path is inside the dir iff the relative path neither escapes
|
||||
// with '..' nor is absolute.
|
||||
const rel = path.relative(ambientConfigDir, out.profile_path);
|
||||
assert.ok(
|
||||
rel.startsWith('..') || path.isAbsolute(rel),
|
||||
`profile must not resolve under the ambient config dir, got: ${out.profile_path}`
|
||||
);
|
||||
});
|
||||
|
||||
test('errors when --input is missing', () => {
|
||||
const result = runGsdTools('write-profile --raw', tmpDir);
|
||||
assert.ok(!result.success, 'should fail without --input');
|
||||
|
||||
@@ -48,28 +48,11 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const FIXTURES_ROOT = path.join(__dirname, 'fixtures', 'representative');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...args], {
|
||||
|
||||
@@ -22,7 +22,7 @@ const path = require('path');
|
||||
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, CONFIG_LOCATION_ENV_KEYS } = require('./helpers.cjs');
|
||||
|
||||
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
|
||||
|
||||
@@ -1412,10 +1412,17 @@ describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
|
||||
* We test the identical logic paths using a tiny timeout.
|
||||
*/
|
||||
function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) {
|
||||
// The session-identity subset stays a local literal on purpose: this helper
|
||||
// mirrors the production one to prove its CONTRACT, so it must not simply
|
||||
// re-import what it is testing. The config-LOCATION keys are the exception —
|
||||
// they are a safety scrub rather than part of the contract under test, and a
|
||||
// hand-copied list of them is the #2665 drift this change exists to end. So
|
||||
// spread the canonical derived set (tests/helpers.cjs) and keep the rest local.
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
...Object.fromEntries(CONFIG_LOCATION_ENV_KEYS.map((k) => [k, ''])),
|
||||
};
|
||||
try {
|
||||
let result;
|
||||
|
||||
@@ -26,7 +26,7 @@ const { resolveRuntimeArtifactLayout, findInstallSourceRoot } = require('../gsd-
|
||||
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { install } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
@@ -687,6 +687,7 @@ describe('#1477 .gsd-source marker provisioning', () => {
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let savedTestMode;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
function silenceConsole(fn) {
|
||||
const orig = { log: console.log, warn: console.warn, error: console.error };
|
||||
@@ -732,6 +733,12 @@ describe('#1477 .gsd-source marker provisioning', () => {
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
savedTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
// #2665: this block calls install(true, 'claude') IN-PROCESS. Redirecting
|
||||
// HOME/USERPROFILE is not enough, because getGlobalConfigDir is env-FIRST:
|
||||
// an ambient CLAUDE_CONFIG_DIR wins over the fixture and a full global
|
||||
// install lands in the developer's live config dir. Found by the post-suite
|
||||
// hermeticity guard (scripts/live-config-guard.cjs), not by inspection.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -743,6 +750,7 @@ describe('#1477 .gsd-source marker provisioning', () => {
|
||||
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -938,6 +946,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let savedTestMode;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
// Run install() with process.exit and console output mocked via t.mock (auto-restored),
|
||||
// per CONTRIBUTING.md test rules (no manual monkeypatch / try-finally in test bodies).
|
||||
@@ -962,6 +971,13 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
savedTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
// #2665: same in-process hazard as the block above. This one calls
|
||||
// install(true, 'claude') via runInstall(), and HOME/USERPROFILE alone do
|
||||
// not contain it — getGlobalConfigDir is env-FIRST, so an ambient
|
||||
// CLAUDE_CONFIG_DIR beats the fixture, the install lands in the developer's
|
||||
// live config dir, and these tests then fail looking for a marker under
|
||||
// tmpRoot that was never written there.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -973,6 +989,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
|
||||
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user