diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index c66d1e56b..c60dc671d 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -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 /.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 /.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 /.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 /.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 /.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 /.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, diff --git a/tests/helpers.cjs b/tests/helpers.cjs index e17c57b59..7e224d74a 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -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 -> /.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 diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index b4c5ce580..153a449cb 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -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 + // /.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; } diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 670ab886e..ae8e62133 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -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 /.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 /.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 /.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, diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs index 6d4c11ecc..49b3ff23a 100644 --- a/tests/opencode-plugin-adapter.test.cjs +++ b/tests/opencode-plugin-adapter.test.cjs @@ -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 /.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;