Files
gstack/test/helpers/hermetic-env.ts
T
Garry Tan 730a1017d1 v1.89.1.0 fix: remove continuous checkpoint commits (#2970)
* v1.89.1.0 fix: remove continuous checkpoint commits and repair validation blockers

* fix: clarify shipping and engineering review recovery

* fix: interpret native no-change review descriptions

* test: separate descendant readiness from timeout delivery
2026-09-24 17:27:14 -04:00

538 lines
25 KiB
TypeScript

/**
* Hermetic child environment for E2E test runners.
*
* Local E2E runs spawn `claude` (and codex/gemini/SDK) children that, until
* this module, inherited the operator's full session context: ~/.claude
* (user CLAUDE.md, .claude.json MCP servers incl. gbrain + Conductor,
* skills), ~/.gstack decision logs, and CONDUCTOR_-/CLAUDECODE-style env vars.
* CI was hermetic only by accident (fresh Docker /home/runner). This module
* makes local children see a CI-equivalent clean room by default.
*
* operator shell (contaminated) hermetic child env
* ┌─────────────────────────────┐ buildHermeticEnv()
* │ PATH, HOME, TMPDIR, ... │── allowlist ─────────► kept
* │ HTTP(S)_PROXY, SSL_CERT_* │── allowlist ─────────► kept (network)
* │ ANTHROPIC_API_KEY/BASE_URL/ │── named list ────────► kept (auth)
* │ AUTH_TOKEN │
* │ GSTACK_ANTHROPIC_API_KEY │── promotedEnv() ─────► ANTHROPIC_API_KEY
* │ CONDUCTOR_*, CLAUDECODE, │
* │ CLAUDE_*, GSTACK_*, MCP_*, │── dropped ───────────► ∅
* │ GBRAIN_*, GH_TOKEN, ... │
* └─────────────────────────────┘
* + per-runner extraAllow (codex: OpenAI vars; gemini: Google vars)
* + CLAUDE_CONFIG_DIR=<runRoot>/.claude GSTACK_HOME=<runRoot>/gstack-home
* + 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).
*
* isHermeticEnabled() is evaluated at CALL time, never at module load —
* ESM hoists imports above any in-file `process.env.EVALS_HERMETIC = '0'`
* assignment, so a module-load-time read would silently ignore test pins.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { promotedEnv } from '../../lib/conductor-env-shim';
import { execFileSync } from 'node:child_process';
import { isProcessAlive, safeUnlink } from '../../lib/error-handling';
import { skillCensus, frontmatterName } from './skill-census';
/** Exact env names a hermetic child keeps. Everything not listed (or matched
* by a prefix rule below) is dropped. */
const ALLOW_EXACT = new Set([
// Process basics
'PATH', 'HOME', 'TMPDIR', 'TERM', 'COLORTERM', 'LANG', 'LC_ALL', 'SHELL',
'USER', 'LOGNAME', 'TZ', 'NODE_ENV', 'CI',
// Browser/runtime caches the child legitimately shares with the operator
'PLAYWRIGHT_BROWSERS_PATH',
// Network reachability — without these, children on proxied networks can't
// reach the Anthropic API at all
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
'http_proxy', 'https_proxy', 'no_proxy',
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
// Auth — named, NOT the broad ANTHROPIC_* prefix: a prefix rule would
// smuggle model/beta/debug knobs that change eval behavior
'ANTHROPIC_API_KEY', // the auth credential evals require
'ANTHROPIC_BASE_URL', // API endpoint override (corp proxies)
'ANTHROPIC_AUTH_TOKEN', // bearer-token auth variant
]);
/** Prefix rules: eval-harness knobs + CI metadata. Deliberately NOT here:
* CONDUCTOR_* / CLAUDE_* (incl. CLAUDECODE, CLAUDE_CODE_ENTRYPOINT) /
* GSTACK_* / MCP_* / GBRAIN_* — session-context contamination; and operator
* credentials (GH_TOKEN, GITHUB_*_TOKEN, SSH_AUTH_SOCK, GIT_*, OPENAI_API_KEY,
* VOYAGE_API_KEY) — CI doesn't have them and eval children have no business
* using them. A test that legitimately needs one opts in via its own env
* override; a provider runner (codex/gemini) re-admits its auth vars via
* opts.extraAllow. Prefix matches reject credential-shaped suffixes; exact
* and explicit runner admissions still win. */
const ALLOW_PREFIXES = ['EVALS_', 'GITHUB_'];
const CREDENTIAL_SUFFIXES = new Set([
'KEY', 'KEYS', 'TOKEN', 'TOKENS', 'SECRET', 'SECRETS', 'PASSWORD', 'PASSWD',
'PASS', 'CREDENTIAL', 'CREDENTIALS', 'AUTH', 'PAT', 'DSN', 'COOKIE',
'SESSION', 'PRIVATE',
]);
export interface HermeticEnvOpts {
/** Per-runner additional allowed names (exact match) or prefixes (entries
* ending in '*'). Example: codex runner passes ['OPENAI_API_KEY', 'CODEX_*']. */
extraAllow?: string[];
}
/** EVALS_HERMETIC !== '0'. Read at call time (see module doc — ESM hoist). */
export function isHermeticEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.EVALS_HERMETIC !== '0';
}
/**
* Pure allowlist scrub. No I/O. Overrides spread LAST so per-test env
* (GSTACK_HOME, CONDUCTOR_WORKSPACE_PATH, GSTACK_HEADLESS opt-out) always
* wins over the scrub — that is the documented re-contamination escape and
* the wiring tripwire forbids passing raw process.env through it.
*/
export function buildHermeticEnv(
base: NodeJS.ProcessEnv,
hermeticVars: Record<string, string>,
overrides?: Record<string, string | undefined>,
opts?: HermeticEnvOpts,
): Record<string, string> {
if (!isHermeticEnabled(base)) {
// Escape hatch: byte-identical to the legacy spread.
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(overrides ?? {})) if (v !== undefined) legacy[k] = v;
return legacy;
}
const promoted = promotedEnv(base);
const extraExact = new Set<string>();
const extraPrefixes: string[] = [];
for (const entry of opts?.extraAllow ?? []) {
if (entry.endsWith('*')) extraPrefixes.push(entry.slice(0, -1));
else extraExact.add(entry);
}
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(promoted)) {
if (v === undefined) continue;
const allowed =
ALLOW_EXACT.has(k) ||
extraExact.has(k) ||
(ALLOW_PREFIXES.some((p) => k.startsWith(p)) &&
!CREDENTIAL_SUFFIXES.has(k.slice(k.lastIndexOf('_') + 1).toUpperCase())) ||
extraPrefixes.some((p) => k.startsWith(p));
if (allowed) out[k] = v;
}
if (!out.TERM) out.TERM = 'xterm-256color';
Object.assign(out, hermeticVars);
for (const [k, v] of Object.entries(overrides ?? {})) if (v !== undefined) out[k] = v;
return out;
}
export interface SeedConfigOpts {
/** When undefined (operator has no key exported), customApiKeyResponses is
* omitted — the child fails auth exactly as it would today, no throw here. */
apiKey: string | undefined;
trustedDirs: string[];
}
/**
* Minimal $CLAUDE_CONFIG_DIR/.claude.json for fresh-config children.
*
* Empirically verified 2026-06-12 on claude 2.1.175: PRINT MODE (`claude -p`)
* with ANTHROPIC_API_KEY needs NO seed at all — a fresh empty config dir ran
* non-interactively (exit 0, real cost billed to the key). The seed exists
* for the PTY path, where first-run TUI prompts DO appear:
* - hasCompletedOnboarding: suppresses the onboarding flow
* - customApiKeyResponses.approved: suppresses the "use this API key?"
* prompt; entries are the key's LAST 20 CHARS (shape verified against a
* real ~/.claude.json)
* - projects[dir].hasTrustDialogAccepted: pre-trusts repo-cwd PTY sessions
* (the pty-runner's 15s trust-watcher remains as fallback for temp cwds)
* bypassPermissionsModeAccepted was considered and dropped: absent from a
* real config even though --dangerously-skip-permissions is in daily use.
*/
export function buildSeedConfig(opts: SeedConfigOpts): Record<string, unknown> {
const seed: Record<string, unknown> = {
hasCompletedOnboarding: true,
diffSidebarOpen: false,
projects: Object.fromEntries(
opts.trustedDirs.map((dir) => [
dir,
{ hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
]),
),
};
if (opts.apiKey) {
seed.customApiKeyResponses = { approved: [opts.apiKey.slice(-20)] };
}
return seed;
}
export interface HermeticDirs {
/** Ends in `/.claude` — load-bearing: extractPlanFilePath in
* claude-pty-runner.ts:191 anchors plan-file paths on `.claude/plans/`
* under a /var|/tmp prefix. Renaming this segment breaks PTY plan tests. */
configDir: string;
gstackHome: string;
runRoot: string;
}
const DIR_PREFIX = 'gstack-hermetic-';
let cachedDirs: HermeticDirs | null = null;
/** Repo root for the trusted-dir seed: test files live in <root>/test/helpers. */
function repoRoot(): string {
return path.resolve(__dirname, '..', '..');
}
/** Seed a private, existing empty directory owned by the calling test.
* Never apply this automatically to a caller-supplied GSTACK_HOME: tests for
* onboarding and upgrades intentionally supply their own state. The caller
* owns cleanup if a write fails. Refuse existing state or a symlink root so
* this helper cannot silently reset operator configuration.
*/
export function seedHermeticGstackHome(gstackHome: string): void {
const existing = fs.lstatSync(gstackHome, { throwIfNoEntry: false });
if (!existing?.isDirectory() || fs.readdirSync(gstackHome).length !== 0) {
throw new Error('Hermetic GStack seed requires a private, existing empty directory');
}
// Seed one-time onboarding markers into the CHILD's GSTACK_HOME.
// bin/gstack-skill-start reads ${GSTACK_HOME:-$HOME/.gstack} (EOV7), so
// the operator-HOME seeding in e2e-helpers.ts no longer reaches hermetic
// children — without these, the emission layer fires lake-intro/telemetry
// prompts that burn turns and can stall PTY tests waiting on an answer.
// Tests that exercise onboarding itself override GSTACK_HOME per-test.
for (const f of [
'.activated',
'.completeness-intro-seen',
'.telemetry-prompted',
'.proactive-prompted',
'.first-loop-tip-shown',
'.feature-prompted-model-overlay',
]) {
fs.writeFileSync(path.join(gstackHome, f), '');
}
// The privacy stop-gate is config-keyed, not marker-keyed: on machines
// with gbrain installed it fires whenever artifacts_sync_mode is off and
// the consent prompt is unrecorded — same PTY-stall class as the markers.
// HOME still exposes the operator's installed runtime to literal skill
// preambles. Its older VERSION or update cache must not turn a scope-gate
// eval into an upgrade prompt. Update-flow tests opt in with their own
// GSTACK_HOME config through the existing per-test override.
fs.writeFileSync(path.join(gstackHome, 'config.yaml'), 'artifacts_sync_mode_prompted: true\nupdate_check: false\n');
}
/**
* Sync memoized per-process singleton — intentionally NO async gap between
* the cache check and create+seed, so concurrent first calls under
* `bun test --concurrent` cannot double-create or observe a half-seeded dir.
* Shared across all tests in the process: that matches CI's within-job
* shared /home/runner (operator isolation, not per-test isolation).
*/
export function getHermeticDirs(): HermeticDirs {
if (cachedDirs) return cachedDirs;
gcStaleHermeticDirs();
// Embed our pid so the GC of future processes can check liveness.
const runRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${DIR_PREFIX}${process.pid}-`));
const configDir = path.join(runRoot, '.claude');
const gstackHome = path.join(runRoot, 'gstack-home');
// A half-seeded config dir means children hang on first-run prompts until
// the test timeout — far worse than failing loudly here. So we throw on
// failure, but tear down the partial dir first: an unseeded runRoot named
// with our (alive) pid would be skipped by this process's GC and leak until
// process exit, so remove it before rethrowing.
try {
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(gstackHome, { recursive: true });
const seed = buildSeedConfig({
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
trustedDirs: [repoRoot()],
});
fs.writeFileSync(path.join(configDir, '.claude.json'), JSON.stringify(seed, null, 2));
seedHermeticGstackHome(gstackHome);
} catch (err) {
try { fs.rmSync(runRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err;
}
process.on('exit', () => {
// Exit handlers cannot await: sync best-effort removal only. Anything
// left behind is reclaimed by the next process's pid-aware GC.
try { fs.rmSync(runRoot, { recursive: true, force: true }); } catch { /* GC reclaims */ }
});
cachedDirs = { configDir, gstackHome, runRoot };
return cachedDirs;
}
/** The caller owns a private fixture and its generated artifact subtree.
* Only the two fixed wrappers below choose the admitted fixture and file types. */
function hermeticArtifactReadArgs(cwd: string, childEnv: Record<string, string>, scopeSpec: {
label: string; fixturePattern: RegExp; fixtureDescription: string;
artifactDirectory: string; filePattern: string;
}): string[] {
const { label, fixturePattern, fixtureDescription, artifactDirectory, filePattern } = scopeSpec;
if (!isHermeticEnabled()) throw new Error(`${label} artifact Read requires hermetic mode`);
const dirs = getHermeticDirs();
const fixture = path.resolve(cwd);
const slug = path.basename(fixture);
if (childEnv.GSTACK_HOME !== dirs.gstackHome || childEnv.GSTACK_PROJECT_SLUG
|| !fixturePattern.test(slug)
|| !fs.lstatSync(fixture).isDirectory()
|| fs.realpathSync(path.dirname(fixture)) !== fs.realpathSync(os.tmpdir())) {
throw new Error(`${label} artifact Read requires this ${fixtureDescription} and hermetic home`);
}
for (const directory of [dirs.runRoot, dirs.gstackHome, path.join(fixture, '.git')]) {
if (!fs.lstatSync(directory).isDirectory()) throw new Error(`${label} artifact Read refuses substituted directories`);
}
// Use the same native slug resolver before granting anything; never allow
// an ancestor project or a foreign remote to redirect this fixture's scope.
const resolved = execFileSync('bash', [path.join(repoRoot(), 'bin', 'gstack-slug')], {
cwd: fixture, env: childEnv, encoding: 'utf8', timeout: 10_000,
}).match(/^SLUG=([^\r\n]+)$/m)?.[1];
if (resolved !== slug) throw new Error(`${label} artifact Read requires the exact fixture project slug`);
const project = path.join(dirs.gstackHome, 'projects', slug);
const scope = path.join(project, artifactDirectory);
for (const directory of [path.dirname(project), project, scope]) {
const existing = fs.lstatSync(directory, { throwIfNoEntry: false });
if (existing && !existing.isDirectory()) throw new Error(`${label} artifact Read refuses substituted directories`);
if (!existing) fs.mkdirSync(directory, { mode: 0o700 });
}
const scopes = new Set([scope, fs.realpathSync(scope)]);
const rules = [...scopes].map(directory => {
const absolute = directory.split(path.sep).join('/');
if (/[\x00-\x1f\x7f\\*?\[\]{}()|+^$,]/.test(absolute)) {
throw new Error(`${label} artifact path contains unsupported permission-pattern syntax`);
}
return `Read(${absolute.startsWith('/') ? '/' : ''}${absolute}/${filePattern})`;
});
return ['--allowedTools', ...rules];
}
/** Only split's own generated CEO review documents need child-agent Read. */
export function hermeticCeoPlanReadArgs(cwd: string, childEnv: Record<string, string>): string[] {
return hermeticArtifactReadArgs(cwd, childEnv, { label: 'CEO',
fixturePattern: /^gstack-e2e-plan-ceo-split-overflow-[A-Za-z0-9]+$/, fixtureDescription: 'private split fixture',
artifactDirectory: 'ceo-plans', filePattern: '*.md' });
}
/** Only the two Design fixtures may read their own generated PNG mockups.
* No operator-home, other-project, document, shell or write permission is added. */
export function hermeticDesignReadArgs(cwd: string, childEnv: Record<string, string>): string[] {
return hermeticArtifactReadArgs(cwd, childEnv, { label: 'Design',
fixturePattern: /^(?:gstack-e2e-plan-design-|design-ui-project-)[A-Za-z0-9]+$/, fixtureDescription: 'private Design fixture',
artifactDirectory: 'designs', filePattern: '*/*.png' });
}
let cachedSkillsConfigDir: string | null = null;
/**
* Canonical paths without exposing the checkout to Claude's recursive markdown
* file index. Directory links would also expose .context, dependencies, and
* generated host registries. Expand owned runtime directories and link files
* individually, preserving their source realpaths and executable bits.
*/
export function seedHermeticRuntimeView(root: string, destination: string): void {
const sourceRoot = fs.realpathSync(root);
const excluded = new Set(['node_modules', 'test', 'tests']);
const roots = new Set([
'SKILL.md', 'ETHOS.md', 'VERSION', 'package.json', 'bin', 'lib', 'scripts',
'browse', 'browser-skills', 'design', 'extension', 'model-overlays', 'agents',
// On-demand protocol/reference files explicitly read by shipped skills.
'docs/askuserquestion-split.md', 'docs/askuserquestion-cjk.md',
'docs/designs/PLAN_TUNING_V0.md', 'docs/designs/PLAN_TUNING_V1.md',
...skillCensus(root).physicalSkillFiles.filter(file => file !== 'SKILL.md').map(file => path.dirname(file)),
]);
const allowed = [...roots].filter(name => fs.existsSync(path.join(root, name))).map(name => ({
path: fs.realpathSync(path.join(root, name)), directory: fs.statSync(path.join(root, name)).isDirectory(),
}));
function link(source: string, target: string, ancestors: Set<string>): void {
const real = fs.realpathSync(source);
if (real !== sourceRoot && !real.startsWith(sourceRoot + path.sep))
throw new Error(`Runtime asset leaves the owned checkout: ${source}`);
if (path.relative(sourceRoot, real).split(path.sep).some(name =>
name.startsWith('.') || excluded.has(name) || name.endsWith('.tmpl')))
throw new Error(`Runtime asset resolves into an excluded tree: ${source}`);
if (real !== sourceRoot && !allowed.some(asset => real === asset.path ||
asset.directory && real.startsWith(asset.path + path.sep)))
throw new Error(`Runtime asset resolves into an excluded tree: ${source}`);
const stat = fs.statSync(source);
if (stat.isDirectory()) {
if (ancestors.has(real)) throw new Error(`Circular runtime asset: ${source}`);
fs.mkdirSync(target);
const next = new Set([...ancestors, real]);
for (const name of fs.readdirSync(source)) {
if (name.startsWith('.') || excluded.has(name) || name.endsWith('.tmpl')) continue;
link(path.join(source, name), path.join(target, name), next);
}
} else if (stat.isFile()) {
fs.symlinkSync(source, target, 'file');
}
}
fs.mkdirSync(destination);
try {
for (const name of roots) {
const source = path.join(root, name);
if (fs.existsSync(source)) {
const target = path.join(destination, name);
fs.mkdirSync(path.dirname(target), { recursive: true });
link(source, target, new Set([sourceRoot]));
}
}
} catch (error) {
fs.rmSync(destination, { recursive: true, force: true });
throw error;
}
}
/**
* A hermetic CLAUDE_CONFIG_DIR with the repo's shipped skills REGISTERED in
* user scope, mirroring ./setup's registration exactly: each discovered skill
* gets a REAL directory `<configDir>/skills/<registryName>/` containing a
* SYMLINK to that skill's SKILL.md (absolute path), plus symlinks to its
* runtime assets using setup's exclusions. An owned runtime view also
* lives at `<configDir>/skills/gstack`, so canonical
* lazy-section paths work alongside flattened discovery. registryName is the frontmatter `name:`
* (dir-name fallback), NO gstack- prefix; the root SKILL.md router registers
* as `_gstack-command`. skillCensus().registryEntries is the authoritative
* set of what must appear here.
*
* The default hermetic dir deliberately seeds no skills — correct for
* children that install their own or probe setup behavior — but a PTY test
* that TYPES `/office-hours` needs the slash command to exist, or claude
* rejects it as Unknown command before any model turn and the gate measures
* nothing. Separate dir, same runRoot: opt-in per session, never contaminates
* the default-config children, and the existing exit teardown + pid-aware GC
* cover it. Ends in `/.claude` for the same plan-path anchoring reason as
* HermeticDirs.configDir.
*
* Seeding reads the live repo tree: the skills are the subject under test.
* For default seeded PTY sessions, launchClaudePty also supplies an owned HOME
* via hermetic-skill-runtime so literal runtime and lazy-section paths reach
* this same checkout. Explicit HOME/config overrides remain caller-owned;
* this registration helper itself does not change their environment.
*/
export function hermeticSkillsConfigDir(): string {
if (cachedSkillsConfigDir) {
let intact = false;
try {
const stat = fs.lstatSync(cachedSkillsConfigDir);
intact = stat.isDirectory() && !stat.isSymbolicLink();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (intact) return cachedSkillsConfigDir;
// A substituted config must never redirect seeding into operator state.
safeUnlink(cachedSkillsConfigDir);
cachedSkillsConfigDir = null;
}
const { runRoot } = getHermeticDirs();
const configDir = path.join(runRoot, 'with-skills', '.claude');
const skillsDir = path.join(configDir, 'skills');
try {
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, '.claude.json'),
JSON.stringify(buildSeedConfig({
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
trustedDirs: [repoRoot()],
}), null, 2),
);
const root = repoRoot();
for (const rel of skillCensus(root).physicalSkillFiles) {
const skillMd = path.join(root, rel);
const skillDir = path.dirname(rel);
const registryName = rel === 'SKILL.md'
? '_gstack-command'
: frontmatterName(skillMd) || skillDir;
const target = path.join(skillsDir, registryName);
// Idempotent overwrite mirrors setup's re-link: connect-chrome (a dir
// symlink to open-gstack-browser) shares its target's frontmatter name,
// so the two walk entries collapse to one registry dir.
fs.mkdirSync(target, { recursive: true });
safeUnlink(path.join(target, 'SKILL.md'));
fs.symlinkSync(skillMd, path.join(target, 'SKILL.md'));
if (rel !== 'SKILL.md') {
// Mirror setup's _link_skill_runtime_assets, including references and
// helpers beside sections. Missing assets can send a live agent looking
// outside its installed fixture and into the operator's stale checkout.
const source = path.join(root, skillDir);
for (const name of fs.readdirSync(source)) {
if (name.startsWith('.') || ['SKILL.md', 'node_modules', 'dist', 'test'].includes(name) || name.endsWith('.tmpl')) continue;
const asset = path.join(source, name);
if (!fs.existsSync(asset)) continue;
const destination = path.join(target, name);
safeUnlink(destination);
fs.symlinkSync(asset, destination, fs.statSync(asset).isDirectory() ? 'dir' : 'file');
}
}
}
// Canonical lazy paths remain available without letting native file-index
// discovery recursively read the source checkout's historical artifacts.
seedHermeticRuntimeView(root, path.join(skillsDir, 'gstack'));
cachedSkillsConfigDir = configDir;
return configDir;
} catch (error) {
try { fs.rmSync(path.join(runRoot, 'with-skills'), { recursive: true, force: true }); } catch { /* preserve the original seeding error */ }
throw error;
}
}
/** A dir younger than this is never GC'd even if its pid looks dead — guards
* against PID reuse deleting a freshly-created dir whose original pid exited
* and was recycled to an unrelated live process between create and GC. */
const GC_MIN_AGE_MS = 60 * 60 * 1000; // 1h
/**
* Reclaim leftovers from crashed runs. Two signals, both required: the
* embedded pid is dead AND the dir is older than GC_MIN_AGE_MS. Pid-alone
* would risk PID-reuse false-deletes of live dirs; age-alone would delete a
* live >24h eval run's config out from under it. Exported for tests.
*/
export function gcStaleHermeticDirs(tmpDir: string = os.tmpdir()): void {
let entries: string[];
try { entries = fs.readdirSync(tmpDir); } catch { return; }
const now = Date.now();
for (const name of entries) {
if (!name.startsWith(DIR_PREFIX)) continue;
const pidStr = name.slice(DIR_PREFIX.length).split('-')[0];
const pid = Number(pidStr);
if (!Number.isInteger(pid) || pid <= 0) continue;
if (pid === process.pid || isProcessAlive(pid)) continue;
const full = path.join(tmpDir, name);
try {
if (now - fs.statSync(full).mtimeMs < GC_MIN_AGE_MS) continue; // too fresh
} catch { continue; } // vanished or unreadable — leave it
try { fs.rmSync(full, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
/**
* The composition runners use: scrub process.env, point the child at the
* singleton hermetic dirs, apply per-test overrides last. Returns the legacy
* env untouched when EVALS_HERMETIC=0 (and skips dir creation entirely).
*/
export function hermeticChildEnv(
overrides?: Record<string, string | undefined>,
opts?: HermeticEnvOpts,
): Record<string, string> {
if (!isHermeticEnabled()) {
return buildHermeticEnv(process.env, {}, overrides, opts);
}
const dirs = getHermeticDirs();
return buildHermeticEnv(
process.env,
{ CLAUDE_CONFIG_DIR: dirs.configDir, GSTACK_HOME: dirs.gstackHome },
overrides,
opts,
);
}