From 650d18163850b862983861340e4da5365b87cd95 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Mon, 6 Jul 2026 14:00:41 +0300 Subject: [PATCH] feat(ai-sandbox-upstash-box): add Upstash Box sandbox provider Add @tanstack/ai-sandbox-upstash-box, a provider that runs harness adapters inside isolated Upstash Box cloud sandboxes through the uniform SandboxHandle: - fs via Box's native file API (read/write/list) + shell for mkdir/remove/ rename/exists; paths normalized between the virtual /workspace root and Box's /workspace/home session home - exec/spawn shell-wrap cwd + env (exports before `cd` so a failed cd is &&-gated); spawn streams stdout over exec.stream with abort-driven kill() - ports.connect via getPublicURL (bearer/basic auth -> channel headers) - native snapshots (box.snapshot / Box.fromSnapshot) and restoreSnapshot - backgroundProcesses: true / writableStdin: false (Daytona parity); fork unsupported Includes unit tests (mocked Box) and gated integration tests (UPSTASH_BOX_API_KEY), README, changeset, and the central provider-list row. --- .changeset/upstash-box-sandbox-provider.md | 12 + packages/ai-sandbox-upstash-box/README.md | 91 +++++ packages/ai-sandbox-upstash-box/package.json | 55 +++ packages/ai-sandbox-upstash-box/src/handle.ts | 386 ++++++++++++++++++ packages/ai-sandbox-upstash-box/src/index.ts | 4 + .../ai-sandbox-upstash-box/src/provider.ts | 144 +++++++ .../tests/handle.test.ts | 286 +++++++++++++ .../tests/upstash-box.test.ts | 64 +++ packages/ai-sandbox-upstash-box/tsconfig.json | 8 + .../ai-sandbox-upstash-box/vite.config.ts | 37 ++ packages/ai-sandbox/README.md | 1 + 11 files changed, 1088 insertions(+) create mode 100644 .changeset/upstash-box-sandbox-provider.md create mode 100644 packages/ai-sandbox-upstash-box/README.md create mode 100644 packages/ai-sandbox-upstash-box/package.json create mode 100644 packages/ai-sandbox-upstash-box/src/handle.ts create mode 100644 packages/ai-sandbox-upstash-box/src/index.ts create mode 100644 packages/ai-sandbox-upstash-box/src/provider.ts create mode 100644 packages/ai-sandbox-upstash-box/tests/handle.test.ts create mode 100644 packages/ai-sandbox-upstash-box/tests/upstash-box.test.ts create mode 100644 packages/ai-sandbox-upstash-box/tsconfig.json create mode 100644 packages/ai-sandbox-upstash-box/vite.config.ts diff --git a/.changeset/upstash-box-sandbox-provider.md b/.changeset/upstash-box-sandbox-provider.md new file mode 100644 index 000000000..d2991cf32 --- /dev/null +++ b/.changeset/upstash-box-sandbox-provider.md @@ -0,0 +1,12 @@ +--- +'@tanstack/ai-sandbox-upstash-box': minor +--- + +Add `@tanstack/ai-sandbox-upstash-box`, an Upstash Box sandbox provider. Runs +harness adapters inside isolated Upstash Box cloud sandboxes through the uniform +`SandboxHandle` — real filesystem (native Box file API + shell fallbacks), shell +`exec`, streamed background processes (`spawn` over `exec.stream`), public +preview URLs via `getPublicURL`, and native snapshots (`box.snapshot()` / +`Box.fromSnapshot()`). Like the Daytona provider, spawned processes have no +writable stdin (`writableStdin: false`), so stdin-driven harnesses must deliver +their prompt via a file + shell redirection. diff --git a/packages/ai-sandbox-upstash-box/README.md b/packages/ai-sandbox-upstash-box/README.md new file mode 100644 index 000000000..ba77e34a7 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/README.md @@ -0,0 +1,91 @@ +# @tanstack/ai-sandbox-upstash-box + +Upstash Box sandbox provider for [TanStack AI](https://tanstack.com/ai). Runs +harness adapters inside isolated [Upstash Box](https://github.com/upstash/box) +cloud sandboxes through the uniform `SandboxHandle` — real filesystem, shell, +public preview URLs, and native snapshots. + +## Install + +```bash +npm install @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-upstash-box +``` + +## Usage + +```ts +import { + defineSandbox, + defineWorkspace, + withSandbox, +} from '@tanstack/ai-sandbox' +import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box' + +const sandbox = defineSandbox({ + id: 'agent', + provider: upstashBoxSandbox({ + apiKey: process.env.UPSTASH_BOX_API_KEY, // or set the env var and omit + runtime: 'node', + }), + workspace: defineWorkspace({ + /* … */ + }), +}) + +// Then pass `withSandbox(sandbox)` as chat() middleware. +``` + +The API key falls back to the `UPSTASH_BOX_API_KEY` environment variable when +`apiKey` is omitted. + +### End-to-end example + +Using the provider directly through the uniform `SandboxHandle` (no harness / +`chat()` involved): + +```ts +import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box' + +const provider = upstashBoxSandbox({ runtime: 'node' }) +const box = await provider.create({}) +try { + await box.fs.write('/workspace/hello.txt', 'hello from upstash box') + console.log(await box.fs.read('/workspace/hello.txt')) + + const run = await box.process.exec('node --version') + console.log('node', run.stdout.trim(), '(exit', run.exitCode, ')') + + const channel = await box.ports.connect(3000) + console.log('preview url:', channel.url) +} finally { + await box.destroy() +} +``` + +## Configuration + +| Option | Default | Notes | +| --------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiKey` | `UPSTASH_BOX_API_KEY` | Upstash Box API key. | +| `baseUrl` | SDK default | Overrides the Box API base URL. | +| `runtime` | `node` | Box runtime image. | +| `size` | `small` | Box resource size. | +| `keepAlive` | `false` | `false` avoids billing a perpetually-running box and keeps `pause()` available. `true` prevents auto-pause mid-run but bills continuously and disables pausing. | +| `snapshot` | — | Base snapshot id to create the box from (routed through `Box.fromSnapshot`). | +| `name` | — | Human-readable box name. The caller's deterministic sandbox id (from `ensure()`) takes precedence when present. | +| `publicUrlAuth` | none | `{ bearerToken?, basicAuth? }` — auth to request when minting public URLs via `ports.connect`. | + +## Capabilities + +| Capability | Supported | Notes | +| --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------ | +| `fs` | ✅ | Native Box file API (read/write/list) + shell for mkdir/remove/rename. | +| `exec` | ✅ | Combined stdout/stderr in `stdout`; `stderr` is always empty. | +| `env` | ✅ | Applied as shell `export` prefixes on subsequent `exec` calls. | +| `ports` | ✅ | Public preview URLs via `getPublicURL`. | +| `snapshots` | ✅ | Native `box.snapshot()` / `Box.fromSnapshot()`. | +| `durableFilesystem` | ✅ | Persists across pause/resume until deleted. | +| `backgroundProcesses` | ✅ | `spawn()` streams stdout via `exec.stream`; no host-visible pid, `kill()` stops consuming the stream. | +| `writableStdin` | ❌ | No host→process stdin; stdin-driven harnesses (Codex/Gemini ACP) must deliver the prompt via a file + shell redirection. | +| `networkPolicy` | ❌ | | +| `fork` | ❌ | `fork()` throws. | diff --git a/packages/ai-sandbox-upstash-box/package.json b/packages/ai-sandbox-upstash-box/package.json new file mode 100644 index 000000000..467af3472 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/package.json @@ -0,0 +1,55 @@ +{ + "name": "@tanstack/ai-sandbox-upstash-box", + "version": "0.1.0", + "description": "Upstash Box sandbox provider for TanStack AI — run harness adapters inside isolated Upstash Box cloud sandboxes through the uniform SandboxHandle.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-sandbox-upstash-box" + }, + "keywords": [ + "ai", + "tanstack", + "sandbox", + "upstash", + "box", + "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" + }, + "dependencies": { + "@upstash/box": "^0.5.1" + }, + "peerDependencies": { + "@tanstack/ai-sandbox": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai-sandbox": "workspace:*", + "@vitest/coverage-v8": "4.0.14" + } +} diff --git a/packages/ai-sandbox-upstash-box/src/handle.ts b/packages/ai-sandbox-upstash-box/src/handle.ts new file mode 100644 index 000000000..55f76c786 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/src/handle.ts @@ -0,0 +1,386 @@ +/** + * SandboxHandle backed by an Upstash Box cloud sandbox (via `@upstash/box`). + * Real isolation: fs/exec/git operate inside the remote box. The conventional + * `/workspace` virtual root maps to Box's session home, `/workspace/home` + * (`Box.WORKSPACE`). + * + * Filesystem read/write/list use Box's native file API; mkdir/remove/rename/ + * exists desugar to `process.exec` (the box image provides `sh` + coreutils). + * + * NOTES on parity with the contract: + * - Box's `exec.command` returns a single combined `result` string plus an + * `exitCode`; there is no separate stderr channel, so {@link ExecResult.stderr} + * is always empty for this provider. + * - `exec.command` takes no per-call cwd/env arguments, and the SDK's `cd()` / + * `box.cwd` is in-memory client state that resets when a box is re-fetched with + * `Box.get()`. So cwd and env are applied by shell-wrapping every command + * (`cd && export … && `). + * - Background processes (`process.spawn`) run on Box's `exec.stream`: stdout is + * streamed, `wait()` resolves with the exit code. There is no writable stdin + * (`capabilities.writableStdin` is false) and no host-visible pid, and `kill()` + * only stops consuming the stream (the server-side command keeps running) — + * the same shape as the Daytona provider's session-backed spawn. + */ +import { Buffer } from 'node:buffer' +import { + UnsupportedCapabilityError, + createExecBackedGit, +} from '@tanstack/ai-sandbox' +import type { Box, ExecStreamChunk } from '@upstash/box' +import type { + ExecResult, + ProcessOptions, + SandboxCapabilities, + SandboxChannel, + SandboxHandle, + SnapshotRef, + SpawnHandle, +} from '@tanstack/ai-sandbox' + +export const UPSTASH_BOX_CAPS: SandboxCapabilities = { + fs: true, + exec: true, + env: true, + ports: true, + // spawn() streams a background command via Box's exec.stream. + backgroundProcesses: true, + // The streamed command has no host->process stdin; adapters that feed a prompt + // over stdin must deliver it via a file + shell redirection instead. + writableStdin: false, + // Native box.snapshot / Box.fromSnapshot. + snapshots: true, + networkPolicy: false, + // The box filesystem persists across exec calls and pause/resume until deleted. + durableFilesystem: true, + fork: false, +} + +/** The `/workspace` virtual root maps to Box's session home. */ +export const WORKSPACE_ROOT = '/workspace/home' + +/** POSIX single-quote escape for embedding a value in a `sh -c` command. */ +function q(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +/** + * A push-driven async iterable. The streamer pushes decoded chunks and calls + * `end()` once; consumers `for await` over it 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)) + }, + } + } +} + +/** Auth to request when minting a public URL for a port (Box's shape). */ +export interface PublicUrlAuth { + bearerToken?: boolean + basicAuth?: boolean +} + +export interface UpstashBoxHandleDeps { + /** The live Upstash Box object. */ + box: Box + /** Auth requested for `ports.connect` public URLs. Defaults to none. */ + publicUrlAuth?: PublicUrlAuth +} + +export class UpstashBoxHandle implements SandboxHandle { + readonly id: string + readonly provider = 'upstash-box' + readonly workspaceRoot = WORKSPACE_ROOT + readonly capabilities = UPSTASH_BOX_CAPS + readonly fs: SandboxHandle['fs'] + readonly git: SandboxHandle['git'] + readonly process: SandboxHandle['process'] + readonly ports: SandboxHandle['ports'] + readonly env: SandboxHandle['env'] + + private readonly box: Box + private readonly publicUrlAuth?: PublicUrlAuth + private readonly envVars: Record = {} + + constructor(deps: UpstashBoxHandleDeps) { + this.box = deps.box + this.publicUrlAuth = deps.publicUrlAuth + this.id = deps.box.id + + this.process = { + exec: (command, opts) => this.exec(command, opts), + spawn: (command, opts) => this.spawnProcess(command, opts), + } + + this.fs = { + read: (p) => this.box.files.read(this.abs(p)), + readBytes: async (p) => { + const b64 = await this.box.files.read(this.abs(p), { + encoding: 'base64', + }) + return new Uint8Array(Buffer.from(b64, 'base64')) + }, + write: async (p, data) => { + const abs = this.abs(p) + // Box's file API does not guarantee parent-dir creation; ensure it. + const dir = abs.replace(/\/[^/]*$/, '') || '/' + const mk = await this.exec(`mkdir -p ${q(dir)}`) + if (mk.exitCode !== 0) + throw new Error(`write failed (mkdir): ${mk.stdout.trim()}`) + if (typeof data === 'string') { + await this.box.files.write({ path: abs, content: data }) + } else { + await this.box.files.write({ + path: abs, + content: Buffer.from(data).toString('base64'), + encoding: 'base64', + }) + } + }, + list: async (p) => { + const entries = await this.box.files.list(this.abs(p)) + // Return paths in the caller's virtual namespace (/workspace/...), + // not Box's physical /workspace/home/... paths. + const base = p.replace(/\/$/, '') + return entries.map((e) => ({ + name: e.name, + path: `${base}/${e.name}`, + type: e.is_dir ? ('dir' as const) : ('file' as const), + })) + }, + mkdir: async (p) => { + const r = await this.exec(`mkdir -p ${q(this.abs(p))}`) + if (r.exitCode !== 0) + throw new Error(`mkdir failed: ${r.stdout.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.stdout.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.stdout.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.workspaceRoot) + + 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 box home. */ + private abs(p: string): string { + // Already an absolute box-home path — leave untouched. + if (p === this.workspaceRoot || p.startsWith(`${this.workspaceRoot}/`)) { + return p + } + if (p === '/workspace') return this.workspaceRoot + if (p.startsWith('/workspace/')) { + return `${this.workspaceRoot}/${p.slice('/workspace/'.length)}` + } + return p + } + + /** + * Prefix a command with `export`s for the accumulated env vars (and any + * per-command overrides) so they apply to the executed command. Applied + * in-shell because Box's `exec.command` has no env argument. + */ + private withEnv(command: string, extra?: Record): string { + const merged = { ...this.envVars, ...extra } + const exports = Object.entries(merged) + .map(([k, v]) => `export ${k}=${q(v)}; `) + .join('') + return `${exports}${command}` + } + + /** + * Wrap a command with `cd ` and env exports. Done in-shell because Box's + * `exec.command`/`exec.stream` take no cwd/env args and the SDK's cwd resets + * when a box is re-fetched with `Box.get()`. + */ + private wrap(command: string, opts?: ProcessOptions): string { + const cwd = this.abs(opts?.cwd ?? this.workspaceRoot) + // Env exports go BEFORE `cd` so a failed `cd` (guarded by `&&`) prevents the + // command from running. Wrapping the exports around `cd … && command` — i.e. + // `export …; cd … && command` — keeps the command `&&`-gated on cd success; + // putting exports between `cd &&` and the command would let a `;`-separated + // command run even when cd failed. + return this.withEnv(`cd ${q(cwd)} && ${command}`, opts?.env) + } + + private async exec( + command: string, + opts?: ProcessOptions, + ): Promise { + // The Box SDK can't cancel an in-flight command, so this is a best-effort + // pre-flight check rather than true mid-flight cancellation. + opts?.signal?.throwIfAborted() + const run = await this.box.exec.command(this.wrap(command, opts)) + return { + // Box returns a single combined output string; there is no separate + // stderr channel for blocking exec. + stdout: run.result, + stderr: '', + exitCode: run.exitCode ?? 1, + } + } + + /** + * Background process backed by Box's `exec.stream`. stdout is streamed; + * `wait()` resolves with the exit code. There is no writable stdin and no + * host-visible pid; `kill()` stops consuming the stream (the server-side + * command keeps running). + */ + private async spawnProcess( + command: string, + opts?: ProcessOptions, + ): Promise { + opts?.signal?.throwIfAborted() + // Awaited so a failed stream start rejects spawn() rather than only + // surfacing later on wait()/stdout. + const stream = await this.box.exec.stream(this.wrap(command, opts)) + + const stdoutQ = new AsyncChunkQueue() + // Box's stream merges stderr into stdout; expose an empty, closed stderr. + const stderrQ = new AsyncChunkQueue() + let exitCode = 0 + // kill() and the caller's signal both feed this controller; its + // `signal.aborted` flag stops the consume loop. + const controller = new AbortController() + const onAbort = (): void => controller.abort() + opts?.signal?.addEventListener('abort', onAbort, { once: true }) + + // Resolves as a terminated iterator result the moment the controller aborts, + // so a killed but silent long-running stream unblocks `wait()` immediately + // instead of hanging until the next chunk/exit arrives. + const aborted = new Promise>((resolve) => { + const done = (): void => resolve({ done: true, value: undefined }) + if (controller.signal.aborted) done() + else controller.signal.addEventListener('abort', done, { once: true }) + }) + + const pump = (async (): Promise => { + const iterator = stream[Symbol.asyncIterator]() + try { + for (;;) { + const nextResult = iterator.next() + // Guard against an unhandled rejection if next() loses the race to + // `aborted` and later rejects; the winning branch still surfaces below. + nextResult.catch(() => undefined) + const result = await Promise.race([nextResult, aborted]) + if (result.done) break + const chunk = result.value + if (chunk.type === 'output') stdoutQ.push(chunk.data) + else exitCode = chunk.exitCode + } + } finally { + // Best-effort: close the underlying stream reader on kill/exit. + await iterator.return?.().catch(() => undefined) + opts?.signal?.removeEventListener('abort', onAbort) + stdoutQ.end() + stderrQ.end() + } + })() + + return { + pid: -1, + stdout: stdoutQ, + stderr: stderrQ, + stdin: { + write: () => + Promise.reject( + new Error( + 'upstash-box: background process stdin is not writable (see capabilities.writableStdin)', + ), + ), + end: () => Promise.resolve(), + }, + wait: async () => { + await pump + return exitCode + }, + kill: () => { + controller.abort() + return Promise.resolve() + }, + } + } + + private async connectPort(port: number): Promise { + const link = await this.box.getPublicURL(port, this.publicUrlAuth) + if (link.token) { + return { + url: link.url, + token: link.token, + headers: { Authorization: `Bearer ${link.token}` }, + } + } + if (link.username && link.password) { + const basic = Buffer.from(`${link.username}:${link.password}`).toString( + 'base64', + ) + return { url: link.url, headers: { Authorization: `Basic ${basic}` } } + } + return { url: link.url } + } + + snapshot = async (label?: string): Promise => { + const name = label ?? `tanstack-ai-${Date.now()}` + const snap = await this.box.snapshot({ name }) + return { id: snap.id, label: snap.name } + } + + fork = (): Promise => { + throw new UnsupportedCapabilityError('upstash-box', 'fork') + } + + async destroy(): Promise { + await this.box.delete() + } +} diff --git a/packages/ai-sandbox-upstash-box/src/index.ts b/packages/ai-sandbox-upstash-box/src/index.ts new file mode 100644 index 000000000..b633d24a6 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/src/index.ts @@ -0,0 +1,4 @@ +export { upstashBoxSandbox } from './provider' +export type { UpstashBoxSandboxConfig } from './provider' +export { UpstashBoxHandle, UPSTASH_BOX_CAPS, WORKSPACE_ROOT } from './handle' +export type { UpstashBoxHandleDeps, PublicUrlAuth } from './handle' diff --git a/packages/ai-sandbox-upstash-box/src/provider.ts b/packages/ai-sandbox-upstash-box/src/provider.ts new file mode 100644 index 000000000..f15e73cf1 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/src/provider.ts @@ -0,0 +1,144 @@ +import { Box } from '@upstash/box' +import { UPSTASH_BOX_CAPS, UpstashBoxHandle } from './handle' +import type { PublicUrlAuth } from './handle' +import type { BoxConfig, BoxSize, Runtime } from '@upstash/box' +import type { + SandboxCapabilities, + SandboxCreateInput, + SandboxDestroyInput, + SandboxHandle, + SandboxProvider, + SandboxRestoreInput, + SandboxResumeInput, +} from '@tanstack/ai-sandbox' + +export interface UpstashBoxSandboxConfig { + /** + * Upstash Box API key. Falls back to the `UPSTASH_BOX_API_KEY` env var (read + * by the SDK) when omitted. + */ + apiKey?: string + /** Base URL of the Box API (defaults to the SDK default / `UPSTASH_BOX_BASE_URL`). */ + baseUrl?: string + /** Runtime image for created boxes. Defaults to `node`. */ + runtime?: Runtime + /** Resource size for created boxes. Defaults to Box's default (`small`). */ + size?: BoxSize + /** + * Keep the box alive instead of allowing pause-based idle lifecycle. Defaults + * to `false` (Box's default): avoids billing a perpetually-running box and + * keeps `pause()` available. Set `true` to prevent auto-pause mid-run — note + * this bills continuously and disables pausing. + */ + keepAlive?: boolean + /** + * Base snapshot id to create the box from. `BoxConfig` has no snapshot field, + * so this is forwarded to `Box.fromSnapshot` instead of `Box.create`. + */ + snapshot?: string + /** Human-readable name for created boxes (also honors {@link SandboxCreateInput.id}). */ + name?: string + /** Auth to request when minting public URLs via `ports.connect`. */ + publicUrlAuth?: PublicUrlAuth +} + +const DEFAULT_RUNTIME: Runtime = 'node' + +class UpstashBoxProvider implements SandboxProvider { + readonly name = 'upstash-box' + + constructor(private readonly config: UpstashBoxSandboxConfig) {} + + capabilities(): SandboxCapabilities { + return UPSTASH_BOX_CAPS + } + + /** Connection options common to every static Box call. */ + private get connection(): { apiKey?: string; baseUrl?: string } { + const opts: { apiKey?: string; baseUrl?: string } = {} + if (this.config.apiKey !== undefined) opts.apiKey = this.config.apiKey + if (this.config.baseUrl !== undefined) opts.baseUrl = this.config.baseUrl + return opts + } + + private boxConfig(input?: { + env?: Record + name?: string + }): BoxConfig { + const cfg: BoxConfig = { + ...this.connection, + runtime: this.config.runtime ?? DEFAULT_RUNTIME, + keepAlive: this.config.keepAlive ?? false, + } + if (this.config.size !== undefined) cfg.size = this.config.size + // The caller's deterministic id (input.name) wins over a static config + // label so ensure()'s reconstructable id is honored. + const name = input?.name ?? this.config.name + if (name !== undefined) cfg.name = name + if (input?.env !== undefined) cfg.env = input.env + return cfg + } + + async create(input: SandboxCreateInput): Promise { + // Best-effort: the Box SDK can't cancel an in-flight create/snapshot, so we + // only pre-flight check the signal here. + input.signal?.throwIfAborted() + // Pass the caller's deterministic id through as the box name so it becomes + // a stable, addressable label (Box.getByName === Box.get). + const boxConfig = this.boxConfig({ env: input.env, name: input.id }) + const box = this.config.snapshot + ? await Box.fromSnapshot(this.config.snapshot, boxConfig) + : await Box.create(boxConfig) + return new UpstashBoxHandle({ + box, + publicUrlAuth: this.config.publicUrlAuth, + }) + } + + async resume(input: SandboxResumeInput): Promise { + input.signal?.throwIfAborted() + try { + const box = await Box.get(input.id, this.connection) + return new UpstashBoxHandle({ + box, + publicUrlAuth: this.config.publicUrlAuth, + }) + } catch { + // Gone / not found. + return null + } + } + + async restoreSnapshot(input: SandboxRestoreInput): Promise { + input.signal?.throwIfAborted() + const box = await Box.fromSnapshot( + input.snapshotId, + this.boxConfig({ env: input.env }), + ) + return new UpstashBoxHandle({ + box, + publicUrlAuth: this.config.publicUrlAuth, + }) + } + + async destroy(input: SandboxDestroyInput): Promise { + input.signal?.throwIfAborted() + try { + const box = await Box.get(input.id, this.connection) + await box.delete() + } catch { + // Already deleted / gone. + } + } +} + +/** + * Upstash Box sandbox provider — runs harness adapters inside isolated Upstash + * Box cloud sandboxes through the uniform `SandboxHandle`. Requires an Upstash + * Box API key (`config.apiKey` or the `UPSTASH_BOX_API_KEY` env var). + */ +export function upstashBoxSandbox( + config: UpstashBoxSandboxConfig = {}, +): SandboxProvider { + return new UpstashBoxProvider(config) +} diff --git a/packages/ai-sandbox-upstash-box/tests/handle.test.ts b/packages/ai-sandbox-upstash-box/tests/handle.test.ts new file mode 100644 index 000000000..d6be421c1 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/tests/handle.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from 'vitest' +import { UnsupportedCapabilityError } from '@tanstack/ai-sandbox' +import { UPSTASH_BOX_CAPS, UpstashBoxHandle } from '../src/handle' +import type { Box } from '@upstash/box' +import type { PublicUrlAuth } from '../src/handle' + +/** + * Build a fake Box exposing only the surface the handle touches. `exec.command` + * records every wrapped command and returns a successful run by default. + */ +type StreamChunk = + | { type: 'output'; data: string } + | { type: 'exit'; exitCode: number; cpuNs: number } + +function fakeBox( + overrides: { + exec?: (cmd: string) => { result: string; exitCode: number | null } + stream?: (cmd: string) => AsyncIterable + files?: Partial + getPublicURL?: Box['getPublicURL'] + snapshot?: Box['snapshot'] + delete?: Box['delete'] + } = {}, +) { + const commands: Array = [] + const box = { + id: 'box_123', + exec: { + command: vi.fn(async (cmd: string) => { + commands.push(cmd) + return overrides.exec?.(cmd) ?? { result: '', exitCode: 0 } + }), + stream: vi.fn(async (cmd: string) => { + commands.push(cmd) + return overrides.stream + ? overrides.stream(cmd) + : (async function* () {})() + }), + }, + files: { + read: vi.fn(async () => ''), + write: vi.fn(async () => {}), + list: vi.fn(async () => []), + ...overrides.files, + }, + getPublicURL: overrides.getPublicURL ?? vi.fn(), + snapshot: overrides.snapshot ?? vi.fn(), + delete: overrides.delete ?? vi.fn(async () => {}), + } + return { box: box as unknown as Box, commands } +} + +describe('UpstashBoxHandle', () => { + it('exposes the expected capabilities and identity', () => { + const { box } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + expect(handle.id).toBe('box_123') + expect(handle.provider).toBe('upstash-box') + expect(handle.workspaceRoot).toBe('/workspace/home') + expect(handle.capabilities).toBe(UPSTASH_BOX_CAPS) + expect(handle.capabilities.backgroundProcesses).toBe(true) + expect(handle.capabilities.snapshots).toBe(true) + expect(handle.capabilities.writableStdin).toBe(false) + }) + + it('shell-wraps exec with the mapped cwd and returns combined output', async () => { + const { box, commands } = fakeBox({ + exec: () => ({ result: 'hello', exitCode: 0 }), + }) + const handle = new UpstashBoxHandle({ box }) + const res = await handle.process.exec('echo hello') + expect(res).toEqual({ stdout: 'hello', stderr: '', exitCode: 0 }) + // Default cwd is the mapped workspace root. + expect(commands[0]).toBe("cd '/workspace/home' && echo hello") + }) + + it('maps /workspace cwd and applies env exports in order', async () => { + const { box, commands } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await handle.env.set({ FOO: 'bar' }) + await handle.process.exec('run', { + cwd: '/workspace/app', + env: { BAZ: 'q' }, + }) + // cwd is mapped through abs() (/workspace/app -> /workspace/home/app), and + // env exports go BEFORE `cd` so a failed cd (&&) prevents the command running. + expect(commands[0]).toBe( + "export FOO='bar'; export BAZ='q'; cd '/workspace/home/app' && run", + ) + }) + + it('spawn streams stdout via exec.stream and resolves wait() with the exit code', async () => { + async function* chunks(): AsyncGenerator { + yield { type: 'output', data: 'streamed-' } + yield { type: 'output', data: 'line\n' } + yield { type: 'exit', exitCode: 0, cpuNs: 0 } + } + const { box, commands } = fakeBox({ stream: () => chunks() }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('run-agent') + let out = '' + for await (const c of proc.stdout) out += c + expect(out).toBe('streamed-line\n') + expect(await proc.wait()).toBe(0) + expect(proc.pid).toBe(-1) + // Command is shell-wrapped with the mapped cwd, same as exec. + expect(commands[0]).toBe("cd '/workspace/home' && run-agent") + }) + + it('spawned process has no writable stdin', async () => { + const { box } = fakeBox({ stream: () => (async function* () {})() }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('run-agent') + await expect(proc.stdin.write('x')).rejects.toThrow() + }) + + it('kill() unblocks wait() even when the stream is silent', async () => { + // A stream whose next() never resolves and never ends on its own — only an + // abort can unblock the consumer. + const silent: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + }), + } + const { box } = fakeBox({ stream: () => silent }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('sleep-forever') + await proc.kill() + await expect(proc.wait()).resolves.toBe(0) + }) + + it('fork throws UnsupportedCapabilityError', () => { + const { box } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + expect(() => handle.fork()).toThrow(UnsupportedCapabilityError) + }) + + it('write mkdirs the parent dir then writes via the native file API', async () => { + const { box, commands } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await handle.fs.write('/workspace/dir/note.txt', 'hi') + // mkdir runs through the cd-wrapped exec at the default workspace root. + expect(commands[0]).toBe( + "cd '/workspace/home' && mkdir -p '/workspace/home/dir'", + ) + expect(box.files.write).toHaveBeenCalledWith({ + path: '/workspace/home/dir/note.txt', + content: 'hi', + }) + }) + + it('write base64-encodes binary data', async () => { + const { box } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await handle.fs.write('/workspace/bin', new Uint8Array([0, 1, 2, 250])) + expect(box.files.write).toHaveBeenCalledWith({ + path: '/workspace/home/bin', + content: Buffer.from([0, 1, 2, 250]).toString('base64'), + encoding: 'base64', + }) + }) + + it('readBytes decodes the base64 payload', async () => { + const { box } = fakeBox({ + files: { + read: vi.fn(async () => Buffer.from([9, 8, 7]).toString('base64')), + }, + }) + const handle = new UpstashBoxHandle({ box }) + const bytes = await handle.fs.readBytes('/workspace/bin') + expect(Array.from(bytes)).toEqual([9, 8, 7]) + expect(box.files.read).toHaveBeenCalledWith('/workspace/home/bin', { + encoding: 'base64', + }) + }) + + it('maps list entries to { name, path, type }', async () => { + const { box } = fakeBox({ + files: { + list: vi.fn(async () => [ + { + name: 'a', + path: '/workspace/home/a', + size: 1, + is_dir: false, + mod_time: '', + }, + { + name: 'sub', + path: '/workspace/home/sub', + size: 0, + is_dir: true, + mod_time: '', + }, + ]), + }, + }) + const handle = new UpstashBoxHandle({ box }) + const entries = await handle.fs.list('/workspace') + // Paths come back in the caller's virtual namespace, not Box's physical + // /workspace/home/... paths. + expect(entries).toEqual([ + { name: 'a', path: '/workspace/a', type: 'file' }, + { name: 'sub', path: '/workspace/sub', type: 'dir' }, + ]) + expect(box.files.list).toHaveBeenCalledWith('/workspace/home') + }) + + it('maps a bare public URL to a plain channel', async () => { + const { box } = fakeBox({ + getPublicURL: vi.fn(async () => ({ + url: 'https://box_123-3000.preview.box.upstash.com', + port: 3000, + })), + }) + const handle = new UpstashBoxHandle({ box }) + const channel = await handle.ports.connect(3000) + expect(channel).toEqual({ + url: 'https://box_123-3000.preview.box.upstash.com', + }) + }) + + it('maps a bearer-token URL to Authorization: Bearer headers', async () => { + const auth: PublicUrlAuth = { bearerToken: true } + const getPublicURL = vi.fn(async () => ({ + url: 'https://u', + port: 3000, + token: 'tok', + })) as Box['getPublicURL'] + const { box } = fakeBox({ getPublicURL }) + const handle = new UpstashBoxHandle({ box, publicUrlAuth: auth }) + const channel = await handle.ports.connect(3000) + expect(channel).toEqual({ + url: 'https://u', + token: 'tok', + headers: { Authorization: 'Bearer tok' }, + }) + expect(getPublicURL).toHaveBeenCalledWith(3000, auth) + }) + + it('maps basic-auth credentials to Authorization: Basic headers', async () => { + const { box } = fakeBox({ + getPublicURL: vi.fn(async () => ({ + url: 'https://u', + port: 8080, + username: 'user', + password: 'pass', + })), + }) + const handle = new UpstashBoxHandle({ + box, + publicUrlAuth: { basicAuth: true }, + }) + const channel = await handle.ports.connect(8080) + expect(channel).toEqual({ + url: 'https://u', + headers: { + Authorization: `Basic ${Buffer.from('user:pass').toString('base64')}`, + }, + }) + }) + + it('snapshot delegates to box.snapshot and returns a SnapshotRef', async () => { + const snapshot = vi.fn(async () => ({ + id: 'snap_1', + name: 'label', + box_id: 'box_123', + size_bytes: 0, + status: 'ready' as const, + created_at: 0, + })) as Box['snapshot'] + const { box } = fakeBox({ snapshot }) + const handle = new UpstashBoxHandle({ box }) + const ref = await handle.snapshot('label') + expect(ref).toEqual({ id: 'snap_1', label: 'label' }) + expect(snapshot).toHaveBeenCalledWith({ name: 'label' }) + }) + + it('destroy deletes the box', async () => { + const del = vi.fn(async () => {}) as Box['delete'] + const { box } = fakeBox({ delete: del }) + const handle = new UpstashBoxHandle({ box }) + await handle.destroy() + expect(del).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/ai-sandbox-upstash-box/tests/upstash-box.test.ts b/packages/ai-sandbox-upstash-box/tests/upstash-box.test.ts new file mode 100644 index 000000000..0b2db35dd --- /dev/null +++ b/packages/ai-sandbox-upstash-box/tests/upstash-box.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { upstashBoxSandbox } from '../src/index' +import type { SandboxHandle } from '@tanstack/ai-sandbox' + +// Auto-gate: only run when an Upstash Box API key is present (these tests create +// real cloud boxes and are billed). +const apiKey = process.env.UPSTASH_BOX_API_KEY + +describe.skipIf(!apiKey)( + 'upstash-box provider (gated on UPSTASH_BOX_API_KEY)', + () => { + it('creates a box, runs exec, fs round-trip + destroy', async () => { + const provider = upstashBoxSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + + const echo = await sbx.process.exec('echo hello-box') + expect(echo.stdout.trim()).toBe('hello-box') + expect(echo.exitCode).toBe(0) + + await sbx.fs.write('/workspace/note.txt', 'inside the box') + expect(await sbx.fs.exists('/workspace/note.txt')).toBe(true) + expect(await sbx.fs.read('/workspace/note.txt')).toBe('inside the box') + + 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, + ]) + + // Background process: stream stdout via exec.stream and wait for exit. + const proc = await sbx.process.spawn('echo streamed-line') + let out = '' + for await (const chunk of proc.stdout) out += chunk + expect(out).toContain('streamed-line') + expect(await proc.wait()).toBe(0) + } finally { + await sbx?.destroy() + } + }, 300_000) + + it('snapshots a box and restores it into a new box', async () => { + const provider = upstashBoxSandbox({ apiKey }) + let source: SandboxHandle | undefined + let restored: SandboxHandle | undefined + try { + source = await provider.create({}) + await source.fs.write('/workspace/keep.txt', 'survives snapshot') + + const ref = await source.snapshot?.('test-snapshot') + expect(ref?.id).toBeTruthy() + + restored = await provider.restoreSnapshot!({ snapshotId: ref!.id }) + expect(await restored.fs.read('/workspace/keep.txt')).toBe( + 'survives snapshot', + ) + } finally { + await source?.destroy() + await restored?.destroy() + } + }, 300_000) + }, +) diff --git a/packages/ai-sandbox-upstash-box/tsconfig.json b/packages/ai-sandbox-upstash-box/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-sandbox-upstash-box/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-upstash-box/vite.config.ts b/packages/ai-sandbox-upstash-box/vite.config.ts new file mode 100644 index 000000000..11f5b20b7 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/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 5b73ddd4f..efe219c1a 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-upstash-box` | Upstash Box cloud sandboxes, snapshots | | `@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.