@agent-delivery-harness/cli 0.5.0 → 0.7.4
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 +3 -3
- package/src/boundary.ts +7 -2
- package/src/check-snapshot.ts +167 -0
- package/src/commands/gate.ts +33 -7
- package/src/commands/managed.ts +104 -34
- package/src/commands/prepare.ts +29 -4
- package/src/commands/record.ts +10 -1
- package/src/commands/runs.ts +53 -7
- package/src/commands/verify.ts +127 -2
- package/src/index.ts +3 -0
- package/src/main.ts +36 -18
- package/src/ordinary-context.ts +2 -18
- package/src/run-export.ts +40 -2
- package/src/run-projection.ts +198 -1
- package/src/run-server.ts +9 -0
- package/src/run-surface.ts +115 -1
- package/src/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- package/src/snapshot-inventory-worker.ts +40 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/** Invocation-scoped execution coordinator; durable truth remains per-check evidence. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { access, mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { captureCheckBindings, captureCheckOutputSnapshots, candidateTreeEvidenceReader, candidateTreeSourceReader, createExecPort, digestCanonical, resolveRecordStorage, runGitCommand, scopedCheckIdentity, selectScopedCheckAttempt, sha256Hex, submitManifest,
|
|
6
|
+
type CandidateBinding, type CapturedCandidate, type ProviderRegistration, type ScopedCheckPlan, type ScopedRuntimeObservation, type RecordCandidateBinding } from "@agent-delivery-harness/kernel";
|
|
7
|
+
import type { CommandContext } from "./boundary.ts";
|
|
8
|
+
import { AttemptStore, type AttemptPayload, type StoredAttempt } from "./scoped-attempts.ts";
|
|
9
|
+
import { CheckSnapshotError, createCheckSnapshot, executionPath, type CheckSnapshot } from "./check-snapshot.ts";
|
|
10
|
+
import { redactScopedOutput, scopedCommandDiagnostic, scopedDependencyDiagnostic, scopedDiagnosticFailure, type RecordedScopedAttemptDiagnostic } from "./scoped-diagnostics.ts";
|
|
11
|
+
export function scopedCandidate(candidate: CandidateBinding): RecordCandidateBinding {
|
|
12
|
+
return { treeSha: candidate.treeSha, deliverableDigest: candidate.deliverable.digest, identityToken: candidate.deliverable.identity, baseRef: candidate.base.ref, baseTipSha: candidate.base.tipSha, mergeBaseSha: candidate.base.mergeBaseSha, workspaceId: candidate.workspaceId };
|
|
13
|
+
}
|
|
14
|
+
async function executableIdentity(command: string, root: string, searchPath: string, cwd: string, read: Awaited<ReturnType<typeof candidateTreeEvidenceReader>>) {
|
|
15
|
+
if (command.includes(path.sep) && !path.isAbsolute(command)) {
|
|
16
|
+
const file = path.posix.normalize(path.posix.join(cwd, command));
|
|
17
|
+
if (file === ".." || file.startsWith("../")) throw new CheckSnapshotError("check_runtime_unavailable", "A relative execution tool must stay inside the prepared tree.");
|
|
18
|
+
const bytes = await read(file);
|
|
19
|
+
if (bytes === null) throw new CheckSnapshotError("check_runtime_unavailable", "A relative execution tool is missing from the prepared tree.");
|
|
20
|
+
return { command, sha256: sha256Hex(bytes), metadata: await read.metadata(file) };
|
|
21
|
+
}
|
|
22
|
+
const candidates = command.includes(path.sep) ? [path.resolve(root, command)] : searchPath.split(path.delimiter).map(p => path.join(p, command));
|
|
23
|
+
for (const file of candidates) {
|
|
24
|
+
try { await access(file); } catch { continue; }
|
|
25
|
+
const relative = path.relative(await realpath(root), await realpath(file));
|
|
26
|
+
if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) throw new CheckSnapshotError("check_runtime_author_path", "Repository execution tools must use a relative command so they run from the private prepared tree.");
|
|
27
|
+
return { command, sha256: sha256Hex(await readFile(file)), mode: (await stat(file)).mode & 0o111 };
|
|
28
|
+
}
|
|
29
|
+
throw new CheckSnapshotError("check_runtime_unavailable", "A declared execution tool cannot be resolved and bound.");
|
|
30
|
+
}
|
|
31
|
+
export class ScopedChecks {
|
|
32
|
+
private readonly snapshots = new Map<string, CheckSnapshot>();
|
|
33
|
+
private readonly owned = new Map<string, Awaited<ReturnType<AttemptStore["allocate"]>>>();
|
|
34
|
+
private readonly stores = new Map<string, AttemptStore>();
|
|
35
|
+
private readonly identities = new Map<string, Awaited<ReturnType<typeof scopedCheckIdentity>>>();
|
|
36
|
+
private readonly observations = new Map<string, ScopedRuntimeObservation>();
|
|
37
|
+
private readonly runId = randomUUID();
|
|
38
|
+
private readonly submissions: (() => Promise<void>)[] = [];
|
|
39
|
+
readonly context: CommandContext;
|
|
40
|
+
readonly candidate: CapturedCandidate;
|
|
41
|
+
private constructor(context: CommandContext, candidate: CapturedCandidate) { this.context = context; this.candidate = candidate; }
|
|
42
|
+
static async create(context: CommandContext, candidate: CapturedCandidate): Promise<ScopedChecks | undefined> {
|
|
43
|
+
const providers = context.config.providers.filter(p => p.check?.scope);
|
|
44
|
+
if (!providers.length) return undefined;
|
|
45
|
+
if (context.config.scopedExecution?.version !== "scoped-execution/1") throw new CheckSnapshotError("scoped_executor_required", "Scoped checks require supported private execution profiles before preparation.");
|
|
46
|
+
const session = new ScopedChecks(context, candidate);
|
|
47
|
+
const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace, leaf: "scoped-attempts" });
|
|
48
|
+
const read = await candidateTreeSourceReader(context.rootDir, candidate.treeSha);
|
|
49
|
+
const readEvidence = await candidateTreeEvidenceReader(context.rootDir, candidate.treeSha);
|
|
50
|
+
const listing = await runGitCommand(["git", "ls-tree", "-r", "--name-only", "-z", candidate.treeSha], { cwd: context.rootDir });
|
|
51
|
+
if (listing.exitCode !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "Cannot enumerate the prepared source tree.");
|
|
52
|
+
const inventory = listing.stdout.split("\0").filter(Boolean);
|
|
53
|
+
for (const provider of providers) {
|
|
54
|
+
const scope = provider.check!.scope!, profile = context.config.scopedExecution.profiles.find(p => p.id === scope.profile)!;
|
|
55
|
+
if (!profile) throw new CheckSnapshotError("scoped_executor_required", "A selected execution profile is unsupported.");
|
|
56
|
+
if (profile.mutableOutputs.some(o => inventory.some(f => f === o.replace(/\/$/, "") || f.startsWith(`${o.replace(/\/$/, "")}/`)))) throw new CheckSnapshotError("check_output_overlaps_source", "Mutable outputs overlap prepared source.");
|
|
57
|
+
if ((provider.check!.outputs ?? []).some(o => !profile.mutableOutputs.some(m => m.endsWith("/") ? o.startsWith(m) : o === m))) throw new CheckSnapshotError("check_output_undeclared", "Every retained output must be declared mutable in its profile.");
|
|
58
|
+
const searchPath = executionPath(context.rootDir, context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin");
|
|
59
|
+
const tools = await Promise.all([[process.execPath, "."], [provider.check!.command[0], scope.cwd], ...(profile.dependencies ? [[profile.dependencies.command[0], "."]] : [])].map(([c, cwd]) => executableIdentity(c!, context.rootDir, searchPath, cwd!, read)));
|
|
60
|
+
const observation: ScopedRuntimeObservation = { version: "scoped-runtime/1", runtimeDigest: digestCanonical({ platform: process.platform, arch: process.arch, tools, searchPath }),
|
|
61
|
+
flags: Object.fromEntries(scope.environment.filter(e => e.kind === "flag" && context.env[e.name] !== undefined).map(e => [e.name, context.env[e.name]!])),
|
|
62
|
+
credentials: Object.fromEntries(scope.environment.filter(e => e.kind === "credential").map(e => {
|
|
63
|
+
const present = context.env[e.name] !== undefined, identity = present ? profile.credentialIdentities[e.name] ?? null : null;
|
|
64
|
+
if (identity !== null && (identity === context.env[e.name] || identity === sha256Hex(context.env[e.name] ?? ""))) throw new CheckSnapshotError("check_credential_identity_invalid", "Credential identity must be an external nonsecret revision.");
|
|
65
|
+
return [e.name, { present, identity }];
|
|
66
|
+
})) };
|
|
67
|
+
session.observations.set(provider.id, observation);
|
|
68
|
+
session.identities.set(provider.id, await scopedCheckIdentity(context.config, provider, inventory, read, observation, candidate, readEvidence));
|
|
69
|
+
session.stores.set(provider.id, new AttemptStore(path.join(storage.storageDir, digestCanonical({ gate: context.config.gateId, provider: provider.id }))));
|
|
70
|
+
}
|
|
71
|
+
return session;
|
|
72
|
+
}
|
|
73
|
+
async plan(): Promise<ScopedCheckPlan> {
|
|
74
|
+
const checks: Record<string, ScopedCheckPlan["checks"][string]> = {};
|
|
75
|
+
for (const [id, identity] of this.identities) checks[id] = { inputDigest: identity.inputDigest, profileDigest: identity.profileDigest, reusable: identity.reusable, attempts: (await this.stores.get(id)!.read()).map(r => r.attempt) };
|
|
76
|
+
return { version: "scoped-plan/1", candidate: scopedCandidate(this.candidate), selectionDigest: digestCanonical(this.context.config.providers.filter(p => p.check?.scope).map(p => ({ id: p.id, check: p.check })).sort((a, b) => a.id.localeCompare(b.id))), checks };
|
|
77
|
+
}
|
|
78
|
+
private async selected(id: string): Promise<StoredAttempt | undefined> {
|
|
79
|
+
const identity = this.identities.get(id)!;
|
|
80
|
+
const rows = await this.stores.get(id)!.read();
|
|
81
|
+
const selected = selectScopedCheckAttempt(id, identity.inputDigest, identity.profileDigest, rows.map(r => r.attempt));
|
|
82
|
+
return rows.find(r => r.attempt.attemptId === selected?.attemptId);
|
|
83
|
+
}
|
|
84
|
+
readonly readOutput = async (repoPath: string, providerId: string): Promise<Uint8Array> => {
|
|
85
|
+
if (!this.identities.has(providerId)) return readFile(path.join(this.context.rootDir, repoPath));
|
|
86
|
+
const selected = await this.selected(providerId);
|
|
87
|
+
const output = selected?.attempt.status === "passed" ? selected.payload?.outputs.find(o => o.path === repoPath) : undefined;
|
|
88
|
+
if (!output || sha256Hex(Buffer.from(output.base64, "base64")) !== output.sha256) throw new CheckSnapshotError("check_output_missing", "Retained scoped output is absent or corrupt.");
|
|
89
|
+
return Buffer.from(output.base64, "base64");
|
|
90
|
+
};
|
|
91
|
+
/** No safe secret identity means a fresh attempt in every executing invocation. */
|
|
92
|
+
async fenceNonReusable(ids: readonly string[]): Promise<void> {
|
|
93
|
+
for (const id of ids) if (!this.identities.get(id)!.reusable && !this.owned.has(id)) await this.allocate(id);
|
|
94
|
+
}
|
|
95
|
+
private async allocate(id: string) {
|
|
96
|
+
const identity = this.identities.get(id)!;
|
|
97
|
+
const attempt = await this.stores.get(id)!.allocate({ version: "scoped-attempt/1", providerId: id, inputDigest: identity.inputDigest, profileDigest: identity.profileDigest, origin: { candidate: scopedCandidate(this.candidate), runId: this.runId } });
|
|
98
|
+
this.owned.set(id, attempt); return attempt;
|
|
99
|
+
}
|
|
100
|
+
async explainReuse(admitted: readonly string[]): Promise<void> {
|
|
101
|
+
for (const id of this.identities.keys()) {
|
|
102
|
+
const selected = await this.selected(id);
|
|
103
|
+
if (admitted.includes(id) && selected?.attempt.status === "passed") this.context.write(`reusing ${id}: matching inputs/profile; attempt ${selected.attempt.attemptId}`);
|
|
104
|
+
for (const row of await this.stores.get(id)!.read()) if (row.attempt.status !== "passed" && row.attempt.inputDigest !== this.identities.get(id)!.inputDigest) this.context.write(`superseded ${id}: ${row.attempt.status} attempt ${row.attempt.attemptId} has different inputs`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async satisfyMechanical(): Promise<void> {
|
|
108
|
+
const ids = this.context.config.scopedExecution!.mechanicalProviders;
|
|
109
|
+
await this.fenceNonReusable(ids);
|
|
110
|
+
for (const id of ids) {
|
|
111
|
+
if ((await this.selected(id))?.attempt.status === "passed") { this.context.write(`reusing mechanical ${id}`); continue; }
|
|
112
|
+
const provider = this.context.config.providers.find(p => p.id === id)!;
|
|
113
|
+
await this.execute(provider, this.context.config.obligations.filter(o => o.providers.includes(id)).map(o => o.id), true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async submitMechanical(): Promise<void> { for (const submit of this.submissions) await submit(); this.submissions.length = 0; }
|
|
117
|
+
async execute(provider: ProviderRegistration, obligationIds: readonly string[], deferSubmission = false): Promise<void> {
|
|
118
|
+
const started = Date.now(), check = provider.check!, profile = this.context.config.scopedExecution!.profiles.find(p => p.id === check.scope!.profile)!;
|
|
119
|
+
const attempt = this.owned.get(provider.id) ?? await this.allocate(provider.id);
|
|
120
|
+
let payload: AttemptPayload = { outputs: [] }, terminal = false;
|
|
121
|
+
let diagnostic: RecordedScopedAttemptDiagnostic = { availability: "available", phase: "snapshot-setup", failure: { unavailable: "not-failed" }, command: { unavailable: "not-started" } };
|
|
122
|
+
let executionErrorCode: string | undefined;
|
|
123
|
+
const store = this.stores.get(provider.id)!;
|
|
124
|
+
const secrets = check.scope!.environment.filter(e => e.kind === "credential").map(e => this.context.env[e.name]).filter((v): v is string => !!v);
|
|
125
|
+
try {
|
|
126
|
+
const key = profile.id;
|
|
127
|
+
let snapshot = this.snapshots.get(key);
|
|
128
|
+
if (!snapshot) {
|
|
129
|
+
// Both invocation paths await each execution. Retain only consecutive
|
|
130
|
+
// same-profile reuse; durable outputs no longer need the private tree.
|
|
131
|
+
for (const [previousKey, previous] of this.snapshots) {
|
|
132
|
+
await previous.cleanup();
|
|
133
|
+
this.snapshots.delete(previousKey);
|
|
134
|
+
}
|
|
135
|
+
snapshot = await createCheckSnapshot({ rootDir: this.context.rootDir, candidate: this.candidate, outputs: profile.mutableOutputs, gitContext: profile.gitContext ?? "full", environment: { PATH: this.context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin" }, ...(profile.dependencies ? { dependencies: profile.dependencies } : {}), ...(this.context.signal ? { signal: this.context.signal } : {}),
|
|
136
|
+
onDependencyResult: (result, durationMs) => { diagnostic = { ...diagnostic, dependency: scopedDependencyDiagnostic(result, durationMs, secrets) }; },
|
|
137
|
+
});
|
|
138
|
+
this.snapshots.set(key, snapshot);
|
|
139
|
+
}
|
|
140
|
+
diagnostic = { ...diagnostic, phase: "pre-command-verification" };
|
|
141
|
+
await snapshot.verify();
|
|
142
|
+
// A sibling or dependency setup cannot supply this command's result.
|
|
143
|
+
for (const output of check.outputs ?? []) await rm(path.join(snapshot.rootDir, output), { recursive: true, force: true });
|
|
144
|
+
const injected = Object.fromEntries(check.scope!.environment.filter(e => this.context.env[e.name] !== undefined).map(e => [e.name, this.context.env[e.name]!]));
|
|
145
|
+
this.context.write(`checking ${provider.id}: attempt ${attempt.attemptId}`);
|
|
146
|
+
diagnostic = { ...diagnostic, phase: "command" };
|
|
147
|
+
const commandHome = path.join(snapshot.commandRoot, attempt.attemptId, "home"), commandTemp = path.join(snapshot.commandRoot, attempt.attemptId, "tmp");
|
|
148
|
+
await mkdir(commandHome, { recursive: true }); await mkdir(commandTemp, { recursive: true });
|
|
149
|
+
diagnostic = { ...diagnostic, command: { unavailable: "not-completed" } };
|
|
150
|
+
const result = await createExecPort().run({ command: check.command[0], args: check.command.slice(1), cwd: path.join(snapshot.rootDir, check.scope!.cwd), env: { ...snapshot.environment, ...injected, HOME: commandHome, TMPDIR: commandTemp }, timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024, ...(this.context.signal ? { signal: this.context.signal } : {}) });
|
|
151
|
+
const redact = (s: string) => redactScopedOutput(s, secrets);
|
|
152
|
+
executionErrorCode = result.errorCode;
|
|
153
|
+
diagnostic = { ...diagnostic, command: scopedCommandDiagnostic(result, secrets) };
|
|
154
|
+
payload = { outputs: [], durationMs: Date.now() - started, log: redact(`${result.stdout}\n${result.stderr}`).slice(-4000), dependencyDigest: snapshot.dependencyDigest };
|
|
155
|
+
if (result.code !== 0 || this.context.signal?.aborted) throw new CheckSnapshotError("check_command_failed", `Declared scoped check ${provider.id} did not complete successfully (exit ${result.code}).`);
|
|
156
|
+
diagnostic = { ...diagnostic, phase: "post-command-verification" };
|
|
157
|
+
await snapshot.verify();
|
|
158
|
+
diagnostic = { ...diagnostic, phase: "output-capture" };
|
|
159
|
+
const outputs = await captureCheckOutputSnapshots(snapshot.rootDir, check.outputs ?? []);
|
|
160
|
+
if (!outputs || outputs.some(o => secrets.some(secret => Buffer.from(o.base64, "base64").includes(Buffer.from(secret))))) throw new CheckSnapshotError("check_output_missing", "A retained output is absent, corrupt, escaped or contains credential bytes.");
|
|
161
|
+
payload = { ...payload, outputs, diagnostic: { ...diagnostic, phase: "complete" } };
|
|
162
|
+
await store.finish(attempt, "passed", payload); terminal = true;
|
|
163
|
+
const scopedPlan = await this.plan();
|
|
164
|
+
const wiring = await this.context.wire();
|
|
165
|
+
const binding = (await captureCheckBindings(this.context.rootDir, this.context.config, this.candidate, { ...wiring.storageOptions, scopedPlan, readOutput: this.readOutput }))[provider.id];
|
|
166
|
+
if (!binding || binding.scopedAttemptDigest !== digestCanonical({ ...attempt, status: "passed" })) throw new CheckSnapshotError("check_attempt_superseded", "A newer attempt superseded this completion before evidence publication.");
|
|
167
|
+
const runId = attempt.attemptId, finalPassId = "pass-1";
|
|
168
|
+
const allocation = await this.context.artifacts.allocateRunRoot({ providerId: provider.id, runId });
|
|
169
|
+
if (!allocation.ok) throw new CheckSnapshotError("check_artifact_unavailable", "Cannot allocate scoped check evidence.");
|
|
170
|
+
const artifacts: { path: string; sha256: string; role: string }[] = [];
|
|
171
|
+
const write = async (name: string, value: unknown, role: string) => { const text = JSON.stringify(value); await this.context.artifacts.writeTextFile(path.join(allocation.runRoot.path, name), text); artifacts.push({ path: name, sha256: sha256Hex(text), role }); };
|
|
172
|
+
for (const [index, output] of outputs.entries()) await write(`check-output-${index}.json`, { path: output.path, base64: output.base64 }, "check-output");
|
|
173
|
+
const claim = { verdict: "green", exitCode: 0, binding };
|
|
174
|
+
await write("check-result.json", { providerId: provider.id, runId, finalPassId, ...claim }, "check-result");
|
|
175
|
+
await write("scoped-inputs.json", { observation: this.observations.get(provider.id), attempt: { ...attempt, status: "passed" }, durationMs: payload.durationMs, dependencyDigest: payload.dependencyDigest }, "scoped-inputs");
|
|
176
|
+
const c = this.candidate;
|
|
177
|
+
const manifest = { spec: "delivery-evidence/1", provider: { id: provider.id, runId, finalPassId }, candidate: { vcs: "git", treeSha: c.treeSha, headSha: c.headSha, deliverable: c.deliverable, base: c.base, workspaceId: c.workspaceId }, runHistory: [{ preparedTreeSha: c.treeSha, evaluatedInPassId: finalPassId }], artifacts, attestation: { level: "self", signatures: [] }, recordedAt: new Date().toISOString(), claims: obligationIds.map(obligation => ({ obligation, payloadSpec: "checks.passed/1", payload: claim })) };
|
|
178
|
+
const manifestPath = path.join(allocation.runRoot.path, "manifest.json"); await this.context.artifacts.writeTextFile(manifestPath, JSON.stringify(manifest));
|
|
179
|
+
const submit = async () => {
|
|
180
|
+
const outcome = await submitManifest({ rootDir: this.context.rootDir, config: this.context.config, manifestPath }, { captureCandidate: wiring.captureCandidate, artifacts: this.context.artifacts, ...wiring.storageOptions, scopedPlan: await this.plan(), readOutput: this.readOutput });
|
|
181
|
+
if (outcome.status !== "accepted") throw new CheckSnapshotError("check_evidence_rejected", outcome.blockers.map(b => b.code).join(", "));
|
|
182
|
+
};
|
|
183
|
+
if (deferSubmission) this.submissions.push(submit); else await submit();
|
|
184
|
+
this.context.write(`passed ${provider.id}: ${Date.now() - started}ms including snapshot setup; retained ${runId}`);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!terminal) await store.finish(attempt, this.context.signal?.aborted ? "interrupted" : "failed", { ...payload, durationMs: Date.now() - started,
|
|
187
|
+
diagnostic: { ...diagnostic, failure: scopedDiagnosticFailure(error, executionErrorCode) },
|
|
188
|
+
...(error instanceof CheckSnapshotError ? { log: `${payload.log ?? ""}\n${error.code}`.slice(-4000) } : {}) });
|
|
189
|
+
const damaged = this.snapshots.get(profile.id);
|
|
190
|
+
this.snapshots.delete(profile.id);
|
|
191
|
+
await damaged?.cleanup();
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async cleanup(): Promise<void> { for (const snapshot of this.snapshots.values()) await snapshot.cleanup(); this.snapshots.clear(); }
|
|
196
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { digestCanonical, resolveRecordStorage, type ExecOutcome, type HarnessConfig, type ScopedCheckAttempt } from "@agent-delivery-harness/kernel";
|
|
3
|
+
import { AttemptStore } from "./scoped-attempts.ts";
|
|
4
|
+
import { CheckSnapshotError } from "./check-snapshot.ts";
|
|
5
|
+
import { projectScopedAttempt } from "./scoped-observations.ts";
|
|
6
|
+
|
|
7
|
+
const phases = ["snapshot-setup", "pre-command-verification", "command", "post-command-verification", "output-capture", "complete"] as const;
|
|
8
|
+
const failureCodes = ["check_snapshot_interrupted", "check_snapshot_timeout", "check_snapshot_unavailable", "check_snapshot_escape", "check_snapshot_drift", "check_snapshot_cleanup_failed", "check_dependency_source_overlap", "check_dependency_failed", "check_command_failed", "check_output_missing", "check_attempt_superseded", "check_artifact_unavailable", "check_evidence_rejected"] as const;
|
|
9
|
+
const executionCodes = ["ENOENT", "EACCES", "EPERM", "ABORT_ERR", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", "SIGKILL", "SIGTERM", "execution_failed"] as const;
|
|
10
|
+
export type ScopedDiagnosticPhase = typeof phases[number];
|
|
11
|
+
export type ScopedDiagnosticFailureCode = typeof failureCodes[number];
|
|
12
|
+
export type ScopedDiagnosticExecutionCode = typeof executionCodes[number];
|
|
13
|
+
export type ScopedDiagnosticCommand =
|
|
14
|
+
| { readonly exitCode: number | null; readonly outputTail: string; readonly truncated: boolean }
|
|
15
|
+
| { readonly unavailable: "not-started" | "not-completed" };
|
|
16
|
+
export interface ScopedDependencyDiagnostic {
|
|
17
|
+
readonly durationMs: number;
|
|
18
|
+
readonly executionErrorCode?: ScopedDiagnosticExecutionCode;
|
|
19
|
+
readonly command: ScopedDiagnosticCommand;
|
|
20
|
+
}
|
|
21
|
+
export interface RecordedScopedAttemptDiagnostic {
|
|
22
|
+
readonly availability: "available";
|
|
23
|
+
readonly phase: ScopedDiagnosticPhase;
|
|
24
|
+
readonly failure: { readonly code: ScopedDiagnosticFailureCode; readonly executionErrorCode?: ScopedDiagnosticExecutionCode }
|
|
25
|
+
| { readonly unavailable: "not-failed" | "unclassified" };
|
|
26
|
+
readonly command: ScopedDiagnosticCommand;
|
|
27
|
+
/** Present only when this attempt actually ran dependency setup; never borrowed from a reused snapshot. */
|
|
28
|
+
readonly dependency?: ScopedDependencyDiagnostic;
|
|
29
|
+
}
|
|
30
|
+
export type ScopedAttemptDiagnostic = RecordedScopedAttemptDiagnostic
|
|
31
|
+
| { readonly availability: "unavailable"; readonly reason: "legacy" | "running" };
|
|
32
|
+
export interface ScopedCheckDiagnostics {
|
|
33
|
+
readonly version: "scoped-check-diagnostics/1";
|
|
34
|
+
readonly providers: readonly {
|
|
35
|
+
readonly providerId: string;
|
|
36
|
+
readonly attempts: readonly (ScopedCheckAttempt & { readonly durationMs?: number; readonly diagnostic: ScopedAttemptDiagnostic })[];
|
|
37
|
+
}[];
|
|
38
|
+
readonly unavailableAttemptIds: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function scopedDiagnosticFailure(error: unknown, executionErrorCode?: string): RecordedScopedAttemptDiagnostic["failure"] {
|
|
42
|
+
if (!(error instanceof CheckSnapshotError) || !(failureCodes as readonly string[]).includes(error.code)) return { unavailable: "unclassified" };
|
|
43
|
+
return { code: error.code as ScopedDiagnosticFailureCode,
|
|
44
|
+
...((executionCodes as readonly string[]).includes(executionErrorCode ?? "") ? { executionErrorCode: executionErrorCode as ScopedDiagnosticExecutionCode } : {}) };
|
|
45
|
+
}
|
|
46
|
+
export function redactScopedOutput(text: string, secrets: readonly string[]): string {
|
|
47
|
+
return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).reduce((value, secret) => value.split(secret).join("[REDACTED]"), text);
|
|
48
|
+
}
|
|
49
|
+
export function scopedCommandDiagnostic(result: ExecOutcome, secrets: readonly string[]): ScopedDiagnosticCommand {
|
|
50
|
+
// execFile can clip a fully emitted credential before full-value redaction.
|
|
51
|
+
// The typed execution failure remains available; this partial capture is not safe to export.
|
|
52
|
+
if (result.errorCode === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return { unavailable: "not-completed" };
|
|
53
|
+
const numericExit = result.errorCode === undefined || result.errorCode === String(result.code);
|
|
54
|
+
if (!numericExit && result.stdout.length + result.stderr.length === 0) return { unavailable: ["ENOENT", "EACCES", "EPERM"].includes(result.errorCode!) ? "not-started" : "not-completed" };
|
|
55
|
+
const redacted = redactScopedOutput(`${result.stdout}\n${result.stderr}`, secrets);
|
|
56
|
+
return { exitCode: numericExit ? result.code : null, outputTail: redacted.slice(-4000), truncated: redacted.length > 4000 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function scopedDependencyDiagnostic(result: ExecOutcome, durationMs: number, secrets: readonly string[]): ScopedDependencyDiagnostic {
|
|
60
|
+
return { durationMs, command: scopedCommandDiagnostic(result, secrets),
|
|
61
|
+
...((executionCodes as readonly string[]).includes(result.errorCode ?? "") ? { executionErrorCode: result.errorCode as ScopedDiagnosticExecutionCode } : {}) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function projectDiagnostic(value: unknown): RecordedScopedAttemptDiagnostic {
|
|
65
|
+
const corrupt = (): never => { throw new CheckSnapshotError("check_attempt_corrupt", "Selected scoped diagnostics are malformed."); };
|
|
66
|
+
const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : corrupt();
|
|
67
|
+
const row = object(value), failure = object(row["failure"]);
|
|
68
|
+
if (row["availability"] !== "available" || !(phases as readonly unknown[]).includes(row["phase"])) return corrupt();
|
|
69
|
+
let safeFailure: RecordedScopedAttemptDiagnostic["failure"];
|
|
70
|
+
if ("unavailable" in failure) {
|
|
71
|
+
if (typeof failure["unavailable"] !== "string" || !["not-failed", "unclassified"].includes(failure["unavailable"]) || "code" in failure || "executionErrorCode" in failure) return corrupt();
|
|
72
|
+
safeFailure = { unavailable: failure["unavailable"] as "not-failed" | "unclassified" };
|
|
73
|
+
} else {
|
|
74
|
+
if (!(failureCodes as readonly unknown[]).includes(failure["code"]) || ("executionErrorCode" in failure && !(executionCodes as readonly unknown[]).includes(failure["executionErrorCode"]))) return corrupt();
|
|
75
|
+
safeFailure = { code: failure["code"] as ScopedDiagnosticFailureCode, ...("executionErrorCode" in failure ? { executionErrorCode: failure["executionErrorCode"] as ScopedDiagnosticExecutionCode } : {}) };
|
|
76
|
+
}
|
|
77
|
+
const commandDiagnostic = (value: unknown): ScopedDiagnosticCommand => {
|
|
78
|
+
const command = object(value);
|
|
79
|
+
if ("unavailable" in command) {
|
|
80
|
+
if (typeof command["unavailable"] !== "string" || !["not-started", "not-completed"].includes(command["unavailable"]) || "exitCode" in command || "outputTail" in command || "truncated" in command) return corrupt();
|
|
81
|
+
return { unavailable: command["unavailable"] as "not-started" | "not-completed" };
|
|
82
|
+
}
|
|
83
|
+
if (!(command["exitCode"] === null || typeof command["exitCode"] === "number" && Number.isSafeInteger(command["exitCode"]) && command["exitCode"] >= 0) || typeof command["outputTail"] !== "string" || command["outputTail"].length > 4000 || typeof command["truncated"] !== "boolean") return corrupt();
|
|
84
|
+
return { exitCode: command["exitCode"] as number | null, outputTail: command["outputTail"], truncated: command["truncated"] };
|
|
85
|
+
};
|
|
86
|
+
let dependency: ScopedDependencyDiagnostic | undefined;
|
|
87
|
+
if ("dependency" in row) {
|
|
88
|
+
const value = object(row["dependency"]);
|
|
89
|
+
if (typeof value["durationMs"] !== "number" || !Number.isFinite(value["durationMs"]) || value["durationMs"] < 0 ||
|
|
90
|
+
("executionErrorCode" in value && !(executionCodes as readonly unknown[]).includes(value["executionErrorCode"]))) return corrupt();
|
|
91
|
+
dependency = { durationMs: value["durationMs"], command: commandDiagnostic(value["command"]),
|
|
92
|
+
...("executionErrorCode" in value ? { executionErrorCode: value["executionErrorCode"] as ScopedDiagnosticExecutionCode } : {}) };
|
|
93
|
+
}
|
|
94
|
+
return { availability: "available", phase: row["phase"] as ScopedDiagnosticPhase, failure: safeFailure, command: commandDiagnostic(row["command"]),
|
|
95
|
+
...(dependency === undefined ? {} : { dependency }) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read at most 100 explicit attempts. No logs from legacy payloads, no writes,
|
|
99
|
+
* and no claim of applicability/admission. Unselected provider history is ignored. */
|
|
100
|
+
export async function readScopedCheckDiagnostics(input: {
|
|
101
|
+
readonly rootDir: string;
|
|
102
|
+
readonly config: Pick<HarnessConfig, "gateId" | "storageNamespace" | "providers">;
|
|
103
|
+
readonly attemptIds: readonly string[];
|
|
104
|
+
}): Promise<ScopedCheckDiagnostics> {
|
|
105
|
+
const { rootDir, config, attemptIds } = input;
|
|
106
|
+
if (!Array.isArray(attemptIds) || attemptIds.length > 100 || attemptIds.some(id => typeof id !== "string" || !id.length || id.length > 128) || new Set(attemptIds).size !== attemptIds.length) throw new CheckSnapshotError("check_diagnostics_request_invalid", "Diagnostics require at most 100 unique nonempty attempt identifiers.");
|
|
107
|
+
const requested = new Set(attemptIds), seen = new Set<string>();
|
|
108
|
+
const providers: ScopedCheckDiagnostics["providers"][number][] = [];
|
|
109
|
+
const storage = await resolveRecordStorage(rootDir, { storageNamespace: config.storageNamespace, leaf: "scoped-attempts" });
|
|
110
|
+
for (const provider of config.providers.filter(p => p.check?.scope !== undefined)) {
|
|
111
|
+
const rows = requested.size ? await new AttemptStore(path.join(storage.storageDir, digestCanonical({ gate: config.gateId, provider: provider.id }))).read() : [];
|
|
112
|
+
if (rows.some(row => row.attempt.providerId !== provider.id)) throw new CheckSnapshotError("check_attempt_corrupt", "Scoped history belongs to a different provider.");
|
|
113
|
+
const attempts: ScopedCheckDiagnostics["providers"][number]["attempts"][number][] = [];
|
|
114
|
+
for (const { attempt, payload } of rows.filter(row => requested.has(row.attempt.attemptId))) {
|
|
115
|
+
if (seen.has(attempt.attemptId)) throw new CheckSnapshotError("check_attempt_corrupt", "A requested attempt identifier is ambiguous.");
|
|
116
|
+
seen.add(attempt.attemptId);
|
|
117
|
+
const diagnostic: ScopedAttemptDiagnostic = attempt.status === "running" ? { availability: "unavailable", reason: "running" }
|
|
118
|
+
: payload?.diagnostic === undefined ? { availability: "unavailable", reason: "legacy" } : projectDiagnostic(payload.diagnostic);
|
|
119
|
+
attempts.push({ ...projectScopedAttempt(attempt, payload?.durationMs), diagnostic });
|
|
120
|
+
}
|
|
121
|
+
providers.push({ providerId: provider.id, attempts });
|
|
122
|
+
}
|
|
123
|
+
return { version: "scoped-check-diagnostics/1", providers, unavailableAttemptIds: attemptIds.filter(id => !seen.has(id)) };
|
|
124
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { digestCanonical, resolveRecordStorage, type HarnessConfig, type ScopedCheckAttempt } from "@agent-delivery-harness/kernel";
|
|
3
|
+
import { AttemptStore } from "./scoped-attempts.ts";
|
|
4
|
+
import { CheckSnapshotError } from "./check-snapshot.ts";
|
|
5
|
+
|
|
6
|
+
export interface ScopedCheckObservations {
|
|
7
|
+
readonly version: "scoped-check-observations/1";
|
|
8
|
+
readonly providers: readonly {
|
|
9
|
+
readonly providerId: string;
|
|
10
|
+
readonly attempts: readonly (ScopedCheckAttempt & { readonly durationMs?: number })[];
|
|
11
|
+
}[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function projectScopedAttempt(attempt: ScopedCheckAttempt, durationMs?: number): ScopedCheckAttempt & { readonly durationMs?: number } {
|
|
15
|
+
return {
|
|
16
|
+
version: attempt.version, providerId: attempt.providerId, attemptId: attempt.attemptId,
|
|
17
|
+
generation: attempt.generation, inputDigest: attempt.inputDigest, profileDigest: attempt.profileDigest,
|
|
18
|
+
status: attempt.status, origin: { runId: attempt.origin.runId, candidate: {
|
|
19
|
+
treeSha: attempt.origin.candidate.treeSha, deliverableDigest: attempt.origin.candidate.deliverableDigest,
|
|
20
|
+
identityToken: attempt.origin.candidate.identityToken, baseRef: attempt.origin.candidate.baseRef,
|
|
21
|
+
baseTipSha: attempt.origin.candidate.baseTipSha, mergeBaseSha: attempt.origin.candidate.mergeBaseSha,
|
|
22
|
+
workspaceId: attempt.origin.candidate.workspaceId,
|
|
23
|
+
} }, ...(durationMs === undefined ? {} : { durationMs }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read retained attempts for the supplied scoped providers, in configuration
|
|
29
|
+
* order and ascending generation order. This creates no directories or evidence.
|
|
30
|
+
* Providers removed from this supplied configuration are not inventoried.
|
|
31
|
+
* Absent history is empty; corrupt selected history throws. Durations are reported only
|
|
32
|
+
* when recorded by the executor. These observations make no statement about
|
|
33
|
+
* reuse, current applicability or admission; use the gate/verify for those.
|
|
34
|
+
*/
|
|
35
|
+
export async function readScopedCheckObservations(input: {
|
|
36
|
+
readonly rootDir: string;
|
|
37
|
+
readonly config: Pick<HarnessConfig, "gateId" | "storageNamespace" | "providers">;
|
|
38
|
+
}): Promise<ScopedCheckObservations> {
|
|
39
|
+
const { rootDir, config } = input;
|
|
40
|
+
const storage = await resolveRecordStorage(rootDir, { storageNamespace: config.storageNamespace, leaf: "scoped-attempts" });
|
|
41
|
+
const providers: ScopedCheckObservations["providers"][number][] = [];
|
|
42
|
+
for (const provider of config.providers.filter(provider => provider.check?.scope !== undefined)) {
|
|
43
|
+
const store = new AttemptStore(path.join(storage.storageDir, digestCanonical({ gate: config.gateId, provider: provider.id })));
|
|
44
|
+
const retained = await store.read();
|
|
45
|
+
if (retained.some(row => row.attempt.providerId !== provider.id)) {
|
|
46
|
+
throw new CheckSnapshotError("check_attempt_corrupt", "Scoped check attempt history belongs to a different provider.");
|
|
47
|
+
}
|
|
48
|
+
providers.push({ providerId: provider.id, attempts: retained.map(({ attempt, payload }) => projectScopedAttempt(attempt, payload?.durationMs)) });
|
|
49
|
+
}
|
|
50
|
+
return { version: "scoped-check-observations/1", providers };
|
|
51
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Inline so the distributed single-file runtime owns the worker bytes too.
|
|
2
|
+
* Only Node builtins are available; no author code or credentials are loaded. */
|
|
3
|
+
export const snapshotInventoryWorker = String.raw`
|
|
4
|
+
import { lstat, readFile, readdir, readlink, realpath } from 'node:fs/promises';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
const { root, outputs, excludeDependencies, forbidGit } = JSON.parse(process.env.DELIVERY_SNAPSHOT_REQUEST);
|
|
8
|
+
const entries = [];
|
|
9
|
+
const inside = target => {
|
|
10
|
+
const relative = path.relative(root, target);
|
|
11
|
+
return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
|
|
12
|
+
};
|
|
13
|
+
const escape = () => { throw { code: 'check_snapshot_escape' }; };
|
|
14
|
+
const walk = async relative => {
|
|
15
|
+
if (relative === '.git' || (excludeDependencies && relative.split('/').includes('node_modules')) ||
|
|
16
|
+
outputs.some(o => o.endsWith('/') ? relative === o.slice(0, -1) || relative.startsWith(o) : relative === o)) return;
|
|
17
|
+
const absolute = path.join(root, relative), stat = await lstat(absolute);
|
|
18
|
+
if (stat.isSymbolicLink()) {
|
|
19
|
+
const target = await readlink(absolute);
|
|
20
|
+
if (path.isAbsolute(target) || !inside(path.resolve(path.dirname(absolute), target))) escape();
|
|
21
|
+
try { if (!inside(await realpath(absolute))) escape(); }
|
|
22
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
23
|
+
entries.push([relative, 'link', target]);
|
|
24
|
+
} else if (stat.isDirectory()) {
|
|
25
|
+
for (const name of (await readdir(absolute)).sort()) await walk(relative ? relative + '/' + name : name);
|
|
26
|
+
} else if (stat.isFile()) {
|
|
27
|
+
entries.push([relative, stat.mode & 0o111, createHash('sha256').update(await readFile(absolute)).digest('hex')]);
|
|
28
|
+
} else escape();
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
if (forbidGit && await lstat(path.join(root, '.git')).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; })) throw { code: 'check_snapshot_drift' };
|
|
32
|
+
await walk('');
|
|
33
|
+
// Tuples contain only arrays, strings and permission integers: JSON.stringify
|
|
34
|
+
// has exactly the same bytes as canonical JSON for this restricted shape.
|
|
35
|
+
process.stdout.write(JSON.stringify({ digest: createHash('sha256').update(JSON.stringify(entries)).digest('hex') }));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
process.stdout.write(JSON.stringify({ code: ['check_snapshot_escape', 'check_snapshot_drift'].includes(error.code) ? error.code : 'check_snapshot_unavailable' }));
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
`;
|