diff --git a/.changeset/upstash-box-sandbox-provider.md b/.changeset/upstash-box-sandbox-provider.md new file mode 100644 index 0000000000..538bd04edb --- /dev/null +++ b/.changeset/upstash-box-sandbox-provider.md @@ -0,0 +1,16 @@ +--- +'@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` — a native filesystem (including `stat`-backed `exists`), shell +`exec` with separate stdout and stderr, background processes over Box's live +`exec.session` (real in-box pid, writable stdin, and `kill()` that signals the +process tree server-side), public preview URLs via `getPublicURL`, and native +snapshots (`box.snapshot()` / `Box.fromSnapshot()`), `fork()` built on the same +snapshot pair, and a `deny` network policy mapped onto Box's `deny-all` egress +mode. + +Requires `@upstash/box` 0.7.1 or newer for `exec.session` and the filesystem +metadata operations. diff --git a/docs/config.json b/docs/config.json index dfe4cd2779..3b4be60324 100644 --- a/docs/config.json +++ b/docs/config.json @@ -578,7 +578,7 @@ "label": "Providers", "to": "sandbox/providers", "addedAt": "2026-06-29", - "updatedAt": "2026-08-18" + "updatedAt": "2026-08-24" }, { "label": "Harnesses", diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index 51b3920065..3af51eb488 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -2,7 +2,7 @@ title: Providers id: providers order: 3 -description: "Pick and configure where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, or Vercel) and what each one can do." +description: "Pick and configure where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, or Upstash Box) and what each one can do." --- A provider owns the isolation primitive: where the harness actually runs. Every @@ -30,6 +30,7 @@ completed workspace data in your application persistence for reconstruction. | Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; snapshots after setup, 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`. | +| Upstash Box | `@tanstack/ai-sandbox-upstash-box` | cloud sandbox | Managed [Upstash Box](https://github.com/upstash/box) sandboxes; interactive processes over a WebSocket session (real pid, stdin, signals), native snapshots, preview URLs, resume-by-id. Needs `UPSTASH_BOX_API_KEY`. | Most providers are their own package. `dockerSandbox()` and `sbxSandbox()` both come from `@tanstack/ai-sandbox-docker`. The constructor is the only thing that @@ -40,15 +41,17 @@ import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process' import { dockerSandbox, sbxSandbox } from '@tanstack/ai-sandbox-docker' import { daytonaSandbox } from '@tanstack/ai-sandbox-daytona' import { vercelSandbox } from '@tanstack/ai-sandbox-vercel' +import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box' const dev = localProcessSandbox() // runs on your host const isolated = dockerSandbox({ image: 'node:22' }) // container const microvm = sbxSandbox() // Docker Sandboxes microVM const daytona = daytonaSandbox({ apiKey: process.env.DAYTONA_API_KEY }) // managed cloud sandbox const vercel = vercelSandbox({ runtime: 'node24' }) // managed Vercel microVM +const box = upstashBoxSandbox({ apiKey: process.env.UPSTASH_BOX_API_KEY }) // managed Upstash Box ``` -> Cloud providers (Daytona, Vercel) run as remote VMs. When you drive them from +> Cloud providers (Daytona, Vercel, Upstash Box) run as remote VMs. When you drive them from > your laptop, [tools](./tools) bridged from `chat()` can't dial your machine's > `localhost`, you need the bridge tunnel. See the [tools guide](./tools) for the > ngrok subpath, and the [Cloudflare guide](./cloudflare) for the edge-native @@ -300,6 +303,50 @@ const sprites = spritesSandbox({ apiKey: process.env.SPRITES_API_KEY }) - **Bridge:** like Daytona and Vercel, it is a remote VM, so bridged tools need the tunnel in local dev (see [tools](./tools)). +## Upstash Box + +```ts +import { upstashBoxSandbox } from '@tanstack/ai-sandbox-upstash-box' + +const box = upstashBoxSandbox({ apiKey: process.env.UPSTASH_BOX_API_KEY }) +``` + +- **Isolation:** a managed [Upstash Box](https://github.com/upstash/box) cloud + sandbox, a remote container you do not run yourself. +- **Auth / env:** needs `UPSTASH_BOX_API_KEY` (or `apiKey`); override the API + base with `baseUrl` / `UPSTASH_BOX_BASE_URL`. Pick the image and size with + `runtime` (default `node`) and `size`. +- **Paths:** the conventional `/workspace` virtual root maps to the box home, + `/workspace/home`, which is the handle's `workspaceRoot`. +- **Processes:** `spawn()` opens a live `exec.session` over a WebSocket, so a + background process has a real in-box pid, a writable stdin, separate stdout and + stderr, and server-side signals. A session owns its process: dropping the + connection kills the command and sessions cannot be reattached, so `spawn()` is + scoped to the lifetime of the handle rather than the box. Blocking `exec()` + stays on the HTTP path and is shell-wrapped for `cwd`/env, which the session + takes natively. +- **Snapshot / resume:** `snapshot()` calls `box.snapshot()` and + `restoreSnapshot()` reconstructs a new box from it via `Box.fromSnapshot()`, so + a snapshot survives deletion of the box that made it. Resume-by-id uses + `Box.get` (id or name) and probes `getStatus`, so a deleted record resumes as + `null` rather than a tombstone handle. +- **Ports:** `ports.connect(port)` mints a preview URL via `getPublicURL`. Pass + `publicUrlAuth` to gate it, `{ bearerToken: true }` returns a token plus an + `Authorization: Bearer` header and `{ basicAuth: true }` returns Basic + credentials; without it the preview URL is unauthenticated. +- **Network:** a `policy.capabilities.network` of `'deny'` maps to Box's + `deny-all` egress mode. The contract's gate is coarse, so Box's domain and CIDR + allowlists are not reachable through it. This is stricter than providers that + model deny as an allowlist: `deny-all` blocks every outbound connection, so an + agent that works under an allowlist-style deny will not reach package + registries or model provider hosts here. Leave the capability unset if the + agent needs either. +- **Fork:** `fork()` snapshots the box and creates a new one from that snapshot, + the same shape as Docker's commit plus create. It costs a full snapshot round + trip (about 25 seconds), unlike Docker's local commit. +- **Bridge:** like Daytona and Vercel, it is a remote VM, so bridged tools need + the tunnel in local dev (see [tools](./tools)). + ## Capabilities Providers declare what they support via `capabilities()`. The flags are: @@ -311,7 +358,7 @@ Providers declare what they support via `capabilities()`. The flags are: | `env` | Inject environment variables. | | `ports` | Expose/forward ports (preview URLs). | | `backgroundProcesses` | Keep long-running processes alive between calls. | -| `writableStdin` | A spawned process exposes a writable host→process stdin. `true` for local-process, Docker container, and Daytona. `false` for Docker Sandboxes (`sbx`), Vercel, and Cloudflare. When `false`, stdin-fed harnesses write the prompt to a file and redirect it in the shell. | +| `writableStdin` | A spawned process exposes a writable host→process stdin. `true` for local-process, Docker container, Daytona, and Upstash Box. `false` for Docker Sandboxes (`sbx`), Vercel, Sprites, and Cloudflare. When `false`, stdin-fed harnesses write the prompt to a file and redirect it in the shell. | | `killableProcesses` | A spawned process can be forcibly stopped via `SpawnHandle.kill()` **and** aborted mid-flight via the `signal` passed to `spawn`. | | `snapshots` | Capture and restore point-in-time snapshots. | | `networkPolicy` | Enforce network allow/deny rules. | @@ -360,6 +407,7 @@ merely slower while a wrong `follow` is a leak. | Daytona | `false` | `kill()` only aborts the client-side poll loop and does not await any termination; the `deleteSession` that might terminate the command runs later from the pump's teardown, is failure-swallowed, and is documented as cleanup for a *completed* session. Unmeasured, needs `DAYTONA_API_KEY`. | | Vercel | `false` | The abort signal reaches only the HTTP request that STARTS a detached command, so the old `kill()` was a no-op. It now issues the SDK's server-side `Command.kill`, but whether that reaches a forked child (the follow command is a multi-statement shell, so `tail -f` is always a child) is unmeasured, needs Vercel credentials. | | Sprites | `true` (unverified) | Not a client-side detach: `kill()` issues a real server-side `POST /exec//kill` before closing the socket. What that endpoint signals (process group or pid) is undocumented and unmeasured; needs `SPRITES_API_KEY`. | +| Upstash Box | `true` | **Measured.** `kill()` sends an allowlisted signal (`TERM`/`KILL`/`INT`/`HUP`) that the box agent delivers to the process TREE server-side, so a forked child is signalled too. Verified against production: a spawned `sleep 5 && touch ` was killed and the marker never appeared. Needs `UPSTASH_BOX_API_KEY`. | | Cloudflare | `false` | `kill()` is a no-op, and the caller's `AbortSignal` reaches neither `exec` nor `spawn`, because Workers RPC cannot serialize one. | Each of the remote providers registers the shared journal conformance suite, so diff --git a/packages/ai-sandbox-upstash-box/README.md b/packages/ai-sandbox-upstash-box/README.md new file mode 100644 index 0000000000..7437d535ff --- /dev/null +++ b/packages/ai-sandbox-upstash-box/README.md @@ -0,0 +1,100 @@ +# @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, +interactive processes, 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 throughout; `exists` is a `stat` probe. | +| `exec` | ✅ | Separate `stdout` and `stderr`. | +| `env` | ✅ | Shell `export` prefixes for `exec`; passed natively to `spawn`. | +| `ports` | ✅ | Public preview URLs via `getPublicURL`. | +| `snapshots` | ✅ | Native `box.snapshot()` / `Box.fromSnapshot()`. | +| `durableFilesystem` | ✅ | Persists across pause/resume until deleted. | +| `backgroundProcesses` | ✅ | `spawn()` runs the command as a live `exec.session` with a real in-box pid. | +| `writableStdin` | ✅ | `stdin.write()` / `stdin.end()` map to the session's `write` / `endStdin`. | +| `killableProcesses` | ✅ | `kill()` signals the process tree server-side; `TERM`/`KILL`/`INT`/`HUP`, others send TERM. | +| `networkPolicy` | ✅ | `policy.capabilities.network: 'deny'` maps to Box's `deny-all` egress mode. | +| `fork` | ✅ | `snapshot()` + `Box.fromSnapshot()`. Costs a full snapshot round trip (~25s). | + +A spawned process is tied to its session: dropping the connection kills the +command, and sessions cannot be reattached. `spawn()` is therefore scoped to the +lifetime of the handle, not the box. + +`network: 'deny'` is stricter here than under providers that model deny as an +allowlist. Box's `deny-all` blocks every outbound connection, so an agent that +runs fine under an allowlist-style deny will not reach package registries or +model provider hosts on this one. Leave the capability unset if the agent needs +either. diff --git a/packages/ai-sandbox-upstash-box/package.json b/packages/ai-sandbox-upstash-box/package.json new file mode 100644 index 0000000000..442c9f9191 --- /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 \u2014 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": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "@upstash/box": "^0.7.1" + }, + "peerDependencies": { + "@tanstack/ai-sandbox": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai-sandbox": "workspace:*", + "@vitest/coverage-v8": "4.1.10" + } +} 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 0000000000..c16af6e45e --- /dev/null +++ b/packages/ai-sandbox-upstash-box/src/handle.ts @@ -0,0 +1,431 @@ +import { Buffer } from 'node:buffer' +import { + UnsupportedCapabilityError, + createExecBackedGit, +} from '@tanstack/ai-sandbox' +import { Box, BoxError } from '@upstash/box' +import type { BoxConfig, ExecSessionHandle } from '@upstash/box' +import type { + ExecResult, + ProcessOptions, + SandboxCapabilities, + SandboxChannel, + SandboxFsStat, + SandboxHandle, + SnapshotRef, + SpawnHandle, +} from '@tanstack/ai-sandbox' + +export const UPSTASH_BOX_CAPS: SandboxCapabilities = { + fs: true, + exec: true, + env: true, + ports: true, + backgroundProcesses: true, + writableStdin: true, + killableProcesses: true, + snapshots: true, + networkPolicy: true, + durableFilesystem: true, + fork: true, +} + +export const WORKSPACE_ROOT = '/workspace/home' + +export function isNotFoundError(error: unknown): boolean { + return error instanceof BoxError && error.statusCode === 404 +} + +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ + +function assertEnvName(key: string): void { + if (!ENV_NAME.test(key)) { + throw new Error( + `upstash-box: invalid environment variable name ${JSON.stringify(key)}`, + ) + } +} + +const BOX_SIGNALS = new Set(['TERM', 'KILL', 'INT', 'HUP']) + +const SIGNAL_NUMBERS: Record = { + 1: 'HUP', + 2: 'INT', + 9: 'KILL', + 15: 'TERM', +} + +function toBoxSignal(signal?: NodeJS.Signals | number): string { + if (signal === undefined) return 'TERM' + const name = + typeof signal === 'number' + ? SIGNAL_NUMBERS[signal] + : signal.replace(/^SIG/, '') + return name !== undefined && BOX_SIGNALS.has(name) ? name : 'TERM' +} + +function q(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +const LSTAT_MISSING = '__TANSTACK_LSTAT_MISSING__' + +function lstatCommand(path: string): string { + return `tanstack_lstat_path=${q(path)}; tanstack_lstat_output=$(stat -c '%f:%s' -- "$tanstack_lstat_path" 2>&1); tanstack_lstat_status=$?; if [ "$tanstack_lstat_status" -eq 0 ]; then printf '%s\n' "$tanstack_lstat_output"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case "$tanstack_missing_path" in /|.) return 1 ;; */*) tanstack_parent=${'$'}{tanstack_missing_path%/*}; tanstack_name=${'$'}{tanstack_missing_path##*/}; [ -n "$tanstack_parent" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- "$tanstack_parent" 2>/dev/null); tanstack_parent_status=$?; if [ "$tanstack_parent_status" -ne 0 ]; then tanstack_lstat_missing "$tanstack_parent"; else case "$tanstack_parent_mode" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case "$tanstack_parent" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H "$tanstack_find_parent" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ "${'$'}{tanstack_candidate##*/}" = "$tanstack_target" ] && { printf 1; exit 0; }; done; exit 0' sh "$tanstack_name" '{}' + 2>/dev/null); tanstack_find_status=$?; [ "$tanstack_find_status" -eq 0 ] && [ -z "$tanstack_match" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing "$tanstack_lstat_path"; then printf '%s' '${LSTAT_MISSING}'; else printf '%s\n' "$tanstack_lstat_output" >&2; exit "$tanstack_lstat_status"; fi; fi` +} + +function parseLstatOutput(output: string): SandboxFsStat { + const fields = /^(?[0-9a-fA-F]{4}):(?\d+)\n?$/.exec(output) + const mode = fields?.groups?.mode + const size = fields?.groups?.size + if (!mode || !size) throw new Error(`invalid lstat output: ${output}`) + const parsedMode = Number.parseInt(mode, 16) + const parsedSize = Number(size) + if ( + !Number.isSafeInteger(parsedMode) || + !Number.isSafeInteger(parsedSize) || + parsedSize < 0 + ) + throw new Error(`invalid lstat output: ${output}`) + const type = parsedMode & 0xf000 + if (type === 0x8000) + return { type: 'file', mode: parsedMode, size: parsedSize } + if (type === 0x4000) return { type: 'dir', mode: parsedMode } + if (type === 0xa000) return { type: 'symlink', mode: parsedMode } + return { type: 'other', mode: parsedMode } +} + +const MAX_STREAM_BYTES = 8 * 1024 * 1024 + +class AsyncChunkQueue implements AsyncIterable { + private readonly chunks: Array = [] + private readonly waiters: Array<(r: IteratorResult) => void> = [] + private ended = false + private bytes = 0 + private truncated = false + + constructor( + private readonly label: string, + private readonly onOverflow?: () => void, + ) {} + + push(chunk: string): void { + if (chunk === '' || this.ended) return + this.bytes += Buffer.byteLength(chunk) + if (this.bytes > MAX_STREAM_BYTES) { + this.truncated = true + this.emit( + `\n[upstash-box] ${this.label} exceeded ${MAX_STREAM_BYTES} bytes; output truncated\n`, + ) + this.end() + this.onOverflow?.() + return + } + this.emit(chunk) + } + + get overflowed(): boolean { + return this.truncated + } + + private emit(chunk: string): void { + 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 PublicUrlAuth { + bearerToken?: boolean + basicAuth?: boolean +} + +export interface UpstashBoxHandleDeps { + box: Box + publicUrlAuth?: PublicUrlAuth + boxConfig?: BoxConfig +} + +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 boxConfig?: BoxConfig + private readonly envVars: Record = {} + + constructor(deps: UpstashBoxHandleDeps) { + this.box = deps.box + this.publicUrlAuth = deps.publicUrlAuth + this.boxConfig = deps.boxConfig + 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) + const dir = abs.replace(/\/[^/]*$/, '') || '/' + await this.box.files.mkdir(dir, { parents: true }) + 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)) + 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: (p) => this.box.files.mkdir(this.abs(p), { parents: true }), + remove: (p) => this.box.files.remove(this.abs(p), { recursive: true }), + rename: (from, to) => this.box.files.rename(this.abs(from), this.abs(to)), + exists: async (p) => { + try { + await this.box.files.stat(this.abs(p)) + return true + } catch (error) { + if (isNotFoundError(error)) return false + throw error + } + }, + lstat: async (p) => this.lstat(this.abs(p)), + } + + 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() + }, + } + } + + private abs(p: string): string { + 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 + } + + private async lstat(path: string): Promise { + const r = await this.exec(lstatCommand(path)) + if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return undefined + if (r.exitCode !== 0) { + const output = `${r.stdout}\n${r.stderr}` + throw new Error(`lstat failed: ${output.trim()}`) + } + return parseLstatOutput(r.stdout) + } + + private envList(extra?: Record): Array { + return Object.entries({ ...this.envVars, ...extra }).map(([k, v]) => { + assertEnvName(k) + return `${k}=${v}` + }) + } + + private withEnv(command: string, extra?: Record): string { + const merged = { ...this.envVars, ...extra } + const exports = Object.entries(merged) + .map(([k, v]) => { + assertEnvName(k) + return `export ${k}=${q(v)}; ` + }) + .join('') + return `${exports}${command}` + } + + private wrap(command: string, opts?: ProcessOptions): string { + const cwd = this.abs(opts?.cwd ?? this.workspaceRoot) + return this.withEnv(`cd ${q(cwd)} && ${command}`, opts?.env) + } + + private async exec( + command: string, + opts?: ProcessOptions, + ): Promise { + opts?.signal?.throwIfAborted() + const run = await this.box.exec.command(this.wrap(command, opts)) + return { + stdout: run.stdout, + stderr: run.stderr, + exitCode: run.exitCode ?? 1, + } + } + + private async spawnProcess( + command: string, + opts?: ProcessOptions, + ): Promise { + opts?.signal?.throwIfAborted() + + const started: { session?: ExecSessionHandle } = {} + const onOverflow = (): void => { + started.session?.kill('TERM') + } + const stdoutQ = new AsyncChunkQueue('stdout', () => onOverflow()) + const stderrQ = new AsyncChunkQueue('stderr', () => onOverflow()) + const outDecoder = new TextDecoder() + const errDecoder = new TextDecoder() + + const session: ExecSessionHandle = await this.box.exec.session({ + cmd: command, + cwd: this.abs(opts?.cwd ?? this.workspaceRoot), + env: this.envList(opts?.env), + onStdout: (data) => + stdoutQ.push(outDecoder.decode(data, { stream: true })), + onStderr: (data) => + stderrQ.push(errDecoder.decode(data, { stream: true })), + }) + + started.session = session + if (stdoutQ.overflowed || stderrQ.overflowed) session.kill('TERM') + + const onAbort = (): void => session.kill('TERM') + opts?.signal?.addEventListener('abort', onAbort, { once: true }) + + const exit = session.wait().finally(() => { + opts?.signal?.removeEventListener('abort', onAbort) + stdoutQ.push(outDecoder.decode()) + stderrQ.push(errDecoder.decode()) + stdoutQ.end() + stderrQ.end() + }) + exit.catch(() => undefined) + + if (opts?.signal?.aborted === true) { + onAbort() + opts.signal.throwIfAborted() + } + + return { + pid: session.pid, + stdout: stdoutQ, + stderr: stderrQ, + stdin: { + write: (data) => { + session.write(data) + return Promise.resolve() + }, + end: () => { + session.endStdin() + return Promise.resolve() + }, + }, + wait: () => exit, + kill: (signal) => { + session.kill(toBoxSignal(signal)) + 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 = async (): Promise => { + if (this.boxConfig === undefined) { + throw new UnsupportedCapabilityError('upstash-box', 'fork') + } + const snap = await this.box.snapshot({ + name: `tanstack-fork-${Date.now()}`, + }) + const { name: _name, ...config } = this.boxConfig + try { + const box = await Box.fromSnapshot(snap.id, config) + return new UpstashBoxHandle({ + box, + boxConfig: this.boxConfig, + ...(this.publicUrlAuth ? { publicUrlAuth: this.publicUrlAuth } : {}), + }) + } finally { + await this.box.deleteSnapshot(snap.id).catch(() => undefined) + } + } + + 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 0000000000..b633d24a6e --- /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 0000000000..43a0e74ba0 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/src/provider.ts @@ -0,0 +1,197 @@ +import { Box } from '@upstash/box' +import { UPSTASH_BOX_CAPS, UpstashBoxHandle, isNotFoundError } from './handle' +import type { PublicUrlAuth } from './handle' +import type { BoxConfig, BoxSize, Runtime } from '@upstash/box' +import type { + SandboxCapabilities, + SandboxPolicy, + 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 + policy?: SandboxPolicy + }): 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 + // The contract's network gate is coarse (allow/ask/deny), so only an + // explicit deny maps; Box's domain/CIDR allowlists have no contract surface. + if (input?.policy?.capabilities?.network === 'deny') { + cfg.networkPolicy = { mode: 'deny-all' } + } + return cfg + } + + /** + * Carry a live box's actual network policy into the config a fork will reuse. + * + * `boxConfig` is what `fork()` hands to `Box.fromSnapshot`, and a snapshot + * does not inherit the parent's policy, so a resumed deny-all box would come + * back open one fork later. The live box is the authority here: the caller's + * create-time policy is not part of a resume input. + */ + private withLivePolicy( + box: Awaited>, + base: BoxConfig, + ): BoxConfig { + const policy = box.networkPolicy + if (policy === undefined) return base + return { ...base, networkPolicy: policy } + } + + /** + * The SDK cannot cancel an in-flight create, so a caller that aborts mid-call + * would otherwise leave a billed box nobody holds the id for. Reconcile by + * deleting what we just made, then honour the abort. + */ + private async settleAbort( + box: Awaited>, + signal: AbortSignal | undefined, + ): Promise { + if (signal?.aborted !== true) return + await box.delete().catch(() => undefined) + signal.throwIfAborted() + } + + async create(input: SandboxCreateInput): Promise { + // Best-effort: the SDK can't cancel an in-flight call. + input.signal?.throwIfAborted() + // The caller's deterministic id becomes the box name (Box.getByName === Box.get). + const boxConfig = this.boxConfig({ + env: input.env, + name: input.id, + ...(input.policy ? { policy: input.policy } : {}), + }) + const box = this.config.snapshot + ? await Box.fromSnapshot(this.config.snapshot, boxConfig) + : await Box.create(boxConfig) + await this.settleAbort(box, input.signal) + return new UpstashBoxHandle({ + box, + boxConfig, + publicUrlAuth: this.config.publicUrlAuth, + }) + } + + async resume(input: SandboxResumeInput): Promise { + input.signal?.throwIfAborted() + try { + const box = await Box.get(input.id, this.connection) + // `Box.get` resolves for a DELETED box; only `getStatus` reports the + // tombstone. Without this probe a destroyed box resumes as a live handle. + await box.getStatus() + return new UpstashBoxHandle({ + box, + boxConfig: this.withLivePolicy(box, this.boxConfig()), + publicUrlAuth: this.config.publicUrlAuth, + }) + } catch (error) { + if (isNotFoundError(error)) return null + throw error + } + } + + async restoreSnapshot(input: SandboxRestoreInput): Promise { + input.signal?.throwIfAborted() + // `SandboxRestoreInput` carries a policy too. Dropping it would restore a + // snapshot taken under `network: 'deny'` into a box with default egress. + const boxConfig = this.boxConfig({ + env: input.env, + ...(input.policy ? { policy: input.policy } : {}), + }) + const box = await Box.fromSnapshot(input.snapshotId, boxConfig) + await this.settleAbort(box, input.signal) + return new UpstashBoxHandle({ + box, + boxConfig: this.withLivePolicy(box, boxConfig), + 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 (error) { + // Already gone is success; anything else must surface so the caller does + // not believe a still-running box was destroyed. + if (!isNotFoundError(error)) throw error + } + } +} + +/** + * 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 0000000000..cf0828cdd0 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/tests/handle.test.ts @@ -0,0 +1,687 @@ +import { describe, expect, it, vi } from 'vitest' +import { UnsupportedCapabilityError } from '@tanstack/ai-sandbox' +import { UPSTASH_BOX_CAPS, UpstashBoxHandle } from '../src/handle' +import { BoxError } from '@upstash/box' +import type { Box } from '@upstash/box' +import type { PublicUrlAuth } from '../src/handle' + +/** A fake Box covering only the surface the handle touches. */ +interface FakeSession { + pid: number + execId: string + write: ReturnType + endStdin: ReturnType + resize: ReturnType + kill: ReturnType + terminate: ReturnType + wait: () => Promise + close: ReturnType + emitStdout: (text: string) => void + emitStderr: (text: string) => void + exit: (code: number) => void +} + +/** A fake exec.session whose output and exit are driven by the test. */ +function fakeSession(pid = 4242): { + session: FakeSession + attach: (opts: { + onStdout?: (d: Uint8Array) => void + onStderr?: (d: Uint8Array) => void + }) => void +} { + const enc = new TextEncoder() + let onStdout: ((d: Uint8Array) => void) | undefined + let onStderr: ((d: Uint8Array) => void) | undefined + let settle!: (code: number) => void + const exited = new Promise((r) => (settle = r)) + const session: FakeSession = { + pid, + execId: 'exec_1', + write: vi.fn(), + endStdin: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => settle(143)), + terminate: vi.fn(), + wait: () => exited, + close: vi.fn(), + emitStdout: (text) => onStdout?.(enc.encode(text)), + emitStderr: (text) => onStderr?.(enc.encode(text)), + exit: (code) => settle(code), + } + return { + session, + attach: (opts) => { + onStdout = opts.onStdout + onStderr = opts.onStderr + }, + } +} + +function fakeBox( + overrides: { + exec?: (cmd: string) => { + stdout: string + stderr: string + exitCode: number | null + } + session?: ReturnType + duringHandshake?: (fake: ReturnType) => void + files?: Partial + getPublicURL?: Box['getPublicURL'] + snapshot?: Box['snapshot'] + delete?: Box['delete'] + } = {}, +) { + const commands: Array = [] + const sessionOptions: Array> = [] + const box = { + id: 'box_123', + exec: { + command: vi.fn(async (cmd: string) => { + commands.push(cmd) + return overrides.exec?.(cmd) ?? { stdout: '', stderr: '', exitCode: 0 } + }), + session: vi.fn(async (opts: Record) => { + sessionOptions.push(opts) + const fake = overrides.session ?? fakeSession() + fake.attach( + opts as { + onStdout?: (d: Uint8Array) => void + onStderr?: (d: Uint8Array) => void + }, + ) + // Lets a test push output or abort while the handshake is still pending. + overrides.duringHandshake?.(fake) + return fake.session + }), + }, + files: { + read: vi.fn(async () => ''), + write: vi.fn(async () => {}), + list: vi.fn(async () => []), + stat: vi.fn(async () => ({ + type: 'file' as const, + size: 0, + mod_time: '', + inode: 1, + version: 'v1', + })), + mkdir: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + remove: 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, sessionOptions } +} + +function lstatCommand(path: string): string { + const quoted = `'${path.replace(/'/g, `'\\''`)}'` + return `tanstack_lstat_path=${quoted}; tanstack_lstat_output=$(stat -c '%f:%s' -- "$tanstack_lstat_path" 2>&1); tanstack_lstat_status=$?; if [ "$tanstack_lstat_status" -eq 0 ]; then printf '%s\n' "$tanstack_lstat_output"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case "$tanstack_missing_path" in /|.) return 1 ;; */*) tanstack_parent=${'$'}{tanstack_missing_path%/*}; tanstack_name=${'$'}{tanstack_missing_path##*/}; [ -n "$tanstack_parent" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- "$tanstack_parent" 2>/dev/null); tanstack_parent_status=$?; if [ "$tanstack_parent_status" -ne 0 ]; then tanstack_lstat_missing "$tanstack_parent"; else case "$tanstack_parent_mode" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case "$tanstack_parent" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H "$tanstack_find_parent" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ "${'$'}{tanstack_candidate##*/}" = "$tanstack_target" ] && { printf 1; exit 0; }; done; exit 0' sh "$tanstack_name" '{}' + 2>/dev/null); tanstack_find_status=$?; [ "$tanstack_find_status" -eq 0 ] && [ -z "$tanstack_match" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing "$tanstack_lstat_path"; then printf '%s' '__TANSTACK_LSTAT_MISSING__'; else printf '%s\n' "$tanstack_lstat_output" >&2; exit "$tanstack_lstat_status"; fi; fi` +} + +function lstatPath(command: string): string { + return /^tanstack_lstat_path='([^']*)';/.exec(command)?.[1] ?? '' +} + +function unwrapExec(command: string): string { + const prefix = "cd '/workspace/home' && " + expect(command.startsWith(prefix)).toBe(true) + return command.slice(prefix.length) +} + +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) + // exec.session carries real stdin and server-side signals. + expect(handle.capabilities.writableStdin).toBe(true) + expect(handle.capabilities.killableProcesses).toBe(true) + }) + + it('shell-wraps exec with the mapped cwd and splits stdout from stderr', async () => { + const { box, commands } = fakeBox({ + exec: () => ({ stdout: 'hello', stderr: 'warned', exitCode: 0 }), + }) + const handle = new UpstashBoxHandle({ box }) + const res = await handle.process.exec('echo hello') + // Box reports stdout and stderr on separate fields; a warning on stderr + // must not shadow stdout on success. + expect(res).toEqual({ stdout: 'hello', stderr: 'warned', 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.session and resolves wait() with the exit code', async () => { + const fake = fakeSession(4242) + const { box, sessionOptions } = fakeBox({ session: fake }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('run-agent') + // Real in-box pid, not a placeholder. + expect(proc.pid).toBe(4242) + fake.session.emitStdout('streamed-') + fake.session.emitStdout('line\n') + fake.session.exit(0) + let out = '' + for await (const c of proc.stdout) out += c + expect(out).toBe('streamed-line\n') + expect(await proc.wait()).toBe(0) + // The session takes cwd natively, so the command is NOT shell-wrapped. + expect(sessionOptions[0]).toMatchObject({ + cmd: 'run-agent', + cwd: '/workspace/home', + }) + }) + + it('spawn keeps stderr on its own stream', async () => { + const fake = fakeSession() + const { box } = fakeBox({ session: fake }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('run-agent') + fake.session.emitStdout('out') + fake.session.emitStderr('err') + fake.session.exit(0) + let out = '' + for await (const c of proc.stdout) out += c + let err = '' + for await (const c of proc.stderr) err += c + expect(out).toBe('out') + expect(err).toBe('err') + }) + + it('spawn passes cwd and merged env natively instead of shell-wrapping', async () => { + const { box, sessionOptions, commands } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await handle.env.set({ FOO: 'bar' }) + await handle.process.spawn('run', { + cwd: '/workspace/app', + env: { BAZ: 'q' }, + }) + expect(sessionOptions[0]).toMatchObject({ + cmd: 'run', + cwd: '/workspace/home/app', + env: ['FOO=bar', 'BAZ=q'], + }) + // No `sh -c` round-trip for a spawned command. + expect(commands).toEqual([]) + }) + + it('spawned process has a writable stdin', async () => { + const fake = fakeSession() + const { box } = fakeBox({ session: fake }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('run-agent') + await proc.stdin.write('prompt') + await proc.stdin.end() + expect(fake.session.write).toHaveBeenCalledWith('prompt') + expect(fake.session.endStdin).toHaveBeenCalledOnce() + }) + + it('kill maps Node signals onto the box allowlist', async () => { + const fake = fakeSession() + const { box } = fakeBox({ session: fake }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('sleep-forever') + await proc.kill('SIGKILL') + expect(fake.session.kill).toHaveBeenCalledWith('KILL') + await proc.kill(2) + expect(fake.session.kill).toHaveBeenCalledWith('INT') + // Default, and anything outside the allowlist, degrades to TERM. + await proc.kill() + await proc.kill('SIGWINCH') + expect(fake.session.kill).toHaveBeenLastCalledWith('TERM') + }) + + it('aborting the spawn signal terminates the session', async () => { + const fake = fakeSession() + const { box } = fakeBox({ session: fake }) + const handle = new UpstashBoxHandle({ box }) + const controller = new AbortController() + const proc = await handle.process.spawn('sleep-forever', { + signal: controller.signal, + }) + controller.abort() + expect(fake.session.kill).toHaveBeenCalledWith('TERM') + // The fake settles wait() on kill, mirroring a server-side signal landing. + await expect(proc.wait()).resolves.toBe(143) + }) + + // An unbounded queue lets a chatty process whose consumer lags grow the buffer + // without limit. The cap must announce itself rather than silently truncating. + it('caps a spawned stream and stops the process on overflow', async () => { + const fake = fakeSession() + const { box } = fakeBox({ session: fake }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('noisy') + const mb = 'x'.repeat(1024 * 1024) + for (let i = 0; i < 9; i += 1) fake.session.emitStdout(mb) + let out = '' + for await (const c of proc.stdout) out += c + // Measure the payload apart from the notice, or a regression past the cap + // hides inside a bound loose enough to swallow it. + const payload = out.slice(0, out.indexOf('\n[upstash-box]')) + expect(payload.length).toBeLessThanOrEqual(8 * 1024 * 1024) + expect(out).toContain('output truncated') + // Overflow signals the process rather than leaving it writing into a dead stream. + expect(fake.session.kill).toHaveBeenCalledWith('TERM') + }) + + it('fork without a boxConfig throws UnsupportedCapabilityError', async () => { + const { box } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fork()).rejects.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') + // The parent dir is ensured through the native file API, not a shell. + expect(box.files.mkdir).toHaveBeenCalledWith('/workspace/home/dir', { + parents: true, + }) + expect(commands).toEqual([]) + 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('mkdir/remove/rename go through the native file API', async () => { + const { box, commands } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await handle.fs.mkdir('/workspace/a/b') + await handle.fs.remove('/workspace/a') + await handle.fs.rename('/workspace/x', '/workspace/y') + expect(box.files.mkdir).toHaveBeenCalledWith('/workspace/home/a/b', { + parents: true, + }) + // `recursive` is required for a directory, empty or not. + expect(box.files.remove).toHaveBeenCalledWith('/workspace/home/a', { + recursive: true, + }) + expect(box.files.rename).toHaveBeenCalledWith( + '/workspace/home/x', + '/workspace/home/y', + ) + // None of these desugar to a shell command any more. + expect(commands).toEqual([]) + }) + + it('exists probes stat and reports false when it throws', async () => { + const { box } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fs.exists('/workspace/here')).resolves.toBe(true) + expect(box.files.stat).toHaveBeenCalledWith('/workspace/home/here') + + const missing = fakeBox({ + files: { + stat: vi.fn(async () => { + throw new BoxError('Not found', 404) + }) as unknown as Box['files']['stat'], + }, + }) + const handle2 = new UpstashBoxHandle({ box: missing.box }) + await expect(handle2.fs.exists('/workspace/gone')).resolves.toBe(false) + }) + + // Flattening a 401 or a transport error into "absent" makes a caller + // overwrite a file it could not read. + it('exists rethrows a non-404 instead of reporting absent', async () => { + const { box } = fakeBox({ + files: { + stat: vi.fn(async () => { + throw new BoxError('Invalid box API key', 401) + }) as unknown as Box['files']['stat'], + }, + }) + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fs.exists('/workspace/x')).rejects.toThrow( + 'Invalid box API key', + ) + }) + + it('rejects env names that could inject shell syntax', async () => { + const { box, commands } = fakeBox() + const handle = new UpstashBoxHandle({ box }) + await expect( + handle.process.exec('echo hi', { env: { 'X;rm -rf /': 'v' } }), + ).rejects.toThrow(/invalid environment variable name/) + expect(commands).toEqual([]) + await handle.env.set({ 'BAD-NAME': 'v' }) + await expect(handle.process.spawn('run')).rejects.toThrow( + /invalid environment variable name/, + ) + }) + + it('kills a session that overflowed while the handshake was settling', async () => { + const fake = fakeSession() + const { box } = fakeBox({ + session: fake, + // Overflow BEFORE exec.session() resolves, when there is no session to kill. + duringHandshake: (f) => { + for (let i = 0; i < 9; i += 1) + f.session.emitStdout('x'.repeat(1024 * 1024)) + }, + }) + const handle = new UpstashBoxHandle({ box }) + const proc = await handle.process.spawn('noisy') + expect(proc.pid).toBeGreaterThan(0) + expect(fake.session.kill).toHaveBeenCalledWith('TERM') + }) + + it('rejects spawn when the signal aborts during the handshake', async () => { + const controller = new AbortController() + const fake = fakeSession() + const { box } = fakeBox({ + session: fake, + duringHandshake: () => controller.abort(), + }) + const handle = new UpstashBoxHandle({ box }) + await expect( + handle.process.spawn('run', { signal: controller.signal }), + ).rejects.toThrow() + // The started process is signalled rather than left running unowned. + expect(fake.session.kill).toHaveBeenCalledWith('TERM') + }) + + 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() + }) +}) + +describe('UpstashBoxHandle.fs.lstat', () => { + it('parses file, directory, symlink, and other metadata', async () => { + const values = new Map([ + ['file', '81A4:12\n'], + ['dir', '41ed:4096\n'], + ['link', 'a1ff:4\n'], + ['other', 'c1b6:0\n'], + ]) + const { box } = fakeBox({ + exec: (cmd) => { + const inner = unwrapExec(cmd) + const path = lstatPath(inner) + expect(inner).toBe(lstatCommand(path)) + return { + stdout: values.get(path.split('/').pop() ?? '') ?? '', + stderr: '', + exitCode: 0, + } + }, + }) + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fs.lstat!('/workspace/file')).resolves.toEqual({ + type: 'file', + mode: 0x81a4, + size: 12, + }) + await expect(handle.fs.lstat!('/workspace/dir')).resolves.toEqual({ + type: 'dir', + mode: 0x41ed, + }) + await expect(handle.fs.lstat!('/workspace/link')).resolves.toEqual({ + type: 'symlink', + mode: 0xa1ff, + }) + await expect(handle.fs.lstat!('/workspace/other')).resolves.toEqual({ + type: 'other', + mode: 0xc1b6, + }) + }) + + it('parses a zero-byte regular empty file with size zero', async () => { + const { box } = fakeBox({ + exec: () => ({ stdout: '81a4:0\n', stderr: '', exitCode: 0 }), + }) + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fs.lstat!('/workspace/empty')).resolves.toEqual({ + type: 'file', + mode: 0x81a4, + size: 0, + }) + }) + + it.each([ + ['not-a-number', '81a4'], + ['Infinity', '81a4'], + ['-1', '81a4'], + ['', '81a4'], + ['5', '81a4junk'], + ['5', '-81a4'], + [' 5', '81a4'], + ['5 ', '81a4'], + ['5\n6', '81a4'], + ['NaN', '81a4'], + ['5', ''], + ['5', '0x81a4'], + ['5', '81a'], + ['5', '81a45'], + ['5', '81a4 '], + ['9007199254740992', '81a4'], + ])('rejects malformed lstat fields', async (size, mode) => { + const { box } = fakeBox({ + exec: () => ({ stdout: `${mode}:${size}\n`, stderr: '', exitCode: 0 }), + }) + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fs.lstat!('/workspace/file')).rejects.toThrow( + 'invalid lstat output', + ) + }) + + it.each([ + '/workspace/missing', + '/workspace/missing-parent/child', + '-H/missing', + '-delete/missing', + ])('returns undefined for a verified missing path: %s', async (path) => { + const { box } = fakeBox({ + exec: (cmd) => { + const inner = unwrapExec(cmd) + expect(inner).toBe( + lstatCommand( + path.startsWith('/workspace') + ? `/workspace/home${path.slice('/workspace'.length)}` + : path, + ), + ) + return { + stdout: '__TANSTACK_LSTAT_MISSING__', + stderr: '', + exitCode: 0, + } + }, + }) + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fs.lstat!(path)).resolves.toBeUndefined() + }) + + it('treats a transport-padded missing sentinel as missing', async () => { + const { box } = fakeBox({ + exec: () => ({ + stdout: '__TANSTACK_LSTAT_MISSING__\n', + stderr: '', + exitCode: 0, + }), + }) + const handle = new UpstashBoxHandle({ box }) + await expect( + handle.fs.lstat!('/workspace/missing'), + ).resolves.toBeUndefined() + }) + + it.each([ + '/workspace/file/child', + '/workspace/loop/child', + '/workspace/denied-link/child', + ])('preserves an unverified parent failure: %s', async (path) => { + const { box } = fakeBox({ + exec: (cmd) => { + const inner = unwrapExec(cmd) + expect(inner).toBe( + lstatCommand(`/workspace/home${path.slice('/workspace'.length)}`), + ) + return { stdout: '', stderr: 'permission denied', exitCode: 1 } + }, + }) + const handle = new UpstashBoxHandle({ box }) + await expect(handle.fs.lstat!(path)).rejects.toThrow( + 'lstat failed: permission denied', + ) + }) +}) diff --git a/packages/ai-sandbox-upstash-box/tests/journal.conformance.test.ts b/packages/ai-sandbox-upstash-box/tests/journal.conformance.test.ts new file mode 100644 index 0000000000..8726df98df --- /dev/null +++ b/packages/ai-sandbox-upstash-box/tests/journal.conformance.test.ts @@ -0,0 +1,25 @@ +/** + * Journal conformance for the Upstash Box provider. + * + * NO `followUnsupported`: `killableProcesses` is `true`, so the follow cases RUN + * with a key and name-skip without one. Flipping the capability to `false` fails + * the always-running first case until this file declares it, so the declaration + * cannot drift from the provider. + */ +import { runJournalConformance } from '@tanstack/ai-sandbox/testkit' +import { upstashBoxSandbox } from '../src/index' + +// Auto-gate: these cases create real, billed boxes. +const apiKey = process.env.UPSTASH_BOX_API_KEY + +runJournalConformance({ + name: 'upstash-box', + createHandle: async () => { + const provider = upstashBoxSandbox(apiKey !== undefined ? { apiKey } : {}) + const handle = await provider.create({}) + return { handle, dispose: () => handle.destroy() } + }, + ...(apiKey + ? {} + : { unsupported: { reason: 'no UPSTASH_BOX_API_KEY in the environment' } }), +}) diff --git a/packages/ai-sandbox-upstash-box/tests/provider.test.ts b/packages/ai-sandbox-upstash-box/tests/provider.test.ts new file mode 100644 index 0000000000..57472e5d1b --- /dev/null +++ b/packages/ai-sandbox-upstash-box/tests/provider.test.ts @@ -0,0 +1,235 @@ +/** + * Provider tests against a mocked `@upstash/box`. The tombstone case is the one + * that matters: `Box.get` RESOLVES for a deleted box, so only `getStatus` + * distinguishes it and `ensure()` would otherwise reuse a dead sandbox. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { upstashBoxSandbox } from '../src/index' + +const { getMock, createMock, fromSnapshotMock, MockBoxError } = vi.hoisted( + () => { + class MockBoxError extends Error { + constructor( + message: string, + readonly statusCode?: number, + ) { + super(message) + } + } + return { + getMock: vi.fn(), + createMock: vi.fn(), + fromSnapshotMock: vi.fn(), + MockBoxError, + } + }, +) + +vi.mock('@upstash/box', () => ({ + Box: { get: getMock, create: createMock, fromSnapshot: fromSnapshotMock }, + BoxError: MockBoxError, +})) + +/** The API answers 404 for both a missing and a deleted box. */ +const gone = (msg = 'Box not found') => new MockBoxError(msg, 404) + +/** A box stub whose `getStatus` outcome the test chooses. */ +function boxStub( + opts: { + id?: string + getStatus?: () => Promise + networkPolicy?: unknown + } = {}, +) { + return { + id: opts.id ?? 'box_123', + getStatus: vi.fn(opts.getStatus ?? (async () => ({ status: 'idle' }))), + delete: vi.fn(async () => {}), + exec: { command: vi.fn(), session: vi.fn() }, + files: {}, + networkPolicy: opts.networkPolicy, + snapshot: vi.fn(async () => ({ id: 'snap_1' })), + deleteSnapshot: vi.fn(async () => {}), + } +} + +beforeEach(() => { + getMock.mockReset() + createMock.mockReset() + fromSnapshotMock.mockReset() +}) + +describe('upstashBoxSandbox provider', () => { + it('resumes a live box into a handle', async () => { + getMock.mockResolvedValue(boxStub()) + const handle = await upstashBoxSandbox({ apiKey: 'k' }).resume({ + id: 'box_123', + }) + expect(handle).not.toBeNull() + expect(handle!.id).toBe('box_123') + }) + + it('resumes a DELETED box as null even though Box.get resolves', async () => { + // The exact prod shape: the record still fetches, the status probe 404s. + const tombstone = boxStub({ + getStatus: async () => { + throw gone('Box has been deleted') + }, + }) + getMock.mockResolvedValue(tombstone) + const handle = await upstashBoxSandbox({ apiKey: 'k' }).resume({ + id: 'box_123', + }) + expect(handle).toBeNull() + expect(tombstone.getStatus).toHaveBeenCalledOnce() + }) + + it('resumes a missing box as null when Box.get itself throws', async () => { + getMock.mockRejectedValue(gone()) + const handle = await upstashBoxSandbox({ apiKey: 'k' }).resume({ + id: 'nope', + }) + expect(handle).toBeNull() + }) + + // A 401 or a transport error is NOT "gone". Reporting it as null sends + // `ensure()` down the create path and silently duplicates a live box. + it('rethrows a non-404 from resume instead of reporting gone', async () => { + getMock.mockRejectedValue(new MockBoxError('Invalid box API key', 401)) + await expect( + upstashBoxSandbox({ apiKey: 'bad' }).resume({ id: 'box_123' }), + ).rejects.toThrow('Invalid box API key') + }) + + it('rethrows a non-404 from destroy instead of reporting success', async () => { + getMock.mockRejectedValue(new MockBoxError('Invalid box API key', 401)) + await expect( + upstashBoxSandbox({ apiKey: 'bad' }).destroy({ id: 'box_123' }), + ).rejects.toThrow('Invalid box API key') + }) + + it('passes the deterministic id through as the box name on create', async () => { + createMock.mockResolvedValue(boxStub({ id: 'agent-1' })) + await upstashBoxSandbox({ apiKey: 'k' }).create({ + id: 'agent-1', + env: { A: 'b' }, + }) + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'agent-1', + env: { A: 'b' }, + runtime: 'node', + keepAlive: false, + }), + ) + }) + + it('routes create through fromSnapshot when a base snapshot is configured', async () => { + fromSnapshotMock.mockResolvedValue(boxStub()) + await upstashBoxSandbox({ apiKey: 'k', snapshot: 'snap_1' }).create({}) + expect(fromSnapshotMock).toHaveBeenCalledWith('snap_1', expect.any(Object)) + expect(createMock).not.toHaveBeenCalled() + }) + + it('destroy swallows an already-deleted box', async () => { + getMock.mockRejectedValue(gone('Box has been deleted')) + await expect( + upstashBoxSandbox({ apiKey: 'k' }).destroy({ id: 'box_123' }), + ).resolves.toBeUndefined() + }) + + it('maps a deny network policy onto Box deny-all egress', async () => { + createMock.mockResolvedValue(boxStub()) + await upstashBoxSandbox({ apiKey: 'k' }).create({ + policy: { capabilities: { network: 'deny' } }, + }) + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ networkPolicy: { mode: 'deny-all' } }), + ) + }) + + it('carries a deny network policy through restoreSnapshot too', async () => { + fromSnapshotMock.mockResolvedValue(boxStub()) + // restoreSnapshot is optional on the contract; this provider implements it. + await upstashBoxSandbox({ apiKey: 'k' }).restoreSnapshot!({ + snapshotId: 'snap_1', + policy: { capabilities: { network: 'deny' } }, + }) + expect(fromSnapshotMock).toHaveBeenCalledWith( + 'snap_1', + expect.objectContaining({ networkPolicy: { mode: 'deny-all' } }), + ) + }) + + it('leaves egress unset for allow/ask, which Box defaults to open', async () => { + createMock.mockResolvedValue(boxStub()) + await upstashBoxSandbox({ apiKey: 'k' }).create({ + policy: { capabilities: { network: 'allow' } }, + }) + expect(createMock.mock.calls[0]?.[0]).not.toHaveProperty('networkPolicy') + }) + + // The SDK cannot cancel an in-flight create, so an abort that lands after the + // box exists must delete it rather than leave a billed box with no owner. + it('deletes the box when the signal aborts during create', async () => { + const stub = boxStub() + const controller = new AbortController() + // Abort lands WHILE create is in flight, the case the pre-flight check misses. + createMock.mockImplementation(async () => { + controller.abort() + return stub + }) + await expect( + upstashBoxSandbox({ apiKey: 'k' }).create({ signal: controller.signal }), + ).rejects.toThrow() + expect(stub.delete).toHaveBeenCalledOnce() + }) + + it('reports the provider name and capabilities', () => { + const provider = upstashBoxSandbox({ apiKey: 'k' }) + expect(provider.name).toBe('upstash-box') + const caps = provider.capabilities() + expect(caps.writableStdin).toBe(true) + expect(caps.killableProcesses).toBe(true) + expect(caps.networkPolicy).toBe(true) + expect(caps.fork).toBe(true) + }) +}) + +describe('resumed handles keep their sandbox boundary', () => { + it('forks a resumed deny-all box as deny-all, and cleans up the snapshot', async () => { + const resumed = boxStub({ networkPolicy: { mode: 'deny-all' } }) + getMock.mockResolvedValue(resumed) + const child = boxStub({ id: 'box_child' }) + fromSnapshotMock.mockResolvedValue(child) + + const provider = upstashBoxSandbox({ apiKey: 'box_test' }) + const handle = await provider.resume({ id: 'box_123' }) + if (handle?.fork === undefined) + throw new Error('resume did not return a forkable handle') + await handle.fork() + + // A snapshot does not carry the parent's policy, so the resumed handle has + // to supply it or a deny-all box comes back open one fork later. + const [, forkConfig] = fromSnapshotMock.mock.calls[0] as [ + string, + { networkPolicy?: unknown }, + ] + expect(forkConfig.networkPolicy).toEqual({ mode: 'deny-all' }) + // The snapshot is scratch for the copy; keeping it bills storage per fork. + expect(resumed.deleteSnapshot).toHaveBeenCalledWith('snap_1') + }) + + it('deletes the fork snapshot even when the child never starts', async () => { + const resumed = boxStub({ networkPolicy: { mode: 'deny-all' } }) + getMock.mockResolvedValue(resumed) + fromSnapshotMock.mockRejectedValue(new Error('capacity')) + + const provider = upstashBoxSandbox({ apiKey: 'box_test' }) + const handle = await provider.resume({ id: 'box_123' }) + if (handle?.fork === undefined) + throw new Error('resume did not return a forkable handle') + await expect(handle.fork()).rejects.toThrow(/capacity/) + expect(resumed.deleteSnapshot).toHaveBeenCalledWith('snap_1') + }) +}) 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 0000000000..c143093373 --- /dev/null +++ b/packages/ai-sandbox-upstash-box/tests/upstash-box.test.ts @@ -0,0 +1,194 @@ +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.session and wait for exit. + const proc = await sbx.process.spawn('echo streamed-line') + // exec.session reports the real in-box pid. + expect(proc.pid).toBeGreaterThan(0) + let out = '' + for await (const chunk of proc.stdout) out += chunk + expect(out).toContain('streamed-line') + expect(await proc.wait()).toBe(0) + + // stderr arrives on its own stream, not merged into stdout. + const split = await sbx.process.spawn('echo to-out; echo to-err >&2') + let sOut = '' + let sErr = '' + await Promise.all([ + (async () => { + for await (const c of split.stdout) sOut += c + })(), + (async () => { + for await (const c of split.stderr) sErr += c + })(), + ]) + expect(await split.wait()).toBe(0) + expect(sOut).toContain('to-out') + expect(sOut).not.toContain('to-err') + expect(sErr).toContain('to-err') + } 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) + + // MEASURES writableStdin: `cat` only exits if stdin really closes. + it('feeds a spawned process over stdin and closes it', async () => { + const provider = upstashBoxSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + const proc = await sbx.process.spawn('cat') + await proc.stdin.write('fed-over-stdin\n') + await proc.stdin.end() + let out = '' + for await (const chunk of proc.stdout) out += chunk + expect(out).toContain('fed-over-stdin') + expect(await proc.wait()).toBe(0) + } finally { + await sbx?.destroy() + } + }, 300_000) + + // MEASURES killableProcesses: the marker file proves the process died + // server-side rather than the client merely detaching from it. + it('kill() actually terminates the process inside the box', async () => { + const provider = upstashBoxSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + const proc = await sbx.process.spawn( + 'sleep 5 && touch /workspace/home/survived', + ) + expect(proc.pid).toBeGreaterThan(0) + await proc.kill() + // A signalled process reports a non-zero (signal) exit, never 0. + expect(await proc.wait()).not.toBe(0) + // Outlive the original sleep, then confirm it never completed. + await new Promise((r) => setTimeout(r, 8000)) + expect(await sbx.fs.exists('/workspace/survived')).toBe(false) + } finally { + await sbx?.destroy() + } + }, 300_000) + + // `Box.get` resolves for a deleted box, so resume must probe liveness. + it('resume returns null for a destroyed box', async () => { + const provider = upstashBoxSandbox({ apiKey }) + const sbx = await provider.create({}) + await sbx.destroy() + expect(await provider.resume({ id: sbx.id })).toBeNull() + }, 300_000) + + // MEASURES fork: snapshot + fromSnapshot must carry state and then diverge. + it('fork branches a box from current state', async () => { + const provider = upstashBoxSandbox({ apiKey }) + let src: SandboxHandle | undefined + let forked: SandboxHandle | undefined + try { + src = await provider.create({}) + await src.fs.write('/workspace/before-fork.txt', 'carried over') + forked = await src.fork!() + expect(forked.id).not.toBe(src.id) + expect(await forked.fs.read('/workspace/before-fork.txt')).toBe( + 'carried over', + ) + await forked.fs.write('/workspace/only-in-fork.txt', 'x') + expect(await src.fs.exists('/workspace/only-in-fork.txt')).toBe(false) + } finally { + // Sequential awaits would strand the fork if the first destroy rejects, + // and destroy() now rethrows anything that is not a 404. + await Promise.allSettled([src?.destroy(), forked?.destroy()]) + } + }, 900_000) + + // MEASURES networkPolicy: a deny policy must actually block egress. + it('a deny network policy blocks outbound traffic', async () => { + const provider = upstashBoxSandbox({ apiKey }) + const PROBE = + 'curl -s -m 10 -o /dev/null -w "%{http_code}" https://example.com' + let denied: SandboxHandle | undefined + let control: SandboxHandle | undefined + try { + // allSettled, not all: a rejecting create would otherwise reject before + // either handle is assigned, and `finally` could not destroy the box the + // sibling call had already made. + const [deniedRes, controlRes] = await Promise.allSettled([ + provider.create({ policy: { capabilities: { network: 'deny' } } }), + provider.create({}), + ]) + if (deniedRes.status === 'fulfilled') denied = deniedRes.value + if (controlRes.status === 'fulfilled') control = controlRes.value + if (deniedRes.status === 'rejected') throw deniedRes.reason + if (controlRes.status === 'rejected') throw controlRes.reason + + // POSITIVE CONTROL, without it the test passes whenever the probe fails + // for an unrelated reason (DNS, routing, TLS, example.com being down) + // even though egress is wide open. + const reachable = await controlRes.value.process.exec(PROBE) + expect(reachable.stdout).toContain('200') + + // curl must exist, or the negative case proves nothing: a missing binary + // also produces "no 200". + expect( + (await deniedRes.value.process.exec('command -v curl')).exitCode, + ).toBe(0) + // Assert the connection actually failed rather than merely "not 200", + // which an empty stdout would satisfy for any unrelated reason. + const blocked = await deniedRes.value.process.exec(PROBE) + expect(blocked.exitCode).not.toBe(0) + expect(blocked.stdout).not.toContain('200') + } finally { + await Promise.allSettled([denied?.destroy(), control?.destroy()]) + } + }, 900_000) + }, +) diff --git a/packages/ai-sandbox-upstash-box/tsconfig.json b/packages/ai-sandbox-upstash-box/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /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 0000000000..11f5b20b70 --- /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 2892bbcae4..4d187bd974 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 cloud sandboxes, snapshots | +| `@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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d47649050..a6e2ee8348 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2524,6 +2524,19 @@ importers: specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) + packages/ai-sandbox-upstash-box: + dependencies: + '@upstash/box': + specifier: ^0.7.1 + version: 0.7.1(zod@4.3.6) + devDependencies: + '@tanstack/ai-sandbox': + specifier: workspace:* + version: link:../ai-sandbox + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + packages/ai-sandbox-vercel: dependencies: '@vercel/sandbox': @@ -9935,6 +9948,12 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher + '@upstash/box@0.7.1': + resolution: {integrity: sha512-I1ypUYCODkFZIgwxVo9T3/HLDZBtjbI/AzBVtuNPGrABoYmZYtCGW7znYWcKHpGla6LgpdAA1T6y5uSafoRvzA==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@vectorize-io/hindsight-client@0.6.2': resolution: {integrity: sha512-bymmlMWI1z0zOjgY+wRMLudNxzqcW20VHMtyV3QLhwJm63NeQN/nEZ4plWPR0p28DffaUM5nk2VSxzQljN+Mow==} @@ -24949,6 +24968,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@upstash/box@0.7.1(zod@4.3.6)': + dependencies: + ws: 8.21.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@vectorize-io/hindsight-client@0.6.2': {} '@vercel/nft@1.3.0(rollup@4.60.1)':