/** * Launch-scoped REPO SNAPSHOTS for grounding governed interactive runs (CREW-UX-8 v4). * * WHY a snapshot instead of the live repo: the interactive seams launch their governed runs * UNBOUND — on a repoRef-bound run the worker's ACP tool-permission stream closes on the first * call that needs a permission prompt and the session dies (wicked-core#293) — and an unbound * worker's governance boundary is {sandbox, extraWriteRoots, ~/.claude/plugins}, so even READS * of the live repo root are governance-denied (wicked-core#294; the v3 "hand the worker the * root path to read" design rested on read evidence gathered under a *bound* boundary, which * does not transfer). The one location an unbound worker can always read is the inbox the run * already writes to — write roots are readable (wicked-core#259) — so grounding means putting * a copy of the repo INSIDE the inbox, crew-side, BEFORE the launch, and naming that snapshot * in the task. * * Mechanism, in preference order: * 1. `git clone --depth 1 --no-hardlinks file:// ` — a shallow LOCAL clone: * one commit of history, `--no-hardlinks` so the snapshot never aliases the live object * store. Because a clone checks out EVERY tracked path, the result is then swept: tracked * `node_modules` trees and every symlink are removed (see below), and the swept snapshot * is re-measured against the budget before it counts as usable. * 2. A plain file copy that skips `.git`/`node_modules` AND symlinks — for roots that are not * git repos (or where git itself is unavailable/failing). * * SYMLINKS NEVER SURVIVE into a snapshot, on either path (Copilot, crew#313): the snapshot * lands inside the worker-visible inbox, so a repo symlink pointing OUTSIDE the repo would * hand the worker readable reach beyond the governance boundary — and a link to a huge tree * would bypass the size cap the preflight walk (which skips links) enforced. The copy filter * refuses them and the post-clone sweep deletes the ones a clone checked out. * * The snapshot is CAPPED: a working tree whose (`.git`/`node_modules`/symlink-excluded) size * exceeds the budget is not snapshotted at all — the caller degrades honestly to an ungrounded * launch with a visible status note, never a truncated half-repo the worker would mistake for * the whole. Snapshots are launch-scoped: the seams remove them when the run reaches a * terminal state (finalize/failure folds), so the inbox never accretes dead clones. * * OVERLAP REFUSAL: a configured draft dir could place `dest` inside the live repo (or a repo * could be registered AT the inbox). Clearing a stale dest with `rm -rf` before noticing that * would DELETE live repository content — so source/dest containment is checked (realpath'd, * both directions) BEFORE anything is removed, and any overlap refuses the snapshot outright. * For the same reason THIS helper creates `dest`'s parent directory (after the check passes) — * a caller that pre-created it would already have written into the live repo (Copilot round 2). * * Fully ASYNC on purpose: this runs inline in a bus handler whose process also feeds ~15s UI * heartbeats — a synchronous clone/copy of a big repo would starve the event loop and the * canvas would read frozen (Copilot, crew#313). All I/O is fs/promises; the clone goes through * the daemon's capped exec chokepoint (`execCapped`, FINDING-016). */ import path from 'node:path'; import type { CoreAdapter } from '../core/adapter.js'; /** Default snapshot budget (~200MB). A repo whose working tree exceeds this is not * snapshotted — the launch proceeds ungrounded, honestly narrated. */ export declare const REPO_SNAPSHOT_MAX_BYTES: number; /** Why a snapshot was refused — each maps to a DIFFERENT operator-facing degrade message in * the calling seam, so "too large" is never claimed about a repo that was merely unreadable * (Copilot, crew#313). */ export type SnapshotFailureReason = /** `rootPath` does not exist or is not a directory. */ 'root-unreadable' /** The tree (preflight) — or the swept clone (post-check) — exceeds the byte budget. */ | 'too-large' /** `rootPath` and `dest` overlap (either direction) — clearing/cloning would eat live data. */ | 'dest-overlap' /** The stale `dest` could not be cleared. */ | 'dest-unclearable' /** Both the git clone and the file-copy fallback failed. */ | 'copy-failed'; /** The honest outcome of {@link snapshotRepo}: usable snapshot, or the reason there is none. */ export type SnapshotResult = { ok: true; } | { ok: false; reason: SnapshotFailureReason; }; /** Resolve `p` through its nearest EXISTING ancestor's realpath (the path itself may not exist * yet — a fresh dest usually does not), so symlinked parents cannot hide an overlap. Only * ENOENT falls to the ancestor walk (the `api/open-path.ts` rule, Copilot round 2): any OTHER * error (EACCES, ELOOP, …) means an EXISTING component could not be resolved — a lexical * reconstruction there could miss an overlap through the unresolved link, so it THROWS and * the caller's overlap-check catch fails closed (no snapshot, degrade). */ export declare function realpathNearest(p: string): Promise; /** The subset of `node:path` {@link pathsOverlap} needs — injectable so the win32 drive-root * behavior is unit-testable on any host (`path.win32`). */ type PathImpl = Pick; /** `true` when `a` and `b` name the same path or one contains the other — including when one * of them is a filesystem root (`/`, a win32 drive root). Exported for its unit tests. */ export declare function pathsOverlap(a: string, b: string, p?: PathImpl): boolean; /** Options for {@link snapshotRepo}. */ export interface SnapshotRepoOptions { /** Size budget in bytes (default {@link REPO_SNAPSHOT_MAX_BYTES}; tests shrink it). */ maxBytes?: number | undefined; /** Diagnostics sink — every degrade path logs its real reason here. */ log?: ((message: string) => void) | undefined; } /** * Materialize a snapshot of the repo at `rootPath` into `dest` (creating it), for a worker to * READ as grounding context. Resolves `{ok: true}` when `dest` now holds a usable snapshot, * `{ok: false, reason}` when it does not — see {@link SnapshotFailureReason}; the reason lets * the caller narrate the RIGHT degrade (too large ≠ unreadable ≠ clone failed) — in which case * any partial `dest` has been removed and the caller must launch UNGROUNDED (and say so on the * thread). Never rejects: a grounding failure must degrade the launch, not kill it. * * The clone is the git-preferred path, so on a real repo the snapshot is HEAD's tracked * content (uncommitted/untracked files are not included) minus swept symlinks/`node_modules`; * the copy fallback takes the working tree as-is minus `.git`/`node_modules`/symlinks. */ export declare function snapshotRepo(rootPath: string, dest: string, opts?: SnapshotRepoOptions): Promise; /** The registry could not be enumerated or a registered root could not be resolved — the inbox * cannot be PROVEN clear, so the caller refuses the launch (codex on crew#506: fail closed). */ export declare class RunDirUnverifiableError extends Error { constructor(message: string); } /** * The registered repository whose root a run's inbox would sit INSIDE (or contain), or `null` when * the inbox is PROVABLY clear of every registered repo (codex on crew#506, CRITICAL). A seam * declares its per-run directory as the worker's extra write root, so an inbox configured inside a * live repository — `--interactive-*-dir` pointing into a checkout, or a repo registered AT the * inbox — would hand the unbound worker write access to live source no matter what the run is * about. The per-repo snapshot check catches only the repo being snapshotted; this walks the WHOLE * registry BEFORE anything is created, realpath'd both sides (symlinked parents cannot hide an * overlap). * * FAIL CLOSED: a registry that cannot be listed (engine/addon hiccup) or a root that cannot be * resolved for any reason but plain ENOENT THROWS {@link RunDirUnverifiableError} — "could not * check" is never read as "clear". A root that does not exist (ENOENT — a stale registration) is * CANONICALIZED through its nearest existing ancestor (`realpathNearest`, codex on crew#506): a * missing root beneath a symlinked ancestor then compares in the same namespace as the run dir, * so an inbox configured inside the registered path is still caught. */ export declare function runDirInsideRepo(adapter: CoreAdapter, runDir: string): Promise; export {}; //# sourceMappingURL=repo-snapshot.d.ts.map