@tangle-network/agent-runtime 0.249.1 → 0.252.0
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/dist/{activation-ygjQvYwG.js → activation-BSLatbiJ.js} +3 -3
- package/dist/{activation-ygjQvYwG.js.map → activation-BSLatbiJ.js.map} +1 -1
- package/dist/{activation-CZz7kkgd.d.ts → activation-BykUtuec.d.ts} +2 -2
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +2 -2
- package/dist/bundle-DXrBPEFw.js +17 -0
- package/dist/bundle-DXrBPEFw.js.map +1 -0
- package/dist/candidate-execution/index.js +4 -4
- package/dist/{coordination-driver-B61Hh6B1.js → coordination-driver-QxitIuva.js} +21 -8
- package/dist/coordination-driver-QxitIuva.js.map +1 -0
- package/dist/{delegate-A6EYmMxy.js → delegate-BB90iDo7.js} +2 -2
- package/dist/{delegate-A6EYmMxy.js.map → delegate-BB90iDo7.js.map} +1 -1
- package/dist/durable.d.ts +1 -1
- package/dist/durable.js +2 -2
- package/dist/{graph-CXwmMtTx.js → graph-yC6wqOIt.js} +3 -3
- package/dist/{graph-CXwmMtTx.js.map → graph-yC6wqOIt.js.map} +1 -1
- package/dist/{improvement-cycle-B049YmQ8.js → improvement-cycle-on_u94aK.js} +5 -5
- package/dist/{improvement-cycle-B049YmQ8.js.map → improvement-cycle-on_u94aK.js.map} +1 -1
- package/dist/{index-BV5EmjCL.d.ts → index-DGWG7wSS.d.ts} +14 -6
- package/dist/index.d.ts +4 -4
- package/dist/index.js +9 -10
- package/dist/index.js.map +1 -1
- package/dist/intelligence.d.ts +2 -2
- package/dist/intelligence.js +6 -6
- package/dist/kernel.d.ts +3 -3
- package/dist/kernel.js +9 -9
- package/dist/{loop-runner-bin-q0akFXYX.d.ts → loop-runner-bin-CsVNUclF.d.ts} +2 -2
- package/dist/{loop-runner-bin-C1O16oIq.js → loop-runner-bin-PL0dItet.js} +3 -3
- package/dist/{loop-runner-bin-C1O16oIq.js.map → loop-runner-bin-PL0dItet.js.map} +1 -1
- package/dist/loop-runner-bin.d.ts +1 -1
- package/dist/loop-runner-bin.js +1 -1
- package/dist/mcp/bin.js +3 -3
- package/dist/mcp/index.d.ts +2 -2
- package/dist/mcp/index.js +4 -4
- package/dist/{prepare-CibQrhfi.js → prepare-RuaAclli.js} +8 -598
- package/dist/prepare-RuaAclli.js.map +1 -0
- package/dist/{protected-model-port-Dhdi5mDl.js → protected-model-port-BFhSgMAy.js} +3 -3
- package/dist/{protected-model-port-Dhdi5mDl.js.map → protected-model-port-BFhSgMAy.js.map} +1 -1
- package/dist/{protected-redaction-Fn1ETfJ-.js → protected-redaction--F3v1oo8.js} +3 -54
- package/dist/protected-redaction--F3v1oo8.js.map +1 -0
- package/dist/{provision-supervisor-D7gC-Veu.js → provision-supervisor-DsAhgOpn.js} +4 -4
- package/dist/{provision-supervisor-D7gC-Veu.js.map → provision-supervisor-DsAhgOpn.js.map} +1 -1
- package/dist/{run-layout-hKAJGF4g.js → run-layout-vwd4SwX_.js} +15 -9
- package/dist/run-layout-vwd4SwX_.js.map +1 -0
- package/dist/{runtime-BVZIbtyL.js → runtime-CMNTa7Rw.js} +11 -10
- package/dist/{runtime-BVZIbtyL.js.map → runtime-CMNTa7Rw.js.map} +1 -1
- package/dist/{server-DHS1Gxt2.js → server-2fL_QElQ.js} +3 -3
- package/dist/{server-DHS1Gxt2.js.map → server-2fL_QElQ.js.map} +1 -1
- package/dist/{stream-agent-turn-D9IbZMys.d.ts → stream-agent-turn-oeWkjxMi.d.ts} +31 -4
- package/dist/{structural-rollout-z1A0okaB.js → structural-rollout-DRD-WnV-.js} +2 -2
- package/dist/{structural-rollout-z1A0okaB.js.map → structural-rollout-DRD-WnV-.js.map} +1 -1
- package/dist/{supervise-CnFk7_wb.js → supervise-BXzkqfTE.js} +9 -6
- package/dist/supervise-BXzkqfTE.js.map +1 -0
- package/dist/{supervisor-BIBWUFUr.js → supervisor-OJwKAnFd.js} +391 -187
- package/dist/supervisor-OJwKAnFd.js.map +1 -0
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +14 -14
- package/dist/{top-app-d-bmwO7O.js → top-app-VAOrBskv.js} +2 -2
- package/dist/{top-app-d-bmwO7O.js.map → top-app-VAOrBskv.js.map} +1 -1
- package/dist/tui/bin.js +1 -1
- package/dist/tui/index.d.ts +1 -1
- package/dist/tui/index.js +2 -2
- package/dist/workspace-archive-DQGFwvM6.js +1209 -0
- package/dist/workspace-archive-DQGFwvM6.js.map +1 -0
- package/package.json +1 -1
- package/dist/coordination-driver-B61Hh6B1.js.map +0 -1
- package/dist/prepare-CibQrhfi.js.map +0 -1
- package/dist/protected-redaction-Fn1ETfJ-.js.map +0 -1
- package/dist/run-layout-hKAJGF4g.js.map +0 -1
- package/dist/supervise-CnFk7_wb.js.map +0 -1
- package/dist/supervisor-BIBWUFUr.js.map +0 -1
- package/dist/workspace-archive-DSRT4Tro.js +0 -572
- package/dist/workspace-archive-DSRT4Tro.js.map +0 -1
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
import { agentCandidateArtifactRefSchema, canonicalCandidateBytes as canonicalCandidateBytes$1 } from "@tangle-network/agent-interface";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
6
|
+
import { lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { isAnyArrayBuffer, isSharedArrayBuffer } from "node:util/types";
|
|
9
|
+
import { extract, pack } from "tar-stream";
|
|
10
|
+
//#region src/candidate-execution/digest.ts
|
|
11
|
+
/** Use native hashing for workspace archives. */
|
|
12
|
+
function sha256Bytes(bytes) {
|
|
13
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
14
|
+
}
|
|
15
|
+
function canonicalCandidateDigest$1(value) {
|
|
16
|
+
return sha256Bytes(canonicalCandidateBytes$1(value));
|
|
17
|
+
}
|
|
18
|
+
/** Returns a detached, deeply frozen JSON value with canonical number normalization. */
|
|
19
|
+
function immutableCandidateValue(value) {
|
|
20
|
+
return deepFreezeCandidate(JSON.parse(Buffer.from(canonicalCandidateBytes$1(value)).toString("utf8")));
|
|
21
|
+
}
|
|
22
|
+
function canonicalCandidateDocument(valueWithoutDigest) {
|
|
23
|
+
const bytes = canonicalCandidateBytes$1(valueWithoutDigest);
|
|
24
|
+
const digest = sha256Bytes(bytes);
|
|
25
|
+
const value = deepFreezeCandidate({
|
|
26
|
+
...JSON.parse(Buffer.from(bytes).toString("utf8")),
|
|
27
|
+
digest
|
|
28
|
+
});
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
value,
|
|
31
|
+
get bytes() {
|
|
32
|
+
return Uint8Array.from(bytes);
|
|
33
|
+
},
|
|
34
|
+
digest
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function verifyCanonicalCandidateDocument(value, label) {
|
|
38
|
+
if (canonicalCandidateDigest$1(omitTopLevelDigest(value)) !== value.digest) throw new Error(`${label} digest does not match`);
|
|
39
|
+
return immutableCandidateValue(value);
|
|
40
|
+
}
|
|
41
|
+
function embeddedCandidateArtifact(bytes) {
|
|
42
|
+
return {
|
|
43
|
+
encoding: "base64",
|
|
44
|
+
content: Buffer.from(bytes).toString("base64"),
|
|
45
|
+
sha256: sha256Bytes(bytes),
|
|
46
|
+
byteLength: bytes.byteLength
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function omitTopLevelDigest(value) {
|
|
50
|
+
const { digest: _digest, ...rest } = value;
|
|
51
|
+
return rest;
|
|
52
|
+
}
|
|
53
|
+
function deepFreezeCandidate(value, seen = /* @__PURE__ */ new Set()) {
|
|
54
|
+
if (value === null || typeof value !== "object" || ArrayBuffer.isView(value) || seen.has(value)) return value;
|
|
55
|
+
seen.add(value);
|
|
56
|
+
for (const child of Object.values(value)) deepFreezeCandidate(child, seen);
|
|
57
|
+
return Object.freeze(value);
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/candidate-execution/artifacts.ts
|
|
61
|
+
function artifactCacheKey(artifact) {
|
|
62
|
+
return `${artifact.sha256}:${artifact.byteLength}`;
|
|
63
|
+
}
|
|
64
|
+
async function readVerifiedArtifact(artifact, port) {
|
|
65
|
+
const bytes = "content" in artifact ? Buffer.from(artifact.content, "base64") : await port.read(artifact);
|
|
66
|
+
verifyBytes(bytes, artifact.sha256, artifact.byteLength, "candidate artifact");
|
|
67
|
+
return Uint8Array.from(bytes);
|
|
68
|
+
}
|
|
69
|
+
function verifyBytes(bytes, digest, byteLength, label) {
|
|
70
|
+
if (bytes.byteLength !== byteLength) throw new Error(`${label} byte length ${bytes.byteLength} does not match ${byteLength}`);
|
|
71
|
+
const actual = sha256Bytes(bytes);
|
|
72
|
+
if (actual !== digest) throw new Error(`${label} digest ${actual} does not match ${digest}`);
|
|
73
|
+
}
|
|
74
|
+
async function verifyWorkspaceSnapshotArtifacts(snapshot, port) {
|
|
75
|
+
const [manifest, archive] = await Promise.all([readVerifiedArtifact(snapshot.manifest, port), readVerifiedArtifact(snapshot.archive, port)]);
|
|
76
|
+
const canonicalManifest = canonicalCandidateBytes$1(snapshot.material);
|
|
77
|
+
if (!Buffer.from(manifest).equals(Buffer.from(canonicalManifest))) throw new Error("workspace manifest artifact is not the exact canonical manifest material");
|
|
78
|
+
if (sha256Bytes(canonicalManifest) !== snapshot.digest) throw new Error("workspace snapshot digest does not match its canonical manifest material");
|
|
79
|
+
return {
|
|
80
|
+
manifest,
|
|
81
|
+
archive
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Refuse a materialized workspace whose files, modes, or bytes are not the signed manifest.
|
|
86
|
+
*
|
|
87
|
+
* The scan streams, so the size of the largest file does not decide whether the check can run, and
|
|
88
|
+
* the refusal names the one mismatch a caller can produce on its own: a capture and a verify that
|
|
89
|
+
* disagree about `portableTree`.
|
|
90
|
+
*/
|
|
91
|
+
async function verifyMaterializedWorkspace(root, expected, options = {}) {
|
|
92
|
+
assertWorkspaceManifest(await scanMaterializedWorkspaceManifest(root, options), expected);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The canonical manifest of one materialized workspace, read without holding any file.
|
|
96
|
+
*
|
|
97
|
+
* Every file is digested by streaming, so the size of the largest file does not decide whether the
|
|
98
|
+
* workspace can be described. `FileHandle.readFile` refuses anything above 2 GiB with
|
|
99
|
+
* `ERR_FS_FILE_TOO_LARGE`, which made a workspace holding one such artifact impossible to verify
|
|
100
|
+
* against a manifest it already matched. The digest is sha-256 over the same bytes either way, so
|
|
101
|
+
* a manifest a buffered read produced is reproduced exactly.
|
|
102
|
+
*/
|
|
103
|
+
async function scanMaterializedWorkspaceManifest(root, options = {}) {
|
|
104
|
+
return workspaceManifestFromEntries(await walkWorkspace(root, options, false));
|
|
105
|
+
}
|
|
106
|
+
async function captureMaterializedWorkspace(root, options = {}) {
|
|
107
|
+
const entries = await walkWorkspace(root, options, true);
|
|
108
|
+
return {
|
|
109
|
+
manifest: workspaceManifestFromEntries(entries),
|
|
110
|
+
files: entries.map((entry) => ({
|
|
111
|
+
path: entry.path,
|
|
112
|
+
mode: entry.mode,
|
|
113
|
+
bytes: Uint8Array.from(capturedBytes(entry))
|
|
114
|
+
}))
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Capture exact verified regular-file bytes for fresh isolated materialization. */
|
|
118
|
+
async function readMaterializedWorkspaceFiles(root, expected, options = {}) {
|
|
119
|
+
const observed = await captureMaterializedWorkspace(root, options);
|
|
120
|
+
assertWorkspaceManifest(observed.manifest, expected);
|
|
121
|
+
return observed.files.map((file) => Object.freeze({
|
|
122
|
+
path: file.path,
|
|
123
|
+
mode: file.mode,
|
|
124
|
+
bytes: Uint8Array.from(file.bytes)
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Build the canonical manifest for files a caller already holds — the shape a remote executor
|
|
129
|
+
* returns. Pass `portableTree` to record Git's two file modes instead of exact permission bits, and
|
|
130
|
+
* pass the same flag to every verify that reads the result.
|
|
131
|
+
*/
|
|
132
|
+
function candidateWorkspaceManifest(files, options = {}) {
|
|
133
|
+
return workspaceManifestFromEntries(files.map((file) => ({
|
|
134
|
+
path: file.path,
|
|
135
|
+
mode: options.portableTree === true ? portableFileMode(file.mode) : file.mode,
|
|
136
|
+
sha256: sha256Bytes(file.bytes),
|
|
137
|
+
byteLength: file.bytes.byteLength
|
|
138
|
+
})));
|
|
139
|
+
}
|
|
140
|
+
function workspaceManifestFromEntries(entries) {
|
|
141
|
+
return {
|
|
142
|
+
kind: "agent-candidate-workspace-manifest",
|
|
143
|
+
files: entries.map((entry) => ({
|
|
144
|
+
path: entry.path,
|
|
145
|
+
mode: entry.mode,
|
|
146
|
+
sha256: entry.sha256,
|
|
147
|
+
byteLength: entry.byteLength
|
|
148
|
+
})).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Git records exactly two file modes, and a portable-tree scan records the same two. */
|
|
152
|
+
function portableFileMode(mode) {
|
|
153
|
+
return (mode & 73) !== 0 ? 493 : 420;
|
|
154
|
+
}
|
|
155
|
+
function capturedBytes(entry) {
|
|
156
|
+
if (!entry.bytes) throw new Error(`workspace file was digested without its bytes: ${entry.path}`);
|
|
157
|
+
return entry.bytes;
|
|
158
|
+
}
|
|
159
|
+
function assertWorkspaceManifest(observed, expected) {
|
|
160
|
+
if (Buffer.from(canonicalCandidateBytes$1(observed)).equals(canonicalCandidateBytes$1(expected))) return;
|
|
161
|
+
if (Buffer.from(canonicalCandidateBytes$1(withPortableModes(observed))).equals(canonicalCandidateBytes$1(withPortableModes(expected)))) throw new Error("materialized workspace files do not match the signed manifest: only the file modes differ, so the capture and this verify disagree about portableTree");
|
|
162
|
+
throw new Error("materialized workspace files, modes, or bytes do not match the signed manifest");
|
|
163
|
+
}
|
|
164
|
+
function withPortableModes(material) {
|
|
165
|
+
return {
|
|
166
|
+
...material,
|
|
167
|
+
files: material.files.map((file) => ({
|
|
168
|
+
...file,
|
|
169
|
+
mode: portableFileMode(file.mode)
|
|
170
|
+
}))
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async function verifyMaterializedProfileWorkspace(root, expected, fileRoot = "workspace") {
|
|
174
|
+
const observedProfile = (await scanMaterializedWorkspaceManifest(root)).files.map(({ path, mode, sha256 }) => ({
|
|
175
|
+
relPath: path,
|
|
176
|
+
mode,
|
|
177
|
+
contentSha256: sha256
|
|
178
|
+
}));
|
|
179
|
+
const expectedFiles = expected.files.filter((file) => (file.root ?? "workspace") === fileRoot).map(({ root: _root, ...file }) => file);
|
|
180
|
+
if (!Buffer.from(canonicalCandidateBytes$1(observedProfile)).equals(canonicalCandidateBytes$1(expectedFiles))) throw new Error(`profile ${fileRoot} staging files, modes, or bytes do not match the signed profile plan`);
|
|
181
|
+
}
|
|
182
|
+
async function walkWorkspace(root, options, keepBytes) {
|
|
183
|
+
const ignoredProtectedRootEntries = new Set(options.ignoredProtectedRootEntries ?? []);
|
|
184
|
+
const limits = options.limits;
|
|
185
|
+
const requestedRoot = resolve(root);
|
|
186
|
+
const rootStats = await lstat(requestedRoot);
|
|
187
|
+
if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) throw new Error("workspace root must be a real directory");
|
|
188
|
+
const absoluteRoot = await realpath(requestedRoot);
|
|
189
|
+
const resolvedStats = await lstat(absoluteRoot);
|
|
190
|
+
if (!resolvedStats.isDirectory() || resolvedStats.isSymbolicLink()) throw new Error("workspace root must resolve to a real directory");
|
|
191
|
+
const scanned = [];
|
|
192
|
+
let totalBytes = 0;
|
|
193
|
+
async function visit(directory) {
|
|
194
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
195
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
if (directory === absoluteRoot && ignoredProtectedRootEntries.has(entry.name)) continue;
|
|
198
|
+
const absolute = resolve(directory, entry.name);
|
|
199
|
+
const relPath = relative(absoluteRoot, absolute).split(sep).join("/");
|
|
200
|
+
if (!relPath || relPath.startsWith("../") || relPath.includes("/../")) throw new Error(`workspace entry escapes root: ${relPath}`);
|
|
201
|
+
const stats = await lstat(absolute);
|
|
202
|
+
if (stats.isSymbolicLink()) throw new Error(await symlinkRefusal(absolute, relPath, absoluteRoot));
|
|
203
|
+
if (stats.isDirectory()) {
|
|
204
|
+
await visit(absolute);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!stats.isFile()) throw new Error(`workspace contains a non-regular entry: ${relPath}`);
|
|
208
|
+
if (limits && scanned.length >= limits.maxFiles) throw new Error("workspace exceeds maxFiles");
|
|
209
|
+
const descriptor = await open(absolute, constants.O_RDONLY | (typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0));
|
|
210
|
+
try {
|
|
211
|
+
const openedStats = await descriptor.stat();
|
|
212
|
+
if (!openedStats.isFile()) throw new Error(`workspace contains a non-regular entry: ${relPath}`);
|
|
213
|
+
if (openedStats.nlink !== 1) throw new Error(`workspace contains a hard-linked file: ${relPath}`);
|
|
214
|
+
const mode = options.portableTree === true ? portableFileMode(openedStats.mode) : openedStats.mode & 511;
|
|
215
|
+
if (limits && openedStats.size > limits.maxFileBytes) throw new Error(`workspace file exceeds maxFileBytes: ${relPath}`);
|
|
216
|
+
const remainingBytes = limits ? limits.maxTotalFileBytes - totalBytes : void 0;
|
|
217
|
+
if (remainingBytes !== void 0 && openedStats.size > remainingBytes) throw new Error("workspace exceeds maxTotalFileBytes");
|
|
218
|
+
const read = await readWorkspaceFile(descriptor, limits ? Math.min(limits.maxFileBytes, remainingBytes ?? limits.maxFileBytes) : void 0, keepBytes, relPath);
|
|
219
|
+
totalBytes += read.byteLength;
|
|
220
|
+
scanned.push({
|
|
221
|
+
path: relPath,
|
|
222
|
+
mode,
|
|
223
|
+
sha256: read.sha256,
|
|
224
|
+
byteLength: read.byteLength,
|
|
225
|
+
...read.bytes === void 0 ? {} : { bytes: read.bytes }
|
|
226
|
+
});
|
|
227
|
+
} finally {
|
|
228
|
+
await descriptor.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await visit(absoluteRoot);
|
|
233
|
+
return scanned;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Why one symbolic link is refused, in the terms that separate a portable link from a link that
|
|
237
|
+
* reaches outside the tree.
|
|
238
|
+
*
|
|
239
|
+
* Every link is refused, in both mode policies. `AgentCandidateWorkspaceManifestMaterial` has no
|
|
240
|
+
* representation for one — `mode`, `sha256` and `byteLength` describe a regular file — so a link
|
|
241
|
+
* recorded as a file would let two different trees share one digest. The reason says which kind of
|
|
242
|
+
* link it was, so a caller can tell a link it can rewrite from one it cannot.
|
|
243
|
+
*/
|
|
244
|
+
async function symlinkRefusal(absolute, relPath, absoluteRoot) {
|
|
245
|
+
const target = await readlink(absolute);
|
|
246
|
+
if (isAbsolute(target)) return `workspace contains an absolute symlink: ${relPath} -> ${target}`;
|
|
247
|
+
if (!isWithin(absoluteRoot, resolve(dirname(absolute), target))) return `workspace contains a symlink that escapes its tree: ${relPath} -> ${target}`;
|
|
248
|
+
let physical;
|
|
249
|
+
try {
|
|
250
|
+
physical = await realpath(absolute);
|
|
251
|
+
} catch {
|
|
252
|
+
return `workspace contains an unresolved symlink: ${relPath} -> ${target}`;
|
|
253
|
+
}
|
|
254
|
+
if (!isWithin(absoluteRoot, physical)) return `workspace contains a symlink that resolves outside its tree: ${relPath} -> ${target}`;
|
|
255
|
+
return `workspace contains a symlink: ${relPath} -> ${target}`;
|
|
256
|
+
}
|
|
257
|
+
function isWithin(root, path) {
|
|
258
|
+
return path === root || path.startsWith(`${root}${sep}`);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Read one open regular file into its digest, keeping the bytes only when the caller needs them.
|
|
262
|
+
*
|
|
263
|
+
* The content reaches the hash in 1 MiB chunks and is never held whole for a digest-only walk, so
|
|
264
|
+
* the size of the largest file does not decide whether a workspace can be described.
|
|
265
|
+
* `FileHandle.readFile` refuses anything above 2 GiB with `ERR_FS_FILE_TOO_LARGE`. Hashing the
|
|
266
|
+
* chunks in order equals hashing the whole buffer, so a manifest a buffered read produced is
|
|
267
|
+
* reproduced byte for byte.
|
|
268
|
+
*/
|
|
269
|
+
async function readWorkspaceFile(descriptor, maxBytes, keepBytes, path) {
|
|
270
|
+
const hash = createHash("sha256");
|
|
271
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
272
|
+
const chunks = [];
|
|
273
|
+
let total = 0;
|
|
274
|
+
while (true) {
|
|
275
|
+
const { bytesRead } = await descriptor.read(buffer, 0, buffer.byteLength, null);
|
|
276
|
+
if (bytesRead === 0) break;
|
|
277
|
+
total += bytesRead;
|
|
278
|
+
if (maxBytes !== void 0 && total > maxBytes) throw new Error(`workspace file exceeds its capture limit: ${path}`);
|
|
279
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
280
|
+
hash.update(chunk);
|
|
281
|
+
if (keepBytes) chunks.push(Buffer.from(chunk));
|
|
282
|
+
}
|
|
283
|
+
const sha256 = `sha256:${hash.digest("hex")}`;
|
|
284
|
+
return keepBytes ? {
|
|
285
|
+
sha256,
|
|
286
|
+
byteLength: total,
|
|
287
|
+
bytes: Buffer.concat(chunks, total)
|
|
288
|
+
} : {
|
|
289
|
+
sha256,
|
|
290
|
+
byteLength: total
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/candidate-execution/exact-object.ts
|
|
295
|
+
/** Reject unknown fields while requiring every declared non-optional field. */
|
|
296
|
+
function assertExactObjectKeys(value, required, label, optional = []) {
|
|
297
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
298
|
+
const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
|
|
299
|
+
if (allowed.size !== required.length + optional.length) throw new Error(`${label} exact-key contract contains duplicate fields`);
|
|
300
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${label} contains unknown field ${key}`);
|
|
301
|
+
for (const key of required) if (!(key in value)) throw new Error(`${label} is missing field ${key}`);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Reject a string carrying a lone surrogate. A well-formed UTF-16 string round-trips through UTF-8
|
|
305
|
+
* unchanged; a lone surrogate does not, so a value that passes here is safe to persist and re-read
|
|
306
|
+
* as the same bytes.
|
|
307
|
+
*/
|
|
308
|
+
function isWellFormedUnicode(value) {
|
|
309
|
+
for (let index = 0; index < value.length; index++) {
|
|
310
|
+
const code = value.charCodeAt(index);
|
|
311
|
+
if (code >= 55296 && code <= 56319) {
|
|
312
|
+
const next = value.charCodeAt(index + 1);
|
|
313
|
+
if (!(next >= 56320 && next <= 57343)) return false;
|
|
314
|
+
index++;
|
|
315
|
+
} else if (code >= 56320 && code <= 57343) return false;
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/candidate-execution/git-materialize.ts
|
|
321
|
+
async function verifyCandidateCode(code, repositories, patchBytes) {
|
|
322
|
+
if (code.kind === "disabled") return void 0;
|
|
323
|
+
const repositoryRoot = await verifiedRepositoryRoot(code.repository, repositories);
|
|
324
|
+
await assertCommitAndBaseTree(repositoryRoot, code.baseCommit, code.baseTree);
|
|
325
|
+
if (code.kind === "no-op") {
|
|
326
|
+
await assertSafeTree(repositoryRoot, code.baseTree);
|
|
327
|
+
return code.baseTree;
|
|
328
|
+
}
|
|
329
|
+
if (!patchBytes) throw new Error("git-patch candidate is missing verified patch bytes");
|
|
330
|
+
const temporary = await mkdtemp(join(tmpdir(), "agent-candidate-git-"));
|
|
331
|
+
try {
|
|
332
|
+
const indexFile = join(temporary, "index");
|
|
333
|
+
await runCandidateGit(repositoryRoot, ["read-tree", code.baseTree], void 0, { GIT_INDEX_FILE: indexFile });
|
|
334
|
+
await runCandidateGit(repositoryRoot, [
|
|
335
|
+
"apply",
|
|
336
|
+
"--cached",
|
|
337
|
+
"--binary",
|
|
338
|
+
"--whitespace=nowarn",
|
|
339
|
+
"-"
|
|
340
|
+
], patchBytes, { GIT_INDEX_FILE: indexFile });
|
|
341
|
+
const candidateTree = (await runCandidateGit(repositoryRoot, ["write-tree"], void 0, { GIT_INDEX_FILE: indexFile })).stdout.toString("utf8").trim();
|
|
342
|
+
if (candidateTree !== code.candidateTree) throw new Error(`git patch materialized tree ${candidateTree} does not match ${code.candidateTree}`);
|
|
343
|
+
await assertSafeTree(repositoryRoot, candidateTree);
|
|
344
|
+
return candidateTree;
|
|
345
|
+
} finally {
|
|
346
|
+
await rm(temporary, {
|
|
347
|
+
recursive: true,
|
|
348
|
+
force: true
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function readCandidateGitHubResource(resource, repositories) {
|
|
353
|
+
const repositoryRoot = await verifiedRepositoryRoot(resource.repository, repositories);
|
|
354
|
+
if ((await runCandidateGit(repositoryRoot, [
|
|
355
|
+
"cat-file",
|
|
356
|
+
"-t",
|
|
357
|
+
resource.commit
|
|
358
|
+
])).stdout.toString("utf8").trim() !== "commit") throw new Error(`GitHub resource commit is not a commit object: ${resource.commit}`);
|
|
359
|
+
const listing = (await runCandidateGit(repositoryRoot, [
|
|
360
|
+
"ls-tree",
|
|
361
|
+
"-z",
|
|
362
|
+
resource.commit,
|
|
363
|
+
"--",
|
|
364
|
+
resource.path
|
|
365
|
+
])).stdout;
|
|
366
|
+
const entries = parseTreeEntries(listing);
|
|
367
|
+
if (entries.length !== 1 || entries[0]?.path !== resource.path) throw new Error(`GitHub resource path is not one exact file: ${resource.path}`);
|
|
368
|
+
const entry = entries[0];
|
|
369
|
+
if (entry?.type !== "blob" || entry.mode !== "100644" && entry.mode !== "100755") throw new Error(`GitHub resource path is not a regular Git blob: ${resource.path}`);
|
|
370
|
+
const bytes = (await runCandidateGit(repositoryRoot, [
|
|
371
|
+
"cat-file",
|
|
372
|
+
"blob",
|
|
373
|
+
entry.object
|
|
374
|
+
])).stdout;
|
|
375
|
+
verifyBytes(bytes, resource.sha256, resource.byteLength, `GitHub resource ${resource.path}`);
|
|
376
|
+
return Uint8Array.from(bytes);
|
|
377
|
+
}
|
|
378
|
+
async function verifyTaskCheckout(taskRoot, expected) {
|
|
379
|
+
const root = resolve(taskRoot);
|
|
380
|
+
const head = (await runCandidateGit(root, ["rev-parse", "HEAD"])).stdout.toString("utf8").trim();
|
|
381
|
+
if (head !== expected.baseCommit) throw new Error(`task checkout HEAD ${head} does not match ${expected.baseCommit}`);
|
|
382
|
+
const tree = (await runCandidateGit(root, ["rev-parse", "HEAD^{tree}"])).stdout.toString("utf8").trim();
|
|
383
|
+
if (tree !== expected.baseTree) throw new Error(`task checkout base tree ${tree} does not match ${expected.baseTree}`);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Apply an evaluator-captured binary diff in a detached object database and
|
|
387
|
+
* prove its result tree exactly matches the captured after-state manifest.
|
|
388
|
+
*/
|
|
389
|
+
async function verifyTaskOutcomePatch(input) {
|
|
390
|
+
const repositoryRoot = resolve(input.repositoryRoot);
|
|
391
|
+
await verifyTaskCheckout(repositoryRoot, input);
|
|
392
|
+
const gitDir = resolve((await runCandidateGit(repositoryRoot, ["rev-parse", "--absolute-git-dir"])).stdout.toString("utf8").trim());
|
|
393
|
+
if (await realpath(gitDir) !== gitDir || gitDir.includes(":")) throw new Error("task Git object store has an unsupported path");
|
|
394
|
+
await assertNoGitIndirection(repositoryRoot, gitDir, "task repository");
|
|
395
|
+
const temporary = await mkdtemp(join(tmpdir(), "agent-candidate-task-outcome-"));
|
|
396
|
+
try {
|
|
397
|
+
const objectDirectory = join(temporary, "objects");
|
|
398
|
+
const indexFile = join(temporary, "index");
|
|
399
|
+
await mkdir(objectDirectory);
|
|
400
|
+
const gitEnvironment = {
|
|
401
|
+
GIT_INDEX_FILE: indexFile,
|
|
402
|
+
GIT_OBJECT_DIRECTORY: objectDirectory,
|
|
403
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: join(gitDir, "objects")
|
|
404
|
+
};
|
|
405
|
+
await runCandidateGit(repositoryRoot, ["read-tree", input.baseTree], void 0, gitEnvironment);
|
|
406
|
+
if (input.patch.byteLength > 0) await runCandidateGit(repositoryRoot, [
|
|
407
|
+
"apply",
|
|
408
|
+
"--cached",
|
|
409
|
+
"--binary",
|
|
410
|
+
"--whitespace=nowarn",
|
|
411
|
+
"-"
|
|
412
|
+
], input.patch, gitEnvironment);
|
|
413
|
+
const resultTree = (await runCandidateGit(repositoryRoot, ["write-tree"], void 0, gitEnvironment)).stdout.toString("utf8").trim();
|
|
414
|
+
if (resultTree !== input.resultTree) throw new Error(`task outcome patch materialized tree ${resultTree} does not match ${input.resultTree}`);
|
|
415
|
+
await assertSafeTree(repositoryRoot, resultTree, gitEnvironment);
|
|
416
|
+
const observed = await workspaceManifestFromGitTree(repositoryRoot, resultTree, gitEnvironment);
|
|
417
|
+
if (!Buffer.from(canonicalCandidateBytes$1(observed)).equals(Buffer.from(canonicalCandidateBytes$1(input.afterState)))) throw new Error("task outcome after-state does not match the materialized result tree");
|
|
418
|
+
const resultCommit = (await runCandidateGit(repositoryRoot, [
|
|
419
|
+
"commit-tree",
|
|
420
|
+
resultTree,
|
|
421
|
+
"-p",
|
|
422
|
+
input.baseCommit
|
|
423
|
+
], Buffer.from("candidate task outcome\n", "utf8"), {
|
|
424
|
+
...gitEnvironment,
|
|
425
|
+
GIT_AUTHOR_NAME: "Tangle Evaluator",
|
|
426
|
+
GIT_AUTHOR_EMAIL: "evaluator@tangle.tools",
|
|
427
|
+
GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
|
|
428
|
+
GIT_COMMITTER_NAME: "Tangle Evaluator",
|
|
429
|
+
GIT_COMMITTER_EMAIL: "evaluator@tangle.tools",
|
|
430
|
+
GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z"
|
|
431
|
+
})).stdout.toString("utf8").trim();
|
|
432
|
+
if ((await runCandidateGit(repositoryRoot, ["rev-parse", `${resultCommit}^{tree}`], void 0, gitEnvironment)).stdout.toString("utf8").trim() !== resultTree) throw new Error("task outcome evaluator commit does not bind the verified result tree");
|
|
433
|
+
return {
|
|
434
|
+
resultTree,
|
|
435
|
+
resultCommit
|
|
436
|
+
};
|
|
437
|
+
} finally {
|
|
438
|
+
await rm(temporary, {
|
|
439
|
+
recursive: true,
|
|
440
|
+
force: true
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async function verifiedRepositoryRoot(repository, repositories) {
|
|
445
|
+
const repositoryRoot = resolve(await repositories.resolve(repository));
|
|
446
|
+
if (!(await stat(repositoryRoot)).isDirectory()) throw new Error("candidate repository path is not a directory");
|
|
447
|
+
const origin = (await runCandidateGit(repositoryRoot, [
|
|
448
|
+
"remote",
|
|
449
|
+
"get-url",
|
|
450
|
+
"origin"
|
|
451
|
+
])).stdout.toString("utf8").trim();
|
|
452
|
+
const actual = parseGitHubRemote(origin);
|
|
453
|
+
if (!actual || actual.owner !== repository.owner || actual.repo !== repository.repo) throw new Error(`local repository origin ${origin || "<missing>"} does not match github.com/${repository.owner}/${repository.repo}`);
|
|
454
|
+
await assertNoGitIndirection(repositoryRoot, resolve((await runCandidateGit(repositoryRoot, ["rev-parse", "--absolute-git-dir"])).stdout.toString("utf8").trim()), "candidate repository");
|
|
455
|
+
return repositoryRoot;
|
|
456
|
+
}
|
|
457
|
+
async function assertNoGitIndirection(repositoryRoot, gitDir, label) {
|
|
458
|
+
if ((await runCandidateGit(repositoryRoot, [
|
|
459
|
+
"for-each-ref",
|
|
460
|
+
"--format=%(refname)",
|
|
461
|
+
"refs/replace"
|
|
462
|
+
])).stdout.toString("utf8").trim()) throw new Error(`${label} contains Git replace refs`);
|
|
463
|
+
const commonDir = await realpath(resolve(repositoryRoot, (await runCandidateGit(repositoryRoot, ["rev-parse", "--git-common-dir"])).stdout.toString("utf8").trim()));
|
|
464
|
+
if (commonDir.includes(":")) throw new Error(`${label} Git common directory has an unsupported path`);
|
|
465
|
+
for (const objectRoot of /* @__PURE__ */ new Set([gitDir, commonDir])) for (const name of ["alternates", "http-alternates"]) {
|
|
466
|
+
const path = join(objectRoot, "objects", "info", name);
|
|
467
|
+
try {
|
|
468
|
+
if ((await readFile(path, "utf8")).trim()) throw new Error(`${label} uses forbidden Git ${name}`);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (!isNoEntry(error)) throw error;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async function assertCommitAndBaseTree(repositoryRoot, commit, expectedTree) {
|
|
475
|
+
if ((await runCandidateGit(repositoryRoot, [
|
|
476
|
+
"cat-file",
|
|
477
|
+
"-t",
|
|
478
|
+
commit
|
|
479
|
+
])).stdout.toString("utf8").trim() !== "commit") throw new Error(`candidate base object is not a commit: ${commit}`);
|
|
480
|
+
const actualTree = (await runCandidateGit(repositoryRoot, ["rev-parse", `${commit}^{tree}`])).stdout.toString("utf8").trim();
|
|
481
|
+
if (actualTree !== expectedTree) throw new Error(`candidate base commit tree ${actualTree} does not match ${expectedTree}`);
|
|
482
|
+
}
|
|
483
|
+
async function assertSafeTree(repositoryRoot, tree, environment = {}) {
|
|
484
|
+
const listing = (await runCandidateGit(repositoryRoot, [
|
|
485
|
+
"ls-tree",
|
|
486
|
+
"-rz",
|
|
487
|
+
"--full-tree",
|
|
488
|
+
tree
|
|
489
|
+
], void 0, environment)).stdout;
|
|
490
|
+
const entries = parseTreeEntries(listing);
|
|
491
|
+
if (entries.length === 0) throw new Error("candidate Git tree cannot be empty");
|
|
492
|
+
for (const entry of entries) {
|
|
493
|
+
if (entry.type !== "blob" || entry.mode !== "100644" && entry.mode !== "100755") throw new Error(`candidate Git tree contains a symlink, submodule, or non-blob: ${entry.path}`);
|
|
494
|
+
assertSafeGitPath(entry.path);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function workspaceManifestFromGitTree(repositoryRoot, tree, environment) {
|
|
498
|
+
return candidateWorkspaceManifest(await readCandidateGitTreeFiles(repositoryRoot, tree, environment));
|
|
499
|
+
}
|
|
500
|
+
async function readCandidateGitTreeFiles(repositoryRoot, tree, environment = {}, limits) {
|
|
501
|
+
const listing = (await runCandidateGit(repositoryRoot, [
|
|
502
|
+
"ls-tree",
|
|
503
|
+
"-rzl",
|
|
504
|
+
"--full-tree",
|
|
505
|
+
tree
|
|
506
|
+
], void 0, environment)).stdout;
|
|
507
|
+
const files = [];
|
|
508
|
+
const entries = parseTreeEntries(listing);
|
|
509
|
+
if (limits && entries.length > limits.maxFiles) throw new Error("candidate Git tree exceeds maxFiles");
|
|
510
|
+
let totalBytes = 0;
|
|
511
|
+
for (const entry of entries) {
|
|
512
|
+
if (entry.type !== "blob" || entry.mode !== "100644" && entry.mode !== "100755") throw new Error(`candidate Git tree contains a non-regular file: ${entry.path}`);
|
|
513
|
+
assertSafeGitPath(entry.path);
|
|
514
|
+
if (entry.size === void 0) throw new Error(`candidate Git tree is missing blob size: ${entry.path}`);
|
|
515
|
+
if (limits && entry.size > limits.maxFileBytes) throw new Error(`candidate Git tree file exceeds maxFileBytes: ${entry.path}`);
|
|
516
|
+
totalBytes += entry.size;
|
|
517
|
+
if (limits && totalBytes > limits.maxTotalFileBytes) throw new Error("candidate Git tree exceeds maxTotalFileBytes");
|
|
518
|
+
const bytes = (await runCandidateGit(repositoryRoot, [
|
|
519
|
+
"cat-file",
|
|
520
|
+
"blob",
|
|
521
|
+
entry.object
|
|
522
|
+
], void 0, environment)).stdout;
|
|
523
|
+
if (bytes.byteLength !== entry.size) throw new Error(`candidate Git blob size changed: ${entry.path}`);
|
|
524
|
+
files.push({
|
|
525
|
+
path: entry.path,
|
|
526
|
+
mode: entry.mode === "100755" ? 493 : 420,
|
|
527
|
+
bytes: Uint8Array.from(bytes)
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
531
|
+
}
|
|
532
|
+
function parseTreeEntries(bytes) {
|
|
533
|
+
const raw = Buffer.from(bytes);
|
|
534
|
+
const decoded = raw.toString("utf8");
|
|
535
|
+
if (!Buffer.from(decoded, "utf8").equals(raw)) throw new Error("candidate Git tree contains a non-UTF-8 path");
|
|
536
|
+
return decoded.split("\0").filter(Boolean).map((row) => {
|
|
537
|
+
const tab = row.indexOf(" ");
|
|
538
|
+
const header = row.slice(0, tab).split(" ").filter(Boolean);
|
|
539
|
+
const path = row.slice(tab + 1);
|
|
540
|
+
const [mode, type, object, sizeText] = header;
|
|
541
|
+
if (tab < 1 || !mode || !type || !object || !path) throw new Error("malformed Git tree entry");
|
|
542
|
+
const size = sizeText && /^\d+$/.test(sizeText) ? Number(sizeText) : void 0;
|
|
543
|
+
if (size !== void 0 && !Number.isSafeInteger(size)) throw new Error("candidate Git tree entry size exceeds the safe integer range");
|
|
544
|
+
return {
|
|
545
|
+
mode,
|
|
546
|
+
type,
|
|
547
|
+
object,
|
|
548
|
+
...size === void 0 ? {} : { size },
|
|
549
|
+
path
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
function assertSafeGitPath(path) {
|
|
554
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.includes("\0") || hasControlCharacter$1(path) || path.split("/").some((part) => !part || part === "." || part === "..") || path.split("/")[0]?.toLowerCase() === ".git") throw new Error(`candidate Git tree contains an unsafe path: ${path}`);
|
|
555
|
+
}
|
|
556
|
+
function hasControlCharacter$1(value) {
|
|
557
|
+
for (let index = 0; index < value.length; index++) {
|
|
558
|
+
const code = value.charCodeAt(index);
|
|
559
|
+
if (code < 32 || code === 127) return true;
|
|
560
|
+
}
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
function parseGitHubRemote(value) {
|
|
564
|
+
const match = value.match(/^(?:https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
565
|
+
if (!match?.[1] || !match[2]) return void 0;
|
|
566
|
+
return {
|
|
567
|
+
owner: match[1],
|
|
568
|
+
repo: match[2]
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
async function runCandidateGit(repositoryRoot, args, input, extraEnv = {}) {
|
|
572
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")));
|
|
573
|
+
Object.assign(env, {
|
|
574
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
575
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
576
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
577
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
578
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
579
|
+
LC_ALL: "C",
|
|
580
|
+
...extraEnv
|
|
581
|
+
});
|
|
582
|
+
const fullArgs = [
|
|
583
|
+
"-c",
|
|
584
|
+
"core.hooksPath=/dev/null",
|
|
585
|
+
"-c",
|
|
586
|
+
"core.fsmonitor=false",
|
|
587
|
+
"-c",
|
|
588
|
+
"core.untrackedCache=false",
|
|
589
|
+
"-c",
|
|
590
|
+
"protocol.file.allow=never",
|
|
591
|
+
"-C",
|
|
592
|
+
repositoryRoot,
|
|
593
|
+
...args
|
|
594
|
+
];
|
|
595
|
+
return await new Promise((resolveResult, reject) => {
|
|
596
|
+
const child = spawn("git", fullArgs, {
|
|
597
|
+
env,
|
|
598
|
+
stdio: [
|
|
599
|
+
"pipe",
|
|
600
|
+
"pipe",
|
|
601
|
+
"pipe"
|
|
602
|
+
]
|
|
603
|
+
});
|
|
604
|
+
const stdout = [];
|
|
605
|
+
const stderr = [];
|
|
606
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
607
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
608
|
+
child.on("error", reject);
|
|
609
|
+
child.on("close", (code, signal) => {
|
|
610
|
+
const out = Buffer.concat(stdout);
|
|
611
|
+
const err = Buffer.concat(stderr);
|
|
612
|
+
if (code !== 0) {
|
|
613
|
+
reject(/* @__PURE__ */ new Error(`git ${args[0] ?? "<command>"} failed (${signal ?? code}): ${err.toString("utf8").trim()}`));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
resolveResult({
|
|
617
|
+
stdout: out,
|
|
618
|
+
stderr: err
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
if (input) child.stdin.end(input);
|
|
622
|
+
else child.stdin.end();
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
function isNoEntry(error) {
|
|
626
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/candidate-execution/output-artifacts.ts
|
|
630
|
+
/** Persist evaluator evidence, read it back, and bind the returned locator to the exact bytes. */
|
|
631
|
+
async function persistCandidateOutputArtifact(port, input) {
|
|
632
|
+
input.signal?.throwIfAborted();
|
|
633
|
+
const bytes = Uint8Array.from(input.bytes);
|
|
634
|
+
const expectedDigest = sha256Bytes(bytes);
|
|
635
|
+
const ref = agentCandidateArtifactRefSchema.parse(await port.put({
|
|
636
|
+
executionId: input.executionId,
|
|
637
|
+
purpose: input.purpose,
|
|
638
|
+
bytes: Uint8Array.from(bytes),
|
|
639
|
+
...input.signal ? { signal: input.signal } : {}
|
|
640
|
+
}));
|
|
641
|
+
input.signal?.throwIfAborted();
|
|
642
|
+
if (ref.sha256 !== expectedDigest || ref.byteLength !== bytes.byteLength) throw new Error("candidate output locator does not identify the submitted bytes");
|
|
643
|
+
const stored = await port.read(ref);
|
|
644
|
+
input.signal?.throwIfAborted();
|
|
645
|
+
verifyBytes(stored, expectedDigest, bytes.byteLength, "persisted candidate output");
|
|
646
|
+
return immutableCandidateValue(ref);
|
|
647
|
+
}
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/candidate-execution/workspace-archive.ts
|
|
650
|
+
const archiveKind = "agent-candidate-workspace-archive";
|
|
651
|
+
const workspaceEntryPrefix = "workspace/";
|
|
652
|
+
const repositoryMetadataEntry = "metadata/repository.json";
|
|
653
|
+
const repositoryBundleEntry = "metadata/repository.bundle";
|
|
654
|
+
const fixedTarTime = /* @__PURE__ */ new Date(0);
|
|
655
|
+
const defaultLimits = Object.freeze({
|
|
656
|
+
maxArchiveBytes: 512 * 1024 * 1024,
|
|
657
|
+
maxEmbeddedArtifactBytes: 1024 * 1024,
|
|
658
|
+
maxFiles: 5e4,
|
|
659
|
+
maxFileBytes: 128 * 1024 * 1024,
|
|
660
|
+
maxTotalFileBytes: 256 * 1024 * 1024,
|
|
661
|
+
maxPathBytes: 4096,
|
|
662
|
+
maxRepositoryBundleBytes: 128 * 1024 * 1024
|
|
663
|
+
});
|
|
664
|
+
/** Capture one exact regular-file workspace for immutable candidate execution. */
|
|
665
|
+
async function captureAgentCandidateWorkspace(rootInput, options = {}) {
|
|
666
|
+
const root = resolve(rootInput);
|
|
667
|
+
const limits = workspaceLimits(options.limits);
|
|
668
|
+
const captured = options.includeRepository ? await captureRepository(root, limits) : await captureMaterializedWorkspace(root, { limits: {
|
|
669
|
+
maxFiles: limits.maxFiles,
|
|
670
|
+
maxFileBytes: limits.maxFileBytes,
|
|
671
|
+
maxTotalFileBytes: limits.maxTotalFileBytes
|
|
672
|
+
} });
|
|
673
|
+
assertWorkspaceFilesWithinLimits(captured.files, limits);
|
|
674
|
+
const repository = "repository" in captured ? captured.repository : void 0;
|
|
675
|
+
return captureWorkspaceFiles(captured.files, repository, limits, options.artifactPersistence);
|
|
676
|
+
}
|
|
677
|
+
/** Capture detached files returned by a remote executor into the standard archive. */
|
|
678
|
+
async function captureAgentCandidateWorkspaceFiles(input, options = {}) {
|
|
679
|
+
const limits = workspaceLimits(options.limits);
|
|
680
|
+
return captureWorkspaceFiles(normalizeWorkspaceFiles(input, limits), void 0, limits, options.artifactPersistence);
|
|
681
|
+
}
|
|
682
|
+
async function captureWorkspaceFiles(files, repository, limits, artifactPersistence) {
|
|
683
|
+
const material = candidateWorkspaceManifest(files);
|
|
684
|
+
const manifest = canonicalCandidateBytes$1(material);
|
|
685
|
+
const archive = await encodeWorkspaceArchive(files, repository, limits.maxArchiveBytes);
|
|
686
|
+
if (!artifactPersistence && (manifest.byteLength > limits.maxEmbeddedArtifactBytes || archive.byteLength > limits.maxEmbeddedArtifactBytes)) throw new Error("candidate workspace artifacts exceed maxEmbeddedArtifactBytes; artifactPersistence is required");
|
|
687
|
+
const manifestArtifact = await captureWorkspaceArtifact("manifest", manifest, artifactPersistence);
|
|
688
|
+
const archiveArtifact = await captureWorkspaceArtifact("archive", archive, artifactPersistence);
|
|
689
|
+
const snapshot = deepFreezeCandidate({
|
|
690
|
+
kind: "agent-candidate-workspace-snapshot",
|
|
691
|
+
digest: canonicalCandidateDigest$1(material),
|
|
692
|
+
material,
|
|
693
|
+
manifest: manifestArtifact,
|
|
694
|
+
archive: archiveArtifact
|
|
695
|
+
});
|
|
696
|
+
return Object.freeze({
|
|
697
|
+
snapshot,
|
|
698
|
+
archive
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
async function captureWorkspaceArtifact(kind, bytes, persistence) {
|
|
702
|
+
if (!persistence) return embeddedCandidateArtifact(bytes);
|
|
703
|
+
if (!persistence.executionId.trim()) throw new Error("candidate workspace artifact persistence requires an executionId");
|
|
704
|
+
return persistCandidateOutputArtifact(persistence.outputArtifacts, {
|
|
705
|
+
executionId: persistence.executionId,
|
|
706
|
+
purpose: kind === "manifest" ? "candidate-workspace-manifest" : "candidate-workspace-archive",
|
|
707
|
+
bytes,
|
|
708
|
+
...persistence.signal ? { signal: persistence.signal } : {}
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
/** Create the standard bounded materializer for candidate execution ports. */
|
|
712
|
+
function createAgentCandidateWorkspacePort(options = {}) {
|
|
713
|
+
const limits = workspaceLimits(options.limits);
|
|
714
|
+
return Object.freeze({ materialize: async ({ role, snapshot, archive, destination }) => {
|
|
715
|
+
await materializeAgentCandidateWorkspace({
|
|
716
|
+
role,
|
|
717
|
+
snapshot,
|
|
718
|
+
archive,
|
|
719
|
+
destination,
|
|
720
|
+
limits
|
|
721
|
+
});
|
|
722
|
+
} });
|
|
723
|
+
}
|
|
724
|
+
async function materializeAgentCandidateWorkspace(input) {
|
|
725
|
+
const decoded = await decodeVerifiedWorkspaceArchive(input);
|
|
726
|
+
const destination = resolve(input.destination);
|
|
727
|
+
await prepareEmptyDestination(destination);
|
|
728
|
+
const staging = await mkdtemp(`${destination}.materializing-`);
|
|
729
|
+
try {
|
|
730
|
+
await writeWorkspaceFiles(staging, decoded.files);
|
|
731
|
+
if (decoded.repository) await materializeRepository(decoded.repository, staging);
|
|
732
|
+
await verifyMaterializedWorkspace(staging, input.snapshot.material, { ignoredProtectedRootEntries: decoded.repository ? [".git"] : [] });
|
|
733
|
+
await rename(staging, destination);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
await rm(staging, {
|
|
736
|
+
recursive: true,
|
|
737
|
+
force: true
|
|
738
|
+
});
|
|
739
|
+
await rm(destination, {
|
|
740
|
+
recursive: true,
|
|
741
|
+
force: true
|
|
742
|
+
});
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/** Verify a portable archive before its source environment may be discarded. */
|
|
747
|
+
async function verifyAgentCandidateWorkspaceArchive(input) {
|
|
748
|
+
await decodeVerifiedWorkspaceArchive({
|
|
749
|
+
...input,
|
|
750
|
+
limits: workspaceLimits(input.limits)
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
async function decodeVerifiedWorkspaceArchive(input) {
|
|
754
|
+
verifyBytes(input.archive, input.snapshot.archive.sha256, input.snapshot.archive.byteLength, "candidate workspace archive");
|
|
755
|
+
const decoded = await parseWorkspaceArchive(input.archive, input.limits);
|
|
756
|
+
if (decoded.repository && input.role !== "task") throw new Error("only task workspaces may carry a Git repository");
|
|
757
|
+
assertArchiveMatchesSnapshot(decoded.files, input.snapshot);
|
|
758
|
+
return decoded;
|
|
759
|
+
}
|
|
760
|
+
async function captureRepository(requestedRoot, limits) {
|
|
761
|
+
const requestedStats = await lstat(requestedRoot);
|
|
762
|
+
if (!requestedStats.isDirectory() || requestedStats.isSymbolicLink()) throw new Error("candidate repository root must be a real directory");
|
|
763
|
+
const root = await realpath(requestedRoot);
|
|
764
|
+
const resolvedStats = await lstat(root);
|
|
765
|
+
if (!resolvedStats.isDirectory() || resolvedStats.isSymbolicLink()) throw new Error("candidate repository root must resolve to a real directory");
|
|
766
|
+
if (await realpath(resolve((await runCandidateGit(root, ["rev-parse", "--show-toplevel"])).stdout.toString("utf8").trim())) !== root) throw new Error("candidate repository capture must start at the Git worktree root");
|
|
767
|
+
const gitDir = resolve((await runCandidateGit(root, ["rev-parse", "--absolute-git-dir"])).stdout.toString("utf8").trim());
|
|
768
|
+
if (await realpath(gitDir) !== gitDir || gitDir.includes(":")) throw new Error("candidate repository Git object store has an unsupported path");
|
|
769
|
+
await assertNoGitIndirection(root, gitDir, "candidate repository");
|
|
770
|
+
const headCommit = (await runCandidateGit(root, ["rev-parse", "HEAD"])).stdout.toString("utf8").trim();
|
|
771
|
+
const headTree = (await runCandidateGit(root, ["rev-parse", "HEAD^{tree}"])).stdout.toString("utf8").trim();
|
|
772
|
+
assertGitObjectId(headCommit, "HEAD");
|
|
773
|
+
assertGitObjectId(headTree, "HEAD tree");
|
|
774
|
+
if (headCommit.length !== headTree.length) throw new Error("candidate repository HEAD and tree use different object formats");
|
|
775
|
+
const files = await readCandidateGitTreeFiles(root, headTree, {}, limits);
|
|
776
|
+
assertWorkspaceFilesWithinLimits(files, limits);
|
|
777
|
+
const reachableBytesText = (await runCandidateGit(root, [
|
|
778
|
+
"rev-list",
|
|
779
|
+
"--disk-usage",
|
|
780
|
+
"--objects",
|
|
781
|
+
"HEAD"
|
|
782
|
+
])).stdout.toString("utf8").trim();
|
|
783
|
+
const reachableBytes = Number(reachableBytesText);
|
|
784
|
+
if (!Number.isSafeInteger(reachableBytes) || reachableBytes < 0) throw new Error("candidate repository reachable size is invalid");
|
|
785
|
+
if (reachableBytes > limits.maxRepositoryBundleBytes) throw new Error("candidate repository exceeds maxRepositoryBundleBytes");
|
|
786
|
+
const temporary = await mkdtemp(join(tmpdir(), "agent-candidate-workspace-bundle-"));
|
|
787
|
+
const bundlePath = join(temporary, `${randomUUID()}.bundle`);
|
|
788
|
+
try {
|
|
789
|
+
await runCandidateGit(root, [
|
|
790
|
+
"bundle",
|
|
791
|
+
"create",
|
|
792
|
+
bundlePath,
|
|
793
|
+
"HEAD"
|
|
794
|
+
]);
|
|
795
|
+
const bundle = Uint8Array.from(await readFile(bundlePath));
|
|
796
|
+
if (bundle.byteLength > limits.maxRepositoryBundleBytes) throw new Error("candidate repository bundle exceeds maxRepositoryBundleBytes");
|
|
797
|
+
return {
|
|
798
|
+
files,
|
|
799
|
+
repository: {
|
|
800
|
+
headCommit,
|
|
801
|
+
headTree,
|
|
802
|
+
bundle: {
|
|
803
|
+
sha256: sha256Bytes(bundle),
|
|
804
|
+
byteLength: bundle.byteLength,
|
|
805
|
+
bytes: bundle
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
} finally {
|
|
810
|
+
await rm(temporary, {
|
|
811
|
+
recursive: true,
|
|
812
|
+
force: true
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function encodeWorkspaceArchive(files, repository, maxArchiveBytes) {
|
|
817
|
+
const archive = pack();
|
|
818
|
+
const output = collectStream(archive, maxArchiveBytes, "candidate workspace archive");
|
|
819
|
+
try {
|
|
820
|
+
await writeWorkspaceArchiveEntries(archive, files, repository);
|
|
821
|
+
archive.finalize();
|
|
822
|
+
return await output;
|
|
823
|
+
} catch (error) {
|
|
824
|
+
archive.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
825
|
+
await output.catch(() => void 0);
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async function verifyCanonicalWorkspaceArchive(files, repository, expected, maxArchiveBytes) {
|
|
830
|
+
const archive = pack();
|
|
831
|
+
const comparison = streamEqualsBytes(archive, expected, maxArchiveBytes, "candidate workspace archive");
|
|
832
|
+
try {
|
|
833
|
+
await writeWorkspaceArchiveEntries(archive, files, repository);
|
|
834
|
+
archive.finalize();
|
|
835
|
+
if (!await comparison) throw new Error("candidate workspace tar is not canonical");
|
|
836
|
+
} catch (error) {
|
|
837
|
+
archive.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
838
|
+
await comparison.catch(() => void 0);
|
|
839
|
+
throw error;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async function writeWorkspaceArchiveEntries(archive, files, repository) {
|
|
843
|
+
for (const file of files) await writeTarEntry(archive, `${workspaceEntryPrefix}${file.path}`, file.mode, file.bytes);
|
|
844
|
+
if (!repository) return;
|
|
845
|
+
const metadata = canonicalCandidateBytes$1({
|
|
846
|
+
kind: archiveKind,
|
|
847
|
+
headCommit: repository.headCommit,
|
|
848
|
+
headTree: repository.headTree,
|
|
849
|
+
bundle: {
|
|
850
|
+
sha256: repository.bundle.sha256,
|
|
851
|
+
byteLength: repository.bundle.byteLength
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
await writeTarEntry(archive, repositoryMetadataEntry, 384, metadata);
|
|
855
|
+
await writeTarEntry(archive, repositoryBundleEntry, 384, repository.bundle.bytes);
|
|
856
|
+
}
|
|
857
|
+
async function parseWorkspaceArchive(bytes, limits) {
|
|
858
|
+
if (bytes.byteLength > limits.maxArchiveBytes) throw new Error("candidate workspace archive exceeds maxArchiveBytes");
|
|
859
|
+
const parser = extract();
|
|
860
|
+
const files = [];
|
|
861
|
+
const observedEntryNames = /* @__PURE__ */ new Set();
|
|
862
|
+
const observedPaths = /* @__PURE__ */ new Set();
|
|
863
|
+
let totalBytes = 0;
|
|
864
|
+
let retainedEntryBytes = 0;
|
|
865
|
+
let previousPath;
|
|
866
|
+
let repositoryMetadata;
|
|
867
|
+
let repositoryBundle;
|
|
868
|
+
const decoded = (async () => {
|
|
869
|
+
for await (const entry of parser) {
|
|
870
|
+
const name = entry.header.name;
|
|
871
|
+
if (entry.header.type !== "file" || typeof name !== "string" || observedEntryNames.has(name)) throw new Error("candidate workspace tar contains an invalid entry");
|
|
872
|
+
observedEntryNames.add(name);
|
|
873
|
+
if (name.startsWith(workspaceEntryPrefix)) {
|
|
874
|
+
if (files.length >= limits.maxFiles) throw new Error("candidate workspace archive has an invalid file count");
|
|
875
|
+
const path = safeArchivePath(name.slice(10), limits.maxPathBytes);
|
|
876
|
+
if (!isWorkspaceFileMode(entry.header.mode)) throw new Error(`candidate workspace archive has an unsupported mode: ${path}`);
|
|
877
|
+
assertRetainedArchiveSize(bytes.byteLength, retainedEntryBytes, entry.header.size, limits);
|
|
878
|
+
const fileBytes = await readTarEntry(entry, limits.maxFileBytes, `workspace file ${path}`);
|
|
879
|
+
retainedEntryBytes += fileBytes.byteLength;
|
|
880
|
+
assertWorkspacePathOrder(path, previousPath, observedPaths, "candidate workspace archive paths must be unique and sorted");
|
|
881
|
+
previousPath = path;
|
|
882
|
+
if (fileBytes.byteLength > limits.maxTotalFileBytes - totalBytes) throw new Error("candidate workspace archive exceeds maxTotalFileBytes");
|
|
883
|
+
totalBytes += fileBytes.byteLength;
|
|
884
|
+
files.push({
|
|
885
|
+
path,
|
|
886
|
+
mode: entry.header.mode,
|
|
887
|
+
bytes: fileBytes
|
|
888
|
+
});
|
|
889
|
+
} else if (name === repositoryMetadataEntry) {
|
|
890
|
+
if (entry.header.mode !== 384 || repositoryMetadata) throw new Error("candidate workspace archive repository metadata is invalid");
|
|
891
|
+
assertRetainedArchiveSize(bytes.byteLength, retainedEntryBytes, entry.header.size, limits);
|
|
892
|
+
const metadataBytes = await readTarEntry(entry, limits.maxPathBytes, "candidate repository metadata");
|
|
893
|
+
retainedEntryBytes += metadataBytes.byteLength;
|
|
894
|
+
repositoryMetadata = parseRepositoryMetadata(metadataBytes, limits.maxRepositoryBundleBytes);
|
|
895
|
+
} else if (name === repositoryBundleEntry) {
|
|
896
|
+
if (entry.header.mode !== 384 || repositoryBundle) throw new Error("candidate workspace archive repository bundle is invalid");
|
|
897
|
+
assertRetainedArchiveSize(bytes.byteLength, retainedEntryBytes, entry.header.size, limits);
|
|
898
|
+
repositoryBundle = await readTarEntry(entry, limits.maxRepositoryBundleBytes, "candidate Git bundle");
|
|
899
|
+
retainedEntryBytes += repositoryBundle.byteLength;
|
|
900
|
+
} else throw new Error(`candidate workspace tar contains an unsupported entry: ${name}`);
|
|
901
|
+
}
|
|
902
|
+
if (Boolean(repositoryMetadata) !== Boolean(repositoryBundle)) throw new Error("candidate workspace archive repository evidence is incomplete");
|
|
903
|
+
const repository = repositoryMetadata && repositoryBundle ? repositoryFromTar(repositoryMetadata, repositoryBundle) : void 0;
|
|
904
|
+
return {
|
|
905
|
+
files,
|
|
906
|
+
...repository ? { repository } : {}
|
|
907
|
+
};
|
|
908
|
+
})();
|
|
909
|
+
parser.end(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
910
|
+
const result = await decoded;
|
|
911
|
+
await verifyCanonicalWorkspaceArchive(result.files, result.repository, bytes, limits.maxArchiveBytes);
|
|
912
|
+
return result;
|
|
913
|
+
}
|
|
914
|
+
function assertRetainedArchiveSize(archiveBytes, retainedEntryBytes, entryBytes, limits) {
|
|
915
|
+
if (!Number.isSafeInteger(entryBytes) || entryBytes === void 0 || entryBytes < 0 || entryBytes > limits.maxArchiveBytes - archiveBytes - retainedEntryBytes) throw new Error("candidate workspace archive exceeds maxArchiveBytes while decoding");
|
|
916
|
+
}
|
|
917
|
+
function parseRepositoryMetadata(bytes, maxBundleBytes) {
|
|
918
|
+
let value;
|
|
919
|
+
try {
|
|
920
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
921
|
+
} catch (error) {
|
|
922
|
+
throw new Error("candidate workspace archive repository metadata is invalid", { cause: error });
|
|
923
|
+
}
|
|
924
|
+
if (!isRecord(value) || Object.keys(value).sort().join(",") !== "bundle,headCommit,headTree,kind" || value.kind !== archiveKind || typeof value.headCommit !== "string" || typeof value.headTree !== "string" || !isRecord(value.bundle) || Object.keys(value.bundle).sort().join(",") !== "byteLength,sha256" || typeof value.bundle.sha256 !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value.bundle.sha256) || !Number.isSafeInteger(value.bundle.byteLength) || value.bundle.byteLength <= 0 || value.bundle.byteLength > maxBundleBytes) throw new Error("candidate workspace archive repository is invalid");
|
|
925
|
+
if (!Buffer.from(canonicalCandidateBytes$1(value)).equals(Buffer.from(bytes))) throw new Error("candidate workspace archive repository metadata is not canonical");
|
|
926
|
+
assertGitObjectId(value.headCommit, "repository HEAD");
|
|
927
|
+
assertGitObjectId(value.headTree, "repository HEAD tree");
|
|
928
|
+
if (value.headCommit.length !== value.headTree.length) throw new Error("candidate repository HEAD and tree use different object formats");
|
|
929
|
+
return {
|
|
930
|
+
kind: archiveKind,
|
|
931
|
+
headCommit: value.headCommit,
|
|
932
|
+
headTree: value.headTree,
|
|
933
|
+
bundle: {
|
|
934
|
+
sha256: value.bundle.sha256,
|
|
935
|
+
byteLength: value.bundle.byteLength
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
function repositoryFromTar(metadata, bundle) {
|
|
940
|
+
verifyBytes(bundle, metadata.bundle.sha256, metadata.bundle.byteLength, "candidate Git bundle");
|
|
941
|
+
return {
|
|
942
|
+
headCommit: metadata.headCommit,
|
|
943
|
+
headTree: metadata.headTree,
|
|
944
|
+
bundle: {
|
|
945
|
+
...metadata.bundle,
|
|
946
|
+
bytes: bundle
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
async function writeTarEntry(archive, name, mode, bytes) {
|
|
951
|
+
await new Promise((resolveEntry, rejectEntry) => {
|
|
952
|
+
archive.entry({
|
|
953
|
+
name,
|
|
954
|
+
mode,
|
|
955
|
+
uid: 0,
|
|
956
|
+
gid: 0,
|
|
957
|
+
size: bytes.byteLength,
|
|
958
|
+
mtime: fixedTarTime,
|
|
959
|
+
type: "file",
|
|
960
|
+
uname: "",
|
|
961
|
+
gname: ""
|
|
962
|
+
}, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength), (error) => error ? rejectEntry(error) : resolveEntry());
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
async function readTarEntry(entry, maxBytes, label) {
|
|
966
|
+
const size = entry.header.size;
|
|
967
|
+
if (!Number.isSafeInteger(size) || size === void 0 || size < 0 || size > maxBytes) throw new Error(`${label} has an invalid tar size`);
|
|
968
|
+
const bytes = await collectStream(entry, maxBytes, label);
|
|
969
|
+
if (bytes.byteLength !== size) throw new Error(`${label} differs from its tar size`);
|
|
970
|
+
return bytes;
|
|
971
|
+
}
|
|
972
|
+
async function collectStream(stream, maxBytes, label) {
|
|
973
|
+
const chunks = [];
|
|
974
|
+
let totalBytes = 0;
|
|
975
|
+
for await (const chunk of stream) {
|
|
976
|
+
if (!(chunk instanceof Uint8Array)) throw new Error(`${label} emitted a non-byte chunk`);
|
|
977
|
+
if (chunk.byteLength > maxBytes - totalBytes) {
|
|
978
|
+
const error = /* @__PURE__ */ new Error(`${label} exceeds its size limit`);
|
|
979
|
+
if ("destroy" in stream && typeof stream.destroy === "function") stream.destroy(error);
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
983
|
+
chunks.push(bytes);
|
|
984
|
+
totalBytes += bytes.byteLength;
|
|
985
|
+
}
|
|
986
|
+
return Buffer.concat(chunks, totalBytes);
|
|
987
|
+
}
|
|
988
|
+
async function streamEqualsBytes(stream, expected, maxBytes, label) {
|
|
989
|
+
const expectedBuffer = Buffer.from(expected.buffer, expected.byteOffset, expected.byteLength);
|
|
990
|
+
let equal = true;
|
|
991
|
+
let offset = 0;
|
|
992
|
+
for await (const chunk of stream) {
|
|
993
|
+
if (!(chunk instanceof Uint8Array)) throw new Error(`${label} emitted a non-byte chunk`);
|
|
994
|
+
if (chunk.byteLength > maxBytes - offset) {
|
|
995
|
+
const error = /* @__PURE__ */ new Error(`${label} exceeds its size limit`);
|
|
996
|
+
if ("destroy" in stream && typeof stream.destroy === "function") stream.destroy(error);
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
999
|
+
const observed = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
1000
|
+
if (offset + observed.byteLength > expectedBuffer.byteLength || !observed.equals(expectedBuffer.subarray(offset, offset + observed.byteLength))) equal = false;
|
|
1001
|
+
offset += observed.byteLength;
|
|
1002
|
+
}
|
|
1003
|
+
return equal && offset === expectedBuffer.byteLength;
|
|
1004
|
+
}
|
|
1005
|
+
async function prepareEmptyDestination(destination) {
|
|
1006
|
+
await mkdir(destination, {
|
|
1007
|
+
recursive: true,
|
|
1008
|
+
mode: 448
|
|
1009
|
+
});
|
|
1010
|
+
const stats = await lstat(destination);
|
|
1011
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error("candidate workspace destination must be a real directory");
|
|
1012
|
+
const resolvedStats = await lstat(await realpath(destination));
|
|
1013
|
+
if (!resolvedStats.isDirectory() || resolvedStats.isSymbolicLink()) throw new Error("candidate workspace destination must resolve to a real directory");
|
|
1014
|
+
if ((await readdir(destination)).length > 0) throw new Error("candidate workspace destination must be empty");
|
|
1015
|
+
}
|
|
1016
|
+
async function writeWorkspaceFiles(destination, files) {
|
|
1017
|
+
for (const file of files) {
|
|
1018
|
+
const path = workspacePath(destination, file.path);
|
|
1019
|
+
await mkdir(dirname(path), {
|
|
1020
|
+
recursive: true,
|
|
1021
|
+
mode: 448
|
|
1022
|
+
});
|
|
1023
|
+
const descriptor = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0), 384);
|
|
1024
|
+
try {
|
|
1025
|
+
await descriptor.writeFile(file.bytes);
|
|
1026
|
+
const stats = await descriptor.stat();
|
|
1027
|
+
if (!stats.isFile() || stats.nlink !== 1) throw new Error(`candidate workspace file identity changed while writing: ${file.path}`);
|
|
1028
|
+
await descriptor.chmod(file.mode);
|
|
1029
|
+
} finally {
|
|
1030
|
+
await descriptor.close();
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
async function materializeRepository(repository, destination) {
|
|
1035
|
+
const temporary = await mkdtemp(join(tmpdir(), "agent-candidate-workspace-bundle-"));
|
|
1036
|
+
const bundlePath = join(temporary, `${randomUUID()}.bundle`);
|
|
1037
|
+
try {
|
|
1038
|
+
await writeFile(bundlePath, repository.bundle.bytes, {
|
|
1039
|
+
flag: "wx",
|
|
1040
|
+
mode: 384
|
|
1041
|
+
});
|
|
1042
|
+
await runCandidateGit(destination, [
|
|
1043
|
+
"init",
|
|
1044
|
+
"--quiet",
|
|
1045
|
+
"--initial-branch=candidate",
|
|
1046
|
+
`--object-format=${repository.headCommit.length === 64 ? "sha256" : "sha1"}`
|
|
1047
|
+
]);
|
|
1048
|
+
await runCandidateGit(destination, [
|
|
1049
|
+
"bundle",
|
|
1050
|
+
"verify",
|
|
1051
|
+
bundlePath
|
|
1052
|
+
]);
|
|
1053
|
+
await runCandidateGit(destination, [
|
|
1054
|
+
"bundle",
|
|
1055
|
+
"unbundle",
|
|
1056
|
+
bundlePath
|
|
1057
|
+
]);
|
|
1058
|
+
if ((await runCandidateGit(destination, [
|
|
1059
|
+
"cat-file",
|
|
1060
|
+
"-t",
|
|
1061
|
+
repository.headCommit
|
|
1062
|
+
])).stdout.toString("utf8").trim() !== "commit") throw new Error("candidate repository HEAD is not a commit");
|
|
1063
|
+
if ((await runCandidateGit(destination, ["rev-parse", `${repository.headCommit}^{tree}`])).stdout.toString("utf8").trim() !== repository.headTree) throw new Error("candidate repository bundle HEAD tree changed");
|
|
1064
|
+
await runCandidateGit(destination, [
|
|
1065
|
+
"update-ref",
|
|
1066
|
+
"--no-deref",
|
|
1067
|
+
"HEAD",
|
|
1068
|
+
repository.headCommit
|
|
1069
|
+
]);
|
|
1070
|
+
await runCandidateGit(destination, ["read-tree", repository.headTree]);
|
|
1071
|
+
if ((await runCandidateGit(destination, [
|
|
1072
|
+
"status",
|
|
1073
|
+
"--porcelain=v1",
|
|
1074
|
+
"--untracked-files=all"
|
|
1075
|
+
])).stdout.toString("utf8").trim()) throw new Error("candidate repository files do not exactly match HEAD");
|
|
1076
|
+
} finally {
|
|
1077
|
+
await rm(temporary, {
|
|
1078
|
+
recursive: true,
|
|
1079
|
+
force: true
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function assertArchiveMatchesSnapshot(files, snapshot) {
|
|
1084
|
+
const material = candidateWorkspaceManifest(files);
|
|
1085
|
+
const materialBytes = canonicalCandidateBytes$1(material);
|
|
1086
|
+
verifyBytes(materialBytes, snapshot.manifest.sha256, snapshot.manifest.byteLength, "candidate workspace manifest");
|
|
1087
|
+
if (canonicalCandidateDigest$1(material) !== snapshot.digest || !Buffer.from(materialBytes).equals(Buffer.from(canonicalCandidateBytes$1(snapshot.material)))) throw new Error("candidate workspace archive does not match its snapshot manifest");
|
|
1088
|
+
}
|
|
1089
|
+
function assertWorkspaceFilesWithinLimits(files, limits) {
|
|
1090
|
+
if (files.length > limits.maxFiles) throw new Error("candidate workspace exceeds maxFiles");
|
|
1091
|
+
let totalBytes = 0;
|
|
1092
|
+
let previousPath;
|
|
1093
|
+
const observedPaths = /* @__PURE__ */ new Set();
|
|
1094
|
+
for (const file of files) {
|
|
1095
|
+
const path = safeArchivePath(file.path, limits.maxPathBytes);
|
|
1096
|
+
assertWorkspacePathOrder(path, previousPath, observedPaths, "candidate workspace paths must be unique and sorted");
|
|
1097
|
+
previousPath = path;
|
|
1098
|
+
if (!isWorkspaceFileMode(file.mode)) throw new Error(`candidate workspace file has unsupported mode: ${path}`);
|
|
1099
|
+
if (file.bytes.byteLength > limits.maxFileBytes) throw new Error(`candidate workspace file exceeds maxFileBytes: ${path}`);
|
|
1100
|
+
totalBytes += file.bytes.byteLength;
|
|
1101
|
+
if (totalBytes > limits.maxTotalFileBytes) throw new Error("candidate workspace exceeds maxTotalFileBytes");
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function normalizeWorkspaceFiles(input, limits) {
|
|
1105
|
+
if (!Array.isArray(input)) throw new Error("candidate workspace files must be an array");
|
|
1106
|
+
const fileCount = input.length;
|
|
1107
|
+
if (fileCount > limits.maxFiles) throw new Error("candidate workspace exceeds maxFiles");
|
|
1108
|
+
let totalBytes = 0;
|
|
1109
|
+
const files = [];
|
|
1110
|
+
for (let index = 0; index < fileCount; index++) {
|
|
1111
|
+
const inputFile = input[index];
|
|
1112
|
+
if (!isRecord(inputFile) || Object.keys(inputFile).sort().join(",") !== "bytes,mode,path") throw new Error(`candidate workspace file ${index} is invalid`);
|
|
1113
|
+
const descriptors = Object.getOwnPropertyDescriptors(inputFile);
|
|
1114
|
+
if (!("value" in (descriptors.path ?? {})) || !("value" in (descriptors.mode ?? {})) || !("value" in (descriptors.bytes ?? {}))) throw new Error(`candidate workspace file ${index} must use fixed values`);
|
|
1115
|
+
const path = safeArchivePath(descriptors.path?.value, limits.maxPathBytes);
|
|
1116
|
+
const mode = descriptors.mode?.value;
|
|
1117
|
+
const inputBytes = descriptors.bytes?.value;
|
|
1118
|
+
if (!isWorkspaceFileMode(mode)) throw new Error(`candidate workspace file has unsupported mode: ${path}`);
|
|
1119
|
+
const view = inspectWorkspaceBytes(inputBytes, path);
|
|
1120
|
+
if (view.byteLength > limits.maxFileBytes) throw new Error(`candidate workspace file exceeds maxFileBytes: ${path}`);
|
|
1121
|
+
if (view.byteLength > limits.maxTotalFileBytes - totalBytes) throw new Error("candidate workspace exceeds maxTotalFileBytes");
|
|
1122
|
+
if (view.byteLength > Math.max(0, limits.maxArchiveBytes - 1024)) throw new Error("candidate workspace archive exceeds maxArchiveBytes");
|
|
1123
|
+
totalBytes += view.byteLength;
|
|
1124
|
+
files.push({
|
|
1125
|
+
path,
|
|
1126
|
+
mode,
|
|
1127
|
+
bytes: detachWorkspaceBytes(view)
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
1131
|
+
assertWorkspaceFilesWithinLimits(files, limits);
|
|
1132
|
+
return files;
|
|
1133
|
+
}
|
|
1134
|
+
function isWorkspaceFileMode(value) {
|
|
1135
|
+
return Number.isSafeInteger(value) && value >= 0 && value <= 511;
|
|
1136
|
+
}
|
|
1137
|
+
function workspaceLimits(overrides) {
|
|
1138
|
+
const limits = {
|
|
1139
|
+
...defaultLimits,
|
|
1140
|
+
...overrides
|
|
1141
|
+
};
|
|
1142
|
+
for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`candidate workspace ${name} must be a positive safe integer`);
|
|
1143
|
+
return Object.freeze(limits);
|
|
1144
|
+
}
|
|
1145
|
+
function safeArchivePath(value, maxPathBytes) {
|
|
1146
|
+
if (typeof value !== "string" || !value || !isWellFormedUnicode(value) || Buffer.byteLength(value, "utf8") > maxPathBytes || isAbsolute(value) || win32.isAbsolute(value) || value.includes("\\") || value.includes("\0") || hasControlCharacter(value) || value.split("/").some((part) => !part || part === "." || part === "..") || [".git", ".sidecar"].includes(value.split("/")[0]?.toLowerCase() ?? "")) throw new Error(`candidate workspace archive contains an unsafe path: ${String(value)}`);
|
|
1147
|
+
return value;
|
|
1148
|
+
}
|
|
1149
|
+
function assertWorkspacePathOrder(path, previousPath, observedPaths, errorMessage) {
|
|
1150
|
+
if (previousPath !== void 0 && previousPath >= path) throw new Error(errorMessage);
|
|
1151
|
+
for (let slash = path.indexOf("/"); slash !== -1; slash = path.indexOf("/", slash + 1)) if (observedPaths.has(path.slice(0, slash))) throw new Error(errorMessage);
|
|
1152
|
+
observedPaths.add(path);
|
|
1153
|
+
}
|
|
1154
|
+
function inspectWorkspaceBytes(input, path) {
|
|
1155
|
+
let buffer;
|
|
1156
|
+
let byteOffset;
|
|
1157
|
+
let byteLength;
|
|
1158
|
+
try {
|
|
1159
|
+
buffer = typedArrayBufferGetter.call(input);
|
|
1160
|
+
byteOffset = typedArrayByteOffsetGetter.call(input);
|
|
1161
|
+
byteLength = typedArrayByteLengthGetter.call(input);
|
|
1162
|
+
} catch {
|
|
1163
|
+
throw new Error(`candidate workspace file bytes are invalid: ${path}`);
|
|
1164
|
+
}
|
|
1165
|
+
if (isSharedArrayBuffer(buffer)) throw new Error(`candidate workspace file uses shared bytes: ${path}`);
|
|
1166
|
+
if (!isAnyArrayBuffer(buffer)) throw new Error(`candidate workspace file bytes are invalid: ${path}`);
|
|
1167
|
+
return {
|
|
1168
|
+
buffer,
|
|
1169
|
+
byteOffset,
|
|
1170
|
+
byteLength
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
function detachWorkspaceBytes(input) {
|
|
1174
|
+
const { buffer, byteOffset, byteLength } = input;
|
|
1175
|
+
const copy = new Uint8Array(byteLength);
|
|
1176
|
+
Uint8Array.prototype.set.call(copy, new Uint8Array(buffer, byteOffset, byteLength));
|
|
1177
|
+
return copy;
|
|
1178
|
+
}
|
|
1179
|
+
function workspacePath(root, relativePath) {
|
|
1180
|
+
const path = resolve(root, relativePath);
|
|
1181
|
+
if (!path.startsWith(`${root}${sep}`)) throw new Error(`candidate workspace archive path escapes its destination: ${relativePath}`);
|
|
1182
|
+
return path;
|
|
1183
|
+
}
|
|
1184
|
+
function assertGitObjectId(value, label) {
|
|
1185
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) throw new Error(`candidate repository ${label} is not a Git object id`);
|
|
1186
|
+
}
|
|
1187
|
+
function hasControlCharacter(value) {
|
|
1188
|
+
for (let index = 0; index < value.length; index++) {
|
|
1189
|
+
const code = value.charCodeAt(index);
|
|
1190
|
+
if (code < 32 || code === 127) return true;
|
|
1191
|
+
}
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
function isRecord(value) {
|
|
1195
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1196
|
+
}
|
|
1197
|
+
function requireTypedArrayGetter(name) {
|
|
1198
|
+
const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
1199
|
+
const getter = Object.getOwnPropertyDescriptor(typedArrayPrototype, name)?.get;
|
|
1200
|
+
if (!getter) throw new Error(`typed array intrinsic ${name} accessor is unavailable`);
|
|
1201
|
+
return getter;
|
|
1202
|
+
}
|
|
1203
|
+
const typedArrayBufferGetter = requireTypedArrayGetter("buffer");
|
|
1204
|
+
const typedArrayByteOffsetGetter = requireTypedArrayGetter("byteOffset");
|
|
1205
|
+
const typedArrayByteLengthGetter = requireTypedArrayGetter("byteLength");
|
|
1206
|
+
//#endregion
|
|
1207
|
+
export { canonicalCandidateDocument as C, omitTopLevelDigest as D, immutableCandidateValue as E, sha256Bytes as O, canonicalCandidateDigest$1 as S, embeddedCandidateArtifact as T, verifyBytes as _, persistCandidateOutputArtifact as a, verifyWorkspaceSnapshotArtifacts as b, verifyTaskCheckout as c, isWellFormedUnicode as d, artifactCacheKey as f, scanMaterializedWorkspaceManifest as g, readVerifiedArtifact as h, verifyAgentCandidateWorkspaceArchive as i, verifyCanonicalCandidateDocument as k, verifyTaskOutcomePatch as l, readMaterializedWorkspaceFiles as m, captureAgentCandidateWorkspaceFiles as n, readCandidateGitHubResource as o, candidateWorkspaceManifest as p, createAgentCandidateWorkspacePort as r, verifyCandidateCode as s, captureAgentCandidateWorkspace as t, assertExactObjectKeys as u, verifyMaterializedProfileWorkspace as v, deepFreezeCandidate as w, canonicalCandidateBytes$1 as x, verifyMaterializedWorkspace as y };
|
|
1208
|
+
|
|
1209
|
+
//# sourceMappingURL=workspace-archive-DQGFwvM6.js.map
|