From df90af6e5e0cb523a8236303da6d8a3f96716951 Mon Sep 17 00:00:00 2001 From: Michael Stolarz <146425971+SystemSculpt@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:55:07 -0700 Subject: [PATCH 1/3] feat(ai-sandbox): add Blaxel provider Signed-off-by: Michael Stolarz <146425971+SystemSculpt@users.noreply.github.com> --- .changeset/add-ai-sandbox-blaxel.md | 24 + docs/config.json | 2 +- docs/sandbox/providers.md | 67 +- packages/ai-sandbox-blaxel/CHANGELOG.md | 1 + packages/ai-sandbox-blaxel/package.json | 57 + packages/ai-sandbox-blaxel/src/handle.ts | 1196 +++++++++++++++++ packages/ai-sandbox-blaxel/src/index.ts | 4 + packages/ai-sandbox-blaxel/src/provider.ts | 416 ++++++ .../ai-sandbox-blaxel/tests/blaxel.test.ts | 161 +++ .../ai-sandbox-blaxel/tests/handle.test.ts | 940 +++++++++++++ .../tests/journal.conformance.test.ts | 30 + .../ai-sandbox-blaxel/tests/provider.test.ts | 437 ++++++ packages/ai-sandbox-blaxel/tsconfig.json | 8 + packages/ai-sandbox-blaxel/vite.config.ts | 37 + packages/ai-sandbox/README.md | 1 + pnpm-lock.yaml | 271 +++- 16 files changed, 3637 insertions(+), 15 deletions(-) create mode 100644 .changeset/add-ai-sandbox-blaxel.md create mode 100644 packages/ai-sandbox-blaxel/CHANGELOG.md create mode 100644 packages/ai-sandbox-blaxel/package.json create mode 100644 packages/ai-sandbox-blaxel/src/handle.ts create mode 100644 packages/ai-sandbox-blaxel/src/index.ts create mode 100644 packages/ai-sandbox-blaxel/src/provider.ts create mode 100644 packages/ai-sandbox-blaxel/tests/blaxel.test.ts create mode 100644 packages/ai-sandbox-blaxel/tests/handle.test.ts create mode 100644 packages/ai-sandbox-blaxel/tests/journal.conformance.test.ts create mode 100644 packages/ai-sandbox-blaxel/tests/provider.test.ts create mode 100644 packages/ai-sandbox-blaxel/tsconfig.json create mode 100644 packages/ai-sandbox-blaxel/vite.config.ts diff --git a/.changeset/add-ai-sandbox-blaxel.md b/.changeset/add-ai-sandbox-blaxel.md new file mode 100644 index 000000000..df90691b0 --- /dev/null +++ b/.changeset/add-ai-sandbox-blaxel.md @@ -0,0 +1,24 @@ +--- +'@tanstack/ai-sandbox-blaxel': minor +--- + +Add `@tanstack/ai-sandbox-blaxel`, a sandbox provider backed by managed Blaxel +sandboxes. It implements the `SandboxProvider` / `SandboxHandle` +contract: native filesystem reads and writes, `fs.watch()` without polling, +commands with separate stdout and stderr, live bounded background-process +output, per-port preview URLs, `env` injection, and resume-by-id. Process stdout +and stderr are byte-bounded through remotely supervised 8 MiB capture pipelines +and sent live as fixed-size base64 records, preventing the pinned SDK from +accumulating unbounded cumulative or newline-free logs while retaining exact +bytes and the framework's line-stream contract. + +Blaxel's source-scoped snapshot/fork API is currently a private preview, has +no entitlement probe, and does not document snapshots surviving source deletion. +The provider therefore keeps the framework's `snapshots`, `fork`, and +`restoreSnapshot` surface disabled rather than claiming reconstruct-after-delete +semantics it cannot guarantee. + +Created sandboxes carry a `1h` TTL by default so an abandoned run cannot strand +a paid sandbox; pass `ttl: null` to manage lifetime yourself. Previews are +token-gated by default, and the returned channel reports both the token and the +ready-to-send `X-Blaxel-Preview-Token` header. diff --git a/docs/config.json b/docs/config.json index 2dc4bc145..cb31a125a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -500,7 +500,7 @@ "label": "Providers", "to": "sandbox/providers", "addedAt": "2026-06-29", - "updatedAt": "2026-08-04" + "updatedAt": "2026-08-06" }, { "label": "Harnesses", diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index efa75bf26..3e375a9e0 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, Daytona, or Vercel) and what each one can do." +description: "Pick and configure a local, container, edge, or managed-cloud TanStack AI sandbox provider and understand what each one can do." --- A provider owns the isolation primitive: where the harness actually runs. Every @@ -25,6 +25,7 @@ same. | Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; port preview links, resume-by-id. Needs `DAYTONA_API_KEY`. | | Vercel | `@tanstack/ai-sandbox-vercel` | microVM | Managed [Vercel Sandbox](https://vercel.com/docs/sandbox) microVMs; exposed-port domains, resume-by-id (persistent). Needs `VERCEL_TOKEN` + team/project. | | Sprites | `@tanstack/ai-sandbox-sprites` | stateful sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, in-place checkpoints, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. | +| Blaxel | `@tanstack/ai-sandbox-blaxel` | cloud sandbox | Managed [Blaxel](https://blaxel.ai) sandboxes; durable filesystem, per-port preview URLs, native file watch, resume-by-id. Snapshot/fork remain disabled while the source-scoped private-preview semantics are unproven. Needs `BL_API_KEY` + `BL_WORKSPACE`. | Each provider is its own package, and the constructor is the only thing that differs between them: @@ -34,15 +35,18 @@ import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process' import { dockerSandbox } from '@tanstack/ai-sandbox-docker' import { daytonaSandbox } from '@tanstack/ai-sandbox-daytona' import { vercelSandbox } from '@tanstack/ai-sandbox-vercel' +import { blaxelSandbox } from '@tanstack/ai-sandbox-blaxel' const dev = localProcessSandbox() // runs on your host const isolated = dockerSandbox({ image: 'node:22' }) // runs in a container const daytona = daytonaSandbox({ apiKey: process.env.DAYTONA_API_KEY }) // managed cloud sandbox const vercel = vercelSandbox({ runtime: 'node24' }) // managed Vercel microVM +const blaxel = blaxelSandbox() // managed Blaxel sandbox; reads BL_API_KEY + BL_WORKSPACE ``` -> Cloud providers (Daytona, Vercel) run as remote VMs. When you drive them from -> your laptop, [tools](./tools) bridged from `chat()` can't dial your machine's +> Cloud providers (including Daytona, Vercel, Sprites, and Blaxel) run +> remotely. 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 > co-located model. @@ -200,6 +204,60 @@ 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)). +## Blaxel + +```ts +import { blaxelSandbox } from '@tanstack/ai-sandbox-blaxel' + +const blaxel = blaxelSandbox({ + apiKey: process.env.BL_API_KEY, + workspace: process.env.BL_WORKSPACE, +}) +``` + +- **Isolation:** a managed [Blaxel](https://blaxel.ai) cloud sandbox — a remote VM + you don't run yourself. Pick the image with `image` (default + `blaxel/base-image:latest`) and the size with `memory` (default 2048 MB). Set + `region` (or `BL_REGION`) to choose a region and to silence the SDK's warning + that it will become required. +- **Auth / env:** needs `BL_API_KEY` and `BL_WORKSPACE`, either as constructor + options or environment variables. `@blaxel/core` authentication is + process-global, so use one Blaxel API key/workspace pair per Node.js process + and do not call `@blaxel/core.initialize()` again afterward. The provider + rejects a second pair at construction time instead of risking cross-workspace + requests. +- **Lifetime:** created sandboxes carry a `1h` TTL by default so an abandoned run + cannot strand a paid sandbox. Override with `ttl`, or pass `ttl: null` to manage + lifetime yourself. +- **Resume:** resume-by-id reconnects to the named sandbox, and its filesystem + is durable across idle suspend/resume for the sandbox's lifetime. Blaxel's + snapshot/fork API is currently a source-scoped private preview, has no + entitlement probe, and does not document snapshots surviving source deletion. + The framework requires `snapshots` to reconstruct after the source is gone, so + this provider conservatively advertises both `snapshots` and `fork` as `false` + and does not expose `restoreSnapshot`. +- **Ports:** `ports.connect(port)` creates a per-port preview URL. Previews are + token-gated by default and the returned channel carries both the token and the + ready-to-send `X-Blaxel-Preview-Token` header. Set `publicPreviews: true` for + unauthenticated URLs. +- **Files:** `fs.watch()` is native, so file-event and diff hooks work without + polling. +- **Process output:** stdout and stderr remain live-streamed through bounded + remote capture pipelines. Each stream has an 8 MiB total limit; exceeding it + fails and remotely reaps the process instead of accumulating unbounded logs in + the provider host. Cancellation uses the same process-group supervisor because + the pinned SDK does not prove named-process kill reaches child processes. + Custom images must provide Bash plus `cat`, `mkfifo`, `dd`, `base64`, `tr`, + and `wc` (the default Blaxel base image does). The supervisor invokes Bash + explicitly so job-control process groups do not depend on the image's + `/bin/sh` implementation. +- **Resume semantics:** a destroyed sandbox does not disappear immediately — + Blaxel keeps the record in a teardown state before purging it. `resume()` + treats deleting, deactivating, failed, and terminated records as gone, while a + `DEACTIVATED` sandbox remains resumable consistently with the pinned SDK. +- **Bridge:** like Daytona/Vercel, a remote VM — bridged tools need the tunnel in + local dev (see [tools](./tools)). + ## Capabilities Providers declare what they support via `capabilities()`. The flags are: @@ -211,7 +269,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 and Docker; `false` on remote/edge providers (Daytona, Vercel, Cloudflare), where stdin-fed harnesses deliver the prompt via a file + shell redirection instead. | +| `writableStdin` | A spawned process exposes a writable host→process stdin. `true` for local-process and Docker; `false` on remote/edge providers (Daytona, Vercel, Sprites, Blaxel, Cloudflare), where stdin-fed harnesses deliver the prompt via a file + shell redirection instead. | | `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. | @@ -259,6 +317,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`. | +| Blaxel | `false` | The SDK issues a server-side process kill, but whether it terminates the shell's child process group is unmeasured. The shared live conformance suite is credential-gated on `BL_API_KEY` and `BL_WORKSPACE`. | | 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-blaxel/CHANGELOG.md b/packages/ai-sandbox-blaxel/CHANGELOG.md new file mode 100644 index 000000000..6c0641dff --- /dev/null +++ b/packages/ai-sandbox-blaxel/CHANGELOG.md @@ -0,0 +1 @@ +# @tanstack/ai-sandbox-blaxel diff --git a/packages/ai-sandbox-blaxel/package.json b/packages/ai-sandbox-blaxel/package.json new file mode 100644 index 000000000..d5d094617 --- /dev/null +++ b/packages/ai-sandbox-blaxel/package.json @@ -0,0 +1,57 @@ +{ + "name": "@tanstack/ai-sandbox-blaxel", + "version": "0.1.0", + "description": "Blaxel sandbox provider for TanStack AI — run harness adapters inside isolated Blaxel cloud sandboxes through the uniform SandboxHandle.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-sandbox-blaxel" + }, + "keywords": [ + "ai", + "tanstack", + "sandbox", + "blaxel", + "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" + }, + "engines": { + "node": ">=22.4.0" + }, + "dependencies": { + "@blaxel/core": "^0.3.10" + }, + "peerDependencies": { + "@tanstack/ai-sandbox": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai-sandbox": "workspace:*", + "@vitest/coverage-v8": "4.0.14" + } +} diff --git a/packages/ai-sandbox-blaxel/src/handle.ts b/packages/ai-sandbox-blaxel/src/handle.ts new file mode 100644 index 000000000..f48c6c7b3 --- /dev/null +++ b/packages/ai-sandbox-blaxel/src/handle.ts @@ -0,0 +1,1196 @@ +/** + * SandboxHandle backed by a Blaxel cloud sandbox. Real + * isolation: fs/exec/git operate inside the remote sandbox and paths are real + * sandbox paths (default workspace root `/workspace`). + * + * Filesystem data ops (read/readBytes/write/list/mkdir/remove) use Blaxel's + * native filesystem endpoints; `rename` and `exists` desugar to `exec` because + * the filesystem API has no move or stat call. Commands run through Blaxel's + * process API, which reports stdout, stderr, and the exit code as separate + * fields, so no output demultiplexing is needed. + */ +import { createHash, randomUUID } from 'node:crypto' +import { createExecBackedGit } from '@tanstack/ai-sandbox' +import type { + ExecResult, + ProcessOptions, + SandboxCapabilities, + SandboxChannel, + SandboxHandle, + SpawnHandle, +} from '@tanstack/ai-sandbox' + +export const BLAXEL_CAPS: SandboxCapabilities = { + fs: true, + exec: true, + env: true, + ports: true, + backgroundProcesses: true, + // Blaxel's process API streams stdout and stderr but exposes no host→process + // stdin channel, so adapters that feed a prompt over stdin must write it to a + // file and redirect instead. + writableStdin: false, + // The SDK exposes a server-side kill endpoint, but child-process-group + // termination has not yet been measured against a live sandbox. Keep journal + // follow mode disabled until that conformance test can prove it leaves no + // remote process behind. + killableProcesses: false, + // Blaxel's source-scoped snapshot/fork API is a private preview, offers no + // entitlement probe, and does not document snapshots surviving deletion. + // TanStack's snapshot contract must reconstruct a sandbox after resume says + // the source is gone, so neither capability is advertised here. + snapshots: false, + // Blaxel supports workspace-level network rules, but this provider does not + // yet translate `SandboxPolicy` into them. Advertised false until it does. + networkPolicy: false, + // The sandbox filesystem persists for the sandbox's lifetime, across exec + // calls and idle suspend/resume, until it is deleted or its TTL expires. + durableFilesystem: true, + fork: false, +} + +/** Default workspace root created inside the sandbox. */ +export const BLAXEL_DEFAULT_WORKDIR = '/workspace' + +/** How long a minted preview token stays valid. */ +const PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1000 + +/** Maximum unread stdout or stderr retained per spawned process. */ +const STREAM_BUFFER_LIMIT_BYTES = 8 * 1024 * 1024 +const PROCESS_REGISTRATION_RECONCILIATION_MS = 10_000 + +function abortable(operation: Promise, signal?: AbortSignal): Promise { + if (!signal) return operation + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + try { + signal.throwIfAborted() + } catch (error) { + reject(error) + } + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + }) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * The Blaxel sandbox surface this handle uses, expressed structurally so a real + * `SandboxInstance` from `@blaxel/core` satisfies it and unit tests can inject a + * fake without touching the network. + */ +export interface BlaxelSandboxLike { + metadata?: { labels?: Record } + status?: string + fs: { + read: (path: string) => Promise + readBinary: (path: string) => Promise + write: (path: string, content: string) => Promise + writeBinary: (path: string, content: Uint8Array) => Promise + ls: (path: string) => Promise + mkdir: (path: string, permissions?: string) => Promise + rm: (path: string, recursive?: boolean) => Promise + watch: ( + path: string, + callback: (event: BlaxelWatchEventLike) => void | Promise, + options?: { onError?: (error: Error) => void; withContent: boolean }, + ) => { close: () => void } + } + process: { + exec: (request: BlaxelProcessRequestLike) => Promise + wait: ( + identifier: string, + options?: { maxWait?: number; interval?: number }, + ) => Promise + kill: (identifier: string) => Promise + streamLogs: ( + identifier: string, + options?: { + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void + onError?: (error: Error) => void + }, + ) => { close: () => void; wait: () => Promise } + } + previews: { + createIfNotExists: (preview: { + metadata: { name: string } + spec: { port: number; public?: boolean; ttl?: string } + }) => Promise + } + delete: () => Promise +} + +export interface BlaxelDirectoryLike { + files?: Array<{ name: string }> + subdirectories?: Array<{ name: string }> +} + +export interface BlaxelProcessRequestLike { + command: string + name?: string + workingDir?: string + env?: Record + waitForCompletion?: boolean + keepAlive?: boolean + timeout?: number + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void +} + +export interface BlaxelProcessLike { + name?: string + pid?: string + stdout?: string + stderr?: string + exitCode?: number + status?: string + close?: () => void +} + +export interface BlaxelPreviewLike { + spec?: { url?: string; public?: boolean; port?: number } + tokens: { create: (expiresAt: Date) => Promise<{ value: string }> } +} + +export interface BlaxelWatchEventLike { + op: string + path: string + name?: string +} + +export interface BlaxelHandleDeps { + sandbox: BlaxelSandboxLike + /** Sandbox name — the durable id used to reconnect and destroy. */ + name: string + /** Working directory inside the sandbox; `/workspace` maps here. */ + workdir: string + /** Mint public preview URLs instead of token-gated ones. */ + publicPreviews: boolean + /** TTL applied to previews this handle creates. */ + previewTtl: string +} + +export class BlaxelHandle implements SandboxHandle { + readonly id: string + readonly provider = 'blaxel' + readonly workspaceRoot: string + readonly capabilities: SandboxCapabilities + readonly fs: SandboxHandle['fs'] + readonly git: SandboxHandle['git'] + readonly process: SandboxHandle['process'] + readonly ports: SandboxHandle['ports'] + readonly env: SandboxHandle['env'] + + private readonly sandbox: BlaxelSandboxLike + private readonly workdir: string + private readonly publicPreviews: boolean + private readonly previewTtl: string + private readonly envVars: Record = Object.create(null) + private destroyPromise?: Promise + + constructor(deps: BlaxelHandleDeps) { + this.sandbox = deps.sandbox + this.id = deps.name + this.workdir = deps.workdir + this.workspaceRoot = deps.workdir + this.publicPreviews = deps.publicPreviews + this.previewTtl = deps.previewTtl + this.capabilities = BLAXEL_CAPS + + this.process = { + exec: (command, opts) => this.exec(command, opts), + spawn: (command, opts) => this.spawnProcess(command, opts), + } + + this.fs = { + read: (p) => this.sandbox.fs.read(this.abs(p)), + readBytes: async (p) => { + const blob = await this.sandbox.fs.readBinary(this.abs(p)) + return new Uint8Array(await blob.arrayBuffer()) + }, + write: async (p, data) => { + if (typeof data === 'string') { + await this.sandbox.fs.write(this.abs(p), data) + return + } + await this.sandbox.fs.writeBinary(this.abs(p), data) + }, + list: async (p) => { + const directory = await this.sandbox.fs.ls(this.abs(p)) + // Re-root native absolute paths under the caller's virtual path so + // consumers stay provider-agnostic. + const base = p.replace(/\/$/, '') + return [ + ...(directory.subdirectories ?? []).map((entry) => ({ + name: entry.name, + path: `${base}/${entry.name}`, + type: 'dir' as const, + })), + ...(directory.files ?? []).map((entry) => ({ + name: entry.name, + path: `${base}/${entry.name}`, + type: 'file' as const, + })), + ] + }, + // Blaxel's mkdir creates parents, matching the contract's recursive intent. + mkdir: async (p) => { + await this.sandbox.fs.mkdir(this.abs(p)) + }, + // `remove` is unconditionally recursive per the contract; Blaxel's rm + // needs that opt-in explicitly. + remove: async (p) => { + await this.sandbox.fs.rm(this.abs(p), true) + }, + // No native move call, so shell out. `mv` also gives us the + // overwrite-destination semantics the contract expects. + rename: async (from, to) => { + const result = await this.exec( + `mv -- ${q(this.abs(from))} ${q(this.abs(to))}`, + ) + if (result.exitCode !== 0) { + throw new Error(`rename failed: ${errText(result)}`) + } + }, + // No native stat call. `test -e` answers the contract's question directly + // and does not need the parent directory to be listable. + exists: async (p) => { + const result = await this.exec(`test -e ${q(this.abs(p))}`) + return result.exitCode === 0 + }, + watch: (p, onEvent) => { + const subscription = this.sandbox.fs.watch( + this.abs(p), + (event) => { + onEvent({ type: event.op, path: this.virtual(eventPath(event)) }) + }, + { withContent: false }, + ) + return Promise.resolve({ + stop: () => { + subscription.close() + return Promise.resolve() + }, + }) + }, + } + + const git = createExecBackedGit(this.process, this.workdir) + this.git = { + clone: (input) => + git.clone({ + ...input, + ...(input.dir !== undefined ? { dir: this.abs(input.dir) } : {}), + }), + status: (dir) => + git.status(dir !== undefined ? this.abs(dir) : undefined), + add: (paths, dir) => + git.add( + paths.map((path) => this.abs(path)), + dir !== undefined ? this.abs(dir) : undefined, + ), + commit: (message, dir) => + git.commit(message, dir !== undefined ? this.abs(dir) : undefined), + push: (dir) => git.push(dir !== undefined ? this.abs(dir) : undefined), + pull: (dir) => git.pull(dir !== undefined ? this.abs(dir) : undefined), + branch: (dir) => + git.branch(dir !== undefined ? this.abs(dir) : undefined), + } + + this.ports = { + connect: (port) => this.connectPort(port), + } + + this.env = { + // Blaxel fixes a sandbox's environment at creation time, so later + // additions are held here and merged into every command this handle runs. + set: (vars) => { + Object.assign(this.envVars, vars) + return Promise.resolve() + }, + } + } + + /** Map the conventional `/workspace` virtual root onto the real workdir. */ + private abs(p: string): string { + if (this.workdir === BLAXEL_DEFAULT_WORKDIR) return p + if (p === BLAXEL_DEFAULT_WORKDIR) return this.workdir + if (p.startsWith(`${BLAXEL_DEFAULT_WORKDIR}/`)) { + return `${this.workdir}/${p.slice(BLAXEL_DEFAULT_WORKDIR.length + 1)}` + } + return p + } + + /** Map a native path back onto the conventional `/workspace` root. */ + private virtual(p: string): string { + if (this.workdir === BLAXEL_DEFAULT_WORKDIR) return p + if (p === this.workdir) return BLAXEL_DEFAULT_WORKDIR + if (p.startsWith(`${this.workdir}/`)) { + return `${BLAXEL_DEFAULT_WORKDIR}/${p.slice(this.workdir.length + 1)}` + } + return p + } + + private mergedEnv(extra?: Record): Record { + return { ...this.envVars, ...extra } + } + + /** Names stay unique across resumed handles for the same sandbox. */ + private processName(kind: string): string { + return `tanstack-ai-${kind}-${randomUUID().replace(/-/g, '')}` + } + + /** + * Capture remote stdout/stderr live into byte-bounded files. Fixed-size wire + * records bound the SDK's newline accumulator, and the terminal path never + * calls cumulative wait/get. + */ + private boundedCommand(command: string): { + command: string + outputDir: string + overflowMarker: string + recordPrefix: string + } { + const outputDir = `/tmp/tanstack-ai-output-${randomUUID()}` + const overflowMarker = `__TANSTACK_AI_OUTPUT_OVERFLOW_${randomUUID()}__` + const recordPrefix = `__TANSTACK_AI_OUTPUT_CHUNK_${randomUUID()}__:` + const supervisorDelimiter = `__TANSTACK_AI_SUPERVISOR_${randomUUID().replace(/-/g, '')}__` + const dir = q(outputDir) + const outPipe = q(`${outputDir}/stdout.pipe`) + const errPipe = q(`${outputDir}/stderr.pipe`) + const outFile = q(`${outputDir}/stdout`) + const errFile = q(`${outputDir}/stderr`) + const statusFile = q(`${outputDir}/status`) + const pidsFile = q(`${outputDir}/pids`) + const limitsFile = q(`${outputDir}/limits`) + const supervisorFile = q(`${outputDir}/supervisor.sh`) + const capture = ( + pipe: string, + file: string, + label: 'stdout' | 'stderr', + redirect = '', + ): string => { + const chunkFile = q(`${outputDir}/${label}.chunk`) + return [ + '(', + // Count raw bytes explicitly because `ulimit -f` units vary between + // shells. Every completed chunk is appended before the same bytes are + // framed for the SDK; the one-byte probe detects exclusive overflow. + '__tanstack_capture_status=0', + `: > ${file} || __tanstack_capture_status=1`, + `__tanstack_remaining=${STREAM_BUFFER_LIMIT_BYTES}`, + 'while [ "$__tanstack_capture_status" -eq 0 ] && [ "$__tanstack_remaining" -gt 0 ]; do', + ' __tanstack_block=65536', + ' if [ "$__tanstack_remaining" -lt "$__tanstack_block" ]; then __tanstack_block="$__tanstack_remaining"; fi', + ` : > ${chunkFile}`, + ` dd of=${chunkFile} bs="$__tanstack_block" count=1 2>/dev/null`, + ' __tanstack_dd_status=$?', + ' if [ "$__tanstack_dd_status" -ne 0 ]; then __tanstack_capture_status=1; break; fi', + ` __tanstack_chunk_size=$(wc -c < ${chunkFile})`, + ' [ "$__tanstack_chunk_size" -gt 0 ] || break', + ` cat ${chunkFile} >> ${file} || { __tanstack_capture_status=1; break; }`, + ` printf '%s' ${q(recordPrefix)}`, + ` base64 < ${chunkFile} | tr -d '\r\n'`, + ` printf '\n'`, + ' __tanstack_remaining=$((__tanstack_remaining - __tanstack_chunk_size))', + 'done', + 'if [ "$__tanstack_capture_status" -eq 0 ] && [ "$__tanstack_remaining" -eq 0 ]; then', + ` : > ${chunkFile}`, + ` dd of=${chunkFile} bs=1 count=1 2>/dev/null`, + ' __tanstack_dd_status=$?', + ` __tanstack_probe_size=$(wc -c < ${chunkFile})`, + ' if [ "$__tanstack_dd_status" -ne 0 ]; then __tanstack_capture_status=1; fi', + ' if [ "$__tanstack_probe_size" -gt 0 ]; then __tanstack_capture_status=2; fi', + 'fi', + `rm -f -- ${chunkFile}`, + 'if [ "$__tanstack_capture_status" -ne 0 ]; then', + ` printf '%s\n' ${q(label)} >> ${limitsFile}`, + ` printf '%s\n' ${q(overflowMarker)}`, + 'fi', + `) < ${pipe}${redirect} &`, + ].join('\n') + } + + const supervisor = [ + `mkdir -p -- ${dir}`, + `rm -f -- ${supervisorFile}`, + `mkfifo -- ${outPipe} ${errPipe}`, + `: > ${limitsFile}`, + '__tanstack_reap() {', + ' __tanstack_reap_status="$1"', + ' trap - HUP INT TERM', + ' for __tanstack_pid in "${__tanstack_command:-}" "${__tanstack_stdout_reader:-}" "${__tanstack_stderr_reader:-}"; do', + ' if [ -n "$__tanstack_pid" ]; then kill -TERM -- "-$__tanstack_pid" 2>/dev/null || true; fi', + ' done', + ' sleep 0.1', + ' for __tanstack_pid in "${__tanstack_command:-}" "${__tanstack_stdout_reader:-}" "${__tanstack_stderr_reader:-}"; do', + ' if [ -n "$__tanstack_pid" ]; then kill -KILL -- "-$__tanstack_pid" 2>/dev/null || true; fi', + ' done', + ' for __tanstack_pid in "${__tanstack_command:-}" "${__tanstack_stdout_reader:-}" "${__tanstack_stderr_reader:-}"; do', + ' if [ -n "$__tanstack_pid" ]; then wait "$__tanstack_pid" 2>/dev/null || true; fi', + ' done', + ` rm -f -- ${outPipe} ${errPipe} ${pidsFile}`, + ' exit "$__tanstack_reap_status"', + '}', + "trap '__tanstack_reap 129' HUP", + "trap '__tanstack_reap 130' INT", + "trap '__tanstack_reap 143' TERM", + // Job control gives each background job a distinct process group. Both + // this supervisor and a later host-side reaper can then terminate the + // command and the complete capture pipelines without relying on + // Blaxel's unproven named-process tree-kill behavior. + 'set -m', + capture(outPipe, outFile, 'stdout'), + '__tanstack_stdout_reader=$!', + capture(errPipe, errFile, 'stderr', ' >&2'), + '__tanstack_stderr_reader=$!', + '(', + command, + `) > ${outPipe} 2> ${errPipe} &`, + '__tanstack_command=$!', + `printf '%s\\n' "$__tanstack_command" "$__tanstack_stdout_reader" "$__tanstack_stderr_reader" > ${pidsFile}`, + 'set +m', + 'wait "$__tanstack_command"', + '__tanstack_status=$?', + 'wait "$__tanstack_stdout_reader" 2>/dev/null || true', + 'wait "$__tanstack_stderr_reader" 2>/dev/null || true', + `printf '%s' "$__tanstack_status" > ${statusFile}`, + `rm -f -- ${outPipe} ${errPipe} ${pidsFile}`, + 'exit "$__tanstack_status"', + ].join('\n') + return { + outputDir, + overflowMarker, + recordPrefix, + // Materialize the supervisor without evaluating it in Blaxel's + // image-dependent outer shell, then run it in Bash so job-control process + // groups have consistent semantics. The script unlinks itself on entry. + command: [ + `mkdir -p -- ${dir}`, + `cat > ${supervisorFile} <<${q(supervisorDelimiter)}`, + supervisor, + supervisorDelimiter, + `exec bash ${supervisorFile}`, + '', + ].join('\n'), + } + } + + private async readCapturedOutput( + outputDir: string, + stream: 'stdout' | 'stderr', + ): Promise { + const [text, limits] = await Promise.all([ + this.sandbox.fs.read(`${outputDir}/${stream}`), + this.sandbox.fs.read(`${outputDir}/limits`), + ]) + if (limits.split(/\r?\n/).includes(stream)) { + throw new Error( + `blaxel: ${stream} exceeded the ${STREAM_BUFFER_LIMIT_BYTES}-byte remote output limit.`, + ) + } + if (Buffer.byteLength(text) > STREAM_BUFFER_LIMIT_BYTES) { + throw new Error( + `blaxel: ${stream} exceeded the ${STREAM_BUFFER_LIMIT_BYTES}-byte remote output limit.`, + ) + } + return text + } + + private async cleanupCapturedOutput(outputDir: string): Promise { + let lastError: unknown + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await this.sandbox.fs.rm(outputDir, true) + return + } catch (error) { + if (isNotFound(error)) return + lastError = error + if (attempt < 4) await sleep(Math.min(50 * 2 ** attempt, 500)) + } + } + throw lastError + } + + private async reapProcessGroups(outputDir: string): Promise { + const pidsFile = q(`${outputDir}/pids`) + const result = await this.sandbox.process.exec({ + name: this.processName('reap'), + command: [ + `if [ -r ${pidsFile} ]; then`, + ` __tanstack_pids=$(cat ${pidsFile})`, + ' for __tanstack_pid in $__tanstack_pids; do', + ' case "$__tanstack_pid" in (""|*[!0-9]*) continue ;; esac', + ' kill -TERM -- "-$__tanstack_pid" 2>/dev/null || true', + ' done', + ' sleep 0.1', + ' for __tanstack_pid in $__tanstack_pids; do', + ' case "$__tanstack_pid" in (""|*[!0-9]*) continue ;; esac', + ' kill -KILL -- "-$__tanstack_pid" 2>/dev/null || true', + ' done', + 'fi', + ].join('\n'), + workingDir: this.workdir, + waitForCompletion: true, + timeout: 30, + }) + if ((result.exitCode ?? 0) !== 0) { + throw new Error( + `blaxel: remote process-group reaper exited ${result.exitCode ?? 'without a status'}.`, + ) + } + } + + private async killEventually(name: string): Promise { + let lastError: unknown + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + await this.sandbox.process.kill(name) + return + } catch (error) { + if (isNotFound(error)) return + lastError = error + if (attempt < 7) await sleep(Math.min(25 * 2 ** attempt, 500)) + } + } + throw lastError + } + + private async terminateProcess( + name: string, + outputDir: string, + ): Promise { + const errors: Array = [] + try { + await this.reapProcessGroups(outputDir) + } catch (error) { + errors.push(error) + } + try { + await this.killEventually(name) + } catch (error) { + errors.push(error) + } + try { + await this.cleanupCapturedOutput(outputDir) + } catch (error) { + errors.push(error) + } + if (errors.length > 0) { + throw new AggregateError( + errors, + `blaxel: failed to fully terminate process ${name} and clean its capture files.`, + ) + } + } + + /** + * A rejected non-idempotent POST can still register its unique name after the + * response has been lost. Treat 404 as transient for a bounded visibility + * window and repeat both group reaping and named-process deletion. + */ + private async reconcileAmbiguousProcessStart( + name: string, + outputDir: string, + ): Promise { + const deadline = Date.now() + PROCESS_REGISTRATION_RECONCILIATION_MS + let attempt = 0 + for (;;) { + const errors: Array = [] + try { + await this.reapProcessGroups(outputDir) + } catch (error) { + errors.push(error) + } + + let killed = false + try { + await this.sandbox.process.kill(name) + killed = true + } catch (error) { + if (!isNotFound(error)) errors.push(error) + } + + // A successful named kill may race the PID file by a few instructions; + // reap once more before removing the capture metadata. + if (killed) { + try { + await this.reapProcessGroups(outputDir) + } catch (error) { + errors.push(error) + } + } + try { + await this.cleanupCapturedOutput(outputDir) + } catch (error) { + errors.push(error) + } + + if (killed) { + if (errors.length > 0) { + throw new AggregateError( + errors, + `blaxel: process ${name} appeared after an ambiguous start response, but cleanup was incomplete.`, + ) + } + return + } + if (Date.now() >= deadline) { + if (errors.length > 0) { + throw new AggregateError( + errors, + `blaxel: could not reconcile ambiguous process start ${name}.`, + ) + } + return + } + attempt += 1 + await sleep(Math.min(50 * 2 ** Math.min(attempt, 4), 500)) + } + } + + private cleanupFailure( + error: unknown, + cleanupError: unknown, + name: string, + ): AggregateError { + return new AggregateError( + [error, cleanupError], + `blaxel: process ${name} failed and remote cleanup also failed.`, + ) + } + + private async exec( + command: string, + opts?: ProcessOptions, + ): Promise { + const signal = opts?.signal + signal?.throwIfAborted() + const name = this.processName('exec') + const bounded = this.boundedCommand(command) + const execution = this.sandbox.process.exec({ + name, + command: bounded.command, + workingDir: opts?.cwd ? this.abs(opts.cwd) : this.workdir, + env: this.mergedEnv(opts?.env), + waitForCompletion: true, + }) + let termination: Promise | undefined + const terminate = (): Promise => + (termination ??= this.terminateProcess(name, bounded.outputDir)) + const onAbort = (): void => { + void terminate().catch(() => undefined) + } + signal?.addEventListener('abort', onAbort, { once: true }) + let executionResolved = false + try { + const result = await abortable(execution, signal) + executionResolved = true + if (signal?.aborted) { + await terminate().catch(() => undefined) + signal.throwIfAborted() + } + const [stdout, stderr] = await Promise.all([ + this.readCapturedOutput(bounded.outputDir, 'stdout'), + this.readCapturedOutput(bounded.outputDir, 'stderr'), + ]) + await this.cleanupCapturedOutput(bounded.outputDir) + return { + stdout, + stderr, + exitCode: result.exitCode ?? 0, + } + } catch (error) { + if (signal?.aborted) { + // The SDK POST cannot be aborted. When it settles, repeat cleanup and + // use the registration window if the eventual rejection is ambiguous. + void execution + .then( + () => this.terminateProcess(name, bounded.outputDir), + (startError: unknown) => + mayHaveStartedProcess(startError) + ? this.reconcileAmbiguousProcessStart(name, bounded.outputDir) + : this.terminateProcess(name, bounded.outputDir), + ) + .catch(() => undefined) + } + try { + if (!executionResolved && mayHaveStartedProcess(error)) { + await this.reconcileAmbiguousProcessStart(name, bounded.outputDir) + } else { + await terminate() + } + } catch (cleanupError) { + throw this.cleanupFailure(error, cleanupError, name) + } + throw error + } finally { + signal?.removeEventListener('abort', onAbort) + } + } + + private async spawnProcess( + command: string, + opts?: ProcessOptions, + ): Promise { + const signal = opts?.signal + signal?.throwIfAborted() + const name = this.processName('spawn') + const stdout = new ChunkStream('stdout') + const stderr = new ChunkStream('stderr') + const bounded = this.boundedCommand(command) + let termination: Promise | undefined + const terminate = (): Promise => + (termination ??= this.terminateProcess(name, bounded.outputDir)) + + const starting = this.sandbox.process.exec({ + name, + command: bounded.command, + workingDir: opts?.cwd ? this.abs(opts.cwd) : this.workdir, + env: this.mergedEnv(opts?.env), + waitForCompletion: false, + // Keep the sandbox awake for the process and do not inherit the SDK's + // finite keep-alive timeout. + keepAlive: true, + timeout: 0, + }) + const abortStart = (): void => { + void terminate().catch(() => undefined) + } + signal?.addEventListener('abort', abortStart, { once: true }) + + let started: BlaxelProcessLike + try { + started = await abortable(starting, signal) + } catch (error) { + stdout.end() + stderr.end() + if (signal?.aborted) { + void starting + .then( + () => this.terminateProcess(name, bounded.outputDir), + (startError: unknown) => + mayHaveStartedProcess(startError) + ? this.reconcileAmbiguousProcessStart(name, bounded.outputDir) + : this.terminateProcess(name, bounded.outputDir), + ) + .catch(() => undefined) + } + try { + if (mayHaveStartedProcess(error)) { + await this.reconcileAmbiguousProcessStart(name, bounded.outputDir) + } else { + await terminate() + } + } catch (cleanupError) { + throw this.cleanupFailure(error, cleanupError, name) + } + throw error + } finally { + signal?.removeEventListener('abort', abortStart) + } + + let transportError: unknown + const failTransport = (error: unknown): void => { + transportError ??= error + stdout.fail(error) + stderr.fail(error) + void terminate().catch(() => undefined) + } + const onLine = (stream: ChunkStream, line: string): void => { + if (line === bounded.overflowMarker) { + failTransport( + new Error( + `blaxel: remote process output exceeded the ${STREAM_BUFFER_LIMIT_BYTES}-byte per-stream limit.`, + ), + ) + return + } + if (!line.startsWith(bounded.recordPrefix)) { + failTransport( + new Error('blaxel: received malformed framed process output.'), + ) + return + } + const encoded = line.slice(bounded.recordPrefix.length) + const bytes = Buffer.from(encoded, 'base64') + if (bytes.length === 0 || bytes.toString('base64') !== encoded) { + failTransport( + new Error('blaxel: received invalid encoded process output.'), + ) + return + } + if (!stream.pushBytes(bytes)) { + void terminate().catch(() => undefined) + } + } + const logs = this.sandbox.process.streamLogs(name, { + onStdout: (line) => onLine(stdout, line), + onStderr: (line) => onLine(stderr, line), + onError: failTransport, + }) + + const removeAbort = (): void => + signal?.removeEventListener('abort', onAbort) + const closeControls = (): void => { + logs.close() + started.close?.() + removeAbort() + } + let killPromise: Promise | undefined + const kill = (): Promise => { + killPromise ??= terminate().finally(() => { + closeControls() + stdout.end() + stderr.end() + }) + return killPromise + } + const onAbort = (): void => { + void kill().catch(() => undefined) + } + + if (signal?.aborted) { + await kill().catch(() => undefined) + signal.throwIfAborted() + } + signal?.addEventListener('abort', onAbort, { once: true }) + + // Fixed-size encoded records preserve delimiters and keep the SDK's own + // line accumulator bounded; the raw capture keeps the authoritative total + // bounded. Cumulative process.wait/get is never materialized in host memory. + const terminal = logs + .wait() + .then(async () => { + if (transportError !== undefined) throw transportError + const streamError = stdout.failure ?? stderr.failure + if (streamError !== undefined) throw streamError + const [finalStdout, finalStderr, statusText] = await Promise.all([ + this.readCapturedOutput(bounded.outputDir, 'stdout'), + this.readCapturedOutput(bounded.outputDir, 'stderr'), + this.sandbox.fs.read(`${bounded.outputDir}/status`), + ]) + const exitCode = Number.parseInt(statusText, 10) + if (!Number.isInteger(exitCode)) { + throw new Error( + `blaxel: background process ${name} ended without a valid exit status.`, + ) + } + stdout.finish(finalStdout) + stderr.finish(finalStderr) + return exitCode + }) + .catch((error: unknown) => { + stdout.fail(error) + stderr.fail(error) + throw error + }) + + const completion = terminal.then( + async (exitCode) => { + closeControls() + await this.cleanupCapturedOutput(bounded.outputDir) + return exitCode + }, + async (error: unknown) => { + closeControls() + try { + await terminate() + } catch (cleanupError) { + throw this.cleanupFailure(error, cleanupError, name) + } + throw error + }, + ) + void completion.catch(() => undefined) + + return { + pid: Number.parseInt(started.pid ?? '', 10) || -1, + stdout: stdout.iterable, + stderr: stderr.iterable, + stdin: { + write: () => + Promise.reject( + new Error( + 'blaxel: background process stdin is not writable (see capabilities.writableStdin)', + ), + ), + end: () => Promise.resolve(), + }, + wait: () => completion, + kill, + } + } + + private async connectPort(port: number): Promise { + const preview = await this.sandbox.previews.createIfNotExists({ + metadata: { name: previewName(port) }, + spec: { + port, + public: this.publicPreviews, + ttl: this.previewTtl, + }, + }) + const actualPort = preview.spec?.port + if (actualPort !== port) { + throw new Error( + `blaxel: existing preview ${previewName(port)} targets port=${String(actualPort)}, but this provider requested port=${port}. Delete the stale preview before reconnecting.`, + ) + } + const actualPublic = preview.spec?.public ?? false + if (actualPublic !== this.publicPreviews) { + throw new Error( + `blaxel: existing preview ${previewName(port)} has public=${String(actualPublic)}, but this provider requested public=${String(this.publicPreviews)}. Delete the stale preview before reconnecting.`, + ) + } + + const url = preview.spec?.url + if (!url) { + throw new Error( + `blaxel: preview for port ${port} did not report a URL. Retry once the sandbox reports ready.`, + ) + } + if (this.publicPreviews) return { url } + + // Keep the credential out of the URL and report Blaxel's explicit preview + // header. The separate token field remains available to channel consumers. + const token = await preview.tokens.create( + new Date(Date.now() + PREVIEW_TOKEN_TTL_MS), + ) + if (!token.value) { + throw new Error( + `blaxel: preview for port ${port} returned an empty token.`, + ) + } + return { + url, + token: token.value, + headers: { 'X-Blaxel-Preview-Token': token.value }, + } + } + + destroy(): Promise { + this.destroyPromise ??= this.sandbox + .delete() + .then(() => undefined) + .catch((error: unknown) => { + this.destroyPromise = undefined + throw error + }) + return this.destroyPromise + } +} + +/** Preview names are scoped to their sandbox, so the port alone keeps them + * distinct, and deriving it from the port keeps `connect()` idempotent. */ +export function previewName(port: number): string { + return `tanstack-ai-${port}` +} + +/** Exposes a bounded remote capture as the `SpawnHandle` async iterable. */ +class ChunkStream { + private readonly buffer: Array = [] + private readonly emittedHash = createHash('sha256') + private readonly decoder = new TextDecoder() + private head = 0 + private bufferedBytes = 0 + private emittedLength = 0 + private resolveNext: (() => void) | undefined + private done = false + private terminalError: unknown + + constructor(private readonly label: 'stdout' | 'stderr') {} + + push(chunk: string): boolean { + if (this.done) return false + const bytes = Buffer.byteLength(chunk) + if (this.bufferedBytes + bytes > STREAM_BUFFER_LIMIT_BYTES) { + this.fail( + new Error( + `blaxel: unread ${this.label} exceeded the ${STREAM_BUFFER_LIMIT_BYTES}-byte stream buffer limit; consume spawned output continuously.`, + ), + ) + return false + } + this.bufferedBytes += bytes + if (chunk) this.buffer.push(chunk) + this.wake() + return true + } + + /** Decode one independently framed raw-output chunk. */ + pushBytes(bytes: Uint8Array): boolean { + return this.push(this.decoder.decode(bytes, { stream: true })) + } + + /** + * Reconcile live callbacks with the exact bounded remote capture. Bytes not + * yet consumed can be replaced; already emitted bytes must match exactly. + */ + finish(finalText: string): void { + if (this.done) return + const trailing = this.decoder.decode() + if (trailing && !this.push(trailing)) throw this.terminalError + const emittedPrefix = finalText.slice(0, this.emittedLength) + if ( + finalText.length < this.emittedLength || + createHash('sha256').update(emittedPrefix).digest('hex') !== + this.emittedHash.copy().digest('hex') + ) { + this.clearBuffer() + const error = new Error( + `blaxel: progressive ${this.label} diverged from the completed process output.`, + ) + this.fail(error) + throw error + } + + const remainder = finalText.slice(this.emittedLength) + this.clearBuffer() + if (Buffer.byteLength(remainder) > STREAM_BUFFER_LIMIT_BYTES) { + const error = new Error( + `blaxel: unread ${this.label} exceeded the ${STREAM_BUFFER_LIMIT_BYTES}-byte stream buffer limit; consume spawned output continuously.`, + ) + this.fail(error) + throw error + } + if (remainder) { + this.buffer.push(remainder) + this.bufferedBytes = Buffer.byteLength(remainder) + } + this.end() + } + + get failure(): unknown { + return this.terminalError + } + + fail(error: unknown): void { + if (this.done) return + this.terminalError = error + this.done = true + this.wake() + } + + end(): void { + if (this.done) return + this.done = true + this.wake() + } + + private clearBuffer(): void { + this.buffer.length = 0 + this.head = 0 + this.bufferedBytes = 0 + } + + private wake(): void { + const wake = this.resolveNext + this.resolveNext = undefined + wake?.() + } + + readonly iterable: AsyncIterable = { + [Symbol.asyncIterator]: (): AsyncIterator => ({ + next: async (): Promise> => { + for (;;) { + const chunk = this.buffer[this.head] + if (chunk !== undefined) { + this.buffer[this.head] = undefined + this.head += 1 + this.bufferedBytes -= Buffer.byteLength(chunk) + this.emittedLength += chunk.length + this.emittedHash.update(chunk) + if (this.head > 1024 && this.head * 2 > this.buffer.length) { + this.buffer.splice(0, this.head) + this.head = 0 + } + return { value: chunk, done: false } + } + if (this.done) { + this.buffer.length = 0 + this.head = 0 + if (this.terminalError !== undefined) throw this.terminalError + return { value: undefined, done: true } + } + await new Promise((resolve) => { + this.resolveNext = resolve + }) + } + }, + }), + } +} + +/** Blaxel reports a watch event as a directory plus an entry name. */ +function eventPath(event: BlaxelWatchEventLike): string { + if (!event.name) return event.path + return event.path.endsWith('/') + ? `${event.path}${event.name}` + : `${event.path}/${event.name}` +} + +function errorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined + const record = error as { + code?: unknown + status?: unknown + response?: { status?: unknown } + } + for (const value of [record.code, record.status, record.response?.status]) { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value) + } + return undefined +} + +function isNotFound(error: unknown): boolean { + return errorStatus(error) === 404 +} + +/** A lost POST response can arrive before the accepted process is observable. */ +function mayHaveStartedProcess(error: unknown): boolean { + if ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError' + ) { + return false + } + const status = errorStatus(error) + return ( + status === undefined || + status === 408 || + status === 409 || + status === 425 || + status === 429 || + status >= 500 + ) +} + +/** POSIX single-quote escape for embedding paths in a shell command. */ +function q(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +/** Best available error text from a failed command. */ +function errText(result: ExecResult): string { + return result.stderr.trim() || result.stdout.trim() || '(no output)' +} diff --git a/packages/ai-sandbox-blaxel/src/index.ts b/packages/ai-sandbox-blaxel/src/index.ts new file mode 100644 index 000000000..fa4786422 --- /dev/null +++ b/packages/ai-sandbox-blaxel/src/index.ts @@ -0,0 +1,4 @@ +export { blaxelSandbox } from './provider' +export type { BlaxelSandboxConfig } from './provider' +export { BlaxelHandle, BLAXEL_CAPS, BLAXEL_DEFAULT_WORKDIR } from './handle' +export type { BlaxelHandleDeps } from './handle' diff --git a/packages/ai-sandbox-blaxel/src/provider.ts b/packages/ai-sandbox-blaxel/src/provider.ts new file mode 100644 index 000000000..abbe0b8a1 --- /dev/null +++ b/packages/ai-sandbox-blaxel/src/provider.ts @@ -0,0 +1,416 @@ +import { createHash, randomUUID } from 'node:crypto' +import { SandboxInstance, initialize, settings } from '@blaxel/core' +import { BLAXEL_CAPS, BLAXEL_DEFAULT_WORKDIR, BlaxelHandle } from './handle' +import type { SandboxCreateConfiguration } from '@blaxel/core' +import type { BlaxelSandboxLike } from './handle' +import type { + SandboxCapabilities, + SandboxCreateInput, + SandboxDestroyInput, + SandboxHandle, + SandboxProvider, + SandboxResumeInput, +} from '@tanstack/ai-sandbox' + +export interface BlaxelSandboxConfig { + /** + * Blaxel API key. Falls back to the `BL_API_KEY` environment variable. + */ + apiKey?: string + /** + * Blaxel workspace. Falls back to the `BL_WORKSPACE` environment variable. + */ + workspace?: string + /** Sandbox image. Defaults to `blaxel/base-image:latest`. */ + image?: string + /** Memory in MB. Defaults to 2048. */ + memory?: number + /** Region to create sandboxes in. Defaults to the workspace default. */ + region?: string + /** + * Time to live for created sandboxes (e.g. `30m`, `4h`). Defaults to `1h` so + * an abandoned run cannot strand a paid sandbox indefinitely. Pass `null` to + * opt out and manage lifetime yourself. + */ + ttl?: string | null + /** + * Working directory inside the sandbox. The `/workspace` virtual root maps + * here. Defaults to `/workspace`. + */ + workdir?: string + /** + * Expose `ports.connect()` previews publicly instead of gating them with a + * preview token. Defaults to false — a token-gated preview is the safe + * default for agent-generated services. + */ + publicPreviews?: boolean + /** TTL for previews `ports.connect()` creates. Defaults to `1h`. */ + previewTtl?: string +} + +const DEFAULT_IMAGE = 'blaxel/base-image:latest' +const DEFAULT_MEMORY = 2048 +const DEFAULT_TTL = '1h' +const DEFAULT_PREVIEW_TTL = '1h' +const NAME_PREFIX = 'tanstack-ai' +const MAX_NAME_LENGTH = 49 +const HASH_SUFFIX_LENGTH = 24 +const CREATE_ATTEMPT_LABEL = 'tanstack-ai-create-attempt' +const CREATE_RECONCILE_ATTEMPTS = 30 +const CREATE_RECONCILE_INTERVAL_MS = 1000 + +/** + * Sandbox states from which no usable handle can be built. `DEPLOYING`, + * `BUILDING`, `UPLOADING`, and `BUILT` are deliberately absent: those are on + * their way up, and the first call simply waits for the sandbox to be ready. + */ +const TERMINAL_STATUSES = new Set([ + 'DELETING', + 'TERMINATED', + 'TERMINATING', + 'FAILED', + 'DEACTIVATING', +]) + +export function isTerminal(status?: string): boolean { + return status !== undefined && TERMINAL_STATUSES.has(status) +} + +function abortable(operation: Promise, signal?: AbortSignal): Promise { + if (!signal) return operation + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + try { + signal.throwIfAborted() + } catch (error) { + reject(error) + } + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + }) +} + +function isNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const record = error as { + code?: unknown + status?: unknown + response?: { status?: unknown } + } + return [record.code, record.status, record.response?.status].some( + (value) => value === 404 || value === '404', + ) +} + +function mayHaveCreatedSandbox(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const record = error as { + code?: unknown + status?: unknown + response?: { status?: unknown } + } + const rawStatus = record.response?.status ?? record.status ?? record.code + const status = Number(rawStatus) + if (!Number.isFinite(status)) return true + return ( + status === 408 || + status === 409 || + status === 425 || + status === 429 || + status >= 500 + ) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Blaxel sandbox names are DNS-label-ish; keep them short, lower-case, and safe. */ +function safeName(value: string): string { + const lower = value.toLowerCase() + const normalized = lower + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + + if ( + normalized && + normalized === value && + normalized.length <= MAX_NAME_LENGTH + ) { + return normalized + } + + // Preserve deterministic ids without letting normalization or truncation + // collapse two framework keys onto the same paid sandbox. + const suffix = createHash('sha256') + .update(value) + .digest('hex') + .slice(0, HASH_SUFFIX_LENGTH) + const prefixLength = MAX_NAME_LENGTH - HASH_SUFFIX_LENGTH - 1 + const prefix = + normalized.slice(0, prefixLength).replace(/-$/, '') || NAME_PREFIX + return `${prefix}-${suffix}` +} + +function randomName(): string { + return `${NAME_PREFIX}-${randomUUID().replace(/-/g, '')}` +} + +/** + * @blaxel/core keeps one credential set in process-global settings. Refuse to + * overwrite an already configured tenant: doing so could route an existing + * provider's later requests into a different workspace. + */ +function configureBlaxelSdk(apiKey: string, workspace: string): void { + // A fresh @blaxel/core 0.3.10 install exposes empty strings for its legacy + // `apikey` and `workspace` defaults. Those mean "not configured", not a + // conflicting tenant. `initialize()` writes the newer `apiKey` field. + const configuredKey = + settings.config.apiKey || settings.config.apikey || undefined + const configuredWorkspace = settings.config.workspace || undefined + const configuredClientCredentials = settings.config.clientCredentials + if ( + (configuredClientCredentials !== undefined && + configuredClientCredentials !== '') || + (configuredKey !== undefined && configuredKey !== apiKey) || + (configuredWorkspace !== undefined && configuredWorkspace !== workspace) + ) { + throw new Error( + '@blaxel/core credentials are process-global. Use one Blaxel API key and workspace per process.', + ) + } + + // Preserve transport settings an application may have initialized separately. + initialize({ ...settings.config, apiKey, workspace }) +} + +class BlaxelProvider implements SandboxProvider { + readonly name = 'blaxel' + + constructor(private readonly config: BlaxelSandboxConfig) { + const apiKey = config.apiKey ?? process.env.BL_API_KEY + if (!apiKey) { + throw new Error( + 'Blaxel API key is required. Pass `apiKey` or set the BL_API_KEY environment variable.', + ) + } + const workspace = config.workspace ?? process.env.BL_WORKSPACE + if (!workspace) { + throw new Error( + 'Blaxel workspace is required. Pass `workspace` or set the BL_WORKSPACE environment variable.', + ) + } + configureBlaxelSdk(apiKey, workspace) + } + + capabilities(): SandboxCapabilities { + return BLAXEL_CAPS + } + + private get workdir(): string { + return this.config.workdir ?? BLAXEL_DEFAULT_WORKDIR + } + + private get previewTtl(): string { + return this.config.previewTtl ?? DEFAULT_PREVIEW_TTL + } + + private handle(sandbox: BlaxelSandboxLike, name: string): BlaxelHandle { + return new BlaxelHandle({ + sandbox, + name, + workdir: this.workdir, + publicPreviews: this.config.publicPreviews ?? false, + previewTtl: this.previewTtl, + }) + } + + private createConfiguration( + name: string, + attemptId: string, + input: { env?: Record }, + ): SandboxCreateConfiguration { + const ttl = this.config.ttl === undefined ? DEFAULT_TTL : this.config.ttl + return { + name, + image: this.config.image ?? DEFAULT_IMAGE, + memory: this.config.memory ?? DEFAULT_MEMORY, + labels: { [CREATE_ATTEMPT_LABEL]: attemptId }, + ...(this.config.region ? { region: this.config.region } : {}), + ...(ttl ? { ttl } : {}), + ...(input.env + ? { + envs: Object.entries(input.env).map(([key, value]) => ({ + name: key, + value, + })), + } + : {}), + } + } + + async create(input: SandboxCreateInput): Promise { + input.signal?.throwIfAborted() + // Honor the deterministic id ensure() supplies (see SandboxCreateInput.id). + // Blaxel addresses sandboxes by name, so an out-of-band reconnect — such as + // attaching a preview to the sandbox an agent is editing — depends on it. + const name = input.id ? safeName(input.id) : randomName() + const attemptId = randomUUID() + const creation = SandboxInstance.createIfNotExists( + this.createConfiguration(name, attemptId, input), + ) + let sandbox: BlaxelSandboxLike | undefined + try { + sandbox = await abortable(creation, input.signal) + return await this.prepare(sandbox, name, input.env, input.signal) + } catch (error) { + if (sandbox) { + await this.cleanupOwnedSandbox(name, attemptId, error, sandbox) + } else if (input.signal?.aborted) { + // The SDK does not accept an AbortSignal. Reject the caller promptly, + // then reconcile the labeled attempt whether the SDK eventually + // resolves or rejects. A reused same-name sandbox has a different label + // and is never deleted. + void creation + .then( + (created) => + this.cleanupOwnedSandbox(name, attemptId, error, created), + () => this.cleanupOwnedSandbox(name, attemptId, error), + ) + .catch(() => undefined) + } else if (mayHaveCreatedSandbox(error)) { + // @blaxel/core can accept a create, poll after an edge 504, and still + // throw the original 504. Reconcile the attempt label before returning + // so ttl:null cannot leave an accepted paid sandbox behind. + await this.cleanupOwnedSandbox(name, attemptId, error) + } + throw error + } + } + + /** + * Make a freshly created sandbox usable: the workspace root must exist before + * any cwd-bound command runs in it. Blaxel's `mkdir` creates parents and is a + * native call rather than a shell command, so it needs no working directory of + * its own — which is what makes it safe to run before the workdir exists. + */ + private async prepare( + sandbox: BlaxelSandboxLike, + name: string, + env?: Record, + signal?: AbortSignal, + ): Promise { + await abortable(sandbox.fs.mkdir(this.workdir), signal) + signal?.throwIfAborted() + const handle = this.handle(sandbox, name) + // The sandbox already carries `env` as real environment variables; mirror + // them into the handle so per-command merging reports the same values. + if (env) await handle.env.set(env) + return handle + } + + private ownsAttempt(sandbox: BlaxelSandboxLike, attemptId: string): boolean { + return sandbox.metadata?.labels?.[CREATE_ATTEMPT_LABEL] === attemptId + } + + private async findOwnedSandbox( + name: string, + attemptId: string, + ): Promise { + for (let attempt = 0; attempt < CREATE_RECONCILE_ATTEMPTS; attempt += 1) { + try { + const sandbox = await SandboxInstance.get(name) + return this.ownsAttempt(sandbox, attemptId) ? sandbox : undefined + } catch (error) { + if (!isNotFound(error)) throw error + if (attempt < CREATE_RECONCILE_ATTEMPTS - 1) { + await sleep(CREATE_RECONCILE_INTERVAL_MS) + } + } + } + return undefined + } + + private async cleanupOwnedSandbox( + name: string, + attemptId: string, + originalError: unknown, + candidate?: BlaxelSandboxLike, + ): Promise { + try { + const owned = + candidate && this.ownsAttempt(candidate, attemptId) + ? candidate + : await this.findOwnedSandbox(name, attemptId) + if (!owned) return + try { + await SandboxInstance.delete(name) + } catch (error) { + if (!isNotFound(error)) throw error + } + } catch (cleanupError) { + throw new AggregateError( + [originalError, cleanupError], + `blaxel: sandbox ${name} failed and its owned create attempt could not be cleaned up.`, + ) + } + } + + async resume(input: SandboxResumeInput): Promise { + input.signal?.throwIfAborted() + try { + const sandbox = await abortable( + SandboxInstance.get(input.id), + input.signal, + ) + // A deleted sandbox is not immediately a 404: Blaxel keeps a record in a + // terminal state first (a single delete leaves it `DELETING`, and only a + // second one purges it). Reporting that as a live handle would hand the + // caller a sandbox that is being torn down, so treat every terminal state + // as gone and let the framework create a fresh one. An unknown or absent + // status stays resumable rather than failing closed on an unrecognized + // value. + if (isTerminal(sandbox.status)) return null + return this.handle(sandbox, input.id) + } catch (error) { + // Only absence is a cache miss. Authentication, authorization, and + // transport failures must stay visible rather than creating duplicates. + if (isNotFound(error)) return null + throw error + } + } + + async destroy(input: SandboxDestroyInput): Promise { + input.signal?.throwIfAborted() + try { + await abortable(SandboxInstance.delete(input.id), input.signal) + } catch (error) { + if (!isNotFound(error)) throw error + } + } +} + +/** + * Blaxel sandbox provider — runs harness adapters inside isolated Blaxel + * cloud sandboxes. Requires Blaxel credentials (`config.apiKey` / + * `config.workspace`, or the `BL_API_KEY` and `BL_WORKSPACE` environment + * variables). + */ +export function blaxelSandbox( + config: BlaxelSandboxConfig = {}, +): SandboxProvider { + return new BlaxelProvider(config) +} diff --git a/packages/ai-sandbox-blaxel/tests/blaxel.test.ts b/packages/ai-sandbox-blaxel/tests/blaxel.test.ts new file mode 100644 index 000000000..161cb75f1 --- /dev/null +++ b/packages/ai-sandbox-blaxel/tests/blaxel.test.ts @@ -0,0 +1,161 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { blaxelSandbox } from '../src/index' +import type { SandboxHandle } from '@tanstack/ai-sandbox' + +// Auto-gate: only run when Blaxel credentials are present (these tests create +// real cloud sandboxes and are billed). +const apiKey = process.env.BL_API_KEY +const workspace = process.env.BL_WORKSPACE +const gated = !apiKey || !workspace + +const created: Array = [] + +function track(handle: SandboxHandle): SandboxHandle { + created.push(handle) + return handle +} + +afterAll(async () => { + // Destroy every sandbox this file created, even if an expectation failed. + await Promise.allSettled(created.map((handle) => handle.destroy())) +}) + +describe.skipIf(gated)( + 'blaxel provider (gated on BL_API_KEY + BL_WORKSPACE)', + () => { + it('creates a sandbox, runs exec, round-trips the filesystem, and destroys it', async () => { + const provider = blaxelSandbox({ apiKey, workspace }) + const sbx = track(await provider.create({})) + + const echo = await sbx.process.exec('echo hello-blaxel') + expect(echo.stdout.trim()).toBe('hello-blaxel') + expect(echo.exitCode).toBe(0) + + // stderr and a non-zero exit are reported separately, not merged. + const failed = await sbx.process.exec('echo boom >&2; exit 3') + expect(failed.stderr.trim()).toBe('boom') + expect(failed.exitCode).toBe(3) + + await sbx.fs.write('/workspace/note.txt', 'inside the sandbox') + expect(await sbx.fs.exists('/workspace/note.txt')).toBe(true) + expect(await sbx.fs.read('/workspace/note.txt')).toBe( + 'inside the sandbox', + ) + + // Every byte value survives the binary path, not just printable text. + const bytes = new Uint8Array([0, 1, 2, 250, 255]) + await sbx.fs.write('/workspace/bin', bytes) + expect(Array.from(await sbx.fs.readBytes('/workspace/bin'))).toEqual([ + 0, 1, 2, 250, 255, + ]) + + // env + cwd are honored. + const env = await sbx.process.exec('echo "$GREETING from $(pwd)"', { + env: { GREETING: 'hi' }, + cwd: '/workspace', + }) + expect(env.stdout.trim()).toBe('hi from /workspace') + + // env.set persists across later commands. + await sbx.env.set({ PERSISTED: 'yes' }) + const persisted = await sbx.process.exec('echo "$PERSISTED"') + expect(persisted.stdout.trim()).toBe('yes') + + await sbx.fs.mkdir('/workspace/sub') + await sbx.fs.write('/workspace/sub/a.txt', 'a') + const listed = await sbx.fs.list('/workspace/sub') + expect(listed).toEqual([ + { name: 'a.txt', path: '/workspace/sub/a.txt', type: 'file' }, + ]) + + await sbx.fs.rename('/workspace/sub/a.txt', '/workspace/sub/b.txt') + expect(await sbx.fs.exists('/workspace/sub/a.txt')).toBe(false) + expect(await sbx.fs.read('/workspace/sub/b.txt')).toBe('a') + + await sbx.fs.remove('/workspace/sub') + expect(await sbx.fs.exists('/workspace/sub')).toBe(false) + }, 180_000) + + it('streams a spawned process and reports its exit code', async () => { + const provider = blaxelSandbox({ apiKey, workspace }) + const sbx = track(await provider.create({})) + + const spawned = await sbx.process.spawn('echo first; echo second; exit 0') + const exit = await spawned.wait() + expect(exit).toBe(0) + + // Assert the exact stream, not just that both words appear. A + // `toContain` pair passes on "firstsecond", which is what a stream that + // drops its line delimiters actually produces. + let out = '' + for await (const chunk of spawned.stdout) out += chunk + expect(out).toBe('first\nsecond\n') + }, 180_000) + + it('serves a port through a token-gated preview URL', async () => { + const provider = blaxelSandbox({ apiKey, workspace }) + const sbx = track(await provider.create({})) + + await sbx.fs.write( + '/workspace/server.mjs', + [ + "import { createServer } from 'node:http'", + "createServer((_req, res) => res.end('ready')).listen(3000)", + '', + ].join('\n'), + ) + const server = await sbx.process.spawn('node /workspace/server.mjs') + + try { + const channel = await sbx.ports.connect(3000) + expect(channel.url).toMatch(/^https:\/\//) + expect(channel.token).toBeTruthy() + expect(channel.headers?.['X-Blaxel-Preview-Token']).toBe(channel.token) + + // The preview needs a moment to route to the freshly bound port. + let body = '' + for (let attempt = 0; attempt < 20; attempt += 1) { + const response = await fetch(channel.url, { + headers: channel.headers ?? {}, + }).catch(() => undefined) + if (response?.ok) { + body = await response.text() + break + } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + expect(body).toBe('ready') + + // The same URL must reject an unauthenticated request. + const unauthenticated = await fetch(channel.url) + expect(unauthenticated.status).toBe(401) + } finally { + await server.kill() + } + }, 240_000) + + it('resumes a sandbox by id and reports a missing one as null', async () => { + const provider = blaxelSandbox({ apiKey, workspace }) + const sbx = track(await provider.create({})) + await sbx.fs.write('/workspace/origin.txt', 'resumed') + + const resumed = await provider.resume({ id: sbx.id }) + expect(resumed?.id).toBe(sbx.id) + expect(await resumed?.fs.read('/workspace/origin.txt')).toBe('resumed') + + expect( + await provider.resume({ id: 'tanstack-ai-does-not-exist' }), + ).toBeNull() + }, 180_000) + + it('reports a destroyed sandbox as gone rather than as a live handle', async () => { + // A destroyed Blaxel sandbox does not 404 straight away — the record + // survives in a terminal state — so resume() has to read the status. + // Without that check this returns a handle to a dead sandbox. + const provider = blaxelSandbox({ apiKey, workspace }) + const sbx = await provider.create({}) + await sbx.destroy() + expect(await provider.resume({ id: sbx.id })).toBeNull() + }, 180_000) + }, +) diff --git a/packages/ai-sandbox-blaxel/tests/handle.test.ts b/packages/ai-sandbox-blaxel/tests/handle.test.ts new file mode 100644 index 000000000..65b6a4490 --- /dev/null +++ b/packages/ai-sandbox-blaxel/tests/handle.test.ts @@ -0,0 +1,940 @@ +/* eslint-disable @typescript-eslint/require-await -- trivial fixed-value fakes */ +import { SandboxInstance } from '@blaxel/core' +import { toLines } from '@tanstack/ai-sandbox' +import { spawn as spawnChild } from 'node:child_process' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { BLAXEL_CAPS, BlaxelHandle, previewName } from '../src/handle' +import type { + BlaxelProcessLike, + BlaxelProcessRequestLike, + BlaxelSandboxLike, +} from '../src/handle' + +interface FakeOptions { + files?: Record + directory?: { + files?: Array<{ name: string }> + subdirectories?: Array<{ name: string }> + } + onExec?: (request: BlaxelProcessRequestLike) => BlaxelProcessLike + execGate?: Promise + onKill?: () => Promise + previewUrl?: string | undefined + previewPublic?: boolean + previewPort?: number + watchBase?: string + waitResult?: BlaxelProcessLike + waitGate?: Promise + streamError?: Error + streamLines?: Array<{ stream: 'stdout' | 'stderr'; line: string }> + onRm?: (path: string, recursive?: boolean) => Promise + onReap?: () => Promise +} + +function fakeSandbox(options: FakeOptions = {}): { + sandbox: BlaxelSandboxLike + execCalls: Array + previewCalls: Array + mkdir: ReturnType + rm: ReturnType + kill: ReturnType + wait: ReturnType + streamLogs: ReturnType + del: ReturnType + tokenCreate: ReturnType +} { + const execCalls: Array = [] + const previewCalls: Array = [] + const captures = new Map() + const processOutputDirs = new Map() + const mkdir = vi.fn(async () => ({})) + const rm = vi.fn(async (path: string, recursive?: boolean) => { + await options.onRm?.(path, recursive) + return {} + }) + const kill = vi.fn(async () => options.onKill?.() ?? {}) + const wait = vi.fn(async () => options.waitResult ?? { exitCode: 7 }) + const streamLogs = vi.fn( + ( + identifier: string, + streamOptions?: { + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void + onError?: (error: Error) => void + }, + ) => ({ + close: vi.fn(), + wait: async () => { + if (options.streamError) { + streamOptions?.onError?.(options.streamError) + return + } + const command = execCalls.find( + (request) => request.name === identifier, + )?.command + const recordPrefix = command?.match( + /__TANSTACK_AI_OUTPUT_CHUNK_[0-9a-f-]+__:/, + )?.[0] + if ((options.streamLines?.length ?? 0) > 0 && !recordPrefix) { + throw new Error('test fake could not find the output record prefix') + } + for (const entry of options.streamLines ?? []) { + const bytes = Buffer.from(entry.line) + for (let offset = 0; offset < bytes.length; offset += 65_536) { + const record = `${recordPrefix}${bytes.subarray(offset, offset + 65_536).toString('base64')}` + if (entry.stream === 'stdout') streamOptions?.onStdout?.(record) + else streamOptions?.onStderr?.(record) + } + } + if (options.waitGate) { + const result = await options.waitGate + const outputDir = processOutputDirs.get(identifier) + if (outputDir) captures.set(outputDir, result) + } + }, + }), + ) + const del = vi.fn(async () => ({})) + const tokenCreate = vi.fn(async () => ({ value: 'preview-token' })) + const files = options.files ?? {} + + const sandbox: BlaxelSandboxLike = { + fs: { + read: async (path) => { + const found = files[path] + if (found !== undefined) return found + const match = path.match( + /^(\/tmp\/tanstack-ai-output-[^/]+)\/(stdout|stderr|status|limits)$/, + ) + if (match) { + const result = captures.get(match[1]!) + if (match[2] === 'stdout') return result?.stdout ?? '' + if (match[2] === 'stderr') return result?.stderr ?? '' + if (match[2] === 'limits') { + const streams: Array = [] + if (Buffer.byteLength(result?.stdout ?? '') > 8 * 1024 * 1024) { + streams.push('stdout') + } + if (Buffer.byteLength(result?.stderr ?? '') > 8 * 1024 * 1024) { + streams.push('stderr') + } + return streams.join('\n') + } + return String(result?.exitCode ?? 0) + } + throw new Error(`no such file: ${path}`) + }, + readBinary: async (path) => + new Blob([new TextEncoder().encode(files[path] ?? '')]), + write: async () => ({}), + writeBinary: async () => ({}), + ls: async () => options.directory ?? {}, + mkdir, + rm, + watch: (_path, callback) => { + callback({ + op: 'WRITE', + path: options.watchBase ?? '/workspace', + name: 'note.txt', + }) + return { close: () => undefined } + }, + }, + process: { + exec: async (request) => { + execCalls.push(request) + if (request.name?.startsWith('tanstack-ai-reap-')) { + await options.onReap?.() + return { exitCode: 0, stdout: '', stderr: '' } + } + const result = await (options.execGate ?? + options.onExec?.(request) ?? { exitCode: 0, stdout: '', stderr: '' }) + const outputDir = request.command.match( + /mkdir -p -- '(\/tmp\/tanstack-ai-output-[^']+)'/, + )?.[1] + if (outputDir) { + captures.set( + outputDir, + request.waitForCompletion === false + ? (options.waitResult ?? result) + : result, + ) + if (request.name) processOutputDirs.set(request.name, outputDir) + } + return result + }, + wait, + kill, + streamLogs, + }, + previews: { + createIfNotExists: async (preview) => { + previewCalls.push(preview) + return { + spec: { + ...('previewUrl' in options + ? { url: options.previewUrl } + : { url: 'https://abc.preview.bl.run' }), + public: options.previewPublic ?? preview.spec.public, + port: options.previewPort ?? preview.spec.port, + }, + tokens: { create: tokenCreate }, + } + }, + }, + delete: del, + } + return { + sandbox, + execCalls, + previewCalls, + mkdir, + rm, + kill, + wait, + streamLogs, + del, + tokenCreate, + } +} + +function makeHandle( + options: FakeOptions = {}, + overrides: { + workdir?: string + publicPreviews?: boolean + } = {}, +): { + handle: BlaxelHandle + fake: ReturnType +} { + const fake = fakeSandbox(options) + const handle = new BlaxelHandle({ + sandbox: fake.sandbox, + name: 'sb', + workdir: overrides.workdir ?? '/workspace', + publicPreviews: overrides.publicPreviews ?? false, + previewTtl: '1h', + }) + return { handle, fake } +} + +describe('BlaxelHandle capabilities', () => { + it('keeps unproven and unsupported capabilities disabled', () => { + expect(BLAXEL_CAPS.snapshots).toBe(false) + expect(BLAXEL_CAPS.fork).toBe(false) + expect(BLAXEL_CAPS.durableFilesystem).toBe(true) + expect(BLAXEL_CAPS.writableStdin).toBe(false) + expect(BLAXEL_CAPS.killableProcesses).toBe(false) + }) + + it('exposes the sandbox name as the reconnectable id', () => { + const { handle } = makeHandle() + expect(handle.id).toBe('sb') + expect(handle.provider).toBe('blaxel') + expect(handle.workspaceRoot).toBe('/workspace') + }) +}) + +describe('BlaxelHandle filesystem', () => { + it('reads text and bytes through the native filesystem', async () => { + const { handle } = makeHandle({ files: { '/workspace/a.txt': 'hello' } }) + expect(await handle.fs.read('/workspace/a.txt')).toBe('hello') + expect(await handle.fs.readBytes('/workspace/a.txt')).toEqual( + new TextEncoder().encode('hello'), + ) + }) + + it('lists directories before files and re-roots entries under the virtual path', async () => { + const { handle } = makeHandle({ + directory: { + subdirectories: [{ name: 'src' }], + files: [{ name: 'a.txt' }, { name: 'b.txt' }], + }, + }) + expect(await handle.fs.list('/workspace/proj')).toEqual([ + { name: 'src', path: '/workspace/proj/src', type: 'dir' }, + { name: 'a.txt', path: '/workspace/proj/a.txt', type: 'file' }, + { name: 'b.txt', path: '/workspace/proj/b.txt', type: 'file' }, + ]) + }) + + it('removes recursively, because the contract has no non-recursive remove', async () => { + const { handle, fake } = makeHandle() + await handle.fs.remove('/workspace/dir') + expect(fake.rm).toHaveBeenCalledWith('/workspace/dir', true) + }) + + it('renames through mv and surfaces the shell error', async () => { + const { handle, fake } = makeHandle({ + onExec: () => ({ exitCode: 0, stdout: '', stderr: '' }), + }) + await handle.fs.rename('/workspace/a', '/workspace/b') + expect(fake.execCalls[0]?.command).toContain( + "mv -- '/workspace/a' '/workspace/b'", + ) + + const failing = makeHandle({ + onExec: () => ({ exitCode: 1, stdout: '', stderr: 'not permitted' }), + }) + await expect( + failing.handle.fs.rename('/workspace/a', '/workspace/b'), + ).rejects.toThrow(/not permitted/) + }) + + it('answers exists from the test exit code without needing a stat call', async () => { + const present = makeHandle({ onExec: () => ({ exitCode: 0 }) }) + expect(await present.handle.fs.exists('/workspace/a')).toBe(true) + expect(present.fake.execCalls[0]?.command).toContain( + "test -e '/workspace/a'", + ) + + const absent = makeHandle({ onExec: () => ({ exitCode: 1 }) }) + expect(await absent.handle.fs.exists('/workspace/a')).toBe(false) + }) + + it('quotes paths that contain a single quote', async () => { + const { handle, fake } = makeHandle({ onExec: () => ({ exitCode: 0 }) }) + await handle.fs.exists("/workspace/it's") + expect(fake.execCalls[0]?.command).toContain( + `test -e '/workspace/it'\\''s'`, + ) + }) + + it('maps the virtual workspace root onto a custom workdir', async () => { + const { handle, fake } = makeHandle( + { onExec: () => ({ exitCode: 0 }) }, + { workdir: '/home/agent' }, + ) + await handle.fs.exists('/workspace/a.txt') + expect(fake.execCalls[0]?.command).toContain("test -e '/home/agent/a.txt'") + }) + + it('maps and quotes explicit Git directories under a custom workdir', async () => { + const { handle, fake } = makeHandle( + { onExec: () => ({ exitCode: 0, stdout: '' }) }, + { workdir: "/home/agent's work" }, + ) + await handle.git.clone({ + url: 'https://example.com/repo.git', + dir: '/workspace/project', + }) + await handle.git.status('/workspace/project') + + const quotedDir = String.raw`'/home/agent'\''s work/project'` + expect(fake.execCalls[0]?.command).toContain(quotedDir) + expect(fake.execCalls[1]?.command).toContain( + `git -C ${quotedDir} status --porcelain`, + ) + }) + + it('reports watch events as a single path', async () => { + const { handle } = makeHandle() + const events: Array<{ type: string; path: string }> = [] + const subscription = await handle.fs.watch?.('/workspace', (event) => + events.push(event), + ) + expect(events).toEqual([{ type: 'WRITE', path: '/workspace/note.txt' }]) + await subscription?.stop() + }) + + it('re-roots native watch events when a custom workdir is used', async () => { + const { handle } = makeHandle( + { watchBase: '/home/agent/src' }, + { workdir: '/home/agent' }, + ) + const events: Array<{ type: string; path: string }> = [] + await handle.fs.watch?.('/workspace/src', (event) => events.push(event)) + expect(events).toEqual([{ type: 'WRITE', path: '/workspace/src/note.txt' }]) + }) +}) + +describe('pinned @blaxel/core process surface', () => { + it('uses core streamLogs without calling cumulative process.wait', async () => { + const sandbox = new SandboxInstance({ + metadata: { name: 'sdk-surface-regression' }, + } as ConstructorParameters[0]) + const exec = vi + .spyOn(sandbox.process, 'exec') + .mockResolvedValue({ pid: '17' } as never) + const processWait = vi.spyOn(sandbox.process, 'wait') + const streamLogs = vi + .spyOn(sandbox.process, 'streamLogs') + .mockReturnValue({ close: vi.fn(), wait: async () => undefined }) + vi.spyOn(sandbox.fs, 'read').mockImplementation(async (path) => { + if (path.endsWith('/status')) return '0' + if (path.endsWith('/stdout')) return 'sdk output' + return '' + }) + vi.spyOn(sandbox.fs, 'rm').mockResolvedValue({} as never) + const handle = new BlaxelHandle({ + sandbox: sandbox as unknown as BlaxelSandboxLike, + name: 'sdk-surface-regression', + workdir: '/workspace', + publicPreviews: false, + previewTtl: '1h', + }) + + const spawned = await handle.process.spawn('printf sdk-output') + await expect(spawned.wait()).resolves.toBe(0) + let output = '' + for await (const chunk of spawned.stdout) output += chunk + expect(output).toBe('sdk output') + expect(exec).toHaveBeenCalledOnce() + expect(streamLogs).toHaveBeenCalledOnce() + expect(processWait).not.toHaveBeenCalled() + }) +}) + +describe('BlaxelHandle process', () => { + it('passes stdout, stderr, and the exit code straight through', async () => { + const { handle } = makeHandle({ + onExec: () => ({ exitCode: 3, stdout: 'out', stderr: 'err' }), + }) + expect(await handle.process.exec('true')).toEqual({ + stdout: 'out', + stderr: 'err', + exitCode: 3, + }) + }) + + it('runs in the workspace root and waits for completion by default', async () => { + const { handle, fake } = makeHandle() + await handle.process.exec('pwd') + expect(fake.execCalls[0]?.workingDir).toBe('/workspace') + expect(fake.execCalls[0]?.waitForCompletion).toBe(true) + }) + + it('merges env.set values under per-command env', async () => { + const { handle, fake } = makeHandle() + await handle.env.set({ SHARED: 'a', OVERRIDDEN: 'from-handle' }) + await handle.process.exec('env', { env: { OVERRIDDEN: 'from-command' } }) + expect(fake.execCalls[0]?.env).toEqual({ + SHARED: 'a', + OVERRIDDEN: 'from-command', + }) + }) + + it('gives each process a distinct name, because Blaxel addresses them by name', async () => { + const { handle, fake } = makeHandle() + await handle.process.exec('a') + await handle.process.exec('b') + expect(fake.execCalls[0]?.name).toMatch(/^tanstack-ai-exec-[0-9a-f]{32}$/) + expect(fake.execCalls[1]?.name).toMatch(/^tanstack-ai-exec-[0-9a-f]{32}$/) + expect(fake.execCalls[0]?.name).not.toBe(fake.execCalls[1]?.name) + }) + + it('does not start a process for a pre-aborted signal', async () => { + const controller = new AbortController() + controller.abort() + const { handle, fake } = makeHandle() + await expect( + handle.process.exec('never', { signal: controller.signal }), + ).rejects.toThrow() + await expect( + handle.process.spawn('never', { signal: controller.signal }), + ).rejects.toThrow() + expect(fake.execCalls).toHaveLength(0) + }) + + it('awaits and retries bounded-capture cleanup', async () => { + let rmAttempts = 0 + const { handle, fake } = makeHandle({ + onExec: () => ({ exitCode: 0, stdout: 'ok', stderr: '' }), + onRm: async () => { + rmAttempts += 1 + if (rmAttempts < 3) throw new Error('transient rm failure') + }, + }) + await expect(handle.process.exec('printf ok')).resolves.toEqual({ + exitCode: 0, + stdout: 'ok', + stderr: '', + }) + expect(fake.rm).toHaveBeenCalledTimes(3) + }) + + it('surfaces a permanent bounded-capture cleanup failure', async () => { + const { handle } = makeHandle({ + onExec: () => ({ exitCode: 0, stdout: 'ok', stderr: '' }), + onRm: async () => { + throw new Error('permanent rm failure') + }, + }) + await expect(handle.process.exec('printf ok')).rejects.toThrow( + /remote cleanup also failed/, + ) + }) + + it('cleans up when abort races ahead of process registration', async () => { + const controller = new AbortController() + let registered = false + let resolveExec!: (value: BlaxelProcessLike) => void + const execGate = new Promise((resolve) => { + resolveExec = resolve + }) + const { handle, fake } = makeHandle({ + execGate, + onKill: async () => { + if (!registered) throw new Error('not registered yet') + return {} + }, + }) + const executing = handle.process.exec('sleep 30', { + signal: controller.signal, + }) + controller.abort() + await expect(executing).rejects.toThrow() + + registered = true + resolveExec({ exitCode: 0 }) + await vi.waitFor(() => expect(fake.kill).toHaveReturned()) + expect(fake.kill).toHaveBeenCalledWith(fake.execCalls[0]?.name) + }) + + it('reaps an accepted blocking exec whose POST response rejects', async () => { + const execGate = Promise.resolve().then(() => { + throw new Error('connection reset after accept') + }) + const { handle, fake } = makeHandle({ execGate }) + await expect(handle.process.exec('long-task')).rejects.toThrow( + /connection reset after accept/, + ) + const started = fake.execCalls[0]! + const reaper = fake.execCalls.find(({ name }) => + name?.startsWith('tanstack-ai-reap-'), + ) + expect(reaper?.command).toContain('kill -TERM -- "-$__tanstack_pid"') + expect(reaper?.command).toContain('kill -KILL -- "-$__tanstack_pid"') + expect(fake.kill).toHaveBeenCalledWith(started.name) + expect(fake.rm).toHaveBeenCalledWith( + expect.stringMatching(/^\/tmp\/tanstack-ai-output-/), + true, + ) + }) + + it('reaps an accepted spawn whose POST response rejects', async () => { + const execGate = Promise.resolve().then(() => { + throw new Error('spawn response lost after accept') + }) + const { handle, fake } = makeHandle({ execGate }) + await expect(handle.process.spawn('long-task')).rejects.toThrow( + /spawn response lost after accept/, + ) + const started = fake.execCalls[0]! + expect( + fake.execCalls.some(({ name }) => name?.startsWith('tanstack-ai-reap-')), + ).toBe(true) + expect(fake.kill).toHaveBeenCalledWith(started.name) + expect(fake.rm).toHaveBeenCalledWith( + expect.stringMatching(/^\/tmp\/tanstack-ai-output-/), + true, + ) + }) + + it.each(['exec', 'spawn'] as const)( + 'polls through delayed registration after an ambiguous %s POST response', + async (kind) => { + let visible = false + let visibilityScheduled = false + let killAttempts = 0 + let reapAttempts = 0 + let rmAttempts = 0 + const notFound = (): Error => + Object.assign(new Error('not registered yet'), { status: 404 }) + const execGate = Promise.reject( + Object.assign(new Error('gateway lost the accepted response'), { + status: 504, + }), + ) + const { handle } = makeHandle({ + execGate, + onKill: async () => { + killAttempts += 1 + if (!visible) { + if (!visibilityScheduled) { + visibilityScheduled = true + setTimeout(() => { + visible = true + }, 75) + } + throw notFound() + } + return {} + }, + onReap: async () => { + reapAttempts += 1 + if (!visible) throw notFound() + }, + onRm: async () => { + rmAttempts += 1 + if (!visible) throw notFound() + return {} + }, + }) + + const operation = + kind === 'exec' + ? handle.process.exec('long-task') + : handle.process.spawn('long-task') + await expect(operation).rejects.toThrow(/accepted response/) + expect(killAttempts).toBeGreaterThanOrEqual(2) + expect(reapAttempts).toBeGreaterThanOrEqual(3) + expect(rmAttempts).toBeGreaterThanOrEqual(2) + }, + ) + + it('returns bounded captured output and resolves wait with the exit code', async () => { + const close = vi.fn() + const { handle, fake } = makeHandle({ + onExec: () => ({ pid: '42', close }), + waitResult: { + exitCode: 7, + stdout: 'first\nsecond', + stderr: 'warned', + }, + }) + const spawned = await handle.process.spawn('build') + expect(fake.execCalls[0]?.waitForCompletion).toBe(false) + expect(fake.execCalls[0]?.keepAlive).toBe(true) + expect(fake.execCalls[0]?.timeout).toBe(0) + expect(fake.execCalls[0]?.command).toMatch( + /exec bash '\/tmp\/tanstack-ai-output-[^']+\/supervisor\.sh'/, + ) + expect(fake.execCalls[0]?.command).toContain('__tanstack_remaining=8388608') + expect(fake.execCalls[0]?.command).toContain('stdout.pipe') + expect(fake.execCalls[0]?.command).toContain('cat ') + expect(fake.execCalls[0]?.command).toContain('dd of=') + expect(fake.execCalls[0]?.command).toContain('base64 <') + expect(fake.execCalls[0]?.command).toMatch( + /__TANSTACK_AI_OUTPUT_CHUNK_[0-9a-f-]+__:/, + ) + expect(fake.execCalls[0]?.command).toContain('set -m') + expect(fake.execCalls[0]?.command).toContain( + "trap '__tanstack_reap 143' TERM", + ) + expect(fake.execCalls[0]).not.toHaveProperty('onStdout') + expect(fake.execCalls[0]).not.toHaveProperty('onStderr') + expect(spawned.pid).toBe(42) + + const exit = await spawned.wait() + expect(exit).toBe(7) + expect(fake.streamLogs).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ onError: expect.any(Function) }), + ) + expect(fake.wait).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledOnce() + + const collect = async (stream: AsyncIterable): Promise => { + let text = '' + for await (const chunk of stream) text += chunk + return text + } + expect(await collect(spawned.stdout)).toBe('first\nsecond') + expect(await collect(spawned.stderr)).toBe('warned') + }) + + it.runIf(process.platform !== 'win32')( + 'supervisor reaps the command and capture process groups on termination', + async () => { + let resolveWait!: (value: BlaxelProcessLike) => void + const waitGate = new Promise((resolve) => { + resolveWait = resolve + }) + const { handle, fake } = makeHandle({ + onExec: () => ({ pid: '1' }), + waitGate, + }) + const spawned = await handle.process.spawn('sleep 30') + const script = fake.execCalls[0]!.command + const outputDir = script.match( + /mkdir -p -- '(\/tmp\/tanstack-ai-output-[^']+)'/, + )?.[1] + expect(outputDir).toBeDefined() + const child = spawnChild('/bin/sh', ['-c', script], { + stdio: 'ignore', + }) + const pidsPath = `${outputDir!}/pids` + await vi.waitFor(() => expect(existsSync(pidsPath)).toBe(true)) + const pids = readFileSync(pidsPath, 'utf8') + .trim() + .split(/\s+/) + .map(Number) + const exited = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', () => resolve()) + }) + child.kill('SIGTERM') + await exited + await vi.waitFor(() => { + for (const pid of pids) { + expect(() => process.kill(-pid, 0)).toThrow() + } + }) + rmSync(outputDir!, { recursive: true, force: true }) + resolveWait({ exitCode: 0, stdout: '', stderr: '' }) + await spawned.wait() + }, + ) + + it('yields line one through toLines while the remote process remains blocked', async () => { + let resolveWait!: (value: BlaxelProcessLike) => void + const waitGate = new Promise((resolve) => { + resolveWait = resolve + }) + const { handle, fake } = makeHandle({ + onExec: () => ({ pid: '1' }), + streamLines: [{ stream: 'stdout', line: 'first\n' }], + waitGate, + }) + const spawned = await handle.process.spawn('build') + expect(fake.streamLogs).toHaveBeenCalledOnce() + expect(fake.wait).not.toHaveBeenCalled() + + const lines = toLines(spawned.stdout)[Symbol.asyncIterator]() + await expect( + Promise.race([ + lines.next(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('line one was not live')), 250), + ), + ]), + ).resolves.toEqual({ done: false, value: 'first' }) + + resolveWait({ exitCode: 0, stdout: 'first\nsecond\n', stderr: '' }) + await expect(lines.next()).resolves.toEqual({ + done: false, + value: 'second', + }) + await expect(lines.next()).resolves.toEqual({ + done: true, + value: undefined, + }) + expect(await spawned.wait()).toBe(0) + expect(fake.wait).not.toHaveBeenCalled() + }) + + it('ends streams on process exit before the caller invokes wait', async () => { + const { handle } = makeHandle({ + onExec: (request) => { + request.onStdout?.('done') + return { pid: '1' } + }, + waitResult: { exitCode: 0, stdout: 'done\n' }, + }) + const spawned = await handle.process.spawn('build') + let text = '' + for await (const chunk of spawned.stdout) text += chunk + expect(text).toBe('done\n') + expect(await spawned.wait()).toBe(0) + }) + + it('separates streamed lines with newlines rather than running them together', async () => { + // Regression: concatenating the raw callback payloads yielded + // "firstsecond", so a consumer streaming output lost every line break. + const { handle } = makeHandle({ + onExec: (request) => { + request.onStdout?.('first') + request.onStdout?.('second') + return { pid: '1' } + }, + waitResult: { exitCode: 0, stdout: 'first\nsecond\n' }, + }) + const spawned = await handle.process.spawn('build') + await spawned.wait() + let text = '' + for await (const chunk of spawned.stdout) text += chunk + expect(text).toBe('first\nsecond\n') + }) + + it('does not invent a trailing newline for output that has none', async () => { + // The separator goes between lines, so `printf x` stays exactly "x". Adding + // a delimiter after every line would corrupt unterminated output. + const { handle } = makeHandle({ + onExec: (request) => { + request.onStdout?.('x') + return { pid: '1' } + }, + waitResult: { exitCode: 0, stdout: 'x' }, + }) + const spawned = await handle.process.spawn('printf x') + await spawned.wait() + let text = '' + for await (const chunk of spawned.stdout) text += chunk + expect(text).toBe('x') + }) + + it('does not truncate output that is still in flight when wait() resolves', async () => { + // Only the first line is streamed via callbacks; the completed process + // reports the full text. Awaiting wait() must still yield everything. + const { handle } = makeHandle({ + onExec: (request) => { + request.onStdout?.('first') + return { pid: '1' } + }, + waitResult: { exitCode: 0, stdout: 'first\nsecond\n', stderr: 'warned' }, + }) + const spawned = await handle.process.spawn('build') + await spawned.wait() + + const collect = async (stream: AsyncIterable): Promise => { + let text = '' + for await (const chunk of stream) text += chunk + return text + } + expect(await collect(spawned.stdout)).toBe('first\nsecond\n') + expect(await collect(spawned.stderr)).toBe('warned') + }) + + it('surfaces log-stream transport errors', async () => { + const { handle } = makeHandle({ + onExec: () => ({ pid: '1' }), + streamError: new Error('stream failed'), + }) + const spawned = await handle.process.spawn('build') + await expect(spawned.wait()).rejects.toThrow(/stream failed/) + }) + + it('reads authoritative output from the bounded remote capture', async () => { + const { handle } = makeHandle({ + onExec: (request) => { + request.onStdout?.('streamed-only') + return { pid: '1' } + }, + waitResult: { exitCode: 0, stdout: 'totally different' }, + }) + const spawned = await handle.process.spawn('build') + await spawned.wait() + let text = '' + for await (const chunk of spawned.stdout) text += chunk + expect(text).toBe('totally different') + }) + + it('signals divergence after live output was already emitted', async () => { + let resolveWait!: (value: BlaxelProcessLike) => void + const waitGate = new Promise((resolve) => { + resolveWait = resolve + }) + const { handle } = makeHandle({ + onExec: () => ({ pid: '1' }), + streamLines: [{ stream: 'stdout', line: 'progressive' }], + waitGate, + }) + const spawned = await handle.process.spawn('build') + const iterator = spawned.stdout[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: 'progressive', + }) + resolveWait({ exitCode: 0, stdout: 'different', stderr: '' }) + await expect(spawned.wait()).rejects.toThrow(/diverged/) + await expect(iterator.next()).rejects.toThrow(/diverged/) + }) + + it('hard-bounds unread live output for a stalled consumer', async () => { + const tooLarge = 'x'.repeat(8 * 1024 * 1024 + 1) + const { handle } = makeHandle({ + onExec: () => ({ pid: '1' }), + streamLines: [{ stream: 'stdout', line: tooLarge }], + waitResult: { exitCode: 0, stdout: '', stderr: '' }, + }) + const spawned = await handle.process.spawn('noisy-live') + await expect(spawned.wait()).rejects.toThrow(/stream buffer limit/) + await expect( + (async () => { + for await (const _chunk of spawned.stdout) { + // Drain the bounded prefix; the terminal read must still fail. + } + })(), + ).rejects.toThrow(/stream buffer limit/) + }) + + it('hard-bounds remote output before the pinned SDK can accumulate it', async () => { + const tooLarge = 'x'.repeat(8 * 1024 * 1024 + 1) + const { handle } = makeHandle({ + onExec: () => ({ pid: '1' }), + waitResult: { exitCode: 0, stdout: tooLarge }, + }) + const spawned = await handle.process.spawn('noisy') + await expect(spawned.wait()).rejects.toThrow(/remote output limit/) + const iterator = spawned.stdout[Symbol.asyncIterator]() + await expect(iterator.next()).rejects.toThrow(/remote output limit/) + }) + + it('allows exactly 8 MiB per stream and rejects only output beyond it', async () => { + const exactLimit = 'x'.repeat(8 * 1024 * 1024) + const { handle, fake } = makeHandle({ + onExec: () => ({ exitCode: 0, stdout: exactLimit, stderr: '' }), + }) + await expect(handle.process.exec('exact-limit')).resolves.toEqual({ + exitCode: 0, + stdout: exactLimit, + stderr: '', + }) + expect(fake.execCalls[0]?.command).not.toContain('-ge 8388608') + }) + + it('rejects stdin writes, matching the advertised capability', async () => { + const { handle } = makeHandle({ onExec: () => ({ pid: '1' }) }) + const spawned = await handle.process.spawn('cat') + await expect(spawned.stdin.write('hi')).rejects.toThrow(/writableStdin/) + await spawned.stdin.end() + }) +}) + +describe('BlaxelHandle ports', () => { + it('token-gates a preview by default and reports its auth header', async () => { + const { handle, fake } = makeHandle() + expect(await handle.ports.connect(3000)).toEqual({ + url: 'https://abc.preview.bl.run', + token: 'preview-token', + headers: { 'X-Blaxel-Preview-Token': 'preview-token' }, + }) + expect(fake.previewCalls[0]).toEqual({ + metadata: { name: 'tanstack-ai-3000' }, + spec: { port: 3000, public: false, ttl: '1h' }, + }) + }) + + it('returns a bare URL and mints no token for a public preview', async () => { + const { handle, fake } = makeHandle({}, { publicPreviews: true }) + expect(await handle.ports.connect(8080)).toEqual({ + url: 'https://abc.preview.bl.run', + }) + expect(fake.tokenCreate).not.toHaveBeenCalled() + }) + + it('rejects a stale preview whose visibility does not match config', async () => { + const { handle, fake } = makeHandle({ previewPublic: true }) + await expect(handle.ports.connect(3000)).rejects.toThrow(/stale preview/) + expect(fake.tokenCreate).not.toHaveBeenCalled() + }) + + it('rejects a stale preview that targets a different port', async () => { + const { handle, fake } = makeHandle({ previewPort: 4000 }) + await expect(handle.ports.connect(3000)).rejects.toThrow( + /targets port=4000/, + ) + expect(fake.tokenCreate).not.toHaveBeenCalled() + }) + + it('fails loudly when the preview has no URL yet', async () => { + const { handle } = makeHandle({ previewUrl: undefined }) + await expect(handle.ports.connect(3000)).rejects.toThrow(/did not report/) + }) + + it('derives a stable preview name from the port', () => { + expect(previewName(3000)).toBe('tanstack-ai-3000') + }) +}) + +describe('BlaxelHandle lifecycle', () => { + it('destroy deletes the sandbox once', async () => { + const { handle, fake } = makeHandle() + await handle.destroy() + await handle.destroy() + expect(fake.del).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/ai-sandbox-blaxel/tests/journal.conformance.test.ts b/packages/ai-sandbox-blaxel/tests/journal.conformance.test.ts new file mode 100644 index 000000000..7242da7af --- /dev/null +++ b/packages/ai-sandbox-blaxel/tests/journal.conformance.test.ts @@ -0,0 +1,30 @@ +/** + * Register Blaxel's process-lifecycle claims with the shared conformance suite. + * The live cases are credential-gated because they create billed sandboxes. + */ +import { runJournalConformance } from '@tanstack/ai-sandbox/testkit' +import { blaxelSandbox } from '../src/index' + +const apiKey = process.env.BL_API_KEY +const workspace = process.env.BL_WORKSPACE +const credentialsAvailable = Boolean(apiKey && workspace) + +runJournalConformance({ + name: 'blaxel', + createHandle: async () => { + const provider = blaxelSandbox({ apiKey, workspace }) + const handle = await provider.create({}) + return { handle, dispose: () => handle.destroy() } + }, + followUnsupported: { + reason: + 'killableProcesses is false until a live Blaxel measurement proves kill() terminates the shell and its child process group', + }, + ...(credentialsAvailable + ? {} + : { + unsupported: { + reason: 'no BL_API_KEY and BL_WORKSPACE in the environment', + }, + }), +}) diff --git a/packages/ai-sandbox-blaxel/tests/provider.test.ts b/packages/ai-sandbox-blaxel/tests/provider.test.ts new file mode 100644 index 000000000..b278a968e --- /dev/null +++ b/packages/ai-sandbox-blaxel/tests/provider.test.ts @@ -0,0 +1,437 @@ +/* eslint-disable @typescript-eslint/require-await -- trivial fixed-value fakes */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const calls: { + created: Array> + got: Array + deleted: Array + forked: Array<{ source: string; target: string; snapshotId?: string }> +} = { created: [], got: [], deleted: [], forked: [] } + +let getError: unknown +let getFactory: ((name: string) => Record) | undefined +let createGate: Promise> | undefined +let mkdirError: unknown +let deleteGate: Promise> | undefined + +let getStatus: string | undefined = 'DEPLOYED' + +const sdkSettings: { + config: Record & { + apiKey?: string + apikey?: string + workspace?: string + } +} = { config: {} } +const initialize = vi.fn((config: typeof sdkSettings.config) => { + sdkSettings.config = config +}) + +function fakeInstance( + name: string, + labels?: Record, +): Record { + return { + metadata: { name, ...(labels ? { labels } : {}) }, + status: getStatus, + fs: { + mkdir: vi.fn(async () => { + if (mkdirError !== undefined) throw mkdirError + return {} + }), + read: async () => '', + readBinary: async () => new Blob([]), + write: async () => ({}), + writeBinary: async () => ({}), + ls: async () => ({}), + rm: async () => ({}), + watch: () => ({ close: () => undefined }), + }, + process: { + exec: async () => ({ exitCode: 0 }), + get: async () => ({ exitCode: 0 }), + wait: async () => ({ exitCode: 0 }), + kill: async () => ({}), + }, + previews: { createIfNotExists: async () => ({ spec: {}, tokens: {} }) }, + snapshot: async () => ({ id: 'snap-1' }), + fork: async (target: string, options?: { snapshotId?: string }) => { + calls.forked.push({ + source: name, + target, + ...(options?.snapshotId ? { snapshotId: options.snapshotId } : {}), + }) + return { name: target } + }, + delete: async () => ({}), + } +} + +vi.mock('@blaxel/core', () => ({ + initialize, + settings: sdkSettings, + SandboxInstance: { + createIfNotExists: async (config: Record) => { + calls.created.push(config) + return ( + createGate ?? + fakeInstance( + String(config.name), + config.labels as Record | undefined, + ) + ) + }, + get: async (name: string) => { + calls.got.push(name) + if (getError !== undefined) throw getError + return getFactory?.(name) ?? fakeInstance(name) + }, + delete: async (name: string) => { + calls.deleted.push(name) + return deleteGate ?? {} + }, + }, +})) + +const { blaxelSandbox } = await import('../src/index') +const { isTerminal } = await import('../src/provider') + +beforeEach(() => { + calls.created = [] + calls.got = [] + calls.deleted = [] + calls.forked = [] + getError = undefined + getFactory = undefined + createGate = undefined + mkdirError = undefined + deleteGate = undefined + getStatus = 'DEPLOYED' + sdkSettings.config = {} + initialize.mockClear() + vi.stubEnv('BL_API_KEY', 'test-key') + vi.stubEnv('BL_WORKSPACE', 'test-workspace') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('blaxelSandbox credentials', () => { + it('requires an API key', () => { + delete process.env.BL_API_KEY + expect(() => blaxelSandbox()).toThrow(/BL_API_KEY/) + }) + + it('requires a workspace', () => { + delete process.env.BL_WORKSPACE + expect(() => blaxelSandbox()).toThrow(/BL_WORKSPACE/) + }) + + it('accepts the empty config defaults from @blaxel/core 0.3.10', () => { + sdkSettings.config = { proxy: '', apikey: '', workspace: '' } + expect(() => + blaxelSandbox({ apiKey: 'explicit', workspace: 'explicit-ws' }), + ).not.toThrow() + expect(initialize).toHaveBeenCalledWith({ + proxy: '', + apikey: '', + workspace: 'explicit-ws', + apiKey: 'explicit', + }) + }) + + it('initializes the SDK explicitly without mutating environment variables', () => { + delete process.env.BL_API_KEY + delete process.env.BL_WORKSPACE + expect(() => + blaxelSandbox({ apiKey: 'explicit', workspace: 'explicit-ws' }), + ).not.toThrow() + expect(process.env.BL_API_KEY).toBeUndefined() + expect(process.env.BL_WORKSPACE).toBeUndefined() + expect(initialize).toHaveBeenCalledWith({ + apiKey: 'explicit', + workspace: 'explicit-ws', + }) + }) + + it('refuses to overwrite process-global SDK credentials', () => { + blaxelSandbox({ apiKey: 'one', workspace: 'workspace-one' }) + expect(() => + blaxelSandbox({ apiKey: 'two', workspace: 'workspace-two' }), + ).toThrow(/process-global/) + }) +}) + +describe('blaxelSandbox create', () => { + it('honors the deterministic id ensure() supplies', async () => { + const provider = blaxelSandbox() + const handle = await provider.create({ id: 'thread-abc' }) + expect(handle.id).toBe('thread-abc') + expect(calls.created[0]?.name).toBe('thread-abc') + }) + + it('normalizes an id without collapsing distinct framework keys', async () => { + const provider = blaxelSandbox() + const first = await provider.create({ id: 'Thread/ABC_123!' }) + const second = await provider.create({ id: 'Thread_ABC/123!' }) + expect(first.id).toMatch(/^thread-abc-123-[0-9a-f]{24}$/) + expect(second.id).toMatch(/^thread-abc-123-[0-9a-f]{24}$/) + expect(first.id).not.toBe(second.id) + }) + + it('keeps case-only framework ids distinct', async () => { + const provider = blaxelSandbox() + const upper = await provider.create({ id: 'ABC' }) + const lower = await provider.create({ id: 'abc' }) + expect(upper.id).toMatch(/^abc-[0-9a-f]{24}$/) + expect(lower.id).toBe('abc') + expect(upper.id).not.toBe(lower.id) + }) + + it("keeps long deterministic ids distinct after Blaxel's 49-character limit", async () => { + const provider = blaxelSandbox() + const prefix = 'a'.repeat(70) + const first = await provider.create({ id: `${prefix}-one` }) + const second = await provider.create({ id: `${prefix}-two` }) + expect(first.id).toHaveLength(49) + expect(second.id).toHaveLength(49) + expect(first.id).not.toBe(second.id) + }) + + it('falls back to a random name when no id is supplied', async () => { + const provider = blaxelSandbox() + const handle = await provider.create({}) + expect(handle.id).toMatch(/^tanstack-ai-[0-9a-f]{32}$/) + }) + + it('applies a default TTL so an abandoned sandbox cannot linger', async () => { + const provider = blaxelSandbox() + await provider.create({}) + expect(calls.created[0]?.ttl).toBe('1h') + expect(calls.created[0]?.image).toBe('blaxel/base-image:latest') + expect(calls.created[0]?.memory).toBe(2048) + expect(calls.created[0]).not.toHaveProperty('snapshotEnabled') + }) + + it('omits the TTL when explicitly opted out', async () => { + const provider = blaxelSandbox({ ttl: null }) + await provider.create({}) + expect(calls.created[0]).not.toHaveProperty('ttl') + }) + + it('passes env through as real sandbox environment variables', async () => { + const provider = blaxelSandbox() + await provider.create({ env: { TOKEN: 'abc' } }) + expect(calls.created[0]?.envs).toEqual([{ name: 'TOKEN', value: 'abc' }]) + }) + + it('creates the workspace root before any command can use it', async () => { + const provider = blaxelSandbox() + const handle = await provider.create({ id: 'sb' }) + expect(handle.workspaceRoot).toBe('/workspace') + }) + + it('does not create a sandbox for a pre-aborted request', async () => { + const controller = new AbortController() + controller.abort() + const provider = blaxelSandbox() + await expect( + provider.create({ signal: controller.signal }), + ).rejects.toThrow() + expect(calls.created).toHaveLength(0) + }) + + it('aborts an in-flight create and deletes the late billed sandbox', async () => { + let resolveCreate!: (sandbox: Record) => void + createGate = new Promise((resolve) => { + resolveCreate = resolve + }) + const controller = new AbortController() + const provider = blaxelSandbox() + const creating = provider.create({ + id: 'late-create', + signal: controller.signal, + }) + controller.abort() + await expect(creating).rejects.toThrow() + + resolveCreate( + fakeInstance( + 'late-create', + calls.created[0]?.labels as Record | undefined, + ), + ) + await vi.waitFor(() => expect(calls.deleted).toEqual(['late-create'])) + }) + + it('deletes a billed sandbox when workspace preparation fails', async () => { + mkdirError = new Error('mkdir failed') + const provider = blaxelSandbox() + await expect(provider.create({ id: 'prepare-failed' })).rejects.toThrow( + /mkdir failed/, + ) + expect(calls.deleted).toEqual(['prepare-failed']) + }) + + it('never deletes a same-name sandbox reused by concurrent callers', async () => { + mkdirError = new Error('mkdir failed') + createGate = Promise.resolve( + fakeInstance('shared', { 'tanstack-ai-create-attempt': 'other-attempt' }), + ) + const provider = blaxelSandbox() + const results = await Promise.allSettled([ + provider.create({ id: 'shared' }), + provider.create({ id: 'shared' }), + ]) + expect(results.map(({ status }) => status)).toEqual([ + 'rejected', + 'rejected', + ]) + expect(calls.deleted).toHaveLength(0) + }) + + it('reconciles and deletes an owned create accepted before a late 504', async () => { + createGate = Promise.reject({ status: 504, message: 'gateway timeout' }) + getFactory = (name) => + fakeInstance( + name, + calls.created[0]?.labels as Record | undefined, + ) + const provider = blaxelSandbox() + await expect( + provider.create({ id: 'accepted-late' }), + ).rejects.toMatchObject({ + status: 504, + }) + expect(calls.got).toEqual(['accepted-late']) + expect(calls.deleted).toEqual(['accepted-late']) + }) + + it('reports cleanup failure after reconciling an accepted create error', async () => { + createGate = Promise.reject({ status: 504, message: 'gateway timeout' }) + getFactory = (name) => + fakeInstance( + name, + calls.created[0]?.labels as Record | undefined, + ) + deleteGate = Promise.reject({ status: 500, message: 'delete failed' }) + const provider = blaxelSandbox() + await expect( + provider.create({ id: 'accepted-cleanup-failed' }), + ).rejects.toThrow(/could not be cleaned up/) + expect(calls.deleted).toEqual(['accepted-cleanup-failed']) + }) + + it('reports both preparation and owned-sandbox cleanup failures', async () => { + mkdirError = new Error('mkdir failed') + deleteGate = Promise.reject({ status: 500, message: 'delete failed' }) + const provider = blaxelSandbox() + await expect(provider.create({ id: 'cleanup-failed' })).rejects.toThrow( + /cleanup also failed|could not be cleaned up/, + ) + expect(calls.deleted).toEqual(['cleanup-failed']) + }) +}) + +describe('blaxelSandbox resume', () => { + it('reattaches to an existing sandbox', async () => { + const provider = blaxelSandbox() + const handle = await provider.resume({ id: 'sb-1' }) + expect(handle?.id).toBe('sb-1') + expect(calls.got).toEqual(['sb-1']) + }) + + it('resolves null when the sandbox is gone', async () => { + getError = { code: 404 } + const provider = blaxelSandbox() + expect(await provider.resume({ id: 'sb-gone' })).toBeNull() + }) + + it('does not hide authentication or transport errors as cache misses', async () => { + getError = { status: 401, message: 'unauthorized' } + const provider = blaxelSandbox() + await expect(provider.resume({ id: 'sb-1' })).rejects.toMatchObject({ + status: 401, + }) + }) + + it('resolves null for a deleted sandbox that still resolves as DELETING', async () => { + // A single delete leaves the record behind in a terminal state rather than + // 404ing, so a status check is the only thing standing between the caller + // and a handle to a sandbox being torn down. + getStatus = 'DELETING' + const provider = blaxelSandbox() + expect(await provider.resume({ id: 'sb-deleting' })).toBeNull() + }) + + it('treats every terminal state as gone', () => { + for (const status of [ + 'DELETING', + 'TERMINATED', + 'TERMINATING', + 'FAILED', + 'DEACTIVATING', + ]) { + expect(isTerminal(status)).toBe(true) + } + }) + + it('still resumes a sandbox that is on its way up', async () => { + for (const status of ['DEPLOYING', 'BUILDING', 'UPLOADING', 'BUILT']) { + getStatus = status + const provider = blaxelSandbox() + expect(await provider.resume({ id: 'sb-starting' })).not.toBeNull() + } + }) + + it('resumes a deactivated sandbox consistently with @blaxel/core reuse', async () => { + getStatus = 'DEACTIVATED' + const provider = blaxelSandbox() + expect(await provider.resume({ id: 'sb-sleeping' })).not.toBeNull() + expect(isTerminal('DEACTIVATED')).toBe(false) + }) + + it('stays permissive when the API reports no status', async () => { + getStatus = undefined + const provider = blaxelSandbox() + expect(await provider.resume({ id: 'sb-unknown' })).not.toBeNull() + }) +}) + +describe('blaxelSandbox destroy', () => { + it('deletes by id', async () => { + const provider = blaxelSandbox() + await provider.destroy({ id: 'sb-1' }) + expect(calls.deleted).toEqual(['sb-1']) + }) + + it('treats an already deleted sandbox as a successful destroy', async () => { + deleteGate = Promise.reject({ code: 404 }) + const provider = blaxelSandbox() + await expect(provider.destroy({ id: 'sb-gone' })).resolves.toBeUndefined() + }) + + it('does not hang past the destroy signal when the SDK call stalls', async () => { + deleteGate = new Promise(() => {}) + const controller = new AbortController() + const provider = blaxelSandbox() + const destroying = provider.destroy({ + id: 'sb-1', + signal: controller.signal, + }) + controller.abort() + await expect(destroying).rejects.toThrow() + expect(calls.deleted).toEqual(['sb-1']) + }) +}) + +describe('blaxelSandbox capabilities', () => { + it('keeps source-scoped private-preview snapshot and fork APIs disabled', () => { + const provider = blaxelSandbox() + expect(provider.name).toBe('blaxel') + expect(provider.capabilities().snapshots).toBe(false) + expect(provider.capabilities().fork).toBe(false) + expect(provider.restoreSnapshot).toBeUndefined() + }) +}) diff --git a/packages/ai-sandbox-blaxel/tsconfig.json b/packages/ai-sandbox-blaxel/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-sandbox-blaxel/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-blaxel/vite.config.ts b/packages/ai-sandbox-blaxel/vite.config.ts new file mode 100644 index 000000000..11f5b20b7 --- /dev/null +++ b/packages/ai-sandbox-blaxel/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..752d5f19e 100644 --- a/packages/ai-sandbox/README.md +++ b/packages/ai-sandbox/README.md @@ -53,6 +53,7 @@ Pick a **provider** package for where the sandbox runs: | `@tanstack/ai-sandbox-vercel` | Vercel Sandbox | | `@tanstack/ai-sandbox-daytona` | Daytona dev environments | | `@tanstack/ai-sandbox-sprites` | Sprites stateful sandboxes | +| `@tanstack/ai-sandbox-blaxel` | Blaxel cloud sandboxes and previews | **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 d5ff75a6a..ef306950d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2267,6 +2267,19 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.19))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + packages/ai-sandbox-blaxel: + dependencies: + '@blaxel/core': + specifier: ^0.3.10 + version: 0.3.10(@hey-api/openapi-ts@0.99.0(magicast@0.5.2)(typescript@7.0.2)) + devDependencies: + '@tanstack/ai-sandbox': + specifier: workspace:* + version: link:../ai-sandbox + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + packages/ai-sandbox-cloudflare: dependencies: '@cloudflare/sandbox': @@ -3806,6 +3819,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blaxel/core@0.3.10': + resolution: {integrity: sha512-ff0QwzdW+sy7rgoIKoNIWRodvNezZe7Ngnq+i1uj40nJ6Y4ykyEMj3vzY0GTb+4NpHwG7PmGhkje+Cn1rFg1xA==} + engines: {node: '>=18'} + '@bufbuild/protobuf@1.10.1': resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} @@ -5103,6 +5120,37 @@ packages: '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} + '@hey-api/client-fetch@0.10.2': + resolution: {integrity: sha512-AGiFYDx+y8VT1wlQ3EbzzZtfU8EfV+hLLRTtr8Y/tjYZaxIECwJagVZf24YzNbtEBXONFV50bwcU1wLVGXe1ow==} + deprecated: Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts. + peerDependencies: + '@hey-api/openapi-ts': < 2 + + '@hey-api/codegen-core@0.9.1': + resolution: {integrity: sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==} + engines: {node: '>=22.18.0'} + + '@hey-api/json-schema-ref-parser@1.4.4': + resolution: {integrity: sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==} + engines: {node: '>=22.18.0'} + + '@hey-api/openapi-ts@0.99.0': + resolution: {integrity: sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==} + engines: {node: '>=22.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' + + '@hey-api/shared@0.5.0': + resolution: {integrity: sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==} + engines: {node: '>=22.18.0'} + + '@hey-api/spec-types@0.2.0': + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + + '@hey-api/types@0.1.4': + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + '@honcho-ai/sdk@2.2.0': resolution: {integrity: sha512-SyygN+BrpUB2fRjhwcYmT+tcEhHrKmbj9nOZLVUFY7M5YBswJ+mZb/CeLpNbRh+QQTU8F8JWY3lQat95c6nwmA==} @@ -5450,6 +5498,10 @@ packages: cpu: [x64] os: [win32] + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -10396,6 +10448,10 @@ packages: bun-types@1.3.14: resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10631,6 +10687,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -10660,6 +10720,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -11016,6 +11080,14 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -11027,6 +11099,10 @@ packages: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -11111,6 +11187,9 @@ packages: resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} engines: {node: '>= 8.0'} + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + dockerode@4.0.12: resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==} engines: {node: '>= 8.0'} @@ -12489,6 +12568,10 @@ packages: resolution: {integrity: sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==} engines: {node: '>=8'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -12739,6 +12822,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} @@ -12816,6 +12903,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + kebab-case@1.0.2: resolution: {integrity: sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==} @@ -13924,6 +14015,10 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -14283,6 +14378,10 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + preact@10.28.1: resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==} @@ -14870,6 +14969,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -15559,6 +15662,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + toqr@0.1.1: resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} @@ -16125,6 +16231,10 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -16419,6 +16529,12 @@ packages: vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -16661,6 +16777,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xcode@3.0.1: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} @@ -18259,6 +18379,31 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blaxel/core@0.3.10(@hey-api/openapi-ts@0.99.0(magicast@0.5.2)(typescript@7.0.2))': + dependencies: + '@hey-api/client-fetch': 0.10.2(@hey-api/openapi-ts@0.99.0(magicast@0.5.2)(typescript@7.0.2)) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + archiver: 7.0.1 + axios: 1.18.1 + dockerfile-ast: 0.7.1 + dotenv: 16.6.1 + form-data: 4.0.6 + jwt-decode: 4.0.0 + toml: 3.0.0 + uuid: 11.1.1 + ws: 8.21.0 + yaml: 2.9.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@hey-api/openapi-ts' + - bare-abort-controller + - bufferutil + - debug + - react-native-b4a + - supports-color + - utf-8-validate + '@bufbuild/protobuf@1.10.1': {} '@changesets/apply-release-plan@7.1.0': @@ -19602,6 +19747,60 @@ snapshots: '@harperfast/extended-iterable@1.0.3': optional: true + '@hey-api/client-fetch@0.10.2(@hey-api/openapi-ts@0.99.0(magicast@0.5.2)(typescript@7.0.2))': + dependencies: + '@hey-api/openapi-ts': 0.99.0(magicast@0.5.2)(typescript@7.0.2) + + '@hey-api/codegen-core@0.9.1(magicast@0.5.2)': + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4(magicast@0.5.2) + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + + '@hey-api/json-schema-ref-parser@1.4.4': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@hey-api/openapi-ts@0.99.0(magicast@0.5.2)(typescript@7.0.2)': + dependencies: + '@hey-api/codegen-core': 0.9.1(magicast@0.5.2) + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/shared': 0.5.0(magicast@0.5.2) + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 15.0.0 + get-tsconfig: 4.14.0 + typescript: 7.0.2 + transitivePeerDependencies: + - magicast + + '@hey-api/shared@0.5.0(magicast@0.5.2)': + dependencies: + '@hey-api/codegen-core': 0.9.1(magicast@0.5.2) + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.8.4 + transitivePeerDependencies: + - magicast + + '@hey-api/spec-types@0.2.0': + dependencies: + '@hey-api/types': 0.1.4 + + '@hey-api/types@0.1.4': {} + '@honcho-ai/sdk@2.2.0': dependencies: zod: 4.0.0 @@ -19873,6 +20072,8 @@ snapshots: '@lmdb/lmdb-win32-x64@3.5.1': optional: true + '@lukeed/ms@2.0.2': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.2 @@ -25682,6 +25883,10 @@ snapshots: dependencies: '@types/node': 24.10.3 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -25726,7 +25931,6 @@ snapshots: rc9: 3.0.1 optionalDependencies: magicast: 0.5.2 - optional: true cac@6.7.14: {} @@ -25944,6 +26148,8 @@ snapshots: color-name@1.1.4: {} + color-support@1.1.3: {} + colorette@2.0.20: {} combined-stream@1.0.8: @@ -25962,6 +26168,8 @@ snapshots: commander@14.0.3: {} + commander@15.0.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -26015,8 +26223,7 @@ snapshots: confbox@0.2.2: {} - confbox@0.2.4: - optional: true + confbox@0.2.4: {} config-chain@1.1.13: dependencies: @@ -26298,6 +26505,13 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -26310,6 +26524,8 @@ snapshots: define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -26318,8 +26534,7 @@ snapshots: defu@6.1.4: {} - defu@6.1.7: - optional: true + defu@6.1.7: {} degenerator@5.0.1: dependencies: @@ -26379,6 +26594,11 @@ snapshots: transitivePeerDependencies: - supports-color + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + dockerode@4.0.12(supports-color@7.2.0): dependencies: '@balena/dockerignore': 1.0.2 @@ -27505,8 +27725,7 @@ snapshots: nypm: 0.6.2 pathe: 2.0.3 - giget@3.3.0: - optional: true + giget@3.3.0: {} github-from-package@0.0.0: optional: true @@ -28043,6 +28262,8 @@ snapshots: dependencies: html-tags: 3.3.1 + is-in-ssh@1.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -28275,6 +28496,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + jsc-safe-url@0.2.4: {} jscodeshift@17.3.0(supports-color@7.2.0): @@ -28384,6 +28609,8 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} + kebab-case@1.0.2: {} keyv@4.5.4: @@ -30187,6 +30414,15 @@ snapshots: dependencies: mimic-function: 5.0.1 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + open@7.4.2: dependencies: is-docker: 2.2.1 @@ -30540,8 +30776,7 @@ snapshots: perfect-debounce@2.0.0: {} - perfect-debounce@2.1.0: - optional: true + perfect-debounce@2.1.0: {} picocolors@1.1.1: {} @@ -30640,6 +30875,8 @@ snapshots: postgres@3.4.7: optional: true + powershell-utils@0.1.0: {} + preact@10.28.1: {} preact@10.28.2: {} @@ -30959,7 +31196,6 @@ snapshots: dependencies: defu: 6.1.7 destr: 2.0.5 - optional: true rc@1.2.8: dependencies: @@ -31585,6 +31821,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -32356,6 +32594,8 @@ snapshots: toidentifier@1.0.1: {} + toml@3.0.0: {} + toqr@0.1.1: {} totalist@3.0.1: {} @@ -32878,6 +33118,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.1.1: {} + uuid@7.0.3: {} valibot@1.2.0(typescript@5.9.3): @@ -33284,6 +33526,10 @@ snapshots: vlq@1.0.1: {} + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.18.0: {} + vscode-uri@3.1.0: {} vue-component-type-helpers@2.2.12: {} @@ -33504,6 +33750,11 @@ snapshots: ws@8.21.0: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.0 + powershell-utils: 0.1.0 + xcode@3.0.1: dependencies: simple-plist: 1.3.1 From e58d7bfb3e4e990f7f211a1463cf58d0b8a9a9d4 Mon Sep 17 00:00:00 2001 From: Michael Stolarz <146425971+SystemSculpt@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:50:56 -0700 Subject: [PATCH 2/3] fix(ai-sandbox): harden Blaxel abort cleanup --- docs/config.json | 2 +- packages/ai-sandbox-blaxel/src/handle.ts | 59 ++++--------------- packages/ai-sandbox-blaxel/src/provider.ts | 59 ++++--------------- packages/ai-sandbox-blaxel/src/utils.ts | 46 +++++++++++++++ .../ai-sandbox-blaxel/tests/handle.test.ts | 47 +++++++++++++++ .../ai-sandbox-blaxel/tests/provider.test.ts | 36 +++++++++++ 6 files changed, 152 insertions(+), 97 deletions(-) create mode 100644 packages/ai-sandbox-blaxel/src/utils.ts diff --git a/docs/config.json b/docs/config.json index cb31a125a..40ba6a783 100644 --- a/docs/config.json +++ b/docs/config.json @@ -500,7 +500,7 @@ "label": "Providers", "to": "sandbox/providers", "addedAt": "2026-06-29", - "updatedAt": "2026-08-06" + "updatedAt": "2026-08-07" }, { "label": "Harnesses", diff --git a/packages/ai-sandbox-blaxel/src/handle.ts b/packages/ai-sandbox-blaxel/src/handle.ts index f48c6c7b3..5f8c74020 100644 --- a/packages/ai-sandbox-blaxel/src/handle.ts +++ b/packages/ai-sandbox-blaxel/src/handle.ts @@ -11,6 +11,7 @@ */ import { createHash, randomUUID } from 'node:crypto' import { createExecBackedGit } from '@tanstack/ai-sandbox' +import { abortable, errorStatus, isNotFound } from './utils' import type { ExecResult, ProcessOptions, @@ -59,32 +60,6 @@ const PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1000 const STREAM_BUFFER_LIMIT_BYTES = 8 * 1024 * 1024 const PROCESS_REGISTRATION_RECONCILIATION_MS = 10_000 -function abortable(operation: Promise, signal?: AbortSignal): Promise { - if (!signal) return operation - signal.throwIfAborted() - return new Promise((resolve, reject) => { - const onAbort = (): void => { - signal.removeEventListener('abort', onAbort) - try { - signal.throwIfAborted() - } catch (error) { - reject(error) - } - } - signal.addEventListener('abort', onAbort, { once: true }) - operation.then( - (value) => { - signal.removeEventListener('abort', onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener('abort', onAbort) - reject(error) - }, - ) - }) -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -694,11 +669,14 @@ export class BlaxelHandle implements SandboxHandle { let termination: Promise | undefined const terminate = (): Promise => (termination ??= this.terminateProcess(name, bounded.outputDir)) + let executionResolved = false const onAbort = (): void => { - void terminate().catch(() => undefined) + const cleanup = executionResolved + ? terminate() + : this.reconcileAmbiguousProcessStart(name, bounded.outputDir) + void cleanup.catch(() => undefined) } signal?.addEventListener('abort', onAbort, { once: true }) - let executionResolved = false try { const result = await abortable(execution, signal) executionResolved = true @@ -771,7 +749,12 @@ export class BlaxelHandle implements SandboxHandle { timeout: 0, }) const abortStart = (): void => { - void terminate().catch(() => undefined) + // The SDK POST cannot be aborted and its promise may never settle. Start + // bounded registration reconciliation now; the settlement callback below + // remains a later fallback if the process appears after this window. + void this.reconcileAmbiguousProcessStart(name, bounded.outputDir).catch( + () => undefined, + ) } signal?.addEventListener('abort', abortStart, { once: true }) @@ -1146,24 +1129,6 @@ function eventPath(event: BlaxelWatchEventLike): string { : `${event.path}/${event.name}` } -function errorStatus(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined - const record = error as { - code?: unknown - status?: unknown - response?: { status?: unknown } - } - for (const value of [record.code, record.status, record.response?.status]) { - if (typeof value === 'number' && Number.isFinite(value)) return value - if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value) - } - return undefined -} - -function isNotFound(error: unknown): boolean { - return errorStatus(error) === 404 -} - /** A lost POST response can arrive before the accepted process is observable. */ function mayHaveStartedProcess(error: unknown): boolean { if ( diff --git a/packages/ai-sandbox-blaxel/src/provider.ts b/packages/ai-sandbox-blaxel/src/provider.ts index abbe0b8a1..39d959428 100644 --- a/packages/ai-sandbox-blaxel/src/provider.ts +++ b/packages/ai-sandbox-blaxel/src/provider.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' import { SandboxInstance, initialize, settings } from '@blaxel/core' import { BLAXEL_CAPS, BLAXEL_DEFAULT_WORKDIR, BlaxelHandle } from './handle' +import { abortable, errorStatus, isNotFound } from './utils' import type { SandboxCreateConfiguration } from '@blaxel/core' import type { BlaxelSandboxLike } from './handle' import type { @@ -76,54 +77,10 @@ export function isTerminal(status?: string): boolean { return status !== undefined && TERMINAL_STATUSES.has(status) } -function abortable(operation: Promise, signal?: AbortSignal): Promise { - if (!signal) return operation - signal.throwIfAborted() - return new Promise((resolve, reject) => { - const onAbort = (): void => { - signal.removeEventListener('abort', onAbort) - try { - signal.throwIfAborted() - } catch (error) { - reject(error) - } - } - signal.addEventListener('abort', onAbort, { once: true }) - operation.then( - (value) => { - signal.removeEventListener('abort', onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener('abort', onAbort) - reject(error) - }, - ) - }) -} - -function isNotFound(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const record = error as { - code?: unknown - status?: unknown - response?: { status?: unknown } - } - return [record.code, record.status, record.response?.status].some( - (value) => value === 404 || value === '404', - ) -} - function mayHaveCreatedSandbox(error: unknown): boolean { if (typeof error !== 'object' || error === null) return false - const record = error as { - code?: unknown - status?: unknown - response?: { status?: unknown } - } - const rawStatus = record.response?.status ?? record.status ?? record.code - const status = Number(rawStatus) - if (!Number.isFinite(status)) return true + const status = errorStatus(error) + if (status === undefined) return true return ( status === 408 || status === 409 || @@ -281,9 +238,13 @@ class BlaxelProvider implements SandboxProvider { await this.cleanupOwnedSandbox(name, attemptId, error, sandbox) } else if (input.signal?.aborted) { // The SDK does not accept an AbortSignal. Reject the caller promptly, - // then reconcile the labeled attempt whether the SDK eventually - // resolves or rejects. A reused same-name sandbox has a different label - // and is never deleted. + // and immediately reconcile the labeled attempt because the SDK promise + // may never settle. Repeat cleanup when it does settle as a later + // fallback. A reused same-name sandbox has a different label and is + // never deleted. + void this.cleanupOwnedSandbox(name, attemptId, error).catch( + () => undefined, + ) void creation .then( (created) => diff --git a/packages/ai-sandbox-blaxel/src/utils.ts b/packages/ai-sandbox-blaxel/src/utils.ts new file mode 100644 index 000000000..7f6118048 --- /dev/null +++ b/packages/ai-sandbox-blaxel/src/utils.ts @@ -0,0 +1,46 @@ +export function abortable( + operation: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) return operation + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + try { + signal.throwIfAborted() + } catch (error) { + reject(error) + } + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + }) +} + +export function errorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined + const record = error as { + code?: unknown + status?: unknown + response?: { status?: unknown } + } + for (const value of [record.response?.status, record.status, record.code]) { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value) + } + return undefined +} + +export function isNotFound(error: unknown): boolean { + return errorStatus(error) === 404 +} diff --git a/packages/ai-sandbox-blaxel/tests/handle.test.ts b/packages/ai-sandbox-blaxel/tests/handle.test.ts index 65b6a4490..aae05025e 100644 --- a/packages/ai-sandbox-blaxel/tests/handle.test.ts +++ b/packages/ai-sandbox-blaxel/tests/handle.test.ts @@ -493,6 +493,53 @@ describe('BlaxelHandle process', () => { expect(fake.kill).toHaveBeenCalledWith(fake.execCalls[0]?.name) }) + it.each(['exec', 'spawn'] as const)( + 'reconciles an aborted %s when its start promise never settles', + async (kind) => { + const controller = new AbortController() + const neverSettlingStart = new Promise(() => undefined) + let visible = false + let visibilityScheduled = false + let successfulKills = 0 + const notFound = (): Error => + Object.assign(new Error('not registered yet'), { status: 404 }) + const scheduleVisibility = (): void => { + if (visibilityScheduled) return + visibilityScheduled = true + setTimeout(() => { + visible = true + }, 75) + } + const { handle } = makeHandle({ + execGate: neverSettlingStart, + onKill: async () => { + if (!visible) { + scheduleVisibility() + throw notFound() + } + successfulKills += 1 + return {} + }, + onReap: async () => { + if (!visible) throw notFound() + }, + onRm: async () => { + if (!visible) throw notFound() + return {} + }, + }) + + const operation = + kind === 'exec' + ? handle.process.exec('long-task', { signal: controller.signal }) + : handle.process.spawn('long-task', { signal: controller.signal }) + controller.abort() + + await expect(operation).rejects.toThrow() + await vi.waitFor(() => expect(successfulKills).toBeGreaterThan(0)) + }, + ) + it('reaps an accepted blocking exec whose POST response rejects', async () => { const execGate = Promise.resolve().then(() => { throw new Error('connection reset after accept') diff --git a/packages/ai-sandbox-blaxel/tests/provider.test.ts b/packages/ai-sandbox-blaxel/tests/provider.test.ts index b278a968e..54be2b3e7 100644 --- a/packages/ai-sandbox-blaxel/tests/provider.test.ts +++ b/packages/ai-sandbox-blaxel/tests/provider.test.ts @@ -265,6 +265,42 @@ describe('blaxelSandbox create', () => { await vi.waitFor(() => expect(calls.deleted).toEqual(['late-create'])) }) + it('deletes an aborted create whose SDK promise never settles', async () => { + createGate = new Promise(() => undefined) + let visible = false + let visibilityScheduled = false + getFactory = (name) => { + if (!visible) { + if (!visibilityScheduled) { + visibilityScheduled = true + setTimeout(() => { + visible = true + }, 25) + } + throw Object.assign(new Error('sandbox is not visible yet'), { + status: 404, + }) + } + return fakeInstance( + name, + calls.created[0]?.labels as Record | undefined, + ) + } + const controller = new AbortController() + const provider = blaxelSandbox({ ttl: null }) + const creating = provider.create({ + id: 'never-settled-create', + signal: controller.signal, + }) + controller.abort() + + await expect(creating).rejects.toThrow() + await vi.waitFor( + () => expect(calls.deleted).toEqual(['never-settled-create']), + { timeout: 2_500 }, + ) + }) + it('deletes a billed sandbox when workspace preparation fails', async () => { mkdirError = new Error('mkdir failed') const provider = blaxelSandbox() From 6aab0b1b35f686ef4da34a2fa4eed5cdff5b6195 Mon Sep 17 00:00:00 2001 From: Michael Stolarz <146425971+SystemSculpt@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:57:35 -0700 Subject: [PATCH 3/3] fix(ai-sandbox): address Blaxel review feedback --- packages/ai-sandbox-blaxel/src/handle.ts | 51 ++++++++++++-- .../ai-sandbox-blaxel/tests/blaxel.test.ts | 2 +- .../ai-sandbox-blaxel/tests/handle.test.ts | 69 ++++++++++++++----- .../ai-sandbox-blaxel/tests/provider.test.ts | 51 +++++++------- packages/ai-sandbox/README.md | 6 ++ 5 files changed, 133 insertions(+), 46 deletions(-) diff --git a/packages/ai-sandbox-blaxel/src/handle.ts b/packages/ai-sandbox-blaxel/src/handle.ts index 5f8c74020..781837cec 100644 --- a/packages/ai-sandbox-blaxel/src/handle.ts +++ b/packages/ai-sandbox-blaxel/src/handle.ts @@ -53,8 +53,16 @@ export const BLAXEL_CAPS: SandboxCapabilities = { /** Default workspace root created inside the sandbox. */ export const BLAXEL_DEFAULT_WORKDIR = '/workspace' -/** How long a minted preview token stays valid. */ +/** Fallback when a preview TTL uses an unknown server-side duration format. */ const PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1000 +const PREVIEW_TTL_UNIT_MS: Record = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, +} /** Maximum unread stdout or stderr retained per spawned process. */ const STREAM_BUFFER_LIMIT_BYTES = 8 * 1024 * 1024 @@ -139,7 +147,7 @@ export interface BlaxelProcessLike { } export interface BlaxelPreviewLike { - spec?: { url?: string; public?: boolean; port?: number } + spec?: { url?: string; public?: boolean; port?: number; expires?: string } tokens: { create: (expiresAt: Date) => Promise<{ value: string }> } } @@ -161,6 +169,37 @@ export interface BlaxelHandleDeps { previewTtl: string } +function previewTtlMilliseconds(ttl: string): number { + let milliseconds = 0 + let offset = 0 + for (const match of ttl.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/g)) { + if (match.index !== offset) return PREVIEW_TOKEN_TTL_MS + const amount = match[1] + const unitMilliseconds = PREVIEW_TTL_UNIT_MS[match[2] ?? ''] + if (amount === undefined || unitMilliseconds === undefined) { + return PREVIEW_TOKEN_TTL_MS + } + milliseconds += Number(amount) * unitMilliseconds + offset += match[0].length + } + return offset === ttl.length && + milliseconds > 0 && + Number.isFinite(milliseconds) + ? milliseconds + : PREVIEW_TOKEN_TTL_MS +} + +function previewTokenExpiresAt( + preview: BlaxelPreviewLike, + previewTtl: string, +): Date { + if (preview.spec?.expires) { + const serverExpiry = new Date(preview.spec.expires) + if (Number.isFinite(serverExpiry.getTime())) return serverExpiry + } + return new Date(Date.now() + previewTtlMilliseconds(previewTtl)) +} + export class BlaxelHandle implements SandboxHandle { readonly id: string readonly provider = 'blaxel' @@ -382,7 +421,7 @@ export class BlaxelHandle implements SandboxHandle { ` cat ${chunkFile} >> ${file} || { __tanstack_capture_status=1; break; }`, ` printf '%s' ${q(recordPrefix)}`, ` base64 < ${chunkFile} | tr -d '\r\n'`, - ` printf '\n'`, + ` printf '\\n'`, ' __tanstack_remaining=$((__tanstack_remaining - __tanstack_chunk_size))', 'done', 'if [ "$__tanstack_capture_status" -eq 0 ] && [ "$__tanstack_remaining" -eq 0 ]; then', @@ -395,8 +434,8 @@ export class BlaxelHandle implements SandboxHandle { 'fi', `rm -f -- ${chunkFile}`, 'if [ "$__tanstack_capture_status" -ne 0 ]; then', - ` printf '%s\n' ${q(label)} >> ${limitsFile}`, - ` printf '%s\n' ${q(overflowMarker)}`, + ` printf '%s\\n' ${q(label)} >> ${limitsFile}`, + ` printf '%s\\n' ${q(overflowMarker)}`, 'fi', `) < ${pipe}${redirect} &`, ].join('\n') @@ -954,7 +993,7 @@ export class BlaxelHandle implements SandboxHandle { // Keep the credential out of the URL and report Blaxel's explicit preview // header. The separate token field remains available to channel consumers. const token = await preview.tokens.create( - new Date(Date.now() + PREVIEW_TOKEN_TTL_MS), + previewTokenExpiresAt(preview, this.previewTtl), ) if (!token.value) { throw new Error( diff --git a/packages/ai-sandbox-blaxel/tests/blaxel.test.ts b/packages/ai-sandbox-blaxel/tests/blaxel.test.ts index 161cb75f1..23ec98ad5 100644 --- a/packages/ai-sandbox-blaxel/tests/blaxel.test.ts +++ b/packages/ai-sandbox-blaxel/tests/blaxel.test.ts @@ -153,7 +153,7 @@ describe.skipIf(gated)( // survives in a terminal state — so resume() has to read the status. // Without that check this returns a handle to a dead sandbox. const provider = blaxelSandbox({ apiKey, workspace }) - const sbx = await provider.create({}) + const sbx = track(await provider.create({})) await sbx.destroy() expect(await provider.resume({ id: sbx.id })).toBeNull() }, 180_000) diff --git a/packages/ai-sandbox-blaxel/tests/handle.test.ts b/packages/ai-sandbox-blaxel/tests/handle.test.ts index aae05025e..cd80b0b9f 100644 --- a/packages/ai-sandbox-blaxel/tests/handle.test.ts +++ b/packages/ai-sandbox-blaxel/tests/handle.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/require-await -- trivial fixed-value fakes */ import { SandboxInstance } from '@blaxel/core' import { toLines } from '@tanstack/ai-sandbox' -import { spawn as spawnChild } from 'node:child_process' +import { spawn as spawnChild, spawnSync } from 'node:child_process' import { existsSync, readFileSync, rmSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { BLAXEL_CAPS, BlaxelHandle, previewName } from '../src/handle' @@ -23,6 +23,7 @@ interface FakeOptions { previewUrl?: string | undefined previewPublic?: boolean previewPort?: number + previewExpires?: string watchBase?: string waitResult?: BlaxelProcessLike waitGate?: Promise @@ -178,6 +179,9 @@ function fakeSandbox(options: FakeOptions = {}): { : { url: 'https://abc.preview.bl.run' }), public: options.previewPublic ?? preview.spec.public, port: options.previewPort ?? preview.spec.port, + ...(options.previewExpires + ? { expires: options.previewExpires } + : {}), }, tokens: { create: tokenCreate }, } @@ -204,6 +208,7 @@ function makeHandle( overrides: { workdir?: string publicPreviews?: boolean + previewTtl?: string } = {}, ): { handle: BlaxelHandle @@ -215,7 +220,7 @@ function makeHandle( name: 'sb', workdir: overrides.workdir ?? '/workspace', publicPreviews: overrides.publicPreviews ?? false, - previewTtl: '1h', + previewTtl: overrides.previewTtl ?? '1h', }) return { handle, fake } } @@ -683,7 +688,10 @@ describe('BlaxelHandle process', () => { expect(await collect(spawned.stderr)).toBe('warned') }) - it.runIf(process.platform !== 'win32')( + it.runIf( + process.platform !== 'win32' && + spawnSync('bash', ['-c', 'exit 0'], { stdio: 'ignore' }).status === 0, + )( 'supervisor reaps the command and capture process groups on termination', async () => { let resolveWait!: (value: BlaxelProcessLike) => void @@ -703,25 +711,35 @@ describe('BlaxelHandle process', () => { const child = spawnChild('/bin/sh', ['-c', script], { stdio: 'ignore', }) - const pidsPath = `${outputDir!}/pids` - await vi.waitFor(() => expect(existsSync(pidsPath)).toBe(true)) - const pids = readFileSync(pidsPath, 'utf8') - .trim() - .split(/\s+/) - .map(Number) const exited = new Promise((resolve, reject) => { child.once('error', reject) child.once('exit', () => resolve()) }) - child.kill('SIGTERM') - await exited - await vi.waitFor(() => { + let pids: Array = [] + try { + const pidsPath = `${outputDir!}/pids` + await vi.waitFor(() => expect(existsSync(pidsPath)).toBe(true)) + pids = readFileSync(pidsPath, 'utf8').trim().split(/\s+/).map(Number) + child.kill('SIGTERM') + await exited + await vi.waitFor(() => { + for (const pid of pids) { + expect(() => process.kill(-pid, 0)).toThrow() + } + }) + } finally { + child.kill('SIGKILL') for (const pid of pids) { - expect(() => process.kill(-pid, 0)).toThrow() + try { + process.kill(-pid, 'SIGKILL') + } catch { + // The process group already exited. + } } - }) - rmSync(outputDir!, { recursive: true, force: true }) - resolveWait({ exitCode: 0, stdout: '', stderr: '' }) + await exited.catch(() => undefined) + rmSync(outputDir!, { recursive: true, force: true }) + resolveWait({ exitCode: 0, stdout: '', stderr: '' }) + } await spawned.wait() }, ) @@ -945,6 +963,25 @@ describe('BlaxelHandle ports', () => { }) }) + it('keeps a private preview token valid for a custom preview TTL', async () => { + const before = Date.now() + const { handle, fake } = makeHandle({}, { previewTtl: '4h' }) + await handle.ports.connect(3000) + const after = Date.now() + const expiresAt = fake.tokenCreate.mock.calls[0]?.[0] as Date + expect(expiresAt.getTime()).toBeGreaterThanOrEqual( + before + 4 * 60 * 60 * 1000, + ) + expect(expiresAt.getTime()).toBeLessThanOrEqual(after + 4 * 60 * 60 * 1000) + }) + + it('uses the preview expiration reported by Blaxel for its token', async () => { + const previewExpires = new Date(Date.now() + 90 * 60 * 1000).toISOString() + const { handle, fake } = makeHandle({ previewExpires }) + await handle.ports.connect(3000) + expect(fake.tokenCreate).toHaveBeenCalledWith(new Date(previewExpires)) + }) + it('returns a bare URL and mints no token for a public preview', async () => { const { handle, fake } = makeHandle({}, { publicPreviews: true }) expect(await handle.ports.connect(8080)).toEqual({ diff --git a/packages/ai-sandbox-blaxel/tests/provider.test.ts b/packages/ai-sandbox-blaxel/tests/provider.test.ts index 54be2b3e7..b7b345c54 100644 --- a/packages/ai-sandbox-blaxel/tests/provider.test.ts +++ b/packages/ai-sandbox-blaxel/tests/provider.test.ts @@ -10,9 +10,9 @@ const calls: { let getError: unknown let getFactory: ((name: string) => Record) | undefined -let createGate: Promise> | undefined +let createGate: (() => Promise>) | undefined let mkdirError: unknown -let deleteGate: Promise> | undefined +let deleteGate: (() => Promise>) | undefined let getStatus: string | undefined = 'DEPLOYED' @@ -73,13 +73,12 @@ vi.mock('@blaxel/core', () => ({ SandboxInstance: { createIfNotExists: async (config: Record) => { calls.created.push(config) - return ( - createGate ?? - fakeInstance( - String(config.name), - config.labels as Record | undefined, - ) - ) + return createGate + ? await createGate() + : fakeInstance( + String(config.name), + config.labels as Record | undefined, + ) }, get: async (name: string) => { calls.got.push(name) @@ -88,7 +87,7 @@ vi.mock('@blaxel/core', () => ({ }, delete: async (name: string) => { calls.deleted.push(name) - return deleteGate ?? {} + return deleteGate ? await deleteGate() : {} }, }, })) @@ -244,9 +243,10 @@ describe('blaxelSandbox create', () => { it('aborts an in-flight create and deletes the late billed sandbox', async () => { let resolveCreate!: (sandbox: Record) => void - createGate = new Promise((resolve) => { - resolveCreate = resolve - }) + createGate = () => + new Promise((resolve) => { + resolveCreate = resolve + }) const controller = new AbortController() const provider = blaxelSandbox() const creating = provider.create({ @@ -266,7 +266,7 @@ describe('blaxelSandbox create', () => { }) it('deletes an aborted create whose SDK promise never settles', async () => { - createGate = new Promise(() => undefined) + createGate = () => new Promise(() => undefined) let visible = false let visibilityScheduled = false getFactory = (name) => { @@ -312,9 +312,12 @@ describe('blaxelSandbox create', () => { it('never deletes a same-name sandbox reused by concurrent callers', async () => { mkdirError = new Error('mkdir failed') - createGate = Promise.resolve( - fakeInstance('shared', { 'tanstack-ai-create-attempt': 'other-attempt' }), - ) + createGate = () => + Promise.resolve( + fakeInstance('shared', { + 'tanstack-ai-create-attempt': 'other-attempt', + }), + ) const provider = blaxelSandbox() const results = await Promise.allSettled([ provider.create({ id: 'shared' }), @@ -328,7 +331,8 @@ describe('blaxelSandbox create', () => { }) it('reconciles and deletes an owned create accepted before a late 504', async () => { - createGate = Promise.reject({ status: 504, message: 'gateway timeout' }) + createGate = () => + Promise.reject({ status: 504, message: 'gateway timeout' }) getFactory = (name) => fakeInstance( name, @@ -345,13 +349,14 @@ describe('blaxelSandbox create', () => { }) it('reports cleanup failure after reconciling an accepted create error', async () => { - createGate = Promise.reject({ status: 504, message: 'gateway timeout' }) + createGate = () => + Promise.reject({ status: 504, message: 'gateway timeout' }) getFactory = (name) => fakeInstance( name, calls.created[0]?.labels as Record | undefined, ) - deleteGate = Promise.reject({ status: 500, message: 'delete failed' }) + deleteGate = () => Promise.reject({ status: 500, message: 'delete failed' }) const provider = blaxelSandbox() await expect( provider.create({ id: 'accepted-cleanup-failed' }), @@ -361,7 +366,7 @@ describe('blaxelSandbox create', () => { it('reports both preparation and owned-sandbox cleanup failures', async () => { mkdirError = new Error('mkdir failed') - deleteGate = Promise.reject({ status: 500, message: 'delete failed' }) + deleteGate = () => Promise.reject({ status: 500, message: 'delete failed' }) const provider = blaxelSandbox() await expect(provider.create({ id: 'cleanup-failed' })).rejects.toThrow( /cleanup also failed|could not be cleaned up/, @@ -443,13 +448,13 @@ describe('blaxelSandbox destroy', () => { }) it('treats an already deleted sandbox as a successful destroy', async () => { - deleteGate = Promise.reject({ code: 404 }) + deleteGate = () => Promise.reject({ code: 404 }) const provider = blaxelSandbox() await expect(provider.destroy({ id: 'sb-gone' })).resolves.toBeUndefined() }) it('does not hang past the destroy signal when the SDK call stalls', async () => { - deleteGate = new Promise(() => {}) + deleteGate = () => new Promise(() => {}) const controller = new AbortController() const provider = blaxelSandbox() const destroying = provider.destroy({ diff --git a/packages/ai-sandbox/README.md b/packages/ai-sandbox/README.md index 752d5f19e..4a459dca8 100644 --- a/packages/ai-sandbox/README.md +++ b/packages/ai-sandbox/README.md @@ -55,6 +55,12 @@ Pick a **provider** package for where the sandbox runs: | `@tanstack/ai-sandbox-sprites` | Sprites stateful sandboxes | | `@tanstack/ai-sandbox-blaxel` | Blaxel cloud sandboxes and previews | +Install the provider you select separately. For Blaxel: + +```bash +npm install @tanstack/ai-sandbox-blaxel +``` + **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. ## Three moving parts