diff --git a/browse/src/tunnel-denial-log.ts b/browse/src/tunnel-denial-log.ts index 15bec5bd1..104f56755 100644 --- a/browse/src/tunnel-denial-log.ts +++ b/browse/src/tunnel-denial-log.ts @@ -20,23 +20,21 @@ import * as path from 'path'; import { mkdirSecure } from './file-permissions'; import { resolveStateRoot } from '../../lib/state-root'; -const LOG_DIR = path.join(resolveStateRoot(), 'security'); -const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl'); const RATE_CAP = 60; // writes per minute const WINDOW_MS = 60_000; const writeTimestamps: number[] = []; let droppedSinceLastWrite = 0; -let dirEnsured = false; +let ensuredDir: string | null = null; -async function ensureDir(): Promise { - if (dirEnsured) return; +async function ensureDir(dir: string): Promise { + if (ensuredDir === dir) return; try { // Sync mkdir is fine here — runs once per process at first denial. The // (OI)(CI) inheritance set on Windows means subsequent fsp.appendFile // writes pick up the owner-only ACL automatically. - mkdirSecure(LOG_DIR); - dirEnsured = true; + mkdirSecure(dir); + ensuredDir = dir; } catch { // Swallow — log writes are best-effort. Failure to mkdir just means // subsequent appends will also fail and be caught below. @@ -81,8 +79,9 @@ export function logTunnelDenial(req: Request, url: URL, reason: string): void { // Fire and forget. Never await, never block the request path. void (async () => { try { - await ensureDir(); - await fsp.appendFile(LOG_PATH, JSON.stringify(entry) + '\n'); + const dir = path.join(resolveStateRoot(), 'security'); + await ensureDir(dir); + await fsp.appendFile(path.join(dir, 'attempts.jsonl'), JSON.stringify(entry) + '\n'); } catch { // Swallow — log writes are best-effort. If disk is full or ACLs block // us, we don't want to crash the server. @@ -94,5 +93,5 @@ export function logTunnelDenial(req: Request, url: URL, reason: string): void { export function __resetTunnelDenialLog(): void { writeTimestamps.length = 0; droppedSinceLastWrite = 0; - dirEnsured = false; + ensuredDir = null; } diff --git a/browse/test/chromium-profile-isolation.test.ts b/browse/test/chromium-profile-isolation.test.ts index 2330020fe..21269ee0c 100644 --- a/browse/test/chromium-profile-isolation.test.ts +++ b/browse/test/chromium-profile-isolation.test.ts @@ -1,13 +1,48 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { isProcessAlive, safeKill } from '../src/error-handling'; +import { isProcessAlive } from '../src/error-handling'; import { readPidStartTime } from '../src/xvfb'; const CLI = path.resolve(import.meta.dir, '../src/cli.ts'); const LOCKS = ['SingletonLock', 'SingletonSocket', 'SingletonCookie']; +function killDaemonGroup(pid: number): void { + try { + process.kill(-pid, 'SIGKILL'); + } catch (err: any) { + if (err?.code === 'ESRCH') return; + if (err?.code === 'EPERM' && !isProcessAlive(pid)) return; + throw err; + } +} + +describe('Chromium profile isolation cleanup (#2908)', () => { + test('a group kill on an already-stopped daemon tolerates macOS EPERM', () => { + const exited = Bun.spawnSync([process.execPath, '-e', '0'], { timeout: 30_000 }).pid; + const kill = spyOn(process, 'kill').mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { + if (pid === -exited) throw Object.assign(new Error('kill() failed: EPERM'), { code: 'EPERM' }); + if (pid === exited && signal === 0) throw Object.assign(new Error('kill() failed: ESRCH'), { code: 'ESRCH' }); + throw Object.assign(new Error('unexpected signal target'), { code: 'EINVAL' }); + }) as typeof process.kill); + try { + expect(() => killDaemonGroup(exited)).not.toThrow(); + } finally { kill.mockRestore(); } + }); + + test('a group kill still surfaces EPERM while the daemon is alive', () => { + const kill = spyOn(process, 'kill').mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { + if (pid === -process.pid) throw Object.assign(new Error('kill() failed: EPERM'), { code: 'EPERM' }); + if (pid === process.pid && signal === 0) return true; + throw Object.assign(new Error('unexpected signal target'), { code: 'EINVAL' }); + }) as typeof process.kill); + try { + expect(() => killDaemonGroup(process.pid)).toThrow('EPERM'); + } finally { kill.mockRestore(); } + }); +}); + describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817)', () => { let scratch: string; let profile: string; @@ -49,7 +84,7 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 if (fs.existsSync(stateFile)) { daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid; } - if (daemonPid) safeKill(-daemonPid, 'SIGKILL'); + if (daemonPid) killDaemonGroup(daemonPid); for (const child of children) child.kill('SIGKILL'); await Promise.all(children.map(child => child.exited)); fs.rmSync(scratch, { recursive: true, force: true }); diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 543fb521a..4e649b208 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -17,6 +17,7 @@ import { WRITE_COMMANDS, READ_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wr import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers'; import * as fs from 'fs'; import { spawn } from 'child_process'; +import { isProcessAlive } from '../src/error-handling'; import * as path from 'path'; import * as os from 'os'; @@ -961,13 +962,20 @@ describe('CLI lifecycle', () => { restartedPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid; fs.unlinkSync(stateFile); } + let daemonSurvivedStop = false; if (restartedPid) { - try { process.kill(restartedPid, 'SIGTERM'); } catch {} + try { process.kill(restartedPid, 'SIGINT'); } catch {} + const deadline = Date.now() + 5000; + while (Date.now() < deadline && isProcessAlive(restartedPid)) await Bun.sleep(50); + daemonSurvivedStop = isProcessAlive(restartedPid); + if (daemonSurvivedStop) try { process.kill(restartedPid, 'SIGKILL'); } catch {} } expect(result.code).toBe(0); expect(result.stdout).toContain('Status: healthy'); expect(result.stderr).toContain('Starting server'); + expect(restartedPid).toBeGreaterThan(0); + expect(daemonSurvivedStop).toBe(false); }, 20000); }); diff --git a/browse/test/dual-listener.test.ts b/browse/test/dual-listener.test.ts index 1270a8af7..7ab91ac80 100644 --- a/browse/test/dual-listener.test.ts +++ b/browse/test/dual-listener.test.ts @@ -19,10 +19,13 @@ import * as path from 'path'; import { TUNNEL_COMMANDS } from '../src/server'; import { __resetConnectRateLimit } from '../src/token-registry'; import { makeServer, stubRouteContext, callRoute, fakeTunnel, type TestServer } from './route-test-harness'; +import { usePrivateStateRoot } from '../../test/helpers/private-state-root'; +import { __resetTunnelDenialLog, logTunnelDenial } from '../src/tunnel-denial-log'; const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const TABLE_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/routes/table.ts'), 'utf-8'); +const stateRoot = usePrivateStateRoot(); let server: TestServer; let scoped = ''; const overlayCalls: string[] = []; @@ -359,6 +362,16 @@ describe('Rate limit + denial log wiring', () => { expect(SERVER_SRC).toContain("logTunnelDenial(req, url, 'missing_scoped_token')"); }); + test('a denial is logged under the state root current at write time (#2895)', async () => { + __resetTunnelDenialLog(); + const url = new URL('http://127.0.0.1/command'); + logTunnelDenial(new Request(url), url, 'missing_scoped_token'); + const log = path.join(stateRoot.dir, 'security', 'attempts.jsonl'); + const deadline = Date.now() + 2000; + while (!fs.existsSync(log) && Date.now() < deadline) await Bun.sleep(10); + expect(fs.readFileSync(log, 'utf8')).toContain('missing_scoped_token'); + }); + test('/connect rate limit was loosened from 3/min to 300/min', () => { const registrySrc = fs.readFileSync( path.join(import.meta.dir, '../src/token-registry.ts'), @@ -377,9 +390,10 @@ describe('E3: /welcome GSTACK_SLUG path traversal gate', () => { fs.mkdirSync(path.dirname(page(slug)), { recursive: true }); fs.writeFileSync(page(slug), body); } - const saved = { HOME: process.env.HOME, GSTACK_SLUG: process.env.GSTACK_SLUG }; + const saved = { HOME: process.env.HOME, GSTACK_SLUG: process.env.GSTACK_SLUG, GSTACK_HOME: process.env.GSTACK_HOME }; try { process.env.HOME = home; + delete process.env.GSTACK_HOME; process.env.GSTACK_SLUG = 'good-slug_1'; expect(await (await server.local('/welcome')).text()).toBe('GOOD-SLUG-PAGE'); // A traversal slug (and any slug outside the charset) falls back to 'unknown'. diff --git a/browse/test/terminal-agent-lifecycle.test.ts b/browse/test/terminal-agent-lifecycle.test.ts index 2d36b6c7d..e6e2fa3e1 100644 --- a/browse/test/terminal-agent-lifecycle.test.ts +++ b/browse/test/terminal-agent-lifecycle.test.ts @@ -340,7 +340,7 @@ describe('terminal-agent owned lifecycle regression', () => { const stateFile = path.join(stateDir, 'browse.json'); const daemon = Bun.spawn(['bun', 'run', path.join(sourceDir, 'server.ts')], { env: { ...process.env, BROWSE_STATE_FILE: stateFile, BROWSE_HEADLESS_SKIP: '1', BROWSE_PARENT_PID: '0', - GSTACK_AGENT_WATCHDOG_TICK_MS: '50', GSTACK_STATE_WATCH_MS: '50' }, + GSTACK_AGENT_WATCHDOG_TICK_MS: '50', GSTACK_STATE_WATCH_MS: '50', GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25' }, stdio: ['ignore', 'ignore', 'ignore'], }); try { diff --git a/design/src/daemon.ts b/design/src/daemon.ts index 3d4b72ee4..94de82a71 100644 --- a/design/src/daemon.ts +++ b/design/src/daemon.ts @@ -87,7 +87,7 @@ let shuttingDown = false; let serverRef: ReturnType | null = null; let idleInterval: ReturnType | null = null; const startTime = Date.now(); -const daemonLog = openDaemonLog(); +let daemonLog: fs.WriteStream | null | undefined; function openDaemonLog(): fs.WriteStream | null { try { @@ -101,6 +101,7 @@ function openDaemonLog(): fs.WriteStream | null { function dlog(...args: unknown[]): void { const line = `[${new Date().toISOString()}] ${args.map(String).join(" ")}\n`; + if (daemonLog === undefined) daemonLog = openDaemonLog(); if (daemonLog) daemonLog.write(line); process.stderr.write(line); } diff --git a/design/test/daemon-discovery.test.ts b/design/test/daemon-discovery.test.ts index 3a49c1c49..fd34cd712 100644 --- a/design/test/daemon-discovery.test.ts +++ b/design/test/daemon-discovery.test.ts @@ -38,6 +38,9 @@ import { spawnDaemonForTest, type SpawnedDaemon, } from "./daemon-tests-fixtures"; +import { usePrivateStateRoot } from "../../test/helpers/private-state-root"; + +usePrivateStateRoot(); let workDir: string; let stateFile: string; diff --git a/design/test/daemon-tests-fixtures.ts b/design/test/daemon-tests-fixtures.ts index 1df6217f9..011b79c55 100644 --- a/design/test/daemon-tests-fixtures.ts +++ b/design/test/daemon-tests-fixtures.ts @@ -72,6 +72,7 @@ export async function spawnDaemonForTest( DESIGN_DAEMON_IDLE_MS: String(opts.idleMs ?? 60_000), DESIGN_DAEMON_CHECK_MS: String(opts.checkMs ?? 1000), DESIGN_DAEMON_VERSION: "test-version", + GSTACK_HOME: path.join(path.dirname(stateFile), "gstack-home"), ...(opts.env ?? {}), }; diff --git a/design/test/daemon.test.ts b/design/test/daemon.test.ts index 4e1518fcb..a008c2186 100644 --- a/design/test/daemon.test.ts +++ b/design/test/daemon.test.ts @@ -14,6 +14,9 @@ import { __testInternals__, fetchHandler, idleCheckTick } from "../src/daemon"; const { markMeaningfulActivity } = __testInternals__; import { makeBoardHtml, makeTmpDir, req, resetDaemon } from "./daemon-tests-fixtures"; +import { usePrivateStateRoot } from "../../test/helpers/private-state-root"; + +usePrivateStateRoot(); let tmpDir: string; diff --git a/scripts/eval-watch.ts b/scripts/eval-watch.ts index 051a86d1d..03f0a62b7 100644 --- a/scripts/eval-watch.ts +++ b/scripts/eval-watch.ts @@ -21,8 +21,6 @@ const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev'); // getProjectEvalDir() (or GSTACK_EVAL_DIR), so watching the legacy global // path missed it whenever slug detection succeeded — i.e. the normal case. const HEARTBEAT_PATH = path.join(GSTACK_DEV_DIR, 'e2e-live.json'); -const EVAL_DIR = process.env.GSTACK_EVAL_DIR || getProjectEvalDir(); -const PARTIAL_PATH = path.join(EVAL_DIR, '_partial-e2e*.json'); const STALE_THRESHOLD_SEC = 600; // 10 minutes export interface HeartbeatData { @@ -54,7 +52,7 @@ function readJSON(filePath: string): T | null { } /** Read the legacy collector and suite collectors in this shard, never finals. */ -export function readPartialResults(evalDir = EVAL_DIR): PartialData | null { +export function readPartialResults(evalDir = process.env.GSTACK_EVAL_DIR || getProjectEvalDir()): PartialData | null { let names: string[]; try { names = fs.readdirSync(evalDir).sort(); } catch { return null; } const partials = names.filter(name => /^_partial-e2e(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?\.json$/.test(name)) @@ -98,7 +96,7 @@ export function renderDashboard(heartbeat: HeartbeatData | null, partial: Partia lines.push('E2E Watch — No active run detected'); lines.push(''); lines.push(`Heartbeat: ${HEARTBEAT_PATH} (not found)`); - lines.push(`Partial: ${PARTIAL_PATH} (not found)`); + lines.push(`Partial: ${path.join(process.env.GSTACK_EVAL_DIR || getProjectEvalDir(), '_partial-e2e*.json')} (not found)`); lines.push(''); lines.push('Start a run with: EVALS=1 bun test test/skill-e2e-*.test.ts'); return lines.join('\n'); diff --git a/scripts/lib/free-home-guard.ts b/scripts/lib/free-home-guard.ts new file mode 100644 index 000000000..59373e9ba --- /dev/null +++ b/scripts/lib/free-home-guard.ts @@ -0,0 +1,102 @@ +/** + * Free-suite home-write tripwire (#2895). + * + * Snapshots path + mtime + size of the gstack install and config surfaces under + * the real home before and after each free shard, and names the shard's files + * when one of those entries changed. It watches only these surfaces; it is not + * a universal write-containment check. Live agent-session logs that a + * concurrently running host keeps writing are excluded, and directory mtimes + * are ignored, so only file, link and directory-set changes count. Concurrent + * shards whose windows overlap a change all report it. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export const FREE_HOME_SURFACES = ['.gstack', '.claude', '.codex', '.agents', '.config/gstack'] as const; + +export const FREE_HOME_VOLATILE = [ + '.claude/projects', '.claude/todos', '.claude/shell-snapshots', '.claude/statsig', '.claude/ide', + '.claude/session-env', '.claude/file-history', '.claude/debug', '.claude/telemetry', '.claude/history.jsonl', + '.claude/.credentials.json', '.codex/sessions', '.codex/archived_sessions', '.codex/log', '.codex/history.jsonl', + '.codex/auth.json', +] as const; + +const MAX_DEPTH = 4; +const MAX_REPORTED = 10; + +export type FreeHomeSnapshot = Map; + +export function freeHomeDir(env: NodeJS.ProcessEnv = process.env): string { + return env.HOME || env.USERPROFILE || os.homedir(); +} + +export function snapshotFreeHome(home: string): FreeHomeSnapshot { + const entries: FreeHomeSnapshot = new Map(); + const volatile = new Set(FREE_HOME_VOLATILE); + const visit = (relative: string, depth: number) => { + if (volatile.has(relative)) return; + const absolute = path.join(home, relative); + let stat: fs.BigIntStats; + try { + stat = fs.lstatSync(absolute, { bigint: true }); + } catch (error) { + if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return; + throw error; + } + if (stat.isSymbolicLink()) { + entries.set(relative, `link:${fs.readlinkSync(absolute)}:${stat.mtimeNs}`); + return; + } + if (!stat.isDirectory()) { + entries.set(relative, `file:${stat.size}:${stat.mtimeNs}`); + return; + } + entries.set(relative, 'dir'); + if (depth >= MAX_DEPTH) return; + let names: string[]; + try { + names = fs.readdirSync(absolute); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ''; + if (['EACCES', 'EPERM'].includes(code)) entries.set(relative, 'dir:unreadable'); + if (['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(code)) return; + throw error; + } + for (const name of names.sort()) visit(path.join(relative, name), depth + 1); + }; + for (const surface of FREE_HOME_SURFACES) visit(path.normalize(surface), 0); + return entries; +} + +export function diffFreeHome(before: FreeHomeSnapshot, after: FreeHomeSnapshot): string[] { + const changed = new Set(); + for (const [entry, value] of before) if (after.get(entry) !== value) changed.add(entry); + for (const entry of after.keys()) if (!before.has(entry)) changed.add(entry); + return [...changed].sort(); +} + +export function formatFreeHomeChange(changed: string[], files: string[]): string { + const shown = changed.slice(0, MAX_REPORTED).map(entry => `~/${entry.split(path.sep).join('/')}`); + const more = changed.length > MAX_REPORTED ? ` (+${changed.length - MAX_REPORTED} more)` : ''; + return `real home changed while this shard ran (watches ${FREE_HOME_SURFACES.map(s => `~/${s}`).join(', ')} only): ` + + `${shown.join(', ')}${more}. Give the writer a private HOME/GSTACK_HOME. Shard files: ${files.join(', ')}`; +} + +/** Take the baseline now; verify() returns null or the failure to report. */ +export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env): { verify(): string | null } { + const home = freeHomeDir(env); + const unreadable = (error: unknown) => `real home surfaces could not be snapshotted (${(error as NodeJS.ErrnoException).code ?? 'error'}); shard files: ${files.join(', ')}`; + let before: FreeHomeSnapshot | undefined; + let baselineError: string | undefined; + try { before = snapshotFreeHome(home); } catch (error) { baselineError = unreadable(error); } + return { + verify() { + if (!before) return baselineError!; + try { + const changed = diffFreeHome(before, snapshotFreeHome(home)); + return changed.length ? formatFreeHomeChange(changed, files) : null; + } catch (error) { return unreadable(error); } + }, + }; +} diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 4bd9b6590..0763b5ea2 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -86,6 +86,7 @@ import { StringDecoder } from 'node:string_decoder'; import { createHash, randomUUID } from 'node:crypto'; import { isPaidTestFile } from '../test/helpers/paid-test-set'; import { resolveStateRoot } from '../lib/state-root'; +import { guardFreeHome } from './lib/free-home-guard'; import { BunTestOutputClassifier, createShardSandbox, @@ -107,7 +108,6 @@ import { type LanePolicy, type ShardChildResult, } from './lib/shard-engine'; - export { normalizeRelativePath } from './lib/shard-engine'; /** @@ -354,6 +354,7 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> // pattern hit is a false positive — the point of these files is Windows // coverage, so auto-excluding them defeats the regression tests they carry. const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ + { file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' }, { file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral', @@ -384,8 +385,7 @@ const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ }, { file: 'test/claude-code-runner.test.ts', - // The bin/ path is launched through process.execPath (Bun), never as a - // shebang executable. Keep taskkill tree supervision in the Windows lane. + // The bin/ path is launched through process.execPath (Bun), never as a shebang; keep taskkill supervision in the Windows lane. reason: 'invokes the runner via Bun argv; fake CLI and timeout descendant assertions cover native Windows taskkill', }, { @@ -1810,7 +1810,7 @@ function explainFreeVerdict(label: string, status: FreeShardStatus, facts: { summary: ReturnType; expectedFiles: number; wallTimeoutMs: number; }): void { const { summary, exitCode } = facts; - if (facts.cleanupError) console.error(`${label} browser cleanup failed: ${facts.cleanupError}; retained ${facts.stateDir}`); + if (facts.cleanupError) console.error(`${label} shard cleanup or home containment failed: ${facts.cleanupError}; retained ${facts.stateDir}`); if (status === 'timed-out') { console.error( `${label} exceeded the ${Math.round(facts.wallTimeoutMs / 1000)}s wall-clock deadline — ` @@ -1832,7 +1832,7 @@ function explainFreeVerdict(label: string, status: FreeShardStatus, facts: { function logFreeRecovery(log: (line: string) => void, outcome: FreeShardOutcome, facts: { cleanupError: string | null; logWriteFailed: boolean; captureIncomplete: boolean; rootDir: string; }): void { - const problem = facts.cleanupError ? 'Owned-process cleanup is unconfirmed; inspect the retained state before another run.' + const problem = facts.cleanupError ? 'Owned-process cleanup or home containment is unconfirmed; inspect the reported paths and retained state before another run.' : facts.logWriteFailed ? 'The evidence log could not be retained; repair the log destination before another run.' : facts.captureIncomplete ? 'Evidence capture is incomplete; repair the stream or early exit before another run.' : outcome.status === 'timed-out' ? 'Execution exceeded its deadline; inspect the last completed step before changing code or rerunning.' @@ -1952,8 +1952,8 @@ export async function runFreeShard( child = await runShardChild({ command, args, cwd: rootDir, env, timeoutMs: wallTimeoutMs, attach: () => { - const browser = trackShardBrowser(stateDir, env); - return { signal: (force) => browser.signal(force), settle: async () => { cleanupError = await browser.settle(); } }; + const browser = trackShardBrowser(stateDir, env), home = guardFreeHome(files, env); + return { signal: (force) => browser.signal(force), settle: async () => { cleanupError = await browser.settle() ?? home.verify(); } }; }, hookStreams: (spawned) => [consumeStream(spawned.stdout, 'stdout'), consumeStream(spawned.stderr, 'stderr')], }); diff --git a/test/brain-cache-roundtrip.test.ts b/test/brain-cache-roundtrip.test.ts index b27121237..0ef9792f6 100644 --- a/test/brain-cache-roundtrip.test.ts +++ b/test/brain-cache-roundtrip.test.ts @@ -19,14 +19,21 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, existsSync, writeFileSync, readFileSync, rmSync, mkdirSync, readdirSync } from 'fs'; -import { join } from 'path'; +import { delimiter, join } from 'path'; import { tmpdir } from 'os'; +import { spawnSync } from 'child_process'; let TMP_HOME: string; +let GBRAIN_STUB: string; const ORIGINAL_HOME = process.env.GSTACK_HOME; beforeEach(() => { TMP_HOME = mkdtempSync(join(tmpdir(), 'gstack-cache-test-')); + GBRAIN_STUB = mkdtempSync(join(tmpdir(), 'gstack-unreachable-gbrain-')); + writeFileSync(join(GBRAIN_STUB, 'gbrain'), '#!/bin/sh\necho "gbrain unreachable (test stub)" >&2\nexit 1\n', { mode: 0o755 }); + writeFileSync(join(GBRAIN_STUB, 'gbrain.cmd'), '@echo gbrain unreachable (test stub) 1>&2\r\n@exit /b 1\r\n'); + const pathKey = Object.keys(process.env).find(name => name.toLowerCase() === 'path') ?? 'PATH'; + process.env[pathKey] = `${GBRAIN_STUB}${delimiter}${process.env[pathKey] ?? ''}`; process.env.GSTACK_HOME = TMP_HOME; // Reload the cache module fresh per test so it picks up the new HOME. delete require.cache[require.resolve('../bin/gstack-brain-cache')]; @@ -36,12 +43,21 @@ afterEach(() => { if (ORIGINAL_HOME) process.env.GSTACK_HOME = ORIGINAL_HOME; else delete process.env.GSTACK_HOME; try { rmSync(TMP_HOME, { recursive: true, force: true }); } catch { /* best effort */ } + rmSync(GBRAIN_STUB, { recursive: true, force: true }); }); async function importCache(): Promise { return (await import('../bin/gstack-brain-cache')) as typeof import('../bin/gstack-brain-cache'); } +describe('brain-cache hermetic brain (#2829)', () => { + test('an installed gbrain is shadowed by the failing stub', () => { + const probe = spawnSync('gbrain', ['--version'], { encoding: 'utf8', shell: process.platform === 'win32', timeout: 10_000 }); + expect(probe.status).toBe(1); + expect(probe.stderr).toContain('gbrain unreachable (test stub)'); + }); +}); + describe('brain-cache paths', () => { test('cross-project entity (user-profile) lives in ~/.gstack/brain-cache/', async () => { const mod = await importCache(); diff --git a/test/cso-git-hardening.test.ts b/test/cso-git-hardening.test.ts index 71f877517..2c552d7d4 100644 --- a/test/cso-git-hardening.test.ts +++ b/test/cso-git-hardening.test.ts @@ -10,7 +10,7 @@ const roots:string[]=[]; afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});}); function fixture(){ - const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-')),repo=path.join(root,'repo'),runDir=path.join(root,'state','run'); + const root=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-'))),repo=path.join(root,'repo'),runDir=path.join(root,'state','run'); roots.push(root);fs.mkdirSync(repo);fs.mkdirSync(runDir,{recursive:true,mode:0o700}); const git=(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:root,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout;}; git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture'); @@ -59,13 +59,14 @@ describe('CSO Git metadata hardening',()=>{ const {root,repo,runDir,git}=fixture(),empty=path.join(root,'decoy-worktree'),excludes=path.join(root,'global-excludes'); fs.mkdirSync(empty);fs.writeFileSync(excludes,'untracked-security.ts\n'); fs.writeFileSync(path.join(repo,'untracked-security.ts'),'export const vulnerable = true\n'); - fs.writeFileSync(path.join(repo,'TRACKED.ts'),'export const caseVariant = true\n'); + const caseSensitive=!fs.existsSync(path.join(repo,'TRACKED.ts')); + if(caseSensitive)fs.writeFileSync(path.join(repo,'TRACKED.ts'),'export const caseVariant = true\n'); git('config','core.worktree',empty);git('config','core.excludesFile',excludes);git('config','core.ignoreCase','true');git('config','core.precomposeUnicode','true'); const manifest=await capture(repo,runDir,'HEAD'); expect(manifest.entries.map(entry=>entry.path)).toContain('untracked-security.ts'); - expect(manifest.entries.map(entry=>entry.path)).toContain('TRACKED.ts'); + if(caseSensitive)expect(manifest.entries.map(entry=>entry.path)).toContain('TRACKED.ts'); expect(fs.readFileSync(path.join(runDir,'snapshot','untracked-security.ts'),'utf8')).toContain('vulnerable'); expect(manifest.changedPaths).toContain('untracked-security.ts'); }); diff --git a/test/docsync-fault-interface.test.ts b/test/docsync-fault-interface.test.ts index 1bd2a049d..e04d00aeb 100644 --- a/test/docsync-fault-interface.test.ts +++ b/test/docsync-fault-interface.test.ts @@ -693,8 +693,11 @@ for (const [name, mutation] of [ expect(fs.existsSync(fixture.home)).toBe(false); }); `); + const home = path.join(dir, 'home'); + fs.mkdirSync(home); const result = Bun.spawnSync([process.execPath, 'test', script], { env: { ...process.env, EVALS: '', EVALS_TIER: '', EVALS_ALL: '', EVALS_RUN_ID: 'free-docsync-faults', + HOME: home, USERPROFILE: home, GSTACK_EVAL_DIR: path.join(dir, 'evidence'), GSTACK_HOME: path.join(dir, 'state') }, stdout: 'pipe', stderr: 'pipe', timeout: 120_000, }); diff --git a/test/free-home-guard.test.ts b/test/free-home-guard.test.ts new file mode 100644 index 000000000..a1e026cde --- /dev/null +++ b/test/free-home-guard.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { diffFreeHome, guardFreeHome, snapshotFreeHome } from '../scripts/lib/free-home-guard'; +import { runFreeShard } from '../scripts/test-free-shards'; + +const homes: string[] = []; +afterEach(() => { for (const home of homes.splice(0)) fs.rmSync(home, { recursive: true, force: true }); }); + +function fakeHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'free-home-guard-')); + homes.push(home); + for (const dir of ['.gstack/security', '.claude/skills', '.claude/projects/p', '.codex/skills', '.agents/skills', '.config/gstack']) { + fs.mkdirSync(path.join(home, dir), { recursive: true }); + } + fs.writeFileSync(path.join(home, '.gstack/.last-setup-version'), '1.0.0\n'); + fs.writeFileSync(path.join(home, '.claude/projects/p/session.jsonl'), '{}\n'); + return home; +} + +const bump = (file: string) => { + const later = new Date(Date.now() + 5_000); + fs.utimesSync(file, later, later); +}; + +describe('free-suite home-write tripwire (#2895)', () => { + test.skipIf(process.platform === 'win32')('names install and state writes on every watched surface', () => { + const home = fakeHome(); + const before = snapshotFreeHome(home); + fs.writeFileSync(path.join(home, '.gstack/.last-setup-version'), '9.9.9\n'); + bump(path.join(home, '.gstack/.last-setup-version')); + fs.appendFileSync(path.join(home, '.gstack/security/prepush-skip.jsonl'), '{"reason":"env-skip"}\n'); + fs.symlinkSync(path.join(home, 'checkout'), path.join(home, '.claude/skills/gstack')); + fs.mkdirSync(path.join(home, '.codex/skills/gstack-qa')); + fs.writeFileSync(path.join(home, '.agents/skills/marker'), 'x'); + fs.writeFileSync(path.join(home, '.config/gstack/config.yaml'), 'a: b\n'); + expect(diffFreeHome(before, snapshotFreeHome(home)).map(entry => entry.split(path.sep).join('/'))).toEqual([ + '.agents/skills/marker', + '.claude/skills/gstack', + '.codex/skills/gstack-qa', + '.config/gstack/config.yaml', + '.gstack/.last-setup-version', + '.gstack/security/prepush-skip.jsonl', + ]); + }); + + test('ignores live session logs and directory-only mtime churn', () => { + const home = fakeHome(); + const before = snapshotFreeHome(home); + fs.appendFileSync(path.join(home, '.claude/projects/p/session.jsonl'), '{"more":true}\n'); + fs.writeFileSync(path.join(home, '.claude/history.jsonl'), 'line\n'); + fs.writeFileSync(path.join(home, '.claude/.tmp-write'), 'x'); + fs.rmSync(path.join(home, '.claude/.tmp-write')); + expect(diffFreeHome(before, snapshotFreeHome(home))).toEqual([]); + }); + + test.skipIf(process.platform === 'win32')('a removed entry and a retargeted link count as changes', () => { + const home = fakeHome(); + fs.symlinkSync('/one', path.join(home, '.claude/skills/link')); + const before = snapshotFreeHome(home); + fs.rmSync(path.join(home, '.gstack/.last-setup-version')); + fs.rmSync(path.join(home, '.claude/skills/link')); + fs.symlinkSync('/two', path.join(home, '.claude/skills/link')); + expect(diffFreeHome(before, snapshotFreeHome(home)).map(entry => entry.split(path.sep).join('/'))) + .toEqual(['.claude/skills/link', '.gstack/.last-setup-version']); + }); + + test('the guard reports nothing for an untouched home and names the shard files otherwise', () => { + const home = fakeHome(); + expect(guardFreeHome(['test/a.test.ts'], { HOME: home }).verify()).toBeNull(); + const guard = guardFreeHome(['test/a.test.ts', 'test/b.test.ts'], { HOME: home }); + fs.writeFileSync(path.join(home, '.gstack/.last-setup-version'), '2.0.0\n'); + bump(path.join(home, '.gstack/.last-setup-version')); + const failure = guard.verify(); + expect(failure).toContain('~/.gstack/.last-setup-version'); + expect(failure).toContain('Shard files: test/a.test.ts, test/b.test.ts'); + expect(failure).toContain('only'); + }); + + test.skipIf(process.platform === 'win32')('a free shard that writes the real home fails and names its files', async () => { + const home = fakeHome(); + const writer = `require('fs').writeFileSync(require('path').join(process.env.HOME, '.gstack', '.last-setup-version'), '7.7.7\\n');` + + `console.log('Ran 1 tests across 1 files. [1.00ms]')`; + const clean = `console.log('Ran 1 tests across 1 files. [1.00ms]')`; + const errors: string[] = []; + const spy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.join(' ')); }); + try { + const options = (script: string) => ({ env: { ...process.env, HOME: home }, quiet: true, log: () => {}, + logFilePath: path.join(home, `${script === clean ? 'clean' : 'writer'}.log`), + commandFor: () => ({ command: process.execPath, args: ['-e', script] }) }); + expect((await runFreeShard(['test/clean.test.ts'], 1, 2, options(clean))).status).toBe('passed'); + const outcome = await runFreeShard(['test/writer.test.ts'], 2, 2, options(writer)); + expect(outcome.status).toBe('failed'); + expect(outcome.unattributedFailures).toBeGreaterThan(0); + expect(errors.join('\n')).toContain('~/.gstack/.last-setup-version'); + expect(errors.join('\n')).toContain('Shard files: test/writer.test.ts'); + } finally { + spy.mockRestore(); + for (const retained of errors.flatMap(line => /retained (\S+)$/.exec(line)?.[1] ?? [])) { + if (/^gstack-free-shard-/.test(path.basename(retained))) fs.rmSync(retained, { recursive: true, force: true }); + } + } + }); +}); diff --git a/test/gbrain-dream-stage.test.ts b/test/gbrain-dream-stage.test.ts index 64cd3f5a8..886f370b2 100644 --- a/test/gbrain-dream-stage.test.ts +++ b/test/gbrain-dream-stage.test.ts @@ -17,6 +17,9 @@ import { describe, it, expect, afterEach } from "bun:test"; import { mkdtempSync, existsSync, writeFileSync, utimesSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { usePrivateStateRoot } from "./helpers/private-state-root"; + +usePrivateStateRoot(); import { spawnSync } from "child_process"; import { diff --git a/test/gbrain-sources.test.ts b/test/gbrain-sources.test.ts index 810624755..c42129045 100644 --- a/test/gbrain-sources.test.ts +++ b/test/gbrain-sources.test.ts @@ -11,6 +11,9 @@ import { describe, it, expect } from "bun:test"; import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync, symlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { usePrivateStateRoot } from "./helpers/private-state-root"; + +usePrivateStateRoot(); import { ensureSourceRegistered, probeSource, sourcePageCount } from "../lib/gbrain-sources"; diff --git a/test/gstack-brain-context-load.test.ts b/test/gstack-brain-context-load.test.ts index c47d31db4..ae419f1a3 100644 --- a/test/gstack-brain-context-load.test.ts +++ b/test/gstack-brain-context-load.test.ts @@ -10,6 +10,9 @@ import { describe, it, expect } from "bun:test"; import { chmodSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs"; import { tmpdir } from "os"; import { delimiter, join } from "path"; +import { usePrivateStateRoot } from "./helpers/private-state-root"; + +usePrivateStateRoot(); import { spawnSync } from "child_process"; import { parseSkillManifest } from "../lib/gstack-memory-helpers"; diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index d18358a41..0def6935a 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -34,7 +34,7 @@ function git(cwd: string, ...args: string[]) { } beforeAll(() => { - SANDBOX = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-detect-')); + SANDBOX = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-detect-'))); REPO = path.join(SANDBOX, 'repo'); fs.mkdirSync(REPO); git(REPO, 'init', '-q', '-b', 'main'); @@ -1520,7 +1520,7 @@ describe('install: the one download gstack makes, after consent', () => { const PLATFORM = ENGINE_ASSETS[`${process.platform}-${process.arch}`]; const VERSION = TESTED_ENGINE_VERSIONS[TESTED_ENGINE_VERSIONS.length - 1]; const ASSET = PLATFORM ? `impeccable-${PLATFORM}${PLATFORM.startsWith('windows') ? '.exe' : ''}` : ''; - const freshHome = () => fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-impeccable-home-')); + const freshHome = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-impeccable-home-'))); const mirror = (body: Uint8Array, hits: string[]) => Bun.serve({ port: 0, hostname: '127.0.0.1', fetch(req) { diff --git a/test/gstack-detach.test.ts b/test/gstack-detach.test.ts index 517bf6d45..1b8e8295b 100644 --- a/test/gstack-detach.test.ts +++ b/test/gstack-detach.test.ts @@ -129,16 +129,16 @@ describe('gstack-detach', () => { const logB = path.join(dir, 'b.log'); try { // First holds the lock for ~3s; second must wait then acquire. - spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'sleep', '3'], { encoding: 'utf-8', timeout: 10000 }); + spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'sleep', '3'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } }); waitFor(() => logHas(logA, "ACQUIRED"), 4000); - spawnSync(DETACH, ['--log', logB, '--lock', lock, '--', 'echo', 'second-ran'], { encoding: 'utf-8', timeout: 10000 }); + spawnSync(DETACH, ['--log', logB, '--lock', lock, '--', 'echo', 'second-ran'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } }); // Second should report WAITING (first still holds it) then ACQUIRE after release. expect(waitFor(() => logHas(logB, 'WAITING for lock'), 4000)).toBe(true); expect(waitFor(() => logHas(logB, '### gstack-detach EXIT=0 ###'), 12000)).toBe(true); expect(logHas(logB, 'second-ran')).toBe(true); + expect(fs.existsSync(path.join(dir, '.gstack', 'locks', `${lock}.lock`))).toBe(true); } finally { fs.rmSync(dir, { recursive: true, force: true }); - fs.rmSync(path.join(os.homedir(), '.gstack', 'locks', `${lock}.lock`), { force: true }); } }, 20000); diff --git a/test/gstack-memory-ingest.test.ts b/test/gstack-memory-ingest.test.ts index 9b9b6fc2b..99be71c21 100644 --- a/test/gstack-memory-ingest.test.ts +++ b/test/gstack-memory-ingest.test.ts @@ -15,7 +15,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, statSync, chmodSync, readdirSync, symlinkSync, utimesSync, copyFileSync } from "fs"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, statSync, chmodSync, readdirSync, symlinkSync, utimesSync, copyFileSync, realpathSync } from "fs"; import { tmpdir } from "os"; import { basename, dirname, join } from "path"; import { spawnSync } from "child_process"; @@ -30,7 +30,7 @@ describe("requested secret scanning at the import boundary", () => { const realScanner = process.env.GSTACK_TEST_GITLEAKS; beforeEach(() => { - home = mkdtempSync(join(tmpdir(), "gstack-scan-")); + home = realpathSync(mkdtempSync(join(tmpdir(), "gstack-scan-"))); bin = join(home, "bin"); mkdirSync(bin); mkdirSync(join(home, "tmp")); diff --git a/test/helpers/docsync-fixture.ts b/test/helpers/docsync-fixture.ts index c04b1af2d..48c2d0034 100644 --- a/test/helpers/docsync-fixture.ts +++ b/test/helpers/docsync-fixture.ts @@ -145,9 +145,9 @@ export function fixtureDocs(scenario: DocsScenario, generatedRoot = process.env. }; } -export function preserveDocsEvidence(fixture: ReturnType, result: Pick, runId: string, name: string, extra: Record = {}): string { +export function preserveDocsEvidence(fixture: ReturnType, result: Pick, runId: string, name: string, extra: Record = {}, projectDir?: string): string { if (!runId) throw new Error('EVALS_RUN_ID is required to retain docs evidence'); - const dir = path.join(path.dirname(getProjectEvalDir()), 'e2e-runs', runId, `${name}-${path.basename(fixture.home)}-fixture`); + const dir = path.join(projectDir ?? path.dirname(getProjectEvalDir()), 'e2e-runs', runId, `${name}-${path.basename(fixture.home)}-fixture`); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.chmodSync(dir, 0o700); const file = path.join(dir, 'state.json'); diff --git a/test/helpers/e2e-helpers.ts b/test/helpers/e2e-helpers.ts index 4b9e1a747..2d4a2a530 100644 --- a/test/helpers/e2e-helpers.ts +++ b/test/helpers/e2e-helpers.ts @@ -15,6 +15,7 @@ import { selectTests, detectBaseBranch, getChangedFiles, E2E_TOUCHFILES, E2E_TIE import { WorktreeManager } from '../../lib/worktree'; import type { HarvestResult } from '../../lib/worktree'; import { preflightAnthropicApi } from './anthropic-preflight'; +import { isHermeticEnabled } from './hermetic-env'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -342,7 +343,7 @@ export async function finalizeEvalCollector(evalCollector: EvalCollector | null) // NOTE: since gstack-skill-start honors GSTACK_HOME (EOV7), hermetic children read the // temp GSTACK_HOME that hermetic-env.ts seeds (the canonical marker list lives there); // this operator-HOME seeding only serves EVALS_HERMETIC=0 debug runs. -if (evalsEnabled) { +if (evalsEnabled && !isHermeticEnabled()) { const gstackDir = path.join(os.homedir(), '.gstack'); fs.mkdirSync(gstackDir, { recursive: true }); // Marker list kept at parity with hermetic-env.ts's child-GSTACK_HOME seed diff --git a/test/helpers/plan-count-file-permission.ts b/test/helpers/plan-count-file-permission.ts index 824df6fa5..787d50c4b 100644 --- a/test/helpers/plan-count-file-permission.ts +++ b/test/helpers/plan-count-file-permission.ts @@ -17,8 +17,11 @@ const scoped = (file: unknown, config: string, session: string) => { return rel.length === 2 && rel[0] !== '..' && rel[0] !== '.' && rel[1] === `${session}.jsonl`; }; export function createFilePermissionRecorder(cwd: string, config: string, expected: string) { - const relative = path.relative(os.tmpdir(), expected); - if (!path.isAbsolute(expected) || !relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) return undefined; + const inside = (root: string) => { + const relative = path.relative(root, expected); + return !!relative && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative); + }; + if (!path.isAbsolute(expected) || !(inside(os.tmpdir()) || inside(fs.realpathSync(os.tmpdir())))) return undefined; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-file-permission-')); const file = path.join(dir, 'state.json'); const command = [process.execPath, import.meta.path, '--record', file, cwd, config, expected].map(quote).join(' '); diff --git a/test/helpers/plan-count-fixture.ts b/test/helpers/plan-count-fixture.ts index 536dc0def..519c8d176 100644 --- a/test/helpers/plan-count-fixture.ts +++ b/test/helpers/plan-count-fixture.ts @@ -83,7 +83,7 @@ export function createPlanCountFixture(prompt: string, opts: { } { if (opts.preconfiguredReviewActor && !opts.nativeReviewOnly) throw new Error('Preconfigured review actor requires owned native review state'); - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-plan-count-')); + const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-plan-count-'))); let nativeState: ReturnType | undefined; const env: Record = {}; const cleanup = () => { diff --git a/test/helpers/private-state-root.ts b/test/helpers/private-state-root.ts new file mode 100644 index 000000000..4505122c9 --- /dev/null +++ b/test/helpers/private-state-root.ts @@ -0,0 +1,26 @@ +/** + * Point GSTACK_HOME at a fresh private directory for every test in the calling + * file, and restore the previous value afterwards. Use in test files whose + * code under test writes state (error logs, caches) through the state root, so + * a run never appends to the developer's real ~/.gstack (#2895). + */ +import { afterEach, beforeEach } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export function usePrivateStateRoot(): { readonly dir: string } { + const state = { dir: '' }; + let previous: string | undefined; + beforeEach(() => { + previous = process.env.GSTACK_HOME; + state.dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-private-state-')); + process.env.GSTACK_HOME = state.dir; + }); + afterEach(() => { + if (previous === undefined) delete process.env.GSTACK_HOME; + else process.env.GSTACK_HOME = previous; + fs.rmSync(state.dir, { recursive: true, force: true }); + }); + return state; +} diff --git a/test/helpers/session-runner.ts b/test/helpers/session-runner.ts index ecf5f5460..702444c3b 100644 --- a/test/helpers/session-runner.ts +++ b/test/helpers/session-runner.ts @@ -19,7 +19,6 @@ import { resolveEvalModel } from '../../lib/eval-model'; const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev'); const HEARTBEAT_PATH = path.join(GSTACK_DEV_DIR, 'e2e-live.json'); // heartbeat stays global -const PROJECT_DIR = path.dirname(getProjectEvalDir()); // ~/.gstack/projects/$SLUG/ /** Sanitize test name for use as filename: strip leading slashes, replace / with - */ export function sanitizeTestName(name: string): string { @@ -302,7 +301,7 @@ Before source Reads, use Bash to run exactly \`date -u +%Y-%m-%dT%H:%M:%SZ\`. Af const safeName = testName ? sanitizeTestName(testName) : null; if (runId) { try { - runDir = path.join(PROJECT_DIR, 'e2e-runs', runId); + runDir = path.join(path.dirname(getProjectEvalDir()), 'e2e-runs', runId); fs.mkdirSync(runDir, { recursive: true }); } catch { /* non-fatal */ } } diff --git a/test/helpers/unreachable-gbrain.ts b/test/helpers/unreachable-gbrain.ts new file mode 100644 index 000000000..2ad41759e --- /dev/null +++ b/test/helpers/unreachable-gbrain.ts @@ -0,0 +1,22 @@ +/** + * Make `gbrain` resolve to a stub that fails at once, so tests that model an + * unreachable brain never reach a developer's installed gbrain (#2829). Call in + * beforeEach and pass the returned directory to the cleanup in afterEach; + * test-setup.ts restores PATH after every test. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export function useUnreachableGbrain(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-unreachable-gbrain-')); + fs.writeFileSync(path.join(dir, 'gbrain'), '#!/bin/sh\necho "gbrain unreachable (test stub)" >&2\nexit 1\n', { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'gbrain.cmd'), '@echo gbrain unreachable (test stub) 1>&2\r\n@exit /b 1\r\n'); + const key = Object.keys(process.env).find(name => name.toLowerCase() === 'path') ?? 'PATH'; + process.env[key] = `${dir}${path.delimiter}${process.env[key] ?? ''}`; + return dir; +} + +export function removeUnreachableGbrain(dir: string | undefined): void { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index df3b9575c..502bfa48d 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -3,9 +3,11 @@ import { spawnSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; +import { usePrivateStateRoot } from './helpers/private-state-root'; import { gitArgvIn } from './helpers/scratch-repo'; const ROOT = path.resolve(import.meta.dir, '..'); +usePrivateStateRoot(); const CAREFUL_SCRIPT = path.join(ROOT, 'careful', 'bin', 'check-careful.sh'); const FREEZE_SCRIPT = path.join(ROOT, 'freeze', 'bin', 'check-freeze.sh'); diff --git a/test/office-hours-attempt.test.ts b/test/office-hours-attempt.test.ts index e612d56c4..cbd1a9596 100644 --- a/test/office-hours-attempt.test.ts +++ b/test/office-hours-attempt.test.ts @@ -422,6 +422,7 @@ await import(join(root, 'test/skill-e2e-office-hours-brain-writeback.test.ts')); EVALS: mode === 'disabled' ? '' : '1', EVALS_ALL: '', EVALS_TIER: 'periodic', EVALS_SELECTION_JSON: JSON.stringify({ selected: mode === 'unselected' ? [] : null, reason: 'free fixture' }), EVALS_PREFLIGHT_OK: '1', GSTACK_EVAL_DIR: evalDir, GSTACK_CLAUDE_CLI_VERSION: 'free fixture', + GSTACK_HOME: path.join(dir, 'state'), }, stdout: 'pipe', stderr: 'pipe', }); @@ -629,7 +630,7 @@ await import(path.join(root, 'test', ${JSON.stringify(file)})); env: { ...process.env, EVALS: '1', EVALS_ALL: '', EVALS_TIER: 'periodic', EVALS_PREFLIGHT_OK: '1', EVALS_SELECTION_JSON: JSON.stringify({ selected: [id], reason: 'free format lifecycle' }), - GSTACK_EVAL_DIR: evalDir, GSTACK_CLAUDE_CLI_VERSION: 'free fixture', + GSTACK_EVAL_DIR: evalDir, GSTACK_CLAUDE_CLI_VERSION: 'free fixture', GSTACK_HOME: path.join(dir, 'state'), ANTHROPIC_API_KEY: 'free-fixture', ANTHROPIC_AUTH_TOKEN: '', ANTHROPIC_BASE_URL: 'http://127.0.0.1:1', }, }); diff --git a/test/plan-review-board-feedback.test.ts b/test/plan-review-board-feedback.test.ts index 915364547..3249aaab1 100644 --- a/test/plan-review-board-feedback.test.ts +++ b/test/plan-review-board-feedback.test.ts @@ -11,6 +11,9 @@ import { pickPlanReviewQuestion } from './helpers/plan-review-cases'; import type { NativeQuestion } from './helpers/plan-skill-questions'; import captured from './fixtures/design-board-questions.json'; import outsideVoices from './fixtures/design-outside-voices-question.json'; +import { usePrivateStateRoot } from './helpers/private-state-root'; + +usePrivateStateRoot(); let cwd: string; let stateFile: string; diff --git a/test/plan-review-report-recording.test.ts b/test/plan-review-report-recording.test.ts index 28354d1d6..53879a874 100644 --- a/test/plan-review-report-recording.test.ts +++ b/test/plan-review-report-recording.test.ts @@ -265,9 +265,12 @@ function runnerResult(exitReason: string, turnsUsed = 0, transcript: any[] = [{ costEstimate: { inputChars: 1, outputChars: 0, estimatedTokens: 0, estimatedCost: 0, turnsUsed } }; } function recordedClass(result: any, extra?: Partial) { - const collector = new EvalCollector('e2e'); - recordE2E(collector, 'infra-probe', 'Infra probe', result, extra); - return (collector as any).tests.at(-1)?.failure_class; + const evalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'report-recording-class-')); + try { + const collector = new EvalCollector('e2e', evalDir); + recordE2E(collector, 'infra-probe', 'Infra probe', result, extra); + return (collector as any).tests.at(-1)?.failure_class; + } finally { fs.rmSync(evalDir, { recursive: true, force: true }); } } test.each(['error_api', 'timeout_startup', 'error_output_stream', 'exit_code_1'])( diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 78a0a6b1d..f0cb450ac 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -18,6 +18,7 @@ const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepus const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact"); let repo: string; +let stateHome: string; function git(args: string[], cwd = repo): string { const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 }); @@ -73,6 +74,7 @@ const FAKE_AWS_KEY = ['AKIA', '1234567890ABCDEF'].join(''); beforeEach(() => { repo = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-")); + stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-state-")); git(["init", "-q", "-b", "main"]); git(["config", "user.email", "t@example.com"]); git(["config", "user.name", "T"]); @@ -81,6 +83,7 @@ beforeEach(() => { afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(stateHome, { recursive: true, force: true }); }); describe("pre-push hook gating", () => { @@ -405,10 +408,11 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(line), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip" }, + env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, timeout: 30_000, }); expect(r.status).toBe(0); + expect(fs.readFileSync(path.join(stateHome, "security", "prepush-skip.jsonl"), "utf8")).toContain("env-skip"); expect(fs.existsSync(seen)).toBe(true); expect(fs.readFileSync(seen, "utf8").trim()).toBe( `refs/heads/main ${sha} refs/heads/main ${ZERO}`, @@ -428,7 +432,7 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(`refs/heads/main ${"b".repeat(40)} refs/heads/main ${ZERO}\n`), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip" }, + env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, timeout: 30_000, }); expect(r.status).toBe(1); @@ -491,9 +495,10 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(`refs/heads/main ${sha} refs/heads/main ${ZERO}\n`), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip" }, + env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, }); expect(run.status).toBe(0); + expect(fs.readFileSync(path.join(stateHome, "security", "prepush-skip.jsonl"), "utf8")).toContain("env-skip"); expect(fs.readFileSync(seen, "utf8").trim()).toBe("refs/heads/main"); }); diff --git a/test/schema-version-migration.test.ts b/test/schema-version-migration.test.ts index 2cb9e1a82..51535fe7e 100644 --- a/test/schema-version-migration.test.ts +++ b/test/schema-version-migration.test.ts @@ -19,13 +19,16 @@ const SLOW_TIMEOUT = 60_000; import { mkdtempSync, existsSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { removeUnreachableGbrain, useUnreachableGbrain } from './helpers/unreachable-gbrain'; import { GSTACK_SCHEMA_PACK_VERSION } from '../scripts/brain-cache-spec'; let TMP_HOME: string; +let GBRAIN_STUB: string | undefined; const ORIGINAL_HOME = process.env.GSTACK_HOME; beforeEach(() => { TMP_HOME = mkdtempSync(join(tmpdir(), 'gstack-schema-test-')); + GBRAIN_STUB = useUnreachableGbrain(); process.env.GSTACK_HOME = TMP_HOME; delete require.cache[require.resolve('../bin/gstack-brain-cache')]; }); @@ -34,6 +37,7 @@ afterEach(() => { if (ORIGINAL_HOME) process.env.GSTACK_HOME = ORIGINAL_HOME; else delete process.env.GSTACK_HOME; try { rmSync(TMP_HOME, { recursive: true, force: true }); } catch { /* best effort */ } + removeUnreachableGbrain(GBRAIN_STUB); }); async function importCache(): Promise { diff --git a/test/ship-document-release-dispatch.test.ts b/test/ship-document-release-dispatch.test.ts index 02583cead..71c6b4ee5 100644 --- a/test/ship-document-release-dispatch.test.ts +++ b/test/ship-document-release-dispatch.test.ts @@ -289,6 +289,7 @@ describe('native docs fixture preflight', () => { test('the real preamble marker and private fixture evidence survive cleanup', () => { const fixture = fixtureDocs('risky', generated); + const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-evidence-')); let retained: string | undefined; try { const result = spawnSync('bash', [path.join(fixture.skills, 'bin/gstack-skill-start'), '--skill', 'document-release'], { @@ -298,14 +299,14 @@ describe('native docs fixture preflight', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('SESSION_KIND: spawned'); retained = preserveDocsEvidence(fixture, { output: 'preflight', toolCalls: [] } as unknown as SkillTestResult, - `docs-free-${path.basename(fixture.home)}`, 'fixture'); + `docs-free-${path.basename(fixture.home)}`, 'fixture', {}, evidenceRoot); fixture.clean(); expect(fs.existsSync(retained)).toBe(true); expect(fs.statSync(retained).mode & 0o777).toBe(0o600); expect(JSON.parse(fs.readFileSync(retained, 'utf8')).before.contents['SECURITY.md']).toBeDefined(); } finally { fixture.clean(); - if (retained) fs.rmSync(path.dirname(path.dirname(retained)), { recursive: true, force: true }); + fs.rmSync(evidenceRoot, { recursive: true, force: true }); } }); }); @@ -382,6 +383,7 @@ describe('native docs fixture preflight', () => { test('kernel evidence remains private and readable after fixture cleanup', async () => { const fixture = fixtureDocs('current', generated); const observer = await observeDocsWrites(fixture); + const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-evidence-')); let evidence: string | undefined; try { const file = path.join(fixture.repo, 'app.ts'); @@ -390,7 +392,7 @@ describe('native docs fixture preflight', () => { fs.writeFileSync(file, original); const observation = observer.stop(); evidence = preserveDocsEvidence(fixture, { output: 'observer preflight', toolCalls: [] }, - `docs-observer-${path.basename(fixture.home)}`, 'transient', { observation }); + `docs-observer-${path.basename(fixture.home)}`, 'transient', { observation }, evidenceRoot); fixture.clean(); expect(fs.statSync(evidence).mode & 0o777).toBe(0o600); const retained = JSON.parse(fs.readFileSync(evidence, 'utf8')).observation; @@ -398,7 +400,7 @@ describe('native docs fixture preflight', () => { expect(docsWriteFailures(retained, [])).toContain('forbidden docs write: app.ts'); } finally { fixture.clean(); - if (evidence) fs.rmSync(path.dirname(path.dirname(evidence)), { recursive: true, force: true }); + fs.rmSync(evidenceRoot, { recursive: true, force: true }); } }); diff --git a/test/spec-quality-gate-secret-sink.test.ts b/test/spec-quality-gate-secret-sink.test.ts index d44f838cb..f828a163c 100644 --- a/test/spec-quality-gate-secret-sink.test.ts +++ b/test/spec-quality-gate-secret-sink.test.ts @@ -1,8 +1,8 @@ /** Execute the actual generated redaction fence before observable dispatch/sinks. */ import { beforeAll, afterAll, describe, test, expect } from 'bun:test'; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, symlinkSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, symlinkSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { delimiter, join, resolve } from 'node:path'; const ROOT = resolve(import.meta.dir, '..'); let output: string; @@ -27,7 +27,15 @@ function runScan(host: 'claude' | 'codex', body: string, scanner: 'real' | 'brok const bin = join(runtime, 'bin'); const sinks = join(scratch, 'sinks'); const temps = join(scratch, 'tmp'); - for (const dir of [bin, sinks, temps]) mkdirSync(dir, { recursive: true }); + const systemTemps = join(scratch, 'system-tmp'); + const shims = join(scratch, 'shims'); + for (const dir of [bin, sinks, temps, systemTemps, shims]) mkdirSync(dir, { recursive: true }); + const realMktemp = Bun.which('mktemp'); + if (!realMktemp) throw new Error('mktemp is required for the spec redaction fixture'); + writeFileSync(join(shims, 'mktemp'), `#!/usr/bin/env bash +if [ "$#" -eq 0 ]; then exec '${realMktemp}' '${systemTemps}/tmp.XXXXXXXX'; fi +exec '${realMktemp}' "$@" +`, { mode: 0o755 }); writeFileSync(join(bin, 'gstack-config'), '#!/usr/bin/env bash\nprintf "public\\n"\n', { mode: 0o755 }); if (scanner === 'real') symlinkSync(join(ROOT, 'bin/gstack-redact'), join(bin, 'gstack-redact')); if (scanner === 'broken') writeFileSync(join(bin, 'gstack-redact'), '#!/usr/bin/env bash\nexit 70\n', { mode: 0o755 }); @@ -51,12 +59,16 @@ rm -f "$REDACT_FILE" `; try { const result = Bun.spawnSync(['bash', '-c', script], { cwd: scratch, - env: { ...process.env, GSTACK_ROOT: runtime, GSTACK_BIN: bin, SINK_DIR: sinks, TMPDIR: temps }, + env: { ...process.env, PATH: `${shims}${delimiter}${process.env.PATH ?? ''}`, + GSTACK_ROOT: runtime, GSTACK_BIN: bin, SINK_DIR: sinks, TMPDIR: temps }, stdout: 'pipe', stderr: 'pipe', timeout: 10_000, }); - return { code: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString(), + const stdout = result.stdout.toString(); + const printed = /^REDACT_FILE: (.+)$/m.exec(stdout)?.[1]; + return { code: result.exitCode, stdout, stderr: result.stderr.toString(), sinks: readdirSync(sinks).map(name => ({ name, body: readFileSync(join(sinks, name), 'utf8') })), - pending: readdirSync(temps).map(name => readFileSync(join(temps, name), 'utf8')) }; + pending: [temps, systemTemps].flatMap(dir => readdirSync(dir).map(name => readFileSync(join(dir, name), 'utf8'))), + printed: printed && existsSync(printed) ? readFileSync(printed, 'utf8') : undefined }; } finally { rmSync(scratch, { recursive: true, force: true }); } } @@ -79,6 +91,7 @@ for (const host of ['claude', 'codex'] as const) { expect(result.code).toBe(2); expect(result.sinks).toEqual([]); expect(result.pending).toEqual([body + '\n']); + expect(result.printed).toBe(body + '\n'); expect(result.stderr).toContain('paused'); }); for (const scanner of ['broken', 'missing'] as const) { diff --git a/test/test-free-shards.test.ts b/test/test-free-shards.test.ts index f735bb8e5..3ac204405 100644 --- a/test/test-free-shards.test.ts +++ b/test/test-free-shards.test.ts @@ -467,7 +467,7 @@ describe('test-free-shards: exclusive host-state phase', () => { let child: ReturnType | undefined; let watchdog: ReturnType | undefined; try { - for (const file of ['scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'lib/state-root.ts', + for (const file of ['scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', 'lib/state-root.ts', 'test/helpers/paid-test-set.ts', 'test/helpers/touchfiles.ts', 'test/helpers/touchfiles-data.ts', 'test/helpers/test-selection.ts']) { const target = path.join(directory, file); fs.mkdirSync(path.dirname(target), { recursive: true });