fix(#131): isolate HOME for release-tarball-smoke (before() + runSmoke A-F) (#139)

* fix(#131): pass explicit HOME and npm cache to before() npm invocations

npm reads $HOME/.npmrc (user config) and writes to $HOME/.npm (default
cache dir) unless overridden. On Docker hosts the running user's HOME
may be uninitialized, unwritable, or contain stale state from prior
runs — any of which causes `npm pack` / `npm install -g` in the
before() hook to fail with EACCES, cancelling all 6 subtests (A–F).

Fix: allocate a fresh mkdtemp dir once per test process in helpers.cjs
and inject it as HOME, npm_config_cache, and npm_config_userconfig for
every runNpm() call. A process.on('exit') handler removes the dir on
teardown. The caller-supplied env option (if any) is merged on top of
the isolated env so explicit overrides still win.

TDD: tests/bug-131-release-tarball-smoke-explicit-home.test.cjs
- Test 1: runNpm succeeds when process HOME is chmod-0500 (unwritable)
- Test 2: npm_config_cache resolves under tmpdir, not caller HOME

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): extend HOME isolation to runSmoke spawnSync calls so A-F pass

Pass effectiveNpmEnv to the gsd-sdk --version and gsd-sdk query spawnSync
invocations inside runSmoke(), matching the isolation already applied to the
npm install step. Also add npmEnv: isolatedNpmEnv() to every runSmoke() call
in the install test so the full env isolation chain is in effect.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): address CI feedback — prompt-injection comment, Windows USERPROFILE stub, macOS realpath

- Rephrase 'act as a poisoned HOME' comment to 'serve as a poisoned HOME'
  to avoid triggering the prompt-injection scanner's act-as pattern
- Add paired process.env.USERPROFILE stub alongside process.env.HOME in
  Test 1 inline script so Windows parity guard offender count stays at 8
- Fix macOS /var→/private/var symlink false-negative in Test 2 by resolving
  the nearest existing ancestor with fs.realpathSync before the startsWith
  comparison

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): export isolatedNpmEnv from helpers.cjs (CI repro of missing symbol)

isolatedNpmEnv() was defined in tests/helpers.cjs but never committed —
the function body and the updated module.exports line were left as unstaged
local edits. CI checkouts saw the old module.exports (without isolatedNpmEnv),
causing TypeError: isolatedNpmEnv is not a function at the call site in
bug-131-release-tarball-smoke-explicit-home.test.cjs:178 and in
release-tarball-smoke.install.test.cjs wherever the function is destructured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): canonicalize macOS tmpdir in remaining startsWith assertions

Replace the ad-hoc try/catch realpathSync fallback chain in Test 2 and the
inline try/catch in Test 3 with a shared safeRealpath() helper that walks up
to the nearest existing ancestor before resolving, then reconstructs the
canonical path. This ensures /var→/private/var symlink expansion succeeds
even when the leaf (.npm cache dir) does not yet exist on macOS CI runners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-23 00:24:32 -04:00
committed by GitHub
parent 21c6c29ac0
commit 899c8cff3a
4 changed files with 305 additions and 6 deletions

View File

@@ -282,6 +282,10 @@ function scanWorkflowMissingSdkFallback(filePath) {
* @param {string} [opts.fixtureDir] - Temp dir to run `init` into (must NOT be HOME)
* @param {string[]} [opts.lifecycleCommands] - Commands to file-check (default: see below)
* @param {boolean} [opts.dryRun=false] - If true, skip actual npm install; validate input only
* @param {object} [opts.npmEnv] - Optional env dict for the internal npm install
* spawnSync call. Pass an isolated HOME env (e.g. from isolatedNpmEnv() in tests/helpers.cjs)
* to prevent npm from reading/writing the caller's $HOME — required on Docker hosts where HOME
* may be unwritable. Defaults to process.env. (#131)
* @returns {{ code: string, details: object }}
*/
function runSmoke({
@@ -291,6 +295,7 @@ function runSmoke({
fixtureDir,
lifecycleCommands = ['init', 'discuss-phase', 'plan-phase', 'execute-phase'],
dryRun = false,
npmEnv = undefined,
}) {
const details = {
tarball: tarballPath,
@@ -304,10 +309,14 @@ function runSmoke({
// --- Install the tarball into the temp prefix ----------------------------
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
// Use the caller-supplied npmEnv if provided (allows HOME isolation on Docker
// hosts where HOME may be unwritable — same pattern as runNpm() in helpers.cjs).
// Falls back to process.env to preserve existing CLI / programmatic behaviour. (#131)
const effectiveNpmEnv = npmEnv !== undefined ? npmEnv : process.env;
const installResult = spawnSync(
npmCmd,
['install', '-g', '--prefix', installPrefix, tarballPath],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: CHILD_TIMEOUT_MS },
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: CHILD_TIMEOUT_MS, env: effectiveNpmEnv },
);
if (installResult.status !== 0) {
@@ -335,10 +344,12 @@ function runSmoke({
}
// --- Invoke `gsd-sdk --version` ------------------------------------------
// Use effectiveNpmEnv so the installed binary sees an isolated HOME on Docker
// hosts where HOME may be unwritable (same isolation as the npm install). (#131)
const versionResult = spawnSync(
process.execPath,
[actualBin, '--version'],
{ encoding: 'utf-8', timeout: CHILD_TIMEOUT_MS },
{ encoding: 'utf-8', timeout: CHILD_TIMEOUT_MS, env: effectiveNpmEnv },
);
if (versionResult.status !== 0) {
@@ -497,11 +508,13 @@ function runSmoke({
// ─────────────────────────────────────────────────────────────────────────
// --- Verify `gsd-sdk` query is callable and returns parseable JSON -------
// Use effectiveNpmEnv so the installed binary sees an isolated HOME on Docker
// hosts where HOME may be unwritable (same isolation as the npm install). (#131)
const sdkQueryDir = fixtureDir || os.tmpdir();
const sdkQueryResult = spawnSync(
process.execPath,
[actualBin, 'query', 'state.json', '--project-dir', sdkQueryDir],
{ encoding: 'utf-8', timeout: CHILD_TIMEOUT_MS },
{ encoding: 'utf-8', timeout: CHILD_TIMEOUT_MS, env: effectiveNpmEnv },
);
if (sdkQueryResult.status !== 0) {