fix(#2665): derive the config-location scrub set from the capability registry

TEST_ENV_BASE listed three config-location vars by hand. The resolver reaches
25: every runtime descriptor's configHome.env, plus GROK_AGENTS_HOME (a hardcoded
branch of getGlobalConfigDir), GSD_RUNTIME, and GSD_PROJECT/GSD_WORKSTREAM
(planningDir). A hand-written list can only ever be as complete as the author's
recall, and every one of those resolvers is env-FIRST, so a missing key is a live
escape hatch rather than a cosmetic gap -- which is why this bug has now been
diagnosed three times.

Derive the set from the same registry the resolver reads. The scrub list becomes
structurally incapable of being narrower than the surface it guards: adding a
capability that declares a new configHome env var extends it in the same commit.

Also export TEST_ENV_BASE (a parity test could not previously import the
canonical copy) and add scrubConfigLocationEnv(), the in-process counterpart --
TEST_ENV_BASE only ever reaches child processes.

Addresses review findings: Blocker 2, Major 4 (GSD_WORKSTREAM/GSD_PROJECT),
Major 5 (XDG_CONFIG_HOME).
This commit is contained in:
0xdhx
2026-07-28 06:05:00 -05:00
parent 08021b02c0
commit 0f97a26f76

View File

@@ -10,28 +10,91 @@ 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: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
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',
];
// 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.
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
// Config-location vars the registry does NOT carry, 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)
const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
'GROK_AGENTS_HOME',
'GSD_RUNTIME',
'GSD_PROJECT',
'GSD_WORKSTREAM',
];
const CONFIG_LOCATION_ENV_KEYS = [
...new Set([
...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS,
]),
].sort();
const TEST_ENV_BASE = Object.fromEntries(
[...SESSION_IDENTITY_ENV_KEYS, ...CONFIG_LOCATION_ENV_KEYS].map((k) => [k, '']),
);
/**
* 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 = {};
for (const key of CONFIG_LOCATION_ENV_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
return function restoreConfigLocationEnv() {
for (const key of CONFIG_LOCATION_ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
};
}
/**
* Run gsd-tools command.
@@ -762,4 +825,4 @@ 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 };
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, TEST_ENV_BASE, SESSION_IDENTITY_ENV_KEYS, CONFIG_LOCATION_ENV_KEYS, scrubConfigLocationEnv };