fix(#3156): sandbox HOME on raw installer spawns — the one leak no scrub reaches

The strict live-config guard this PR ships went red on CI: the suite creates
$HOME/.gsd/defaults.json. Diagnosed rather than suppressed, because the guard
is right — this is #2665's class arriving through the one door the scrub set is
structurally unable to close.

bin/install.js writeNonClaudeDefaults() (#2834) writes
path.join(os.homedir(), '.gsd', 'defaults.json') for every non-Claude runtime.
os.homedir() consults NO GSD variable, so:

  - no entry in CONFIG_LOCATION_ENV_KEYS can reach it, however the set is
    derived; and
  - blanking GSD_HOME does not reach it either -- a blank GSD_HOME falls back
    to exactly that homedir().

Only a sandboxed HOME contains it, and HOME is deliberately excluded from
TEST_ENV_BASE because blanking it would break far more than it fixed. So the
containment belongs per-spawn, which is the discipline the suite already
applies by hand -- install-minimal-hooks.test.cjs:576 carries a comment
naming this exact hazard for the --codex spawn, while the parameterized
--${runtime} spawn 130 lines below it does not. Instance fixed, class open.

Rather than add a fifth hand-synced env shape to a PR whose subject is that
hand-synced copies drift, this adds ONE export -- installSpawnEnv() in
tests/helpers.cjs -- and routes every raw installer spawn through it, including
the shared tests/helpers/install-shared.cjs installerEnv(), which every install
suite already consumes. Callers passing an explicit { HOME, USERPROFILE } are
unaffected: overrides spread last.

Census (measured, not reasoned): of the 119 test files that spawn bin/install.js,
exactly four wrote into a sandboxed $HOME before this commit -- install,
copilot-install, install-minimal-hooks, opencode-plugin-adapter -- and zero do
after. Only install.test.cjs sits in CI's targeted lane, which is why ubuntu
went red on one file while the macOS full lanes went red on four.

Attribution: the leak reproduces unchanged at upstream/next itself, so the
defect is base-owned and pre-existing; only the detector is new. The guard
found a real leak on next within one run.

No new failures: the surviving names under a sandboxed HOME
(folded:enh-2380-sync-skills, getGlobalConfigDir (Copilot)) fail at base too,
and base additionally fails folded:bug-3288-model-catalog-install-path, which
this tree does not.
This commit is contained in:
0xdhx
2026-08-08 05:56:22 -05:00
parent 87f6af282a
commit 35df0891af
5 changed files with 85 additions and 11 deletions

View File

@@ -1362,7 +1362,10 @@ const EXPECTED_AGENTS = listAgentFiles().length;
const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
function runCopilotInstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--no-sdk'], {
cwd,
@@ -1374,7 +1377,10 @@ function runCopilotInstall(cwd) {
}
function runCopilotUninstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--uninstall', '--no-sdk'], {
cwd,
@@ -1673,7 +1679,10 @@ describe('E2E: Copilot uninstall verification', () => {
// ─── E2E: Copilot global scope (#786) ──────────────────────────────────────────
function runCopilotInstallGlobal(cwd, configDir) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode(
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'],
@@ -1684,7 +1693,10 @@ function runCopilotInstallGlobal(cwd, configDir) {
}
function runCopilotUninstallGlobal(cwd, configDir) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode(
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'],
@@ -1734,7 +1746,10 @@ describe('E2E: Copilot global install (#786)', () => {
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
function runClaudeInstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,
@@ -1746,7 +1761,10 @@ function runClaudeInstall(cwd) {
}
function runClaudeUninstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--claude', '--local', '--uninstall', '--no-sdk'], {
cwd,

View File

@@ -911,7 +911,43 @@ 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, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv };
/**
* #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.
*/
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

View File

@@ -520,7 +520,15 @@ function simulateHookCopy(hooksSrc, hooksDest) {
/** Build a clean env for spawned installer processes.
* Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */
function installerEnv(overrides = {}) {
const env = { ...process.env, ...overrides };
// #3156: delegate to the ONE canonical raw-installer-spawn env rather than
// carrying a second shape of it. The installer writes GSD's own user store to
// <home>/.gsd/defaults.json through os.homedir() DIRECTLY
// (bin/install.js writeNonClaudeDefaults, #2834), which reads no GSD variable,
// so no config-location scrub can reach it — only a sandboxed HOME can. Every
// caller that already passes an explicit { HOME, USERPROFILE } still wins:
// overrides spread last.
const { installSpawnEnv } = require('../helpers.cjs');
const env = installSpawnEnv(overrides);
delete env.GSD_TEST_MODE;
return env;
}

View File

@@ -5535,7 +5535,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
@@ -9597,7 +9600,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
@@ -10022,7 +10028,10 @@ const {
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runClaudeLocalInstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,

View File

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