mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
test: pin DISABLE_AUTOUPDATER in hermetic env and capture corrupt-seed warning
Both EVALS_HERMETIC branches of buildHermeticEnv now carry DISABLE_AUTOUPDATER=1 (the allowlist scrubbed the workflow's copy, so every PTY screen showed the updater's npm-prefix failure). Per-test overrides still win. The corrupt durations-seed test now captures its expected warning and restores the console spy.
This commit is contained in:
1 parent
dcaea52800
commit
049fdc315b
4 files changed
+33
-8
No files matched your search
@@ -3,7 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Pins three contracts:
|
* Pins three contracts:
|
||||||
* 1. Allowlist semantics: contamination vars dropped, basics/auth/network
|
* 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.
|
* 2. Seed-config shape: 20-char key suffix, trusted dirs, undefined-key safe.
|
||||||
* 3. Dir lifecycle: /.claude suffix (extractPlanFilePath contract —
|
* 3. Dir lifecycle: /.claude suffix (extractPlanFilePath contract —
|
||||||
* claude-pty-runner.ts:191), sync singleton reuse, pid-aware GC.
|
* claude-pty-runner.ts:191), sync singleton reuse, pid-aware GC.
|
||||||
@@ -167,7 +168,7 @@ describe('buildHermeticEnv allowlist', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('EVALS_HERMETIC=0 escape hatch', () => {
|
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 base = { ...CONTAMINATED, EVALS_HERMETIC: '0' } as NodeJS.ProcessEnv;
|
||||||
const e = buildHermeticEnv(base, HERMETIC_VARS, { GSTACK_HEADLESS: '1' });
|
const e = buildHermeticEnv(base, HERMETIC_VARS, { GSTACK_HEADLESS: '1' });
|
||||||
// Legacy spread: every base var survives, hermeticVars NOT applied.
|
// 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.CLAUDE_CONFIG_DIR).toBe('/Users/op/.claude');
|
||||||
expect(e.GSTACK_HOME).toBe('/Users/op/.gstack');
|
expect(e.GSTACK_HOME).toBe('/Users/op/.gstack');
|
||||||
expect(e.GSTACK_HEADLESS).toBe('1');
|
expect(e.GSTACK_HEADLESS).toBe('1');
|
||||||
expect(e).toEqual({ ...(base as Record<string, string>), GSTACK_HEADLESS: '1' });
|
expect(e).toEqual({ ...(base as Record<string, string>), DISABLE_AUTOUPDATER: '1', GSTACK_HEADLESS: '1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isHermeticEnabled reads at call time (ESM-hoist safety)', () => {
|
test('isHermeticEnabled reads at call time (ESM-hoist safety)', () => {
|
||||||
|
|||||||
@@ -21,11 +21,15 @@
|
|||||||
* └─────────────────────────────┘
|
* └─────────────────────────────┘
|
||||||
* + per-runner extraAllow (codex: OpenAI vars; gemini: Google vars)
|
* + per-runner extraAllow (codex: OpenAI vars; gemini: Google vars)
|
||||||
* + CLAUDE_CONFIG_DIR=<runRoot>/.claude GSTACK_HOME=<runRoot>/gstack-home
|
* + CLAUDE_CONFIG_DIR=<runRoot>/.claude GSTACK_HOME=<runRoot>/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
|
* + per-test overrides spread LAST
|
||||||
*
|
*
|
||||||
* Escape hatch: EVALS_HERMETIC=0 restores the legacy contaminated env
|
* Escape hatch: EVALS_HERMETIC=0 restores the legacy contaminated env
|
||||||
* byte-identically (runners must also gate --strict-mcp-config on
|
* plus only the DISABLE_AUTOUPDATER pin (runners must also gate
|
||||||
* isHermeticEnabled() so the escape hatch restores args too).
|
* --strict-mcp-config on isHermeticEnabled() so the escape hatch restores
|
||||||
|
* args too).
|
||||||
*
|
*
|
||||||
* isHermeticEnabled() is evaluated at CALL time, never at module load —
|
* isHermeticEnabled() is evaluated at CALL time, never at module load —
|
||||||
* ESM hoists imports above any in-file `process.env.EVALS_HERMETIC = '0'`
|
* ESM hoists imports above any in-file `process.env.EVALS_HERMETIC = '0'`
|
||||||
@@ -100,9 +104,10 @@ export function buildHermeticEnv(
|
|||||||
opts?: HermeticEnvOpts,
|
opts?: HermeticEnvOpts,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
if (!isHermeticEnabled(base)) {
|
if (!isHermeticEnabled(base)) {
|
||||||
// Escape hatch: byte-identical to the legacy spread.
|
// Escape hatch: the legacy spread plus the updater pin.
|
||||||
const legacy: Record<string, string> = {};
|
const legacy: Record<string, string> = {};
|
||||||
for (const [k, v] of Object.entries(base)) if (v !== undefined) legacy[k] = v;
|
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;
|
for (const [k, v] of Object.entries(overrides ?? {})) if (v !== undefined) legacy[k] = v;
|
||||||
return legacy;
|
return legacy;
|
||||||
}
|
}
|
||||||
@@ -127,6 +132,7 @@ export function buildHermeticEnv(
|
|||||||
if (allowed) out[k] = v;
|
if (allowed) out[k] = v;
|
||||||
}
|
}
|
||||||
if (!out.TERM) out.TERM = 'xterm-256color';
|
if (!out.TERM) out.TERM = 'xterm-256color';
|
||||||
|
out.DISABLE_AUTOUPDATER = '1';
|
||||||
Object.assign(out, hermeticVars);
|
Object.assign(out, hermeticVars);
|
||||||
for (const [k, v] of Object.entries(overrides ?? {})) if (v !== undefined) out[k] = v;
|
for (const [k, v] of Object.entries(overrides ?? {})) if (v !== undefined) out[k] = v;
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { describe, test, expect } from 'bun:test';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
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, '..', '..');
|
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()', () => {
|
test('claude runners gate --strict-mcp-config on isHermeticEnabled()', () => {
|
||||||
// Zero MCP servers for hermetic children; EVALS_HERMETIC=0 must restore
|
// Zero MCP servers for hermetic children; EVALS_HERMETIC=0 must restore
|
||||||
// operator MCP along with the operator env (the flag may not be
|
// operator MCP along with the operator env (the flag may not be
|
||||||
|
|||||||
@@ -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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
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');
|
fs.writeFileSync(seedPath, '{ definitely not json');
|
||||||
const prev = process.env.GSTACK_FREE_TEST_DURATIONS;
|
const prev = process.env.GSTACK_FREE_TEST_DURATIONS;
|
||||||
process.env.GSTACK_FREE_TEST_DURATIONS = seedPath;
|
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 {
|
try {
|
||||||
expect(loadFreeTestDurations()).toBeNull();
|
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).
|
// Missing file: silent null (fresh checkouts are normal).
|
||||||
process.env.GSTACK_FREE_TEST_DURATIONS = path.join(dir, 'missing.json');
|
process.env.GSTACK_FREE_TEST_DURATIONS = path.join(dir, 'missing.json');
|
||||||
expect(loadFreeTestDurations()).toBeNull();
|
expect(loadFreeTestDurations()).toBeNull();
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
// Valid seed round-trips, non-numeric entries dropped.
|
// Valid seed round-trips, non-numeric entries dropped.
|
||||||
fs.writeFileSync(seedPath, JSON.stringify({ version: 1, durations: { 'test/a.test.ts': 42, bad: 'nope' } }));
|
fs.writeFileSync(seedPath, JSON.stringify({ version: 1, durations: { 'test/a.test.ts': 42, bad: 'nope' } }));
|
||||||
process.env.GSTACK_FREE_TEST_DURATIONS = seedPath;
|
process.env.GSTACK_FREE_TEST_DURATIONS = seedPath;
|
||||||
expect(loadFreeTestDurations()).toEqual({ 'test/a.test.ts': 42 });
|
expect(loadFreeTestDurations()).toEqual({ 'test/a.test.ts': 42 });
|
||||||
} finally {
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
if (prev === undefined) delete process.env.GSTACK_FREE_TEST_DURATIONS;
|
if (prev === undefined) delete process.env.GSTACK_FREE_TEST_DURATIONS;
|
||||||
else process.env.GSTACK_FREE_TEST_DURATIONS = prev;
|
else process.env.GSTACK_FREE_TEST_DURATIONS = prev;
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
|||||||
Reference in new issue
Block a user