diff --git a/test/helpers/hermetic-env.test.ts b/test/helpers/hermetic-env.test.ts index 74901ff83..6d18f7faf 100644 --- a/test/helpers/hermetic-env.test.ts +++ b/test/helpers/hermetic-env.test.ts @@ -3,7 +3,8 @@ * * Pins three contracts: * 1. Allowlist semantics: contamination vars dropped, basics/auth/network - * kept, overrides merge last, EVALS_HERMETIC=0 is byte-identical legacy. + * kept, overrides merge last, EVALS_HERMETIC=0 is the legacy env plus the + * DISABLE_AUTOUPDATER pin. * 2. Seed-config shape: 20-char key suffix, trusted dirs, undefined-key safe. * 3. Dir lifecycle: /.claude suffix (extractPlanFilePath contract — * claude-pty-runner.ts:191), sync singleton reuse, pid-aware GC. @@ -167,7 +168,7 @@ describe('buildHermeticEnv allowlist', () => { }); describe('EVALS_HERMETIC=0 escape hatch', () => { - test('returns byte-identical legacy env, overrides still last', () => { + test('returns the legacy env plus the updater pin, overrides still last', () => { const base = { ...CONTAMINATED, EVALS_HERMETIC: '0' } as NodeJS.ProcessEnv; const e = buildHermeticEnv(base, HERMETIC_VARS, { GSTACK_HEADLESS: '1' }); // Legacy spread: every base var survives, hermeticVars NOT applied. @@ -175,7 +176,7 @@ describe('EVALS_HERMETIC=0 escape hatch', () => { expect(e.CLAUDE_CONFIG_DIR).toBe('/Users/op/.claude'); expect(e.GSTACK_HOME).toBe('/Users/op/.gstack'); expect(e.GSTACK_HEADLESS).toBe('1'); - expect(e).toEqual({ ...(base as Record), GSTACK_HEADLESS: '1' }); + expect(e).toEqual({ ...(base as Record), DISABLE_AUTOUPDATER: '1', GSTACK_HEADLESS: '1' }); }); test('isHermeticEnabled reads at call time (ESM-hoist safety)', () => { diff --git a/test/helpers/hermetic-env.ts b/test/helpers/hermetic-env.ts index 2867fb1e7..cb6c8c1c2 100644 --- a/test/helpers/hermetic-env.ts +++ b/test/helpers/hermetic-env.ts @@ -21,11 +21,15 @@ * └─────────────────────────────┘ * + per-runner extraAllow (codex: OpenAI vars; gemini: Google vars) * + CLAUDE_CONFIG_DIR=/.claude GSTACK_HOME=/gstack-home + * + DISABLE_AUTOUPDATER=1 (pinned in both branches; the scrub drops the + * workflow's copy and every PTY screen otherwise shows the updater's + * "no write permission to npm prefix" failure) * + per-test overrides spread LAST * * Escape hatch: EVALS_HERMETIC=0 restores the legacy contaminated env - * byte-identically (runners must also gate --strict-mcp-config on - * isHermeticEnabled() so the escape hatch restores args too). + * plus only the DISABLE_AUTOUPDATER pin (runners must also gate + * --strict-mcp-config on isHermeticEnabled() so the escape hatch restores + * args too). * * isHermeticEnabled() is evaluated at CALL time, never at module load — * ESM hoists imports above any in-file `process.env.EVALS_HERMETIC = '0'` @@ -100,9 +104,10 @@ export function buildHermeticEnv( opts?: HermeticEnvOpts, ): Record { if (!isHermeticEnabled(base)) { - // Escape hatch: byte-identical to the legacy spread. + // Escape hatch: the legacy spread plus the updater pin. const legacy: Record = {}; for (const [k, v] of Object.entries(base)) if (v !== undefined) legacy[k] = v; + legacy.DISABLE_AUTOUPDATER = '1'; for (const [k, v] of Object.entries(overrides ?? {})) if (v !== undefined) legacy[k] = v; return legacy; } @@ -127,6 +132,7 @@ export function buildHermeticEnv( if (allowed) out[k] = v; } if (!out.TERM) out.TERM = 'xterm-256color'; + out.DISABLE_AUTOUPDATER = '1'; Object.assign(out, hermeticVars); for (const [k, v] of Object.entries(overrides ?? {})) if (v !== undefined) out[k] = v; return out; diff --git a/test/hermetic-wiring.test.ts b/test/hermetic-wiring.test.ts index 78fa96679..f47fa738a 100644 --- a/test/hermetic-wiring.test.ts +++ b/test/hermetic-wiring.test.ts @@ -17,7 +17,7 @@ import { describe, test, expect } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { getHermeticDirs, hermeticSkillsConfigDir } from './helpers/hermetic-env'; +import { buildHermeticEnv, getHermeticDirs, hermeticSkillsConfigDir } from './helpers/hermetic-env'; const ROOT = path.resolve(import.meta.path, '..', '..'); @@ -87,6 +87,17 @@ describe('hermetic wiring tripwire', () => { } }); + test('both EVALS_HERMETIC branches pin DISABLE_AUTOUPDATER=1 over the workflow env', () => { + // The allowlist scrubs the workflow's own copy; without this pin every PTY + // screen carries "Auto-update failed: no write permission to npm prefix". + for (const EVALS_HERMETIC of ['1', '0']) { + const base = { PATH: '/usr/bin', EVALS_HERMETIC, DISABLE_AUTOUPDATER: '0' }; + expect(buildHermeticEnv(base, {}).DISABLE_AUTOUPDATER, `EVALS_HERMETIC=${EVALS_HERMETIC}`).toBe('1'); + expect(buildHermeticEnv(base, {}, { DISABLE_AUTOUPDATER: '0' }).DISABLE_AUTOUPDATER, 'per-test override stays last').toBe('0'); + } + expect(read('test/helpers/hermetic-env.ts')).toContain('DISABLE_AUTOUPDATER=1 (pinned in both branches'); + }); + test('claude runners gate --strict-mcp-config on isHermeticEnabled()', () => { // Zero MCP servers for hermetic children; EVALS_HERMETIC=0 must restore // operator MCP along with the operator env (the flag may not be diff --git a/test/test-free-shards.test.ts b/test/test-free-shards.test.ts index e15c37de5..57a01dd6b 100644 --- a/test/test-free-shards.test.ts +++ b/test/test-free-shards.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from 'bun:test'; +import { describe, test, expect, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -1284,16 +1284,23 @@ describe('test-free-shards: duration-aware packing (full-suite LPT)', () => { fs.writeFileSync(seedPath, '{ definitely not json'); const prev = process.env.GSTACK_FREE_TEST_DURATIONS; process.env.GSTACK_FREE_TEST_DURATIONS = seedPath; + // The corrupt seed's warning is the expected output; keep it off the console. + const warn = spyOn(console, 'error').mockImplementation(() => {}); try { expect(loadFreeTestDurations()).toBeNull(); + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ + expect.stringContaining(`[test:free] WARNING: corrupt durations seed ${seedPath}`), + ]); // Missing file: silent null (fresh checkouts are normal). process.env.GSTACK_FREE_TEST_DURATIONS = path.join(dir, 'missing.json'); expect(loadFreeTestDurations()).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); // Valid seed round-trips, non-numeric entries dropped. fs.writeFileSync(seedPath, JSON.stringify({ version: 1, durations: { 'test/a.test.ts': 42, bad: 'nope' } })); process.env.GSTACK_FREE_TEST_DURATIONS = seedPath; expect(loadFreeTestDurations()).toEqual({ 'test/a.test.ts': 42 }); } finally { + warn.mockRestore(); if (prev === undefined) delete process.env.GSTACK_FREE_TEST_DURATIONS; else process.env.GSTACK_FREE_TEST_DURATIONS = prev; fs.rmSync(dir, { recursive: true, force: true });