@opengeni/runtime 2.4.3 → 2.5.2-canary.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.
Files changed (44) hide show
  1. package/dist/{agent-UA75SUZ7.js → agent-Q2VNH5FV.js} +2 -2
  2. package/dist/{chunk-ORKCCH5X.js → chunk-25B3EQRW.js} +3 -398
  3. package/dist/{chunk-ORKCCH5X.js.map → chunk-25B3EQRW.js.map} +1 -1
  4. package/dist/{chunk-YRKIQK25.js → chunk-CD22I76N.js} +2 -2
  5. package/dist/{chunk-7K6WOXFS.js → chunk-GB763TSV.js} +17 -1
  6. package/dist/chunk-GB763TSV.js.map +1 -0
  7. package/dist/{chunk-BV2DSPZK.js → chunk-GBHOT4KS.js} +267 -181
  8. package/dist/chunk-GBHOT4KS.js.map +1 -0
  9. package/dist/{chunk-WATBACLT.js → chunk-LEIGL6X2.js} +2309 -1428
  10. package/dist/chunk-LEIGL6X2.js.map +1 -0
  11. package/dist/index.d.ts +12 -5
  12. package/dist/index.js +15 -5
  13. package/dist/{localSources-GDQRRHT3.js → localSources-Z5IJIIRV.js} +3 -3
  14. package/dist/model-input.d.ts +2 -1
  15. package/dist/runtime-skills.d.ts +28 -5
  16. package/dist/sandbox/archive-spool.d.ts +14 -0
  17. package/dist/sandbox/host-archive-spool.d.ts +19 -0
  18. package/dist/sandbox/index.d.ts +5 -1
  19. package/dist/sandbox/index.js +7 -3
  20. package/dist/sandbox/workspace-archive.d.ts +7 -0
  21. package/dist/skill-files.d.ts +4 -0
  22. package/dist/skill-library.d.ts +1 -1
  23. package/dist/skill-library.js +3 -1
  24. package/dist/workspace-tool-gateway.js +5 -5
  25. package/package.json +11 -11
  26. package/src/bundled_management_skills/opengeni-skills/SKILL.md +3 -1
  27. package/src/index.ts +184 -33
  28. package/src/lazy-tool-transport.ts +0 -3
  29. package/src/model-input.ts +1 -1
  30. package/src/run-events.ts +23 -0
  31. package/src/runtime-skills.ts +122 -161
  32. package/src/sandbox/archive-spool.ts +16 -0
  33. package/src/sandbox/host-archive-spool.ts +1040 -0
  34. package/src/sandbox/index.ts +292 -203
  35. package/src/sandbox/routing/routing-session.ts +1 -1
  36. package/src/sandbox/workspace-archive.ts +54 -1
  37. package/src/skill-files.ts +20 -0
  38. package/src/skill-library.ts +1 -0
  39. package/dist/chunk-7K6WOXFS.js.map +0 -1
  40. package/dist/chunk-BV2DSPZK.js.map +0 -1
  41. package/dist/chunk-WATBACLT.js.map +0 -1
  42. /package/dist/{agent-UA75SUZ7.js.map → agent-Q2VNH5FV.js.map} +0 -0
  43. /package/dist/{chunk-YRKIQK25.js.map → chunk-CD22I76N.js.map} +0 -0
  44. /package/dist/{localSources-GDQRRHT3.js.map → localSources-Z5IJIIRV.js.map} +0 -0
