mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
164 lines
8.0 KiB
TypeScript
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;
|
|
}
|