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:
Tom Boucher
2026-08-08 11:25:44 -04:00
committed by GitHub
31 changed files with 3072 additions and 599 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
});
});

View File

@@ -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 },
});

View File

@@ -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;
}

View File

@@ -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).

View File

@@ -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,

View File

@@ -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,

View 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)}`,
);
});
});

View File

@@ -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;

View File

@@ -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');

View File

@@ -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');

View File

@@ -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], {

View File

@@ -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;

View File

@@ -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);
});