@@ -0,0 +1,1040 @@
1
+ import { createHash, type Hash } from "node:crypto";
2
+ import { constants, type BigIntStats } from "node:fs";
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ open,
8
+ readdir,
9
+ realpath,
10
+ rm,
11
+ rmdir,
12
+ unlink,
13
+ type FileHandle,
14
+ } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
17
+ import type { WorkspaceTreeFingerprint } from "@opengeni/contracts";
18
+ import type { WorkspaceArchiveSpool } from "./archive-spool";
19
+ import { WorkspaceArchiveIntegrityError } from "./workspace-archive";
20
+
21
+ const CHUNK_BYTES = 64 * 1024;
22
+ // Filesystem representability, not an archive resource quota. Check component
23
+ // UTF-8 bytes before clearing a destination, not after open/mkdir fails.
24
+ const HOST_FILENAME_BYTES = 255;
25
+ const PROJECTION = "sdk_local_archive_v1" as const;
26
+ const READ = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
27
+ const READ_DIRECTORY = READ | constants.O_DIRECTORY;
28
+ const CREATE = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
29
+
30
+ /** Matches SDK 0.14.3 resolveSandboxArchiveLimits: absent/null means unlimited;
31
+ * an explicit object fills undefined fields with SDK defaults, null disables a field.
32
+ * chunkBytes controls buffering, never the amount of input accepted. */
33
+ export type HostWorkspaceArchiveOptions = {
34
+ chunkBytes?: number;
35
+ archiveLimits?: {
36
+ maxInputBytes?: number | null;
37
+ maxExtractedBytes?: number | null;
38
+ maxMembers?: number | null;
39
+ } | null;
40
+ };
41
+
42
+ function invalid(message: string): never {
43
+ throw new WorkspaceArchiveIntegrityError("archive_hydration_failed", message);
44
+ }
45
+
46
+ function changed(cause?: unknown): never {
47
+ throw new WorkspaceArchiveIntegrityError(
48
+ "workspace_changed_during_capture",
49
+ "host workspace changed during archive observation",
50
+ { retryable: true, cause },
51
+ );
52
+ }
53
+
54
+ function captureError(error: unknown): never {
55
+ if (error instanceof WorkspaceArchiveIntegrityError) throw error;
56
+ const code = (error as NodeJS.ErrnoException | null)?.code;
57
+ if (code === "ENOENT" || code === "ELOOP" || code === "ENOTDIR") return changed(error);
58
+ // Disk exhaustion, permission failures and other operational errors are not
59
+ // evidence of a changing tree and must not become a retryable race diagnosis.
60
+ throw error;
61
+ }
62
+
63
+ function chunkSize(options: HostWorkspaceArchiveOptions): number {
64
+ const size = options.chunkBytes ?? CHUNK_BYTES;
65
+ if (!Number.isSafeInteger(size) || size < 1 || size > CHUNK_BYTES) {
66
+ throw new RangeError(`chunkBytes must be between 1 and ${CHUNK_BYTES}`);
67
+ }
68
+ return size;
69
+ }
70
+
71
+ function limitsFor(options: HostWorkspaceArchiveOptions) {
72
+ if (options.archiveLimits == null) return null;
73
+ const defaults = {
74
+ maxInputBytes: 1024 ** 3,
75
+ maxExtractedBytes: 4 * 1024 ** 3,
76
+ maxMembers: 100_000,
77
+ };
78
+ const limits = { ...defaults, ...options.archiveLimits };
79
+ for (const key of Object.keys(defaults) as Array<keyof typeof defaults>) {
80
+ if (limits[key] === undefined) limits[key] = defaults[key];
81
+ const value = limits[key];
82
+ if (value != null && (!Number.isSafeInteger(value) || value < 1)) {
83
+ throw new RangeError(`archiveLimits.${key} must be at least 1`);
84
+ }
85
+ }
86
+ return limits;
87
+ }
88
+
89
+ function checkLimit(actual: number, limit: number | null | undefined, label: string) {
90
+ if (!Number.isSafeInteger(actual)) invalid(`${label} exceeds the supported integer range`);
91
+ if (limit != null && actual > limit)
92
+ invalid(`workspace archive ${label} exceeds configured limit`);
93
+ }
94
+
95
+ function sameEntry(a: BigIntStats, b: BigIntStats) {
96
+ return a.dev === b.dev && a.ino === b.ino && a.mode === b.mode;
97
+ }
98
+
99
+ function sameVersion(a: BigIntStats, b: BigIntStats) {
100
+ return sameEntry(a, b) && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
101
+ }
102
+
103
+ function fdPath(handle: FileHandle, name?: string) {
104
+ return name === undefined ? `/proc/self/fd/${handle.fd}` : `/proc/self/fd/${handle.fd}/${name}`;
105
+ }
106
+
107
+ /** Node has no openat API. Linux's descriptor namespace supplies the same pinned
108
+ * parent resolution; O_NOFOLLOW applies to the child, never an untrusted ancestor.
109
+ * Do not replace this with lstat(path) followed by open(path): that races. */
110
+ async function openRoot(root: string, create = false): Promise<FileHandle> {
111
+ if (process.platform !== "linux")
112
+ invalid("host archive codec requires Linux descriptor-relative filesystem access");
113
+ if (!isAbsolute(root)) invalid("host workspace root must be absolute");
114
+ const absolute = resolve(root);
115
+ if (create && absolute === sep) invalid("cannot restore into the filesystem root");
116
+ let current = await open(sep, READ_DIRECTORY);
117
+ try {
118
+ for (const segment of absolute.split(sep).filter(Boolean)) {
119
+ if (create) {
120
+ await mkdir(fdPath(current, segment), { mode: 0o777 }).catch(
121
+ (error: NodeJS.ErrnoException) => {
122
+ if (error.code !== "EEXIST") throw error;
123
+ },
124
+ );
125
+ }
126
+ const next = await open(fdPath(current, segment), READ_DIRECTORY);
127
+ await current.close();
128
+ current = next;
129
+ }
130
+ return current;
131
+ } catch (error) {
132
+ await current.close();
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ type TreeIndex = {
138
+ directories: Map<string, BigIntStats>;
139
+ files: Map<string, BigIntStats>;
140
+ };
141
+
142
+ const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
143
+
144
+ async function inventory(root: FileHandle, excludedPaths: readonly string[]): Promise<TreeIndex> {
145
+ const tree: TreeIndex = { directories: new Map(), files: new Map() };
146
+ const walk = async (handle: FileHandle, logical: string) => {
147
+ const before = await handle.stat({ bigint: true });
148
+ tree.directories.set(logical, before);
149
+ const names = await readdir(fdPath(handle));
150
+ for (const name of names) {
151
+ const path = logical ? `${logical}/${name}` : name;
152
+ if (
153
+ excludedPaths.some(
154
+ (excluded) => excluded === "" || path === excluded || path.startsWith(`${excluded}/`),
155
+ )
156
+ )
157
+ continue;
158
+ const stats = await lstat(fdPath(handle, name), { bigint: true });
159
+ if (stats.isDirectory()) {
160
+ const child = await open(fdPath(handle, name), READ_DIRECTORY);
161
+ try {
162
+ if (!sameVersion(stats, await child.stat({ bigint: true }))) changed();
163
+ await walk(child, path);
164
+ if (!sameVersion(stats, await lstat(fdPath(handle, name), { bigint: true }))) changed();
165
+ } finally {
166
+ await child.close();
167
+ }
168
+ } else if (stats.isFile()) {
169
+ tree.files.set(path, stats);
170
+ }
171
+ // Exactly the SDK projection: symlinks, FIFOs, sockets and devices are skipped.
172
+ }
173
+ if (!sameVersion(before, await handle.stat({ bigint: true }))) changed();
174
+ };
175
+ await walk(root, "");
176
+ return tree;
177
+ }
178
+
179
+ async function openDirectory(root: FileHandle, logical: string, tree?: TreeIndex, create = false) {
180
+ let current = await open(fdPath(root), READ_DIRECTORY & ~constants.O_NOFOLLOW);
181
+ try {
182
+ if (tree && !sameVersion(tree.directories.get("")!, await current.stat({ bigint: true })))
183
+ changed();
184
+ let path = "";
185
+ for (const segment of logical.split("/").filter(Boolean)) {
186
+ path = path ? `${path}/${segment}` : segment;
187
+ if (create) {
188
+ await mkdir(fdPath(current, segment), { mode: 0o777 }).catch(
189
+ (error: NodeJS.ErrnoException) => {
190
+ if (error.code !== "EEXIST") throw error;
191
+ },
192
+ );
193
+ }
194
+ const next = await open(fdPath(current, segment), READ_DIRECTORY);
195
+ await current.close();
196
+ current = next;
197
+ if (tree) {
198
+ const expected = tree.directories.get(path);
199
+ if (!expected || !sameVersion(expected, await current.stat({ bigint: true }))) changed();
200
+ }
201
+ }
202
+ return current;
203
+ } catch (error) {
204
+ await current.close();
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ function parentAndName(path: string): [string, string] {
210
+ const slash = path.lastIndexOf("/");
211
+ return slash < 0 ? ["", path] : [path.slice(0, slash), path.slice(slash + 1)];
212
+ }
213
+
214
+ async function readTreeFile(
215
+ root: FileHandle,
216
+ tree: TreeIndex,
217
+ path: string,
218
+ consume: (bytes: Buffer) => void | Promise<void>,
219
+ ) {
220
+ const [parent, name] = parentAndName(path);
221
+ const directory = await openDirectory(root, parent, tree);
222
+ try {
223
+ const expected = tree.files.get(path)!;
224
+ const handle = await open(fdPath(directory, name), READ);
225
+ try {
226
+ if (!sameVersion(expected, await handle.stat({ bigint: true }))) changed();
227
+ const size = Number(expected.size);
228
+ checkLimit(size, null, "file size");
229
+ const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
230
+ let offset = 0;
231
+ while (offset < size) {
232
+ const { bytesRead } = await handle.read(
233
+ buffer,
234
+ 0,
235
+ Math.min(buffer.length, size - offset),
236
+ offset,
237
+ );
238
+ if (bytesRead === 0) changed();
239
+ await consume(buffer.subarray(0, bytesRead));
240
+ offset += bytesRead;
241
+ }
242
+ if (
243
+ !sameVersion(expected, await handle.stat({ bigint: true })) ||
244
+ !sameVersion(expected, await lstat(fdPath(directory, name), { bigint: true }))
245
+ )
246
+ changed();
247
+ } finally {
248
+ await handle.close();
249
+ }
250
+ } finally {
251
+ await directory.close();
252
+ }
253
+ }
254
+
255
+ function frame(hash: Hash, label: string, value: string) {
256
+ hash
257
+ .update(label)
258
+ .update("\0")
259
+ .update(String(Buffer.byteLength(value)))
260
+ .update("\0")
261
+ .update(value)
262
+ .update("\0");
263
+ }
264
+
265
+ function projection(directories: readonly string[]) {
266
+ const hash = createHash("sha256");
267
+ frame(hash, "projection", PROJECTION);
268
+ for (const path of [...directories].sort(compare)) frame(hash, "dir", path);
269
+ return hash;
270
+ }
271
+
272
+ function fingerprint(
273
+ hash: Hash,
274
+ directories: number,
275
+ files: number,
276
+ totalFileBytes: number,
277
+ ): WorkspaceTreeFingerprint {
278
+ checkLimit(totalFileBytes, null, "extracted size");
279
+ return {
280
+ algorithm: "sha256",
281
+ sha256: hash.digest("hex"),
282
+ entryCount: directories + files,
283
+ fileCount: files,
284
+ totalFileBytes,
285
+ projection: PROJECTION,
286
+ };
287
+ }
288
+
289
+ async function verifyInventory(rootPath: string, root: FileHandle, tree: TreeIndex) {
290
+ const fresh = await openRoot(rootPath);
291
+ try {
292
+ if (!sameVersion(tree.directories.get("")!, await fresh.stat({ bigint: true }))) changed();
293
+ } finally {
294
+ await fresh.close();
295
+ }
296
+ for (const path of tree.directories.keys()) {
297
+ const handle = await openDirectory(root, path, tree);
298
+ await handle.close();
299
+ }
300
+ for (const [path, expected] of tree.files) {
301
+ const [parent, name] = parentAndName(path);
302
+ const handle = await openDirectory(root, parent, tree);
303
+ try {
304
+ if (!sameVersion(expected, await lstat(fdPath(handle, name), { bigint: true }))) changed();
305
+ } finally {
306
+ await handle.close();
307
+ }
308
+ }
309
+ }
310
+
311
+ async function hashTree(rootPath: string, root: FileHandle, tree: TreeIndex) {
312
+ const directories = [...tree.directories.keys()].filter(Boolean);
313
+ const hash = projection(directories);
314
+ let total = 0;
315
+ for (const path of [...tree.files.keys()].sort(compare)) {
316
+ const size = Number(tree.files.get(path)!.size);
317
+ frame(hash, "file", path);
318
+ frame(hash, "bytes", String(size));
319
+ await readTreeFile(root, tree, path, (bytes) => {
320
+ hash.update(bytes);
321
+ });
322
+ total += size;
323
+ }
324
+ await verifyInventory(rootPath, root, tree);
325
+ return fingerprint(hash, directories.length, tree.files.size, total);
326
+ }
327
+
328
+ export async function fingerprintHostWorkspace(
329
+ root: string,
330
+ excludedPaths: readonly string[],
331
+ ): Promise<WorkspaceTreeFingerprint> {
332
+ const handle = await openRoot(root);
333
+ try {
334
+ return await hashTree(root, handle, await inventory(handle, excludedPaths));
335
+ } catch (error) {
336
+ return captureError(error);
337
+ } finally {
338
+ await handle.close();
339
+ }
340
+ }
341
+
342
+ function within(root: string, candidate: string) {
343
+ const path = relative(root, candidate);
344
+ return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`));
345
+ }
346
+
347
+ async function privateTemporaryDirectory(root: string) {
348
+ // Resolve the actual location before creating anything: TMPDIR can itself be a
349
+ // symlink or live inside the source workspace. Never add spool files to it.
350
+ const actualRoot = await realpath(root).catch(() => resolve(root));
351
+ for (const candidate of [tmpdir(), "/var/tmp", "/tmp"]) {
352
+ const base = await realpath(candidate).catch(() => null);
353
+ if (!base || within(actualRoot, base) || within("/dev/shm", base)) continue;
354
+ return await mkdtemp(join(base, "opengeni-host-archive-"));
355
+ }
356
+ invalid("no private disk temporary directory exists outside the workspace");
357
+ }
358
+
359
+ async function writeAll(handle: FileHandle, bytes: Uint8Array) {
360
+ let offset = 0;
361
+ while (offset < bytes.byteLength) {
362
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset);
363
+ if (!bytesWritten) throw new Error("archive spool write made no progress");
364
+ offset += bytesWritten;
365
+ }
366
+ }
367
+
368
+ class ArchiveWriter {
369
+ readonly hash = createHash("sha256");
370
+ byteSize = 0;
371
+ constructor(readonly handle: FileHandle) {}
372
+ async write(input: string | Uint8Array) {
373
+ const bytes = typeof input === "string" ? Buffer.from(input) : input;
374
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
375
+ const chunk = bytes.subarray(offset, offset + CHUNK_BYTES);
376
+ await writeAll(this.handle, chunk);
377
+ this.hash.update(chunk);
378
+ this.byteSize += chunk.length;
379
+ checkLimit(this.byteSize, null, "input size");
380
+ }
381
+ }
382
+ }
383
+
384
+ /** Native base64 conversion remains bounded to a chunk; both binary backing
385
+ * stores are reused. In particular, never Buffer.concat the carry with every
386
+ * source chunk or Buffer.from every encoded string: external-memory GC can lag
387
+ * far behind those allocations even though no individual buffer is large. */
388
+ class Base64Encoder {
389
+ private readonly input = Buffer.allocUnsafe(CHUNK_BYTES + 2);
390
+ private readonly output = Buffer.allocUnsafe(Math.ceil((CHUNK_BYTES + 2) / 3) * 4);
391
+ private carry = 0;
392
+ constructor(private readonly writer: ArchiveWriter) {}
393
+ async write(bytes: Buffer) {
394
+ bytes.copy(this.input, this.carry);
395
+ const available = this.carry + bytes.length;
396
+ const length = available - (available % 3);
397
+ if (length) await this.encode(length);
398
+ this.carry = available - length;
399
+ this.input.copy(this.input, 0, length, available);
400
+ }
401
+ private async encode(length: number) {
402
+ const written = this.output.write(this.input.subarray(0, length).toString("base64"), "ascii");
403
+ await this.writer.write(this.output.subarray(0, written));
404
+ }
405
+ async finish() {
406
+ if (this.carry) await this.encode(this.carry);
407
+ }
408
+ }
409
+
410
+ function ownedSpool(
411
+ directory: string,
412
+ path: string,
413
+ byteSize: number,
414
+ sha256: string,
415
+ ): WorkspaceArchiveSpool {
416
+ let disposal: Promise<void> | undefined;
417
+ return {
418
+ path,
419
+ byteSize,
420
+ sha256,
421
+ async *open() {
422
+ if (disposal) throw new Error("archive spool has been disposed");
423
+ const handle = await open(path, READ);
424
+ try {
425
+ const before = await handle.stat({ bigint: true });
426
+ if (!before.isFile() || Number(before.size) !== byteSize) invalid("archive spool changed");
427
+ let position = 0;
428
+ while (position < byteSize) {
429
+ const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, byteSize - position));
430
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
431
+ if (!bytesRead) invalid("archive spool was truncated");
432
+ position += bytesRead;
433
+ yield buffer.subarray(0, bytesRead);
434
+ }
435
+ if (!sameVersion(before, await handle.stat({ bigint: true })))
436
+ invalid("archive spool changed while reading");
437
+ } finally {
438
+ await handle.close();
439
+ }
440
+ },
441
+ dispose() {
442
+ return (disposal ??= rm(directory, { recursive: true, force: true }));
443
+ },
444
+ };
445
+ }
446
+
447
+ export async function captureHostWorkspaceArchive(
448
+ root: string,
449
+ excludedPaths: readonly string[],
450
+ ): Promise<{ spool: WorkspaceArchiveSpool; workspace: WorkspaceTreeFingerprint }> {
451
+ const handle = await openRoot(root);
452
+ let temporary: string | undefined;
453
+ try {
454
+ const tree = await inventory(handle, excludedPaths);
455
+ const before = await hashTree(root, handle, tree);
456
+ temporary = await privateTemporaryDirectory(root);
457
+ const path = join(temporary, "archive.json");
458
+ const file = await open(path, CREATE, 0o600);
459
+ const writer = new ArchiveWriter(file);
460
+ const directories = [...tree.directories.keys()].filter(Boolean).sort(compare);
461
+ const hash = projection(directories);
462
+ let total = 0;
463
+ try {
464
+ await writer.write('{"version":1,"directories":[');
465
+ for (let index = 0; index < directories.length; index++) {
466
+ await writer.write(`${index ? "," : ""}${JSON.stringify(directories[index])}`);
467
+ }
468
+ await writer.write('],"files":[');
469
+ let first = true;
470
+ for (const logical of [...tree.files.keys()].sort(compare)) {
471
+ const size = Number(tree.files.get(logical)!.size);
472
+ frame(hash, "file", logical);
473
+ frame(hash, "bytes", String(size));
474
+ await writer.write(`${first ? "" : ","}{"path":${JSON.stringify(logical)},"data":"`);
475
+ first = false;
476
+ const encoder = new Base64Encoder(writer);
477
+ await readTreeFile(handle, tree, logical, async (bytes) => {
478
+ hash.update(bytes);
479
+ await encoder.write(bytes);
480
+ });
481
+ await encoder.finish();
482
+ await writer.write('"}');
483
+ total += size;
484
+ }
485
+ await writer.write("]}");
486
+ } finally {
487
+ await file.close();
488
+ }
489
+ const archived = fingerprint(hash, directories.length, tree.files.size, total);
490
+ // Prove equivalence of the serialized bytes themselves, not just of the
491
+ // source chunks passed to the base64 encoder.
492
+ const encoded = await open(path, READ);
493
+ const payloadPath = join(temporary, "capture-payload");
494
+ let decoded: WorkspaceTreeFingerprint;
495
+ try {
496
+ const payload = await open(
497
+ payloadPath,
498
+ constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
499
+ 0o600,
500
+ );
501
+ try {
502
+ decoded = await fingerprintArchiveIndex(await indexArchive(encoded, payload, {}), payload);
503
+ } finally {
504
+ await payload.close();
505
+ }
506
+ } finally {
507
+ await encoded.close();
508
+ }
509
+ await unlink(payloadPath);
510
+ await verifyInventory(root, handle, tree);
511
+ const after = await hashTree(root, handle, await inventory(handle, excludedPaths));
512
+ if (
513
+ before.sha256 !== archived.sha256 ||
514
+ after.sha256 !== archived.sha256 ||
515
+ decoded.sha256 !== archived.sha256
516
+ )
517
+ changed();
518
+ const spool = ownedSpool(temporary, path, writer.byteSize, writer.hash.digest("hex"));
519
+ temporary = undefined;
520
+ return { spool, workspace: archived };
521
+ } catch (error) {
522
+ return captureError(error);
523
+ } finally {
524
+ try {
525
+ await handle.close();
526
+ } finally {
527
+ if (temporary) await rm(temporary, { recursive: true, force: true });
528
+ }
529
+ }
530
+ }
531
+
532
+ /** Byte-oriented JSON lexer. Only metadata strings are accumulated. String
533
+ * payloads are decoded in bounded runs, including JSON escapes and split UTF-8. */
534
+ class JsonReader {
535
+ private readonly buffer: Buffer;
536
+ private cursor = 0;
537
+ private available = 0;
538
+ private position = 0;
539
+ constructor(
540
+ private readonly handle: FileHandle,
541
+ size: number,
542
+ ) {
543
+ this.buffer = Buffer.allocUnsafe(size);
544
+ }
545
+ private async fill() {
546
+ if (this.cursor < this.available) return true;
547
+ const { bytesRead } = await this.handle.read(this.buffer, 0, this.buffer.length, this.position);
548
+ this.position += bytesRead;
549
+ this.cursor = 0;
550
+ this.available = bytesRead;
551
+ return bytesRead !== 0;
552
+ }
553
+ async peek(): Promise<number> {
554
+ return (await this.fill()) ? this.buffer[this.cursor]! : -1;
555
+ }
556
+ async byte(): Promise<number> {
557
+ const byte = await this.peek();
558
+ if (byte === -1) invalid("truncated workspace archive JSON");
559
+ this.cursor++;
560
+ return byte;
561
+ }
562
+ async whitespace() {
563
+ while (true) {
564
+ const byte = await this.peek();
565
+ if (byte !== 32 && byte !== 9 && byte !== 10 && byte !== 13) return;
566
+ this.cursor++;
567
+ }
568
+ }
569
+ async expect(character: string) {
570
+ await this.whitespace();
571
+ if ((await this.byte()) !== character.charCodeAt(0))
572
+ invalid(`expected ${character} in workspace archive JSON`);
573
+ }
574
+ async string(consume?: Base64Decoder): Promise<string> {
575
+ await this.expect('"');
576
+ const parts: string[] = [];
577
+ const decoder = consume
578
+ ? undefined
579
+ : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
580
+ const emit = async (text: string) => {
581
+ if (!text) return;
582
+ if (consume) await consume.write(text);
583
+ else parts.push(text);
584
+ };
585
+ while (await this.fill()) {
586
+ const start = this.cursor;
587
+ while (this.cursor < this.available) {
588
+ const byte = this.buffer[this.cursor]!;
589
+ if (byte === 34 || byte === 92 || byte < 32) break;
590
+ this.cursor++;
591
+ }
592
+ if (this.cursor > start) {
593
+ const bytes = this.buffer.subarray(start, this.cursor);
594
+ // Base64's alphabet is ASCII. Its decoder validates these bytes directly,
595
+ // so payload runs never become UTF-8/base64 strings or fresh buffers.
596
+ if (consume) await consume.write(bytes);
597
+ else await emit(decoder!.decode(bytes, { stream: true }));
598
+ }
599
+ if (this.cursor === this.available) continue;
600
+ if (decoder) await emit(decoder.decode());
601
+ const special = await this.byte();
602
+ if (special === 34) return parts.join("");
603
+ if (special !== 92) invalid("unescaped control character in workspace archive JSON");
604
+ const escape = String.fromCharCode(await this.byte());
605
+ if (escape === "u") {
606
+ let digits = "";
607
+ for (let index = 0; index < 4; index++) digits += String.fromCharCode(await this.byte());
608
+ if (!/^[0-9a-fA-F]{4}$/.test(digits)) invalid("invalid JSON Unicode escape");
609
+ await emit(String.fromCharCode(Number.parseInt(digits, 16)));
610
+ } else {
611
+ const escapes: Record<string, string> = {
612
+ '"': '"',
613
+ "\\": "\\",
614
+ "/": "/",
615
+ b: "\b",
616
+ f: "\f",
617
+ n: "\n",
618
+ r: "\r",
619
+ t: "\t",
620
+ };
621
+ if (!Object.hasOwn(escapes, escape)) invalid("invalid JSON escape");
622
+ await emit(escapes[escape]!);
623
+ }
624
+ }
625
+ invalid("unterminated workspace archive JSON string");
626
+ }
627
+ async array(item: () => Promise<void>) {
628
+ await this.expect("[");
629
+ await this.whitespace();
630
+ if ((await this.peek()) === 93) {
631
+ await this.byte();
632
+ return;
633
+ }
634
+ while (true) {
635
+ await item();
636
+ await this.whitespace();
637
+ const separator = await this.byte();
638
+ if (separator === 93) return;
639
+ if (separator !== 44) invalid("invalid workspace archive JSON array");
640
+ }
641
+ }
642
+ async object(field: (key: string) => Promise<void>) {
643
+ await this.expect("{");
644
+ const keys = new Set<string>();
645
+ await this.whitespace();
646
+ if ((await this.peek()) === 125) {
647
+ await this.byte();
648
+ return keys;
649
+ }
650
+ while (true) {
651
+ const key = await this.string();
652
+ if (keys.has(key)) invalid("duplicate workspace archive JSON property");
653
+ keys.add(key);
654
+ await this.expect(":");
655
+ await field(key);
656
+ await this.whitespace();
657
+ const separator = await this.byte();
658
+ if (separator === 125) return keys;
659
+ if (separator !== 44) invalid("invalid workspace archive JSON object");
660
+ }
661
+ }
662
+ async version() {
663
+ await this.whitespace();
664
+ // Recognize the exact numeric value 1 without accumulating a potentially
665
+ // archive-sized number token (1, 1.0, 0.1e1, 100e-2, etc.).
666
+ let digits = 0;
667
+ let integerDigits = 0;
668
+ let significant = -1;
669
+ let valid = true;
670
+ const isDigit = (byte: number) => byte >= 48 && byte <= 57;
671
+ const mantissaDigit = (byte: number) => {
672
+ if (byte !== 48) {
673
+ if (byte !== 49 || significant !== -1) valid = false;
674
+ if (significant === -1) significant = digits;
675
+ }
676
+ digits++;
677
+ };
678
+ if ((await this.peek()) === 45) {
679
+ valid = false;
680
+ await this.byte();
681
+ }
682
+ const first = await this.byte();
683
+ if (!isDigit(first)) invalid("invalid workspace archive version number");
684
+ mantissaDigit(first);
685
+ integerDigits++;
686
+ if (first !== 48) {
687
+ while (isDigit(await this.peek())) {
688
+ mantissaDigit(await this.byte());
689
+ integerDigits++;
690
+ }
691
+ } else if (isDigit(await this.peek())) invalid("invalid leading zero in archive version");
692
+ if ((await this.peek()) === 46) {
693
+ await this.byte();
694
+ if (!isDigit(await this.peek())) invalid("invalid archive version fraction");
695
+ while (isDigit(await this.peek())) mantissaDigit(await this.byte());
696
+ }
697
+ let exponent = 0;
698
+ let sign = 1;
699
+ if ((await this.peek()) === 101 || (await this.peek()) === 69) {
700
+ await this.byte();
701
+ if ((await this.peek()) === 45 || (await this.peek()) === 43) {
702
+ if ((await this.byte()) === 45) sign = -1;
703
+ }
704
+ if (!isDigit(await this.peek())) invalid("invalid archive version exponent");
705
+ while (isDigit(await this.peek())) {
706
+ // Saturation is arithmetic only: every byte is still read/validated.
707
+ exponent = Math.min(Number.MAX_SAFE_INTEGER, exponent * 10 + (await this.byte()) - 48);
708
+ }
709
+ }
710
+ if (!valid || significant === -1 || integerDigits - significant - 1 + sign * exponent !== 0)
711
+ invalid("unsupported workspace archive version");
712
+ }
713
+ }
714
+
715
+ class Base64Decoder {
716
+ private readonly output = Buffer.allocUnsafe(CHUNK_BYTES);
717
+ private outputLength = 0;
718
+ private bits = 0;
719
+ private digits = 0;
720
+ /** 1 requires the second '=', 2 forbids any further data. */
721
+ private padding = 0;
722
+ byteSize = 0;
723
+ constructor(private readonly consume: (bytes: Buffer) => Promise<void>) {}
724
+ private async flush() {
725
+ if (!this.outputLength) return;
726
+ await this.consume(this.output.subarray(0, this.outputLength));
727
+ this.byteSize += this.outputLength;
728
+ this.outputLength = 0;
729
+ }
730
+ async write(input: string | Uint8Array) {
731
+ for (let index = 0; index < input.length; index++) {
732
+ const code = typeof input === "string" ? input.charCodeAt(index) : input[index]!;
733
+ if (this.padding === 2) invalid("data follows workspace archive base64 padding");
734
+ if (this.padding === 1) {
735
+ if (code !== 61) invalid("missing workspace archive base64 padding");
736
+ this.padding = 2;
737
+ continue;
738
+ }
739
+ // Any complete quartet emits at most three bytes. Await the sink before
740
+ // reusing storage; there are no asynchronous operations per input byte.
741
+ if (this.outputLength > this.output.length - 3) await this.flush();
742
+ if (code === 61) {
743
+ if (this.digits === 2) {
744
+ if (this.bits & 15) invalid("noncanonical workspace archive base64 unused bits");
745
+ this.output[this.outputLength++] = this.bits >>> 4;
746
+ this.padding = 1;
747
+ } else if (this.digits === 3) {
748
+ if (this.bits & 3) invalid("noncanonical workspace archive base64 unused bits");
749
+ this.output[this.outputLength++] = this.bits >>> 10;
750
+ this.output[this.outputLength++] = (this.bits >>> 2) & 255;
751
+ this.padding = 2;
752
+ } else invalid("invalid workspace archive base64 padding");
753
+ this.digits = 0;
754
+ continue;
755
+ }
756
+ const value =
757
+ code >= 65 && code <= 90
758
+ ? code - 65
759
+ : code >= 97 && code <= 122
760
+ ? code - 71
761
+ : code >= 48 && code <= 57
762
+ ? code + 4
763
+ : code === 43
764
+ ? 62
765
+ : code === 47
766
+ ? 63
767
+ : -1;
768
+ if (value < 0) invalid("invalid workspace archive base64 alphabet");
769
+ this.bits = (this.bits << 6) | value;
770
+ if (++this.digits === 4) {
771
+ this.output[this.outputLength++] = this.bits >>> 16;
772
+ this.output[this.outputLength++] = (this.bits >>> 8) & 255;
773
+ this.output[this.outputLength++] = this.bits & 255;
774
+ this.bits = 0;
775
+ this.digits = 0;
776
+ }
777
+ }
778
+ }
779
+ async finish() {
780
+ if (this.digits || this.padding === 1) invalid("truncated workspace archive base64");
781
+ await this.flush();
782
+ }
783
+ }
784
+
785
+ type FileSpan = { path: string; offset: number; byteSize: number };
786
+ type ArchiveIndex = { directories: string[]; files: FileSpan[] };
787
+
788
+ async function fingerprintArchiveIndex(index: ArchiveIndex, payload: FileHandle) {
789
+ const hash = projection(index.directories);
790
+ let total = 0;
791
+ const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
792
+ for (const file of [...index.files].sort((a, b) => compare(a.path, b.path))) {
793
+ frame(hash, "file", file.path);
794
+ frame(hash, "bytes", String(file.byteSize));
795
+ let copied = 0;
796
+ while (copied < file.byteSize) {
797
+ const { bytesRead } = await payload.read(
798
+ buffer,
799
+ 0,
800
+ Math.min(buffer.length, file.byteSize - copied),
801
+ file.offset + copied,
802
+ );
803
+ if (!bytesRead) invalid("validated workspace payload was truncated");
804
+ hash.update(buffer.subarray(0, bytesRead));
805
+ copied += bytesRead;
806
+ }
807
+ total += file.byteSize;
808
+ }
809
+ return fingerprint(hash, index.directories.length, index.files.length, total);
810
+ }
811
+
812
+ function validatePath(path: string) {
813
+ if (
814
+ !path ||
815
+ path.includes("\0") ||
816
+ Buffer.from(path).toString("utf8") !== path ||
817
+ path
818
+ .split("/")
819
+ .some(
820
+ (part) =>
821
+ !part ||
822
+ part === "." ||
823
+ part === ".." ||
824
+ Buffer.byteLength(part, "utf8") > HOST_FILENAME_BYTES,
825
+ )
826
+ )
827
+ invalid("unsafe workspace archive path");
828
+ }
829
+
830
+ async function indexArchive(
831
+ input: FileHandle,
832
+ payload: FileHandle,
833
+ options: HostWorkspaceArchiveOptions,
834
+ ): Promise<ArchiveIndex> {
835
+ const reader = new JsonReader(input, chunkSize(options));
836
+ const limits = limitsFor(options);
837
+ const directories: string[] = [];
838
+ const files: FileSpan[] = [];
839
+ let extracted = 0;
840
+ let members = 0;
841
+ const member = () => {
842
+ checkLimit(++members, limits?.maxMembers, "member count");
843
+ };
844
+ const keys = await reader.object(async (key) => {
845
+ if (key === "version") return await reader.version();
846
+ if (key === "directories") {
847
+ return await reader.array(async () => {
848
+ const path = await reader.string();
849
+ validatePath(path);
850
+ directories.push(path);
851
+ member();
852
+ });
853
+ }
854
+ if (key === "files") {
855
+ return await reader.array(async () => {
856
+ let path: string | undefined;
857
+ const offset = extracted;
858
+ const decoder = new Base64Decoder(async (bytes) => {
859
+ extracted += bytes.length;
860
+ checkLimit(extracted, limits?.maxExtractedBytes, "extracted size");
861
+ await writeAll(payload, bytes);
862
+ });
863
+ const fields = await reader.object(async (field) => {
864
+ if (field === "path") {
865
+ path = await reader.string();
866
+ validatePath(path);
867
+ } else if (field === "data") {
868
+ await reader.string(decoder);
869
+ await decoder.finish();
870
+ } else invalid("unknown workspace archive file property");
871
+ });
872
+ if (path === undefined || !fields.has("data"))
873
+ invalid("workspace archive file is missing path or data");
874
+ files.push({ path, offset, byteSize: decoder.byteSize });
875
+ member();
876
+ });
877
+ }
878
+ invalid("unknown workspace archive property");
879
+ });
880
+ if (!keys.has("version") || !keys.has("directories") || !keys.has("files"))
881
+ invalid("workspace archive is missing required properties");
882
+ await reader.whitespace();
883
+ if ((await reader.peek()) !== -1) invalid("trailing data in workspace archive JSON");
884
+ const paths = new Map<string, "file" | "directory">();
885
+ for (const [path, kind] of [
886
+ ...directories.map((directory) => [directory, "directory"] as const),
887
+ ...files.map((file) => [file.path, "file"] as const),
888
+ ]) {
889
+ if (paths.has(path)) invalid("duplicate workspace archive path");
890
+ paths.set(path, kind);
891
+ }
892
+ for (const path of paths.keys()) {
893
+ let parent = parentAndName(path)[0];
894
+ while (parent) {
895
+ if (paths.get(parent) === "file") invalid("workspace archive file is also an ancestor");
896
+ parent = parentAndName(parent)[0];
897
+ }
898
+ }
899
+ return { directories, files };
900
+ }
901
+
902
+ /** Removes directory entries through pinned parents. unlink never follows a
903
+ * symlink; rmdir never follows a replacement symlink. No recursive rm uses an
904
+ * attacker-controlled pathname. */
905
+ async function clearDirectory(handle: FileHandle) {
906
+ for (const name of await readdir(fdPath(handle))) {
907
+ const path = fdPath(handle, name);
908
+ const stats = await lstat(path, { bigint: true });
909
+ if (stats.isDirectory()) {
910
+ const child = await open(path, READ_DIRECTORY);
911
+ try {
912
+ if (!sameEntry(stats, await child.stat({ bigint: true })))
913
+ invalid("restore destination directory changed");
914
+ await clearDirectory(child);
915
+ if (!sameEntry(stats, await lstat(path, { bigint: true })))
916
+ invalid("restore destination directory changed");
917
+ await rmdir(path);
918
+ } finally {
919
+ await child.close();
920
+ }
921
+ } else {
922
+ await unlink(path);
923
+ }
924
+ }
925
+ }
926
+
927
+ async function copySpan(payload: FileHandle, output: FileHandle, span: FileSpan) {
928
+ const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
929
+ let copied = 0;
930
+ while (copied < span.byteSize) {
931
+ const { bytesRead } = await payload.read(
932
+ buffer,
933
+ 0,
934
+ Math.min(buffer.length, span.byteSize - copied),
935
+ span.offset + copied,
936
+ );
937
+ if (!bytesRead) invalid("validated workspace payload was truncated");
938
+ await writeAll(output, buffer.subarray(0, bytesRead));
939
+ copied += bytesRead;
940
+ }
941
+ }
942
+
943
+ export async function restoreHostWorkspaceArchive(
944
+ root: string,
945
+ spool: WorkspaceArchiveSpool,
946
+ options: HostWorkspaceArchiveOptions = {},
947
+ ): Promise<void> {
948
+ // A provider callback can mutate the caller's spool/options objects. The
949
+ // selected expectation must remain the one supplied before any asynchronous
950
+ // work, not a replacement advertised while bytes are being produced.
951
+ const { byteSize: expectedByteSize, sha256: expectedSha256 } = spool;
952
+ const readChunks = spool.open.bind(spool);
953
+ const processingChunkBytes = chunkSize(options);
954
+ const limits = limitsFor(options);
955
+ checkLimit(expectedByteSize, limits?.maxInputBytes, "input size");
956
+ if (expectedByteSize < 0 || !/^[0-9a-f]{64}$/.test(expectedSha256))
957
+ invalid("invalid workspace archive spool metadata");
958
+ const temporary = await privateTemporaryDirectory(root);
959
+ const handles: FileHandle[] = [];
960
+ try {
961
+ const input = await open(
962
+ join(temporary, "input.json"),
963
+ constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
964
+ 0o600,
965
+ );
966
+ handles.push(input);
967
+ const writer = new ArchiveWriter(input);
968
+ // Read the untrusted source exactly once. All later work uses this private
969
+ // copy, so a changing/reopenable provider cannot switch bytes after validation.
970
+ for await (const bytes of readChunks()) {
971
+ if (!(bytes instanceof Uint8Array)) invalid("invalid workspace archive stream chunk");
972
+ checkLimit(writer.byteSize + bytes.length, limits?.maxInputBytes, "input size");
973
+ if (writer.byteSize + bytes.length > expectedByteSize)
974
+ invalid("workspace archive size mismatch");
975
+ await writer.write(bytes);
976
+ }
977
+ if (writer.byteSize !== expectedByteSize || writer.hash.digest("hex") !== expectedSha256)
978
+ invalid("workspace archive SHA-256/size mismatch");
979
+ const payload = await open(
980
+ join(temporary, "payload"),
981
+ constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
982
+ 0o600,
983
+ );
984
+ handles.push(payload);
985
+ const archive = await indexArchive(input, payload, {
986
+ chunkBytes: processingChunkBytes,
987
+ archiveLimits: limits,
988
+ });
989
+ // Nothing above creates, clears or truncates anything under root.
990
+ const destination = await openRoot(root, true);
991
+ handles.push(destination);
992
+ const destinationIdentity = await destination.stat({ bigint: true });
993
+ await clearDirectory(destination);
994
+ for (const path of archive.directories) {
995
+ const directory = await openDirectory(destination, path, undefined, true);
996
+ await directory.close();
997
+ }
998
+ for (const span of archive.files) {
999
+ const [parent, name] = parentAndName(span.path);
1000
+ const directory = await openDirectory(destination, parent, undefined, true);
1001
+ try {
1002
+ const output = await open(fdPath(directory, name), CREATE, 0o666);
1003
+ try {
1004
+ const identity = await output.stat({ bigint: true });
1005
+ await copySpan(payload, output, span);
1006
+ const freshParent = await openDirectory(destination, parent);
1007
+ try {
1008
+ if (
1009
+ !sameEntry(
1010
+ await directory.stat({ bigint: true }),
1011
+ await freshParent.stat({ bigint: true }),
1012
+ ) ||
1013
+ !sameEntry(identity, await lstat(fdPath(freshParent, name), { bigint: true }))
1014
+ )
1015
+ invalid("restore destination file changed");
1016
+ } finally {
1017
+ await freshParent.close();
1018
+ }
1019
+ } finally {
1020
+ await output.close();
1021
+ }
1022
+ } finally {
1023
+ await directory.close();
1024
+ }
1025
+ }
1026
+ const fresh = await openRoot(root);
1027
+ try {
1028
+ if (!sameEntry(destinationIdentity, await fresh.stat({ bigint: true })))
1029
+ invalid("restore destination root changed");
1030
+ } finally {
1031
+ await fresh.close();
1032
+ }
1033
+ } finally {
1034
+ try {
1035
+ await Promise.all(handles.map((handle) => handle.close()));
1036
+ } finally {
1037
+ await rm(temporary, { recursive: true, force: true });
1038
+ }
1039
+ }
1040
+ }