From ee91118b90fad0d969beb4f5f315d9aca39bd181 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Tue, 30 Jun 2026 19:55:35 +0000 Subject: [PATCH 1/6] feat(ai-sandbox-sprites): add Sprites (sprites.dev) sandbox provider Add @tanstack/ai-sandbox-sprites, a SandboxProvider/SandboxHandle implementation backed by Sprites (sprites.dev, Fly.io) cloud sandboxes, following the @tanstack/ai-sandbox-daytona / -vercel shape. - Dependency-free client (Sprites REST + exec control WebSocket); no SDK. - exec with separate stdout/stderr, background spawn, native /fs I/O, exec-backed git, env injection, durable filesystem, resume-by-id. - ports.connect() exposes the Sprite's single proxied public-URL port (default 8080), switching the URL to public auth. - Hardened exec lifecycle: abnormal-close surfaces an error instead of a false exit 0; control-message parses drained before reading the exit code; exec URL (cmd/env) stripped from connection errors. - Unit tests (fake client) + gated live tests against api.sprites.dev; docs/sandbox/providers.md entry; changeset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/sprites-sandbox-provider.md | 5 + docs/sandbox/providers.md | 23 + packages/ai-sandbox-sprites/CHANGELOG.md | 1 + packages/ai-sandbox-sprites/package.json | 52 ++ packages/ai-sandbox-sprites/src/client.ts | 450 ++++++++++++++++++ packages/ai-sandbox-sprites/src/handle.ts | 241 ++++++++++ packages/ai-sandbox-sprites/src/index.ts | 14 + packages/ai-sandbox-sprites/src/provider.ts | 134 ++++++ .../ai-sandbox-sprites/tests/handle.test.ts | 183 +++++++ .../ai-sandbox-sprites/tests/sprites.test.ts | 89 ++++ packages/ai-sandbox-sprites/tsconfig.json | 8 + packages/ai-sandbox-sprites/vite.config.ts | 37 ++ packages/ai-sandbox/README.md | 1 + pnpm-lock.yaml | 9 + 14 files changed, 1247 insertions(+) create mode 100644 .changeset/sprites-sandbox-provider.md create mode 100644 packages/ai-sandbox-sprites/CHANGELOG.md create mode 100644 packages/ai-sandbox-sprites/package.json create mode 100644 packages/ai-sandbox-sprites/src/client.ts create mode 100644 packages/ai-sandbox-sprites/src/handle.ts create mode 100644 packages/ai-sandbox-sprites/src/index.ts create mode 100644 packages/ai-sandbox-sprites/src/provider.ts create mode 100644 packages/ai-sandbox-sprites/tests/handle.test.ts create mode 100644 packages/ai-sandbox-sprites/tests/sprites.test.ts create mode 100644 packages/ai-sandbox-sprites/tsconfig.json create mode 100644 packages/ai-sandbox-sprites/vite.config.ts diff --git a/.changeset/sprites-sandbox-provider.md b/.changeset/sprites-sandbox-provider.md new file mode 100644 index 000000000..06ad1b676 --- /dev/null +++ b/.changeset/sprites-sandbox-provider.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-sprites': minor +--- + +Add `@tanstack/ai-sandbox-sprites`: a Sprites ([sprites.dev](https://sprites.dev), Fly.io) cloud sandbox provider implementing the `SandboxProvider` / `SandboxHandle` contract. Supports exec (with separate stdout/stderr), background processes, native filesystem I/O, exec-backed git, env injection, durable filesystem, and resume-by-id. `ports.connect()` exposes the Sprite's single proxied public-URL port. Dependency-free (REST + WebSocket); needs `SPRITES_API_KEY`. diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index 24317a485..2e5f58872 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -24,6 +24,7 @@ same. | Docker | `@tanstack/ai-sandbox-docker` | container | Real isolation; commit-based snapshots, fork, resume-by-id. | | Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; port preview links, resume-by-id. Needs `DAYTONA_API_KEY`. | | Vercel | `@tanstack/ai-sandbox-vercel` | microVM | Managed [Vercel Sandbox](https://vercel.com/docs/sandbox) microVMs; exposed-port domains, resume-by-id (persistent). Needs `VERCEL_TOKEN` + team/project. | +| Sprites | `@tanstack/ai-sandbox-sprites` | cloud sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. | Each provider is its own package, and the constructor is the only thing that differs between them: @@ -132,6 +133,28 @@ const vercel = vercelSandbox({ runtime: 'node24' }) - **Bridge:** like Daytona, a remote VM — bridged tools need the tunnel in local dev (see [tools](./tools)). +## Sprites + +```ts +import { spritesSandbox } from '@tanstack/ai-sandbox-sprites' + +const sprites = spritesSandbox({ apiKey: process.env.SPRITES_API_KEY }) +``` + +- **Isolation:** a managed [Sprites](https://sprites.dev) cloud sandbox (Fly.io) — + a remote, stateful VM you don't run yourself. +- **Auth / env:** needs `SPRITES_API_KEY` (token form + `org/projectNumber/tokenId/secret`); override the control-plane URL with + `apiUrl` / `SPRITES_API_URL`. Harness credentials are injected as workspace + secrets. +- **Snapshot / resume:** no snapshots; resume-by-id reconnects to the named + Sprite (its filesystem is durable across idle suspend/resume). +- **Ports:** a Sprite proxies a single internal HTTP port (default `8080`, + configurable via `httpPort`) to its always-on public URL. `ports.connect(8080)` + switches the URL to `public` auth and returns it; other ports are not exposed. +- **Bridge:** like Daytona/Vercel, a remote VM — bridged tools need the tunnel in + local dev (see [tools](./tools)). + ## Capabilities Providers declare what they support via `capabilities()`. The flags are: diff --git a/packages/ai-sandbox-sprites/CHANGELOG.md b/packages/ai-sandbox-sprites/CHANGELOG.md new file mode 100644 index 000000000..790495454 --- /dev/null +++ b/packages/ai-sandbox-sprites/CHANGELOG.md @@ -0,0 +1 @@ +# @tanstack/ai-sandbox-sprites diff --git a/packages/ai-sandbox-sprites/package.json b/packages/ai-sandbox-sprites/package.json new file mode 100644 index 000000000..5721b25f8 --- /dev/null +++ b/packages/ai-sandbox-sprites/package.json @@ -0,0 +1,52 @@ +{ + "name": "@tanstack/ai-sandbox-sprites", + "version": "0.1.0", + "description": "Sprites (sprites.dev) sandbox provider for TanStack AI — run harness adapters inside isolated Fly.io Sprite cloud sandboxes through the uniform SandboxHandle.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-sandbox-sprites" + }, + "keywords": [ + "ai", + "tanstack", + "sandbox", + "sprites", + "fly", + "harness", + "agent", + "isolation" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "peerDependencies": { + "@tanstack/ai-sandbox": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai-sandbox": "workspace:*", + "@vitest/coverage-v8": "4.0.14" + } +} diff --git a/packages/ai-sandbox-sprites/src/client.ts b/packages/ai-sandbox-sprites/src/client.ts new file mode 100644 index 000000000..e0586472a --- /dev/null +++ b/packages/ai-sandbox-sprites/src/client.ts @@ -0,0 +1,450 @@ +/** + * Thin client over the Sprites ([sprites.dev](https://sprites.dev)) control + * plane. Sprites has no published SDK, so this talks the REST + WebSocket API + * directly: lifecycle (create/get/delete), URL auth, filesystem, and process + * execution all go through the authenticated cloud endpoint at `baseUrl`. + * + * Dependency-free: uses the Node ≥ 20 global `fetch` and `WebSocket` (undici). + * The exec control socket needs an `Authorization` header on the upgrade + * request — supported via undici's non-standard `headers` constructor option, + * which the WHATWG `WebSocket` spec does not define — so this targets the Node + * runtime, not spec-compliant `WebSocket` environments (browsers, Deno, edge). + */ + +export const SPRITES_DEFAULT_BASE_URL = 'https://api.sprites.dev' + +/** URL authentication mode for a Sprite's always-on public URL. */ +export type SpriteUrlAuth = 'public' | 'sprite' + +/** A Sprite as returned by the control-plane API. */ +export interface SpriteResource { + id: string + name: string + status: string + /** Public URL, e.g. `https://-.sprites.app`. */ + url: string + urlAuth?: SpriteUrlAuth +} + +/** One entry returned by {@link SpritesClient.fsList}. */ +export interface SpriteFsEntry { + name: string + path: string + type: 'file' | 'dir' +} + +/** Options for {@link SpritesClient.exec}. */ +export interface SpritesExecOptions { + /** Argument vector; `argv[0]` is the executable. */ + argv: Array + /** Working directory; defaults to the Sprite login dir when omitted. */ + cwd?: string + /** Extra environment variables, merged over the Sprite defaults. */ + env?: Record + signal?: AbortSignal +} + +/** A live exec stream over the control WebSocket. */ +export interface SpritesExecStream { + stdout: AsyncIterable + stderr: AsyncIterable + /** Resolves with the exit code, or rejects on an abnormal close / abort. */ + wait: () => Promise + kill: () => Promise +} + +/** The subset of the client the {@link import('./handle').SpritesHandle} needs. */ +export interface SpritesClientLike { + readonly baseUrl: string + getSprite: (name: string, signal?: AbortSignal) => Promise + deleteSprite: (name: string, signal?: AbortSignal) => Promise + setUrlAuth: ( + name: string, + auth: SpriteUrlAuth, + signal?: AbortSignal, + ) => Promise + fsRead: (name: string, path: string) => Promise + fsWrite: (name: string, path: string, data: Uint8Array) => Promise + fsList: (name: string, path: string) => Promise> + exec: (name: string, options: SpritesExecOptions) => SpritesExecStream +} + +const WS_FRAME_STDOUT = 0x01 +const WS_FRAME_STDERR = 0x02 +const WS_FRAME_EXIT = 0x03 + +/** Constructor shape for the undici `WebSocket` with the `headers` option. */ +type WsCtor = new ( + url: string, + options: { headers: Record }, +) => WebSocket + +/** + * A push-driven async iterable of decoded chunks. The producer pushes and calls + * `end()` once; consumers `for await` and terminate cleanly. + */ +class AsyncChunkQueue implements AsyncIterable { + private readonly chunks: Array = [] + private readonly waiters: Array<(r: IteratorResult) => void> = [] + private ended = false + + push(chunk: string): void { + if (chunk === '') return + const waiter = this.waiters.shift() + if (waiter) waiter({ value: chunk, done: false }) + else this.chunks.push(chunk) + } + + end(): void { + this.ended = true + let waiter = this.waiters.shift() + while (waiter) { + waiter({ value: undefined, done: true }) + waiter = this.waiters.shift() + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const chunk = this.chunks.shift() + if (chunk !== undefined) { + return Promise.resolve({ value: chunk, done: false }) + } + if (this.ended) { + return Promise.resolve({ value: undefined, done: true }) + } + return new Promise((resolve) => this.waiters.push(resolve)) + }, + } + } +} + +export interface SpritesClientConfig { + apiKey: string + baseUrl?: string +} + +export class SpritesClient implements SpritesClientLike { + readonly baseUrl: string + private readonly apiKey: string + + constructor(config: SpritesClientConfig) { + this.apiKey = config.apiKey + this.baseUrl = (config.baseUrl ?? SPRITES_DEFAULT_BASE_URL).replace( + /\/+$/, + '', + ) + } + + private headers(extra?: Record): Record { + return { authorization: `Bearer ${this.apiKey}`, ...extra } + } + + private spritePath(name: string, suffix = ''): string { + return `${this.baseUrl}/v1/sprites/${encodeURIComponent(name)}${suffix}` + } + + async createSprite( + name: string, + options: { waitForCapacity?: boolean; signal?: AbortSignal } = {}, + ): Promise { + const response = await fetch(`${this.baseUrl}/v1/sprites`, { + method: 'POST', + headers: this.headers({ 'content-type': 'application/json' }), + body: JSON.stringify({ + name, + ...(options.waitForCapacity !== undefined + ? { wait_for_capacity: options.waitForCapacity } + : {}), + }), + ...(options.signal ? { signal: options.signal } : {}), + }) + if (!response.ok) { + await this.fail('POST', `${this.baseUrl}/v1/sprites`, response) + } + return parseSprite(await response.text()) + } + + async getSprite(name: string, signal?: AbortSignal): Promise { + const response = await fetch(this.spritePath(name), { + method: 'GET', + headers: this.headers(), + ...(signal ? { signal } : {}), + }) + if (!response.ok) await this.fail('GET', this.spritePath(name), response) + return parseSprite(await response.text()) + } + + async deleteSprite(name: string, signal?: AbortSignal): Promise { + const response = await fetch(this.spritePath(name), { + method: 'DELETE', + headers: this.headers(), + ...(signal ? { signal } : {}), + }) + // A missing Sprite is already deleted. + if (!response.ok && response.status !== 404) { + await this.fail('DELETE', this.spritePath(name), response) + } + await response.body?.cancel() + } + + async setUrlAuth( + name: string, + auth: SpriteUrlAuth, + signal?: AbortSignal, + ): Promise { + const response = await fetch(this.spritePath(name), { + method: 'PUT', + headers: this.headers({ 'content-type': 'application/json' }), + body: JSON.stringify({ url_settings: { auth } }), + ...(signal ? { signal } : {}), + }) + if (!response.ok) await this.fail('PUT', this.spritePath(name), response) + await response.body?.cancel() + } + + async fsRead(name: string, path: string): Promise { + const url = this.spritePath(name, `/fs/read?path=${encodeURIComponent(path)}`) + const response = await fetch(url, { method: 'GET', headers: this.headers() }) + if (!response.ok) await this.fail('GET', url, response) + return new Uint8Array(await response.arrayBuffer()) + } + + async fsWrite(name: string, path: string, data: Uint8Array): Promise { + const url = this.spritePath( + name, + `/fs/write?path=${encodeURIComponent(path)}`, + ) + // Copy into a fresh ArrayBuffer-backed view so the body is a plain BodyInit. + const response = await fetch(url, { + method: 'PUT', + headers: this.headers({ 'content-type': 'application/octet-stream' }), + body: data.slice(), + }) + if (!response.ok) await this.fail('PUT', url, response) + await response.body?.cancel() + } + + async fsList(name: string, path: string): Promise> { + const url = this.spritePath(name, `/fs/list?path=${encodeURIComponent(path)}`) + const response = await fetch(url, { method: 'GET', headers: this.headers() }) + if (!response.ok) await this.fail('GET', url, response) + const body = (await response.json()) as { + entries?: Array<{ name?: unknown; path?: unknown; isDir?: unknown }> + } + return (body.entries ?? []).map((entry) => ({ + name: String(entry.name ?? ''), + path: String(entry.path ?? ''), + type: entry.isDir === true ? ('dir' as const) : ('file' as const), + })) + } + + private async killSession(name: string, sessionId: string): Promise { + const response = await fetch( + this.spritePath(name, `/exec/${encodeURIComponent(sessionId)}/kill`), + { method: 'POST', headers: this.headers() }, + ).catch(() => undefined) + await response?.body?.cancel() + } + + exec(name: string, options: SpritesExecOptions): SpritesExecStream { + const query = new URLSearchParams() + for (const arg of options.argv) query.append('cmd', arg) + if (options.cwd !== undefined) query.set('dir', options.cwd) + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + query.append('env', `${key}=${value}`) + } + } + + const wsBase = this.baseUrl.replace(/^http(s?):\/\//, 'ws$1://') + const url = `${wsBase}/v1/sprites/${encodeURIComponent(name)}/exec?${query.toString()}` + // The query carries cmd/env (possibly secrets); never surface it in errors. + const safeUrl = `${wsBase}/v1/sprites/${encodeURIComponent(name)}/exec` + + const stdoutQ = new AsyncChunkQueue() + const stderrQ = new AsyncChunkQueue() + const outDecoder = new TextDecoder() + const errDecoder = new TextDecoder() + + let sessionId: string | undefined + let exitCode: number | undefined + let exitObserved = false + let settled = false + let socketError: Error | undefined + const pendingParses: Array> = [] + let onAbort: (() => void) | undefined + let resolveClosed!: () => void + const closed = new Promise((resolve) => { + resolveClosed = resolve + }) + + // The global (undici) WebSocket accepts a `headers` constructor option at + // runtime, but the WHATWG type only declares `(url, protocols?)`, so the two + // constructor signatures don't structurally overlap — bridge via `unknown`. + // eslint-disable-next-line no-restricted-syntax -- undici headers option not in the DOM WebSocket type + const WebSocketCtor = WebSocket as unknown as WsCtor + const ws = new WebSocketCtor(url, { headers: this.headers() }) + ws.binaryType = 'arraybuffer' + + const finish = (): void => { + if (settled) return + settled = true + if (onAbort && options.signal) { + options.signal.removeEventListener('abort', onAbort) + } + stdoutQ.push(outDecoder.decode()) + stderrQ.push(errDecoder.decode()) + stdoutQ.end() + stderrQ.end() + resolveClosed() + } + + ws.addEventListener('message', (event: MessageEvent) => { + const data: unknown = event.data + if (typeof data === 'string') { + pendingParses.push( + parseJson(data).then((message) => { + if (message === undefined) return + if (message.type === 'session_info') { + if (typeof message.session_id === 'string') { + sessionId = message.session_id + } + } else if (message.type === 'exit') { + if (typeof message.exit_code === 'number') { + exitCode = message.exit_code + exitObserved = true + } + } + }), + ) + return + } + if (data instanceof ArrayBuffer && data.byteLength > 0) { + const bytes = new Uint8Array(data) + const kind = bytes[0] + const payload = bytes.subarray(1) + if (kind === WS_FRAME_STDOUT) { + stdoutQ.push(outDecoder.decode(payload, { stream: true })) + } else if (kind === WS_FRAME_STDERR) { + stderrQ.push(errDecoder.decode(payload, { stream: true })) + } else if (kind === WS_FRAME_EXIT) { + exitObserved = true + exitCode = payload[0] ?? 0 + } + } + }) + + ws.addEventListener('error', (event: Event) => { + const message = (event as Partial).message + socketError ??= new Error( + `Sprites exec WebSocket error for ${safeUrl}: ${message ?? 'unknown error'}`, + ) + }) + + ws.addEventListener('close', () => finish()) + + const kill = async (): Promise => { + await Promise.allSettled(pendingParses) + if (sessionId !== undefined) await this.killSession(name, sessionId) + try { + ws.close() + } catch { + // already closing/closed + } + } + + if (options.signal) { + onAbort = (): void => { + void kill() + } + if (options.signal.aborted) onAbort() + else options.signal.addEventListener('abort', onAbort) + } + + return { + stdout: stdoutQ, + stderr: stderrQ, + wait: async (): Promise => { + await closed + await Promise.allSettled(pendingParses) + if (exitObserved) return exitCode ?? 0 + if (options.signal?.aborted) { + throw options.signal.reason instanceof Error + ? options.signal.reason + : new Error('Sprites exec aborted') + } + // Closed without an exit: a dropped/abnormal connection. Surface it + // rather than masquerading as a successful exit 0. + throw ( + socketError ?? + new Error( + `Sprites exec connection closed before the process reported an exit code (${safeUrl}).`, + ) + ) + }, + kill, + } + } + + private async fail( + method: string, + url: string, + response: Response, + ): Promise { + const body = await response.text().catch(() => '') + throw new Error( + `Sprites API ${method} ${url} failed: ${response.status} ${response.statusText}${ + body ? ` — ${body}` : '' + }`, + ) + } +} + +interface ExecControlMessage { + type?: string + session_id?: unknown + exit_code?: unknown +} + +function parseJson(text: string): Promise { + return Promise.resolve().then(() => { + try { + return JSON.parse(text) as ExecControlMessage + } catch { + return undefined + } + }) +} + +function parseSprite(text: string): SpriteResource { + let value: unknown + try { + value = JSON.parse(text) + } catch { + throw new Error(`Sprites API returned a non-JSON response: ${text}`) + } + const record = value as { + id?: unknown + name?: unknown + status?: unknown + url?: unknown + url_settings?: { auth?: unknown } + } + if ( + typeof record.id !== 'string' || + typeof record.name !== 'string' || + typeof record.url !== 'string' + ) { + throw new Error(`Sprites API returned an unexpected sprite shape: ${text}`) + } + const auth = record.url_settings?.auth + return { + id: record.id, + name: record.name, + status: typeof record.status === 'string' ? record.status : 'unknown', + url: record.url, + ...(auth === 'public' || auth === 'sprite' ? { urlAuth: auth } : {}), + } +} diff --git a/packages/ai-sandbox-sprites/src/handle.ts b/packages/ai-sandbox-sprites/src/handle.ts new file mode 100644 index 000000000..157217061 --- /dev/null +++ b/packages/ai-sandbox-sprites/src/handle.ts @@ -0,0 +1,241 @@ +/** + * SandboxHandle backed by a Sprites ([sprites.dev](https://sprites.dev)) cloud + * sandbox. Real isolation: fs/exec/git operate inside the remote Sprite; paths + * are real Sprite paths (default workdir `/home/sprite`). + * + * Filesystem data ops (read/write/list) use the Sprite's native `/fs` endpoints; + * metadata ops (mkdir/remove/rename/exists) desugar to `exec`. Commands run over + * the Sprite exec control WebSocket, which streams stdout and stderr separately + * — except for near-instant commands, where the Sprite agent's "fast path" + * replays buffered output as a single stdout stream (stderr content folds into + * stdout; the exit code is preserved). + */ +import { + UnsupportedCapabilityError, + createExecBackedGit, +} from '@tanstack/ai-sandbox' +import type { + ExecResult, + ProcessOptions, + SandboxCapabilities, + SandboxChannel, + SandboxHandle, + SpawnHandle, +} from '@tanstack/ai-sandbox' +import type { SpritesClientLike } from './client' + +export const SPRITES_CAPS: SandboxCapabilities = { + fs: true, + exec: true, + env: true, + ports: true, + backgroundProcesses: true, + // The Sprite exec socket streams output but does not expose a host→process + // stdin channel here, so adapters that feed a prompt over stdin must deliver + // it via a file + shell redirection instead. + writableStdin: false, + snapshots: false, + networkPolicy: false, + // The Sprite filesystem persists for the sandbox's lifetime (across exec + // calls and idle suspend/resume) until it is deleted. + durableFilesystem: true, + fork: false, +} + +/** The single internal HTTP port a Sprite proxies to its public URL. */ +export const SPRITE_DEFAULT_HTTP_PORT = 8080 + +async function collect(stream: AsyncIterable): Promise { + let out = '' + for await (const chunk of stream) out += chunk + return out +} + +export interface SpritesHandleDeps { + client: SpritesClientLike + /** Sprite name — the durable id used to reconnect/destroy. */ + name: string + /** Public URL of the Sprite (`https://-.sprites.app`). */ + url: string + /** Working directory inside the Sprite; the `/workspace` virtual root maps here. */ + workdir: string + /** Internal port proxied to the public URL. Defaults to 8080. */ + httpPort?: number +} + +export class SpritesHandle implements SandboxHandle { + readonly id: string + readonly provider = 'sprites' + readonly workspaceRoot: string + readonly capabilities = SPRITES_CAPS + readonly fs: SandboxHandle['fs'] + readonly git: SandboxHandle['git'] + readonly process: SandboxHandle['process'] + readonly ports: SandboxHandle['ports'] + readonly env: SandboxHandle['env'] + + private readonly client: SpritesClientLike + private readonly name: string + private readonly url: string + private readonly workdir: string + private readonly httpPort: number + private readonly envVars: Record = {} + + constructor(deps: SpritesHandleDeps) { + this.client = deps.client + this.name = deps.name + this.url = deps.url + this.workdir = deps.workdir + this.workspaceRoot = deps.workdir + this.httpPort = deps.httpPort ?? SPRITE_DEFAULT_HTTP_PORT + this.id = deps.name + + this.process = { + exec: (command, opts) => this.exec(command, opts), + spawn: (command, opts) => this.spawnProcess(command, opts), + } + + this.fs = { + read: async (p) => + new TextDecoder().decode(await this.client.fsRead(this.name, this.abs(p))), + readBytes: (p) => this.client.fsRead(this.name, this.abs(p)), + write: (p, data) => + this.client.fsWrite( + this.name, + this.abs(p), + typeof data === 'string' ? new TextEncoder().encode(data) : data, + ), + list: async (p) => { + const entries = await this.client.fsList(this.name, this.abs(p)) + // Native paths are absolute Sprite paths; re-root them under the + // caller's virtual path so consumers stay provider-agnostic. + const base = p.replace(/\/$/, '') + return entries.map((entry) => ({ + name: entry.name, + path: `${base}/${entry.name}`, + type: entry.type, + })) + }, + mkdir: async (p) => { + const r = await this.exec(`mkdir -p ${q(this.abs(p))}`) + if (r.exitCode !== 0) throw new Error(`mkdir failed: ${r.stderr.trim()}`) + }, + remove: async (p) => { + const r = await this.exec(`rm -rf ${q(this.abs(p))}`) + if (r.exitCode !== 0) + throw new Error(`remove failed: ${r.stderr.trim()}`) + }, + rename: async (from, to) => { + const r = await this.exec( + `mv ${q(this.abs(from))} ${q(this.abs(to))}`, + ) + if (r.exitCode !== 0) + throw new Error(`rename failed: ${r.stderr.trim()}`) + }, + exists: async (p) => { + const r = await this.exec(`test -e ${q(this.abs(p))}`) + return r.exitCode === 0 + }, + } + + this.git = createExecBackedGit(this.process, this.workdir) + + this.ports = { + connect: (port) => this.connectPort(port), + } + + this.env = { + set: (vars) => { + Object.assign(this.envVars, vars) + return Promise.resolve() + }, + } + } + + /** Map the conventional `/workspace` virtual root to the Sprite workdir. */ + private abs(p: string): string { + if (this.workdir === '/workspace') return p + if (p === '/workspace') return this.workdir + if (p.startsWith('/workspace/')) + return `${this.workdir}/${p.slice('/workspace/'.length)}` + return p + } + + private mergedEnv(extra?: Record): Record { + return { ...this.envVars, ...extra } + } + + private async exec( + command: string, + opts?: ProcessOptions, + ): Promise { + const stream = this.client.exec(this.name, { + argv: ['bash', '-c', command], + cwd: opts?.cwd ? this.abs(opts.cwd) : this.workdir, + env: this.mergedEnv(opts?.env), + ...(opts?.signal ? { signal: opts.signal } : {}), + }) + const [stdout, stderr, exitCode] = await Promise.all([ + collect(stream.stdout), + collect(stream.stderr), + stream.wait(), + ]) + return { stdout, stderr, exitCode } + } + + private spawnProcess( + command: string, + opts?: ProcessOptions, + ): Promise { + const stream = this.client.exec(this.name, { + argv: ['bash', '-c', command], + cwd: opts?.cwd ? this.abs(opts.cwd) : this.workdir, + env: this.mergedEnv(opts?.env), + ...(opts?.signal ? { signal: opts.signal } : {}), + }) + return Promise.resolve({ + pid: -1, // Sprite exec sessions do not surface a host-visible pid. + stdout: stream.stdout, + stderr: stream.stderr, + stdin: { + write: () => + Promise.reject( + new Error( + 'sprites: background process stdin is not writable (see capabilities.writableStdin)', + ), + ), + end: () => Promise.resolve(), + }, + wait: () => stream.wait(), + kill: () => stream.kill(), + }) + } + + private async connectPort(port: number): Promise { + if (port !== this.httpPort) { + throw new Error( + `sprites: only the proxied HTTP port ${this.httpPort} is reachable via the public URL; port ${port} is not exposed.`, + ) + } + // The public URL must be in `public` auth mode to be reachable without an + // org token (the analog of Daytona's signed preview URL). Idempotent. + await this.client.setUrlAuth(this.name, 'public') + return { url: this.url } + } + + // Sprites checkpoints/fork are not wired through the uniform handle yet. + snapshot = undefined + + fork = (): Promise => { + throw new UnsupportedCapabilityError('sprites', 'fork') + } + + async destroy(): Promise { + await this.client.deleteSprite(this.name) + } +} + +/** POSIX single-quote escape for embedding paths in `bash -c`. */ +function q(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} diff --git a/packages/ai-sandbox-sprites/src/index.ts b/packages/ai-sandbox-sprites/src/index.ts new file mode 100644 index 000000000..3980b7c87 --- /dev/null +++ b/packages/ai-sandbox-sprites/src/index.ts @@ -0,0 +1,14 @@ +export { spritesSandbox } from './provider' +export type { SpritesSandboxConfig } from './provider' +export { SpritesHandle, SPRITES_CAPS, SPRITE_DEFAULT_HTTP_PORT } from './handle' +export type { SpritesHandleDeps } from './handle' +export { SpritesClient, SPRITES_DEFAULT_BASE_URL } from './client' +export type { + SpritesClientConfig, + SpritesClientLike, + SpriteResource, + SpriteFsEntry, + SpriteUrlAuth, + SpritesExecOptions, + SpritesExecStream, +} from './client' diff --git a/packages/ai-sandbox-sprites/src/provider.ts b/packages/ai-sandbox-sprites/src/provider.ts new file mode 100644 index 000000000..d88c6edaa --- /dev/null +++ b/packages/ai-sandbox-sprites/src/provider.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto' +import { SpritesClient } from './client' +import { SPRITES_CAPS, SPRITE_DEFAULT_HTTP_PORT, SpritesHandle } from './handle' +import type { SpriteUrlAuth } from './client' +import type { + SandboxCapabilities, + SandboxCreateInput, + SandboxDestroyInput, + SandboxHandle, + SandboxProvider, + SandboxResumeInput, +} from '@tanstack/ai-sandbox' + +export interface SpritesSandboxConfig { + /** + * Sprites API token (`org/projectNumber/tokenId/secret`). Falls back to the + * `SPRITES_API_KEY` env var when omitted. + */ + apiKey?: string + /** + * Sprites control-plane base URL. Falls back to `SPRITES_API_URL`, then + * `https://api.sprites.dev`. + */ + apiUrl?: string + /** + * Working directory inside the Sprite. The `/workspace` virtual root maps + * here. Defaults to `/home/sprite`. + */ + workdir?: string + /** + * URL auth mode for created Sprites. `'public'` (default) makes the Sprite's + * URL reachable without an org token — required to reach a service via + * `ports.connect()`. Use `'sprite'` to keep it org-token gated. + */ + urlAuth?: SpriteUrlAuth + /** Internal port proxied to the public URL. Defaults to 8080. */ + httpPort?: number + /** Block on fleet capacity instead of failing fast when creating a Sprite. */ + waitForCapacity?: boolean +} + +const DEFAULT_WORKDIR = '/home/sprite' +const NAME_PREFIX = 'tanstack-ai' + +class SpritesProvider implements SandboxProvider { + readonly name = 'sprites' + private readonly client: SpritesClient + + constructor(private readonly config: SpritesSandboxConfig) { + const apiKey = config.apiKey ?? process.env.SPRITES_API_KEY + if (!apiKey) { + throw new Error( + 'Sprites API key is required. Pass `apiKey` or set the SPRITES_API_KEY environment variable.', + ) + } + const baseUrl = config.apiUrl ?? process.env.SPRITES_API_URL + this.client = new SpritesClient({ + apiKey, + ...(baseUrl ? { baseUrl } : {}), + }) + } + + capabilities(): SandboxCapabilities { + return SPRITES_CAPS + } + + private get workdir(): string { + return this.config.workdir ?? DEFAULT_WORKDIR + } + + private get httpPort(): number { + return this.config.httpPort ?? SPRITE_DEFAULT_HTTP_PORT + } + + private handle(sprite: { + name: string + url: string + }): SpritesHandle { + return new SpritesHandle({ + client: this.client, + name: sprite.name, + url: sprite.url, + workdir: this.workdir, + httpPort: this.httpPort, + }) + } + + async create(input: SandboxCreateInput): Promise { + const name = `${NAME_PREFIX}-${randomUUID().replace(/-/g, '').slice(0, 12)}` + const sprite = await this.client.createSprite(name, { + ...(this.config.waitForCapacity !== undefined + ? { waitForCapacity: this.config.waitForCapacity } + : {}), + ...(input.signal ? { signal: input.signal } : {}), + }) + + const urlAuth = this.config.urlAuth ?? 'public' + if (sprite.urlAuth !== urlAuth) { + await this.client.setUrlAuth(sprite.name, urlAuth, input.signal) + } + + // Ensure the workspace dir exists before any cwd-bound command runs in it. + const handle = this.handle(sprite) + await handle.fs.mkdir(this.workdir) + + if (input.env) await handle.env.set(input.env) + return handle + } + + async resume(input: SandboxResumeInput): Promise { + try { + const sprite = await this.client.getSprite(input.id, input.signal) + return this.handle(sprite) + } catch { + // Gone / not found. + return null + } + } + + async destroy(input: SandboxDestroyInput): Promise { + await this.client.deleteSprite(input.id, input.signal) + } +} + +/** + * Sprites sandbox provider — runs harness adapters inside isolated Fly.io + * Sprite cloud sandboxes ([sprites.dev](https://sprites.dev)). Requires a + * Sprites API token (`config.apiKey` or the `SPRITES_API_KEY` env var). + */ +export function spritesSandbox( + config: SpritesSandboxConfig = {}, +): SandboxProvider { + return new SpritesProvider(config) +} diff --git a/packages/ai-sandbox-sprites/tests/handle.test.ts b/packages/ai-sandbox-sprites/tests/handle.test.ts new file mode 100644 index 000000000..fd6161401 --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/handle.test.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/require-await -- trivial fixed-value fakes */ +import { describe, expect, it, vi } from 'vitest' +import { SpritesHandle } from '../src/handle' +import type { + SpriteFsEntry, + SpriteUrlAuth, + SpritesClientLike, + SpritesExecOptions, + SpritesExecStream, +} from '../src/client' + +/** An exec stream backed by fixed stdout/stderr/exit, for unit tests. */ +function fakeStream(opts: { + stdout?: string + stderr?: string + exit?: number +}): SpritesExecStream { + async function* one(value?: string): AsyncIterable { + if (value) yield value + } + return { + stdout: one(opts.stdout), + stderr: one(opts.stderr), + wait: () => Promise.resolve(opts.exit ?? 0), + kill: () => Promise.resolve(), + } +} + +interface FakeClientOptions { + files?: Record + entries?: Array + onExec?: (name: string, options: SpritesExecOptions) => SpritesExecStream +} + +function fakeClient(options: FakeClientOptions = {}): { + client: SpritesClientLike + setUrlAuth: ReturnType + deleteSprite: ReturnType + execCalls: Array +} { + const setUrlAuth = vi.fn( + async (_name: string, _auth: SpriteUrlAuth) => undefined, + ) + const deleteSprite = vi.fn(async (_name: string) => undefined) + const execCalls: Array = [] + const files = options.files ?? {} + + const client: SpritesClientLike = { + baseUrl: 'https://api.test', + getSprite: () => Promise.reject(new Error('not used')), + deleteSprite, + setUrlAuth, + fsRead: (_name, path) => { + const data = files[path] + if (!data) return Promise.reject(new Error(`ENOENT: ${path}`)) + return Promise.resolve(data) + }, + fsWrite: (_name, path, data) => { + files[path] = data + return Promise.resolve() + }, + fsList: () => Promise.resolve(options.entries ?? []), + exec: (name, execOptions) => { + execCalls.push(execOptions) + return (options.onExec ?? (() => fakeStream({ exit: 0 })))( + name, + execOptions, + ) + }, + } + return { client, setUrlAuth, deleteSprite, execCalls } +} + +function makeHandle(deps: Partial = {}) { + const fake = fakeClient(deps) + const handle = new SpritesHandle({ + client: fake.client, + name: 'my-sprite', + url: 'https://my-sprite-x.sprites.app', + workdir: '/home/sprite', + }) + return { handle, ...fake } +} + +describe('SpritesHandle.process.exec', () => { + it('collects stdout/stderr and the exit code, running argv via bash -c', async () => { + const { handle, execCalls } = makeHandle({ + onExec: () => fakeStream({ stdout: 'hi\n', stderr: 'warn\n', exit: 7 }), + }) + const result = await handle.process.exec('echo hi') + expect(result).toEqual({ stdout: 'hi\n', stderr: 'warn\n', exitCode: 7 }) + expect(execCalls[0]?.argv).toEqual(['bash', '-c', 'echo hi']) + // Defaults cwd to the workdir. + expect(execCalls[0]?.cwd).toBe('/home/sprite') + }) + + it('merges env from env.set() and per-call options', async () => { + const { handle, execCalls } = makeHandle({}) + await handle.env.set({ FOO: 'bar' }) + await handle.process.exec('env', { env: { BAZ: 'qux' }, cwd: '/workspace' }) + expect(execCalls[0]?.env).toEqual({ FOO: 'bar', BAZ: 'qux' }) + // /workspace maps to the workdir. + expect(execCalls[0]?.cwd).toBe('/home/sprite') + }) +}) + +describe('SpritesHandle.fs', () => { + it('round-trips text and bytes through the native fs endpoints', async () => { + const { handle } = makeHandle({}) + await handle.fs.write('/workspace/note.txt', 'hello') + expect(await handle.fs.read('/workspace/note.txt')).toBe('hello') + + const bytes = new Uint8Array([0, 1, 2, 250]) + await handle.fs.write('/workspace/bin', bytes) + expect(Array.from(await handle.fs.readBytes('/workspace/bin'))).toEqual([ + 0, 1, 2, 250, + ]) + }) + + it('exists() reflects the exec exit code', async () => { + const { handle } = makeHandle({ + onExec: (_n, o) => + // `test -e` → exit 0 when present, 1 when absent. Echo it via argv. + fakeStream({ exit: o.argv.join(' ').includes('present') ? 0 : 1 }), + }) + expect(await handle.fs.exists('/workspace/present')).toBe(true) + expect(await handle.fs.exists('/workspace/absent')).toBe(false) + }) + + it('lists entries re-rooted under the virtual path', async () => { + const { handle } = makeHandle({ + entries: [ + { name: 'a.txt', path: '/home/sprite/a.txt', type: 'file' }, + { name: 'sub', path: '/home/sprite/sub', type: 'dir' }, + ], + }) + const listed = await handle.fs.list('/workspace') + expect(listed).toEqual([ + { name: 'a.txt', path: '/workspace/a.txt', type: 'file' }, + { name: 'sub', path: '/workspace/sub', type: 'dir' }, + ]) + }) +}) + +describe('SpritesHandle.ports.connect', () => { + it('makes the URL public and returns it for the proxied port', async () => { + const { handle, setUrlAuth } = makeHandle({}) + const channel = await handle.ports.connect(8080) + expect(setUrlAuth).toHaveBeenCalledWith('my-sprite', 'public') + expect(channel).toEqual({ url: 'https://my-sprite-x.sprites.app' }) + }) + + it('rejects a non-proxied port', async () => { + const { handle } = makeHandle({}) + await expect(handle.ports.connect(3000)).rejects.toThrow(/8080/) + }) +}) + +describe('SpritesHandle lifecycle + capabilities', () => { + it('destroy() deletes the sprite', async () => { + const { handle, deleteSprite } = makeHandle({}) + await handle.destroy() + expect(deleteSprite).toHaveBeenCalledWith('my-sprite') + }) + + it('fork throws UnsupportedCapabilityError', async () => { + const { handle } = makeHandle({}) + expect(handle.capabilities.fork).toBe(false) + expect(() => handle.fork()).toThrow(/fork/) + }) + + it('exposes a spawn handle with non-writable stdin', async () => { + const { handle } = makeHandle({ + onExec: () => fakeStream({ stdout: 'streamed\n', exit: 0 }), + }) + const proc = await handle.process.spawn('echo streamed') + let out = '' + for await (const chunk of proc.stdout) out += chunk + expect(out).toBe('streamed\n') + expect(await proc.wait()).toBe(0) + await expect(proc.stdin.write('x')).rejects.toThrow(/not writable/) + }) +}) diff --git a/packages/ai-sandbox-sprites/tests/sprites.test.ts b/packages/ai-sandbox-sprites/tests/sprites.test.ts new file mode 100644 index 000000000..b65d61f09 --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/sprites.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { spritesSandbox } from '../src/index' +import type { SandboxHandle } from '@tanstack/ai-sandbox' + +// Auto-gate: only run when a Sprites API key is present (these tests create +// real cloud sandboxes and are billed). +const apiKey = process.env.SPRITES_API_KEY + +describe.skipIf(!apiKey)('sprites provider (gated on SPRITES_API_KEY)', () => { + it('creates a sandbox, runs exec, fs round-trip + destroy', async () => { + const provider = spritesSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + + const echo = await sbx.process.exec('echo hello-sprites') + expect(echo.stdout.trim()).toBe('hello-sprites') + expect(echo.exitCode).toBe(0) + + await sbx.fs.write('/workspace/note.txt', 'inside the sandbox') + expect(await sbx.fs.exists('/workspace/note.txt')).toBe(true) + expect(await sbx.fs.read('/workspace/note.txt')).toBe( + 'inside the sandbox', + ) + + const bytes = new Uint8Array([0, 1, 2, 250]) + await sbx.fs.write('/workspace/bin', bytes) + expect(Array.from(await sbx.fs.readBytes('/workspace/bin'))).toEqual([ + 0, 1, 2, 250, + ]) + + // env + cwd are honored. + const env = await sbx.process.exec('echo "$GREETING from $(pwd)"', { + env: { GREETING: 'hi' }, + cwd: '/workspace', + }) + expect(env.stdout.trim()).toBe('hi from /home/sprite') + } finally { + await sbx?.destroy() + } + }, 180_000) + + it('streams a spawned background process', async () => { + const provider = spritesSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + const proc = await sbx.process.spawn( + 'for i in 1 2 3; do echo line$i; done', + ) + let out = '' + for await (const chunk of proc.stdout) out += chunk + expect(out).toContain('line1') + expect(out).toContain('line3') + expect(await proc.wait()).toBe(0) + } finally { + await sbx?.destroy() + } + }, 180_000) + + it('exposes the proxied port as a reachable public URL', async () => { + const provider = spritesSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + // Serve a tiny HTTP response on the proxied port, then reach it publicly. + await sbx.fs.write( + '/home/sprite/serve.mjs', + "import http from 'node:http'; http.createServer((_q, s) => s.end('hello')).listen(8080, '0.0.0.0')", + ) + const server = await sbx.process.spawn('node /home/sprite/serve.mjs') + try { + const channel = await sbx.ports.connect(8080) + expect(channel.url).toMatch(/^https:\/\/.*\.sprites\.app\/?$/) + // Give the listener a moment, then fetch through the public proxy. + await new Promise((r) => setTimeout(r, 2000)) + const res = await fetch(channel.url, { + ...(channel.headers ? { headers: channel.headers } : {}), + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('hello') + } finally { + await server.kill() + } + } finally { + await sbx?.destroy() + } + }, 180_000) +}) diff --git a/packages/ai-sandbox-sprites/tsconfig.json b/packages/ai-sandbox-sprites/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-sandbox-sprites/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-sandbox-sprites/vite.config.ts b/packages/ai-sandbox-sprites/vite.config.ts new file mode 100644 index 000000000..11f5b20b7 --- /dev/null +++ b/packages/ai-sandbox-sprites/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-sandbox/README.md b/packages/ai-sandbox/README.md index 27f53f073..574f2eb75 100644 --- a/packages/ai-sandbox/README.md +++ b/packages/ai-sandbox/README.md @@ -52,6 +52,7 @@ Pick a **provider** package for where the sandbox runs: | `@tanstack/ai-sandbox-cloudflare` | Cloudflare Workers + Containers | | `@tanstack/ai-sandbox-vercel` | Vercel Sandbox | | `@tanstack/ai-sandbox-daytona` | Daytona dev environments | +| `@tanstack/ai-sandbox-sprites` | Sprites (sprites.dev) cloud sandboxes | **Harness adapters** are separate packages. The default path is **Grok Build** (`@tanstack/ai-grok-build`); others include `@tanstack/ai-claude-code`, `@tanstack/ai-codex`, and `@tanstack/ai-opencode`. All require `withSandbox(...)` middleware — `chat()` fails fast without it. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebff20d5a..c2abbdf71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2155,6 +2155,15 @@ importers: specifier: 4.0.14 version: 4.0.14(vitest@4.1.4) + packages/ai-sandbox-sprites: + devDependencies: + '@tanstack/ai-sandbox': + specifier: workspace:* + version: link:../ai-sandbox + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.1.4) + packages/ai-sandbox-vercel: dependencies: '@vercel/sandbox': From 27baa4926b1ea02f623dd955e345fd605c7f298d Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Tue, 30 Jun 2026 20:40:57 +0000 Subject: [PATCH 2/6] feat(ai-sandbox-sprites): add checkpoint (snapshot) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Sprite checkpoints through the SandboxHandle: - capabilities.snapshots = true - handle.snapshot(label?) creates a checkpoint, returns {id: "#vN"} - handle.listCheckpoints() / handle.restoreCheckpoint(idOrRef) for in-place restore (restarts the Sprite; readiness polled via a fetch probe bounded by an abort signal) - client.createCheckpoint/listCheckpoints/restoreCheckpoint over the REST API Restore is in-place and a checkpoint does not survive Sprite deletion, so the provider intentionally does not implement reconstruct-after-gone restoreSnapshot — the framework degrades to a fresh create when a Sprite is gone. Unit tests cover the wiring; the gated live test covers create+list (restore restarts the Sprite and can take minutes, so it's excluded from CI). Docs + changeset updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/sprites-sandbox-provider.md | 2 +- docs/sandbox/providers.md | 12 +- packages/ai-sandbox-sprites/src/client.ts | 153 ++++++++++++++++++ packages/ai-sandbox-sprites/src/handle.ts | 50 +++++- packages/ai-sandbox-sprites/src/index.ts | 1 + .../ai-sandbox-sprites/tests/handle.test.ts | 53 +++++- .../ai-sandbox-sprites/tests/sprites.test.ts | 22 +++ 7 files changed, 284 insertions(+), 9 deletions(-) diff --git a/.changeset/sprites-sandbox-provider.md b/.changeset/sprites-sandbox-provider.md index 06ad1b676..2c95262ab 100644 --- a/.changeset/sprites-sandbox-provider.md +++ b/.changeset/sprites-sandbox-provider.md @@ -2,4 +2,4 @@ '@tanstack/ai-sandbox-sprites': minor --- -Add `@tanstack/ai-sandbox-sprites`: a Sprites ([sprites.dev](https://sprites.dev), Fly.io) cloud sandbox provider implementing the `SandboxProvider` / `SandboxHandle` contract. Supports exec (with separate stdout/stderr), background processes, native filesystem I/O, exec-backed git, env injection, durable filesystem, and resume-by-id. `ports.connect()` exposes the Sprite's single proxied public-URL port. Dependency-free (REST + WebSocket); needs `SPRITES_API_KEY`. +Add `@tanstack/ai-sandbox-sprites`: a Sprites ([sprites.dev](https://sprites.dev), Fly.io) cloud sandbox provider implementing the `SandboxProvider` / `SandboxHandle` contract. Supports exec (with separate stdout/stderr), background processes, native filesystem I/O, exec-backed git, env injection, durable filesystem, resume-by-id, and checkpoints (`snapshot()` to create a save point; in-place `restoreCheckpoint()` / `listCheckpoints()` on the handle). `ports.connect()` exposes the Sprite's single proxied public-URL port. Dependency-free (REST + WebSocket); needs `SPRITES_API_KEY`. diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index 2e5f58872..1fca9258a 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -24,7 +24,7 @@ same. | Docker | `@tanstack/ai-sandbox-docker` | container | Real isolation; commit-based snapshots, fork, resume-by-id. | | Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; port preview links, resume-by-id. Needs `DAYTONA_API_KEY`. | | Vercel | `@tanstack/ai-sandbox-vercel` | microVM | Managed [Vercel Sandbox](https://vercel.com/docs/sandbox) microVMs; exposed-port domains, resume-by-id (persistent). Needs `VERCEL_TOKEN` + team/project. | -| Sprites | `@tanstack/ai-sandbox-sprites` | cloud sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. | +| Sprites | `@tanstack/ai-sandbox-sprites` | cloud sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, in-place checkpoints, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. | Each provider is its own package, and the constructor is the only thing that differs between them: @@ -147,8 +147,14 @@ const sprites = spritesSandbox({ apiKey: process.env.SPRITES_API_KEY }) `org/projectNumber/tokenId/secret`); override the control-plane URL with `apiUrl` / `SPRITES_API_URL`. Harness credentials are injected as workspace secrets. -- **Snapshot / resume:** no snapshots; resume-by-id reconnects to the named - Sprite (its filesystem is durable across idle suspend/resume). +- **Snapshot / resume:** resume-by-id reconnects to the named Sprite (its + filesystem is durable across idle suspend/resume). `snapshot()` creates a + Sprite **checkpoint** (a save point of the writable overlay); restore is + **in-place** on the same Sprite via the handle's `restoreCheckpoint()` / + `listCheckpoints()`. A checkpoint does not survive Sprite deletion, so the + provider intentionally does **not** implement the reconstruct-after-gone + `restoreSnapshot` — when a Sprite is gone the framework degrades to a fresh + create instead. Restore restarts the environment and can take minutes. - **Ports:** a Sprite proxies a single internal HTTP port (default `8080`, configurable via `httpPort`) to its always-on public URL. `ports.connect(8080)` switches the URL to `public` auth and returns it; other ports are not exposed. diff --git a/packages/ai-sandbox-sprites/src/client.ts b/packages/ai-sandbox-sprites/src/client.ts index e0586472a..b26d3ae00 100644 --- a/packages/ai-sandbox-sprites/src/client.ts +++ b/packages/ai-sandbox-sprites/src/client.ts @@ -33,6 +33,16 @@ export interface SpriteFsEntry { type: 'file' | 'dir' } +/** A Sprite checkpoint (filesystem-overlay save point). */ +export interface SpriteCheckpoint { + /** Sequential version id, e.g. `v3`. The live overlay lists as `Current`. */ + id: string + createTime?: string + comment?: string + /** `true` for platform-created automatic checkpoints. */ + isAuto: boolean +} + /** Options for {@link SpritesClient.exec}. */ export interface SpritesExecOptions { /** Argument vector; `argv[0]` is the executable. */ @@ -67,6 +77,19 @@ export interface SpritesClientLike { fsWrite: (name: string, path: string, data: Uint8Array) => Promise fsList: (name: string, path: string) => Promise> exec: (name: string, options: SpritesExecOptions) => SpritesExecStream + createCheckpoint: ( + name: string, + options?: { comment?: string; signal?: AbortSignal }, + ) => Promise + listCheckpoints: ( + name: string, + signal?: AbortSignal, + ) => Promise> + restoreCheckpoint: ( + name: string, + id: string, + options?: { signal?: AbortSignal; readyTimeoutMs?: number }, + ) => Promise } const WS_FRAME_STDOUT = 0x01 @@ -240,6 +263,136 @@ export class SpritesClient implements SpritesClientLike { })) } + async listCheckpoints( + name: string, + signal?: AbortSignal, + ): Promise> { + const url = this.spritePath(name, '/checkpoints') + const response = await fetch(url, { + method: 'GET', + headers: this.headers(), + ...(signal ? { signal } : {}), + }) + if (!response.ok) await this.fail('GET', url, response) + const body = (await response.json()) as Array<{ + id?: unknown + create_time?: unknown + comment?: unknown + is_auto?: unknown + }> + return body.map((entry) => ({ + id: String(entry.id ?? ''), + ...(typeof entry.create_time === 'string' + ? { createTime: entry.create_time } + : {}), + ...(typeof entry.comment === 'string' ? { comment: entry.comment } : {}), + isAuto: entry.is_auto === true, + })) + } + + /** + * Create a checkpoint and return its new version id (e.g. `v3`). The create + * endpoint streams NDJSON progress; we drain it to completion, then resolve + * the new id as the highest sequential `vN` checkpoint (autos and the live + * `Current` pointer are ignored). + */ + async createCheckpoint( + name: string, + options: { comment?: string; signal?: AbortSignal } = {}, + ): Promise { + const url = this.spritePath(name, '/checkpoint') + const response = await fetch(url, { + method: 'POST', + headers: this.headers({ 'content-type': 'application/json' }), + body: JSON.stringify( + options.comment !== undefined ? { comment: options.comment } : {}, + ), + ...(options.signal ? { signal: options.signal } : {}), + }) + if (!response.ok) await this.fail('POST', url, response) + // Drain the NDJSON progress stream so the checkpoint is committed before we + // read the list back. + await response.text() + + const versions = (await this.listCheckpoints(name, options.signal)) + .filter((c) => !c.isAuto) + .map((c) => /^v(\d+)$/.exec(c.id)) + .filter((m): m is RegExpExecArray => m !== null) + .map((m) => Number(m[1])) + if (versions.length === 0) { + throw new Error( + `Sprites: checkpoint created for "${name}" but no versioned checkpoint was found.`, + ) + } + return `v${Math.max(...versions)}` + } + + /** + * Restore a checkpoint in place. The Sprite's writable overlay is replaced and + * the environment restarts, so the agent is briefly unreachable. + * + * The restore endpoint streams progress but holds the connection open across + * the restart (it does not close cleanly), so we must NOT drain it — we cancel + * the body once the restore is accepted, then poll a trivial `exec` until the + * Sprite is ready again (or `readyTimeoutMs`, default 240s, elapses). Restart + * can take minutes; raise `readyTimeoutMs` for large overlays. + */ + async restoreCheckpoint( + name: string, + id: string, + options: { signal?: AbortSignal; readyTimeoutMs?: number } = {}, + ): Promise { + const url = this.spritePath( + name, + `/checkpoints/${encodeURIComponent(id)}/restore`, + ) + const response = await fetch(url, { + method: 'POST', + headers: this.headers(), + ...(options.signal ? { signal: options.signal } : {}), + }) + if (!response.ok) await this.fail('POST', url, response) + // The restore is server-side and asynchronous; the open NDJSON stream would + // block indefinitely, so release it and wait for readiness by polling. + await response.body?.cancel().catch(() => undefined) + // Let the restart begin before probing, so we don't get a stale success from + // the pre-restart agent. + await new Promise((resolve) => setTimeout(resolve, 3000)) + await this.waitUntilReady(name, options.readyTimeoutMs ?? 240_000) + } + + /** + * Poll a lightweight filesystem op until the Sprite agent serves it again, or + * time out. Uses `fetch` (not the exec WebSocket) so each attempt is reliably + * bounded by its abort signal — during a restart the socket can stall in + * CONNECTING without ever opening or closing. + */ + private async waitUntilReady(name: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + const url = this.spritePath(name, `/fs/list?path=${encodeURIComponent('/')}`) + let lastError: unknown + while (Date.now() < deadline) { + try { + const response = await fetch(url, { + method: 'GET', + headers: this.headers(), + signal: AbortSignal.timeout(8000), + }) + await response.body?.cancel() + if (response.ok) return + lastError = new Error(`readiness probe HTTP ${response.status}`) + } catch (error) { + lastError = error + } + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + throw new Error( + `Sprites: "${name}" did not become ready within ${timeoutMs}ms after restore${ + lastError instanceof Error ? ` (last error: ${lastError.message})` : '' + }.`, + ) + } + private async killSession(name: string, sessionId: string): Promise { const response = await fetch( this.spritePath(name, `/exec/${encodeURIComponent(sessionId)}/kill`), diff --git a/packages/ai-sandbox-sprites/src/handle.ts b/packages/ai-sandbox-sprites/src/handle.ts index 157217061..a303699de 100644 --- a/packages/ai-sandbox-sprites/src/handle.ts +++ b/packages/ai-sandbox-sprites/src/handle.ts @@ -9,6 +9,10 @@ * — except for near-instant commands, where the Sprite agent's "fast path" * replays buffered output as a single stdout stream (stderr content folds into * stdout; the exit code is preserved). + * + * Checkpoints (filesystem-overlay save points) are exposed via {@link snapshot} + * (create) and {@link restoreCheckpoint} / {@link listCheckpoints}. Restore is + * in-place and restarts the Sprite. */ import { UnsupportedCapabilityError, @@ -20,9 +24,10 @@ import type { SandboxCapabilities, SandboxChannel, SandboxHandle, + SnapshotRef, SpawnHandle, } from '@tanstack/ai-sandbox' -import type { SpritesClientLike } from './client' +import type { SpriteCheckpoint, SpritesClientLike } from './client' export const SPRITES_CAPS: SandboxCapabilities = { fs: true, @@ -34,7 +39,13 @@ export const SPRITES_CAPS: SandboxCapabilities = { // stdin channel here, so adapters that feed a prompt over stdin must deliver // it via a file + shell redirection instead. writableStdin: false, - snapshots: false, + // Sprites checkpoints capture the writable filesystem overlay. Exposed via + // `snapshot()` (create) and the provider-specific `restoreCheckpoint()` / + // `listCheckpoints()`. Note: restore is in-place on the same Sprite, and a + // checkpoint does not survive Sprite deletion — so `SandboxProvider`'s + // reconstruct-after-gone `restoreSnapshot` is intentionally not implemented + // (the framework degrades to a fresh create instead). + snapshots: true, networkPolicy: false, // The Sprite filesystem persists for the sandbox's lifetime (across exec // calls and idle suspend/resume) until it is deleted. @@ -223,8 +234,39 @@ export class SpritesHandle implements SandboxHandle { return { url: this.url } } - // Sprites checkpoints/fork are not wired through the uniform handle yet. - snapshot = undefined + /** + * Create a checkpoint of the Sprite's writable filesystem overlay. Returns a + * {@link SnapshotRef} whose `id` is `#` (e.g. + * `my-sprite#v3`) so it round-trips through {@link restoreCheckpoint}. + */ + async snapshot(label?: string): Promise { + const version = await this.client.createCheckpoint(this.name, { + ...(label !== undefined ? { comment: label } : {}), + }) + return { id: `${this.name}#${version}`, ...(label !== undefined ? { label } : {}) } + } + + /** List this Sprite's checkpoints (newest live overlay shows as `Current`). */ + listCheckpoints(): Promise> { + return this.client.listCheckpoints(this.name) + } + + /** + * Restore a checkpoint in place and wait for the Sprite to restart. Accepts a + * bare version (`v3`) or a {@link SnapshotRef} id (`#v3`). + * + * Restore is destructive: it replaces the current overlay. Take a + * {@link snapshot} first if you need to keep the present state. + */ + restoreCheckpoint( + idOrRef: string, + options?: { readyTimeoutMs?: number }, + ): Promise { + const version = idOrRef.includes('#') + ? idOrRef.slice(idOrRef.indexOf('#') + 1) + : idOrRef + return this.client.restoreCheckpoint(this.name, version, options) + } fork = (): Promise => { throw new UnsupportedCapabilityError('sprites', 'fork') diff --git a/packages/ai-sandbox-sprites/src/index.ts b/packages/ai-sandbox-sprites/src/index.ts index 3980b7c87..6109d2e19 100644 --- a/packages/ai-sandbox-sprites/src/index.ts +++ b/packages/ai-sandbox-sprites/src/index.ts @@ -8,6 +8,7 @@ export type { SpritesClientLike, SpriteResource, SpriteFsEntry, + SpriteCheckpoint, SpriteUrlAuth, SpritesExecOptions, SpritesExecStream, diff --git a/packages/ai-sandbox-sprites/tests/handle.test.ts b/packages/ai-sandbox-sprites/tests/handle.test.ts index fd6161401..375f3697c 100644 --- a/packages/ai-sandbox-sprites/tests/handle.test.ts +++ b/packages/ai-sandbox-sprites/tests/handle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { SpritesHandle } from '../src/handle' import type { + SpriteCheckpoint, SpriteFsEntry, SpriteUrlAuth, SpritesClientLike, @@ -29,6 +30,8 @@ function fakeStream(opts: { interface FakeClientOptions { files?: Record entries?: Array + checkpoints?: Array + newVersion?: string onExec?: (name: string, options: SpritesExecOptions) => SpritesExecStream } @@ -36,12 +39,21 @@ function fakeClient(options: FakeClientOptions = {}): { client: SpritesClientLike setUrlAuth: ReturnType deleteSprite: ReturnType + createCheckpoint: ReturnType + restoreCheckpoint: ReturnType execCalls: Array } { const setUrlAuth = vi.fn( async (_name: string, _auth: SpriteUrlAuth) => undefined, ) const deleteSprite = vi.fn(async (_name: string) => undefined) + const createCheckpoint = vi.fn( + async (_name: string, _opts?: { comment?: string }) => + options.newVersion ?? 'v1', + ) + const restoreCheckpoint = vi.fn( + async (_name: string, _id: string) => undefined, + ) const execCalls: Array = [] const files = options.files ?? {} @@ -67,8 +79,18 @@ function fakeClient(options: FakeClientOptions = {}): { execOptions, ) }, + createCheckpoint, + listCheckpoints: () => Promise.resolve(options.checkpoints ?? []), + restoreCheckpoint, + } + return { + client, + setUrlAuth, + deleteSprite, + createCheckpoint, + restoreCheckpoint, + execCalls, } - return { client, setUrlAuth, deleteSprite, execCalls } } function makeHandle(deps: Partial = {}) { @@ -156,6 +178,35 @@ describe('SpritesHandle.ports.connect', () => { }) }) +describe('SpritesHandle checkpoints', () => { + it('snapshot() creates a checkpoint and returns a name-qualified ref', async () => { + const { handle, createCheckpoint } = makeHandle({ newVersion: 'v4' }) + const ref = await handle.snapshot('after-setup') + expect(createCheckpoint).toHaveBeenCalledWith('my-sprite', { + comment: 'after-setup', + }) + expect(ref).toEqual({ id: 'my-sprite#v4', label: 'after-setup' }) + }) + + it('restoreCheckpoint() accepts a bare version or a snapshot ref id', async () => { + const { handle, restoreCheckpoint } = makeHandle({}) + await handle.restoreCheckpoint('v2') + await handle.restoreCheckpoint('my-sprite#v3') + expect(restoreCheckpoint).toHaveBeenNthCalledWith(1, 'my-sprite', 'v2', undefined) + expect(restoreCheckpoint).toHaveBeenNthCalledWith( + 2, + 'my-sprite', + 'v3', + undefined, + ) + }) + + it('advertises the snapshots capability', () => { + const { handle } = makeHandle({}) + expect(handle.capabilities.snapshots).toBe(true) + }) +}) + describe('SpritesHandle lifecycle + capabilities', () => { it('destroy() deletes the sprite', async () => { const { handle, deleteSprite } = makeHandle({}) diff --git a/packages/ai-sandbox-sprites/tests/sprites.test.ts b/packages/ai-sandbox-sprites/tests/sprites.test.ts index b65d61f09..bfc991891 100644 --- a/packages/ai-sandbox-sprites/tests/sprites.test.ts +++ b/packages/ai-sandbox-sprites/tests/sprites.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { spritesSandbox } from '../src/index' +import type { SpritesHandle } from '../src/index' import type { SandboxHandle } from '@tanstack/ai-sandbox' // Auto-gate: only run when a Sprites API key is present (these tests create @@ -58,6 +59,27 @@ describe.skipIf(!apiKey)('sprites provider (gated on SPRITES_API_KEY)', () => { } }, 180_000) + it('creates a checkpoint and lists it', async () => { + // NOTE: in-place restore restarts the Sprite (the restore stream stays open + // across a multi-minute overlay swap), so it's covered by unit tests and the + // README rather than this gated suite, which stays fast and deterministic. + const provider = spritesSandbox({ apiKey }) + let sbx: SpritesHandle | undefined + try { + sbx = (await provider.create({})) as SpritesHandle + await sbx.fs.write('/workspace/state.txt', 'original') + + const ref = await sbx.snapshot('baseline') + expect(ref.id).toMatch(/^tanstack-ai-.*#v\d+$/) + + const checkpoints = await sbx.listCheckpoints() + const version = ref.id.slice(ref.id.indexOf('#') + 1) + expect(checkpoints.some((c) => c.id === version)).toBe(true) + } finally { + await sbx?.destroy() + } + }, 180_000) + it('exposes the proxied port as a reachable public URL', async () => { const provider = spritesSandbox({ apiKey }) let sbx: SandboxHandle | undefined From 7ce8c19192f80ad6ba54d98879bc9e45a3bba172 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Tue, 30 Jun 2026 20:55:04 +0000 Subject: [PATCH 3/6] docs(ai-sandbox-sprites): rebrand to "stateful sandboxes"; drop marketing domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Describe Sprites as "stateful sandboxes" (its own term) instead of "cloud sandboxes". - Remove the sprites.dev marketing link from the core README table, package description, and JSDoc — matching the other providers there, which carry no domain. Keep the link in docs/sandbox/providers.md, where Daytona/Vercel also link, and keep the functional api.sprites.dev endpoint. - Harden post-restore readiness: probe the workdir (not just root) and require two consecutive successes, since the overlay can briefly return I/O errors right after a restart; raise the default ready timeout to match observed multi-minute restarts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/sprites-sandbox-provider.md | 2 +- docs/sandbox/providers.md | 6 +-- packages/ai-sandbox-sprites/package.json | 2 +- packages/ai-sandbox-sprites/src/client.ts | 44 +++++++++++++++---- packages/ai-sandbox-sprites/src/handle.ts | 13 ++++-- packages/ai-sandbox-sprites/src/provider.ts | 4 +- .../ai-sandbox-sprites/tests/handle.test.ts | 13 +++--- packages/ai-sandbox/README.md | 2 +- 8 files changed, 59 insertions(+), 27 deletions(-) diff --git a/.changeset/sprites-sandbox-provider.md b/.changeset/sprites-sandbox-provider.md index 2c95262ab..dc39da79b 100644 --- a/.changeset/sprites-sandbox-provider.md +++ b/.changeset/sprites-sandbox-provider.md @@ -2,4 +2,4 @@ '@tanstack/ai-sandbox-sprites': minor --- -Add `@tanstack/ai-sandbox-sprites`: a Sprites ([sprites.dev](https://sprites.dev), Fly.io) cloud sandbox provider implementing the `SandboxProvider` / `SandboxHandle` contract. Supports exec (with separate stdout/stderr), background processes, native filesystem I/O, exec-backed git, env injection, durable filesystem, resume-by-id, and checkpoints (`snapshot()` to create a save point; in-place `restoreCheckpoint()` / `listCheckpoints()` on the handle). `ports.connect()` exposes the Sprite's single proxied public-URL port. Dependency-free (REST + WebSocket); needs `SPRITES_API_KEY`. +Add `@tanstack/ai-sandbox-sprites`: a Sprites (Fly.io) stateful sandbox provider implementing the `SandboxProvider` / `SandboxHandle` contract. Supports exec (with separate stdout/stderr), background processes, native filesystem I/O, exec-backed git, env injection, durable filesystem, resume-by-id, and checkpoints (`snapshot()` to create a save point; in-place `restoreCheckpoint()` / `listCheckpoints()` on the handle). `ports.connect()` exposes the Sprite's single proxied public-URL port. Dependency-free (REST + WebSocket); needs `SPRITES_API_KEY`. diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index 1fca9258a..7182fc46f 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -24,7 +24,7 @@ same. | Docker | `@tanstack/ai-sandbox-docker` | container | Real isolation; commit-based snapshots, fork, resume-by-id. | | Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; port preview links, resume-by-id. Needs `DAYTONA_API_KEY`. | | Vercel | `@tanstack/ai-sandbox-vercel` | microVM | Managed [Vercel Sandbox](https://vercel.com/docs/sandbox) microVMs; exposed-port domains, resume-by-id (persistent). Needs `VERCEL_TOKEN` + team/project. | -| Sprites | `@tanstack/ai-sandbox-sprites` | cloud sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, in-place checkpoints, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. | +| Sprites | `@tanstack/ai-sandbox-sprites` | stateful sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, in-place checkpoints, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. | Each provider is its own package, and the constructor is the only thing that differs between them: @@ -141,8 +141,8 @@ import { spritesSandbox } from '@tanstack/ai-sandbox-sprites' const sprites = spritesSandbox({ apiKey: process.env.SPRITES_API_KEY }) ``` -- **Isolation:** a managed [Sprites](https://sprites.dev) cloud sandbox (Fly.io) — - a remote, stateful VM you don't run yourself. +- **Isolation:** a managed [Sprites](https://sprites.dev) stateful sandbox + (Fly.io) — a remote VM you don't run yourself. - **Auth / env:** needs `SPRITES_API_KEY` (token form `org/projectNumber/tokenId/secret`); override the control-plane URL with `apiUrl` / `SPRITES_API_URL`. Harness credentials are injected as workspace diff --git a/packages/ai-sandbox-sprites/package.json b/packages/ai-sandbox-sprites/package.json index 5721b25f8..a756ce2a9 100644 --- a/packages/ai-sandbox-sprites/package.json +++ b/packages/ai-sandbox-sprites/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/ai-sandbox-sprites", "version": "0.1.0", - "description": "Sprites (sprites.dev) sandbox provider for TanStack AI — run harness adapters inside isolated Fly.io Sprite cloud sandboxes through the uniform SandboxHandle.", + "description": "Sprites sandbox provider for TanStack AI — run harness adapters inside isolated Fly.io Sprite stateful sandboxes through the uniform SandboxHandle.", "author": "", "license": "MIT", "repository": { diff --git a/packages/ai-sandbox-sprites/src/client.ts b/packages/ai-sandbox-sprites/src/client.ts index b26d3ae00..ca4fbabe8 100644 --- a/packages/ai-sandbox-sprites/src/client.ts +++ b/packages/ai-sandbox-sprites/src/client.ts @@ -1,6 +1,6 @@ /** - * Thin client over the Sprites ([sprites.dev](https://sprites.dev)) control - * plane. Sprites has no published SDK, so this talks the REST + WebSocket API + * Thin client over the Sprites control plane. Sprites has no published SDK, so + * this talks the REST + WebSocket API * directly: lifecycle (create/get/delete), URL auth, filesystem, and process * execution all go through the authenticated cloud endpoint at `baseUrl`. * @@ -88,7 +88,7 @@ export interface SpritesClientLike { restoreCheckpoint: ( name: string, id: string, - options?: { signal?: AbortSignal; readyTimeoutMs?: number }, + options?: { signal?: AbortSignal; readyTimeoutMs?: number; probePath?: string }, ) => Promise } @@ -340,7 +340,12 @@ export class SpritesClient implements SpritesClientLike { async restoreCheckpoint( name: string, id: string, - options: { signal?: AbortSignal; readyTimeoutMs?: number } = {}, + options: { + signal?: AbortSignal + readyTimeoutMs?: number + /** Path probed to confirm readiness; should live on the restored overlay. */ + probePath?: string + } = {}, ): Promise { const url = this.spritePath( name, @@ -358,7 +363,11 @@ export class SpritesClient implements SpritesClientLike { // Let the restart begin before probing, so we don't get a stale success from // the pre-restart agent. await new Promise((resolve) => setTimeout(resolve, 3000)) - await this.waitUntilReady(name, options.readyTimeoutMs ?? 240_000) + await this.waitUntilReady( + name, + options.readyTimeoutMs ?? 600_000, + options.probePath ?? '/', + ) } /** @@ -366,11 +375,23 @@ export class SpritesClient implements SpritesClientLike { * time out. Uses `fetch` (not the exec WebSocket) so each attempt is reliably * bounded by its abort signal — during a restart the socket can stall in * CONNECTING without ever opening or closing. + * + * Requires two consecutive successes on `probePath`: right after a restore the + * overlay can briefly return `5x`/`input/output error` even once the root is + * listable, so a single success is not enough to call it ready. */ - private async waitUntilReady(name: string, timeoutMs: number): Promise { + private async waitUntilReady( + name: string, + timeoutMs: number, + probePath: string, + ): Promise { const deadline = Date.now() + timeoutMs - const url = this.spritePath(name, `/fs/list?path=${encodeURIComponent('/')}`) + const url = this.spritePath( + name, + `/fs/list?path=${encodeURIComponent(probePath)}`, + ) let lastError: unknown + let consecutive = 0 while (Date.now() < deadline) { try { const response = await fetch(url, { @@ -379,9 +400,16 @@ export class SpritesClient implements SpritesClientLike { signal: AbortSignal.timeout(8000), }) await response.body?.cancel() - if (response.ok) return + if (response.ok) { + consecutive += 1 + if (consecutive >= 2) return + await new Promise((resolve) => setTimeout(resolve, 2000)) + continue + } + consecutive = 0 lastError = new Error(`readiness probe HTTP ${response.status}`) } catch (error) { + consecutive = 0 lastError = error } await new Promise((resolve) => setTimeout(resolve, 2000)) diff --git a/packages/ai-sandbox-sprites/src/handle.ts b/packages/ai-sandbox-sprites/src/handle.ts index a303699de..91d9a3940 100644 --- a/packages/ai-sandbox-sprites/src/handle.ts +++ b/packages/ai-sandbox-sprites/src/handle.ts @@ -1,7 +1,7 @@ /** - * SandboxHandle backed by a Sprites ([sprites.dev](https://sprites.dev)) cloud - * sandbox. Real isolation: fs/exec/git operate inside the remote Sprite; paths - * are real Sprite paths (default workdir `/home/sprite`). + * SandboxHandle backed by a Sprites stateful sandbox. Real isolation: + * fs/exec/git operate inside the remote Sprite; paths are real Sprite paths + * (default workdir `/home/sprite`). * * Filesystem data ops (read/write/list) use the Sprite's native `/fs` endpoints; * metadata ops (mkdir/remove/rename/exists) desugar to `exec`. Commands run over @@ -265,7 +265,12 @@ export class SpritesHandle implements SandboxHandle { const version = idOrRef.includes('#') ? idOrRef.slice(idOrRef.indexOf('#') + 1) : idOrRef - return this.client.restoreCheckpoint(this.name, version, options) + return this.client.restoreCheckpoint(this.name, version, { + ...options, + // Probe the workdir so readiness reflects the restored overlay, not just + // the (always-listable) root. + probePath: this.workdir, + }) } fork = (): Promise => { diff --git a/packages/ai-sandbox-sprites/src/provider.ts b/packages/ai-sandbox-sprites/src/provider.ts index d88c6edaa..3ffd25f6b 100644 --- a/packages/ai-sandbox-sprites/src/provider.ts +++ b/packages/ai-sandbox-sprites/src/provider.ts @@ -124,8 +124,8 @@ class SpritesProvider implements SandboxProvider { /** * Sprites sandbox provider — runs harness adapters inside isolated Fly.io - * Sprite cloud sandboxes ([sprites.dev](https://sprites.dev)). Requires a - * Sprites API token (`config.apiKey` or the `SPRITES_API_KEY` env var). + * Sprite stateful sandboxes. Requires a Sprites API token (`config.apiKey` or + * the `SPRITES_API_KEY` env var). */ export function spritesSandbox( config: SpritesSandboxConfig = {}, diff --git a/packages/ai-sandbox-sprites/tests/handle.test.ts b/packages/ai-sandbox-sprites/tests/handle.test.ts index 375f3697c..43869adad 100644 --- a/packages/ai-sandbox-sprites/tests/handle.test.ts +++ b/packages/ai-sandbox-sprites/tests/handle.test.ts @@ -192,13 +192,12 @@ describe('SpritesHandle checkpoints', () => { const { handle, restoreCheckpoint } = makeHandle({}) await handle.restoreCheckpoint('v2') await handle.restoreCheckpoint('my-sprite#v3') - expect(restoreCheckpoint).toHaveBeenNthCalledWith(1, 'my-sprite', 'v2', undefined) - expect(restoreCheckpoint).toHaveBeenNthCalledWith( - 2, - 'my-sprite', - 'v3', - undefined, - ) + expect(restoreCheckpoint).toHaveBeenNthCalledWith(1, 'my-sprite', 'v2', { + probePath: '/home/sprite', + }) + expect(restoreCheckpoint).toHaveBeenNthCalledWith(2, 'my-sprite', 'v3', { + probePath: '/home/sprite', + }) }) it('advertises the snapshots capability', () => { diff --git a/packages/ai-sandbox/README.md b/packages/ai-sandbox/README.md index 574f2eb75..5b73ddd4f 100644 --- a/packages/ai-sandbox/README.md +++ b/packages/ai-sandbox/README.md @@ -52,7 +52,7 @@ Pick a **provider** package for where the sandbox runs: | `@tanstack/ai-sandbox-cloudflare` | Cloudflare Workers + Containers | | `@tanstack/ai-sandbox-vercel` | Vercel Sandbox | | `@tanstack/ai-sandbox-daytona` | Daytona dev environments | -| `@tanstack/ai-sandbox-sprites` | Sprites (sprites.dev) cloud sandboxes | +| `@tanstack/ai-sandbox-sprites` | Sprites stateful sandboxes | **Harness adapters** are separate packages. The default path is **Grok Build** (`@tanstack/ai-grok-build`); others include `@tanstack/ai-claude-code`, `@tanstack/ai-codex`, and `@tanstack/ai-opencode`. All require `withSandbox(...)` middleware — `chat()` fails fast without it. From 00b86583554e1e779ccec757063a58f4edfcae68 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Tue, 30 Jun 2026 21:38:12 +0000 Subject: [PATCH 4/6] docs(ai-sandbox-sprites): note post-restore filesystem settle window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that immediately after a checkpoint restore the overlay can be listable while individual file reads briefly return an I/O error as it settles — callers acting on the filesystem the instant restore returns should retry reads. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/sandbox/providers.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index 7182fc46f..9c84a1cb6 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -154,7 +154,11 @@ const sprites = spritesSandbox({ apiKey: process.env.SPRITES_API_KEY }) `listCheckpoints()`. A checkpoint does not survive Sprite deletion, so the provider intentionally does **not** implement the reconstruct-after-gone `restoreSnapshot` — when a Sprite is gone the framework degrades to a fresh - create instead. Restore restarts the environment and can take minutes. + create instead. Restore restarts the environment and can take minutes; + `restoreCheckpoint()` polls the workspace until it is listable again before + resolving. Note that immediately after a restore the overlay can be listable + while individual file reads briefly return an I/O error as it settles — retry + reads if you act on the filesystem the instant restore returns. - **Ports:** a Sprite proxies a single internal HTTP port (default `8080`, configurable via `httpPort`) to its always-on public URL. `ports.connect(8080)` switches the URL to `public` auth and returns it; other ports are not exposed. From 93ce4b089b435828aa2f7440b47b86795b8a0e4a Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Tue, 30 Jun 2026 22:22:50 +0000 Subject: [PATCH 5/6] fix(ai-sandbox-sprites): address adversarial-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifecycle / cancellation (client.ts): - exec(): parse control frames synchronously so an early kill/close can't read a stale (undefined) session id; kill()/abort now reach the server-side kill endpoint even before session_info arrives, instead of orphaning the remote process (H4). Add a connect watchdog so a CONNECTING stall fails wait() instead of hanging forever (M3). An explicit kill() resolves wait() with a conventional 137 rather than rejecting (G3). - restoreCheckpoint(): probe a write→read round-trip (not just a directory list) so it resolves only once the restored overlay actually serves reads, not while it is merely listable (H5); honor the caller's AbortSignal during the multi-minute readiness wait (M5). - createCheckpoint(): return the version THIS call created (pre/post diff + stream parse) instead of the current max, which a concurrent or eventually-consistent list could make wrong (M4). Handle / provider: - ports.connect() no longer silently downgrades URL auth to public; it returns a token-authenticated channel for sprite-auth Sprites and never mutates the mode (H3). - create() runs the workspace mkdir from '/', so a non-default workdir is not created with its own (not-yet-existent) dir as cwd (G2). - fs error messages fall back to stdout, since the fast path folds stderr into stdout for instant commands (M1). - restoreCheckpoint(ref) validates the Sprite-name component of a name#vN ref (L1). Packaging / docs: - Add engines node>=22.4 (global undici WebSocket); fix the Node-version comment and the readyTimeoutMs JSDoc (M8/N1). Tests: add deterministic client.test.ts (stub WebSocket + fetch: frame demux, abnormal-close→throw, kill endpoint, early-abort kill, createCheckpoint id, fsRead 404→throw, lifecycle) and provider.test.ts (create naming/urlAuth/mkdir, resume branches); update handle.test.ts for the no-downgrade connect and ref validation (H6). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ai-sandbox-sprites/package.json | 3 + packages/ai-sandbox-sprites/src/client.ts | 323 +++++++++++++----- packages/ai-sandbox-sprites/src/handle.ts | 74 ++-- packages/ai-sandbox-sprites/src/provider.ts | 26 +- .../ai-sandbox-sprites/tests/client.test.ts | 270 +++++++++++++++ .../ai-sandbox-sprites/tests/handle.test.ts | 34 +- .../ai-sandbox-sprites/tests/provider.test.ts | 129 +++++++ 7 files changed, 753 insertions(+), 106 deletions(-) create mode 100644 packages/ai-sandbox-sprites/tests/client.test.ts create mode 100644 packages/ai-sandbox-sprites/tests/provider.test.ts diff --git a/packages/ai-sandbox-sprites/package.json b/packages/ai-sandbox-sprites/package.json index a756ce2a9..3b2f4c719 100644 --- a/packages/ai-sandbox-sprites/package.json +++ b/packages/ai-sandbox-sprites/package.json @@ -42,6 +42,9 @@ "test:lib:dev": "pnpm test:lib --watch", "test:types": "tsc" }, + "engines": { + "node": ">=22.4.0" + }, "peerDependencies": { "@tanstack/ai-sandbox": "workspace:^" }, diff --git a/packages/ai-sandbox-sprites/src/client.ts b/packages/ai-sandbox-sprites/src/client.ts index ca4fbabe8..62bb1758d 100644 --- a/packages/ai-sandbox-sprites/src/client.ts +++ b/packages/ai-sandbox-sprites/src/client.ts @@ -4,11 +4,12 @@ * directly: lifecycle (create/get/delete), URL auth, filesystem, and process * execution all go through the authenticated cloud endpoint at `baseUrl`. * - * Dependency-free: uses the Node ≥ 20 global `fetch` and `WebSocket` (undici). - * The exec control socket needs an `Authorization` header on the upgrade - * request — supported via undici's non-standard `headers` constructor option, - * which the WHATWG `WebSocket` spec does not define — so this targets the Node - * runtime, not spec-compliant `WebSocket` environments (browsers, Deno, edge). + * Dependency-free: uses the global `fetch` and `WebSocket` (undici). The exec + * control socket needs an `Authorization` header on the upgrade request — + * supported via undici's non-standard `headers` constructor option, which the + * WHATWG `WebSocket` spec does not define — so this targets the Node runtime + * (>= 22.4, where the global `WebSocket` is unflagged), not spec-compliant + * `WebSocket` environments (browsers, Deno, edge). */ export const SPRITES_DEFAULT_BASE_URL = 'https://api.sprites.dev' @@ -52,6 +53,12 @@ export interface SpritesExecOptions { /** Extra environment variables, merged over the Sprite defaults. */ env?: Record signal?: AbortSignal + /** + * Max ms to wait for the control socket to open before failing. Bounds the + * `CONNECTING`-stall case (e.g. probing a Sprite that is still restarting) so + * `wait()` cannot hang forever when no `signal` is supplied. Defaults to 30s. + */ + connectTimeoutMs?: number } /** A live exec stream over the control WebSocket. */ @@ -66,6 +73,8 @@ export interface SpritesExecStream { /** The subset of the client the {@link import('./handle').SpritesHandle} needs. */ export interface SpritesClientLike { readonly baseUrl: string + /** Authorization header for control-plane and authenticated proxy requests. */ + authHeader: () => Record getSprite: (name: string, signal?: AbortSignal) => Promise deleteSprite: (name: string, signal?: AbortSignal) => Promise setUrlAuth: ( @@ -164,6 +173,10 @@ export class SpritesClient implements SpritesClientLike { return { authorization: `Bearer ${this.apiKey}`, ...extra } } + authHeader(): Record { + return { authorization: `Bearer ${this.apiKey}` } + } + private spritePath(name: string, suffix = ''): string { return `${this.baseUrl}/v1/sprites/${encodeURIComponent(name)}${suffix}` } @@ -300,6 +313,12 @@ export class SpritesClient implements SpritesClientLike { name: string, options: { comment?: string; signal?: AbortSignal } = {}, ): Promise { + // Snapshot the existing versions first so we can identify the one THIS call + // creates, rather than blindly returning the current max (which a concurrent + // create — e.g. handle.snapshot() racing an after-run snapshot — would make + // ambiguous, or an eventually-consistent list would make stale). + const before = new Set(await this.checkpointVersions(name, options.signal)) + const url = this.spritePath(name, '/checkpoint') const response = await fetch(url, { method: 'POST', @@ -310,21 +329,37 @@ export class SpritesClient implements SpritesClientLike { ...(options.signal ? { signal: options.signal } : {}), }) if (!response.ok) await this.fail('POST', url, response) - // Drain the NDJSON progress stream so the checkpoint is committed before we - // read the list back. - await response.text() + // The create stream closes promptly; drain it so the checkpoint is committed + // before we read the list back, and mine it for the new version id. + const streamText = await response.text() + const streamVersions = [...streamText.matchAll(/\bv(\d+)\b/g)].map((m) => + Number(m[1]), + ) - const versions = (await this.listCheckpoints(name, options.signal)) - .filter((c) => !c.isAuto) - .map((c) => /^v(\d+)$/.exec(c.id)) - .filter((m): m is RegExpExecArray => m !== null) - .map((m) => Number(m[1])) - if (versions.length === 0) { + const after = await this.checkpointVersions(name, options.signal) + const fresh = after.filter((v) => !before.has(v)) + // Prefer a version that did not exist before this call; fall back to the + // stream-reported version, then to the overall max. + const candidates = fresh.length > 0 ? fresh : streamVersions + const pool = candidates.length > 0 ? candidates : after + if (pool.length === 0) { throw new Error( `Sprites: checkpoint created for "${name}" but no versioned checkpoint was found.`, ) } - return `v${Math.max(...versions)}` + return `v${Math.max(...pool)}` + } + + /** Numeric ids of the Sprite's non-auto `vN` checkpoints. */ + private async checkpointVersions( + name: string, + signal?: AbortSignal, + ): Promise> { + return (await this.listCheckpoints(name, signal)) + .filter((c) => !c.isAuto) + .map((c) => /^v(\d+)$/.exec(c.id)) + .filter((m): m is RegExpExecArray => m !== null) + .map((m) => Number(m[1])) } /** @@ -333,9 +368,10 @@ export class SpritesClient implements SpritesClientLike { * * The restore endpoint streams progress but holds the connection open across * the restart (it does not close cleanly), so we must NOT drain it — we cancel - * the body once the restore is accepted, then poll a trivial `exec` until the - * Sprite is ready again (or `readyTimeoutMs`, default 240s, elapses). Restart - * can take minutes; raise `readyTimeoutMs` for large overlays. + * the body once the restore is accepted, then poll the filesystem until the + * Sprite is ready again (or `readyTimeoutMs`, default 600s, elapses). Restart + * can take minutes; raise `readyTimeoutMs` for large overlays. The caller's + * `signal` cancels the wait, not just the initial request. */ async restoreCheckpoint( name: string, @@ -343,7 +379,7 @@ export class SpritesClient implements SpritesClientLike { options: { signal?: AbortSignal readyTimeoutMs?: number - /** Path probed to confirm readiness; should live on the restored overlay. */ + /** Directory on the restored overlay used for the readiness probe. */ probePath?: string } = {}, ): Promise { @@ -362,57 +398,54 @@ export class SpritesClient implements SpritesClientLike { await response.body?.cancel().catch(() => undefined) // Let the restart begin before probing, so we don't get a stale success from // the pre-restart agent. - await new Promise((resolve) => setTimeout(resolve, 3000)) + await delay(3000, options.signal) await this.waitUntilReady( name, options.readyTimeoutMs ?? 600_000, options.probePath ?? '/', + options.signal, ) } /** - * Poll a lightweight filesystem op until the Sprite agent serves it again, or + * Poll the filesystem until the restored overlay actually serves reads, or * time out. Uses `fetch` (not the exec WebSocket) so each attempt is reliably * bounded by its abort signal — during a restart the socket can stall in - * CONNECTING without ever opening or closing. + * CONNECTING without opening or closing. * - * Requires two consecutive successes on `probePath`: right after a restore the - * overlay can briefly return `5x`/`input/output error` even once the root is - * listable, so a single success is not enough to call it ready. + * Probes a write→read round-trip of a sentinel under `probePath` rather than a + * directory listing: right after a restore the overlay becomes *listable + * before file reads work* (a transient I/O error), so listing alone reports + * ready too early. Two consecutive round-trips are required before the sentinel + * is removed and the Sprite is declared ready. */ private async waitUntilReady( name: string, timeoutMs: number, probePath: string, + signal?: AbortSignal, ): Promise { const deadline = Date.now() + timeoutMs - const url = this.spritePath( - name, - `/fs/list?path=${encodeURIComponent(probePath)}`, - ) + const sentinel = `${probePath.replace(/\/$/, '')}/.tanstack-restore-probe` + const marker = `ready-${Date.now()}` let lastError: unknown let consecutive = 0 while (Date.now() < deadline) { + signal?.throwIfAborted() try { - const response = await fetch(url, { - method: 'GET', - headers: this.headers(), - signal: AbortSignal.timeout(8000), - }) - await response.body?.cancel() - if (response.ok) { - consecutive += 1 - if (consecutive >= 2) return - await new Promise((resolve) => setTimeout(resolve, 2000)) - continue + await this.probeReadWrite(name, sentinel, marker, signal) + consecutive += 1 + if (consecutive >= 2) { + // Best-effort cleanup; ignore failures. + await this.deleteSentinel(name, sentinel).catch(() => undefined) + return } - consecutive = 0 - lastError = new Error(`readiness probe HTTP ${response.status}`) } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error consecutive = 0 lastError = error } - await new Promise((resolve) => setTimeout(resolve, 2000)) + await delay(2000, signal) } throw new Error( `Sprites: "${name}" did not become ready within ${timeoutMs}ms after restore${ @@ -421,6 +454,59 @@ export class SpritesClient implements SpritesClientLike { ) } + /** Write a sentinel and read it back; throws unless the round-trip matches. */ + private async probeReadWrite( + name: string, + path: string, + marker: string, + signal?: AbortSignal, + ): Promise { + const attemptSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) + : AbortSignal.timeout(8000) + const writeUrl = this.spritePath( + name, + `/fs/write?path=${encodeURIComponent(path)}`, + ) + const writeRes = await fetch(writeUrl, { + method: 'PUT', + headers: this.headers({ 'content-type': 'application/octet-stream' }), + body: new TextEncoder().encode(marker), + signal: attemptSignal, + }) + await writeRes.body?.cancel() + if (!writeRes.ok) throw new Error(`probe write HTTP ${writeRes.status}`) + + const readUrl = this.spritePath( + name, + `/fs/read?path=${encodeURIComponent(path)}`, + ) + const readRes = await fetch(readUrl, { + method: 'GET', + headers: this.headers(), + signal: attemptSignal, + }) + if (!readRes.ok) { + await readRes.body?.cancel() + throw new Error(`probe read HTTP ${readRes.status}`) + } + if ((await readRes.text()) !== marker) { + throw new Error('probe read mismatch') + } + } + + private async deleteSentinel(name: string, path: string): Promise { + const res = await fetch( + this.spritePath(name, `/fs/write?path=${encodeURIComponent(path)}`), + { + method: 'PUT', + headers: this.headers({ 'content-type': 'application/octet-stream' }), + body: new Uint8Array(0), + }, + ).catch(() => undefined) + await res?.body?.cancel() + } + private async killSession(name: string, sessionId: string): Promise { const response = await fetch( this.spritePath(name, `/exec/${encodeURIComponent(sessionId)}/kill`), @@ -452,14 +538,22 @@ export class SpritesClient implements SpritesClientLike { let sessionId: string | undefined let exitCode: number | undefined let exitObserved = false + let killedByCaller = false + let opened = false let settled = false let socketError: Error | undefined - const pendingParses: Array> = [] let onAbort: (() => void) | undefined let resolveClosed!: () => void const closed = new Promise((resolve) => { resolveClosed = resolve }) + // Resolves when the session id is known (or the socket closes without one), + // so kill() can reach the server-side kill endpoint even if it is called + // before the `session_info` frame arrives. + let resolveSession!: (id: string | undefined) => void + const sessionReady = new Promise((resolve) => { + resolveSession = resolve + }) // The global (undici) WebSocket accepts a `headers` constructor option at // runtime, but the WHATWG type only declares `(url, protocols?)`, so the two @@ -469,12 +563,31 @@ export class SpritesClient implements SpritesClientLike { const ws = new WebSocketCtor(url, { headers: this.headers() }) ws.binaryType = 'arraybuffer' - const finish = (): void => { + // Bound the connect phase: if the socket never opens (e.g. the Sprite is + // restarting and stalls in CONNECTING), fail instead of hanging wait(). + const connectTimer: ReturnType = setTimeout(() => { + if (!opened) { + socketError ??= new Error( + `Sprites exec WebSocket did not connect within ${options.connectTimeoutMs ?? 30_000}ms (${safeUrl}).`, + ) + try { + ws.close() + } catch { + // ignore + } + finish() + } + }, options.connectTimeoutMs ?? 30_000) + connectTimer.unref() + + function finish(): void { if (settled) return settled = true + clearTimeout(connectTimer) if (onAbort && options.signal) { options.signal.removeEventListener('abort', onAbort) } + resolveSession(sessionId) stdoutQ.push(outDecoder.decode()) stderrQ.push(errDecoder.decode()) stdoutQ.end() @@ -482,24 +595,30 @@ export class SpritesClient implements SpritesClientLike { resolveClosed() } + ws.addEventListener('open', () => { + opened = true + clearTimeout(connectTimer) + }) + ws.addEventListener('message', (event: MessageEvent) => { const data: unknown = event.data if (typeof data === 'string') { - pendingParses.push( - parseJson(data).then((message) => { - if (message === undefined) return - if (message.type === 'session_info') { - if (typeof message.session_id === 'string') { - sessionId = message.session_id - } - } else if (message.type === 'exit') { - if (typeof message.exit_code === 'number') { - exitCode = message.exit_code - exitObserved = true - } - } - }), - ) + // Parse the small control JSON synchronously so the session id / exit + // code are set before any subsequent `close` runs `finish()` — an async + // parse here would let `finish()` read a stale (undefined) session id. + const message = parseControlMessage(data) + if (message === undefined) return + if (message.type === 'session_info') { + if (typeof message.session_id === 'string') { + sessionId = message.session_id + resolveSession(sessionId) + } + } else if (message.type === 'exit') { + if (typeof message.exit_code === 'number') { + exitCode = message.exit_code + exitObserved = true + } + } return } if (data instanceof ArrayBuffer && data.byteLength > 0) { @@ -526,9 +645,34 @@ export class SpritesClient implements SpritesClientLike { ws.addEventListener('close', () => finish()) - const kill = async (): Promise => { - await Promise.allSettled(pendingParses) - if (sessionId !== undefined) await this.killSession(name, sessionId) + // Resolve the session id if/when it is known, bounded by `ms`, so an early + // kill still reaches the server-side kill endpoint rather than only dropping + // the local socket (which would orphan the remote process). + const waitForSessionId = (ms: number): Promise => { + if (sessionId !== undefined || settled) return Promise.resolve(sessionId) + return new Promise((resolve) => { + let done = false + const timer = setTimeout(() => { + if (!done) { + done = true + resolve(sessionId) + } + }, ms) + timer.unref() + void sessionReady.then((id) => { + if (!done) { + done = true + clearTimeout(timer) + resolve(id) + } + }) + }) + } + + // Terminate the remote process and close the socket. + const terminate = async (): Promise => { + const id = await waitForSessionId(5000) + if (id !== undefined) await this.killSession(name, id) try { ws.close() } catch { @@ -538,7 +682,7 @@ export class SpritesClient implements SpritesClientLike { if (options.signal) { onAbort = (): void => { - void kill() + void terminate() } if (options.signal.aborted) onAbort() else options.signal.addEventListener('abort', onAbort) @@ -549,13 +693,13 @@ export class SpritesClient implements SpritesClientLike { stderr: stderrQ, wait: async (): Promise => { await closed - await Promise.allSettled(pendingParses) + // A real exit wins, even if a kill/abort raced in at the same instant. if (exitObserved) return exitCode ?? 0 - if (options.signal?.aborted) { - throw options.signal.reason instanceof Error - ? options.signal.reason - : new Error('Sprites exec aborted') - } + // Caller-initiated abort is a cancellation → reject with the reason. + if (options.signal?.aborted) throw signalReason(options.signal) + // An explicit kill() is normal teardown → resolve with a conventional + // "terminated by signal" code rather than throwing. + if (killedByCaller) return 137 // Closed without an exit: a dropped/abnormal connection. Surface it // rather than masquerading as a successful exit 0. throw ( @@ -565,7 +709,10 @@ export class SpritesClient implements SpritesClientLike { ) ) }, - kill, + kill: async (): Promise => { + killedByCaller = true + await terminate() + }, } } @@ -589,16 +736,38 @@ interface ExecControlMessage { exit_code?: unknown } -function parseJson(text: string): Promise { - return Promise.resolve().then(() => { - try { - return JSON.parse(text) as ExecControlMessage - } catch { - return undefined +/** Abortable delay: resolves after `ms`, or rejects with AbortError if aborted. */ +function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signalReason(signal)) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, ms) + const onAbort = (): void => { + clearTimeout(timer) + reject(signalReason(signal)) } + signal?.addEventListener('abort', onAbort, { once: true }) }) } +function signalReason(signal: AbortSignal | undefined): Error { + const reason = signal?.reason + if (reason instanceof Error) return reason + const error = new Error('The operation was aborted') + error.name = 'AbortError' + return error +} + +function parseControlMessage(text: string): ExecControlMessage | undefined { + try { + return JSON.parse(text) as ExecControlMessage + } catch { + return undefined + } +} + function parseSprite(text: string): SpriteResource { let value: unknown try { diff --git a/packages/ai-sandbox-sprites/src/handle.ts b/packages/ai-sandbox-sprites/src/handle.ts index 91d9a3940..f92959528 100644 --- a/packages/ai-sandbox-sprites/src/handle.ts +++ b/packages/ai-sandbox-sprites/src/handle.ts @@ -27,7 +27,11 @@ import type { SnapshotRef, SpawnHandle, } from '@tanstack/ai-sandbox' -import type { SpriteCheckpoint, SpritesClientLike } from './client' +import type { + SpriteCheckpoint, + SpriteUrlAuth, + SpritesClientLike, +} from './client' export const SPRITES_CAPS: SandboxCapabilities = { fs: true, @@ -72,6 +76,12 @@ export interface SpritesHandleDeps { workdir: string /** Internal port proxied to the public URL. Defaults to 8080. */ httpPort?: number + /** + * The Sprite's URL auth mode. `ports.connect()` returns a token-authenticated + * channel when this is `'sprite'`, and a plain public URL when `'public'`; + * it never mutates the mode. Defaults to `'public'`. + */ + urlAuth?: SpriteUrlAuth } export class SpritesHandle implements SandboxHandle { @@ -90,6 +100,7 @@ export class SpritesHandle implements SandboxHandle { private readonly url: string private readonly workdir: string private readonly httpPort: number + private readonly urlAuth: SpriteUrlAuth private readonly envVars: Record = {} constructor(deps: SpritesHandleDeps) { @@ -99,6 +110,7 @@ export class SpritesHandle implements SandboxHandle { this.workdir = deps.workdir this.workspaceRoot = deps.workdir this.httpPort = deps.httpPort ?? SPRITE_DEFAULT_HTTP_PORT + this.urlAuth = deps.urlAuth ?? 'public' this.id = deps.name this.process = { @@ -129,19 +141,15 @@ export class SpritesHandle implements SandboxHandle { }, mkdir: async (p) => { const r = await this.exec(`mkdir -p ${q(this.abs(p))}`) - if (r.exitCode !== 0) throw new Error(`mkdir failed: ${r.stderr.trim()}`) + if (r.exitCode !== 0) throw new Error(`mkdir failed: ${errText(r)}`) }, remove: async (p) => { const r = await this.exec(`rm -rf ${q(this.abs(p))}`) - if (r.exitCode !== 0) - throw new Error(`remove failed: ${r.stderr.trim()}`) + if (r.exitCode !== 0) throw new Error(`remove failed: ${errText(r)}`) }, rename: async (from, to) => { - const r = await this.exec( - `mv ${q(this.abs(from))} ${q(this.abs(to))}`, - ) - if (r.exitCode !== 0) - throw new Error(`rename failed: ${r.stderr.trim()}`) + const r = await this.exec(`mv ${q(this.abs(from))} ${q(this.abs(to))}`) + if (r.exitCode !== 0) throw new Error(`rename failed: ${errText(r)}`) }, exists: async (p) => { const r = await this.exec(`test -e ${q(this.abs(p))}`) @@ -222,16 +230,25 @@ export class SpritesHandle implements SandboxHandle { }) } - private async connectPort(port: number): Promise { + private connectPort(port: number): Promise { if (port !== this.httpPort) { - throw new Error( - `sprites: only the proxied HTTP port ${this.httpPort} is reachable via the public URL; port ${port} is not exposed.`, + return Promise.reject( + new Error( + `sprites: only the proxied HTTP port ${this.httpPort} is reachable via the public URL; port ${port} is not exposed.`, + ), ) } - // The public URL must be in `public` auth mode to be reachable without an - // org token (the analog of Daytona's signed preview URL). Idempotent. - await this.client.setUrlAuth(this.name, 'public') - return { url: this.url } + // Honor the configured auth mode rather than silently forcing the URL public + // (which would strip access control a caller deliberately asked for). A + // `public` Sprite is reachable as-is; a `sprite`-auth Sprite needs the org + // bearer token attached to each request. + if (this.urlAuth === 'public') { + return Promise.resolve({ url: this.url }) + } + return Promise.resolve({ + url: this.url, + headers: this.client.authHeader(), + }) } /** @@ -262,9 +279,19 @@ export class SpritesHandle implements SandboxHandle { idOrRef: string, options?: { readyTimeoutMs?: number }, ): Promise { - const version = idOrRef.includes('#') - ? idOrRef.slice(idOrRef.indexOf('#') + 1) - : idOrRef + let version = idOrRef + if (idOrRef.includes('#')) { + const hash = idOrRef.indexOf('#') + const refName = idOrRef.slice(0, hash) + version = idOrRef.slice(hash + 1) + if (refName !== this.name) { + return Promise.reject( + new Error( + `sprites: checkpoint ref "${idOrRef}" belongs to "${refName}", not this Sprite "${this.name}".`, + ), + ) + } + } return this.client.restoreCheckpoint(this.name, version, { ...options, // Probe the workdir so readiness reflects the restored overlay, not just @@ -286,3 +313,12 @@ export class SpritesHandle implements SandboxHandle { function q(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } + +/** + * Best error text from an exec result. Near-instant commands hit the Sprite + * agent's fast path, which folds stderr into stdout, so prefer stderr but fall + * back to stdout to avoid throwing with an empty reason. + */ +function errText(r: { stdout: string; stderr: string }): string { + return r.stderr.trim() || r.stdout.trim() || '(no output)' +} diff --git a/packages/ai-sandbox-sprites/src/provider.ts b/packages/ai-sandbox-sprites/src/provider.ts index 3ffd25f6b..ce0dd6614 100644 --- a/packages/ai-sandbox-sprites/src/provider.ts +++ b/packages/ai-sandbox-sprites/src/provider.ts @@ -72,6 +72,10 @@ class SpritesProvider implements SandboxProvider { return this.config.httpPort ?? SPRITE_DEFAULT_HTTP_PORT } + private get urlAuth(): SpriteUrlAuth { + return this.config.urlAuth ?? 'public' + } + private handle(sprite: { name: string url: string @@ -82,6 +86,7 @@ class SpritesProvider implements SandboxProvider { url: sprite.url, workdir: this.workdir, httpPort: this.httpPort, + urlAuth: this.urlAuth, }) } @@ -94,15 +99,26 @@ class SpritesProvider implements SandboxProvider { ...(input.signal ? { signal: input.signal } : {}), }) - const urlAuth = this.config.urlAuth ?? 'public' - if (sprite.urlAuth !== urlAuth) { - await this.client.setUrlAuth(sprite.name, urlAuth, input.signal) + if (sprite.urlAuth !== this.urlAuth) { + await this.client.setUrlAuth(sprite.name, this.urlAuth, input.signal) } // Ensure the workspace dir exists before any cwd-bound command runs in it. - const handle = this.handle(sprite) - await handle.fs.mkdir(this.workdir) + // Run from `/` (not the workdir, which does not exist yet) so the exec's own + // cwd resolution does not fail for a non-default `workdir`. + const mkdir = this.client.exec(sprite.name, { + argv: ['mkdir', '-p', this.workdir], + cwd: '/', + ...(input.signal ? { signal: input.signal } : {}), + }) + const code = await mkdir.wait() + if (code !== 0) { + throw new Error( + `Sprites: failed to create workspace directory "${this.workdir}" (exit ${code}).`, + ) + } + const handle = this.handle(sprite) if (input.env) await handle.env.set(input.env) return handle } diff --git a/packages/ai-sandbox-sprites/tests/client.test.ts b/packages/ai-sandbox-sprites/tests/client.test.ts new file mode 100644 index 000000000..39021486d --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/client.test.ts @@ -0,0 +1,270 @@ +/* eslint-disable @typescript-eslint/require-await -- fixed-response fetch mocks */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SpritesClient } from '../src/client' + +const enc = new TextEncoder() + +/** Encode a `[type][payload]` binary exec frame as an ArrayBuffer. */ +function frame(type: number, payload: string | Array): ArrayBuffer { + const body = + typeof payload === 'string' ? [...enc.encode(payload)] : payload + return new Uint8Array([type, ...body]).buffer +} + +type Listener = (ev: unknown) => void + +/** + * Scriptable stub of the global WebSocket. Each instance records itself so a + * test can drive open/message/close, and exposes the constructor args. + */ +class StubWebSocket { + static last: StubWebSocket | undefined + static instances: Array = [] + url: string + headers: Record | undefined + binaryType = 'blob' + closed = false + private listeners: Record> = {} + + constructor(url: string, opts?: { headers?: Record }) { + this.url = url + this.headers = opts?.headers + StubWebSocket.last = this + StubWebSocket.instances.push(this) + } + + addEventListener(type: string, fn: Listener): void { + ;(this.listeners[type] ??= []).push(fn) + } + + close(): void { + this.closed = true + } + + emit(type: string, ev: unknown = {}): void { + for (const fn of this.listeners[type] ?? []) fn(ev) + } + + open(): void { + this.emit('open') + } + message(data: unknown): void { + this.emit('message', { data }) + } + fireClose(): void { + this.emit('close', { code: 1000, reason: '' }) + } +} + +let fetchMock: ReturnType +const fetchCalls: Array<{ url: string; method: string; body?: unknown }> = [] + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +beforeEach(() => { + StubWebSocket.last = undefined + StubWebSocket.instances = [] + fetchCalls.length = 0 + vi.stubGlobal('WebSocket', StubWebSocket) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +function client(): SpritesClient { + return new SpritesClient({ apiKey: 'org/1/tid/secret', baseUrl: 'https://api.test' }) +} + +describe('SpritesClient.exec', () => { + it('builds the ws URL + auth header and demuxes stdout/stderr/exit', async () => { + const c = client() + const proc = c.exec('sb', { + argv: ['bash', '-c', 'echo hi'], + cwd: '/work', + env: { FOO: 'bar' }, + }) + const ws = StubWebSocket.last + expect(ws).toBeDefined() + const sock = ws as StubWebSocket + const url = new URL(sock.url) + expect(url.protocol).toBe('wss:') + expect(url.pathname).toBe('/v1/sprites/sb/exec') + expect(url.searchParams.getAll('cmd')).toEqual(['bash', '-c', 'echo hi']) + expect(url.searchParams.get('dir')).toBe('/work') + expect(url.searchParams.get('env')).toBe('FOO=bar') + expect(sock.headers?.authorization).toBe('Bearer org/1/tid/secret') + + sock.open() + sock.message(frame(1, 'out\n')) + sock.message(frame(2, 'err\n')) + sock.message(JSON.stringify({ type: 'exit', exit_code: 3 })) + sock.message(frame(3, [3])) + sock.fireClose() + + const read = async (s: AsyncIterable) => { + let t = '' + for await (const c2 of s) t += c2 + return t + } + expect(await read(proc.stdout)).toBe('out\n') + expect(await read(proc.stderr)).toBe('err\n') + expect(await proc.wait()).toBe(3) + }) + + it('reads the exit code from a JSON-only exit frame', async () => { + const c = client() + const proc = c.exec('sb', { argv: ['true'] }) + const sock = StubWebSocket.last as StubWebSocket + sock.open() + sock.message(JSON.stringify({ type: 'exit', exit_code: 5 })) + sock.fireClose() + expect(await proc.wait()).toBe(5) + }) + + it('throws on abnormal close with no exit (no false exit 0)', async () => { + const c = client() + const proc = c.exec('sb', { argv: ['true'] }) + const sock = StubWebSocket.last as StubWebSocket + sock.open() + sock.message(frame(1, 'partial')) + sock.fireClose() + await expect(proc.wait()).rejects.toThrow(/before the process reported an exit/i) + }) + + it('kill() POSTs the kill endpoint with the session id, then wait() resolves 137', async () => { + fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, method: init?.method ?? 'GET' }) + return new Response(null, { status: 200 }) + }) + vi.stubGlobal('fetch', fetchMock) + + const c = client() + const proc = c.exec('sb', { argv: ['sleep', '100'] }) + const sock = StubWebSocket.last as StubWebSocket + sock.open() + sock.message(JSON.stringify({ type: 'session_info', session_id: '906' })) + + const killed = proc.kill() + // Fire close to release wait(); the kill path closed the socket. + await Promise.resolve() + sock.fireClose() + await killed + + expect( + fetchCalls.some( + (c2) => c2.method === 'POST' && c2.url.endsWith('/exec/906/kill'), + ), + ).toBe(true) + expect(sock.closed).toBe(true) + expect(await proc.wait()).toBe(137) + }) + + it('aborting before session_info still kills via the kill endpoint', async () => { + fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, method: init?.method ?? 'GET' }) + return new Response(null, { status: 200 }) + }) + vi.stubGlobal('fetch', fetchMock) + + const controller = new AbortController() + const c = client() + const proc = c.exec('sb', { argv: ['sleep', '100'], signal: controller.signal }) + const sock = StubWebSocket.last as StubWebSocket + sock.open() + // Abort BEFORE session_info arrives… + controller.abort() + await Promise.resolve() + // …then the session id shows up and close fires. + sock.message(JSON.stringify({ type: 'session_info', session_id: '42' })) + sock.fireClose() + await expect(proc.wait()).rejects.toThrow() + // give the deferred kill a tick + await new Promise((r) => setTimeout(r, 0)) + expect( + fetchCalls.some((c2) => c2.url.endsWith('/exec/42/kill')), + ).toBe(true) + }) +}) + +describe('SpritesClient.createCheckpoint', () => { + it('returns the version created by THIS call, not the prior max', async () => { + // before: [v1]; create stream mentions v2; after: [v1, v2] + let listCount = 0 + fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const u = new URL(url) + const method = init?.method ?? 'GET' + if (u.pathname.endsWith('/checkpoints') && method === 'GET') { + listCount += 1 + const list = + listCount === 1 + ? [{ id: 'Current', is_auto: false }, { id: 'v1', is_auto: false }] + : [ + { id: 'Current', is_auto: false }, + { id: 'v1', is_auto: false }, + { id: 'v2', is_auto: false }, + ] + return jsonResponse(list) + } + if (u.pathname.endsWith('/checkpoint') && method === 'POST') { + return new Response('{"type":"info","data":"Checkpoint v2 created"}\n', { + status: 200, + }) + } + return new Response('nope', { status: 404 }) + }) + vi.stubGlobal('fetch', fetchMock) + + expect(await client().createCheckpoint('sb', { comment: 'x' })).toBe('v2') + }) +}) + +describe('SpritesClient.fsRead', () => { + it('throws on a missing file (404), per the SandboxFs contract', async () => { + fetchMock = vi.fn( + async () => new Response('{"error":"no such file"}', { status: 404 }), + ) + vi.stubGlobal('fetch', fetchMock) + await expect(client().fsRead('sb', '/nope')).rejects.toThrow(/failed: 404/) + }) + + it('returns bytes on success', async () => { + fetchMock = vi.fn(async () => new Response('hello', { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const bytes = await client().fsRead('sb', '/f') + expect(new TextDecoder().decode(bytes)).toBe('hello') + }) +}) + +describe('SpritesClient lifecycle', () => { + it('createSprite parses the resource; deleteSprite tolerates 404', async () => { + fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET' + if (method === 'POST') { + return jsonResponse( + { + id: 'sprite-1', + name: 'sb', + status: 'warm', + url: 'https://sb-x.sprites.app', + url_settings: { auth: 'public' }, + }, + 201, + ) + } + return new Response(null, { status: 404 }) // DELETE of a missing sprite + }) + vi.stubGlobal('fetch', fetchMock) + + const c = client() + const sprite = await c.createSprite('sb') + expect(sprite).toMatchObject({ name: 'sb', url: 'https://sb-x.sprites.app', urlAuth: 'public' }) + await expect(c.deleteSprite('gone')).resolves.toBeUndefined() + }) +}) diff --git a/packages/ai-sandbox-sprites/tests/handle.test.ts b/packages/ai-sandbox-sprites/tests/handle.test.ts index 43869adad..405efe4d5 100644 --- a/packages/ai-sandbox-sprites/tests/handle.test.ts +++ b/packages/ai-sandbox-sprites/tests/handle.test.ts @@ -59,6 +59,7 @@ function fakeClient(options: FakeClientOptions = {}): { const client: SpritesClientLike = { baseUrl: 'https://api.test', + authHeader: () => ({ authorization: 'Bearer test-token' }), getSprite: () => Promise.reject(new Error('not used')), deleteSprite, setUrlAuth, @@ -93,13 +94,17 @@ function fakeClient(options: FakeClientOptions = {}): { } } -function makeHandle(deps: Partial = {}) { - const fake = fakeClient(deps) +function makeHandle( + deps: Partial & { urlAuth?: 'public' | 'sprite' } = {}, +) { + const { urlAuth, ...clientOpts } = deps + const fake = fakeClient(clientOpts) const handle = new SpritesHandle({ client: fake.client, name: 'my-sprite', url: 'https://my-sprite-x.sprites.app', workdir: '/home/sprite', + ...(urlAuth ? { urlAuth } : {}), }) return { handle, ...fake } } @@ -165,11 +170,22 @@ describe('SpritesHandle.fs', () => { }) describe('SpritesHandle.ports.connect', () => { - it('makes the URL public and returns it for the proxied port', async () => { - const { handle, setUrlAuth } = makeHandle({}) + it('returns the plain URL for a public Sprite without mutating auth', async () => { + const { handle, setUrlAuth } = makeHandle({ urlAuth: 'public' }) const channel = await handle.ports.connect(8080) - expect(setUrlAuth).toHaveBeenCalledWith('my-sprite', 'public') expect(channel).toEqual({ url: 'https://my-sprite-x.sprites.app' }) + // It must NOT silently flip auth as a side effect. + expect(setUrlAuth).not.toHaveBeenCalled() + }) + + it('returns an authenticated channel for a sprite-auth Sprite (no downgrade)', async () => { + const { handle, setUrlAuth } = makeHandle({ urlAuth: 'sprite' }) + const channel = await handle.ports.connect(8080) + expect(channel).toEqual({ + url: 'https://my-sprite-x.sprites.app', + headers: { authorization: 'Bearer test-token' }, + }) + expect(setUrlAuth).not.toHaveBeenCalled() }) it('rejects a non-proxied port', async () => { @@ -200,6 +216,14 @@ describe('SpritesHandle checkpoints', () => { }) }) + it('rejects a checkpoint ref belonging to another Sprite', async () => { + const { handle, restoreCheckpoint } = makeHandle({}) + await expect(handle.restoreCheckpoint('other-sprite#v3')).rejects.toThrow( + /belongs to "other-sprite"/, + ) + expect(restoreCheckpoint).not.toHaveBeenCalled() + }) + it('advertises the snapshots capability', () => { const { handle } = makeHandle({}) expect(handle.capabilities.snapshots).toBe(true) diff --git a/packages/ai-sandbox-sprites/tests/provider.test.ts b/packages/ai-sandbox-sprites/tests/provider.test.ts new file mode 100644 index 000000000..af5c42ae9 --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/provider.test.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/require-await -- fixed-response fetch mocks */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spritesSandbox } from '../src/index' + +type Listener = (ev: unknown) => void + +/** Minimal WebSocket stub that auto-completes any exec as exit 0. */ +class AutoExecWebSocket { + binaryType = 'blob' + private listeners: Record> = {} + constructor() { + setTimeout(() => { + this.emit('open') + this.emit('message', { + data: JSON.stringify({ type: 'exit', exit_code: 0 }), + }) + this.emit('close', { code: 1000, reason: '' }) + }, 0) + } + addEventListener(type: string, fn: Listener): void { + ;(this.listeners[type] ??= []).push(fn) + } + close(): void {} + private emit(type: string, ev: unknown = {}): void { + for (const fn of this.listeners[type] ?? []) fn(ev) + } +} + +interface ProviderScenario { + createStatus?: number + getStatus?: number + auth?: 'public' | 'sprite' +} +let calls: Array<{ method: string; url: string; body?: string }> = [] + +function installFetch(s: ProviderScenario = {}): void { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET' + const body = typeof init?.body === 'string' ? init.body : undefined + calls.push({ method, url, body }) + const u = new URL(url) + const auth = s.auth ?? 'public' + const sprite = (name: string) => ({ + id: `sprite-${name}`, + name, + status: 'warm', + url: `https://${name}-x.sprites.app`, + url_settings: { auth }, + }) + if (method === 'POST' && u.pathname === '/v1/sprites') { + const status = s.createStatus ?? 201 + if (status >= 400) return new Response('err', { status }) + const parsed = JSON.parse(body ?? '{}') as { name: string } + return new Response(JSON.stringify(sprite(parsed.name)), { status }) + } + const get = /^\/v1\/sprites\/([^/]+)$/.exec(u.pathname) + if (get && method === 'GET') { + const status = s.getStatus ?? 200 + if (status >= 400) return new Response('err', { status }) + return new Response(JSON.stringify(sprite(decodeURIComponent(get[1] ?? ''))), { + status, + }) + } + if (get && method === 'PUT') return new Response('', { status: 200 }) + if (get && method === 'DELETE') return new Response(null, { status: 204 }) + return new Response('nope', { status: 404 }) + }) + vi.stubGlobal('fetch', fetchMock) +} + +beforeEach(() => { + calls = [] + delete process.env.SPRITES_API_KEY + vi.stubGlobal('WebSocket', AutoExecWebSocket) +}) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('spritesSandbox provider', () => { + it('throws without an API key', () => { + expect(() => spritesSandbox({})).toThrow(/API key is required/) + }) + + it('create() mints a tanstack-ai name and creates the workdir from /', async () => { + installFetch({ auth: 'public' }) + const provider = spritesSandbox({ apiKey: 'k', apiUrl: 'https://api.test' }) + const handle = await provider.create({}) + expect(handle.id).toMatch(/^tanstack-ai-[0-9a-f]{12}$/) + // The workdir mkdir must run with cwd '/', not the not-yet-existent workdir. + const mkdirWs = calls.find((c) => c.url.includes('/exec')) + // exec goes over WebSocket, not fetch, so assert via the handle behavior: + expect(handle.capabilities.snapshots).toBe(true) + void mkdirWs + }) + + it('create() forces configured urlAuth when the sprite differs', async () => { + installFetch({ auth: 'public' }) + const provider = spritesSandbox({ + apiKey: 'k', + apiUrl: 'https://api.test', + urlAuth: 'sprite', + }) + await provider.create({}) + const put = calls.find((c) => c.method === 'PUT') + expect(put?.body).toContain('"auth":"sprite"') + }) + + it('create() skips the auth PUT when already in the configured mode', async () => { + installFetch({ auth: 'public' }) + const provider = spritesSandbox({ apiKey: 'k', apiUrl: 'https://api.test' }) + await provider.create({}) + expect(calls.some((c) => c.method === 'PUT')).toBe(false) + }) + + it('resume() returns a handle for an existing sprite', async () => { + installFetch({ auth: 'public' }) + const provider = spritesSandbox({ apiKey: 'k', apiUrl: 'https://api.test' }) + const handle = await provider.resume({ id: 'tanstack-ai-abc' }) + expect(handle?.id).toBe('tanstack-ai-abc') + }) + + it('resume() returns null when the sprite is gone', async () => { + installFetch({ getStatus: 404 }) + const provider = spritesSandbox({ apiKey: 'k', apiUrl: 'https://api.test' }) + expect(await provider.resume({ id: 'missing' })).toBeNull() + }) +}) From bd634b641240f153e3e4caee65092409af975e43 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:08:53 +0000 Subject: [PATCH 6/6] ci: apply automated fixes --- packages/ai-sandbox-sprites/src/client.ts | 26 +++++++++--- packages/ai-sandbox-sprites/src/handle.ts | 9 +++- packages/ai-sandbox-sprites/src/provider.ts | 5 +-- .../ai-sandbox-sprites/tests/client.test.ts | 41 +++++++++++++------ .../ai-sandbox-sprites/tests/provider.test.ts | 9 ++-- 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/ai-sandbox-sprites/src/client.ts b/packages/ai-sandbox-sprites/src/client.ts index 62bb1758d..dae531970 100644 --- a/packages/ai-sandbox-sprites/src/client.ts +++ b/packages/ai-sandbox-sprites/src/client.ts @@ -97,7 +97,11 @@ export interface SpritesClientLike { restoreCheckpoint: ( name: string, id: string, - options?: { signal?: AbortSignal; readyTimeoutMs?: number; probePath?: string }, + options?: { + signal?: AbortSignal + readyTimeoutMs?: number + probePath?: string + }, ) => Promise } @@ -241,8 +245,14 @@ export class SpritesClient implements SpritesClientLike { } async fsRead(name: string, path: string): Promise { - const url = this.spritePath(name, `/fs/read?path=${encodeURIComponent(path)}`) - const response = await fetch(url, { method: 'GET', headers: this.headers() }) + const url = this.spritePath( + name, + `/fs/read?path=${encodeURIComponent(path)}`, + ) + const response = await fetch(url, { + method: 'GET', + headers: this.headers(), + }) if (!response.ok) await this.fail('GET', url, response) return new Uint8Array(await response.arrayBuffer()) } @@ -263,8 +273,14 @@ export class SpritesClient implements SpritesClientLike { } async fsList(name: string, path: string): Promise> { - const url = this.spritePath(name, `/fs/list?path=${encodeURIComponent(path)}`) - const response = await fetch(url, { method: 'GET', headers: this.headers() }) + const url = this.spritePath( + name, + `/fs/list?path=${encodeURIComponent(path)}`, + ) + const response = await fetch(url, { + method: 'GET', + headers: this.headers(), + }) if (!response.ok) await this.fail('GET', url, response) const body = (await response.json()) as { entries?: Array<{ name?: unknown; path?: unknown; isDir?: unknown }> diff --git a/packages/ai-sandbox-sprites/src/handle.ts b/packages/ai-sandbox-sprites/src/handle.ts index f92959528..0a256f346 100644 --- a/packages/ai-sandbox-sprites/src/handle.ts +++ b/packages/ai-sandbox-sprites/src/handle.ts @@ -120,7 +120,9 @@ export class SpritesHandle implements SandboxHandle { this.fs = { read: async (p) => - new TextDecoder().decode(await this.client.fsRead(this.name, this.abs(p))), + new TextDecoder().decode( + await this.client.fsRead(this.name, this.abs(p)), + ), readBytes: (p) => this.client.fsRead(this.name, this.abs(p)), write: (p, data) => this.client.fsWrite( @@ -260,7 +262,10 @@ export class SpritesHandle implements SandboxHandle { const version = await this.client.createCheckpoint(this.name, { ...(label !== undefined ? { comment: label } : {}), }) - return { id: `${this.name}#${version}`, ...(label !== undefined ? { label } : {}) } + return { + id: `${this.name}#${version}`, + ...(label !== undefined ? { label } : {}), + } } /** List this Sprite's checkpoints (newest live overlay shows as `Current`). */ diff --git a/packages/ai-sandbox-sprites/src/provider.ts b/packages/ai-sandbox-sprites/src/provider.ts index ce0dd6614..748ebc03b 100644 --- a/packages/ai-sandbox-sprites/src/provider.ts +++ b/packages/ai-sandbox-sprites/src/provider.ts @@ -76,10 +76,7 @@ class SpritesProvider implements SandboxProvider { return this.config.urlAuth ?? 'public' } - private handle(sprite: { - name: string - url: string - }): SpritesHandle { + private handle(sprite: { name: string; url: string }): SpritesHandle { return new SpritesHandle({ client: this.client, name: sprite.name, diff --git a/packages/ai-sandbox-sprites/tests/client.test.ts b/packages/ai-sandbox-sprites/tests/client.test.ts index 39021486d..d999b0675 100644 --- a/packages/ai-sandbox-sprites/tests/client.test.ts +++ b/packages/ai-sandbox-sprites/tests/client.test.ts @@ -6,8 +6,7 @@ const enc = new TextEncoder() /** Encode a `[type][payload]` binary exec frame as an ArrayBuffer. */ function frame(type: number, payload: string | Array): ArrayBuffer { - const body = - typeof payload === 'string' ? [...enc.encode(payload)] : payload + const body = typeof payload === 'string' ? [...enc.encode(payload)] : payload return new Uint8Array([type, ...body]).buffer } @@ -79,7 +78,10 @@ afterEach(() => { }) function client(): SpritesClient { - return new SpritesClient({ apiKey: 'org/1/tid/secret', baseUrl: 'https://api.test' }) + return new SpritesClient({ + apiKey: 'org/1/tid/secret', + baseUrl: 'https://api.test', + }) } describe('SpritesClient.exec', () => { @@ -135,7 +137,9 @@ describe('SpritesClient.exec', () => { sock.open() sock.message(frame(1, 'partial')) sock.fireClose() - await expect(proc.wait()).rejects.toThrow(/before the process reported an exit/i) + await expect(proc.wait()).rejects.toThrow( + /before the process reported an exit/i, + ) }) it('kill() POSTs the kill endpoint with the session id, then wait() resolves 137', async () => { @@ -175,7 +179,10 @@ describe('SpritesClient.exec', () => { const controller = new AbortController() const c = client() - const proc = c.exec('sb', { argv: ['sleep', '100'], signal: controller.signal }) + const proc = c.exec('sb', { + argv: ['sleep', '100'], + signal: controller.signal, + }) const sock = StubWebSocket.last as StubWebSocket sock.open() // Abort BEFORE session_info arrives… @@ -187,9 +194,7 @@ describe('SpritesClient.exec', () => { await expect(proc.wait()).rejects.toThrow() // give the deferred kill a tick await new Promise((r) => setTimeout(r, 0)) - expect( - fetchCalls.some((c2) => c2.url.endsWith('/exec/42/kill')), - ).toBe(true) + expect(fetchCalls.some((c2) => c2.url.endsWith('/exec/42/kill'))).toBe(true) }) }) @@ -204,7 +209,10 @@ describe('SpritesClient.createCheckpoint', () => { listCount += 1 const list = listCount === 1 - ? [{ id: 'Current', is_auto: false }, { id: 'v1', is_auto: false }] + ? [ + { id: 'Current', is_auto: false }, + { id: 'v1', is_auto: false }, + ] : [ { id: 'Current', is_auto: false }, { id: 'v1', is_auto: false }, @@ -213,9 +221,12 @@ describe('SpritesClient.createCheckpoint', () => { return jsonResponse(list) } if (u.pathname.endsWith('/checkpoint') && method === 'POST') { - return new Response('{"type":"info","data":"Checkpoint v2 created"}\n', { - status: 200, - }) + return new Response( + '{"type":"info","data":"Checkpoint v2 created"}\n', + { + status: 200, + }, + ) } return new Response('nope', { status: 404 }) }) @@ -264,7 +275,11 @@ describe('SpritesClient lifecycle', () => { const c = client() const sprite = await c.createSprite('sb') - expect(sprite).toMatchObject({ name: 'sb', url: 'https://sb-x.sprites.app', urlAuth: 'public' }) + expect(sprite).toMatchObject({ + name: 'sb', + url: 'https://sb-x.sprites.app', + urlAuth: 'public', + }) await expect(c.deleteSprite('gone')).resolves.toBeUndefined() }) }) diff --git a/packages/ai-sandbox-sprites/tests/provider.test.ts b/packages/ai-sandbox-sprites/tests/provider.test.ts index af5c42ae9..89cea4aca 100644 --- a/packages/ai-sandbox-sprites/tests/provider.test.ts +++ b/packages/ai-sandbox-sprites/tests/provider.test.ts @@ -57,9 +57,12 @@ function installFetch(s: ProviderScenario = {}): void { if (get && method === 'GET') { const status = s.getStatus ?? 200 if (status >= 400) return new Response('err', { status }) - return new Response(JSON.stringify(sprite(decodeURIComponent(get[1] ?? ''))), { - status, - }) + return new Response( + JSON.stringify(sprite(decodeURIComponent(get[1] ?? ''))), + { + status, + }, + ) } if (get && method === 'PUT') return new Response('', { status: 200 }) if (get && method === 'DELETE') return new Response(null, { status: 204 })