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:
@@ -16,26 +16,9 @@ const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
GSD_WORKSTREAM: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools and capture BOTH stdout and stderr on success.
|
||||
|
||||
@@ -20,7 +20,7 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
|
||||
// In-process seam for the fail-closed read-injection tests at the bottom of this
|
||||
// file (#2365 review): readPhaseScope is the pure phase-scope reader behind the
|
||||
@@ -29,23 +29,6 @@ const { readPhaseScope } = require('../gsd-core/bin/lib/check-command-router.cjs
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
|
||||
@@ -21,27 +21,10 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
|
||||
@@ -2108,11 +2108,27 @@ describe('regressions: --runtime override bypasses persisted runtime (#2003)', (
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-'));
|
||||
try {
|
||||
writePersistedRuntime(tmpDir, 'codex');
|
||||
const result = runGsdTools('capability state --runtime claude --raw', tmpDir);
|
||||
// #2665: sandbox HOME and let the config-location vars stay BLANK (helpers'
|
||||
// TEST_ENV_BASE zeroes them), so the child resolves through the home-derived
|
||||
// FALLBACK branch of getGlobalConfigDir. The original test compared the
|
||||
// child's answer against the PARENT process's getGlobalConfigDir() -- two
|
||||
// different environments, agreeing only because the child inherited the
|
||||
// developer's ambient CLAUDE_CONFIG_DIR.
|
||||
//
|
||||
// Injecting CLAUDE_CONFIG_DIR here instead would fix that leak but move the
|
||||
// test onto the env-FIRST branch, silently dropping the only coverage this
|
||||
// #2003 regression has of the fallback branch. Sandboxing HOME keeps the
|
||||
// expectation test-controlled AND keeps the branch under test unchanged.
|
||||
const result = runGsdTools('capability state --runtime claude --raw', tmpDir, {
|
||||
HOME: tmpDir,
|
||||
USERPROFILE: tmpDir,
|
||||
});
|
||||
assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`);
|
||||
const parsed = JSON.parse(result.output);
|
||||
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
assert.strictEqual(parsed.runtimeConfigDir, runtimeHomes.getGlobalConfigDir('claude'),
|
||||
// Home-derived Claude dir. That this is NOT <tmpDir>/.codex is the #2003
|
||||
// assertion; a separate notStrictEqual against the codex dir would be dead,
|
||||
// since it cannot fail whenever this strictEqual passes.
|
||||
assert.strictEqual(parsed.runtimeConfigDir, path.join(tmpDir, '.claude'),
|
||||
'`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
|
||||
@@ -30,7 +30,7 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
@@ -136,23 +136,6 @@ function commitFile(git, tmpDir, filename, commitMessage) {
|
||||
|
||||
// ─── Helpers for subprocess invocation ────────────────────────────────────────
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
|
||||
@@ -740,27 +740,10 @@ const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools and return { stdout, stderr, status }.
|
||||
* Always captures stderr even when exit code is 0.
|
||||
|
||||
@@ -15,27 +15,10 @@ const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runMigrateConfig(cwd, extraArgs = [], env = {}) {
|
||||
const result = runNode([TOOLS_PATH, 'migrate-config', ...extraArgs], {
|
||||
cwd,
|
||||
|
||||
@@ -1362,7 +1362,10 @@ const EXPECTED_AGENTS = listAgentFiles().length;
|
||||
const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
function runCopilotInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
@@ -1374,7 +1377,10 @@ function runCopilotInstall(cwd) {
|
||||
}
|
||||
|
||||
function runCopilotUninstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--uninstall', '--no-sdk'], {
|
||||
cwd,
|
||||
@@ -1673,7 +1679,10 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
// ─── E2E: Copilot global scope (#786) ──────────────────────────────────────────
|
||||
|
||||
function runCopilotInstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode(
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'],
|
||||
@@ -1684,7 +1693,10 @@ function runCopilotInstallGlobal(cwd, configDir) {
|
||||
}
|
||||
|
||||
function runCopilotUninstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode(
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'],
|
||||
@@ -1734,7 +1746,10 @@ describe('E2E: Copilot global install (#786)', () => {
|
||||
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
|
||||
|
||||
function runClaudeInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
@@ -1746,7 +1761,10 @@ function runClaudeInstall(cwd) {
|
||||
}
|
||||
|
||||
function runClaudeUninstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--claude', '--local', '--uninstall', '--no-sdk'], {
|
||||
cwd,
|
||||
|
||||
@@ -1,8 +1,45 @@
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { withIsolatedProcessState } = require('./helpers.cjs');
|
||||
const {
|
||||
withIsolatedProcessState,
|
||||
TEST_ENV_BASE,
|
||||
CONFIG_LOCATION_ENV_KEYS,
|
||||
scrubConfigLocationEnv,
|
||||
} = require('./helpers.cjs');
|
||||
|
||||
describe('#2665: the built-lib require is deferred', () => {
|
||||
// The scrub set derives from gsd-core/bin/lib, which is BUILT. Requiring it at
|
||||
// module scope made an unbuilt tree throw inside `require('./helpers.cjs')` —
|
||||
// before any test() registered — so one missing `npm run build:lib` became a
|
||||
// whole-suite crash in the file ~370 test files import. A cold child is the only
|
||||
// honest probe: this process has already loaded everything.
|
||||
const probe = (touch) => {
|
||||
const src = [
|
||||
"const path = require('node:path');",
|
||||
`require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});`,
|
||||
touch,
|
||||
"const needle = path.join('gsd-core', 'bin', 'lib', 'capability-registry.cjs');",
|
||||
'process.stdout.write(String(Object.keys(require.cache).some((m) => m.endsWith(needle))));',
|
||||
].join('\n');
|
||||
// Bounded per local/no-unbounded-spawn (#3143): a cold require is sub-second,
|
||||
// so 30s is ~30x headroom and still fails loudly instead of hanging a lane.
|
||||
const r = spawnSync(process.execPath, ['-e', src], { encoding: 'utf8', timeout: 30_000 });
|
||||
assert.strictEqual(r.status, 0, `probe failed: ${r.stderr}`);
|
||||
return r.stdout === 'true';
|
||||
};
|
||||
|
||||
test('requiring helpers.cjs alone does NOT load the built runtime lib', () => {
|
||||
assert.strictEqual(probe(''), false, 'the built lib was loaded at import time');
|
||||
});
|
||||
|
||||
test('reading TEST_ENV_BASE is what loads it', () => {
|
||||
const touch = `require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))}).TEST_ENV_BASE;`;
|
||||
assert.strictEqual(probe(touch), true, 'reading the scrub set must resolve the built lib');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withIsolatedProcessState', () => {
|
||||
test('restores env, cwd, and exitCode after callback', () => {
|
||||
@@ -39,3 +76,337 @@ describe('withIsolatedProcessState', () => {
|
||||
assert.strictEqual(process.env.PATH, originalPath);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2665: the config-location scrub is DERIVED, and stays that way ──────────
|
||||
//
|
||||
// The recurrence guard. #2665 documents two prior authors independently
|
||||
// diagnosing this class and each fixing only the instance in front of them;
|
||||
// this is the third pass. A hand-maintained scrub list cannot be defended by
|
||||
// review alone, so the invariant is asserted instead of trusted.
|
||||
//
|
||||
// SCOPE BOUNDARY — read this before trusting a green run here.
|
||||
//
|
||||
// Every test below asserts that TEST_ENV_BASE covers some ENUMERATION (the
|
||||
// capability registry, the non-registry descriptor set, GSD's own location
|
||||
// keys). Each therefore proves only that the scrub set is not narrower than the
|
||||
// enumeration it derives from. NONE of them can prove the enumeration is itself
|
||||
// complete: a config-location var that no enumeration carries is invisible to
|
||||
// all of them, and they stay green.
|
||||
//
|
||||
// That is not hypothetical — it is how round 2 found GSD_HOME and
|
||||
// KIMI_SHARE_DIR while this block was fully green. GSD_HOME belonged to no
|
||||
// enumeration at all (it is GSD's own store root, not a runtime configHome);
|
||||
// KIMI_SHARE_DIR sat inside a function body where nothing could enumerate it.
|
||||
// Round 3's fix was to make both enumerable rather than to add two assertions,
|
||||
// precisely because an assertion added per reviewer-named var is the
|
||||
// hand-maintained list wearing a test's clothes.
|
||||
//
|
||||
// The completeness question — "is every env-first first-party location var in
|
||||
// SOME enumeration?" — is answered by a source census re-derived each round
|
||||
// (see the PR discussion), and by scripts/live-config-guard.cjs at runtime,
|
||||
// which observes actual writes rather than reasoning about names. Neither lives
|
||||
// here, and this block should not be read as standing in for them.
|
||||
describe('#2665: TEST_ENV_BASE config-location coverage', () => {
|
||||
test('every runtime configHome env var in the registry is scrubbed', () => {
|
||||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
const declared = [
|
||||
...new Set(
|
||||
Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
|
||||
),
|
||||
].sort();
|
||||
|
||||
// Guards the guard: an empty/renamed registry shape would make the
|
||||
// assertion below vacuously true and silently retire this test.
|
||||
assert.ok(
|
||||
declared.length >= 15,
|
||||
`expected the registry to declare many configHome env vars, got ${declared.length} — ` +
|
||||
'if the registry shape changed, this derivation needs updating, not deleting',
|
||||
);
|
||||
|
||||
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`config-location env vars reachable by the resolver but not scrubbed: ${missing.join(', ')}. ` +
|
||||
'TEST_ENV_BASE derives this set from the capability registry — a gap here means the ' +
|
||||
'derivation broke, not that the list needs a manual entry.',
|
||||
);
|
||||
});
|
||||
|
||||
test('every scrubbed config-location var is blanked, not merely present', () => {
|
||||
for (const key of CONFIG_LOCATION_ENV_KEYS) {
|
||||
assert.strictEqual(
|
||||
TEST_ENV_BASE[key],
|
||||
'',
|
||||
`${key} must be blanked ('') so the child sees a falsy value on the env-first branch`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the non-registry config-location vars are covered too', () => {
|
||||
// These have no capability descriptor, so the registry derivation alone
|
||||
// cannot reach them: GROK_AGENTS_HOME is a hardcoded branch of
|
||||
// getGlobalConfigDir, GSD_RUNTIME selects which runtime home resolves, and
|
||||
// GSD_PROJECT / GSD_WORKSTREAM move a child's .planning root
|
||||
// (src/planning-workspace.cts). Named explicitly so deleting one from the
|
||||
// helper is a test failure rather than a silent narrowing.
|
||||
for (const key of ['GROK_AGENTS_HOME', 'GSD_RUNTIME', 'GSD_PROJECT', 'GSD_WORKSTREAM']) {
|
||||
assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('descriptor-shaped config homes OUTSIDE the registry are derived, not listed', () => {
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
// Round 3. kimi owns TWO config homes: KIMI_CONFIG_DIR (registry-visible) and
|
||||
// KIMI_SHARE_DIR (a hardcoded descriptor inside resolveKimiHooksTomlDir, which
|
||||
// decides where its native config.toml — carrying GSD's [[hooks]] block — is
|
||||
// written). The registry-only derivation reached the first and not the second,
|
||||
// so it looked structurally complete while missing a live write surface.
|
||||
const declared = [
|
||||
...new Set(NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => d?.env ?? [])),
|
||||
];
|
||||
assert.ok(
|
||||
declared.length >= 1,
|
||||
'expected at least one non-registry descriptor — an empty array makes this vacuous',
|
||||
);
|
||||
assert.ok(
|
||||
declared.includes('KIMI_SHARE_DIR'),
|
||||
`KIMI_SHARE_DIR must come from the descriptor set, got ${declared.join(', ')}`,
|
||||
);
|
||||
|
||||
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`descriptor-declared config-location vars not scrubbed: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('skillsHome env vars are walked on BOTH descriptor rungs', () => {
|
||||
// Round 4. A configHome descriptor can nest a second, independently-resolved
|
||||
// descriptor (skillsHome → resolveSkillsBaseFromDescriptor), which carries
|
||||
// its own env array. Walking configHome.env alone is the identical
|
||||
// walk-one-field gap-shape rounds 2-3 closed for the registry and the
|
||||
// non-registry set. Inert today — only kilo declares skillsHome, with
|
||||
// env: [] — so this asserts the DERIVATION reaches the field, not that any
|
||||
// var currently flows from it: every skillsHome-declared var (registry and
|
||||
// non-registry alike) must land in TEST_ENV_BASE the moment one exists.
|
||||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
const declared = [
|
||||
...new Set([
|
||||
...Object.values(runtimes).flatMap(
|
||||
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
|
||||
),
|
||||
...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap(
|
||||
(d) => d?.skillsHome?.env ?? [],
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
// Anti-vacuity: at least one runtime must actually DECLARE skillsHome, or a
|
||||
// registry reshape could rename the field and retire this test silently.
|
||||
const declaringRuntimes = Object.values(runtimes).filter(
|
||||
(r) => r?.runtime?.configHome?.skillsHome !== undefined,
|
||||
);
|
||||
assert.ok(
|
||||
declaringRuntimes.length >= 1,
|
||||
'expected at least one registry runtime to declare configHome.skillsHome — ' +
|
||||
'if the field moved, this derivation needs updating, not deleting',
|
||||
);
|
||||
|
||||
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`skillsHome-declared config-location vars not scrubbed: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("GSD's OWN location vars are scrubbed (a second family, not a registry gap)", () => {
|
||||
const { GSD_LOCATION_ENV_KEYS } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
// GSD_HOME decides where GSD keeps user-owned state ($GSD_HOME/.gsd/ —
|
||||
// consent.json, defaults.json, capability overlays) and is read env-FIRST,
|
||||
// ahead of os.homedir(), across capability-loader / capability-consent /
|
||||
// capability-state / capability-writer / config-loader / install-profiles /
|
||||
// bin/install.js. GSD_AGENTS_DIR is priority 1 in getAgentsDir. Neither is a
|
||||
// runtime configHome, so no amount of registry derivation reaches them.
|
||||
assert.ok(GSD_LOCATION_ENV_KEYS.includes('GSD_HOME'));
|
||||
for (const key of GSD_LOCATION_ENV_KEYS) {
|
||||
assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('write-escape PERMISSIONS are scrubbed (a fifth family — not a location var)', () => {
|
||||
// #2665 round 5. GSD_ALLOW_SYMLINKED_DEST names no path, so every rung of the
|
||||
// derivation above is structurally incapable of reaching it — it is not a
|
||||
// registry configHome, not descriptor-shaped, not one of GSD's own location
|
||||
// vars. It is still a #2665 leak vector: install-engine.cts reads it env-first
|
||||
// and threads it into the symlink-escape guard that stops a write leaving the
|
||||
// install root, so an ambient `=1` disarms that guard for the whole suite.
|
||||
//
|
||||
// Named literally rather than derived from the family constant on purpose: a
|
||||
// test that reads WRITE_ESCAPE_PERMISSION_ENV_KEYS and asserts over it shrinks
|
||||
// its own expectation when the family is emptied — the enumeration-relative
|
||||
// failure this suite already documents, and the one that let the kimi-code
|
||||
// descriptor go unwatched. Naming it is what makes removal fail loudly.
|
||||
assert.strictEqual(
|
||||
TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST,
|
||||
'',
|
||||
'GSD_ALLOW_SYMLINKED_DEST must be blanked: ambient =1 disarms the symlink-escape guard',
|
||||
);
|
||||
// Blanking must be fail-SAFE — '' is neither '1' nor 'true', so the guard gets
|
||||
// stricter, never looser. This is what licenses scrubbing it wholesale.
|
||||
assert.ok(!['1', 'true'].includes(TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST));
|
||||
});
|
||||
|
||||
test('scrubConfigLocationEnv clears and restores the parent process env', () => {
|
||||
// The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches
|
||||
// children, so a test calling install() in-process needs the PARENT's env
|
||||
// cleared. Round-trip both states — set and unset — because restoring an
|
||||
// originally-unset var as '' rather than deleting it is itself a leak.
|
||||
withIsolatedProcessState(() => {
|
||||
process.env.CLAUDE_CONFIG_DIR = '/tmp/ambient-claude';
|
||||
delete process.env.CODEX_HOME;
|
||||
|
||||
const restore = scrubConfigLocationEnv();
|
||||
assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, undefined,
|
||||
'a set config-location var must be deleted, not blanked, on the parent');
|
||||
assert.strictEqual(process.env.CODEX_HOME, undefined);
|
||||
|
||||
restore();
|
||||
assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, '/tmp/ambient-claude',
|
||||
'restore must put back the original value');
|
||||
assert.ok(!('CODEX_HOME' in process.env),
|
||||
'restore must leave an originally-unset var unset, not set it to empty string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2665 round 4: the skillsHome walk is reversion-sensitive', () => {
|
||||
// The coverage tests above are enumeration-relative, and skillsHome.env is
|
||||
// empty everywhere today — so reverting the skillsHome rungs from the
|
||||
// derivation leaves every one of them green (measured by this round's
|
||||
// pre-push adversarial review). This test closes that: it cold-requires
|
||||
// helpers.cjs in a child process after injecting sentinel skillsHome env
|
||||
// vars into BOTH enumerations (registry and non-registry), so the walk
|
||||
// itself is what is under test, not today's empty declarations.
|
||||
test('sentinel skillsHome vars flow into TEST_ENV_BASE on both rungs', () => {
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const regPath = require.resolve('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const rhPath = require.resolve('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
const helpersPath = require.resolve('./helpers.cjs');
|
||||
|
||||
const script = `
|
||||
'use strict';
|
||||
const reg = require(${JSON.stringify(regPath)});
|
||||
const rh = require(${JSON.stringify(rhPath)});
|
||||
// Rung 1 (registry): give one runtime a skillsHome env var. kilo already
|
||||
// declares skillsHome (env: []); push a sentinel into whichever runtime
|
||||
// declares it, or graft one onto the first runtime if none does.
|
||||
const declaring = Object.values(reg.runtimes).find(
|
||||
(r) => r?.runtime?.configHome?.skillsHome,
|
||||
) ?? Object.values(reg.runtimes)[0];
|
||||
if (!declaring.runtime.configHome.skillsHome) {
|
||||
declaring.runtime.configHome.skillsHome = { kind: 'dot-home', name: '.x', env: [] };
|
||||
}
|
||||
declaring.runtime.configHome.skillsHome.env = ['GSD_TEST_SENTINEL_REGISTRY_SKILLS'];
|
||||
// Rung 2 (non-registry): graft a skillsHome onto the first descriptor.
|
||||
rh.NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[0].skillsHome = {
|
||||
kind: 'dot-home', name: '.x', env: ['GSD_TEST_SENTINEL_NONREG_SKILLS'],
|
||||
};
|
||||
const { TEST_ENV_BASE } = require(${JSON.stringify(helpersPath)});
|
||||
const missing = [
|
||||
'GSD_TEST_SENTINEL_REGISTRY_SKILLS',
|
||||
'GSD_TEST_SENTINEL_NONREG_SKILLS',
|
||||
].filter((k) => TEST_ENV_BASE[k] !== '');
|
||||
if (missing.length > 0) {
|
||||
console.error('skillsHome walk missed: ' + missing.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
`;
|
||||
|
||||
const out = execFileSync(process.execPath, ['-e', script], {
|
||||
cwd: __dirname,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 30_000,
|
||||
});
|
||||
void out; // exit 0 is the assertion; execFileSync throws on nonzero
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3156: a raw installer spawn cannot write into the ambient HOME', () => {
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { installSpawnEnv, cleanup } = require('./helpers.cjs');
|
||||
const { installerEnv } = require('./helpers/install-shared.cjs');
|
||||
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
// Contract half — cheap, and it names the precedence the callers depend on.
|
||||
test('the sandbox HOME replaces the ambient one, but an explicit override still wins', () => {
|
||||
for (const build of [installSpawnEnv, installerEnv]) {
|
||||
const env = build();
|
||||
assert.notStrictEqual(env.HOME, process.env.HOME,
|
||||
'a raw installer spawn must not inherit the ambient HOME');
|
||||
assert.strictEqual(env.USERPROFILE, env.HOME,
|
||||
'USERPROFILE must track HOME — os.homedir() reads it on Windows');
|
||||
assert.strictEqual(build({ HOME: '/explicit', USERPROFILE: '/explicit' }).HOME, '/explicit',
|
||||
'an explicit HOME override must still win (overrides spread last)');
|
||||
}
|
||||
});
|
||||
|
||||
// Behavioural half — the one that actually fails pre-fix.
|
||||
//
|
||||
// bin/install.js writeNonClaudeDefaults() (#2834) writes
|
||||
// <os.homedir()>/.gsd/defaults.json for every NON-Claude runtime, reading no
|
||||
// GSD variable at all. So this is deliberately driven through the real
|
||||
// installer against a real ambient HOME: no assertion about the scrub set can
|
||||
// stand in for it, because no scrub set can reach os.homedir().
|
||||
//
|
||||
// Negative control: revert installerEnv() to `{ ...process.env, ...overrides }`
|
||||
// and the canary gains .gsd/defaults.json.
|
||||
test('installing a non-Claude runtime leaves the ambient HOME untouched', () => {
|
||||
const canaryHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-canary-home-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-project-'));
|
||||
const realHome = process.env.HOME;
|
||||
const realUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
// Make the AMBIENT home the canary — the vector is the parent process's
|
||||
// own HOME, exactly as on a developer machine or a CI runner.
|
||||
process.env.HOME = canaryHome;
|
||||
process.env.USERPROFILE = canaryHome;
|
||||
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--cursor', '--local', '--no-sdk'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: installerEnv(),
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(canaryHome, '.gsd')),
|
||||
`the installer wrote GSD's user store into the ambient HOME: ${
|
||||
fs.existsSync(path.join(canaryHome, '.gsd'))
|
||||
? fs.readdirSync(path.join(canaryHome, '.gsd')).join(', ')
|
||||
: ''
|
||||
}`);
|
||||
} finally {
|
||||
if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome;
|
||||
if (realUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = realUserProfile;
|
||||
cleanup(canaryHome);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,29 +10,177 @@ const { createFixture } = require('./fixtures/index.cjs');
|
||||
const processSeam = require('./helpers/process-seam.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
// #2665: blank config-LOCATION vars so npm test never writes into the developer's
|
||||
// live config directory. The resolver consults these before HOME, so an ambient
|
||||
// value wins unconditionally over a sandboxed HOME. Per-site overrides still win
|
||||
// because env is spread last in the child-env merge.
|
||||
CLAUDE_CONFIG_DIR: '',
|
||||
GSD_RUNTIME: '',
|
||||
CODEX_HOME: '',
|
||||
};
|
||||
|
||||
// Session-IDENTITY vars. Blanked so a child cannot inherit the developer's
|
||||
// terminal/agent session and key shared state off it.
|
||||
const SESSION_IDENTITY_ENV_KEYS = [
|
||||
'GSD_SESSION_KEY',
|
||||
'CODEX_THREAD_ID',
|
||||
'CLAUDE_SESSION_ID',
|
||||
'CLAUDE_CODE_SSE_PORT',
|
||||
'OPENCODE_SESSION_ID',
|
||||
'GEMINI_SESSION_ID',
|
||||
'CURSOR_SESSION_ID',
|
||||
'WINDSURF_SESSION_ID',
|
||||
'TERM_SESSION_ID',
|
||||
'WT_SESSION',
|
||||
'TMUX_PANE',
|
||||
'ZELLIJ_SESSION_NAME',
|
||||
'TTY',
|
||||
'SSH_TTY',
|
||||
];
|
||||
|
||||
// LAZY, and memoized. These live in the BUILT runtime lib, so requiring them at
|
||||
// module scope made an unbuilt tree throw during `require('./helpers.cjs')` —
|
||||
// before a single test() had registered — which turns one missing
|
||||
// `npm run build:lib` into a whole-suite crash with no actionable message, in the
|
||||
// file ~370 test files import. `npm test` builds via its pretest hook, so the
|
||||
// shape that hits this is a direct `node --test` invocation.
|
||||
//
|
||||
// Deferring the require means only the tests that actually need the derived scrub
|
||||
// set pay for the build, and they fail with a message that names the remedy.
|
||||
let _builtLib = null;
|
||||
function builtLib() {
|
||||
if (_builtLib) return _builtLib;
|
||||
try {
|
||||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
GSD_LOCATION_ENV_KEYS,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
_builtLib = { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS };
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
'tests/helpers.cjs derives the config-location scrub set from the built runtime '
|
||||
+ 'lib (gsd-core/bin/lib), which is not present. Run `npm run build:lib` first — '
|
||||
+ '`npm test` does this for you via its pretest script.',
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
return _builtLib;
|
||||
}
|
||||
|
||||
// Config-location vars that are neither in the registry nor descriptor-shaped,
|
||||
// each with its reader:
|
||||
// GROK_AGENTS_HOME — hardcoded `grok` branch in getGlobalConfigDir (src/runtime-homes.cts)
|
||||
// GSD_RUNTIME — selects WHICH runtime home resolves (src/model-resolver.cts)
|
||||
// GSD_PROJECT — planningDir() project segment (src/planning-workspace.cts)
|
||||
// GSD_WORKSTREAM — planningDir() workstream segment (src/planning-workspace.cts)
|
||||
//
|
||||
// #2665 round 3: this list shrinks as sources become enumerable, and that direction
|
||||
// is the point. KIMI_SHARE_DIR was NOT added here — it now derives from
|
||||
// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, because hand-adding each var a reviewer
|
||||
// names is precisely what reopened this bug three times.
|
||||
const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
|
||||
'GROK_AGENTS_HOME',
|
||||
'GSD_RUNTIME',
|
||||
'GSD_PROJECT',
|
||||
'GSD_WORKSTREAM',
|
||||
];
|
||||
|
||||
// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT
|
||||
// folded into any of the four rungs below.
|
||||
//
|
||||
// #2665 round 5: GSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so it is
|
||||
// not a config-location var by any honest reading. But install-engine.cts reads it
|
||||
// env-first (`:214`) and threads it as `allowOptInFollow` into the symlink-escape
|
||||
// guard at four call sites, each gating a write (`:361/:367`, `:416/:424`,
|
||||
// `:785/:790`, `:927/:932`). That guard is what stops a write leaving the install
|
||||
// root, so an ambient `=1` disarms it for the whole suite — the #2665 hazard
|
||||
// exactly, arriving through a permission rather than a path.
|
||||
//
|
||||
// Blanking is fail-safe in the only direction that matters: '' is neither '1' nor
|
||||
// 'true', so a blanked value makes the guard STRICTER, never looser. That asymmetry
|
||||
// is why this can be scrubbed wholesale without reasoning about each call site.
|
||||
const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['GSD_ALLOW_SYMLINKED_DEST'];
|
||||
|
||||
// Config-LOCATION vars — distinct in kind from the session-identity vars above:
|
||||
// these decide WHERE a child writes, so leaving one ambient lets a test that
|
||||
// sandboxes HOME still escape into the developer's real config dir.
|
||||
//
|
||||
// #2665: this list is DERIVED, not hand-maintained. A hand-written list is
|
||||
// exactly what reopened this bug twice — it can only ever be as complete as the
|
||||
// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a
|
||||
// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing
|
||||
// it from the same registry the resolver reads makes the scrub list structurally
|
||||
// incapable of being narrower than the surface it guards: adding a capability
|
||||
// that declares a new configHome env var extends this set in the same commit.
|
||||
let _configLocationEnvKeys = null;
|
||||
function configLocationEnvKeys() {
|
||||
if (_configLocationEnvKeys) return _configLocationEnvKeys;
|
||||
const { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS } = builtLib();
|
||||
_configLocationEnvKeys = [
|
||||
...new Set([
|
||||
// 1. Every runtime descriptor the capability registry carries — including
|
||||
// the nested skillsHome descriptor, which resolves independently of
|
||||
// configHome (resolveSkillsBaseFromDescriptor) and can carry its own
|
||||
// env array. Inert today (only kilo declares skillsHome, with env: []),
|
||||
// but walking configHome.env alone is the identical gap-shape this PR
|
||||
// closed twice already, one field over. (#2665 round 4)
|
||||
...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
|
||||
...Object.values(runtimes).flatMap(
|
||||
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
|
||||
),
|
||||
// 2. Descriptor-shaped config homes resolved OUTSIDE the registry (kimi's
|
||||
// native config.toml home via KIMI_SHARE_DIR). Derived, not hand-listed.
|
||||
// Same skillsHome walk as rung 1 — a descriptor is a descriptor.
|
||||
...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => [
|
||||
...(d?.env ?? []),
|
||||
...(d?.skillsHome?.env ?? []),
|
||||
]),
|
||||
// 3. GSD's OWN location vars — a different family: they decide where GSD keeps
|
||||
// user-owned state ($GSD_HOME/.gsd/), not where a runtime keeps its config.
|
||||
...GSD_LOCATION_ENV_KEYS,
|
||||
// 4. The residue that is neither registry-carried nor descriptor-shaped.
|
||||
...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS,
|
||||
// 5. Write-escape permissions — NOT locations. Same mechanism because the
|
||||
// hazard is identical (ambient env lets a suite write outside the sandbox);
|
||||
// named separately above so the list does not misdescribe what they are.
|
||||
...WRITE_ESCAPE_PERMISSION_ENV_KEYS,
|
||||
]),
|
||||
].sort();
|
||||
return _configLocationEnvKeys;
|
||||
}
|
||||
|
||||
let _testEnvBase = null;
|
||||
function testEnvBase() {
|
||||
if (_testEnvBase) return _testEnvBase;
|
||||
_testEnvBase = Object.fromEntries(
|
||||
[...SESSION_IDENTITY_ENV_KEYS, ...configLocationEnvKeys()].map((k) => [k, '']),
|
||||
);
|
||||
return _testEnvBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save + clear every config-LOCATION env var on THIS process; returns a restorer.
|
||||
*
|
||||
* #2665: TEST_ENV_BASE only reaches CHILD processes. A test that calls the real
|
||||
* installer IN-PROCESS — `install(true, 'claude')` — resolves through the same
|
||||
* env-first `getGlobalConfigDir`, so an ambient CLAUDE_CONFIG_DIR beats a
|
||||
* sandboxed `process.env.HOME` and a complete global install (agents/, commands/,
|
||||
* skills/, gsd-core/, manifest, settings) lands in the developer's live config
|
||||
* dir. No child-env scrub can reach that call; only clearing the parent's env can.
|
||||
*
|
||||
* Pair with a HOME sandbox, not instead of one: HOME covers the home-derived
|
||||
* fallback, this covers the env-first branch that overrides it.
|
||||
*
|
||||
* @returns {() => void} restorer — call in afterEach to put the env back exactly
|
||||
* as it was (deleting keys that were previously unset, rather than setting '').
|
||||
*/
|
||||
function scrubConfigLocationEnv() {
|
||||
const saved = {};
|
||||
const keys = configLocationEnvKeys();
|
||||
for (const key of keys) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
return function restoreConfigLocationEnv() {
|
||||
for (const key of keys) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gsd-tools command.
|
||||
@@ -46,7 +194,7 @@ const TEST_ENV_BASE = {
|
||||
*/
|
||||
function runGsdTools(args, cwd = process.cwd(), env = {}) {
|
||||
// Resolve argv once so both the first attempt and the retry use the same vector.
|
||||
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env };
|
||||
const childEnv = { ...process.env, ...testEnvBase(), ...env };
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
@@ -763,4 +911,57 @@ function clearSessionEnv() {
|
||||
for (const k of SESSION_ENV_KEYS) delete process.env[k];
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH };
|
||||
/**
|
||||
* #3156: env for a RAW installer spawn — one that bypasses runGsdTools and so
|
||||
* never receives TEST_ENV_BASE on its own.
|
||||
*
|
||||
* Blanking config-LOCATION vars is necessary but NOT sufficient here.
|
||||
* bin/install.js writes GSD's own user-owned store through os.homedir()
|
||||
* DIRECTLY (writeNonClaudeDefaults -> <home>/.gsd/defaults.json, #2834), and
|
||||
* os.homedir() consults no GSD variable at all — so nothing in
|
||||
* CONFIG_LOCATION_ENV_KEYS can reach it, and blanking GSD_HOME does not reach
|
||||
* it either, because a blank GSD_HOME falls back to exactly that homedir().
|
||||
* Only a sandboxed HOME/USERPROFILE contains it.
|
||||
*
|
||||
* HOME stays deliberately OUT of TEST_ENV_BASE — blanking it would break far
|
||||
* more than it fixed — so it is sandboxed per spawn instead, which is the
|
||||
* discipline the suite already applies by hand elsewhere. USERPROFILE is set
|
||||
* with it because os.homedir() reads that one on Windows.
|
||||
*
|
||||
* The sandbox home is per-process and removed on exit, so a caller gets
|
||||
* containment without having to own a lifecycle.
|
||||
*
|
||||
* SCOPE, stated because it is a real residual rather than an oversight: this is
|
||||
* one home per test-FILE process, not one per spawn. Two installer spawns in the
|
||||
* same file therefore share `.gsd` state, so a prior non-Claude install can be
|
||||
* observed by a later spawn. That is strictly better than the status quo it
|
||||
* replaces -- which shared the developer's REAL home, and all of its state --
|
||||
* and it closes the leak this helper exists for; it does not claim isolation
|
||||
* BETWEEN spawns. A test needing that passes its own { HOME, USERPROFILE }.
|
||||
*/
|
||||
let installSpawnHomeDir = null;
|
||||
function installSpawnHome() {
|
||||
if (installSpawnHomeDir === null) {
|
||||
installSpawnHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-install-home-'));
|
||||
process.on('exit', () => {
|
||||
try { fs.rmSync(installSpawnHomeDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
});
|
||||
}
|
||||
return installSpawnHomeDir;
|
||||
}
|
||||
|
||||
function installSpawnEnv(overrides = {}) {
|
||||
const home = installSpawnHome();
|
||||
return { ...process.env, ...testEnvBase(), HOME: home, USERPROFILE: home, ...overrides };
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome };
|
||||
|
||||
// Lazy, for the reason builtLib() is lazy: reading either of these is what
|
||||
// forces the built-lib require, so a test file that needs neither can still
|
||||
// import this helper on an unbuilt tree. Enumerable, so destructuring and
|
||||
// Object.keys() behave exactly as they did when these were plain properties.
|
||||
Object.defineProperties(module.exports, {
|
||||
TEST_ENV_BASE: { enumerable: true, get: testEnvBase },
|
||||
CONFIG_LOCATION_ENV_KEYS: { enumerable: true, get: configLocationEnvKeys },
|
||||
});
|
||||
|
||||
@@ -520,7 +520,15 @@ function simulateHookCopy(hooksSrc, hooksDest) {
|
||||
/** Build a clean env for spawned installer processes.
|
||||
* Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */
|
||||
function installerEnv(overrides = {}) {
|
||||
const env = { ...process.env, ...overrides };
|
||||
// #3156: delegate to the ONE canonical raw-installer-spawn env rather than
|
||||
// carrying a second shape of it. The installer writes GSD's own user store to
|
||||
// <home>/.gsd/defaults.json through os.homedir() DIRECTLY
|
||||
// (bin/install.js writeNonClaudeDefaults, #2834), which reads no GSD variable,
|
||||
// so no config-location scrub can reach it — only a sandboxed HOME can. Every
|
||||
// caller that already passes an explicit { HOME, USERPROFILE } still wins:
|
||||
// overrides spread last.
|
||||
const { installSpawnEnv } = require('../helpers.cjs');
|
||||
const env = installSpawnEnv(overrides);
|
||||
delete env.GSD_TEST_MODE;
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -4646,7 +4646,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const PROFILE_OUTPUT = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'profile-output.cjs');
|
||||
@@ -4672,7 +4672,12 @@ describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-prefe
|
||||
m.cmdGenerateDevPreferences(${JSON.stringify(tmpHome)}, { analysis: ${JSON.stringify(analysisPath)} }, false);
|
||||
`);
|
||||
const result = cp.spawnSync(process.execPath, [driver], {
|
||||
env: Object.assign({}, process.env, { HOME: tmpHome, USERPROFILE: tmpHome }),
|
||||
// #2665: TEST_ENV_BASE must be merged in explicitly here. This is a RAW
|
||||
// spawn, not runGsdTools, so nothing scrubs the config-location vars for
|
||||
// it -- and the writer under test resolves them env-FIRST. Sandboxing
|
||||
// HOME alone let an ambient CLAUDE_CONFIG_DIR win, and the SKILL.md
|
||||
// landed in the developer's live config dir instead of tmpHome.
|
||||
env: Object.assign({}, process.env, TEST_ENV_BASE, { HOME: tmpHome, USERPROFILE: tmpHome }),
|
||||
encoding: 'utf-8',
|
||||
// Bound the subprocess so a regression that hangs the writer
|
||||
// (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback).
|
||||
|
||||
@@ -3827,7 +3827,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = () => createTempDir('gsd-3571-');
|
||||
|
||||
function silenceConsole(fn) {
|
||||
@@ -3853,6 +3853,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir();
|
||||
@@ -3861,6 +3862,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
savedUserProfile = process.env.USERPROFILE;
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -3872,6 +3879,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -3984,7 +3992,7 @@ const { install } = require('../bin/install.js');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = createTempDir;
|
||||
|
||||
const rmTmpDir = cleanup;
|
||||
@@ -4026,6 +4034,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir('gsd-3288-');
|
||||
@@ -4040,6 +4049,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
// and target a different directory than tmpRoot (CR finding, PR #3293).
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -4051,6 +4066,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
rmTmpDir(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -5521,7 +5537,10 @@ function ensureHooksDist() {
|
||||
* GSD_TEST_MODE is cleared so the install() main block executes.
|
||||
*/
|
||||
function runInstall(cwd, args) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
// 120s, not 60s. A full install copies and converts the whole shipped
|
||||
// payload (117 workflows, 100 references, 34 agents, ~71 skills) and
|
||||
@@ -7873,7 +7892,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = () => createTempDir('gsd-3571-');
|
||||
|
||||
function silenceConsole(fn) {
|
||||
@@ -7899,6 +7918,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir();
|
||||
@@ -7907,6 +7927,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
savedUserProfile = process.env.USERPROFILE;
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -7918,6 +7944,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -8030,7 +8057,7 @@ const { install } = require('../bin/install.js');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
const makeTmpDir = createTempDir;
|
||||
|
||||
const rmTmpDir = cleanup;
|
||||
@@ -8072,6 +8099,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir('gsd-3288-');
|
||||
@@ -8086,6 +8114,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
// and target a different directory than tmpRoot (CR finding, PR #3293).
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
// #2665: this block calls the real installer IN-PROCESS with only HOME
|
||||
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
|
||||
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
|
||||
// sandbox and a complete global install lands in the developer's live config
|
||||
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -8097,6 +8131,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
restoreConfigLocationEnv();
|
||||
rmTmpDir(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -9567,7 +9602,10 @@ function ensureHooksDist() {
|
||||
* GSD_TEST_MODE is cleared so the install() main block executes.
|
||||
*/
|
||||
function runInstall(cwd, args) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
// 120s, not 60s. A full install copies and converts the whole shipped
|
||||
// payload (117 workflows, 100 references, 34 agents, ~71 skills) and
|
||||
@@ -9992,7 +10030,10 @@ const {
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runClaudeLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
const { installSpawnEnv } = require('./helpers.cjs');
|
||||
const env = installSpawnEnv();
|
||||
delete env.GSD_TEST_MODE;
|
||||
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
|
||||
@@ -24,7 +24,7 @@ const ROOT = path.resolve(__dirname, '..');
|
||||
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
||||
const pkg = require(path.join(ROOT, 'package.json'));
|
||||
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
||||
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
|
||||
@@ -359,9 +359,36 @@ describe('C: plugin.json schema validation', () => {
|
||||
|
||||
// ── C2: Opportunistic CLI integration (skipped when claude not on PATH) ──────
|
||||
|
||||
// #2665: the `claude` CLI is a THIRD-PARTY binary that bootstraps its own
|
||||
// config (.claude.json plus a backups/ dir) into whatever CLAUDE_CONFIG_DIR
|
||||
// names. Two things follow, and only the first is obvious:
|
||||
//
|
||||
// 1. Inheriting the developer's ambient CLAUDE_CONFIG_DIR makes even a bare
|
||||
// `--version` probe write into their live config dir. That alone kept
|
||||
// `CLAUDE_CONFIG_DIR=<dir> npm run test:unit; find <dir> -type f` from
|
||||
// returning empty after every GSD-side leak was closed.
|
||||
// 2. BLANKING it (TEST_ENV_BASE's '' convention) is not enough here. GSD's
|
||||
// own resolvers treat '' as falsy and fall back to the home dir, but this
|
||||
// binary is not ours and gives no such guarantee -- blanking it produced a
|
||||
// stray backups/ directory in the REPO ROOT.
|
||||
//
|
||||
// So point it at a real throwaway dir rather than at nothing.
|
||||
const claudeCliHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2665-claude-cli-'));
|
||||
const claudeCliEnv = () => ({
|
||||
...process.env,
|
||||
...TEST_ENV_BASE,
|
||||
HOME: claudeCliHome,
|
||||
USERPROFILE: claudeCliHome,
|
||||
CLAUDE_CONFIG_DIR: path.join(claudeCliHome, '.claude'),
|
||||
});
|
||||
|
||||
const claudeAvailable = (() => {
|
||||
try {
|
||||
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
|
||||
const result = spawnSync('claude', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
env: claudeCliEnv(),
|
||||
});
|
||||
return result.status === 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
@@ -384,6 +411,7 @@ describe('C: plugin.json schema validation', () => {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: claudeCliEnv(),
|
||||
});
|
||||
assert.equal(
|
||||
result.status,
|
||||
|
||||
861
tests/live-config-guard.test.cjs
Normal file
861
tests/live-config-guard.test.cjs
Normal file
@@ -0,0 +1,861 @@
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
const {
|
||||
GSD_OWNED_ENTRIES,
|
||||
artifactTargets,
|
||||
MAX_DEPTH,
|
||||
resolveLiveConfigRoots,
|
||||
resolveExtraWatchTargets,
|
||||
snapshotLiveConfig,
|
||||
diffLiveConfig,
|
||||
formatViolations,
|
||||
newestMtime,
|
||||
} = require('../scripts/live-config-guard.cjs');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function tmpRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'live-config-guard-'));
|
||||
}
|
||||
|
||||
/** Create `n` flat files under a fresh dir; returns [dir, entryCount-including-dir]. */
|
||||
function treeWithEntries(n) {
|
||||
const dir = tmpRoot();
|
||||
for (let i = 0; i < n; i++) fs.writeFileSync(path.join(dir, `f${i}`), 'x');
|
||||
return [dir, n + 1]; // +1: the directory itself is lstat'd and costs budget
|
||||
}
|
||||
|
||||
describe('#2665: live-config hermeticity guard', () => {
|
||||
test('resolves real runtime config roots via the product resolver', () => {
|
||||
const roots = resolveLiveConfigRoots();
|
||||
// Guards the guard: an empty set would make every downstream assertion
|
||||
// vacuous, and run-tests.cjs would silently skip the check.
|
||||
assert.ok(roots.length > 5, `expected many runtime config roots, got ${roots.length}`);
|
||||
for (const root of roots) {
|
||||
assert.ok(path.isAbsolute(root), `root must be absolute: ${root}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('watches the HOME-derived fallback root, not only the ambient one', () => {
|
||||
// #2665 round 5: the guard resolves env-first, so it sees the AMBIENT root.
|
||||
// A child that blanks CLAUDE_CONFIG_DIR (which is exactly what TEST_ENV_BASE
|
||||
// does) falls back to <HOME>/.claude instead. Watching only the ambient path
|
||||
// leaves that fallback unwatched -- the escape route this PR closes, one
|
||||
// process deeper.
|
||||
const ambient = tmpRoot();
|
||||
const fakeHome = tmpRoot();
|
||||
const saved = process.env.CLAUDE_CONFIG_DIR;
|
||||
try {
|
||||
process.env.CLAUDE_CONFIG_DIR = ambient;
|
||||
const roots = resolveLiveConfigRoots({ os: { homedir: () => fakeHome } });
|
||||
assert.ok(
|
||||
roots.includes(path.resolve(ambient)),
|
||||
`ambient root missing from ${JSON.stringify(roots)}`,
|
||||
);
|
||||
assert.ok(
|
||||
roots.includes(path.resolve(path.join(fakeHome, '.claude'))),
|
||||
`HOME-derived fallback root missing from ${JSON.stringify(roots)}`,
|
||||
);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = saved;
|
||||
cleanup(ambient);
|
||||
cleanup(fakeHome);
|
||||
}
|
||||
});
|
||||
|
||||
test('a clean run produces no violations', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'gsd-core', 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'gsd-core', 'bin', 'x.cjs'), 'x');
|
||||
|
||||
const before = snapshotLiveConfig([root]);
|
||||
const after = snapshotLiveConfig([root]);
|
||||
assert.deepStrictEqual(diffLiveConfig(before, after), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a global install CREATED during the run', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// Exactly the Blocker 1 shape: an in-process install(true, …) landing a
|
||||
// full global install in a live config dir that was previously empty.
|
||||
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'gsd-file-manifest.json'), '{}');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const kinds = Object.fromEntries(violations.map((v) => [path.basename(v.path), v.kind]));
|
||||
assert.strictEqual(kinds['gsd-core'], 'created');
|
||||
assert.strictEqual(kinds['gsd-file-manifest.json'], 'created');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects an existing install MODIFIED during the run', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const target = path.join(root, 'gsd-core', 'bin');
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
const file = path.join(target, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(file, 'original');
|
||||
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// mtime resolution is coarse on some filesystems; set it forward explicitly
|
||||
// rather than racing the clock with a sleep.
|
||||
const future = new Date(Date.now() + 10000);
|
||||
fs.writeFileSync(file, 'clobbered');
|
||||
fs.utimesSync(file, future, future);
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'modified');
|
||||
assert.strictEqual(path.basename(violations[0].path), 'gsd-core');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores non-GSD writes in a shared config root', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// A concurrent host-agent session writing its own state must NOT trip the
|
||||
// guard — a guard that cries wolf gets disabled, and then catches nothing.
|
||||
fs.writeFileSync(path.join(root, 'history.jsonl'), '{}');
|
||||
fs.mkdirSync(path.join(root, 'todos'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'settings.json'), '{}');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('watches exactly the GSD-owned entry set', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
const snap = snapshotLiveConfig([root]);
|
||||
const watched = Object.keys(snap).map((p) => path.relative(root, p)).sort();
|
||||
// Top-level owned entries PLUS the nested paths GSD owns wholesale inside a
|
||||
// shared root; prefixed children contribute nothing in an empty root.
|
||||
const expected = [
|
||||
...GSD_OWNED_ENTRIES,
|
||||
...artifactTargets().owned.map((o) => path.join(...o.split('/'))),
|
||||
].sort();
|
||||
assert.deepStrictEqual(watched, expected);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a gsd-prefixed artifact written into a SHARED dir', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'skills'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// The exact leak the first version of this guard MISSED: a writer that
|
||||
// sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR landed
|
||||
// <live>/skills/gsd-dev-preferences/SKILL.md, outside the three
|
||||
// top-level GSD entries.
|
||||
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'created');
|
||||
assert.strictEqual(path.basename(violations[0].path), 'gsd-dev-preferences');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores NON-gsd artifacts in a shared dir', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'skills'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
// The host agent's own skills must not trip the guard.
|
||||
fs.mkdirSync(path.join(root, 'skills', 'my-personal-skill'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'skills', 'my-personal-skill', 'SKILL.md'), '# mine');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a leaked hook script and the install marker files', () => {
|
||||
// Self-found by re-deriving the census at round 5 rather than by a review
|
||||
// finding. bin/install.js writes hooks/gsd-*.js, .gsd-source and .gsd-profile
|
||||
// into the config ROOT; `hooks` was absent from GSD_PREFIXED_PARENTS and the
|
||||
// two dot-prefixed markers from GSD_OWNED_ENTRIES, so all three leaked past
|
||||
// the guard silently -- the same shape as the skills/gsd-dev-preferences miss
|
||||
// that motivated the prefixed-parent scan in the first place.
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
fs.writeFileSync(path.join(root, 'hooks', 'gsd-check-update.js'), '// x');
|
||||
fs.writeFileSync(path.join(root, '.gsd-source'), 'npm');
|
||||
fs.writeFileSync(path.join(root, '.gsd-profile'), 'default');
|
||||
|
||||
const created = diffLiveConfig(before, snapshotLiveConfig([root]))
|
||||
.filter((v) => v.kind === 'created')
|
||||
.map((v) => path.basename(v.path))
|
||||
.sort();
|
||||
assert.deepStrictEqual(created, ['.gsd-profile', '.gsd-source', 'gsd-check-update.js']);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('a NON-gsd hook belonging to the host agent is still ignored', () => {
|
||||
// Widening GSD_PREFIXED_PARENTS must not widen ownership: `hooks/` is shared
|
||||
// with the host agent, and a guard that flags its files gets switched off.
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
|
||||
const before = snapshotLiveConfig([root]);
|
||||
fs.writeFileSync(path.join(root, 'hooks', 'my-own-hook.js'), '// mine');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('artifact parents are DERIVED from the registry, not hand-listed', () => {
|
||||
// Round 5's adversarial review refuted the completeness claim of the
|
||||
// hand-list: it missed Kilo's SINGULAR `command/`, `workflows/`, and hermes'
|
||||
// `skills/gsd` -- a whole directory whose name carries no `gsd-` prefix, so
|
||||
// no prefix rule could ever reach it.
|
||||
const { parents, owned } = artifactTargets();
|
||||
// NB: `workflows` is declared only in LOCAL scope (windsurf), so it is
|
||||
// deliberately NOT a global config-root parent -- the derivation walks
|
||||
// artifactLayout.global only.
|
||||
for (const p of ['agents', 'commands', 'command', 'skills', 'hooks', 'plugins', 'scripts', 'extensions']) {
|
||||
assert.ok(p in parents, `expected derived parent ${p} in ${JSON.stringify(Object.keys(parents))}`);
|
||||
}
|
||||
// kimi's kimi-agents layout declares prefix `gsd` (no hyphen); a fixed `gsd-`
|
||||
// scan cannot see agents/gsd.yaml, which is the defect this derivation closes.
|
||||
assert.ok(
|
||||
parents.agents.includes('gsd'),
|
||||
`agents must carry kimi's bare 'gsd' prefix; got ${JSON.stringify(parents.agents)}`,
|
||||
);
|
||||
assert.ok(owned.includes('skills/gsd'), `expected hermes skills/gsd in ${JSON.stringify(owned)}`);
|
||||
// Exact GSD filenames only — never the shared directories that contain them.
|
||||
for (const o of ['scripts/fix-slash-commands.cjs', 'hooks/managed-hooks-registry.cjs']) {
|
||||
assert.ok(owned.includes(o), `expected owned nested ${o} in ${JSON.stringify(owned)}`);
|
||||
}
|
||||
for (const shared of ['hooks/lib', 'hooks/package.json', 'scripts/lib', 'scripts/changeset']) {
|
||||
assert.ok(!owned.includes(shared), `${shared} is shared ground and must NOT be watched wholesale`);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects leaks a single hardcoded gsd- prefix cannot see', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
for (const d of ['skills/gsd', 'agents', 'plugins', 'command', 'extensions']) {
|
||||
fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true });
|
||||
}
|
||||
const before = snapshotLiveConfig([root]);
|
||||
const future = new Date(Date.now() + 10000);
|
||||
// kimi declares prefix `gsd` (no hyphen) and writes agents/gsd.yaml;
|
||||
// pi writes extensions/gsd.js. A fixed `gsd-` scan sees neither.
|
||||
const leaks = [
|
||||
['skills', 'gsd', 'executor.md'],
|
||||
['agents', 'gsd.yaml'],
|
||||
['extensions', 'gsd.js'],
|
||||
['plugins', 'gsd-core.js'],
|
||||
['command', 'gsd-plan.md'],
|
||||
];
|
||||
for (const seg of leaks) {
|
||||
const f = path.join(root, ...seg);
|
||||
fs.writeFileSync(f, '// leaked');
|
||||
fs.utimesSync(f, future, future);
|
||||
fs.utimesSync(path.dirname(f), future, future);
|
||||
}
|
||||
|
||||
const hit = diffLiveConfig(before, snapshotLiveConfig([root])).map((v) => v.path);
|
||||
for (const expected of ['skills/gsd', 'agents/gsd.yaml', 'extensions/gsd.js', 'plugins/gsd-core.js', 'command/gsd-plan.md']) {
|
||||
const abs = path.join(root, ...expected.split('/'));
|
||||
assert.ok(hit.includes(abs), `${expected} leaked undetected; got ${JSON.stringify(hit)}`);
|
||||
}
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('a user editing their OWN files in a shared dir is not a violation', () => {
|
||||
// The false-positive case a prior commit shipped: hooks/lib, hooks/package.json,
|
||||
// scripts/lib and scripts/changeset were watched WHOLESALE, so touching a
|
||||
// user-authored helper in any of them tripped the guard. The installer itself
|
||||
// preserves foreign files in all four, so they are not GSD's to watch.
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
for (const d of ['hooks/lib', 'scripts/lib', 'scripts/changeset']) {
|
||||
fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true });
|
||||
}
|
||||
const foreign = [
|
||||
['hooks', 'package.json'],
|
||||
['hooks', 'lib', 'user-helper.js'],
|
||||
['scripts', 'lib', 'user-helper.cjs'],
|
||||
['scripts', 'changeset', 'user-tool.cjs'],
|
||||
];
|
||||
for (const seg of foreign) fs.writeFileSync(path.join(root, ...seg), 'mine');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
const future = new Date(Date.now() + 10000);
|
||||
for (const seg of foreign) {
|
||||
const f = path.join(root, ...seg);
|
||||
fs.writeFileSync(f, 'mine, edited');
|
||||
fs.utimesSync(f, future, future);
|
||||
fs.utimesSync(path.dirname(f), future, future);
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
diffLiveConfig(before, snapshotLiveConfig([root])),
|
||||
[],
|
||||
'editing user-owned files in a shared dir must not trip the guard',
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('extra watch targets cover the fallback root as well as the ambient one', () => {
|
||||
// The MISSED finding from round 5's review: B3 closed this for the registry
|
||||
// roots and left the identical hole in resolveExtraWatchTargets.
|
||||
const targets = resolveExtraWatchTargets({
|
||||
env: { GSD_HOME: '/ambient-gsd-home', KIMI_SHARE_DIR: '/ambient-kimi' },
|
||||
os: { homedir: () => '/fallback-home' },
|
||||
});
|
||||
assert.ok(targets.includes(path.resolve('/ambient-gsd-home/.gsd')), 'ambient $GSD_HOME/.gsd');
|
||||
assert.ok(targets.includes(path.resolve('/fallback-home/.gsd')), 'HOME-derived .gsd fallback');
|
||||
assert.ok(
|
||||
targets.some((t) => t === path.resolve('/fallback-home/.kimi/config.toml')),
|
||||
`HOME-derived kimi fallback missing from ${JSON.stringify(targets)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects a DELETED top-level GSD entry', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
// A fixed owned entry is recorded at BOTH ends whether or not it exists,
|
||||
// so a deletion reads {exists:true} -> {exists:false}. Before the union
|
||||
// walk that pair matched no branch at all and the run passed silently.
|
||||
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'gsd-core', 'x'), 'x');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
cleanup(path.join(root, 'gsd-core'));
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const deleted = violations.filter((v) => v.kind === 'deleted');
|
||||
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
|
||||
assert.strictEqual(path.basename(deleted[0].path), 'gsd-core');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a DELETED gsd-prefixed child of a shared dir', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
// The shape a `pre.exists && !post.exists` branch cannot reach on its own:
|
||||
// prefixed children are DISCOVERED by readdir, so a deleted one is absent
|
||||
// from the `after` snapshot entirely and never enters an after-keyed loop.
|
||||
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
cleanup(path.join(root, 'skills', 'gsd-dev-preferences'));
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const deleted = violations.filter((v) => v.kind === 'deleted');
|
||||
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
|
||||
assert.strictEqual(path.basename(deleted[0].path), 'gsd-dev-preferences');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('the scan budget is PER TARGET, so one big tree cannot cascade unverified', () => {
|
||||
// #2665 round 5: with a single running budget, target A exhausting it made
|
||||
// target B report `truncated` -> `unverified` -- a strict-mode FAILURE caused
|
||||
// by an unrelated directory. Each target now gets its own allotment.
|
||||
const [big] = treeWithEntries(8);
|
||||
const [small] = treeWithEntries(2);
|
||||
try {
|
||||
const snap = snapshotLiveConfig([], [big, small], { perTarget: 9, total: 1000 });
|
||||
assert.strictEqual(snap[path.resolve(big)].truncated, false, 'big target should fit its own budget');
|
||||
assert.strictEqual(
|
||||
snap[path.resolve(small)].truncated,
|
||||
false,
|
||||
'small target must NOT inherit exhaustion from a target scanned before it',
|
||||
);
|
||||
} finally {
|
||||
cleanup(big);
|
||||
cleanup(small);
|
||||
}
|
||||
});
|
||||
|
||||
test('the truncation verdict does not depend on target ORDER (below the global ceiling)', () => {
|
||||
const [big] = treeWithEntries(8);
|
||||
const [small] = treeWithEntries(2);
|
||||
try {
|
||||
const limits = { perTarget: 9, total: 1000 };
|
||||
const a = snapshotLiveConfig([], [big, small], limits);
|
||||
const b = snapshotLiveConfig([], [small, big], limits);
|
||||
assert.strictEqual(a[path.resolve(small)].truncated, b[path.resolve(small)].truncated);
|
||||
assert.strictEqual(a[path.resolve(big)].truncated, b[path.resolve(big)].truncated);
|
||||
} finally {
|
||||
cleanup(big);
|
||||
cleanup(small);
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-finite injected limit falls back to the real bound, never fails open', () => {
|
||||
// Math.max(0, NaN) is NaN, and every budget comparison against NaN is false,
|
||||
// so the walk becomes unbounded — the one thing the bound exists to prevent.
|
||||
// The discriminator has to be a case where the two behaviours DIFFER: pair a
|
||||
// NaN perTarget with a small finite ceiling. Fixed, perTarget falls back to
|
||||
// MAX_ENTRIES and the ceiling still bites (truncated). Broken, min(NaN, 3) is
|
||||
// NaN and nothing truncates at all.
|
||||
const [dir] = treeWithEntries(6);
|
||||
try {
|
||||
const snap = snapshotLiveConfig([], [dir], { perTarget: NaN, total: 3 });
|
||||
assert.strictEqual(
|
||||
snap[path.resolve(dir)].truncated,
|
||||
true,
|
||||
'a NaN perTarget must fall back to a real bound, not disable budgeting',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('the GLOBAL ceiling still bounds the aggregate, and reports unverified', () => {
|
||||
// The bound the single budget was really for is kept -- but when it engages,
|
||||
// the curtailed target is reported rather than silently attested clean.
|
||||
const [a] = treeWithEntries(5);
|
||||
const [b] = treeWithEntries(5);
|
||||
try {
|
||||
const snap = snapshotLiveConfig([], [a, b], { perTarget: 6, total: 6 });
|
||||
assert.strictEqual(snap[path.resolve(a)].truncated, false);
|
||||
assert.strictEqual(snap[path.resolve(b)].truncated, true, 'ceiling-curtailed target must be truncated');
|
||||
const violations = diffLiveConfig(snap, snap);
|
||||
assert.ok(
|
||||
violations.some((v) => v.kind === 'unverified' && v.path === path.resolve(b)),
|
||||
`expected an unverified violation for the curtailed target: ${JSON.stringify(violations)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(a);
|
||||
cleanup(b);
|
||||
}
|
||||
});
|
||||
|
||||
test('the report names the path and the remedy', () => {
|
||||
const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]);
|
||||
assert.match(out, /HERMETICITY WARNING/);
|
||||
assert.match(out, /\/live\/\.claude\/gsd-core/);
|
||||
assert.match(out, /scrubConfigLocationEnv/);
|
||||
assert.match(out, /GSD_SKIP_LIVE_CONFIG_GUARD/);
|
||||
assert.match(out, /GSD_STRICT_LIVE_CONFIG_GUARD/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Round 3: the write surfaces that are not runtime config ROOTS ───────────
|
||||
describe('#2665: guard watches non-root write surfaces', () => {
|
||||
test('resolveExtraWatchTargets covers $GSD_HOME/.gsd and kimi config.toml', () => {
|
||||
const home = tmpRoot();
|
||||
const share = tmpRoot();
|
||||
try {
|
||||
const targets = resolveExtraWatchTargets({
|
||||
env: { GSD_HOME: home, KIMI_SHARE_DIR: share },
|
||||
os: { homedir: () => home },
|
||||
});
|
||||
assert.ok(
|
||||
targets.includes(path.resolve(path.join(home, '.gsd'))),
|
||||
`expected $GSD_HOME/.gsd in ${JSON.stringify(targets)}`,
|
||||
);
|
||||
assert.ok(
|
||||
targets.some((t) => t === path.resolve(path.join(share, 'config.toml'))),
|
||||
`expected kimi config.toml in ${JSON.stringify(targets)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
cleanup(share);
|
||||
}
|
||||
});
|
||||
|
||||
test('extra targets are DERIVED from the descriptor array, not a named resolver', () => {
|
||||
const {
|
||||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||||
resolveConfigHomeFromDescriptor,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const env = { GSD_HOME: home };
|
||||
const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } });
|
||||
|
||||
// Every descriptor in the array must contribute a target. Calling one
|
||||
// named resolver instead would cover one of today's two entries and silently
|
||||
// miss tomorrow's — the same partial-enumeration defect that put
|
||||
// KIMI_SHARE_DIR outside the scrub set, one layer over.
|
||||
//
|
||||
// SCOPE BOUNDARY (per round-2 Nit 7, and it bites here): this asserts one
|
||||
// target PER DESCRIPTOR and nothing about whether one target per descriptor
|
||||
// is ENOUGH. It is not — <root>/hooks/ is also GSD-written and unwatched
|
||||
// (named residual in resolveExtraWatchTargets). A test whose expectation is
|
||||
// derived from the same array it checks cannot see that class.
|
||||
for (const d of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) {
|
||||
const dir = resolveConfigHomeFromDescriptor(d, { env, home });
|
||||
assert.ok(
|
||||
targets.includes(path.resolve(path.join(dir, 'config.toml'))),
|
||||
`descriptor ${JSON.stringify(d.env)} contributed no watch target`,
|
||||
);
|
||||
}
|
||||
// The count is what actually catches a regression to a hardcoded call:
|
||||
// it fails the moment the array grows and the guard does not follow.
|
||||
assert.strictEqual(
|
||||
targets.length,
|
||||
1 + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.length,
|
||||
'expected the GSD store root plus exactly one target per descriptor',
|
||||
);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('#2755: kimi-code config.toml is watched — named, not enumeration-relative', () => {
|
||||
// The test above derives its expectation FROM the descriptor array, so it
|
||||
// passes for whatever that array happens to contain and cannot see a
|
||||
// descriptor that was never added — the enumeration-relative scope boundary
|
||||
// this suite already calls out one layer down. #2755 landed kimi-code's
|
||||
// `~/.kimi-code` (KIMI_CODE_HOME) on `next` as an inline literal inside
|
||||
// resolveKimiHooksTomlDir's body; until it was hoisted into
|
||||
// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS the guard watched Kimi CLI's
|
||||
// config.toml and not Kimi Code's. Naming the path is what makes dropping
|
||||
// the descriptor fail loudly instead of quietly shrinking the expectation.
|
||||
const home = tmpRoot();
|
||||
const codeHome = tmpRoot();
|
||||
try {
|
||||
const env = { GSD_HOME: home, KIMI_CODE_HOME: codeHome };
|
||||
const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } });
|
||||
assert.ok(
|
||||
targets.includes(path.resolve(path.join(codeHome, 'config.toml'))),
|
||||
`expected kimi-code config.toml in ${JSON.stringify(targets)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
cleanup(codeHome);
|
||||
}
|
||||
});
|
||||
|
||||
test('GSD_HOME falls back to homedir when unset', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const targets = resolveExtraWatchTargets({ env: {}, os: { homedir: () => home } });
|
||||
assert.ok(targets.includes(path.resolve(path.join(home, '.gsd'))));
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a consent/defaults write into $GSD_HOME/.gsd', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const target = path.join(home, '.gsd');
|
||||
const before = snapshotLiveConfig([], [target]);
|
||||
// The Blocker-1 shape one family over: an ambient GSD_HOME sends real
|
||||
// consent records and defaults.json into the developer's own store.
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
fs.writeFileSync(path.join(target, 'consent.json'), '{}');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([], [target]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'created');
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a [[hooks]] write into kimi config.toml', () => {
|
||||
const share = tmpRoot();
|
||||
try {
|
||||
const target = path.join(share, 'config.toml');
|
||||
const before = snapshotLiveConfig([], [target]);
|
||||
fs.writeFileSync(target, '[[hooks]]\n');
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([], [target]));
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'created');
|
||||
} finally {
|
||||
cleanup(share);
|
||||
}
|
||||
});
|
||||
|
||||
test('NEGATIVE CONTROL: without the extras both leaks are silent', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
// This is the pre-round-3 guard shape — roots only. It is what let a leak
|
||||
// on either variable pass through the PR's own safety net unreported.
|
||||
const before = snapshotLiveConfig([]);
|
||||
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, '.gsd', 'consent.json'), '{}');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([])), []);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('a whole-dir extra target does not watch unrelated siblings', () => {
|
||||
const home = tmpRoot();
|
||||
try {
|
||||
const target = path.join(home, '.gsd');
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
const before = snapshotLiveConfig([], [target]);
|
||||
// A sibling of .gsd is outside the watched target entirely.
|
||||
fs.writeFileSync(path.join(home, 'unrelated.json'), '{}');
|
||||
|
||||
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([], [target])), []);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Round 3: the truncation budget — the module's own safety-critical case ───
|
||||
describe('#2665: scan-budget truncation', () => {
|
||||
test('boundary: limit-1 truncates, limit and limit+1 do not', () => {
|
||||
const [dir, entries] = treeWithEntries(24);
|
||||
try {
|
||||
// RULESET.TESTS.boundary-coverage: N in {limit-1, limit, limit+1}. The
|
||||
// budget is injected, so the boundary is exercised at a real threshold
|
||||
// without materialising MAX_ENTRIES files.
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: entries - 1 }).truncated,
|
||||
true,
|
||||
'one entry short of the tree size MUST truncate',
|
||||
);
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: entries }).truncated,
|
||||
false,
|
||||
'a budget exactly equal to the tree size must NOT truncate',
|
||||
);
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: entries + 1 }).truncated,
|
||||
false,
|
||||
'a budget above the tree size must NOT truncate',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// RULESET.TESTS.boundary-coverage asks for {limit-1, limit, limit+1}. This was
|
||||
// exercised only at limit+2, which pins neither side of the edge: an off-by-one
|
||||
// that truncated a legal depth would have passed. `nestedDepth(n)` builds a tree
|
||||
// whose deepest entry sits at walk-depth n below the scanned root, and
|
||||
// newestMtime truncates iff that depth EXCEEDS MAX_DEPTH.
|
||||
const nestedDepth = (n) => {
|
||||
const dir = tmpRoot();
|
||||
let deep = dir;
|
||||
for (let i = 0; i < n; i++) deep = path.join(deep, `d${i}`);
|
||||
fs.mkdirSync(deep, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH - 1} (limit-1) does NOT truncate`, () => {
|
||||
const dir = nestedDepth(MAX_DEPTH - 1);
|
||||
try {
|
||||
assert.strictEqual(newestMtime(dir, { remaining: 1e6 }).truncated, false);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH} (limit) does NOT truncate`, () => {
|
||||
const dir = nestedDepth(MAX_DEPTH);
|
||||
try {
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: 1e6 }).truncated,
|
||||
false,
|
||||
'a tree exactly at MAX_DEPTH is within bounds and must be attested',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH + 1} (limit+1) truncates`, () => {
|
||||
const dir = nestedDepth(MAX_DEPTH + 1);
|
||||
try {
|
||||
assert.strictEqual(
|
||||
newestMtime(dir, { remaining: 1e6 }).truncated,
|
||||
true,
|
||||
'a tree deeper than MAX_DEPTH must truncate',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('a truncated scan reports UNVERIFIED, never clean', () => {
|
||||
const [dir, entries] = treeWithEntries(10);
|
||||
try {
|
||||
// The safety-critical branch named in this module's own docstring: a scan
|
||||
// that hit a bound must not read as an attestation of cleanliness.
|
||||
const snap = { [dir]: { exists: true, newest: 1, truncated: true } };
|
||||
const violations = diffLiveConfig(snap, {
|
||||
[dir]: { exists: true, newest: 1, truncated: true },
|
||||
});
|
||||
assert.strictEqual(violations.length, 1);
|
||||
assert.strictEqual(violations[0].kind, 'unverified');
|
||||
assert.match(formatViolations(violations), /UNVERIFIED \(scan bound hit/);
|
||||
assert.ok(entries > 0);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('a modified path outranks unverified (a real leak is never downgraded)', () => {
|
||||
const p = '/live/.claude/gsd-core';
|
||||
const violations = diffLiveConfig(
|
||||
{ [p]: { exists: true, newest: 1, truncated: true } },
|
||||
{ [p]: { exists: true, newest: 2, truncated: true } },
|
||||
);
|
||||
assert.strictEqual(violations[0].kind, 'modified');
|
||||
});
|
||||
|
||||
test('property: truncation is monotone in the budget (boundary containment)', () => {
|
||||
const [dir, entries] = treeWithEntries(12);
|
||||
try {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: entries * 3 }), (budget) => {
|
||||
const { truncated } = newestMtime(dir, { remaining: budget });
|
||||
// The invariant: a budget at or above the tree size never truncates,
|
||||
// and one below it always does. A regression flipping `truncated` to
|
||||
// false on an exhausted budget — the exact silent-clean failure the
|
||||
// module warns about — breaks this for every budget < entries.
|
||||
return budget >= entries ? truncated === false : truncated === true;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('property: newest mtime never exceeds the true maximum', () => {
|
||||
const [dir, entries] = treeWithEntries(8);
|
||||
try {
|
||||
const trueMax = Math.max(
|
||||
...fs.readdirSync(dir).map((f) => fs.lstatSync(path.join(dir, f)).mtimeMs),
|
||||
fs.lstatSync(dir).mtimeMs,
|
||||
);
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: entries * 2 }), (budget) => {
|
||||
const { newest } = newestMtime(dir, { remaining: budget });
|
||||
return newest <= trueMax;
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2665 round 4: CI wires the guard to strict mode', () => {
|
||||
// The reversion this guards: dropping GSD_STRICT_LIVE_CONFIG_GUARD from
|
||||
// test.yml silently demotes the guard back to report-only, and a future
|
||||
// leak of exactly the class #2665 closes prints a warning and CI stays
|
||||
// green. Windows lanes are deliberately report-only until the documented
|
||||
// pre-existing USERPROFILE leak class is swept (SEVERITY note in
|
||||
// scripts/live-config-guard.cjs) — so the assertion is per-OS, not global.
|
||||
test('both guard env vars are documented for humans, not just in the source', () => {
|
||||
// A skip-switch on a safety guard has to be discoverable: an undocumented
|
||||
// bypass is one people eventually set without knowing what they turned off.
|
||||
// The Docs Required gate gets satisfied by ANY docs/ file in the diff --
|
||||
// including a generated index -- so it cannot stand in for this.
|
||||
const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'TESTING-SUITES.md'), 'utf8');
|
||||
for (const v of ['GSD_STRICT_LIVE_CONFIG_GUARD', 'GSD_SKIP_LIVE_CONFIG_GUARD']) {
|
||||
assert.ok(doc.includes(v), `${v} must be documented in docs/TESTING-SUITES.md`);
|
||||
}
|
||||
});
|
||||
|
||||
// DERIVED, not hand-listed. The previous version named three jobs as literals,
|
||||
// so it could not see a FOURTH lane that runs the suite — and there was one:
|
||||
// qa-loop-walk reaches run-tests.cjs through `npm run test:qa` and escaped
|
||||
// strict mode entirely while this test stayed green. A hand-list that certifies
|
||||
// its own completeness is the exact defect this PR exists to fix, reproduced in
|
||||
// the test that guards the fix.
|
||||
test('EVERY job that runs the suite wires GSD_STRICT_LIVE_CONFIG_GUARD', () => {
|
||||
const yaml = require('js-yaml');
|
||||
const root = path.join(__dirname, '..');
|
||||
const wf = yaml.load(fs.readFileSync(path.join(root, '.github', 'workflows', 'test.yml'), 'utf8'));
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
|
||||
// A step reaches the runner directly OR through an npm script, transitively.
|
||||
// Grepping the filename alone misses the indirection that hid qa-loop-walk.
|
||||
const scriptRunsSuite = (name, seen = new Set()) => {
|
||||
if (seen.has(name)) return false;
|
||||
seen.add(name);
|
||||
const body = pkg.scripts?.[name];
|
||||
if (!body) return false;
|
||||
if (/run-tests\.cjs/.test(body)) return true;
|
||||
return [...body.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1], seen));
|
||||
};
|
||||
const runsSuite = (run) =>
|
||||
/run-tests\.cjs/.test(run)
|
||||
|| [...run.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1]));
|
||||
|
||||
const suiteJobs = Object.entries(wf.jobs ?? {})
|
||||
.filter(([, job]) => (job?.steps ?? []).some((s) => typeof s?.run === 'string' && runsSuite(s.run)))
|
||||
.map(([name]) => name)
|
||||
.sort();
|
||||
|
||||
// Guards the guard: an empty derivation would make every assertion below
|
||||
// vacuously true, which is the failure mode of the literal list it replaces.
|
||||
assert.ok(
|
||||
suiteJobs.length >= 4,
|
||||
`expected at least 4 suite-running jobs, derived ${JSON.stringify(suiteJobs)}`,
|
||||
);
|
||||
|
||||
const windowsMatrix = (job) => JSON.stringify(job?.strategy?.matrix ?? {}).includes('windows');
|
||||
const problems = [];
|
||||
for (const name of suiteJobs) {
|
||||
const job = wf.jobs[name];
|
||||
const v = String(job?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? '');
|
||||
// Windows lanes stay report-only until the pre-existing USERPROFILE leak
|
||||
// class is swept (SEVERITY note in scripts/live-config-guard.cjs), so a
|
||||
// job whose matrix includes Windows carries the conditional; an
|
||||
// ubuntu/macOS-only lane must be strict outright.
|
||||
const ok = windowsMatrix(job)
|
||||
// The WHOLE expression, anchored — a prefix match accepted both
|
||||
// `&& '1' || '1'` (Windows silently strict) and a malformed tail.
|
||||
? /^\$\{\{\s*matrix\.os\s*!=\s*'windows-latest'\s*&&\s*'1'\s*\|\|\s*''\s*\}\}$/.test(v)
|
||||
: v === '1';
|
||||
if (!ok) problems.push(`jobs.${name}: ${JSON.stringify(v)}`);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
problems,
|
||||
[],
|
||||
`every job running run-tests.cjs must wire the guard to strict mode `
|
||||
+ `(Windows matrices carved out). Derived jobs: ${JSON.stringify(suiteJobs)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -389,6 +389,9 @@ test('installer copies plugin as .js, records it in the manifest, and removes it
|
||||
const run = (args) => {
|
||||
const result = runNode([installer, '--opencode', '--global', '--config-dir', cfg, ...args], {
|
||||
timeoutMs: 120000,
|
||||
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
|
||||
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
|
||||
env: require('./helpers.cjs').installSpawnEnv(),
|
||||
});
|
||||
result.status = result.exitCode;
|
||||
return result;
|
||||
|
||||
@@ -135,6 +135,27 @@ describe('#2858 — shipped scripts require only shipped paths', () => {
|
||||
.sort();
|
||||
});
|
||||
|
||||
test('#2665: the test-instrumentation chain does not ship', () => {
|
||||
// These four are one closed require chain of test instrumentation
|
||||
// (run-tests -> live-config-guard, affected-tests-lib -> run-tests,
|
||||
// run-affected-tests -> affected-tests-lib). Excluding a strict subset
|
||||
// re-trips the shipped-requires-only-shipped gate above on whichever links
|
||||
// still ship, so the exclusion set and this assertion cover the chain.
|
||||
const TEST_INSTRUMENTATION = [
|
||||
'scripts/live-config-guard.cjs',
|
||||
'scripts/run-tests.cjs',
|
||||
'scripts/affected-tests-lib.cjs',
|
||||
'scripts/run-affected-tests.cjs',
|
||||
];
|
||||
for (const f of TEST_INSTRUMENTATION) {
|
||||
assert.ok(
|
||||
!shippedFiles.has(f),
|
||||
`${f} is test instrumentation and must not ship — restore its ` +
|
||||
"package.json files[] '!'-exclusion (and keep the whole chain excluded)",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every shipped scripts/*.{cjs,js} is require-able from a shipped-only tree', () => {
|
||||
assert.ok(shippedScripts.length > 0, 'expected at least one shipped script');
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
runGsdTools,
|
||||
createTempProject,
|
||||
createTempGitProject,
|
||||
cleanup,
|
||||
withIsolatedProcessState,
|
||||
} = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
PROFILING_QUESTIONS,
|
||||
@@ -184,6 +190,50 @@ describe('write-profile command', () => {
|
||||
assert.strictEqual(out.profile_path, path.join(codexHome, 'gsd-core', 'USER-PROFILE.md'));
|
||||
});
|
||||
|
||||
test('#2665: ambient CLAUDE_CONFIG_DIR cannot escape a HOME-only sandbox', () => {
|
||||
// The defect: TEST_ENV_BASE blanked session-identity vars but none of the
|
||||
// config-LOCATION vars, and the dot-home resolver is env-first. So an
|
||||
// ambient CLAUDE_CONFIG_DIR in the DEVELOPER'S shell beat `{ HOME: tmpDir }`
|
||||
// and the suite wrote into their real config directory. Setting it on the
|
||||
// PARENT process is the actual vector — passing it in the per-call env
|
||||
// argument would test nothing, because that path was never broken.
|
||||
const analysis = {
|
||||
profile_version: '1.0',
|
||||
dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } },
|
||||
};
|
||||
const analysisPath = path.join(tmpDir, 'analysis.json');
|
||||
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
|
||||
|
||||
const ambientConfigDir = path.join(tmpDir, 'ambient-live-config');
|
||||
fs.mkdirSync(ambientConfigDir, { recursive: true });
|
||||
|
||||
const out = withIsolatedProcessState(() => {
|
||||
process.env.CLAUDE_CONFIG_DIR = ambientConfigDir;
|
||||
const result = runGsdTools(
|
||||
['write-profile', '--input', analysisPath, '--raw'],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir }
|
||||
);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
return JSON.parse(result.output);
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(
|
||||
fs.readdirSync(ambientConfigDir),
|
||||
[],
|
||||
'a call site that sandboxes HOME must not write into an ambient CLAUDE_CONFIG_DIR'
|
||||
);
|
||||
// Containment via path.relative, not startsWith: startsWith(ambientConfigDir)
|
||||
// also matches a SIBLING like `<tmp>/ambient-live-config-2`, so it can report
|
||||
// a false leak. A path is inside the dir iff the relative path neither escapes
|
||||
// with '..' nor is absolute.
|
||||
const rel = path.relative(ambientConfigDir, out.profile_path);
|
||||
assert.ok(
|
||||
rel.startsWith('..') || path.isAbsolute(rel),
|
||||
`profile must not resolve under the ambient config dir, got: ${out.profile_path}`
|
||||
);
|
||||
});
|
||||
|
||||
test('errors when --input is missing', () => {
|
||||
const result = runGsdTools('write-profile --raw', tmpDir);
|
||||
assert.ok(!result.success, 'should fail without --input');
|
||||
|
||||
@@ -48,28 +48,11 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const FIXTURES_ROOT = path.join(__dirname, 'fixtures', 'representative');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...args], {
|
||||
|
||||
@@ -22,7 +22,7 @@ const path = require('path');
|
||||
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, CONFIG_LOCATION_ENV_KEYS } = require('./helpers.cjs');
|
||||
|
||||
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
|
||||
|
||||
@@ -1412,10 +1412,17 @@ describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
|
||||
* We test the identical logic paths using a tiny timeout.
|
||||
*/
|
||||
function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) {
|
||||
// The session-identity subset stays a local literal on purpose: this helper
|
||||
// mirrors the production one to prove its CONTRACT, so it must not simply
|
||||
// re-import what it is testing. The config-LOCATION keys are the exception —
|
||||
// they are a safety scrub rather than part of the contract under test, and a
|
||||
// hand-copied list of them is the #2665 drift this change exists to end. So
|
||||
// spread the canonical derived set (tests/helpers.cjs) and keep the rest local.
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
...Object.fromEntries(CONFIG_LOCATION_ENV_KEYS.map((k) => [k, ''])),
|
||||
};
|
||||
try {
|
||||
let result;
|
||||
|
||||
@@ -26,7 +26,7 @@ const { resolveRuntimeArtifactLayout, findInstallSourceRoot } = require('../gsd-
|
||||
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { install } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
@@ -687,6 +687,7 @@ describe('#1477 .gsd-source marker provisioning', () => {
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let savedTestMode;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
function silenceConsole(fn) {
|
||||
const orig = { log: console.log, warn: console.warn, error: console.error };
|
||||
@@ -732,6 +733,12 @@ describe('#1477 .gsd-source marker provisioning', () => {
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
savedTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
// #2665: this block calls install(true, 'claude') IN-PROCESS. Redirecting
|
||||
// HOME/USERPROFILE is not enough, because getGlobalConfigDir is env-FIRST:
|
||||
// an ambient CLAUDE_CONFIG_DIR wins over the fixture and a full global
|
||||
// install lands in the developer's live config dir. Found by the post-suite
|
||||
// hermeticity guard (scripts/live-config-guard.cjs), not by inspection.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -743,6 +750,7 @@ describe('#1477 .gsd-source marker provisioning', () => {
|
||||
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
@@ -938,6 +946,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let savedTestMode;
|
||||
let restoreConfigLocationEnv;
|
||||
|
||||
// Run install() with process.exit and console output mocked via t.mock (auto-restored),
|
||||
// per CONTRIBUTING.md test rules (no manual monkeypatch / try-finally in test bodies).
|
||||
@@ -962,6 +971,13 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
savedTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
// #2665: same in-process hazard as the block above. This one calls
|
||||
// install(true, 'claude') via runInstall(), and HOME/USERPROFILE alone do
|
||||
// not contain it — getGlobalConfigDir is env-FIRST, so an ambient
|
||||
// CLAUDE_CONFIG_DIR beats the fixture, the install lands in the developer's
|
||||
// live config dir, and these tests then fail looking for a marker under
|
||||
// tmpRoot that was never written there.
|
||||
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -973,6 +989,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
|
||||
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
restoreConfigLocationEnv();
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user