diff --git a/.changeset/sprites-sandbox-provider.md b/.changeset/sprites-sandbox-provider.md new file mode 100644 index 000000000..dc39da79b --- /dev/null +++ b/.changeset/sprites-sandbox-provider.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-sprites': minor +--- + +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 24317a485..9c84a1cb6 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` | 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: @@ -132,6 +133,38 @@ 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) 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 + secrets. +- **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; + `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. +- **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..3b2f4c719 --- /dev/null +++ b/packages/ai-sandbox-sprites/package.json @@ -0,0 +1,55 @@ +{ + "name": "@tanstack/ai-sandbox-sprites", + "version": "0.1.0", + "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": { + "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" + }, + "engines": { + "node": ">=22.4.0" + }, + "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..dae531970 --- /dev/null +++ b/packages/ai-sandbox-sprites/src/client.ts @@ -0,0 +1,816 @@ +/** + * 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`. + * + * 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' + +/** 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' +} + +/** 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. */ + 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 + /** + * 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. */ +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 + /** Authorization header for control-plane and authenticated proxy requests. */ + authHeader: () => Record + 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 + 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 + probePath?: string + }, + ) => Promise +} + +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 } + } + + authHeader(): Record { + return { authorization: `Bearer ${this.apiKey}` } + } + + 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), + })) + } + + 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 { + // 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', + 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) + // 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 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(...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])) + } + + /** + * 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 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, + id: string, + options: { + signal?: AbortSignal + readyTimeoutMs?: number + /** Directory on the restored overlay used for the readiness probe. */ + probePath?: string + } = {}, + ): 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 delay(3000, options.signal) + await this.waitUntilReady( + name, + options.readyTimeoutMs ?? 600_000, + options.probePath ?? '/', + options.signal, + ) + } + + /** + * 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 opening or closing. + * + * 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 sentinel = `${probePath.replace(/\/$/, '')}/.tanstack-restore-probe` + const marker = `ready-${Date.now()}` + let lastError: unknown + let consecutive = 0 + while (Date.now() < deadline) { + signal?.throwIfAborted() + try { + 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 + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error + consecutive = 0 + lastError = error + } + await delay(2000, signal) + } + throw new Error( + `Sprites: "${name}" did not become ready within ${timeoutMs}ms after restore${ + lastError instanceof Error ? ` (last error: ${lastError.message})` : '' + }.`, + ) + } + + /** 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`), + { 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 killedByCaller = false + let opened = false + let settled = false + let socketError: Error | undefined + 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 + // 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' + + // 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() + stderrQ.end() + resolveClosed() + } + + ws.addEventListener('open', () => { + opened = true + clearTimeout(connectTimer) + }) + + ws.addEventListener('message', (event: MessageEvent) => { + const data: unknown = event.data + if (typeof data === 'string') { + // 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) { + 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()) + + // 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 { + // already closing/closed + } + } + + if (options.signal) { + onAbort = (): void => { + void terminate() + } + if (options.signal.aborted) onAbort() + else options.signal.addEventListener('abort', onAbort) + } + + return { + stdout: stdoutQ, + stderr: stderrQ, + wait: async (): Promise => { + await closed + // A real exit wins, even if a kill/abort raced in at the same instant. + if (exitObserved) return exitCode ?? 0 + // 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 ( + socketError ?? + new Error( + `Sprites exec connection closed before the process reported an exit code (${safeUrl}).`, + ) + ) + }, + kill: async (): Promise => { + killedByCaller = true + await terminate() + }, + } + } + + 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 +} + +/** 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 { + 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..0a256f346 --- /dev/null +++ b/packages/ai-sandbox-sprites/src/handle.ts @@ -0,0 +1,329 @@ +/** + * 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 + * 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). + * + * 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, + createExecBackedGit, +} from '@tanstack/ai-sandbox' +import type { + ExecResult, + ProcessOptions, + SandboxCapabilities, + SandboxChannel, + SandboxHandle, + SnapshotRef, + SpawnHandle, +} from '@tanstack/ai-sandbox' +import type { + SpriteCheckpoint, + SpriteUrlAuth, + 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, + // 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. + 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 + /** + * 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 { + 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 urlAuth: SpriteUrlAuth + 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.urlAuth = deps.urlAuth ?? 'public' + 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: ${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: ${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: ${errText(r)}`) + }, + 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 connectPort(port: number): Promise { + if (port !== this.httpPort) { + return Promise.reject( + new Error( + `sprites: only the proxied HTTP port ${this.httpPort} is reachable via the public URL; port ${port} is not exposed.`, + ), + ) + } + // 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(), + }) + } + + /** + * 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 { + 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 + // the (always-listable) root. + probePath: this.workdir, + }) + } + + 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, `'\\''`)}'` +} + +/** + * 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/index.ts b/packages/ai-sandbox-sprites/src/index.ts new file mode 100644 index 000000000..6109d2e19 --- /dev/null +++ b/packages/ai-sandbox-sprites/src/index.ts @@ -0,0 +1,15 @@ +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, + SpriteCheckpoint, + 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..748ebc03b --- /dev/null +++ b/packages/ai-sandbox-sprites/src/provider.ts @@ -0,0 +1,147 @@ +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 get urlAuth(): SpriteUrlAuth { + return this.config.urlAuth ?? 'public' + } + + 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, + urlAuth: this.urlAuth, + }) + } + + 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 } : {}), + }) + + 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. + // 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 + } + + 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 stateful sandboxes. 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/client.test.ts b/packages/ai-sandbox-sprites/tests/client.test.ts new file mode 100644 index 000000000..d999b0675 --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/client.test.ts @@ -0,0 +1,285 @@ +/* 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 new file mode 100644 index 000000000..405efe4d5 --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/handle.test.ts @@ -0,0 +1,257 @@ +/* eslint-disable @typescript-eslint/require-await -- trivial fixed-value fakes */ +import { describe, expect, it, vi } from 'vitest' +import { SpritesHandle } from '../src/handle' +import type { + SpriteCheckpoint, + 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 + checkpoints?: Array + newVersion?: string + onExec?: (name: string, options: SpritesExecOptions) => SpritesExecStream +} + +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 ?? {} + + const client: SpritesClientLike = { + baseUrl: 'https://api.test', + authHeader: () => ({ authorization: 'Bearer test-token' }), + 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, + ) + }, + createCheckpoint, + listCheckpoints: () => Promise.resolve(options.checkpoints ?? []), + restoreCheckpoint, + } + return { + client, + setUrlAuth, + deleteSprite, + createCheckpoint, + restoreCheckpoint, + execCalls, + } +} + +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 } +} + +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('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(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 () => { + const { handle } = makeHandle({}) + await expect(handle.ports.connect(3000)).rejects.toThrow(/8080/) + }) +}) + +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', { + probePath: '/home/sprite', + }) + expect(restoreCheckpoint).toHaveBeenNthCalledWith(2, 'my-sprite', 'v3', { + probePath: '/home/sprite', + }) + }) + + 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) + }) +}) + +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/provider.test.ts b/packages/ai-sandbox-sprites/tests/provider.test.ts new file mode 100644 index 000000000..89cea4aca --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/provider.test.ts @@ -0,0 +1,132 @@ +/* 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() + }) +}) 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..bfc991891 --- /dev/null +++ b/packages/ai-sandbox-sprites/tests/sprites.test.ts @@ -0,0 +1,111 @@ +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 +// 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('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 + 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..5b73ddd4f 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 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. 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':