@agent-delivery-harness/cli 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/boundary.ts +16 -5
- package/src/check-snapshot.ts +297 -36
- package/src/command-activity.ts +15 -1
- package/src/commands/admit.ts +5 -4
- package/src/commands/emit-review-evidence.ts +1 -0
- package/src/commands/gate.ts +59 -24
- package/src/commands/prepare.ts +22 -13
- package/src/commands/record.ts +136 -122
- package/src/commands/resume.ts +3 -0
- package/src/commands/review-context.ts +1 -0
- package/src/commands/save-context.ts +9 -2
- package/src/commands/submit-evidence.ts +18 -4
- package/src/commands/verify.ts +5 -0
- package/src/declared-checks.ts +4 -4
- package/src/delivery-posture.ts +39 -0
- package/src/installed-data-resources.ts +57 -0
- package/src/installed-data-worker.ts +83 -0
- package/src/review-evidence.ts +1 -1
- package/src/scoped-attempts.ts +34 -8
- package/src/scoped-budget.ts +20 -0
- package/src/scoped-checks.ts +561 -59
- package/src/scoped-diagnostics.ts +82 -4
- package/src/scoped-installed-data.ts +8 -0
- package/src/snapshot-inventory-worker.ts +160 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-delivery-harness/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Delivery harness CLI: admit, prepare, review-context, submit-evidence, gate, record, verify, check",
|
|
6
6
|
"license": "FSL-1.1-ALv2",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=22.6.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@agent-delivery-harness/kernel": "0.
|
|
16
|
+
"@agent-delivery-harness/kernel": "0.8.1"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts"
|
package/src/boundary.ts
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
type ProviderRailInvocationResult,
|
|
64
64
|
type ProviderRailSession,
|
|
65
65
|
} from "./provider-rails.ts";
|
|
66
|
-
import { beginCommandObservation, type CommandObservation } from "./command-activity.ts";
|
|
66
|
+
import { beginCommandObservation, observeCandidateCaptures, type CommandObservation } from "./command-activity.ts";
|
|
67
67
|
|
|
68
68
|
// ── Exit codes ───────────────────────────────────────────────────────────────
|
|
69
69
|
|
|
@@ -91,7 +91,9 @@ export class CliInterruption extends Error {
|
|
|
91
91
|
* the typed blockers to render; `usage` is an argument or invocation error.
|
|
92
92
|
*/
|
|
93
93
|
export type CommandResult =
|
|
94
|
-
| { readonly kind: "ok"; readonly summary?: string; readonly digest?: string; readonly preparation?: RunPreparationObservation
|
|
94
|
+
| { readonly kind: "ok"; readonly summary?: string; readonly digest?: string; readonly preparation?: RunPreparationObservation;
|
|
95
|
+
/** The command did not do its work, so no completion of it goes in the run's journal. */
|
|
96
|
+
readonly unobserved?: true }
|
|
95
97
|
| { readonly kind: "blocked"; readonly blockers: readonly Blocker[] }
|
|
96
98
|
| { readonly kind: "usage"; readonly message: string };
|
|
97
99
|
|
|
@@ -136,12 +138,17 @@ export interface CommandContext {
|
|
|
136
138
|
}) => Promise<ProviderRailInvocationResult | undefined>;
|
|
137
139
|
/** Emits one line of operator-facing output to stdout. */
|
|
138
140
|
readonly write: (text: string) => void;
|
|
141
|
+
/** Emits one line of operator-facing output to stderr, leaving stdout as it is. */
|
|
142
|
+
readonly writeError: (text: string) => void;
|
|
139
143
|
/** Classifies the execution context from this invocation's env + TTY. */
|
|
140
144
|
classifyContext(): ExecutionContext;
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
export interface CommandDescriptor {
|
|
144
148
|
readonly name: string;
|
|
149
|
+
/** Opt in to progress from this command's fresh captures. Other descriptors
|
|
150
|
+
* retain the boundary's eager capture; this flag grants no candidate reuse. */
|
|
151
|
+
readonly capturesCandidate?: boolean;
|
|
145
152
|
/** The blocker `source.id` this command stamps on failures it raises itself. */
|
|
146
153
|
readonly sourceId: string;
|
|
147
154
|
readonly summary: string;
|
|
@@ -426,7 +433,7 @@ export async function runCliBoundary(
|
|
|
426
433
|
? await beginCommandObservation(runtime.cwd, descriptor.name) : undefined;
|
|
427
434
|
let completed: Extract<CommandResult, { kind: "ok" }> | undefined;
|
|
428
435
|
const code = await runConfiguredCommand(descriptor, args, runtime, value => { completed = value; }, observation);
|
|
429
|
-
await observation?.finish(code, Date.now() - startedAt, completed?.digest, completed?.preparation);
|
|
436
|
+
if (!completed?.unobserved) await observation?.finish(code, Date.now() - startedAt, completed?.digest, completed?.preparation);
|
|
430
437
|
return code;
|
|
431
438
|
}
|
|
432
439
|
|
|
@@ -491,11 +498,14 @@ async function runConfiguredCommand(
|
|
|
491
498
|
|
|
492
499
|
let wiringPromise: Promise<RepoWiring> | undefined;
|
|
493
500
|
const wire = (): Promise<RepoWiring> => {
|
|
494
|
-
wiringPromise ??= wireRepo(runtime.cwd, config)
|
|
501
|
+
wiringPromise ??= wireRepo(runtime.cwd, config).then(wiring =>
|
|
502
|
+
descriptor.capturesCandidate && observation?.needsCandidate
|
|
503
|
+
? { ...wiring, captureCandidate: observeCandidateCaptures(wiring.captureCandidate, observation) }
|
|
504
|
+
: wiring);
|
|
495
505
|
return wiringPromise;
|
|
496
506
|
};
|
|
497
507
|
|
|
498
|
-
if (observation?.needsCandidate) {
|
|
508
|
+
if (observation?.needsCandidate && !descriptor.capturesCandidate) {
|
|
499
509
|
try {
|
|
500
510
|
const capture = await (await wire()).captureCandidate();
|
|
501
511
|
if (capture.ok) await observation.start(capture.candidate.treeSha);
|
|
@@ -579,6 +589,7 @@ async function runConfiguredCommand(
|
|
|
579
589
|
}
|
|
580
590
|
},
|
|
581
591
|
write: (text) => runtime.stdout(`${text}\n`),
|
|
592
|
+
writeError: (text) => runtime.stderr(`${text}\n`),
|
|
582
593
|
classifyContext: () =>
|
|
583
594
|
classifyExecutionContext({
|
|
584
595
|
config,
|
package/src/check-snapshot.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Private execution tree. Never shares writable source, objects, index or dependencies. */
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { mkdir, mkdtemp, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { createExecPort, type ExecOutcome } from "@agent-delivery-harness/kernel";
|
|
6
|
+
import { candidateTreeSourceReader, capturePhysicalSource, createExecPort, isPhysicalSourceDefinition, physicalSourceOutputOverlap, type CandidateCommandRunner, type PhysicalSourceDefinition, type ExecOutcome } from "@agent-delivery-harness/kernel";
|
|
7
7
|
|
|
8
8
|
import { snapshotInventoryWorker } from "./snapshot-inventory-worker.ts";
|
|
9
9
|
|
|
@@ -12,14 +12,20 @@ export class CheckSnapshotError extends Error {
|
|
|
12
12
|
readonly code: string;
|
|
13
13
|
constructor(code: string, message: string) { super(message); this.code = code; }
|
|
14
14
|
}
|
|
15
|
+
export type SnapshotPhase = "materialization" | "baseline-inventory" | "dependencies" | "post-dependency-inventory" | "clone" | "verification";
|
|
16
|
+
/** Copy-on-write cloning: APFS `clonefile(2)` on macOS, a reflink copy on Linux. */
|
|
17
|
+
export type CloneMethod = "clonefile" | "reflink";
|
|
15
18
|
export interface SnapshotRequest {
|
|
16
19
|
readonly rootDir: string;
|
|
17
20
|
readonly candidate: { readonly treeSha: string; readonly headSha: string; readonly base: { readonly ref: string; readonly tipSha: string; readonly mergeBaseSha: string } };
|
|
18
21
|
readonly outputs: readonly string[];
|
|
19
22
|
readonly gitContext?: "full" | "none";
|
|
23
|
+
readonly physicalSource?: PhysicalSourceDefinition;
|
|
20
24
|
readonly environment: Readonly<Record<string, string>>;
|
|
21
25
|
readonly dependencies?: { readonly command: readonly [string, ...string[]]; readonly timeoutMs: number };
|
|
22
26
|
readonly onDependencyResult?: (result: ExecOutcome, durationMs: number) => void;
|
|
27
|
+
/** Best-effort phase timing only; never admission evidence. */
|
|
28
|
+
readonly onPhase?: (phase: SnapshotPhase, durationMs: number) => void;
|
|
23
29
|
readonly signal?: AbortSignal;
|
|
24
30
|
}
|
|
25
31
|
export interface CheckSnapshot {
|
|
@@ -27,9 +33,76 @@ export interface CheckSnapshot {
|
|
|
27
33
|
readonly rootDir: string;
|
|
28
34
|
readonly environment: Readonly<Record<string, string>>;
|
|
29
35
|
readonly dependencyDigest: string;
|
|
30
|
-
|
|
36
|
+
/** False when the setup wrote this tree's own absolute path into one of its files: a clone would
|
|
37
|
+
* still read and write this tree through that path, so each worker builds its own instead. */
|
|
38
|
+
readonly clonable: boolean;
|
|
39
|
+
/** What `verify` checks when given `unchanged`: Git's index and untracked listing, or (finite snapshots, which have no Git) the full inventory. */
|
|
40
|
+
readonly reusedVerification: "index" | "inventory";
|
|
41
|
+
/** `outputs` narrows the writable paths to one profile's within a snapshot shared by several.
|
|
42
|
+
* `unchanged` verifies a reused tree that a full verification already accepted after its last
|
|
43
|
+
* command: untracked paths may exist only where the setup left them or inside `allowed`. */
|
|
44
|
+
verify(options?: { readonly timeoutMs?: number; readonly outputs?: readonly string[]; readonly unchanged?: { readonly allowed: readonly string[] } }): Promise<void>;
|
|
45
|
+
/** A private copy-on-write tree of this one, which stays untouched. The clone's own phases go to `onPhase`. */
|
|
46
|
+
clone(method: CloneMethod, onPhase?: (phase: SnapshotPhase, durationMs: number) => void): Promise<CheckSnapshot>;
|
|
31
47
|
cleanup(): Promise<void>;
|
|
32
48
|
}
|
|
49
|
+
/** A materialized tree: the work tree, the Git-none control directory, and the directories cleanup removes. */
|
|
50
|
+
interface PrivateTree { readonly rootDir: string; readonly privateControl?: string; readonly owned: readonly string[] }
|
|
51
|
+
const nulSeparated = (text: string) => text.split("\0").filter(Boolean);
|
|
52
|
+
/** A listed path (a directory ends in a slash) lies at or below a declared output. As in the
|
|
53
|
+
* inventory, an output without a trailing slash that is a directory covers what is inside it. */
|
|
54
|
+
function withinOutputs(entry: string, outputs: readonly string[]): boolean {
|
|
55
|
+
return outputs.some(output => { const root = output.replace(/\/$/, ""); return entry === root || entry.startsWith(`${root}/`); });
|
|
56
|
+
}
|
|
57
|
+
function phaseMeter(onPhase?: (phase: SnapshotPhase, durationMs: number) => void) {
|
|
58
|
+
return async <T>(phase: SnapshotPhase, operation: () => Promise<T>): Promise<T> => {
|
|
59
|
+
const started = performance.now();
|
|
60
|
+
try { return await operation(); } finally { try { onPhase?.(phase, performance.now() - started); } catch { /* Timing cannot change admission. */ } }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function removeTrees(directories: readonly string[]): Promise<void> {
|
|
64
|
+
const removed = await Promise.allSettled(directories.map(dir => rm(dir, { recursive: true, force: true })));
|
|
65
|
+
if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
|
|
66
|
+
}
|
|
67
|
+
/** One copy-on-write clone of a directory tree into a path that must not exist yet. On macOS
|
|
68
|
+
* `clonefileat(2)` (syscall 462) clones the hierarchy in one call; `cp -c` clones file by file
|
|
69
|
+
* and silently copies where cloning is unsupported. */
|
|
70
|
+
async function cloneTree(method: CloneMethod, source: string, destination: string, deadline: number, signal?: AbortSignal): Promise<void> {
|
|
71
|
+
const [command, args] = method === "clonefile"
|
|
72
|
+
? ["/usr/bin/perl", ["-e", "syscall(462, -2, $ARGV[0], -2, $ARGV[1], 0) == 0 or exit 1", source, destination]] as const
|
|
73
|
+
: ["cp", ["-a", "--reflink=always", "--", source, destination]] as const;
|
|
74
|
+
const result = await snapshotProcess(command, [...args], path.dirname(destination), { PATH: "/usr/bin:/bin" }, deadline, signal);
|
|
75
|
+
if (result.code !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "The warm snapshot could not be cloned.");
|
|
76
|
+
}
|
|
77
|
+
let cloneProbe: Promise<CloneMethod | undefined> | undefined;
|
|
78
|
+
/** The copy-on-write method the temporary directory supports, probed once per process; undefined where none does. */
|
|
79
|
+
export function snapshotCloneMethod(): Promise<CloneMethod | undefined> {
|
|
80
|
+
return cloneProbe ??= (async () => {
|
|
81
|
+
const method: CloneMethod | undefined = process.platform === "darwin" ? "clonefile" : process.platform === "linux" ? "reflink" : undefined;
|
|
82
|
+
if (!method) return undefined;
|
|
83
|
+
const probe = await mkdtemp(path.join(tmpdir(), "delivery-clone-probe-"));
|
|
84
|
+
try {
|
|
85
|
+
await mkdir(path.join(probe, "source")); await writeFile(path.join(probe, "source", "probe"), "probe");
|
|
86
|
+
await cloneTree(method, path.join(probe, "source"), path.join(probe, "clone"), performance.now() + 30_000);
|
|
87
|
+
return method;
|
|
88
|
+
} catch { return undefined; } finally { await rm(probe, { recursive: true, force: true }).catch(() => undefined); }
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
async function cloneSnapshot(tree: PrivateTree, method: CloneMethod, signal: AbortSignal | undefined, onPhase: SnapshotRequest["onPhase"], open: (tree: PrivateTree, onPhase?: SnapshotRequest["onPhase"]) => CheckSnapshot): Promise<CheckSnapshot> {
|
|
92
|
+
return phaseMeter(onPhase)("clone", async () => {
|
|
93
|
+
const parent = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-clone-")));
|
|
94
|
+
const cloned: PrivateTree = { rootDir: path.join(parent, "tree"), ...(tree.privateControl ? { privateControl: path.join(parent, "control") } : {}), owned: [parent] };
|
|
95
|
+
try {
|
|
96
|
+
const deadline = performance.now() + SNAPSHOT_TIMEOUT_MS;
|
|
97
|
+
await cloneTree(method, tree.rootDir, cloned.rootDir, deadline, signal);
|
|
98
|
+
if (tree.privateControl) await cloneTree(method, tree.privateControl, cloned.privateControl!, deadline, signal);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await rm(parent, { recursive: true, force: true }).catch(() => undefined);
|
|
101
|
+
throw error instanceof CheckSnapshotError ? error : new CheckSnapshotError("check_snapshot_unavailable", "The warm snapshot could not be cloned.");
|
|
102
|
+
}
|
|
103
|
+
return open(cloned, onPhase);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
33
106
|
function inside(root: string, target: string): boolean {
|
|
34
107
|
const relative = path.relative(root, target);
|
|
35
108
|
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
@@ -37,7 +110,7 @@ function inside(root: string, target: string): boolean {
|
|
|
37
110
|
/** Kill the owned read-only process and await close before callers may clean up.
|
|
38
111
|
* AbortSignal is handled here: child_process's early AbortError event is not
|
|
39
112
|
* proof that the process has stopped touching the snapshot. */
|
|
40
|
-
async function snapshotProcess(command: string, args: string[], cwd: string, env: Readonly<Record<string, string>>, deadline: number, signal?: AbortSignal): Promise<{ code: number | null; stdout: string }> {
|
|
113
|
+
async function snapshotProcess(command: string, args: string[], cwd: string, env: Readonly<Record<string, string>>, deadline: number, signal?: AbortSignal, limit = 64 * 1024): Promise<{ code: number | null; stdout: string }> {
|
|
41
114
|
if (signal?.aborted) throw new CheckSnapshotError("check_snapshot_interrupted", "Snapshot verification was interrupted.");
|
|
42
115
|
const remaining = deadline - performance.now();
|
|
43
116
|
if (remaining <= 0) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
|
|
@@ -54,7 +127,7 @@ async function snapshotProcess(command: string, args: string[], cwd: string, env
|
|
|
54
127
|
if (signal?.aborted) abort();
|
|
55
128
|
child.stdout.on("data", (bytes: Buffer) => {
|
|
56
129
|
size += bytes.length;
|
|
57
|
-
if (size >
|
|
130
|
+
if (size > limit) stop("check_snapshot_unavailable", "Snapshot verifier output exceeded its limit.");
|
|
58
131
|
else stdout += bytes.toString();
|
|
59
132
|
});
|
|
60
133
|
child.stderr.on("data", () => {}); // Drain; never expose private paths or source bytes.
|
|
@@ -67,18 +140,55 @@ async function snapshotProcess(command: string, args: string[], cwd: string, env
|
|
|
67
140
|
});
|
|
68
141
|
}
|
|
69
142
|
/** The inventory's blocking filesystem operations live only in a killable process. */
|
|
70
|
-
export async function snapshotInventory(root: string, outputs: readonly string[], excludeDependencies = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS, signal?: AbortSignal, forbidGit = false): Promise<string> {
|
|
143
|
+
export async function snapshotInventory(root: string, outputs: readonly string[], excludeDependencies = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS, signal?: AbortSignal, forbidGit = false, absentInputs: readonly string[] = []): Promise<string> {
|
|
144
|
+
return (await snapshotDigests(root, outputs, excludeDependencies, deadline, signal, forbidGit, false, absentInputs)).digest;
|
|
145
|
+
}
|
|
146
|
+
async function snapshotDigests(root: string, outputs: readonly string[], excludeDependencies: boolean, deadline: number, signal?: AbortSignal, forbidGit = false, includeSource = false, absentInputs: readonly string[] = [], selfReferences: readonly string[] = []): Promise<{ digest: string; sourceDigest?: string; selfReferenced: boolean }> {
|
|
71
147
|
const result = await snapshotProcess(process.execPath, ["--input-type=module", "--eval", snapshotInventoryWorker], root,
|
|
72
|
-
{ PATH: process.env["PATH"] ?? "/usr/bin:/bin", DELIVERY_SNAPSHOT_REQUEST: JSON.stringify({ root, outputs, excludeDependencies, forbidGit }) }, deadline, signal);
|
|
73
|
-
let parsed: { code?: string; digest?: string };
|
|
148
|
+
{ PATH: process.env["PATH"] ?? "/usr/bin:/bin", DELIVERY_SNAPSHOT_REQUEST: JSON.stringify({ root, outputs, excludeDependencies, forbidGit, includeSource, absentInputs, selfReferences }) }, deadline, signal);
|
|
149
|
+
let parsed: { code?: string; digest?: string; sourceDigest?: string; selfReferenced?: boolean };
|
|
74
150
|
try { parsed = JSON.parse(result.stdout); } catch { throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot inventory returned no valid result."); }
|
|
75
|
-
if (result.code !== 0 || !/^[a-f0-9]{64}$/.test(parsed.digest ?? "")) throw new CheckSnapshotError(["check_snapshot_escape", "check_snapshot_drift"].includes(parsed.code ?? "") ? parsed.code! : "check_snapshot_unavailable", "Snapshot inventory could not verify private bytes and links.");
|
|
76
|
-
|
|
151
|
+
if (result.code !== 0 || !/^[a-f0-9]{64}$/.test(parsed.digest ?? "") || (includeSource && !/^[a-f0-9]{64}$/.test(parsed.sourceDigest ?? ""))) throw new CheckSnapshotError(["check_snapshot_escape", "check_snapshot_drift"].includes(parsed.code ?? "") ? parsed.code! : "check_snapshot_unavailable", "Snapshot inventory could not verify private bytes and links.");
|
|
152
|
+
if (selfReferences.length && typeof parsed.selfReferenced !== "boolean") throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot inventory returned no valid result.");
|
|
153
|
+
return { digest: parsed.digest!, ...(includeSource ? { sourceDigest: parsed.sourceDigest! } : {}), selfReferenced: parsed.selfReferenced === true };
|
|
154
|
+
}
|
|
155
|
+
/** The spellings of a tree's absolute paths a setup could write: as created under the temporary directory and resolved. */
|
|
156
|
+
function selfReferences(...created: (string | undefined)[]): string[] {
|
|
157
|
+
return [...new Set(created.flatMap(dir => dir === undefined ? [] : [dir, path.join(tmpdir(), path.basename(dir))]))];
|
|
77
158
|
}
|
|
78
159
|
export function executionPath(root: string, value: string): string {
|
|
79
160
|
return value.split(path.delimiter).filter(p => p && path.isAbsolute(p) && !inside(root, p) && !p.split(path.sep).includes("node_modules")).join(path.delimiter) || "/usr/bin:/bin";
|
|
80
161
|
}
|
|
162
|
+
/** The commit depth a materialization fetch needs, or undefined for the source's whole history.
|
|
163
|
+
* A tip lies at most `merge-base..tip` commits plus one above the merge-base on any path, so
|
|
164
|
+
* that depth from both tips keeps every commit a merge-base, ancestry or three-dot read walks.
|
|
165
|
+
* That holds only while the merge-base is the sole commit those ranges stand on: a side branch
|
|
166
|
+
* forked below it would arrive cut off from it and read as a second merge-base. */
|
|
167
|
+
async function materializationDepth(input: SnapshotRequest, git: (...args: string[]) => Promise<string>): Promise<number | undefined> {
|
|
168
|
+
if (input.gitContext === "none") return 1;
|
|
169
|
+
const { headSha, base } = input.candidate;
|
|
170
|
+
try {
|
|
171
|
+
// Reading the author's repository only; the fetch below still transfers object bytes.
|
|
172
|
+
// A tip above the merge-base stands on it alone exactly when it is the range's one boundary
|
|
173
|
+
// commit, which also makes it an ancestor of that tip and so fetched through it.
|
|
174
|
+
for (const tip of [headSha, base.tipSha]) {
|
|
175
|
+
const boundary = (await git("-C", input.rootDir, "rev-list", "--boundary", `${base.mergeBaseSha}..${tip}`))
|
|
176
|
+
.split("\n").filter(line => line.startsWith("-"));
|
|
177
|
+
if (tip !== base.mergeBaseSha && boundary.join("\n") !== `-${base.mergeBaseSha}`) return undefined;
|
|
178
|
+
}
|
|
179
|
+
const counts = await Promise.all([headSha, base.tipSha].map(tip => git("-C", input.rootDir, "rev-list", "--count", `${base.mergeBaseSha}..${tip}`)));
|
|
180
|
+
const longest = Math.max(...counts.map(Number));
|
|
181
|
+
return Number.isSafeInteger(longest) && longest >= 0 ? longest + 1 : undefined;
|
|
182
|
+
} catch { return undefined; }
|
|
183
|
+
}
|
|
184
|
+
|
|
81
185
|
export async function createCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
|
|
186
|
+
if (input.physicalSource !== undefined) return createFiniteCheckSnapshot(input);
|
|
187
|
+
const report = (phase: SnapshotPhase, started: number) => {
|
|
188
|
+
try { input.onPhase?.(phase, performance.now() - started); } catch { /* Timing cannot change admission. */ }
|
|
189
|
+
};
|
|
190
|
+
const measured = phaseMeter(input.onPhase);
|
|
191
|
+
const materializationStarted = performance.now();
|
|
82
192
|
const rootDir = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-")));
|
|
83
193
|
const cleanEnv = { ...input.environment, PATH: executionPath(input.rootDir, input.environment["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z" };
|
|
84
194
|
let privateControl: string | undefined;
|
|
@@ -99,17 +209,23 @@ export async function createCheckSnapshot(input: SnapshotRequest): Promise<Check
|
|
|
99
209
|
return result.stdout.trim();
|
|
100
210
|
} finally { clearTimeout(timer); input.signal?.removeEventListener("abort", abort); }
|
|
101
211
|
};
|
|
102
|
-
const cleanup =
|
|
103
|
-
const removed = await Promise.allSettled([rootDir, ...(privateControl ? [privateControl] : [])].map(dir => rm(dir, { recursive: true, force: true })));
|
|
104
|
-
if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
|
|
105
|
-
};
|
|
212
|
+
const cleanup = () => removeTrees([rootDir, ...(privateControl ? [privateControl] : [])]);
|
|
106
213
|
try {
|
|
107
214
|
await git("init", "-q");
|
|
108
215
|
// Fetch transfers object bytes; unlike worktrees/alternates/local clones it
|
|
109
216
|
// cannot create writable links back to the author's Git metadata.
|
|
110
217
|
// Accept the source's shallow boundary in this private repository. Without
|
|
111
218
|
// it, fetch can reject objects or succeed while dropping required grafts.
|
|
112
|
-
|
|
219
|
+
// Fetch only the history a check can read (V26-3284): none past the pinned
|
|
220
|
+
// commits without Git, and with Git the commits from the merge-base to each
|
|
221
|
+
// tip, which merge-base, ancestry and three-dot diffs need. A distance the
|
|
222
|
+
// source cannot measure keeps today's whole-history fetch.
|
|
223
|
+
const depth = await materializationDepth(input, git);
|
|
224
|
+
// Within a bounded full history the merge-base is already fetched from both
|
|
225
|
+
// tips; asked for by itself it would deepen past itself by the same depth.
|
|
226
|
+
const mergeBase = input.gitContext !== "none" && depth !== undefined ? [] : [input.candidate.base.mergeBaseSha];
|
|
227
|
+
await git("fetch", "--update-shallow", ...(depth === undefined ? [] : ["--depth", String(depth)]), "--no-tags", "--no-write-fetch-head", input.rootDir,
|
|
228
|
+
input.candidate.headSha, input.candidate.base.tipSha, ...mergeBase, input.candidate.treeSha);
|
|
113
229
|
await git("update-ref", "HEAD", input.candidate.headSha);
|
|
114
230
|
await git("read-tree", input.candidate.treeSha);
|
|
115
231
|
await git("checkout-index", "--all", "--force");
|
|
@@ -129,39 +245,184 @@ export async function createCheckSnapshot(input: SnapshotRequest): Promise<Check
|
|
|
129
245
|
await rename(path.join(rootDir, ".git"), gitDirectory);
|
|
130
246
|
}
|
|
131
247
|
const controlRoot = privateControl ?? path.join(rootDir, ".git");
|
|
132
|
-
const inventory = (outputs: readonly string[], sourceOnly = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS) => snapshotInventory(
|
|
133
|
-
|
|
134
|
-
|
|
248
|
+
const inventory = (root: string, outputs: readonly string[], sourceOnly = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS) => snapshotInventory(root, outputs, sourceOnly, deadline, input.signal, input.gitContext === "none");
|
|
249
|
+
report("materialization", materializationStarted);
|
|
250
|
+
// Setup needs a source baseline only when a dependency command can mutate it.
|
|
251
|
+
const baseline = await measured("baseline-inventory", () => inventory(rootDir, input.outputs, Boolean(input.dependencies)));
|
|
252
|
+
let dependencyDigest = baseline, clonable = true;
|
|
253
|
+
const environmentFor = (root: string, control: string): Record<string, string> => ({ ...input.environment, PATH: `${path.join(root, "node_modules/.bin")}${path.delimiter}${cleanEnv["PATH"]}`, HOME: path.join(control, "home"), TMPDIR: path.join(control, "tmp"), GIT_CEILING_DIRECTORIES: path.dirname(root),
|
|
135
254
|
...(input.gitContext === "none" ? {} : {
|
|
136
|
-
DELIVERY_CHECK_BASE_REF: baseRef, DELIVERY_CHECK_CANDIDATE_REF: candidateRef, DELIVERY_CHECK_ORIGIN_HEAD: input.candidate.headSha, DELIVERY_CHECK_ORIGIN_TREE: input.candidate.treeSha, DELIVERY_CHECK_MERGE_BASE: input.candidate.base.mergeBaseSha }) };
|
|
255
|
+
DELIVERY_CHECK_BASE_REF: baseRef, DELIVERY_CHECK_CANDIDATE_REF: candidateRef, DELIVERY_CHECK_ORIGIN_HEAD: input.candidate.headSha, DELIVERY_CHECK_ORIGIN_TREE: input.candidate.treeSha, DELIVERY_CHECK_MERGE_BASE: input.candidate.base.mergeBaseSha }) });
|
|
256
|
+
const environment = environmentFor(rootDir, controlRoot);
|
|
137
257
|
await mkdir(environment["HOME"]!, { recursive: true }); await mkdir(environment["TMPDIR"]!, { recursive: true });
|
|
138
258
|
if (input.dependencies) {
|
|
139
259
|
const started = performance.now();
|
|
140
|
-
const result = await createExecPort().run({ command: input.dependencies
|
|
260
|
+
const result = await measured("dependencies", () => createExecPort().run({ command: input.dependencies!.command[0], args: input.dependencies!.command.slice(1), cwd: rootDir, env: environment, timeoutMs: input.dependencies!.timeoutMs, maxBuffer: 1024 * 1024, ...(input.signal ? { signal: input.signal } : {}) }));
|
|
141
261
|
input.onDependencyResult?.(result, Math.round(performance.now() - started));
|
|
142
262
|
if (result.code !== 0 || input.signal?.aborted) throw new CheckSnapshotError("check_dependency_failed", "Private dependency installation did not complete successfully.");
|
|
263
|
+
// Both digests use the same tuples from one safety walk, hashing each
|
|
264
|
+
// immutable file once. Source comparison still excludes node_modules.
|
|
265
|
+
const installed = await measured("post-dependency-inventory", () => snapshotDigests(rootDir, input.outputs, false, performance.now() + SNAPSHOT_TIMEOUT_MS, input.signal, input.gitContext === "none", true, [], selfReferences(rootDir, privateControl)));
|
|
266
|
+
if (baseline !== installed.sourceDigest) throw new CheckSnapshotError("check_snapshot_drift", "Dependency setup changed prepared source bytes.");
|
|
267
|
+
dependencyDigest = installed.digest;
|
|
268
|
+
clonable = !installed.selfReferenced;
|
|
143
269
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const verifyGit = async (...args: string[]) => {
|
|
154
|
-
const result = await snapshotProcess("git", gitDirectory ? [`--git-dir=${gitDirectory}`, `--work-tree=${rootDir}`, ...args] : args, rootDir, cleanEnv, deadline, input.signal);
|
|
155
|
-
if (result.code !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git identity could not be read.");
|
|
156
|
-
return result.stdout.trim();
|
|
270
|
+
// Explicit directories and settings: a full-Git command can edit .git/config, and core.worktree,
|
|
271
|
+
// core.fileMode=false, a sparse checkout or an fsmonitor hook would otherwise hide changes from
|
|
272
|
+
// verification (and the hook would run inside it).
|
|
273
|
+
const treeGit = (tree: PrivateTree, deadline: number, accepted: readonly number[] = [0], limit?: number) => {
|
|
274
|
+
const gitDir = tree.privateControl ? path.join(tree.privateControl, "repository") : path.join(tree.rootDir, ".git");
|
|
275
|
+
return async (...args: string[]) => {
|
|
276
|
+
const result = await snapshotProcess("git", [`--git-dir=${gitDir}`, `--work-tree=${tree.rootDir}`, "-c", "core.fileMode=true", "-c", "core.sparseCheckout=false", "-c", "core.symlinks=true", "-c", "core.fsmonitor=false", ...args], tree.rootDir, cleanEnv, deadline, input.signal, limit);
|
|
277
|
+
if (!accepted.includes(result.code ?? -1)) throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git identity could not be read.");
|
|
278
|
+
return result.stdout;
|
|
157
279
|
};
|
|
158
|
-
await inventory([], false, deadline);
|
|
159
|
-
if (await verifyGit("write-tree") !== input.candidate.treeSha || await verifyGit("rev-parse", "HEAD") !== candidateCommit || await verifyGit("rev-parse", `${candidateRef}^{tree}`) !== input.candidate.treeSha || await verifyGit("rev-parse", baseRef) !== input.candidate.base.tipSha || dependencyDigest !== await inventory(input.outputs, false, deadline)) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
|
|
160
280
|
};
|
|
161
|
-
|
|
281
|
+
// Untracked entries the setup left, with each wholly untracked directory
|
|
282
|
+
// (an installed dependency tree) collapsed to one entry. Exclude rules are
|
|
283
|
+
// not read: a command could write one to hide its own files.
|
|
284
|
+
const others = ["ls-files", "--others", "--directory", "--no-empty-directory", "-z"];
|
|
285
|
+
const setupEntries = new Set(nulSeparated(await treeGit({ rootDir, ...(privateControl ? { privateControl } : {}), owned: [] }, performance.now() + SNAPSHOT_TIMEOUT_MS)(...others)));
|
|
286
|
+
const reusedVerification = "index" as const;
|
|
287
|
+
const open = (tree: PrivateTree, onPhase?: SnapshotRequest["onPhase"]): CheckSnapshot => {
|
|
288
|
+
const control = tree.privateControl ?? path.join(tree.rootDir, ".git");
|
|
289
|
+
const verify: CheckSnapshot["verify"] = async (options = {}) => phaseMeter(onPhase)("verification", async () => {
|
|
290
|
+
const timeoutMs = options.timeoutMs ?? SNAPSHOT_TIMEOUT_MS;
|
|
291
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > SNAPSHOT_TIMEOUT_MS) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot deadline must be positive and cannot exceed five minutes.");
|
|
292
|
+
const deadline = performance.now() + timeoutMs;
|
|
293
|
+
// The worker also checks file-only Git metadata, so lstat cannot stall the parent.
|
|
294
|
+
const verifyGit = treeGit(tree, deadline);
|
|
295
|
+
if (options.unchanged && reusedVerification === "index") {
|
|
296
|
+
// A reused tree passed a full inventory after its last command. Before
|
|
297
|
+
// the next one it must still match the index: no tracked edit, and no
|
|
298
|
+
// untracked entry the setup did not leave outside the allowed outputs.
|
|
299
|
+
const allowed = options.unchanged.allowed;
|
|
300
|
+
// A clone has new inodes, so the refresh rehashes; 1 means a tracked file differs, which
|
|
301
|
+
// diff-files names. Every entry must be a plain cached one ("H"): an assume-unchanged
|
|
302
|
+
// (lowercase) or skip-worktree ("S") flag would hide an edit from both.
|
|
303
|
+
await treeGit(tree, deadline, [0, 1])("update-index", "-q", "--refresh");
|
|
304
|
+
if (nulSeparated(await treeGit(tree, deadline, [0], 128 * 1024 * 1024)("ls-files", "-v", "-z")).some(entry => !entry.startsWith("H ")))
|
|
305
|
+
throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot's index hides a tracked path.");
|
|
306
|
+
if (input.gitContext === "none" && await lstat(path.join(tree.rootDir, ".git")).then(() => true, (error: NodeJS.ErrnoException) => error.code === "ENOENT" ? false : Promise.reject(new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git absence could not be read."))))
|
|
307
|
+
throw new CheckSnapshotError("check_snapshot_drift", "A file-only snapshot gained Git metadata.");
|
|
308
|
+
if (nulSeparated(await verifyGit("diff-files", "--name-only", "-z")).length) throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot changed tracked source.");
|
|
309
|
+
for (const entry of nulSeparated(await verifyGit(...others))) {
|
|
310
|
+
if (setupEntries.has(entry) || withinOutputs(entry, allowed)) continue;
|
|
311
|
+
// A collapsed directory is looked into only when an allowed output lies inside it.
|
|
312
|
+
if (!entry.endsWith("/") || !allowed.some(output => output.startsWith(entry))) throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot gained an undeclared untracked path.");
|
|
313
|
+
if (nulSeparated(await verifyGit("ls-files", "--others", "-z", "--", `:(literal)${entry}`)).some(file => !withinOutputs(file, allowed))) throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot gained an undeclared untracked path.");
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
// One worker checks every non-Git link/type and hashes only immutable bytes.
|
|
317
|
+
// Finish safety validation before Git reads, under the same owned deadline.
|
|
318
|
+
if (await inventory(tree.rootDir, options.outputs ?? input.outputs, false, deadline) !== dependencyDigest) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
|
|
319
|
+
}
|
|
320
|
+
const indexTree = (await verifyGit("write-tree")).trim();
|
|
321
|
+
const identities = (await verifyGit("rev-parse", "HEAD", `${candidateRef}^{tree}`, baseRef)).trim().split("\n");
|
|
322
|
+
if (indexTree !== input.candidate.treeSha || identities.length !== 3 || identities[0] !== candidateCommit || identities[1] !== input.candidate.treeSha || identities[2] !== input.candidate.base.tipSha) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
|
|
323
|
+
});
|
|
324
|
+
return { rootDir: tree.rootDir, commandRoot: path.join(control, "commands"), environment: environmentFor(tree.rootDir, control), dependencyDigest, clonable, reusedVerification, verify,
|
|
325
|
+
clone: (method, onClonePhase) => cloneSnapshot(tree, method, input.signal, onClonePhase, open), cleanup: () => removeTrees(tree.owned) };
|
|
326
|
+
};
|
|
327
|
+
return open({ rootDir, ...(privateControl ? { privateControl } : {}), owned: [rootDir, ...(privateControl ? [privateControl] : [])] }, input.onPhase);
|
|
162
328
|
} catch (error) {
|
|
163
329
|
await cleanup();
|
|
164
330
|
if (error instanceof CheckSnapshotError) throw error;
|
|
165
331
|
throw new CheckSnapshotError("check_snapshot_unavailable", "The exact prepared Git tree could not be materialized privately.");
|
|
166
332
|
}
|
|
167
333
|
}
|
|
334
|
+
|
|
335
|
+
/** Opt-in file-only execution; legacy snapshots above retain complete Git semantics. */
|
|
336
|
+
function finiteSourceReaderRunner(input: SnapshotRequest): CandidateCommandRunner {
|
|
337
|
+
const deadline = performance.now() + SNAPSHOT_TIMEOUT_MS;
|
|
338
|
+
// Logical source bounds belong to capturePhysicalSource, including repeated
|
|
339
|
+
// blobs. Reserve separate bounded transport room for listings/size responses;
|
|
340
|
+
// protocol overhead must not consume the supported 64 MiB of source bytes.
|
|
341
|
+
const transportLimit = 128 * 1024 * 1024;
|
|
342
|
+
let transportBytes = 0;
|
|
343
|
+
return async (command, options) => {
|
|
344
|
+
if (input.signal?.aborted) throw new CheckSnapshotError("check_snapshot_interrupted", "Finite source capture was cancelled.");
|
|
345
|
+
const remaining = deadline - performance.now();
|
|
346
|
+
if (remaining <= 0) throw new CheckSnapshotError("check_snapshot_timeout", "Finite source capture exceeded its deadline.");
|
|
347
|
+
return await new Promise((resolve, reject) => {
|
|
348
|
+
const child = spawn(command[0]!, command.slice(1), { cwd: options.cwd,
|
|
349
|
+
env: { PATH: executionPath(input.rootDir, input.environment["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" },
|
|
350
|
+
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
351
|
+
let failure: CheckSnapshotError | undefined;
|
|
352
|
+
const chunks: Buffer[] = [];
|
|
353
|
+
const stop = (code: string) => { failure ??= new CheckSnapshotError(code, "Finite source reader did not complete safely."); child.kill("SIGKILL"); };
|
|
354
|
+
const abort = () => stop("check_snapshot_interrupted"), timer = setTimeout(() => stop("check_snapshot_timeout"), remaining);
|
|
355
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
356
|
+
if (input.signal?.aborted) abort();
|
|
357
|
+
child.stdout!.on("data", (chunk: Buffer) => { transportBytes += chunk.length; if (transportBytes > transportLimit) stop("check_snapshot_unavailable"); else chunks.push(chunk); });
|
|
358
|
+
child.stderr!.on("data", () => {});
|
|
359
|
+
child.once("error", () => { failure ??= new CheckSnapshotError("check_snapshot_unavailable", "Finite source reader failed to start."); });
|
|
360
|
+
child.stdin?.once("error", () => stop("check_snapshot_unavailable"));
|
|
361
|
+
child.stdin?.end(options.stdin);
|
|
362
|
+
child.once("close", code => {
|
|
363
|
+
clearTimeout(timer); input.signal?.removeEventListener("abort", abort);
|
|
364
|
+
if (failure) reject(failure);
|
|
365
|
+
else { const bytes = Buffer.concat(chunks); resolve({ exitCode: code ?? -1, stdout: bytes.toString("utf8"), stderr: "", ...(options.captureBytes ? { stdoutBase64: bytes.toString("base64") } : {}) }); }
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
async function createFiniteCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
|
|
371
|
+
const materializationStarted = performance.now();
|
|
372
|
+
const source = input.physicalSource!;
|
|
373
|
+
if (input.gitContext !== "none" || !isPhysicalSourceDefinition(source) || physicalSourceOutputOverlap(source, input.outputs))
|
|
374
|
+
throw new CheckSnapshotError("check_physical_source_invalid", "Finite source requires Git-none and absences disjoint from all mutable outputs.");
|
|
375
|
+
let captured: Awaited<ReturnType<typeof capturePhysicalSource>>;
|
|
376
|
+
try { captured = await capturePhysicalSource(source, await candidateTreeSourceReader(input.rootDir, input.candidate.treeSha, finiteSourceReaderRunner(input))); }
|
|
377
|
+
catch (error) { if (error instanceof CheckSnapshotError) throw error; throw new CheckSnapshotError("check_physical_source_invalid", "Finite source inputs are missing, linked or unsupported in the candidate."); }
|
|
378
|
+
const rootDir = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-")));
|
|
379
|
+
let control: string | undefined;
|
|
380
|
+
const cleanup = () => removeTrees([rootDir, ...(control ? [control] : [])]);
|
|
381
|
+
const measured = phaseMeter(input.onPhase);
|
|
382
|
+
const inventory = async (root: string, outputs: readonly string[], sourceOnly = false, deadline?: number) => {
|
|
383
|
+
if (physicalSourceOutputOverlap(source, outputs)) throw new CheckSnapshotError("check_physical_source_invalid", "Mutable output overlaps a required absence.");
|
|
384
|
+
return snapshotInventory(root, outputs, sourceOnly, deadline, input.signal, true, source.absentInputs);
|
|
385
|
+
};
|
|
386
|
+
try {
|
|
387
|
+
control = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-control-")));
|
|
388
|
+
for (const file of captured.files) {
|
|
389
|
+
await mkdir(path.dirname(path.join(rootDir, file.file)), { recursive: true });
|
|
390
|
+
await writeFile(path.join(rootDir, file.file), file.bytes, { flag: "wx" });
|
|
391
|
+
await chmod(path.join(rootDir, file.file), file.mode === "100755" ? 0o755 : 0o644);
|
|
392
|
+
}
|
|
393
|
+
try { input.onPhase?.("materialization", performance.now() - materializationStarted); } catch { /* Best-effort observation. */ }
|
|
394
|
+
const cleanPath = executionPath(input.rootDir, input.environment["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin");
|
|
395
|
+
const environmentFor = (root: string, controlDir: string) => ({ ...input.environment, PATH: `${path.join(root, "node_modules/.bin")}${path.delimiter}${cleanPath}`,
|
|
396
|
+
HOME: path.join(controlDir, "home"), TMPDIR: path.join(controlDir, "tmp"), GIT_CEILING_DIRECTORIES: path.dirname(root) });
|
|
397
|
+
const environment = environmentFor(rootDir, control);
|
|
398
|
+
await mkdir(environment.HOME, { recursive: true }); await mkdir(environment.TMPDIR, { recursive: true });
|
|
399
|
+
const baseline = await measured("baseline-inventory", () => inventory(rootDir, input.outputs, Boolean(input.dependencies)));
|
|
400
|
+
if (input.dependencies) {
|
|
401
|
+
const start = performance.now();
|
|
402
|
+
const result = await measured("dependencies", () => createExecPort().run({ command: input.dependencies!.command[0], args: input.dependencies!.command.slice(1), cwd: rootDir,
|
|
403
|
+
env: environment, timeoutMs: input.dependencies!.timeoutMs, maxBuffer: 1024 * 1024, ...(input.signal ? { signal: input.signal } : {}) }));
|
|
404
|
+
input.onDependencyResult?.(result, Math.round(performance.now() - start));
|
|
405
|
+
if (result.code !== 0 || input.signal?.aborted) throw new CheckSnapshotError("check_dependency_failed", "Private finite dependency installation failed.");
|
|
406
|
+
}
|
|
407
|
+
const installed = input.dependencies ? await measured("post-dependency-inventory", () =>
|
|
408
|
+
snapshotDigests(rootDir, input.outputs, false, performance.now() + SNAPSHOT_TIMEOUT_MS, input.signal, true, true, source.absentInputs, selfReferences(rootDir, control))) : { digest: baseline, sourceDigest: baseline, selfReferenced: false };
|
|
409
|
+
if (baseline !== installed.sourceDigest) throw new CheckSnapshotError("check_snapshot_drift", "Dependency setup changed finite source bytes or modes.");
|
|
410
|
+
// Without Git there is no index to compare, so a reused finite tree is verified by its full inventory.
|
|
411
|
+
const open = (tree: PrivateTree, onPhase?: SnapshotRequest["onPhase"]): CheckSnapshot => {
|
|
412
|
+
const controlDir = tree.privateControl!;
|
|
413
|
+
const verify: CheckSnapshot["verify"] = async (options = {}) => phaseMeter(onPhase)("verification", async () => {
|
|
414
|
+
const timeout = options.timeoutMs ?? SNAPSHOT_TIMEOUT_MS;
|
|
415
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > SNAPSHOT_TIMEOUT_MS) throw new CheckSnapshotError("check_snapshot_timeout", "Invalid finite verification deadline.");
|
|
416
|
+
if (await inventory(tree.rootDir, options.outputs ?? input.outputs, false, performance.now() + timeout) !== installed.digest)
|
|
417
|
+
throw new CheckSnapshotError("check_snapshot_drift", "Finite source or installed dependencies changed.");
|
|
418
|
+
});
|
|
419
|
+
return { rootDir: tree.rootDir, commandRoot: path.join(controlDir, "commands"), environment: environmentFor(tree.rootDir, controlDir), dependencyDigest: installed.digest, clonable: !installed.selfReferenced, reusedVerification: "inventory", verify,
|
|
420
|
+
clone: (method, onClonePhase) => cloneSnapshot(tree, method, input.signal, onClonePhase, open), cleanup: () => removeTrees(tree.owned) };
|
|
421
|
+
};
|
|
422
|
+
return open({ rootDir, privateControl: control, owned: [rootDir, control] }, input.onPhase);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
await cleanup();
|
|
425
|
+
if (error instanceof CheckSnapshotError) throw error;
|
|
426
|
+
throw new CheckSnapshotError("check_snapshot_unavailable", "Finite source could not be materialized or verified.");
|
|
427
|
+
}
|
|
428
|
+
}
|
package/src/command-activity.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Best-effort CLI observations. No caller uses these results for admission. */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type { RunEventKind, RunPreparationObservation, WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
3
|
+
import type { CaptureCandidate, RunEventKind, RunPreparationObservation, WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
4
4
|
import { buildRunEvent, resolveRunSurface } from "./run-surface.ts";
|
|
5
5
|
|
|
6
6
|
export interface CommandObservation {
|
|
@@ -12,6 +12,20 @@ export interface CommandObservation {
|
|
|
12
12
|
const absent: CommandObservation = { needsCandidate: false, start: async () => {}, prompt: prompt => prompt, finish: async () => {} };
|
|
13
13
|
function outcome(code: number) { return code === 0 ? "ok" : code === 2 ? "usage" : code === 130 ? "interrupted" : "policy"; }
|
|
14
14
|
|
|
15
|
+
/** Observe the first successful command capture; never reuse or replace a result. */
|
|
16
|
+
export function observeCandidateCaptures(capture: CaptureCandidate, observation: CommandObservation): CaptureCandidate {
|
|
17
|
+
let start: Promise<void> | undefined;
|
|
18
|
+
return async () => {
|
|
19
|
+
const result = await capture();
|
|
20
|
+
if (result.ok) {
|
|
21
|
+
start ??= Promise.resolve().then(() => observation.start(result.candidate.treeSha))
|
|
22
|
+
.catch(() => { /* Progress reporting never changes capture or command outcomes. */ });
|
|
23
|
+
await start;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
/** Pin the current run once, so a pointer change cannot split an invocation. */
|
|
16
30
|
export async function beginCommandObservation(cwd: string, command: string): Promise<CommandObservation> {
|
|
17
31
|
try {
|
package/src/commands/admit.ts
CHANGED
|
@@ -13,9 +13,9 @@ import path from "node:path";
|
|
|
13
13
|
|
|
14
14
|
import { commandBlocker, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
15
15
|
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { runGate } from "./gate.ts";
|
|
17
17
|
import { prepareCommand } from "./prepare.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { writeRecord } from "./record.ts";
|
|
19
19
|
import { reviewContextCommand } from "./review-context.ts";
|
|
20
20
|
import { submitEvidenceCommand } from "./submit-evidence.ts";
|
|
21
21
|
|
|
@@ -52,6 +52,7 @@ function invalidOutcome(message: string): CommandResult {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export const admitCommand: CommandDescriptor = {
|
|
55
|
+
capturesCandidate: true,
|
|
55
56
|
name: "admit",
|
|
56
57
|
sourceId: "delivery-harness.cli.admit",
|
|
57
58
|
summary: "Prepare and admit a concluded review outcome through the tracked record write.",
|
|
@@ -97,10 +98,10 @@ export const admitCommand: CommandDescriptor = {
|
|
|
97
98
|
const stoppedAfterSubmission = report(context, "submitted", submitted);
|
|
98
99
|
if (stoppedAfterSubmission !== undefined) return stoppedAfterSubmission;
|
|
99
100
|
|
|
100
|
-
const gated = await
|
|
101
|
+
const gated = await runGate(withArgs(context, []));
|
|
101
102
|
const stoppedAfterGate = report(context, "gate", gated);
|
|
102
103
|
if (stoppedAfterGate !== undefined) return stoppedAfterGate;
|
|
103
104
|
|
|
104
|
-
return
|
|
105
|
+
return writeRecord(withArgs(context, []));
|
|
105
106
|
},
|
|
106
107
|
};
|
|
@@ -4,6 +4,7 @@ import { commandBlocker, type CommandDescriptor } from "../boundary.ts";
|
|
|
4
4
|
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
5
5
|
|
|
6
6
|
export const emitReviewEvidenceCommand: CommandDescriptor = {
|
|
7
|
+
capturesCandidate: true,
|
|
7
8
|
name: "emit-review-evidence",
|
|
8
9
|
sourceId: "delivery-harness.cli.emit-review-evidence",
|
|
9
10
|
summary: "Bind concluded review outcomes to their original prepared context.",
|