test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

This commit is contained in:
garrytan committed 2026-10-02 20:09:36 +00:00
1 parent aefcb1bd4e
commit 615c0dcaa6
38 files changed
+447 -65

No files matched your search

+9 -10
View File
@@ -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<void> {
if (dirEnsured) return;
async function ensureDir(dir: string): Promise<void> {
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;
}
+38 -3
View File
@@ -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 });
+9 -1
View File
@@ -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);
});
+15 -1
View File
@@ -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'.
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -87,7 +87,7 @@ let shuttingDown = false;
let serverRef: ReturnType<typeof Bun.serve> | null = null;
let idleInterval: ReturnType<typeof setInterval> | 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);
}
+3
View File
@@ -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;
+1
View File
@@ -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 ?? {}),
};
+3
View File
@@ -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;
+2 -4
View File
@@ -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<T>(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');
+102
View File
@@ -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<string, string>;
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<string>(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<string>();
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); }
},
};
}
+7 -7
View File
@@ -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<BunTestOutputClassifier['end']>; 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')],
});
+17 -1
View File
@@ -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<typeof import('../bin/gstack-brain-cache')> {
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();
+4 -3
View File
@@ -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');
});
+3
View File
@@ -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,
});
+105
View File
@@ -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 });
}
}
});
});
+3
View File
@@ -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 {
+3
View File
@@ -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";
+3
View File
@@ -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";
+2 -2
View File
@@ -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) {
+3 -3
View File
@@ -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);
+2 -2
View File
@@ -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"));
+2 -2
View File
@@ -145,9 +145,9 @@ export function fixtureDocs(scenario: DocsScenario, generatedRoot = process.env.
};
}
export function preserveDocsEvidence(fixture: ReturnType<typeof fixtureDocs>, result: Pick<SkillTestResult, 'output' | 'toolCalls'>, runId: string, name: string, extra: Record<string, unknown> = {}): string {
export function preserveDocsEvidence(fixture: ReturnType<typeof fixtureDocs>, result: Pick<SkillTestResult, 'output' | 'toolCalls'>, runId: string, name: string, extra: Record<string, unknown> = {}, 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');
+2 -1
View File
@@ -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
+5 -2
View File
@@ -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(' ');
+1 -1
View File
@@ -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<typeof createNativeReviewState> | undefined;
const env: Record<string, string> = {};
const cleanup = () => {
+26
View File
@@ -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;
}
+1 -2
View File
@@ -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 */ }
}
+22
View File
@@ -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 });
}
+2
View File
@@ -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');
+2 -1
View File
@@ -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',
},
});
+3
View File
@@ -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;
+6 -3
View File
@@ -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<EvalTestEntry>) {
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'])(
+8 -3
View File
@@ -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");
});
+4
View File
@@ -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<typeof import('../bin/gstack-brain-cache')> {
+6 -4
View File
@@ -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 });
}
});
+19 -6
View File
@@ -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) {
+1 -1
View File
@@ -467,7 +467,7 @@ describe('test-free-shards: exclusive host-state phase', () => {
let child: ReturnType<typeof Bun.spawn> | undefined;
let watchdog: ReturnType<typeof setTimeout> | 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 });