@tanstack/ai-sandbox 0.3.4 → 0.5.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/README.md +26 -0
- package/dist/esm/approvals.js +5 -4
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/bridge-events.js +7 -6
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/checkpoint-store.d.ts +147 -0
- package/dist/esm/checkpoint-store.js +267 -0
- package/dist/esm/checkpoint-store.js.map +1 -0
- package/dist/esm/chunk-identity.d.ts +0 -17
- package/dist/esm/chunk-identity.js +28 -5
- package/dist/esm/chunk-identity.js.map +1 -1
- package/dist/esm/contracts.d.ts +19 -0
- package/dist/esm/index.d.ts +11 -1
- package/dist/esm/index.js +12 -7
- package/dist/esm/memory-snapshot-types.d.ts +129 -0
- package/dist/esm/memory-snapshots.d.ts +6 -0
- package/dist/esm/memory-snapshots.js +490 -0
- package/dist/esm/memory-snapshots.js.map +1 -0
- package/dist/esm/middleware.d.ts +33 -1
- package/dist/esm/middleware.js +339 -94
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.d.ts +1 -1
- package/dist/esm/sandbox.d.ts +16 -0
- package/dist/esm/sandbox.js +62 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/snapshot-operations.d.ts +65 -0
- package/dist/esm/snapshot-operations.js +317 -0
- package/dist/esm/snapshot-operations.js.map +1 -0
- package/dist/esm/snapshot-tools.d.ts +185 -0
- package/dist/esm/snapshot-tools.js +160 -0
- package/dist/esm/snapshot-tools.js.map +1 -0
- package/dist/esm/snapshots.d.ts +51 -0
- package/dist/esm/snapshots.js +350 -0
- package/dist/esm/snapshots.js.map +1 -0
- package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
- package/dist/esm/testkit/checkpoint-conformance.js +453 -0
- package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +4 -0
- package/dist/esm/testkit/conformance.js +3 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/tool-history.js +4 -5
- package/dist/esm/tool-history.js.map +1 -1
- package/package.json +8 -3
- package/skills/ai-sandbox/SKILL.md +96 -8
- package/src/approvals.ts +13 -12
- package/src/bridge-events.ts +16 -10
- package/src/checkpoint-store.ts +652 -0
- package/src/chunk-identity.ts +36 -5
- package/src/contracts.ts +12 -0
- package/src/index.ts +56 -0
- package/src/memory-snapshot-types.ts +167 -0
- package/src/memory-snapshots.ts +936 -0
- package/src/middleware.ts +610 -160
- package/src/sandbox.ts +107 -6
- package/src/snapshot-operations.ts +540 -0
- package/src/snapshot-tools.ts +208 -0
- package/src/snapshots.ts +711 -0
- package/src/testkit/checkpoint-conformance.ts +472 -0
- package/src/testkit/checkpoint-fork-conformance.ts +299 -0
- package/src/testkit/conformance.ts +7 -0
- package/src/tool-history.ts +9 -13
package/src/snapshots.ts
ADDED
|
@@ -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
|
+
}
|