mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 07:47:27 +01:00
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:
1 parent
aefcb1bd4e
commit
615c0dcaa6
38 files changed
+447
-65
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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')],
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' ');
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'])(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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')> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user