@tanstack/ai-sandbox 0.3.4 → 0.4.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 (51) hide show
  1. package/README.md +26 -0
  2. package/dist/esm/checkpoint-store.d.ts +147 -0
  3. package/dist/esm/checkpoint-store.js +267 -0
  4. package/dist/esm/checkpoint-store.js.map +1 -0
  5. package/dist/esm/contracts.d.ts +19 -0
  6. package/dist/esm/index.d.ts +11 -1
  7. package/dist/esm/index.js +12 -7
  8. package/dist/esm/memory-snapshot-types.d.ts +129 -0
  9. package/dist/esm/memory-snapshots.d.ts +6 -0
  10. package/dist/esm/memory-snapshots.js +490 -0
  11. package/dist/esm/memory-snapshots.js.map +1 -0
  12. package/dist/esm/middleware.d.ts +33 -1
  13. package/dist/esm/middleware.js +339 -94
  14. package/dist/esm/middleware.js.map +1 -1
  15. package/dist/esm/ngrok.d.ts +1 -1
  16. package/dist/esm/sandbox.d.ts +16 -0
  17. package/dist/esm/sandbox.js +62 -9
  18. package/dist/esm/sandbox.js.map +1 -1
  19. package/dist/esm/snapshot-operations.d.ts +65 -0
  20. package/dist/esm/snapshot-operations.js +317 -0
  21. package/dist/esm/snapshot-operations.js.map +1 -0
  22. package/dist/esm/snapshot-tools.d.ts +185 -0
  23. package/dist/esm/snapshot-tools.js +160 -0
  24. package/dist/esm/snapshot-tools.js.map +1 -0
  25. package/dist/esm/snapshots.d.ts +51 -0
  26. package/dist/esm/snapshots.js +350 -0
  27. package/dist/esm/snapshots.js.map +1 -0
  28. package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
  29. package/dist/esm/testkit/checkpoint-conformance.js +453 -0
  30. package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
  31. package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
  32. package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
  33. package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
  34. package/dist/esm/testkit/conformance.d.ts +4 -0
  35. package/dist/esm/testkit/conformance.js +3 -1
  36. package/dist/esm/testkit/conformance.js.map +1 -1
  37. package/package.json +8 -3
  38. package/skills/ai-sandbox/SKILL.md +96 -8
  39. package/src/checkpoint-store.ts +652 -0
  40. package/src/contracts.ts +12 -0
  41. package/src/index.ts +56 -0
  42. package/src/memory-snapshot-types.ts +167 -0
  43. package/src/memory-snapshots.ts +936 -0
  44. package/src/middleware.ts +610 -160
  45. package/src/sandbox.ts +107 -6
  46. package/src/snapshot-operations.ts +540 -0
  47. package/src/snapshot-tools.ts +208 -0
  48. package/src/snapshots.ts +711 -0
  49. package/src/testkit/checkpoint-conformance.ts +472 -0
  50. package/src/testkit/checkpoint-fork-conformance.ts +299 -0
  51. package/src/testkit/conformance.ts +7 -0
