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.

653 lines (606 loc) 21.2 kB
import type { ModelMessage } from '@tanstack/ai' // Compare strings by their UTF-8 bytes so ordering does not depend on locale. const utf8Encoder = new TextEncoder() const compareUtf8Bytes = (left: string, right: string): number => { const leftBytes = utf8Encoder.encode(left) const rightBytes = utf8Encoder.encode(right) const length = Math.min(leftBytes.length, rightBytes.length) for (let index = 0; index < length; index++) { const leftByte = leftBytes[index] const rightByte = rightBytes[index] if (leftByte !== rightByte) { return (leftByte ?? 0) - (rightByte ?? 0) } } return leftBytes.length - rightBytes.length } export interface SandboxSnapshotFileEntry { path: string kind: 'file' blobKey: string size: number } export interface SandboxSnapshotDirectoryEntry { path: string kind: 'dir' } export type SandboxSnapshotEntry = | SandboxSnapshotFileEntry | SandboxSnapshotDirectoryEntry export interface SandboxSnapshotArtifact { artifactId: string name: string mimeType: string size: number blobKey: string createdAt: number } export interface SandboxCheckpoint { id: string threadId: string parentCheckpointId: string | null createdAt: number reason: 'automatic' | 'named' | 'fork-root' label?: string sourceRunId?: string files: ReadonlyArray<SandboxSnapshotEntry> conversation: ReadonlyArray<ModelMessage> artifacts: ReadonlyArray<SandboxSnapshotArtifact> } export interface SandboxCheckpointStore { get: (id: string) => Promise<SandboxCheckpoint | null> list: (threadId: string) => Promise<Array<SandboxCheckpoint>> getHead: (threadId: string) => Promise<string | null> append: (input: { checkpoint: SandboxCheckpoint expectedHeadId: string | null writer: SandboxCheckpointWriter }) => Promise<{ headId: string }> deleteHead: (input: { threadId: string checkpointId: string writer: SandboxCheckpointWriter }) => Promise<void> acquireWriter: (threadId: string) => Promise<SandboxCheckpointWriterLease> listBlobReferences: () => Promise<Array<{ key: string; references: number }>> /** Optional atomic fork capability. Stores without this method cannot fork. */ forkFromCheckpoint?: SandboxCheckpointForkCapability['forkFromCheckpoint'] } export interface SandboxCheckpointForkInput { sourceThreadId: string sourceCheckpointId: string destinationThreadId: string destinationCheckpointId: string createdAt: number writer: SandboxCheckpointWriter } export interface SandboxCheckpointForkCapability { forkFromCheckpoint: (input: SandboxCheckpointForkInput) => Promise<{ checkpoint: SandboxCheckpoint }> } export type ForkCapableSandboxCheckpointStore = SandboxCheckpointStore & SandboxCheckpointForkCapability export function isForkCapableSandboxCheckpointStore( store: SandboxCheckpointStore, ): store is ForkCapableSandboxCheckpointStore { return typeof store.forkFromCheckpoint === 'function' } export interface SandboxCheckpointWriter { threadId: string ownerToken: string fence: number } export interface SandboxCheckpointWriterLease extends SandboxCheckpointWriter { expiresAt: number renewAfterMs: number renew: () => Promise<{ expiresAt: number }> release: () => Promise<void> } export interface SandboxCheckpointStoreOptions { now?: () => number leaseDurationMs?: number renewAfterMs?: number } interface InMemoryCheckpointState { checkpoints: Map<string, SandboxCheckpoint> heads: Map<string, string> writers: Map<string, { ownerToken: string; fence: number; expiresAt: number }> fences: Map<string, number> references: Map<string, number> } export type SandboxCheckpointErrorCode = | 'SANDBOX_SNAPSHOT_STALE_HEAD' | 'SANDBOX_SNAPSHOT_PARENT_MISMATCH' | 'SANDBOX_SNAPSHOT_DUPLICATE_ID' | 'SANDBOX_SNAPSHOT_NOT_HEAD' | 'SANDBOX_SNAPSHOT_WRITER_CONFLICT' | 'SANDBOX_SNAPSHOT_WRITER_LOST' | 'SANDBOX_SNAPSHOT_INVALID_ID' | 'SANDBOX_SNAPSHOT_INVALID_ENTRY' | 'SANDBOX_SNAPSHOT_CHECKPOINT_NOT_FOUND' | 'SANDBOX_SNAPSHOT_ATOMIC_FORK_REQUIRED' | 'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND' | 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH' | 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY' export class SandboxCheckpointError extends Error { readonly code: SandboxCheckpointErrorCode constructor(code: SandboxCheckpointErrorCode, message: string) { super(message) this.name = 'SandboxCheckpointError' this.code = code } } export class SandboxCheckpointConflictError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_STALE_HEAD', message) this.name = 'SandboxCheckpointConflictError' } } export class SandboxCheckpointDuplicateIdError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_DUPLICATE_ID', message) this.name = 'SandboxCheckpointDuplicateIdError' } } export class SandboxCheckpointInvalidIdError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_INVALID_ID', message) this.name = 'SandboxCheckpointInvalidIdError' } } export class SandboxCheckpointInvalidEntryError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_INVALID_ENTRY', message) this.name = 'SandboxCheckpointInvalidEntryError' } } export class SandboxCheckpointParentMismatchError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_PARENT_MISMATCH', message) this.name = 'SandboxCheckpointParentMismatchError' } } export class SandboxCheckpointNotHeadError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_NOT_HEAD', message) this.name = 'SandboxCheckpointNotHeadError' } } export class SandboxCheckpointWriterConflictError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_WRITER_CONFLICT', message) this.name = 'SandboxCheckpointWriterConflictError' } } export class SandboxCheckpointWriterLostError extends SandboxCheckpointError { constructor(message: string) { super('SANDBOX_SNAPSHOT_WRITER_LOST', message) this.name = 'SandboxCheckpointWriterLostError' } } export function defineSandboxCheckpointStore( store: SandboxCheckpointStore, ): SandboxCheckpointStore { return store } function copy<T>(value: T): T { return structuredClone(value) } function blobKeys(checkpoint: SandboxCheckpoint): Set<string> { const keys = new Set<string>() for (const entry of checkpoint.files) { if (entry.kind === 'file') keys.add(entry.blobKey) } for (const artifact of checkpoint.artifacts) keys.add(artifact.blobKey) return keys } function hasUnpairedSurrogate(value: string): boolean { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index) if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(index + 1) if (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) return true index++ } else if (code >= 0xdc00 && code <= 0xdfff) { return true } } return false } function assertValidIdentifier( value: unknown, label: string, ): asserts value is string { if ( typeof value !== 'string' || value.length === 0 || hasUnpairedSurrogate(value) ) { throw new SandboxCheckpointInvalidIdError( `${label} must be a non-empty well-formed Unicode string`, ) } } function hasOwn(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key) } function validateEntries(checkpoint: SandboxCheckpoint): void { if (!Array.isArray(checkpoint.files)) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint files must be an array', ) } const paths = new Set<string>() const kinds = new Map<string, 'file' | 'dir'>() for (const entry of checkpoint.files as ReadonlyArray<unknown>) { if (entry === null || typeof entry !== 'object') { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint entry must be an object', ) } const candidate = entry as Record<string, unknown> if ( typeof candidate.path !== 'string' || candidate.path.length === 0 || candidate.path.includes('\0') || candidate.path.startsWith('/') || candidate.path.startsWith('\\') || /^[A-Za-z]:([\\/]|$)/.test(candidate.path) || candidate.path.includes('\\') || candidate.path .split('/') .some((part) => part.length === 0 || part === '.' || part === '..') ) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint entry path must be a normalized workspace-relative path', ) } const path = candidate.path if (paths.has(path)) { throw new SandboxCheckpointInvalidEntryError( `Checkpoint contains duplicate entry path '${path}'`, ) } for ( let separator = path.indexOf('/'); separator !== -1; separator = path.indexOf('/', separator + 1) ) { const ancestor = path.slice(0, separator) if (kinds.get(ancestor) === 'file') { throw new SandboxCheckpointInvalidEntryError( `Checkpoint entry '${path}' is beneath file '${ancestor}'`, ) } } if ( candidate.kind === 'file' && Array.from(kinds.keys()).some((other) => other.startsWith(`${path}/`)) ) { throw new SandboxCheckpointInvalidEntryError( `Checkpoint file '${path}' is an ancestor of another entry`, ) } paths.add(path) if (candidate.kind === 'file') { if ( typeof candidate.blobKey !== 'string' || candidate.blobKey.length === 0 || hasUnpairedSurrogate(candidate.blobKey) || !/^sandbox-files\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey) ) { throw new SandboxCheckpointInvalidEntryError( 'File entries require a non-empty blobKey', ) } if ( !hasOwn(candidate, 'size') || typeof candidate.size !== 'number' || !Number.isSafeInteger(candidate.size) || candidate.size < 0 ) { throw new SandboxCheckpointInvalidEntryError( 'File entry size must be a non-negative safe integer', ) } } else if (candidate.kind === 'dir') { if (hasOwn(candidate, 'blobKey') || hasOwn(candidate, 'size')) { throw new SandboxCheckpointInvalidEntryError( 'Directory entries cannot contain file fields', ) } } else { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint entry kind must be file or dir', ) } kinds.set(path, candidate.kind) } } function validateArtifacts(checkpoint: SandboxCheckpoint): void { if (!Array.isArray(checkpoint.artifacts)) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint artifacts must be an array', ) } for (const artifact of checkpoint.artifacts as ReadonlyArray<unknown>) { if (artifact === null || typeof artifact !== 'object') { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint artifact must be an object', ) } const candidate = artifact as Record<string, unknown> if ( typeof candidate.artifactId !== 'string' || candidate.artifactId.length === 0 || hasUnpairedSurrogate(candidate.artifactId) || typeof candidate.name !== 'string' || candidate.name.length === 0 || typeof candidate.mimeType !== 'string' || candidate.mimeType.length === 0 || typeof candidate.blobKey !== 'string' || candidate.blobKey.length === 0 || hasUnpairedSurrogate(candidate.blobKey) || !/^sandbox-artifacts\/sha256\/[0-9a-f]{64}$/.test(candidate.blobKey) || typeof candidate.size !== 'number' || !Number.isSafeInteger(candidate.size) || candidate.size < 0 || typeof candidate.createdAt !== 'number' || !Number.isFinite(candidate.createdAt) ) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint artifact has invalid fields', ) } } } export class InMemorySandboxCheckpointStore implements SandboxCheckpointStore { private readonly state: InMemoryCheckpointState private readonly now: () => number private readonly leaseDurationMs: number private readonly renewAfterMs: number constructor(options: SandboxCheckpointStoreOptions = {}) { const state: InMemoryCheckpointState = { checkpoints: new Map(), heads: new Map(), writers: new Map(), fences: new Map(), references: new Map(), } this.state = state this.now = options.now ?? (() => Date.now()) this.leaseDurationMs = options.leaseDurationMs ?? 120_000 this.renewAfterMs = options.renewAfterMs ?? 45_000 if (!Number.isFinite(this.leaseDurationMs) || this.leaseDurationMs <= 0) { throw new Error('leaseDurationMs must be finite and positive') } if ( !Number.isFinite(this.renewAfterMs) || this.renewAfterMs <= 0 || this.renewAfterMs >= this.leaseDurationMs ) { throw new Error( 'renewAfterMs must be finite, positive, and less than leaseDurationMs', ) } } async get(id: string): Promise<SandboxCheckpoint | null> { assertValidIdentifier(id, 'Checkpoint id') const checkpoint = this.state.checkpoints.get(id) return checkpoint ? copy(checkpoint) : null } async list(threadId: string): Promise<Array<SandboxCheckpoint>> { assertValidIdentifier(threadId, 'Thread id') return Array.from(this.state.checkpoints.values()) .filter((checkpoint) => checkpoint.threadId === threadId) .sort((a, b) => a.createdAt - b.createdAt || compareUtf8Bytes(a.id, b.id)) .map(copy) } async getHead(threadId: string): Promise<string | null> { assertValidIdentifier(threadId, 'Thread id') return this.state.heads.get(threadId) ?? null } async append(input: { checkpoint: SandboxCheckpoint expectedHeadId: string | null writer: SandboxCheckpointWriter }): Promise<{ headId: string }> { const checkpoint = copy(input.checkpoint) const { expectedHeadId, writer } = input assertValidIdentifier(checkpoint.threadId, 'Checkpoint thread id') assertValidIdentifier(writer.threadId, 'Writer thread id') assertValidIdentifier(checkpoint.id, 'Checkpoint id') if (expectedHeadId !== null) { assertValidIdentifier(expectedHeadId, 'Expected head id') } if (checkpoint.parentCheckpointId != null) { assertValidIdentifier( checkpoint.parentCheckpointId, 'Parent checkpoint id', ) } if (writer.threadId !== checkpoint.threadId) { throw new SandboxCheckpointWriterLostError( 'Checkpoint writer thread does not match checkpoint thread', ) } if (typeof checkpoint.id !== 'string' || checkpoint.id.length === 0) { throw new SandboxCheckpointInvalidIdError( 'Checkpoint id must be non-empty', ) } if (hasUnpairedSurrogate(checkpoint.id)) { throw new SandboxCheckpointInvalidIdError( 'Checkpoint id must contain valid Unicode', ) } if (hasUnpairedSurrogate(checkpoint.threadId)) { throw new SandboxCheckpointInvalidIdError( 'Checkpoint thread id must contain valid Unicode', ) } if ( typeof checkpoint.createdAt !== 'number' || !Number.isFinite(checkpoint.createdAt) ) { throw new SandboxCheckpointInvalidEntryError( 'Checkpoint createdAt must be a finite number', ) } if (expectedHeadId === '') { throw new SandboxCheckpointInvalidIdError( 'Expected head id must be null or non-empty', ) } const parentCheckpointId = checkpoint.parentCheckpointId ?? null if (parentCheckpointId === '') { throw new SandboxCheckpointInvalidIdError( 'Parent checkpoint id must be null or non-empty', ) } if (expectedHeadId !== null && hasUnpairedSurrogate(expectedHeadId)) { throw new SandboxCheckpointInvalidIdError( 'Expected head id must contain valid Unicode', ) } if ( parentCheckpointId !== null && hasUnpairedSurrogate(parentCheckpointId) ) { throw new SandboxCheckpointInvalidIdError( 'Parent checkpoint id must contain valid Unicode', ) } validateEntries(checkpoint) validateArtifacts(checkpoint) // The caller can re-enter append while its checkpoint is staged. Recheck // live writer and CAS state immediately before publishing. this.assertWriter(writer, checkpoint.threadId) if (this.state.checkpoints.has(checkpoint.id)) { throw new SandboxCheckpointDuplicateIdError( `Checkpoint '${checkpoint.id}' already exists`, ) } const actualHeadId = this.state.heads.get(checkpoint.threadId) ?? null if (actualHeadId !== expectedHeadId) { throw new SandboxCheckpointConflictError( `Expected head '${expectedHeadId}', but thread '${checkpoint.threadId}' is at '${actualHeadId}'`, ) } if (parentCheckpointId !== expectedHeadId) { throw new SandboxCheckpointParentMismatchError( `Checkpoint '${checkpoint.id}' parent does not match expected head`, ) } const stored = { ...checkpoint, parentCheckpointId } const keys = blobKeys(stored) this.state.checkpoints.set(stored.id, stored) this.state.heads.set(stored.threadId, stored.id) for (const key of keys) { this.state.references.set(key, (this.state.references.get(key) ?? 0) + 1) } return { headId: stored.id } } async deleteHead(input: { threadId: string checkpointId: string writer: SandboxCheckpointWriter }): Promise<void> { const { threadId, checkpointId, writer } = input assertValidIdentifier(threadId, 'Thread id') assertValidIdentifier(checkpointId, 'Checkpoint id') assertValidIdentifier(writer.threadId, 'Writer thread id') if (writer.threadId !== threadId) { throw new SandboxCheckpointWriterLostError( 'Checkpoint writer thread does not match operation thread', ) } this.assertWriter(writer, threadId) const headId = this.state.heads.get(threadId) ?? null if (headId !== checkpointId) { throw new SandboxCheckpointNotHeadError( `Checkpoint '${checkpointId}' is not the current head of thread '${threadId}'`, ) } const checkpoint = this.state.checkpoints.get(checkpointId) if (!checkpoint) { throw new SandboxCheckpointNotHeadError( `Checkpoint '${checkpointId}' does not exist`, ) } this.state.checkpoints.delete(checkpointId) if (checkpoint.parentCheckpointId) { this.state.heads.set(threadId, checkpoint.parentCheckpointId) } else { this.state.heads.delete(threadId) } for (const key of blobKeys(checkpoint)) { const references = (this.state.references.get(key) ?? 0) - 1 if (references > 0) this.state.references.set(key, references) else this.state.references.delete(key) } } async acquireWriter(threadId: string): Promise<SandboxCheckpointWriterLease> { assertValidIdentifier(threadId, 'Thread id') const current = this.state.writers.get(threadId) if (current && current.expiresAt > this.now()) { throw new SandboxCheckpointWriterConflictError( `Thread '${threadId}' already has an active checkpoint writer`, ) } const fence = (this.state.fences.get(threadId) ?? 0) + 1 this.state.fences.set(threadId, fence) const ownerToken = globalThis.crypto.randomUUID() const lease = { threadId, ownerToken, fence, expiresAt: this.now() + this.leaseDurationMs, } this.state.writers.set(threadId, lease) return { ...lease, get expiresAt() { return lease.expiresAt }, renewAfterMs: this.renewAfterMs, renew: async () => { this.assertWriter(lease, threadId) lease.expiresAt = this.now() + this.leaseDurationMs return { expiresAt: lease.expiresAt } }, release: async () => { const currentLease = this.state.writers.get(threadId) if ( currentLease?.ownerToken === ownerToken && currentLease.fence === fence ) this.state.writers.delete(threadId) }, } } private assertWriter( writer: SandboxCheckpointWriter, threadId: string, ): void { const current = this.state.writers.get(threadId) if ( !current || current.ownerToken !== writer.ownerToken || current.fence !== writer.fence || current.expiresAt <= this.now() ) { throw new SandboxCheckpointWriterLostError( `Checkpoint writer lease for thread '${threadId}' is no longer current`, ) } } async listBlobReferences(): Promise< Array<{ key: string; references: number }> > { return Array.from(this.state.references.entries()) .sort(([a], [b]) => compareUtf8Bytes(a, b)) .map(([key, references]) => ({ key, references })) } }