Skill Tracer
Skills › gstack › gstack › free-home-guard.ts

free-home-guard.ts

163 lines · scripts/lib/free-home-guard.ts at 3f79d81

1/**
2 * Free-suite home-write tripwire (#2895).
3 *
4 * Snapshots path + mtime + size of the gstack install and config surfaces under
5 * the real home before and after each free shard, and names the shard's files
6 * when one of those entries changed. It watches only these surfaces; it is not
7 * a universal write-containment check. Live agent-session logs that a
8 * concurrently running host keeps writing are excluded, and directory mtimes
9 * are ignored, so only file, link and directory-set changes count.
10 *
11 * A shard that runs alone owns its window, so its guard names its files.
12 * Concurrent shards share one HOME and cannot tell whose write a change was, so
13 * the runner guards that phase once and names no file; `--attribute-home`
14 * then runs every file alone in a private HOME, where any write is that file's.
15 */
16import * as fs from 'node:fs';
17import * as os from 'node:os';
18import * as path from 'node:path';
19import { forEachFileAlone } from './shard-engine';
21export const FREE_HOME_SURFACES = ['.gstack', '.claude', '.codex', '.agents', '.config/gstack'] as const;
23export const FREE_HOME_VOLATILE = [
24 '.claude/projects', '.claude/todos', '.claude/shell-snapshots', '.claude/statsig', '.claude/ide',
25 '.claude/session-env', '.claude/file-history', '.claude/debug', '.claude/telemetry', '.claude/history.jsonl',
26 '.claude/.credentials.json', '.codex/sessions', '.codex/archived_sessions', '.codex/log', '.codex/history.jsonl',
27 '.codex/auth.json',
28] as const;
30const MAX_DEPTH = 4;
31const MAX_REPORTED = 10;
33export type FreeHomeSnapshot = Map<string, string>;
35export function freeHomeDir(env: NodeJS.ProcessEnv = process.env): string {
36 return env.HOME || env.USERPROFILE || os.homedir();
39export function snapshotFreeHome(home: string): FreeHomeSnapshot {
40 const entries: FreeHomeSnapshot = new Map();
41 const volatile = new Set<string>(FREE_HOME_VOLATILE.map(entry => path.normalize(entry)));
42 const visit = (relative: string, depth: number) => {
43 if (volatile.has(relative)) return;
44 const absolute = path.join(home, relative);
45 let stat: fs.BigIntStats;
46 try {
47 stat = fs.lstatSync(absolute, { bigint: true });
48 } catch (error) {
49 if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
50 throw error;
51 }
52 if (stat.isSymbolicLink()) {
53 entries.set(relative, `link:${fs.readlinkSync(absolute)}:${stat.mtimeNs}`);
54 return;
55 }
56 if (!stat.isDirectory()) {
57 entries.set(relative, `file:${stat.size}:${stat.mtimeNs}`);
58 return;
59 }
60 entries.set(relative, 'dir');
61 if (depth >= MAX_DEPTH) return;
62 let names: string[];
63 try {
64 names = fs.readdirSync(absolute);
65 } catch (error) {
66 const code = (error as NodeJS.ErrnoException).code ?? '';
67 if (['EACCES', 'EPERM'].includes(code)) entries.set(relative, 'dir:unreadable');
68 if (['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(code)) return;
69 throw error;
70 }
71 for (const name of names.sort()) visit(path.join(relative, name), depth + 1);
72 };
73 for (const surface of FREE_HOME_SURFACES) visit(path.normalize(surface), 0);
74 return entries;
77export function diffFreeHome(before: FreeHomeSnapshot, after: FreeHomeSnapshot): string[] {
78 const changed = new Set<string>();
79 for (const [entry, value] of before) if (after.get(entry) !== value) changed.add(entry);
80 for (const entry of after.keys()) if (!before.has(entry)) changed.add(entry);
81 return [...changed].sort();
84/** Who owns the snapshotted window: one shard, a concurrent phase, or one file in a private HOME. */
85export type FreeHomeScope = { kind: 'shard' } | { kind: 'concurrent'; shards: number } | { kind: 'private' };
87export function formatFreeHomeChange(changed: string[], files: string[], scope: FreeHomeScope = { kind: 'shard' }): string {
88 const shown = changed.slice(0, MAX_REPORTED).map(entry => `~/${entry.split(path.sep).join('/')}`);
89 const more = changed.length > MAX_REPORTED ? ` (+${changed.length - MAX_REPORTED} more)` : '';
90 const watched = `(watches ${FREE_HOME_SURFACES.map(s => `~/${s}`).join(', ')} only): ${shown.join(', ')}${more}.`;
91 const fix = 'Give the writer a private HOME/GSTACK_HOME.';
92 if (scope.kind === 'concurrent') {
93 return `real home changed while ${scope.shards} shards ran concurrently ${watched} They share one HOME, so no shard or file `
94 + `is named. Find the writer with \`bun run scripts/test-free-shards.ts --attribute-home\` (each file alone in a private HOME). ${fix}`;
95 }
96 if (scope.kind === 'private') return `${files.join(', ')} wrote its private HOME ${watched} With the real HOME it writes there. ${fix}`;
97 return `real home changed while this shard ran ${watched} ${fix} Shard files: ${files.join(', ')}`;
100export interface FreeHomeGuard { verify(): string | null }
101/** How runFreeShard guards one shard; it may redirect `env` before the child starts. */
102export type FreeHomeGuardFactory = (files: string[], env: NodeJS.ProcessEnv, stateDir: string) => FreeHomeGuard;
104/** Take the baseline now; verify() returns null or the failure to report. */
105export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env, scope: FreeHomeScope = { kind: 'shard' }): FreeHomeGuard {
106 const home = freeHomeDir(env);
107 const unreadable = (error: unknown) => `real home surfaces could not be snapshotted (${(error as NodeJS.ErrnoException).code ?? 'error'}); shard files: ${files.join(', ')}`;
108 let before: FreeHomeSnapshot | undefined;
109 let baselineError: string | undefined;
110 try { before = snapshotFreeHome(home); } catch (error) { baselineError = unreadable(error); }
111 return {
112 verify() {
113 if (!before) return baselineError!;
114 try {
115 const changed = diffFreeHome(before, snapshotFreeHome(home));
116 return changed.length ? formatFreeHomeChange(changed, files, scope) : null;
117 } catch (error) { return unreadable(error); }
118 },
119 };
122/** For a shard inside a concurrently guarded phase: the phase guard reports, this one never does. */
123export const sharedFreeHome = (): FreeHomeGuard => ({ verify: () => null });
125/**
126 * Redirect one shard's HOME to `<stateDir>/home` and guard that instead. The
127 * browser cache and git identity still come from the real home, so tests run
128 * as they would there; only home writes are diverted.
129 */
130export function privateFreeHome(files: string[], env: NodeJS.ProcessEnv, stateDir: string): FreeHomeGuard {
131 const real = freeHomeDir(env);
132 const home = path.join(stateDir, 'home');
133 fs.mkdirSync(home, { recursive: true, mode: 0o700 });
134 env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(real, process.platform === 'darwin' ? 'Library/Caches' : '.cache', 'ms-playwright');
135 if (!env.GIT_CONFIG_GLOBAL && fs.existsSync(path.join(real, '.gitconfig'))) env.GIT_CONFIG_GLOBAL = path.join(real, '.gitconfig');
136 env.HOME = home;
137 if (env.USERPROFILE !== undefined) env.USERPROFILE = home;
138 return guardFreeHome(files, env, { kind: 'private' });
141/**
142 * `--attribute-home`: run each reader file alone with a private HOME, `jobs`
143 * at a time, then each exclusive host-state file alone, and name every file
144 * that wrote a watched surface. Exit 1 when any did.
145 */
146export async function attributeFreeHomeWriters(
147 readers: string[], exclusive: string[], jobs: number,
148 runAlone: (file: string, index: number, homeGuard: FreeHomeGuardFactory) => Promise<unknown>,
149): Promise<number> {
150 const writers: string[] = [];
151 console.log(`[test:free] attributing home writes: ${readers.length + exclusive.length} files, each alone in a private HOME, ${jobs} at a time`);
152 await forEachFileAlone(readers, exclusive, jobs, async (file, index) => {
153 let change = null as string | null;
154 await runAlone(file, index, (files, env, stateDir) => {
155 const guard = privateFreeHome(files, env, stateDir);
156 return { verify: () => (change = guard.verify()) };
157 });
158 if (change) writers.push(change);
159 });
160 for (const writer of writers.sort()) console.error(`[test:free] ✗ ${writer}`);
161 console.log(`[test:free] ${writers.length ? `${writers.length} file(s) wrote home surfaces` : 'no file wrote a watched home surface'}`);
162 return writers.length ? 1 : 0;