UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

712 lines (676 loc) 23.3 kB
import type { SandboxHandle, SandboxFsStat } from './contracts' import type { SandboxSnapshotArtifact, SandboxSnapshotEntry, } from './checkpoint-store' import type { MemoryArtifactRecord as ArtifactRecord } from './memory-snapshot-types' type SnapshotBlobStore = { get: (key: string) => Promise<{ arrayBuffer: () => Promise<ArrayBuffer> } | null> head: (key: string) => Promise<unknown> put: (key: string, body: Uint8Array) => Promise<unknown> } export interface SandboxSnapshotPolicy { /** Exact workspace projection hash, when known. */ workspaceHash?: string include?: (path: string, kind: 'file' | 'dir') => boolean exclude?: (path: string, kind: 'file' | 'dir') => boolean redact?: (input: { path: string bytes: Uint8Array resolvedSecrets: Readonly<Record<string, string>> }) => Uint8Array } export interface SandboxSnapshotBundle { blobs: SnapshotBlobStore /** Internal resolved workspace root. */ workspaceRoot?: string /** Internal persistence stores used to capture immutable artifact bytes. */ artifacts?: { listForThread: (threadId: string) => Promise<ReadonlyArray<ArtifactRecord>> } resolveArtifactBlobKey?: (record: ArtifactRecord) => string } export type SandboxSnapshotErrorCode = | 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT' | 'SANDBOX_SNAPSHOT_MISSING_SANDBOX' | 'SANDBOX_SNAPSHOT_MISSING_INSTANCES' | 'SANDBOX_SNAPSHOT_MISSING_PERSISTENCE_STORES' | 'SANDBOX_SNAPSHOT_MISSING_REUSABLE_SANDBOX' | 'SANDBOX_SNAPSHOT_REUSE_NONE' | 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT' | 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT' | 'SANDBOX_SNAPSHOT_FOREIGN_CHECKPOINT_ARTIFACT' | 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES' | 'SANDBOX_SNAPSHOT_FORK_UNAVAILABLE' | 'SANDBOX_SNAPSHOT_INVALID_PATH' | 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE' | 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED' | 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY' | 'SANDBOX_SNAPSHOT_MISSING_BLOB' | 'SANDBOX_SNAPSHOT_INVALID_BLOB' | 'SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED' | 'SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB' export class SandboxSnapshotError extends Error { readonly code: SandboxSnapshotErrorCode constructor(code: SandboxSnapshotErrorCode, message: string) { super(message) this.name = 'SandboxSnapshotError' this.code = code } } const DEFAULT_ROOT = '/workspace' const PROJECTED_SKILL_ROOTS = new Set(['.claude', '.codex', '.grok']) function isFrameworkGeneratedSymlinkPath(path: string): boolean { if (path === 'CLAUDE.md' || path === 'GEMINI.md') return true const segments = path.split('/') return ( segments.length === 3 && PROJECTED_SKILL_ROOTS.has(segments[0] ?? '') && segments[1] === 'skills' ) } function defaultExcluded(path: string, workspaceHash?: string): boolean { const segments = path.split('/') return ( isFrameworkGeneratedSymlinkPath(path) || segments.some( (segment) => segment === '.git' || segment === 'node_modules' || segment.startsWith('.env'), ) || (workspaceHash !== undefined && segments[0] === `.tanstack-projected-${workspaceHash}`) ) } function isProtectedPath(path: string, workspaceHash?: string): boolean { return ( workspaceHash !== undefined && path.split('/')[0] === `.tanstack-projected-${workspaceHash}` ) } const FILE_BLOB_KEY = /^sandbox-files\/sha256\/[0-9a-f]{64}$/ export function defaultSandboxSnapshotPolicy( workspaceHash?: string, ): SandboxSnapshotPolicy { return { workspaceHash, exclude: (path) => defaultExcluded(path, workspaceHash), } } /** * Keep default exclusions unless the caller passed `exclude`. * `include` or `redact` alone must not capture `.env`, `.git`, or * `node_modules`. */ export function resolveSandboxSnapshotPolicy( supplied: SandboxSnapshotPolicy | undefined, workspaceHash?: string, ): SandboxSnapshotPolicy { const defaults = defaultSandboxSnapshotPolicy( workspaceHash ?? supplied?.workspaceHash, ) if (supplied === undefined) return defaults const include = supplied.include const exclude = supplied.exclude const redact = supplied.redact const suppliedWorkspaceHash = supplied.workspaceHash return { ...(suppliedWorkspaceHash === undefined ? {} : { workspaceHash: suppliedWorkspaceHash }), ...(workspaceHash === undefined ? {} : { workspaceHash }), ...(include === undefined ? {} : { include }), exclude: exclude ?? defaults.exclude, ...(redact === undefined ? {} : { redact }), } } function normalize(path: string): string { if (path.includes('\\')) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Unsafe snapshot path '${path}'`, ) const value = path if ( !value || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:/.test(value) || value.endsWith('/') ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Unsafe snapshot path '${path}'`, ) const parts = value.split('/') if ( parts.some((part) => !part || part === '.' || part === '..') || parts.join('/') !== value ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Unsafe snapshot path '${path}'`, ) return value } function childPath( parent: string, child: { name: string; path: string }, ): { absolute: string; relative: string } { if ( !child.name || child.name.includes('/') || child.name.includes('\\') || child.name.includes('\0') || child.name === '.' || child.name === '..' ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE', `Invalid workspace entry '${child.name}'`, ) const absolute = `${parent}/${child.name}` if (child.path !== absolute) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE', `Invalid workspace entry path '${child.path}'`, ) return { absolute, relative: child.name } } function lstat( handle: SandboxHandle, path: string, ): Promise<SandboxFsStat | undefined> { if (!handle.fs.lstat) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED', 'Snapshot operations require fs.lstat', ) return handle.fs.lstat(path) } function assertSupported(stat: SandboxFsStat, path: string): void { if ( stat.type === 'symlink' || stat.type === 'other' || (stat.type === 'file' && (stat.mode & 0o111) !== 0) ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY', `Unsupported entry '${path}'`, ) } function included( path: string, kind: 'file' | 'dir', policy: SandboxSnapshotPolicy, ): boolean { if (policy.exclude?.(path, kind)) return false return kind === 'dir' ? true : (policy.include?.(path, kind) ?? true) } async function hash(bytes: Uint8Array): Promise<string> { // TypeScript requires an ArrayBuffer-backed view; Uint8Array can also use SharedArrayBuffer. const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)) return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'), ).join('') } async function putIfAbsent( blobs: SnapshotBlobStore, bytes: Uint8Array, keys: Map<string, string>, ): Promise<{ key: string; size: number }> { const key = `sandbox-files/sha256/${await hash(bytes)}` if (!keys.has(key)) { if (!(await blobs.head(key))) await blobs.put(key, bytes) keys.set(key, key) } return { key, size: bytes.byteLength } } async function redactBytes( bytes: Uint8Array, resolvedSecrets: Readonly<Record<string, string>>, ): Promise<Uint8Array> { const output = bytes.slice() const redacted = new Uint8Array(bytes.length) const secrets = Object.values(resolvedSecrets) .filter(Boolean) .map((secret) => new TextEncoder().encode(secret)) .sort((a, b) => b.length - a.length || compareBytes(a, b)) for (const needle of secrets) { if (!needle.length || needle.length > bytes.length) continue for (let start = 0; start <= bytes.length - needle.length; start++) { let match = true for (let index = 0; index < needle.length; index++) if (bytes[start + index] !== needle[index]) { match = false break } if (!match) continue if (match) redacted.fill(1, start, start + needle.length) } } for (let index = 0; index < output.length; index++) if (redacted[index]) output[index] = 0 return output } export async function captureSandboxFiles( handle: SandboxHandle, bundle: SandboxSnapshotBundle, suppliedPolicy: SandboxSnapshotPolicy = defaultSandboxSnapshotPolicy(), resolvedSecrets: Readonly<Record<string, string>> = {}, ): Promise<{ files: Array<SandboxSnapshotEntry> }> { const policy = resolveSandboxSnapshotPolicy( suppliedPolicy, suppliedPolicy.workspaceHash, ) if (!handle.fs.lstat) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED', 'Snapshot capture requires fs.lstat', ) const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT const root = await lstat(handle, rootPath) if (!root || root.type !== 'dir') throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE', 'Snapshot workspace is missing or is not a directory', ) assertSupported(root, rootPath) const files: Array<SandboxSnapshotEntry> = [] const destinationKeys = new Map<string, string>() const walk = async (absolute: string, relative: string): Promise<boolean> => { const stat = await lstat(handle, absolute) if (!stat) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE', `Snapshot entry disappeared '${relative}'`, ) assertSupported(stat, relative) if (stat.type !== 'file' && stat.type !== 'dir') throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY', `Unsupported entry '${relative}'`, ) if ( relative && (isProtectedPath(relative, policy.workspaceHash) || policy.exclude?.(relative, stat.type)) ) return false if (stat.type === 'file') { const path = normalize(relative) if (policy.include && !policy.include(path, 'file')) return false let bytes = await handle.fs.readBytes(absolute) if (policy.redact) bytes = policy.redact({ path, bytes, resolvedSecrets }) bytes = await redactBytes(bytes, resolvedSecrets) const blob = await putIfAbsent(bundle.blobs, bytes, destinationKeys) files.push({ path, kind: 'file', blobKey: blob.key, size: blob.size }) return true } const children = await handle.fs.list(absolute) let hasCapturedChild = false for (const child of children) { const childEntry = childPath(absolute, child) const childRelative = relative ? `${relative}/${childEntry.relative}` : childEntry.relative if ( isProtectedPath(childRelative, policy.workspaceHash) || policy.exclude?.(childRelative, child.type) ) continue hasCapturedChild = (await walk(childEntry.absolute, childRelative)) || hasCapturedChild } if ( relative && !hasCapturedChild && (!policy.include || policy.include(relative, 'dir')) ) files.push({ path: normalize(relative), kind: 'dir' }) return ( hasCapturedChild || (relative !== '' && (!policy.include || policy.include(relative, 'dir'))) ) } await walk(rootPath, '') files.sort((a, b) => comparePath(a.path, b.path)) return { files } } type PlannedEntry = SandboxSnapshotEntry & { path: string } type PlannedFile = Extract<PlannedEntry, { kind: 'file' }> function validateManifest( snapshot: { files: ReadonlyArray<SandboxSnapshotEntry> }, policy: SandboxSnapshotPolicy, ): Array<PlannedEntry> { const paths = new Map<string, PlannedEntry>() for (const entry of snapshot.files) { const path = normalize(entry.path) for (const ancestor of parents(path)) { if ( isProtectedPath(ancestor, policy.workspaceHash) || policy.exclude?.(ancestor, 'dir') ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Excluded snapshot ancestor '${ancestor}'`, ) } if (isProtectedPath(path, policy.workspaceHash)) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Protected snapshot path '${path}'`, ) if ( !included(path, entry.kind, policy) || (entry.kind === 'dir' && policy.include?.(path, 'dir') === false) ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Excluded snapshot path '${path}'`, ) if (paths.has(path)) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Duplicate path '${path}'`, ) if (entry.kind === 'file') { const { blobKey, size } = entry if ( typeof blobKey !== 'string' || !FILE_BLOB_KEY.test(blobKey) || typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0 ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Invalid file entry '${path}'`, ) paths.set(path, { path, kind: 'file', blobKey, size }) } else if (entry.kind === 'dir') paths.set(path, { path, kind: 'dir' }) else throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Unknown snapshot entry '${path}'`, ) } for (const [path] of paths) for ( let index = path.indexOf('/'); index !== -1; index = path.indexOf('/', index + 1) ) { const parent = paths.get(path.slice(0, index)) if (parent?.kind === 'file') throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `File ancestor '${parent.path}'`, ) } return [...paths.values()] } async function loadBlobs( entries: ReadonlyArray<PlannedEntry>, bundle: SandboxSnapshotBundle, ): Promise<Map<string, Uint8Array>> { const blobs = new Map<string, Uint8Array>() for (const entry of entries) if (entry.kind === 'file' && !blobs.has(entry.blobKey)) { const object = await bundle.blobs.get(entry.blobKey) if (!object) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_BLOB', `Missing snapshot blob '${entry.blobKey}'`, ) const bytes = new Uint8Array(await object.arrayBuffer()) const expectedKey = `sandbox-files/sha256/${await hash(bytes)}` if (entry.blobKey !== expectedKey) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_BLOB', `Invalid content for snapshot blob '${entry.blobKey}'`, ) blobs.set(entry.blobKey, bytes) } for (const entry of entries) if (entry.kind === 'file') { const bytes = blobs.get(entry.blobKey) if (!bytes || bytes.byteLength !== entry.size) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_BLOB', `Wrong size for snapshot blob '${entry.blobKey}'`, ) } return blobs } type CurrentEntry = { path: string; kind: 'file' | 'dir'; protected?: boolean } async function scanCurrent( handle: SandboxHandle, absolute: string, relative: string, policy: SandboxSnapshotPolicy, ): Promise<Array<CurrentEntry>> { const stat = await lstat(handle, absolute) if (!stat) return [] assertSupported(stat, relative) if (stat.type === 'file') return [{ path: relative, kind: 'file' }] const paths: Array<CurrentEntry> = [] for (const child of await handle.fs.list(absolute)) { const childEntry = childPath(absolute, child) const childRelative = relative ? `${relative}/${childEntry.relative}` : childEntry.relative if (isProtectedPath(childRelative, policy.workspaceHash)) { // Preserve the marker and its parent directories without inspecting the // protected tree. The restore planner uses this marker to avoid removing // an ancestor directory that contains it. paths.push({ path: childRelative, kind: 'dir', protected: true }) continue } if (!included(childRelative, child.type, policy)) { // Excluded entries are outside the portable snapshot. Keep a protected // marker so removing a parent directory cannot remove them either. paths.push({ path: childRelative, kind: child.type, protected: true }) continue } paths.push( ...(await scanCurrent( handle, childEntry.absolute, childRelative, policy, )), ) } return relative ? [{ path: relative, kind: 'dir' }, ...paths] : paths } async function scanDestination( handle: SandboxHandle, policy: SandboxSnapshotPolicy, rootPath: string, ): Promise<Array<CurrentEntry>> { const root = await lstat(handle, rootPath) if (!root || root.type !== 'dir') throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE', 'Snapshot workspace is missing or is not a directory', ) assertSupported(root, rootPath) return scanCurrent(handle, rootPath, '', policy) } function parents(path: string): Array<string> { const values: Array<string> = [] const parts = path.split('/') for (let length = 1; length < parts.length; length++) values.push(parts.slice(0, length).join('/')) return values } function comparePath(a: string, b: string): number { return compareBytes(new TextEncoder().encode(a), new TextEncoder().encode(b)) } function compareBytes(a: Uint8Array, b: Uint8Array): number { for (let i = 0; i < Math.min(a.length, b.length); i++) { const left = a[i] const right = b[i] if (left !== right) return (left ?? 0) - (right ?? 0) } return a.length - b.length } function getRequiredBlob( blobs: ReadonlyMap<string, Uint8Array>, key: string, ): Uint8Array { const bytes = blobs.get(key) if (!bytes) { throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_BLOB', `Missing snapshot blob '${key}'`, ) } return bytes } function depth(path: string): number { return path.split('/').length } function buildRestorePlan( entries: ReadonlyArray<PlannedEntry>, current: ReadonlyArray<CurrentEntry>, ): { removes: Array<string> mkdirs: Array<string> writes: Array<PlannedFile> } { const desired = new Map<string, 'file' | 'dir'>() for (const entry of entries) { desired.set(entry.path, entry.kind) for (const parent of parents(entry.path)) desired.set(parent, 'dir') } const currentKinds = new Map(current.map((entry) => [entry.path, entry.kind])) const candidates = current .filter((entry) => desired.get(entry.path) !== entry.kind) .map((entry) => entry.path) .sort((a, b) => depth(a) - depth(b) || comparePath(a, b)) const removes: Array<string> = [] for (const path of candidates) { const containsProtectedEntry = current.some( (entry) => entry.protected && (entry.path === path || entry.path.startsWith(`${path}/`)), ) if (containsProtectedEntry) continue if (removes.some((ancestor) => path.startsWith(`${ancestor}/`))) continue removes.push(path) } const mkdirs = [...desired] .filter( ([path, kind]) => kind === 'dir' && currentKinds.get(path) !== 'dir', ) .map(([path]) => path) .sort((a, b) => depth(a) - depth(b) || comparePath(a, b)) const writes = entries .filter((entry): entry is PlannedFile => entry.kind === 'file') .sort((a, b) => comparePath(a.path, b.path)) return { removes, mkdirs, writes } } export async function restoreSandboxFiles( handle: SandboxHandle, bundle: SandboxSnapshotBundle, snapshot: { files: ReadonlyArray<SandboxSnapshotEntry> }, suppliedPolicy: SandboxSnapshotPolicy = defaultSandboxSnapshotPolicy(), ): Promise<void> { const policy = resolveSandboxSnapshotPolicy( suppliedPolicy, suppliedPolicy.workspaceHash, ) if (!handle.fs.lstat) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED', 'Snapshot restore requires fs.lstat', ) const entries = validateManifest(snapshot, policy) const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT const current = await scanDestination(handle, policy, rootPath) for (const entry of entries) { if ( entry.kind === 'file' && current.some( (currentEntry) => currentEntry.protected && currentEntry.path.startsWith(`${entry.path}/`), ) ) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_INVALID_PATH', `Protected current descendant conflicts with '${entry.path}'`, ) } const blobs = await loadBlobs(entries, bundle) const plan = buildRestorePlan(entries, current) // Restore is called only while a new sandbox is private to setup. It is not // safe for a caller that allows another process to change the workspace. for (const path of plan.removes) await handle.fs.remove(`${rootPath}/${path}`) for (const path of plan.mkdirs) await handle.fs.mkdir(`${rootPath}/${path}`) for (const entry of plan.writes) await handle.fs.write( `${rootPath}/${entry.path}`, getRequiredBlob(blobs, entry.blobKey), ) } export async function captureSandboxArtifacts( bundle: SandboxSnapshotBundle, threadId: string, resolvedSecrets: Readonly<Record<string, string>> = {}, ): Promise<ReadonlyArray<SandboxSnapshotArtifact>> { if (!bundle.artifacts) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED', 'Snapshot artifact capture requires an artifact store', ) const records = await bundle.artifacts.listForThread(threadId) const loaded = new Map<string, Uint8Array>() const destinationKeys = new Map<string, string>() const resolveBlobKey = bundle.resolveArtifactBlobKey ?? ((record: ArtifactRecord) => record.blobKey ?? `artifacts/${record.runId}/${record.artifactId}`) for (const record of records) { const sourceKey = resolveBlobKey(record) if (loaded.has(sourceKey)) continue const source = await bundle.blobs.get(sourceKey) if (!source) throw new SandboxSnapshotError( 'SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB', `Missing artifact source blob '${sourceKey}'`, ) loaded.set(sourceKey, new Uint8Array(await source.arrayBuffer())) } const output = [] for (const record of records) { const sourceKey = resolveBlobKey(record) let bytes = getRequiredBlob(loaded, sourceKey) bytes = await redactBytes(bytes, resolvedSecrets) const key = `sandbox-artifacts/sha256/${await hash(bytes)}` if (!destinationKeys.has(key)) { if (!(await bundle.blobs.head(key))) await bundle.blobs.put(key, bytes) destinationKeys.set(key, key) } output.push({ artifactId: record.artifactId, name: record.name, mimeType: record.mimeType, size: bytes.byteLength, blobKey: key, createdAt: record.createdAt, }) } output.sort( (a, b) => a.createdAt - b.createdAt || comparePath(a.artifactId, b.artifactId), ) return Object.freeze(output.map((artifact) => Object.freeze(artifact))) }