Files
gstack/scripts/lib/free-home-guard.ts
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* 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)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

164 lines
8.0 KiB
TypeScript

/**
* 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.
*
* A shard that runs alone owns its window, so its guard names its files.
* Concurrent shards share one HOME and cannot tell whose write a change was, so
* the runner guards that phase once and names no file; `--attribute-home`
* then runs every file alone in a private HOME, where any write is that file's.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { forEachFileAlone } from './shard-engine';
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.map(entry => path.normalize(entry)));
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();
}
/** Who owns the snapshotted window: one shard, a concurrent phase, or one file in a private HOME. */
export type FreeHomeScope = { kind: 'shard' } | { kind: 'concurrent'; shards: number } | { kind: 'private' };
export function formatFreeHomeChange(changed: string[], files: string[], scope: FreeHomeScope = { kind: 'shard' }): 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)` : '';
const watched = `(watches ${FREE_HOME_SURFACES.map(s => `~/${s}`).join(', ')} only): ${shown.join(', ')}${more}.`;
const fix = 'Give the writer a private HOME/GSTACK_HOME.';
if (scope.kind === 'concurrent') {
return `real home changed while ${scope.shards} shards ran concurrently ${watched} They share one HOME, so no shard or file `
+ `is named. Find the writer with \`bun run scripts/test-free-shards.ts --attribute-home\` (each file alone in a private HOME). ${fix}`;
}
if (scope.kind === 'private') return `${files.join(', ')} wrote its private HOME ${watched} With the real HOME it writes there. ${fix}`;
return `real home changed while this shard ran ${watched} ${fix} Shard files: ${files.join(', ')}`;
}
export interface FreeHomeGuard { verify(): string | null }
/** How runFreeShard guards one shard; it may redirect `env` before the child starts. */
export type FreeHomeGuardFactory = (files: string[], env: NodeJS.ProcessEnv, stateDir: string) => FreeHomeGuard;
/** Take the baseline now; verify() returns null or the failure to report. */
export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env, scope: FreeHomeScope = { kind: 'shard' }): FreeHomeGuard {
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, scope) : null;
} catch (error) { return unreadable(error); }
},
};
}
/** For a shard inside a concurrently guarded phase: the phase guard reports, this one never does. */
export const sharedFreeHome = (): FreeHomeGuard => ({ verify: () => null });
/**
* Redirect one shard's HOME to `<stateDir>/home` and guard that instead. The
* browser cache and git identity still come from the real home, so tests run
* as they would there; only home writes are diverted.
*/
export function privateFreeHome(files: string[], env: NodeJS.ProcessEnv, stateDir: string): FreeHomeGuard {
const real = freeHomeDir(env);
const home = path.join(stateDir, 'home');
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(real, process.platform === 'darwin' ? 'Library/Caches' : '.cache', 'ms-playwright');
if (!env.GIT_CONFIG_GLOBAL && fs.existsSync(path.join(real, '.gitconfig'))) env.GIT_CONFIG_GLOBAL = path.join(real, '.gitconfig');
env.HOME = home;
if (env.USERPROFILE !== undefined) env.USERPROFILE = home;
return guardFreeHome(files, env, { kind: 'private' });
}
/**
* `--attribute-home`: run each reader file alone with a private HOME, `jobs`
* at a time, then each exclusive host-state file alone, and name every file
* that wrote a watched surface. Exit 1 when any did.
*/
export async function attributeFreeHomeWriters(
readers: string[], exclusive: string[], jobs: number,
runAlone: (file: string, index: number, homeGuard: FreeHomeGuardFactory) => Promise<unknown>,
): Promise<number> {
const writers: string[] = [];
console.log(`[test:free] attributing home writes: ${readers.length + exclusive.length} files, each alone in a private HOME, ${jobs} at a time`);
await forEachFileAlone(readers, exclusive, jobs, async (file, index) => {
let change = null as string | null;
await runAlone(file, index, (files, env, stateDir) => {
const guard = privateFreeHome(files, env, stateDir);
return { verify: () => (change = guard.verify()) };
});
if (change) writers.push(change);
});
for (const writer of writers.sort()) console.error(`[test:free] ✗ ${writer}`);
console.log(`[test:free] ${writers.length ? `${writers.length} file(s) wrote home surfaces` : 'no file wrote a watched home surface'}`);
return writers.length ? 1 : 0;
}