@@ -0,0 +1,711 @@
1
+ import type { SandboxHandle, SandboxFsStat } from './contracts'
2
+ import type {
3
+ SandboxSnapshotArtifact,
4
+ SandboxSnapshotEntry,
5
+ } from './checkpoint-store'
6
+ import type { MemoryArtifactRecord as ArtifactRecord } from './memory-snapshot-types'
7
+
8
+ type SnapshotBlobStore = {
9
+ get: (key: string) => Promise<{
10
+ arrayBuffer: () => Promise<ArrayBuffer>
11
+ } | null>
12
+ head: (key: string) => Promise<unknown>
13
+ put: (key: string, body: Uint8Array) => Promise<unknown>
14
+ }
15
+
16
+ export interface SandboxSnapshotPolicy {
17
+ /** Exact workspace projection hash, when known. */
18
+ workspaceHash?: string
19
+ include?: (path: string, kind: 'file' | 'dir') => boolean
20
+ exclude?: (path: string, kind: 'file' | 'dir') => boolean
21
+ redact?: (input: {
22
+ path: string
23
+ bytes: Uint8Array
24
+ resolvedSecrets: Readonly<Record<string, string>>
25
+ }) => Uint8Array
26
+ }
27
+
28
+ export interface SandboxSnapshotBundle {
29
+ blobs: SnapshotBlobStore
30
+ /** Internal resolved workspace root. */
31
+ workspaceRoot?: string
32
+ /** Internal persistence stores used to capture immutable artifact bytes. */
33
+ artifacts?: {
34
+ listForThread: (threadId: string) => Promise<ReadonlyArray<ArtifactRecord>>
35
+ }
36
+ resolveArtifactBlobKey?: (record: ArtifactRecord) => string
37
+ }
38
+
39
+ export type SandboxSnapshotErrorCode =
40
+ | 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT'
41
+ | 'SANDBOX_SNAPSHOT_MISSING_SANDBOX'
42
+ | 'SANDBOX_SNAPSHOT_MISSING_INSTANCES'
43
+ | 'SANDBOX_SNAPSHOT_MISSING_PERSISTENCE_STORES'
44
+ | 'SANDBOX_SNAPSHOT_MISSING_REUSABLE_SANDBOX'
45
+ | 'SANDBOX_SNAPSHOT_REUSE_NONE'
46
+ | 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT'
47
+ | 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT'
48
+ | 'SANDBOX_SNAPSHOT_FOREIGN_CHECKPOINT_ARTIFACT'
49
+ | 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES'
50
+ | 'SANDBOX_SNAPSHOT_FORK_UNAVAILABLE'
51
+ | 'SANDBOX_SNAPSHOT_INVALID_PATH'
52
+ | 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE'
53
+ | 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED'
54
+ | 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY'
55
+ | 'SANDBOX_SNAPSHOT_MISSING_BLOB'
56
+ | 'SANDBOX_SNAPSHOT_INVALID_BLOB'
57
+ | 'SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED'
58
+ | 'SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB'
59
+
60
+ export class SandboxSnapshotError extends Error {
61
+ readonly code: SandboxSnapshotErrorCode
62
+ constructor(code: SandboxSnapshotErrorCode, message: string) {
63
+ super(message)
64
+ this.name = 'SandboxSnapshotError'
65
+ this.code = code
66
+ }
67
+ }
68
+
69
+ const DEFAULT_ROOT = '/workspace'
70
+ const PROJECTED_SKILL_ROOTS = new Set(['.claude', '.codex', '.grok'])
71
+
72
+ function isFrameworkGeneratedSymlinkPath(path: string): boolean {
73
+ if (path === 'CLAUDE.md' || path === 'GEMINI.md') return true
74
+ const segments = path.split('/')
75
+ return (
76
+ segments.length === 3 &&
77
+ PROJECTED_SKILL_ROOTS.has(segments[0] ?? '') &&
78
+ segments[1] === 'skills'
79
+ )
80
+ }
81
+
82
+ function defaultExcluded(path: string, workspaceHash?: string): boolean {
83
+ const segments = path.split('/')
84
+ return (
85
+ isFrameworkGeneratedSymlinkPath(path) ||
86
+ segments.some(
87
+ (segment) =>
88
+ segment === '.git' ||
89
+ segment === 'node_modules' ||
90
+ segment.startsWith('.env'),
91
+ ) ||
92
+ (workspaceHash !== undefined &&
93
+ segments[0] === `.tanstack-projected-${workspaceHash}`)
94
+ )
95
+ }
96
+
97
+ function isProtectedPath(path: string, workspaceHash?: string): boolean {
98
+ return (
99
+ workspaceHash !== undefined &&
100
+ path.split('/')[0] === `.tanstack-projected-${workspaceHash}`
101
+ )
102
+ }
103
+
104
+ const FILE_BLOB_KEY = /^sandbox-files\/sha256\/[0-9a-f]{64}$/
105
+
106
+ export function defaultSandboxSnapshotPolicy(
107
+ workspaceHash?: string,
108
+ ): SandboxSnapshotPolicy {
109
+ return {
110
+ workspaceHash,
111
+ exclude: (path) => defaultExcluded(path, workspaceHash),
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Keep default exclusions unless the caller passed `exclude`.
117
+ * `include` or `redact` alone must not capture `.env`, `.git`, or
118
+ * `node_modules`.
119
+ */
120
+ export function resolveSandboxSnapshotPolicy(
121
+ supplied: SandboxSnapshotPolicy | undefined,
122
+ workspaceHash?: string,
123
+ ): SandboxSnapshotPolicy {
124
+ const defaults = defaultSandboxSnapshotPolicy(
125
+ workspaceHash ?? supplied?.workspaceHash,
126
+ )
127
+ if (supplied === undefined) return defaults
128
+ const include = supplied.include
129
+ const exclude = supplied.exclude
130
+ const redact = supplied.redact
131
+ const suppliedWorkspaceHash = supplied.workspaceHash
132
+ return {
133
+ ...(suppliedWorkspaceHash === undefined
134
+ ? {}
135
+ : { workspaceHash: suppliedWorkspaceHash }),
136
+ ...(workspaceHash === undefined ? {} : { workspaceHash }),
137
+ ...(include === undefined ? {} : { include }),
138
+ exclude: exclude ?? defaults.exclude,
139
+ ...(redact === undefined ? {} : { redact }),
140
+ }
141
+ }
142
+
143
+ function normalize(path: string): string {
144
+ if (path.includes('\\'))
145
+ throw new SandboxSnapshotError(
146
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
147
+ `Unsafe snapshot path '${path}'`,
148
+ )
149
+ const value = path
150
+ if (
151
+ !value ||
152
+ value.includes('\0') ||
153
+ value.startsWith('/') ||
154
+ /^[A-Za-z]:/.test(value) ||
155
+ value.endsWith('/')
156
+ )
157
+ throw new SandboxSnapshotError(
158
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
159
+ `Unsafe snapshot path '${path}'`,
160
+ )
161
+ const parts = value.split('/')
162
+ if (
163
+ parts.some((part) => !part || part === '.' || part === '..') ||
164
+ parts.join('/') !== value
165
+ )
166
+ throw new SandboxSnapshotError(
167
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
168
+ `Unsafe snapshot path '${path}'`,
169
+ )
170
+ return value
171
+ }
172
+
173
+ function childPath(
174
+ parent: string,
175
+ child: { name: string; path: string },
176
+ ): { absolute: string; relative: string } {
177
+ if (
178
+ !child.name ||
179
+ child.name.includes('/') ||
180
+ child.name.includes('\\') ||
181
+ child.name.includes('\0') ||
182
+ child.name === '.' ||
183
+ child.name === '..'
184
+ )
185
+ throw new SandboxSnapshotError(
186
+ 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
187
+ `Invalid workspace entry '${child.name}'`,
188
+ )
189
+ const absolute = `${parent}/${child.name}`
190
+ if (child.path !== absolute)
191
+ throw new SandboxSnapshotError(
192
+ 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
193
+ `Invalid workspace entry path '${child.path}'`,
194
+ )
195
+ return { absolute, relative: child.name }
196
+ }
197
+
198
+ function lstat(
199
+ handle: SandboxHandle,
200
+ path: string,
201
+ ): Promise<SandboxFsStat | undefined> {
202
+ if (!handle.fs.lstat)
203
+ throw new SandboxSnapshotError(
204
+ 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED',
205
+ 'Snapshot operations require fs.lstat',
206
+ )
207
+ return handle.fs.lstat(path)
208
+ }
209
+
210
+ function assertSupported(stat: SandboxFsStat, path: string): void {
211
+ if (
212
+ stat.type === 'symlink' ||
213
+ stat.type === 'other' ||
214
+ (stat.type === 'file' && (stat.mode & 0o111) !== 0)
215
+ )
216
+ throw new SandboxSnapshotError(
217
+ 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY',
218
+ `Unsupported entry '${path}'`,
219
+ )
220
+ }
221
+
222
+ function included(
223
+ path: string,
224
+ kind: 'file' | 'dir',
225
+ policy: SandboxSnapshotPolicy,
226
+ ): boolean {
227
+ if (policy.exclude?.(path, kind)) return false
228
+ return kind === 'dir' ? true : (policy.include?.(path, kind) ?? true)
229
+ }
230
+
231
+ async function hash(bytes: Uint8Array): Promise<string> {
232
+ // TypeScript requires an ArrayBuffer-backed view; Uint8Array can also use SharedArrayBuffer.
233
+ const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes))
234
+ return Array.from(new Uint8Array(digest), (byte) =>
235
+ byte.toString(16).padStart(2, '0'),
236
+ ).join('')
237
+ }
238
+
239
+ async function putIfAbsent(
240
+ blobs: SnapshotBlobStore,
241
+ bytes: Uint8Array,
242
+ keys: Map<string, string>,
243
+ ): Promise<{ key: string; size: number }> {
244
+ const key = `sandbox-files/sha256/${await hash(bytes)}`
245
+ if (!keys.has(key)) {
246
+ if (!(await blobs.head(key))) await blobs.put(key, bytes)
247
+ keys.set(key, key)
248
+ }
249
+ return { key, size: bytes.byteLength }
250
+ }
251
+
252
+ async function redactBytes(
253
+ bytes: Uint8Array,
254
+ resolvedSecrets: Readonly<Record<string, string>>,
255
+ ): Promise<Uint8Array> {
256
+ const output = bytes.slice()
257
+ const redacted = new Uint8Array(bytes.length)
258
+ const secrets = Object.values(resolvedSecrets)
259
+ .filter(Boolean)
260
+ .map((secret) => new TextEncoder().encode(secret))
261
+ .sort((a, b) => b.length - a.length || compareBytes(a, b))
262
+ for (const needle of secrets) {
263
+ if (!needle.length || needle.length > bytes.length) continue
264
+ for (let start = 0; start <= bytes.length - needle.length; start++) {
265
+ let match = true
266
+ for (let index = 0; index < needle.length; index++)
267
+ if (bytes[start + index] !== needle[index]) {
268
+ match = false
269
+ break
270
+ }
271
+ if (!match) continue
272
+ if (match) redacted.fill(1, start, start + needle.length)
273
+ }
274
+ }
275
+ for (let index = 0; index < output.length; index++)
276
+ if (redacted[index]) output[index] = 0
277
+ return output
278
+ }
279
+
280
+ export async function captureSandboxFiles(
281
+ handle: SandboxHandle,
282
+ bundle: SandboxSnapshotBundle,
283
+ suppliedPolicy: SandboxSnapshotPolicy = defaultSandboxSnapshotPolicy(),
284
+ resolvedSecrets: Readonly<Record<string, string>> = {},
285
+ ): Promise<{ files: Array<SandboxSnapshotEntry> }> {
286
+ const policy = resolveSandboxSnapshotPolicy(
287
+ suppliedPolicy,
288
+ suppliedPolicy.workspaceHash,
289
+ )
290
+ if (!handle.fs.lstat)
291
+ throw new SandboxSnapshotError(
292
+ 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED',
293
+ 'Snapshot capture requires fs.lstat',
294
+ )
295
+ const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT
296
+ const root = await lstat(handle, rootPath)
297
+ if (!root || root.type !== 'dir')
298
+ throw new SandboxSnapshotError(
299
+ 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
300
+ 'Snapshot workspace is missing or is not a directory',
301
+ )
302
+ assertSupported(root, rootPath)
303
+ const files: Array<SandboxSnapshotEntry> = []
304
+ const destinationKeys = new Map<string, string>()
305
+ const walk = async (absolute: string, relative: string): Promise<boolean> => {
306
+ const stat = await lstat(handle, absolute)
307
+ if (!stat)
308
+ throw new SandboxSnapshotError(
309
+ 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
310
+ `Snapshot entry disappeared '${relative}'`,
311
+ )
312
+ assertSupported(stat, relative)
313
+ if (stat.type !== 'file' && stat.type !== 'dir')
314
+ throw new SandboxSnapshotError(
315
+ 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY',
316
+ `Unsupported entry '${relative}'`,
317
+ )
318
+ if (
319
+ relative &&
320
+ (isProtectedPath(relative, policy.workspaceHash) ||
321
+ policy.exclude?.(relative, stat.type))
322
+ )
323
+ return false
324
+ if (stat.type === 'file') {
325
+ const path = normalize(relative)
326
+ if (policy.include && !policy.include(path, 'file')) return false
327
+ let bytes = await handle.fs.readBytes(absolute)
328
+ if (policy.redact) bytes = policy.redact({ path, bytes, resolvedSecrets })
329
+ bytes = await redactBytes(bytes, resolvedSecrets)
330
+ const blob = await putIfAbsent(bundle.blobs, bytes, destinationKeys)
331
+ files.push({ path, kind: 'file', blobKey: blob.key, size: blob.size })
332
+ return true
333
+ }
334
+ const children = await handle.fs.list(absolute)
335
+ let hasCapturedChild = false
336
+ for (const child of children) {
337
+ const childEntry = childPath(absolute, child)
338
+ const childRelative = relative
339
+ ? `${relative}/${childEntry.relative}`
340
+ : childEntry.relative
341
+ if (
342
+ isProtectedPath(childRelative, policy.workspaceHash) ||
343
+ policy.exclude?.(childRelative, child.type)
344
+ )
345
+ continue
346
+ hasCapturedChild =
347
+ (await walk(childEntry.absolute, childRelative)) || hasCapturedChild
348
+ }
349
+ if (
350
+ relative &&
351
+ !hasCapturedChild &&
352
+ (!policy.include || policy.include(relative, 'dir'))
353
+ )
354
+ files.push({ path: normalize(relative), kind: 'dir' })
355
+ return (
356
+ hasCapturedChild ||
357
+ (relative !== '' && (!policy.include || policy.include(relative, 'dir')))
358
+ )
359
+ }
360
+ await walk(rootPath, '')
361
+ files.sort((a, b) => comparePath(a.path, b.path))
362
+ return { files }
363
+ }
364
+
365
+ type PlannedEntry = SandboxSnapshotEntry & { path: string }
366
+ type PlannedFile = Extract<PlannedEntry, { kind: 'file' }>
367
+
368
+ function validateManifest(
369
+ snapshot: {
370
+ files: ReadonlyArray<SandboxSnapshotEntry>
371
+ },
372
+ policy: SandboxSnapshotPolicy,
373
+ ): Array<PlannedEntry> {
374
+ const paths = new Map<string, PlannedEntry>()
375
+ for (const entry of snapshot.files) {
376
+ const path = normalize(entry.path)
377
+ for (const ancestor of parents(path)) {
378
+ if (
379
+ isProtectedPath(ancestor, policy.workspaceHash) ||
380
+ policy.exclude?.(ancestor, 'dir')
381
+ )
382
+ throw new SandboxSnapshotError(
383
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
384
+ `Excluded snapshot ancestor '${ancestor}'`,
385
+ )
386
+ }
387
+ if (isProtectedPath(path, policy.workspaceHash))
388
+ throw new SandboxSnapshotError(
389
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
390
+ `Protected snapshot path '${path}'`,
391
+ )
392
+ if (
393
+ !included(path, entry.kind, policy) ||
394
+ (entry.kind === 'dir' && policy.include?.(path, 'dir') === false)
395
+ )
396
+ throw new SandboxSnapshotError(
397
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
398
+ `Excluded snapshot path '${path}'`,
399
+ )
400
+ if (paths.has(path))
401
+ throw new SandboxSnapshotError(
402
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
403
+ `Duplicate path '${path}'`,
404
+ )
405
+ if (entry.kind === 'file') {
406
+ const { blobKey, size } = entry
407
+ if (
408
+ typeof blobKey !== 'string' ||
409
+ !FILE_BLOB_KEY.test(blobKey) ||
410
+ typeof size !== 'number' ||
411
+ !Number.isSafeInteger(size) ||
412
+ size < 0
413
+ )
414
+ throw new SandboxSnapshotError(
415
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
416
+ `Invalid file entry '${path}'`,
417
+ )
418
+ paths.set(path, { path, kind: 'file', blobKey, size })
419
+ } else if (entry.kind === 'dir') paths.set(path, { path, kind: 'dir' })
420
+ else
421
+ throw new SandboxSnapshotError(
422
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
423
+ `Unknown snapshot entry '${path}'`,
424
+ )
425
+ }
426
+ for (const [path] of paths)
427
+ for (
428
+ let index = path.indexOf('/');
429
+ index !== -1;
430
+ index = path.indexOf('/', index + 1)
431
+ ) {
432
+ const parent = paths.get(path.slice(0, index))
433
+ if (parent?.kind === 'file')
434
+ throw new SandboxSnapshotError(
435
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
436
+ `File ancestor '${parent.path}'`,
437
+ )
438
+ }
439
+ return [...paths.values()]
440
+ }
441
+
442
+ async function loadBlobs(
443
+ entries: ReadonlyArray<PlannedEntry>,
444
+ bundle: SandboxSnapshotBundle,
445
+ ): Promise<Map<string, Uint8Array>> {
446
+ const blobs = new Map<string, Uint8Array>()
447
+ for (const entry of entries)
448
+ if (entry.kind === 'file' && !blobs.has(entry.blobKey)) {
449
+ const object = await bundle.blobs.get(entry.blobKey)
450
+ if (!object)
451
+ throw new SandboxSnapshotError(
452
+ 'SANDBOX_SNAPSHOT_MISSING_BLOB',
453
+ `Missing snapshot blob '${entry.blobKey}'`,
454
+ )
455
+ const bytes = new Uint8Array(await object.arrayBuffer())
456
+ const expectedKey = `sandbox-files/sha256/${await hash(bytes)}`
457
+ if (entry.blobKey !== expectedKey)
458
+ throw new SandboxSnapshotError(
459
+ 'SANDBOX_SNAPSHOT_INVALID_BLOB',
460
+ `Invalid content for snapshot blob '${entry.blobKey}'`,
461
+ )
462
+ blobs.set(entry.blobKey, bytes)
463
+ }
464
+ for (const entry of entries)
465
+ if (entry.kind === 'file') {
466
+ const bytes = blobs.get(entry.blobKey)
467
+ if (!bytes || bytes.byteLength !== entry.size)
468
+ throw new SandboxSnapshotError(
469
+ 'SANDBOX_SNAPSHOT_INVALID_BLOB',
470
+ `Wrong size for snapshot blob '${entry.blobKey}'`,
471
+ )
472
+ }
473
+ return blobs
474
+ }
475
+
476
+ type CurrentEntry = { path: string; kind: 'file' | 'dir'; protected?: boolean }
477
+
478
+ async function scanCurrent(
479
+ handle: SandboxHandle,
480
+ absolute: string,
481
+ relative: string,
482
+ policy: SandboxSnapshotPolicy,
483
+ ): Promise<Array<CurrentEntry>> {
484
+ const stat = await lstat(handle, absolute)
485
+ if (!stat) return []
486
+ assertSupported(stat, relative)
487
+ if (stat.type === 'file') return [{ path: relative, kind: 'file' }]
488
+ const paths: Array<CurrentEntry> = []
489
+ for (const child of await handle.fs.list(absolute)) {
490
+ const childEntry = childPath(absolute, child)
491
+ const childRelative = relative
492
+ ? `${relative}/${childEntry.relative}`
493
+ : childEntry.relative
494
+ if (isProtectedPath(childRelative, policy.workspaceHash)) {
495
+ // Preserve the marker and its parent directories without inspecting the
496
+ // protected tree. The restore planner uses this marker to avoid removing
497
+ // an ancestor directory that contains it.
498
+ paths.push({ path: childRelative, kind: 'dir', protected: true })
499
+ continue
500
+ }
501
+ if (!included(childRelative, child.type, policy)) {
502
+ // Excluded entries are outside the portable snapshot. Keep a protected
503
+ // marker so removing a parent directory cannot remove them either.
504
+ paths.push({ path: childRelative, kind: child.type, protected: true })
505
+ continue
506
+ }
507
+ paths.push(
508
+ ...(await scanCurrent(
509
+ handle,
510
+ childEntry.absolute,
511
+ childRelative,
512
+ policy,
513
+ )),
514
+ )
515
+ }
516
+ return relative ? [{ path: relative, kind: 'dir' }, ...paths] : paths
517
+ }
518
+
519
+ async function scanDestination(
520
+ handle: SandboxHandle,
521
+ policy: SandboxSnapshotPolicy,
522
+ rootPath: string,
523
+ ): Promise<Array<CurrentEntry>> {
524
+ const root = await lstat(handle, rootPath)
525
+ if (!root || root.type !== 'dir')
526
+ throw new SandboxSnapshotError(
527
+ 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE',
528
+ 'Snapshot workspace is missing or is not a directory',
529
+ )
530
+ assertSupported(root, rootPath)
531
+ return scanCurrent(handle, rootPath, '', policy)
532
+ }
533
+
534
+ function parents(path: string): Array<string> {
535
+ const values: Array<string> = []
536
+ const parts = path.split('/')
537
+ for (let length = 1; length < parts.length; length++)
538
+ values.push(parts.slice(0, length).join('/'))
539
+ return values
540
+ }
541
+
542
+ function comparePath(a: string, b: string): number {
543
+ return compareBytes(new TextEncoder().encode(a), new TextEncoder().encode(b))
544
+ }
545
+
546
+ function compareBytes(a: Uint8Array, b: Uint8Array): number {
547
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
548
+ const left = a[i]
549
+ const right = b[i]
550
+ if (left !== right) return (left ?? 0) - (right ?? 0)
551
+ }
552
+ return a.length - b.length
553
+ }
554
+
555
+ function getRequiredBlob(
556
+ blobs: ReadonlyMap<string, Uint8Array>,
557
+ key: string,
558
+ ): Uint8Array {
559
+ const bytes = blobs.get(key)
560
+ if (!bytes) {
561
+ throw new SandboxSnapshotError(
562
+ 'SANDBOX_SNAPSHOT_MISSING_BLOB',
563
+ `Missing snapshot blob '${key}'`,
564
+ )
565
+ }
566
+ return bytes
567
+ }
568
+
569
+ function depth(path: string): number {
570
+ return path.split('/').length
571
+ }
572
+
573
+ function buildRestorePlan(
574
+ entries: ReadonlyArray<PlannedEntry>,
575
+ current: ReadonlyArray<CurrentEntry>,
576
+ ): {
577
+ removes: Array<string>
578
+ mkdirs: Array<string>
579
+ writes: Array<PlannedFile>
580
+ } {
581
+ const desired = new Map<string, 'file' | 'dir'>()
582
+ for (const entry of entries) {
583
+ desired.set(entry.path, entry.kind)
584
+ for (const parent of parents(entry.path)) desired.set(parent, 'dir')
585
+ }
586
+ const currentKinds = new Map(current.map((entry) => [entry.path, entry.kind]))
587
+ const candidates = current
588
+ .filter((entry) => desired.get(entry.path) !== entry.kind)
589
+ .map((entry) => entry.path)
590
+ .sort((a, b) => depth(a) - depth(b) || comparePath(a, b))
591
+ const removes: Array<string> = []
592
+ for (const path of candidates) {
593
+ const containsProtectedEntry = current.some(
594
+ (entry) =>
595
+ entry.protected &&
596
+ (entry.path === path || entry.path.startsWith(`${path}/`)),
597
+ )
598
+ if (containsProtectedEntry) continue
599
+ if (removes.some((ancestor) => path.startsWith(`${ancestor}/`))) continue
600
+ removes.push(path)
601
+ }
602
+ const mkdirs = [...desired]
603
+ .filter(
604
+ ([path, kind]) => kind === 'dir' && currentKinds.get(path) !== 'dir',
605
+ )
606
+ .map(([path]) => path)
607
+ .sort((a, b) => depth(a) - depth(b) || comparePath(a, b))
608
+ const writes = entries
609
+ .filter((entry): entry is PlannedFile => entry.kind === 'file')
610
+ .sort((a, b) => comparePath(a.path, b.path))
611
+ return { removes, mkdirs, writes }
612
+ }
613
+
614
+ export async function restoreSandboxFiles(
615
+ handle: SandboxHandle,
616
+ bundle: SandboxSnapshotBundle,
617
+ snapshot: { files: ReadonlyArray<SandboxSnapshotEntry> },
618
+ suppliedPolicy: SandboxSnapshotPolicy = defaultSandboxSnapshotPolicy(),
619
+ ): Promise<void> {
620
+ const policy = resolveSandboxSnapshotPolicy(
621
+ suppliedPolicy,
622
+ suppliedPolicy.workspaceHash,
623
+ )
624
+ if (!handle.fs.lstat)
625
+ throw new SandboxSnapshotError(
626
+ 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED',
627
+ 'Snapshot restore requires fs.lstat',
628
+ )
629
+ const entries = validateManifest(snapshot, policy)
630
+ const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT
631
+ const current = await scanDestination(handle, policy, rootPath)
632
+ for (const entry of entries) {
633
+ if (
634
+ entry.kind === 'file' &&
635
+ current.some(
636
+ (currentEntry) =>
637
+ currentEntry.protected &&
638
+ currentEntry.path.startsWith(`${entry.path}/`),
639
+ )
640
+ )
641
+ throw new SandboxSnapshotError(
642
+ 'SANDBOX_SNAPSHOT_INVALID_PATH',
643
+ `Protected current descendant conflicts with '${entry.path}'`,
644
+ )
645
+ }
646
+ const blobs = await loadBlobs(entries, bundle)
647
+ const plan = buildRestorePlan(entries, current)
648
+ // Restore is called only while a new sandbox is private to setup. It is not
649
+ // safe for a caller that allows another process to change the workspace.
650
+ for (const path of plan.removes) await handle.fs.remove(`${rootPath}/${path}`)
651
+ for (const path of plan.mkdirs) await handle.fs.mkdir(`${rootPath}/${path}`)
652
+ for (const entry of plan.writes)
653
+ await handle.fs.write(
654
+ `${rootPath}/${entry.path}`,
655
+ getRequiredBlob(blobs, entry.blobKey),
656
+ )
657
+ }
658
+
659
+ export async function captureSandboxArtifacts(
660
+ bundle: SandboxSnapshotBundle,
661
+ threadId: string,
662
+ resolvedSecrets: Readonly<Record<string, string>> = {},
663
+ ): Promise<ReadonlyArray<SandboxSnapshotArtifact>> {
664
+ if (!bundle.artifacts)
665
+ throw new SandboxSnapshotError(
666
+ 'SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED',
667
+ 'Snapshot artifact capture requires an artifact store',
668
+ )
669
+ const records = await bundle.artifacts.listForThread(threadId)
670
+ const loaded = new Map<string, Uint8Array>()
671
+ const destinationKeys = new Map<string, string>()
672
+ const resolveBlobKey =
673
+ bundle.resolveArtifactBlobKey ??
674
+ ((record: ArtifactRecord) =>
675
+ record.blobKey ?? `artifacts/${record.runId}/${record.artifactId}`)
676
+ for (const record of records) {
677
+ const sourceKey = resolveBlobKey(record)
678
+ if (loaded.has(sourceKey)) continue
679
+ const source = await bundle.blobs.get(sourceKey)
680
+ if (!source)
681
+ throw new SandboxSnapshotError(
682
+ 'SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB',
683
+ `Missing artifact source blob '${sourceKey}'`,
684
+ )
685
+ loaded.set(sourceKey, new Uint8Array(await source.arrayBuffer()))
686
+ }
687
+ const output = []
688
+ for (const record of records) {
689
+ const sourceKey = resolveBlobKey(record)
690
+ let bytes = getRequiredBlob(loaded, sourceKey)
691
+ bytes = await redactBytes(bytes, resolvedSecrets)
692
+ const key = `sandbox-artifacts/sha256/${await hash(bytes)}`
693
+ if (!destinationKeys.has(key)) {
694
+ if (!(await bundle.blobs.head(key))) await bundle.blobs.put(key, bytes)
695
+ destinationKeys.set(key, key)
696
+ }
697
+ output.push({
698
+ artifactId: record.artifactId,
699
+ name: record.name,
700
+ mimeType: record.mimeType,
701
+ size: bytes.byteLength,
702
+ blobKey: key,
703
+ createdAt: record.createdAt,
704
+ })
705
+ }
706
+ output.sort(
707
+ (a, b) =>
708
+ a.createdAt - b.createdAt || comparePath(a.artifactId, b.artifactId),
709
+ )
710
+ return Object.freeze(output.map((artifact) => Object.freeze(artifact)))
711
+ }