From 2a7694b3ebb5fe98e5b27a7266c5f2a5bb9b5f9c Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 13:14:43 -0800 Subject: [PATCH 1/9] feat(ai-sandbox-tangle): add Tangle sandbox provider --- .changeset/add-ai-sandbox-tangle.md | 16 + docs/config.json | 4 +- docs/sandbox/providers-advanced.md | 26 +- docs/sandbox/providers.md | 8 +- packages/ai-sandbox-tangle/README.md | 113 +++++ packages/ai-sandbox-tangle/package.json | 55 +++ packages/ai-sandbox-tangle/src/handle.ts | 452 ++++++++++++++++++ packages/ai-sandbox-tangle/src/index.ts | 4 + packages/ai-sandbox-tangle/src/provider.ts | 261 ++++++++++ .../tests/journal.conformance.test.ts | 25 + .../ai-sandbox-tangle/tests/tangle.test.ts | 246 ++++++++++ packages/ai-sandbox-tangle/tsconfig.json | 8 + packages/ai-sandbox-tangle/vite.config.ts | 37 ++ packages/ai-sandbox/README.md | 1 + 14 files changed, 1251 insertions(+), 5 deletions(-) create mode 100644 .changeset/add-ai-sandbox-tangle.md create mode 100644 packages/ai-sandbox-tangle/README.md create mode 100644 packages/ai-sandbox-tangle/package.json create mode 100644 packages/ai-sandbox-tangle/src/handle.ts create mode 100644 packages/ai-sandbox-tangle/src/index.ts create mode 100644 packages/ai-sandbox-tangle/src/provider.ts create mode 100644 packages/ai-sandbox-tangle/tests/journal.conformance.test.ts create mode 100644 packages/ai-sandbox-tangle/tests/tangle.test.ts create mode 100644 packages/ai-sandbox-tangle/tsconfig.json create mode 100644 packages/ai-sandbox-tangle/vite.config.ts diff --git a/.changeset/add-ai-sandbox-tangle.md b/.changeset/add-ai-sandbox-tangle.md new file mode 100644 index 0000000000..31b4a5b39f --- /dev/null +++ b/.changeset/add-ai-sandbox-tangle.md @@ -0,0 +1,16 @@ +--- +'@tanstack/ai-sandbox-tangle': minor +--- + +Add `@tanstack/ai-sandbox-tangle`, a sandbox provider backed by Tangle cloud +sandboxes through `@tangle-network/sandbox`. It implements the +`SandboxProvider` / `SandboxHandle` contract: file reads and writes through the +sandbox file API, commands with separate stdout and stderr and real exit codes, +background processes with streamed output, public preview URLs through +`ports.connect`, `env` injection, and resume by id across stop. + +`snapshot()` captures the workspace and `restoreSnapshot()` creates a new +sandbox from it. `fork()` branches the running sandbox into a new one. +`kill()` signals the process tree inside the sandbox, and an aborted `exec` +kills its process; both are measured against production. A +`network: 'deny'` policy creates the sandbox with egress blocked. diff --git a/docs/config.json b/docs/config.json index cab1bb46ce..bcf654b34f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1371,7 +1371,7 @@ "label": "Providers", "to": "sandbox/providers", "addedAt": "2026-06-29", - "updatedAt": "2026-11-02" + "updatedAt": "2026-11-09" }, { "label": "Harnesses", @@ -1487,7 +1487,7 @@ "label": "Providers (Advanced)", "to": "sandbox/providers-advanced", "addedAt": "2026-10-21", - "updatedAt": "2026-10-29" + "updatedAt": "2026-11-09" }, { "label": "The Run Journal", diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index 8626b8a9d9..4f095e34c3 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -361,6 +361,29 @@ Processes on Railway: - Blocking `exec` runs without a durable session. If the connection drops, the command dies. - `kill()` and an aborted `exec` send `TERM` to the process group, then `KILL` after 2 s. An aborted `spawn` sends `KILL` at once. All of them finish only on the confirmed remote exit. An aborted `exec` resolves with the exit code of the signal. +## Tangle + +```ts +import { tangleSandbox } from '@tanstack/ai-sandbox-tangle' + +const tangle = tangleSandbox({ + apiKey: process.env.TANGLE_API_KEY, + environment: 'node:22', + resources: { cpuCores: 2, memoryMB: 4096 }, +}) +``` + +- **Isolation:** a managed [Tangle](https://tangle.tools) sandbox that you do not run. Pick the image with `environment`: a catalog environment or a container image reference. The default is the platform's default environment. +- **Auth and env:** needs `TANGLE_API_KEY` (or `apiKey`). Set `baseUrl` for another endpoint. Sandboxes are created without platform model credentials or startup scripts. Harness credentials are [workspace secrets](./provisioning) and travel as env for each command. They are not stored with the sandbox. +- **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again. +- **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit, and stdout and stderr stay separate. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`. +- **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A missing, `deleted`, or `failed` sandbox resumes as `null`. +- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent. +- **Ports:** `ports.connect(port)` creates a preview link for the port, or reuses the existing one, and returns its HTTPS URL. The URL is public: anyone who has it can reach the port. +- **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. +- **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it. Commands run as the `agent` user. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`. +- **Bridge:** a remote sandbox, so bridged tools need the tunnel in local dev. See [Tools](./tools). + ## Cloudflare ```ts @@ -383,7 +406,7 @@ The agent coordinator, durable runs, live previews through a quick tunnel, callb ## `writableStdin` per provider - `true`: local process, Docker container, Upstash Box, E2B, boxd, and Railway. -- `false`: Docker Sandboxes (`sbx`), Daytona, Vercel, Sprites, Blaxel, and Cloudflare. Daytona can write to stdin, but it cannot close stdin, so a process that reads to EOF never starts. +- `false`: Docker Sandboxes (`sbx`), Daytona, Vercel, Sprites, Blaxel, Tangle, and Cloudflare. Daytona can write to stdin, but it cannot close stdin, so a process that reads to EOF never starts. When the value is `false`, a harness that reads its prompt from stdin gets the prompt from a file. The shell redirects that file into the process. @@ -412,6 +435,7 @@ If a provider cannot be measured yet, it stays `false`. A `false` value only mak | Cloudflare | `false` | `kill()` does nothing. The `AbortSignal` of the caller reaches neither `exec` nor `spawn`, because Workers RPC cannot serialize one. | | boxd | `true` | **Measured.** The spawn wrapper runs under `setsid`, so the recorded pid leads its own process group. `kill()` runs a shell inside the machine that signals that group, then sends `KILL`, then checks with `kill -0`. Closing the stream alone is not a kill: the process survived it. Verified against production: a spawned `sleep 5 && touch ` was killed, and the marker never appeared. Needs `BOXD_API_KEY`. | | Railway | `true` | **Measured.** `kill()` and an aborted `exec` send `TERM` to the process group, then `KILL` after 2 s. An aborted `spawn` sends `KILL` at once. Each one finishes only when the remote exit is confirmed. Verified against production: the shared journal conformance kill case passes. Spawned `sleep 5 && touch ` commands (one with a background child) were killed before the marker appeared, for `kill()`, an aborted `exec`, and an aborted `spawn`. Needs `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN` and `RAILWAY_ENVIRONMENT_ID`. | +| Tangle | `true` | **Measured.** `kill()` asks the runtime to signal the process tree. Verified against production: a spawned `(sleep 5 && touch ) & wait` was killed and the marker never appeared, an aborted `exec` of `sleep 5 && touch ` left no marker, and the shared journal conformance kill case passes. Needs `TANGLE_API_KEY`. | Each remote provider registers the shared journal conformance suite, so you can test each claim: diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index d52ad66b08..e957cbff6f 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -2,7 +2,7 @@ title: Providers id: providers order: 3 -description: "Pick where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, or Cloudflare), install it, and configure it." +description: "Pick where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, Tangle, or Cloudflare), install it, and configure it." --- A provider is where the sandbox runs: a local process for a fast loop, or an isolated VM in production. @@ -26,6 +26,7 @@ A provider is that place. Every provider has the same contract, so your [workspa | [E2B](https://e2b.dev) | `@tanstack/ai-sandbox-e2b` | microVM | `E2B_API_KEY` | | [boxd](https://boxd.sh) | `@tanstack/ai-sandbox-boxd` | microVM | `BOXD_API_KEY` and `BOXD_ORG`. | | [Railway](https://railway.com) | `@tanstack/ai-sandbox-railway` | microVM | `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN`, and an environment. | +| [Tangle](https://tangle.tools) | `@tanstack/ai-sandbox-tangle` | Cloud sandbox | `TANGLE_API_KEY` | | [Cloudflare](./cloudflare) | `@tanstack/ai-sandbox-cloudflare` | Container | A `Sandbox` Durable Object binding in a Worker. | 2. Install its package. This example installs the Docker provider. Use the package from your row. @@ -82,6 +83,7 @@ import { blaxelSandbox } from '@tanstack/ai-sandbox-blaxel' import { e2bSandbox } from '@tanstack/ai-sandbox-e2b' import { boxdSandbox } from '@tanstack/ai-sandbox-boxd' import { railwaySandbox } from '@tanstack/ai-sandbox-railway' +import { tangleSandbox } from '@tanstack/ai-sandbox-tangle' const dev = localProcessSandbox() // runs on your host const isolated = dockerSandbox({ image: 'node:22' }) // container @@ -94,6 +96,7 @@ const blaxel = blaxelSandbox() // uses the API key or the CLI login const e2b = e2bSandbox() // reads E2B_API_KEY const boxd = boxdSandbox({ org: 'acme' }) // reads BOXD_API_KEY const railway = railwaySandbox() // reads RAILWAY_TOKEN and RAILWAY_ENVIRONMENT_ID +const tangle = tangleSandbox() // reads TANGLE_API_KEY ``` `dockerSandbox()` and `sbxSandbox()` come from the same package. Every other provider has its own package. @@ -108,7 +111,7 @@ const railway = railwaySandbox() // reads RAILWAY_TOKEN and RAILWAY_ENVIRONMENT_ - Provider-native snapshots and resume keep or recreate the provider state. They make bootstrap faster. [Portable Snapshots](./portable-snapshots) store finished files in your own persistence. > [!NOTE] -> Cloud providers (Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, and Railway) run remotely. When you drive them from your laptop, [bridged tools](./tools) cannot reach your `localhost`. Use the ngrok bridge tunnel from the [tools guide](./tools). Cloudflare is different: the Worker that owns the sandbox also runs the bridge, so there is no laptop in the path. See [Cloudflare](./cloudflare). +> Cloud providers (Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, and Tangle) run remotely. When you drive them from your laptop, [bridged tools](./tools) cannot reach your `localhost`. Use the ngrok bridge tunnel from the [tools guide](./tools). Cloudflare is different: the Worker that owns the sandbox also runs the bridge, so there is no laptop in the path. See [Cloudflare](./cloudflare). ## Capabilities @@ -162,6 +165,7 @@ The [run journal](./journal) reads this flag to decide how to read a run's outpu | Cloudflare | `false` | | boxd | `true` | | Railway | `true` | +| Tangle | `true` | A bring-your-own provider must set this flag too. For the reason behind each value, see [Providers (Advanced)](./providers-advanced#killableprocesses-per-provider). diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md new file mode 100644 index 0000000000..6e9bf6a398 --- /dev/null +++ b/packages/ai-sandbox-tangle/README.md @@ -0,0 +1,113 @@ +
+ + + + TanStack AI + +
+ +
+ +# @tanstack/ai-sandbox-tangle + +Tangle sandbox provider for [TanStack AI](https://tanstack.com/ai). Runs harness +adapters inside isolated [Tangle](https://tangle.tools) cloud sandboxes through +the uniform `SandboxHandle`: real filesystem, shell, background processes, +preview URLs, snapshots, and fork. + +## Install + +```bash +npm install @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-tangle +``` + +## Usage + +```ts +import { + defineSandbox, + defineWorkspace, + withSandbox, +} from '@tanstack/ai-sandbox' +import { tangleSandbox } from '@tanstack/ai-sandbox-tangle' + +const sandbox = defineSandbox({ + id: 'agent', + provider: tangleSandbox({ + apiKey: process.env.TANGLE_API_KEY, // or set the env var and omit + }), + workspace: defineWorkspace({ source: { type: 'none' } }), +}) + +// Then pass `withSandbox(sandbox)` as chat() middleware. +``` + +The API key falls back to the `TANGLE_API_KEY` environment variable when +`apiKey` is omitted. + +### End-to-end example + +Using the provider directly through the uniform `SandboxHandle` (no harness / +`chat()` involved): + +```ts +import { tangleSandbox } from '@tanstack/ai-sandbox-tangle' + +const provider = tangleSandbox() +const sbx = await provider.create({}) +try { + await sbx.fs.write('/workspace/hello.txt', 'hello from tangle') + console.log(await sbx.fs.read('/workspace/hello.txt')) + + const run = await sbx.process.exec('node --version') + console.log('node', run.stdout.trim(), '(exit', run.exitCode, ')') + + const channel = await sbx.ports.connect(3000) + console.log('preview url:', channel.url) +} finally { + await sbx.destroy() +} +``` + +## Configuration + +| Option | Default | Notes | +| -------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| `apiKey` | `TANGLE_API_KEY` | Tangle API key. | +| `baseUrl` | `https://sandbox.tangle.tools` | Sandbox API URL. | +| `environment` | platform default | A catalog environment or a container image reference, such as `node:22`. | +| `resources` | platform default | `{ cpuCores, memoryMB, diskGB }`. | +| `maxLifetimeSeconds` | platform default | Run time before the platform suspends the sandbox. The workspace is kept, and `resume` starts it again. | +| `idleTimeoutSeconds` | platform default | Idle time before the platform suspends the sandbox. | +| `egressPolicy` | `{ mode: 'open' }` | Outbound network policy (`open`, `strict` with `allowDomains`, or `blocked`). A `network: 'deny'` policy wins. | +| `workdir` | `/home/agent/workspace` | Where the `/workspace` virtual root maps. | +| `metadata` | none | Extra metadata on every created sandbox. The deterministic sandbox id from `ensure()` is stored under `tanstack-ai-key`. | + +## Capabilities + +| Capability | Supported | Notes | +| --------------------- | --------- | ----------------------------------------------------------------------------------------------------------- | +| `fs` | ✅ | Native file API for `read`, `readBytes`, and `write`; the rest runs as commands. `lstat` reports symlinks. | +| `exec` | ✅ | Separate `stdout` and `stderr`, no run limit. An abort kills the process in the sandbox. | +| `env` | ✅ | Env for each command, plus the `env.set` overlay. Values are never stored with the sandbox. | +| `ports` | ✅ | `ports.connect(port)` creates a preview link and returns its public HTTPS URL. | +| `snapshots` | ✅ | `snapshot()` captures the workspace. `restoreSnapshot()` creates a new sandbox from it. | +| `durableFilesystem` | ✅ | The workspace persists across stop and resume. | +| `backgroundProcesses` | ✅ | `spawn()` returns a real sandbox pid and streams `stdout` and `stderr` separately. | +| `writableStdin` | ❌ | The process API takes no stdin stream; harnesses get their prompt from a file. | +| `killableProcesses` | ✅ | `kill()` signals the process tree inside the sandbox, measured against a live sandbox. | +| `networkPolicy` | ✅ | `policy.capabilities.network: 'deny'` maps to egress mode `blocked`. | +| `fork` | ✅ | `fork()` branches the running sandbox into a new one. | + +The native file calls reach the sandbox user's home (`/home/agent`) and +`/tmp`. Read other paths with `process.exec`. diff --git a/packages/ai-sandbox-tangle/package.json b/packages/ai-sandbox-tangle/package.json new file mode 100644 index 0000000000..160e715ced --- /dev/null +++ b/packages/ai-sandbox-tangle/package.json @@ -0,0 +1,55 @@ +{ + "name": "@tanstack/ai-sandbox-tangle", + "version": "0.0.0", + "description": "Tangle sandbox provider for TanStack AI — run harness adapters inside isolated Tangle cloud sandboxes through the uniform SandboxHandle.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-sandbox-tangle" + }, + "keywords": [ + "ai", + "tanstack", + "sandbox", + "tangle", + "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:coverage": "vitest run --coverage --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=json-summary", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "@tangle-network/sandbox": "^1.0.313" + }, + "peerDependencies": { + "@tanstack/ai-sandbox": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai-sandbox": "workspace:*", + "@vitest/coverage-v8": "4.1.10" + } +} diff --git a/packages/ai-sandbox-tangle/src/handle.ts b/packages/ai-sandbox-tangle/src/handle.ts new file mode 100644 index 0000000000..c9be53a592 --- /dev/null +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -0,0 +1,452 @@ +/** + * SandboxHandle backed by a Tangle sandbox (via `@tangle-network/sandbox`). + * Real isolation: fs/exec/git operate inside the remote sandbox; paths are real + * sandbox paths (default workdir `/home/agent/workspace`). + * + * Every command, blocking or not, goes through the runtime's process API + * (`process.spawn`): it has no run limit (the one-shot exec API stops at 10 + * minutes), stdout and stderr arrive on separate streams, and `kill()` signals + * the process tree inside the sandbox. File reads and writes use the native + * file API; the rest of `fs` desugars to commands. + */ +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createExecBackedGit } from '@tanstack/ai-sandbox' +import type { + ProcessSignal, + Process as TangleProcess, + SandboxInstance, +} from '@tangle-network/sandbox' +import type { + ExecResult, + ProcessOptions, + SandboxCapabilities, + SandboxChannel, + SandboxFsStat, + SandboxHandle, + SnapshotRef, + SpawnHandle, +} from '@tanstack/ai-sandbox' + +export const TANGLE_CAPS: SandboxCapabilities = { + fs: true, + exec: true, + env: true, + ports: true, + backgroundProcesses: true, + // The process API takes no stdin stream, so a harness gets its prompt from + // a file instead. + writableStdin: false, + // MEASURED against production Tangle. `kill()` asks the runtime to signal + // the process TREE: a spawned `sleep 5 && touch ` and a + // `( … ) & wait` background ticker both stopped, and a `tail -f` follower + // left no process behind. `tests/journal.conformance.test.ts` re-measures the + // follow cases whenever `TANGLE_API_KEY` is present. + killableProcesses: true, + // A snapshot restores into a NEW sandbox through `create({ fromSnapshot })`. + snapshots: true, + // `policy.capabilities.network: 'deny'` maps to egress mode `blocked`. + networkPolicy: true, + // The workspace persists across stop and resume. + durableFilesystem: true, + // `fork()` branches the running sandbox into a new one. + fork: true, +} + +/** Default working directory, inside the sandbox user's home. */ +export const DEFAULT_WORKDIR = '/home/agent/workspace' + +const PROCESS_SIGNALS: ReadonlyArray = [ + 'SIGTERM', + 'SIGKILL', + 'SIGINT', + 'SIGHUP', + 'SIGQUIT', + 'SIGUSR1', + 'SIGUSR2', +] +const SIGNAL_NUMBERS: Record = { + 1: 'SIGHUP', + 2: 'SIGINT', + 3: 'SIGQUIT', + 9: 'SIGKILL', + 15: 'SIGTERM', +} + +/** The runtime accepts a fixed set of signals; anything else is a `SIGKILL`. */ +function processSignal(signal?: NodeJS.Signals | number): ProcessSignal { + if (typeof signal === 'number') return SIGNAL_NUMBERS[signal] ?? 'SIGKILL' + return PROCESS_SIGNALS.find((s) => s === signal) ?? 'SIGKILL' +} + +/** POSIX single-quote escape for embedding text in `sh -c`. */ +function q(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function errText(r: { stdout: string; stderr: string }): string { + return r.stderr.trim() || r.stdout.trim() || '(no output)' +} + +async function collect(source: AsyncIterable): Promise { + let out = '' + for await (const chunk of source) out += chunk + return out +} + +/** A snapshot needs its source sandbox id to restore, so the ref carries both. */ +export function snapshotRefId(sandboxId: string, snapshotId: string): string { + return `${sandboxId}:${snapshotId}` +} + +export function parseSnapshotRefId(id: string): { + sandboxId: string + snapshotId: string +} { + const at = id.lastIndexOf(':') + if (at <= 0 || at === id.length - 1) { + throw new Error( + `tangle: snapshot id "${id}" is not ":"`, + ) + } + return { sandboxId: id.slice(0, at), snapshotId: id.slice(at + 1) } +} + +/** A snapshot tag: lowercase `[a-z0-9-]`. */ +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export interface TangleHandleDeps { + /** The live sandbox, as the SDK returned it. */ + sandbox: SandboxInstance + /** Working directory inside the sandbox (the `/workspace` virtual root maps here). */ + workdir: string + /** Env vars every command gets (a fork inherits its parent's). */ + env?: Record +} + +export class TangleHandle implements SandboxHandle { + readonly id: string + readonly provider = 'tangle' + readonly workspaceRoot: string + readonly capabilities = TANGLE_CAPS + readonly fs: SandboxHandle['fs'] + readonly git: SandboxHandle['git'] + readonly process: SandboxHandle['process'] + readonly ports: SandboxHandle['ports'] + readonly env: SandboxHandle['env'] + + private readonly sandbox: SandboxInstance + private readonly workdir: string + private readonly envVars: Record + + constructor(deps: TangleHandleDeps) { + this.sandbox = deps.sandbox + this.id = deps.sandbox.id + this.workdir = deps.workdir + this.workspaceRoot = deps.workdir + this.envVars = { ...deps.env } + + this.process = { + exec: (command, opts) => this.exec(command, opts), + spawn: (command, opts) => this.spawnProcess(command, opts), + } + + // The native file calls take no abort signal, so each call checks it + // first. They reach the sandbox user's home and `/tmp`. + this.fs = { + read: async (p, o) => { + o?.signal?.throwIfAborted() + return this.sandbox.read(this.abs(p)) + }, + readBytes: async (p, o) => { + o?.signal?.throwIfAborted() + return this.download(this.abs(p)) + }, + // The write creates missing parent directories (measured). + write: async (p, data, o) => { + o?.signal?.throwIfAborted() + if (typeof data === 'string') { + await this.sandbox.write(this.abs(p), data) + } else { + await this.sandbox.write( + this.abs(p), + Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString( + 'base64', + ), + { encoding: 'base64' }, + ) + } + }, + list: async (p, o) => { + o?.signal?.throwIfAborted() + const r = await this.exec(`ls -1Ap -- ${q(this.abs(p))}`) + if (r.exitCode !== 0) throw new Error(`list failed: ${errText(r)}`) + const base = p.replace(/\/$/, '') + return r.stdout + .split('\n') + .filter((line) => line !== '') + .map((entry) => { + const isDir = entry.endsWith('/') + const name = isDir ? entry.slice(0, -1) : entry + return { + name, + path: `${base}/${name}`, + type: isDir ? ('dir' as const) : ('file' as const), + } + }) + }, + lstat: async (p, o) => { + o?.signal?.throwIfAborted() + return this.lstat(this.abs(p)) + }, + mkdir: async (p, o) => { + o?.signal?.throwIfAborted() + const r = await this.exec(`mkdir -p -- ${q(this.abs(p))}`) + if (r.exitCode !== 0) throw new Error(`mkdir failed: ${errText(r)}`) + }, + remove: async (p, o) => { + o?.signal?.throwIfAborted() + const r = await this.exec(`rm -rf -- ${q(this.abs(p))}`) + if (r.exitCode !== 0) throw new Error(`remove failed: ${errText(r)}`) + }, + rename: async (from, to, o) => { + o?.signal?.throwIfAborted() + const r = await this.exec( + `mv -- ${q(this.abs(from))} ${q(this.abs(to))}`, + ) + if (r.exitCode !== 0) throw new Error(`rename failed: ${errText(r)}`) + }, + exists: async (p, o) => { + o?.signal?.throwIfAborted() + const r = await this.exec(`test -e ${q(this.abs(p))}`) + return r.exitCode === 0 + }, + } + + this.git = createExecBackedGit(this.process, this.workdir) + + this.ports = { + connect: (port, options) => this.connectPort(port, options?.signal), + } + + this.env = { + set: (vars) => { + Object.assign(this.envVars, vars) + return Promise.resolve() + }, + } + } + + /** Map the conventional `/workspace` virtual root to the sandbox workdir. */ + private abs(p: string): string { + if (this.workdir === '/workspace') return p + if (p === '/workspace') return this.workdir + if (p.startsWith('/workspace/')) + return `${this.workdir}/${p.slice('/workspace/'.length)}` + return p + } + + /** The file API returns bytes only to a local file, so read through one. */ + private async download(path: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'tanstack-ai-tangle-')) + try { + const local = join(dir, 'file') + await this.sandbox.fs.download(path, local) + return new Uint8Array(await readFile(local)) + } finally { + await rm(dir, { recursive: true, force: true }) + } + } + + private async lstat(path: string): Promise { + const r = await this.exec(lstatCommand(path)) + if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return undefined + if (r.exitCode !== 0) throw new Error(`lstat failed: ${errText(r)}`) + return parseLstatOutput(r.stdout) + } + + /** + * Start `command` in `cwd`. The runtime only accepts a `cwd` inside the + * user's home, so the command changes directory itself. `timeoutMs: 0` turns + * the runtime's run limit off; the sandbox lifetime still bounds it. + */ + private start( + command: string, + opts: ProcessOptions | undefined, + ): Promise { + opts?.signal?.throwIfAborted() + const cwd = opts?.cwd ? this.abs(opts.cwd) : this.workdir + const env = { ...this.envVars, ...opts?.env } + return this.sandbox.process.spawn(`cd ${q(cwd)} && ${command}`, { + ...(Object.keys(env).length > 0 ? { env } : {}), + timeoutMs: 0, + }) + } + + /** Signal the whole process tree. A process that already exited is not an error. */ + private kill( + proc: TangleProcess, + signal?: NodeJS.Signals | number, + ): Promise { + return proc + .kill(processSignal(signal), { tree: true }) + .catch(() => undefined) + } + + /** + * Run to completion. A non-zero exit is a result, not an error. An abort + * kills the process inside the sandbox; the result then carries the + * runtime's exit code for a signalled process (`-1`). + */ + private async exec( + command: string, + opts?: ProcessOptions, + ): Promise { + const proc = await this.start(command, opts) + const onAbort = (): void => { + void this.kill(proc) + } + opts?.signal?.addEventListener('abort', onAbort, { once: true }) + // An abort that landed during the start round trip has no listener yet. + if (opts?.signal?.aborted === true) onAbort() + try { + const [stdout, stderr] = await Promise.all([ + collect(proc.stdout()), + collect(proc.stderr()), + ]) + return { stdout, stderr, exitCode: await proc.wait() } + } catch (error) { + // A dropped stream says nothing about the process: stop it rather than + // leave it running until the sandbox expires. + await this.kill(proc) + throw error + } finally { + opts?.signal?.removeEventListener('abort', onAbort) + } + } + + private async spawnProcess( + command: string, + opts?: ProcessOptions, + ): Promise { + const proc = await this.start(command, opts) + const onAbort = (): void => { + void this.kill(proc) + } + opts?.signal?.addEventListener('abort', onAbort, { once: true }) + const exit = proc + .wait() + .catch(async (error: unknown) => { + await this.kill(proc) + throw error + }) + .finally(() => { + opts?.signal?.removeEventListener('abort', onAbort) + }) + exit.catch(() => undefined) + if (opts?.signal?.aborted === true) { + onAbort() + opts.signal.throwIfAborted() + } + return { + pid: proc.pid, + stdout: proc.stdout(), + stderr: proc.stderr(), + stdin: { + write: () => + Promise.reject( + new Error( + 'tangle: background process stdin is not writable (see capabilities.writableStdin)', + ), + ), + end: () => Promise.resolve(), + }, + wait: () => exit, + kill: (signal) => this.kill(proc, signal), + } + } + + /** + * Create (or reuse) the sandbox's preview link for `port`. The URL is + * public: anyone who has it can reach the port. + */ + private async connectPort( + port: number, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted() + const link = await this.sandbox.previewLinks.create(port) + return { url: link.url } + } + + /** Capture the workspace. The returned id restores into a new sandbox. */ + snapshot = async ( + label?: string, + options?: { signal?: AbortSignal }, + ): Promise => { + options?.signal?.throwIfAborted() + const tag = label !== undefined ? slug(label) : '' + const snap = await this.sandbox.snapshot( + tag !== '' ? { tags: [tag] } : {}, + ) + return { + id: snapshotRefId(this.id, snap.snapshotId), + ...(label !== undefined ? { label } : {}), + } + } + + /** Branch the running sandbox into a new one with the same files. */ + fork = async (options?: { signal?: AbortSignal }): Promise => { + options?.signal?.throwIfAborted() + const forked = await this.sandbox.fork(1, { + idempotencyKey: `tanstack-ai-fork-${randomUUID()}`, + }) + const child = forked.children[0] + if (child === undefined) throw new Error('tangle: fork returned no sandbox') + return new TangleHandle({ + sandbox: child, + workdir: this.workdir, + env: this.envVars, + }) + } + + async destroy(): Promise { + await this.sandbox.delete() + } +} + +const LSTAT_MISSING = '__TANSTACK_LSTAT_MISSING__' + +/** Verify a missing path by listing parent entries. `test -e` also fails for inaccessible parents. */ +function lstatCommand(path: string): string { + return `tanstack_lstat_path=${q(path)}; tanstack_lstat_output=$(stat -c '%f:%s' -- "$tanstack_lstat_path" 2>&1); tanstack_lstat_status=$?; if [ "$tanstack_lstat_status" -eq 0 ]; then printf '%s\n' "$tanstack_lstat_output"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case "$tanstack_missing_path" in /|.) return 1 ;; */*) tanstack_parent=${'$'}{tanstack_missing_path%/*}; tanstack_name=${'$'}{tanstack_missing_path##*/}; [ -n "$tanstack_parent" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- "$tanstack_parent" 2>/dev/null); tanstack_parent_status=$?; if [ "$tanstack_parent_status" -ne 0 ]; then tanstack_lstat_missing "$tanstack_parent"; else case "$tanstack_parent_mode" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case "$tanstack_parent" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H "$tanstack_find_parent" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ "${'$'}{tanstack_candidate##*/}" = "$tanstack_target" ] && { printf 1; exit 0; }; done; exit 0' sh "$tanstack_name" '{}' + 2>/dev/null); tanstack_find_status=$?; [ "$tanstack_find_status" -eq 0 ] && [ -z "$tanstack_match" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing "$tanstack_lstat_path"; then printf '%s' '${LSTAT_MISSING}'; else printf '%s\n' "$tanstack_lstat_output" >&2; exit "$tanstack_lstat_status"; fi; fi` +} + +function parseLstatOutput(output: string): SandboxFsStat { + const fields = /^(?[0-9a-fA-F]{4}):(?\d+)\n?$/.exec(output) + const mode = fields?.groups?.mode + const size = fields?.groups?.size + if (!mode || !size) throw new Error(`invalid lstat output: ${output}`) + const parsedMode = Number.parseInt(mode, 16) + const parsedSize = Number(size) + if ( + !Number.isSafeInteger(parsedMode) || + !Number.isSafeInteger(parsedSize) || + parsedSize < 0 + ) + throw new Error(`invalid lstat output: ${output}`) + const type = parsedMode & 0xf000 + if (type === 0x8000) + return { type: 'file', mode: parsedMode, size: parsedSize } + if (type === 0x4000) return { type: 'dir', mode: parsedMode } + if (type === 0xa000) return { type: 'symlink', mode: parsedMode } + return { type: 'other', mode: parsedMode } +} diff --git a/packages/ai-sandbox-tangle/src/index.ts b/packages/ai-sandbox-tangle/src/index.ts new file mode 100644 index 0000000000..904c9187b8 --- /dev/null +++ b/packages/ai-sandbox-tangle/src/index.ts @@ -0,0 +1,4 @@ +export { tangleSandbox, INSTANCE_KEY_METADATA } from './provider' +export type { TangleSandboxConfig } from './provider' +export { TANGLE_CAPS, TangleHandle, DEFAULT_WORKDIR } from './handle' +export type { TangleHandleDeps } from './handle' diff --git a/packages/ai-sandbox-tangle/src/provider.ts b/packages/ai-sandbox-tangle/src/provider.ts new file mode 100644 index 0000000000..235383dfe7 --- /dev/null +++ b/packages/ai-sandbox-tangle/src/provider.ts @@ -0,0 +1,261 @@ +import { Sandbox } from '@tangle-network/sandbox' +import { + DEFAULT_WORKDIR, + TANGLE_CAPS, + TangleHandle, + parseSnapshotRefId, +} from './handle' +import type { + CreateSandboxOptions, + EgressPolicy, + SandboxInstance, + SandboxResources, +} from '@tangle-network/sandbox' +import type { + SandboxCapabilities, + SandboxCreateInput, + SandboxDestroyInput, + SandboxHandle, + SandboxPolicy, + SandboxProvider, + SandboxRestoreInput, + SandboxResumeInput, +} from '@tanstack/ai-sandbox' + +export interface TangleSandboxConfig { + /** Tangle API key. Falls back to the `TANGLE_API_KEY` env var when omitted. */ + apiKey?: string + /** Sandbox API URL. Defaults to `https://sandbox.tangle.tools`. */ + baseUrl?: string + /** + * A catalog environment or a container image reference (for example + * `node:22`). Defaults to the platform's default environment. + */ + environment?: string + /** CPU cores, memory and disk. Defaults to the platform's default size. */ + resources?: Pick + /** + * Seconds of run time before the platform suspends the sandbox. The + * workspace is kept, and `resume` starts it again. + */ + maxLifetimeSeconds?: number + /** Seconds without activity before the platform suspends the sandbox. */ + idleTimeoutSeconds?: number + /** + * Outbound network policy. Defaults to `{ mode: 'open' }`. + * `policy.capabilities.network: 'deny'` always wins with `{ mode: 'blocked' }`. + */ + egressPolicy?: EgressPolicy + /** + * Working directory inside the sandbox. The `/workspace` virtual root maps + * here. Defaults to `/home/agent/workspace`. + */ + workdir?: string + /** Extra metadata attached to every created sandbox. */ + metadata?: Record +} + +/** Metadata key carrying the deterministic id `ensure()` passes to `create`. */ +export const INSTANCE_KEY_METADATA = 'tanstack-ai-key' + +const DEFAULT_BASE_URL = 'https://sandbox.tangle.tools' + +/** POSIX single-quote escape for embedding a path in a shell command. */ +function shQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +/** A deleted sandbox can still be read for a while; it is gone for `resume`. */ +const GONE_STATUSES = new Set(['deleted', 'failed']) + +/** + * `client.create` that rejects promptly on abort. The SDK takes no signal, so + * an accepted create would leave a billed sandbox nobody holds the id for. On + * abort the caller is released at once, and the sandbox is deleted whenever + * the create settles. + */ +async function createSandbox( + client: Sandbox, + options: CreateSandboxOptions, + signal: AbortSignal | undefined, +): Promise { + signal?.throwIfAborted() + const creation = client.create(options) + if (!signal) return creation + const aborted = new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }) + }) + try { + return await Promise.race([creation, aborted]) + } catch (error) { + if (signal.aborted) { + void creation.then((sandbox) => sandbox.delete()).catch(() => undefined) + } + throw error + } +} + +class TangleProvider implements SandboxProvider { + readonly name = 'tangle' + private sdk: Sandbox | undefined + + constructor(private readonly config: TangleSandboxConfig) {} + + capabilities(): SandboxCapabilities { + return TANGLE_CAPS + } + + private get workdir(): string { + return this.config.workdir ?? DEFAULT_WORKDIR + } + + /** Built on first use, so a provider can be constructed without a key. */ + private get client(): Sandbox { + if (this.sdk) return this.sdk + const apiKey = this.config.apiKey ?? process.env.TANGLE_API_KEY + if (!apiKey) { + throw new Error( + 'tangle: no API key. Pass `apiKey` or set the TANGLE_API_KEY env var.', + ) + } + this.sdk = new Sandbox({ + apiKey, + baseUrl: this.config.baseUrl ?? DEFAULT_BASE_URL, + }) + return this.sdk + } + + private createOptions(input: { + id?: string + policy?: SandboxPolicy + snapshot?: { sandboxId: string; snapshotId: string } + }): CreateSandboxOptions { + const metadata: Record = { ...this.config.metadata } + // Tangle mints its own sandbox id, so the deterministic key rides along + // as metadata. + if (input.id !== undefined) metadata[INSTANCE_KEY_METADATA] = input.id + const { resources } = this.config + return { + // No model credentials, coding-agent backend or startup scripts: the + // workspace brings its own. + agent: false, + ...(this.config.environment !== undefined + ? { environment: this.config.environment } + : {}), + ...(resources !== undefined ? { resources } : {}), + ...(this.config.maxLifetimeSeconds !== undefined + ? { maxLifetimeSeconds: this.config.maxLifetimeSeconds } + : {}), + ...(this.config.idleTimeoutSeconds !== undefined + ? { idleTimeoutSeconds: this.config.idleTimeoutSeconds } + : {}), + // The contract's network gate is coarse (allow/ask/deny): only an + // explicit deny maps, onto egress mode `blocked`. + egressPolicy: + input.policy?.capabilities?.network === 'deny' + ? { mode: 'blocked' } + : (this.config.egressPolicy ?? { mode: 'open' }), + metadata, + ...(input.snapshot !== undefined + ? { + fromSnapshot: input.snapshot.snapshotId, + fromSandboxId: input.snapshot.sandboxId, + } + : {}), + } + } + + /** + * Wait until the sandbox runs and make the workdir. Any failure, or an abort + * that landed meanwhile, deletes the sandbox. + */ + private async prepare( + sandbox: SandboxInstance, + env: Record | undefined, + signal: AbortSignal | undefined, + ): Promise { + const handle = new TangleHandle({ + sandbox, + workdir: this.workdir, + ...(env !== undefined ? { env } : {}), + }) + try { + await sandbox.waitFor('running') + // Not through the handle: its commands run in the workdir, which does + // not exist yet. + const r = await sandbox.exec(`mkdir -p -- ${shQuote(this.workdir)}`) + if (r.exitCode !== 0) { + throw new Error(`tangle: could not create ${this.workdir}: ${r.stderr}`) + } + } catch (error) { + await sandbox.delete().catch(() => undefined) + throw error + } + if (signal?.aborted === true) { + await sandbox.delete().catch(() => undefined) + signal.throwIfAborted() + } + return handle + } + + async create(input: SandboxCreateInput): Promise { + const sandbox = await createSandbox( + this.client, + this.createOptions({ + ...(input.id !== undefined ? { id: input.id } : {}), + ...(input.policy !== undefined ? { policy: input.policy } : {}), + }), + input.signal, + ) + // Env stays in the handle and travels with each command; it is never + // stored with the sandbox. + return this.prepare(sandbox, input.env, input.signal) + } + + async restoreSnapshot(input: SandboxRestoreInput): Promise { + // `SandboxRestoreInput` carries a policy too. Dropping it would restore a + // snapshot taken under `network: 'deny'` into a sandbox with open egress. + const sandbox = await createSandbox( + this.client, + this.createOptions({ + snapshot: parseSnapshotRefId(input.snapshotId), + ...(input.policy !== undefined ? { policy: input.policy } : {}), + }), + input.signal, + ) + return this.prepare(sandbox, input.env, input.signal) + } + + async resume(input: SandboxResumeInput): Promise { + input.signal?.throwIfAborted() + const sandbox = await this.client.get(input.id) + if (sandbox === null || GONE_STATUSES.has(sandbox.status)) return null + // A stopped or expired sandbox kept its workspace; start it again. + if (sandbox.status === 'stopped' || sandbox.status === 'expired') { + await sandbox.resume() + } + await sandbox.waitFor('running') + return new TangleHandle({ sandbox, workdir: this.workdir }) + } + + async destroy(input: SandboxDestroyInput): Promise { + input.signal?.throwIfAborted() + const sandbox = await this.client.get(input.id) + if (sandbox === null || sandbox.status === 'deleted') return + // Resolves `already_absent` for a sandbox that is already gone. + await sandbox.delete() + } +} + +/** + * Tangle sandbox provider — runs harness adapters inside isolated Tangle + * cloud sandboxes through the uniform `SandboxHandle`. Requires a Tangle API + * key (`config.apiKey` or the `TANGLE_API_KEY` env var). + */ +export function tangleSandbox( + config: TangleSandboxConfig = {}, +): SandboxProvider { + return new TangleProvider(config) +} diff --git a/packages/ai-sandbox-tangle/tests/journal.conformance.test.ts b/packages/ai-sandbox-tangle/tests/journal.conformance.test.ts new file mode 100644 index 0000000000..2b1cc654e9 --- /dev/null +++ b/packages/ai-sandbox-tangle/tests/journal.conformance.test.ts @@ -0,0 +1,25 @@ +/** + * Journal conformance for the Tangle provider. + * + * NO `followUnsupported`: `killableProcesses` is `true`, so the follow cases RUN + * with a key and name-skip without one. Flipping the capability to `false` fails + * the always-running first case until this file declares it, so the declaration + * cannot drift from the provider. + */ +import { runJournalConformance } from '@tanstack/ai-sandbox/testkit' +import { tangleSandbox } from '../src/index' + +// Auto-gate: these cases create real Tangle sandboxes. +const apiKey = process.env.TANGLE_API_KEY + +runJournalConformance({ + name: 'tangle', + createHandle: async () => { + const provider = tangleSandbox(apiKey !== undefined ? { apiKey } : {}) + const handle = await provider.create({}) + return { handle, dispose: () => handle.destroy() } + }, + ...(apiKey + ? {} + : { unsupported: { reason: 'no TANGLE_API_KEY in the environment' } }), +}) diff --git a/packages/ai-sandbox-tangle/tests/tangle.test.ts b/packages/ai-sandbox-tangle/tests/tangle.test.ts new file mode 100644 index 0000000000..620270d934 --- /dev/null +++ b/packages/ai-sandbox-tangle/tests/tangle.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { tangleSandbox } from '../src/index' +import type { SandboxHandle } from '@tanstack/ai-sandbox' + +// Auto-gate: only run when a Tangle API key is present (these tests create +// real cloud sandboxes). +const apiKey = process.env.TANGLE_API_KEY + +describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { + it('creates a sandbox, runs exec, fs round-trip + destroy', async () => { + const provider = tangleSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({ id: 'live-test' }) + + const echo = await sbx.process.exec('echo hello-tangle; pwd') + expect(echo.stdout).toContain('hello-tangle') + expect(echo.stdout).toContain('/home/agent/workspace') + expect(echo.exitCode).toBe(0) + + // Non-zero exit is a result with stderr intact, not an exception. + const failed = await sbx.process.exec('echo nope >&2; exit 7') + expect(failed.exitCode).toBe(7) + expect(failed.stderr).toContain('nope') + expect(failed.stdout).not.toContain('nope') + + // A cwd outside the user's home works too. + const tmp = await sbx.process.exec('pwd', { cwd: '/tmp' }) + expect(tmp.stdout.trim()).toBe('/tmp') + + // Per-command env arrives, and env.set overlays it. + await sbx.env.set({ BASE_VAR: 'base' }) + const env = await sbx.process.exec('echo "$BASE_VAR:$EXTRA_VAR"', { + env: { EXTRA_VAR: 'extra' }, + }) + expect(env.stdout.trim()).toBe('base:extra') + + await sbx.fs.write('/workspace/nested/note.txt', 'inside tangle') + expect(await sbx.fs.exists('/workspace/nested/note.txt')).toBe(true) + expect(await sbx.fs.read('/workspace/nested/note.txt')).toBe( + 'inside tangle', + ) + expect(await sbx.fs.lstat!('/workspace/nested/note.txt')).toMatchObject({ + type: 'file', + size: 13, + }) + expect(await sbx.fs.lstat!('/workspace/absent')).toBeUndefined() + + const bytes = new Uint8Array([0, 1, 2, 250]) + await sbx.fs.write('/workspace/bin', bytes) + expect(Array.from(await sbx.fs.readBytes('/workspace/bin'))).toEqual([ + 0, 1, 2, 250, + ]) + + expect(await sbx.fs.list('/workspace')).toEqual( + expect.arrayContaining([ + { name: 'nested', path: '/workspace/nested', type: 'dir' }, + { name: 'bin', path: '/workspace/bin', type: 'file' }, + ]), + ) + await sbx.fs.rename('/workspace/bin', '/workspace/bin2') + expect(await sbx.fs.exists('/workspace/bin')).toBe(false) + await sbx.fs.remove('/workspace/nested') + expect(await sbx.fs.exists('/workspace/nested')).toBe(false) + + // Background process: separate streams, real pid, exit code. + const split = await sbx.process.spawn( + 'echo to-out; echo to-err >&2; exit 3', + ) + expect(split.pid).toBeGreaterThan(0) + let sOut = '' + let sErr = '' + await Promise.all([ + (async () => { + for await (const c of split.stdout) sOut += c + })(), + (async () => { + for await (const c of split.stderr) sErr += c + })(), + ]) + expect(await split.wait()).toBe(3) + expect(sOut).toContain('to-out') + expect(sOut).not.toContain('to-err') + expect(sErr).toContain('to-err') + await expect(split.stdin.write('x')).rejects.toThrow(/not writable/) + } finally { + await sbx?.destroy() + } + }, 300_000) + + // MEASURES ports: the preview URL reaches a server in the sandbox. + it('exposes a port as a public preview URL', async () => { + const provider = tangleSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + await sbx.fs.write('/workspace/index.html', 'served-from-tangle') + const server = await sbx.process.spawn('python3 -m http.server 8000') + const channel = await sbx.ports.connect(8000) + expect(channel.url).toMatch(/^https:\/\//) + let body = '' + for (let i = 0; i < 30 && !body.includes('served-from-tangle'); i++) { + await new Promise((r) => setTimeout(r, 2000)) + body = await fetch(channel.url) + .then((r) => r.text()) + .catch(() => '') + } + expect(body).toContain('served-from-tangle') + await server.kill() + } finally { + await sbx?.destroy() + } + }, 300_000) + + // MEASURES killableProcesses: the marker file proves the process died + // server-side rather than the client merely detaching from it. + it('kill() actually terminates the process tree inside the sandbox', async () => { + const provider = tangleSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + const proc = await sbx.process.spawn( + '(sleep 5 && touch /home/agent/workspace/survived) & wait', + ) + expect(proc.pid).toBeGreaterThan(0) + await new Promise((r) => setTimeout(r, 500)) + await proc.kill() + // A signalled process reports a non-zero (signal) exit, never 0. + expect(await proc.wait()).not.toBe(0) + // Outlive the original sleep, then confirm it never completed. + await new Promise((r) => setTimeout(r, 8000)) + expect(await sbx.fs.exists('/workspace/survived')).toBe(false) + } finally { + await sbx?.destroy() + } + }, 300_000) + + // MEASURES the abort path: the process must die, not only the request. + it('aborting exec kills the sandbox-side process', async () => { + const provider = tangleSandbox({ apiKey }) + let sbx: SandboxHandle | undefined + try { + sbx = await provider.create({}) + const controller = new AbortController() + const pending = sbx.process.exec( + 'sleep 5 && touch /home/agent/workspace/survived-abort', + { signal: controller.signal }, + ) + await new Promise((r) => setTimeout(r, 1000)) + controller.abort() + expect((await pending).exitCode).not.toBe(0) + await new Promise((r) => setTimeout(r, 6000)) + expect(await sbx.fs.exists('/workspace/survived-abort')).toBe(false) + } finally { + await sbx?.destroy() + } + }, 300_000) + + it('snapshots a sandbox and restores it into a new one', async () => { + const provider = tangleSandbox({ apiKey }) + let source: SandboxHandle | undefined + let restored: SandboxHandle | undefined + try { + source = await provider.create({}) + await source.fs.write('/workspace/keep.txt', 'survives snapshot') + + const ref = await source.snapshot?.('after-setup') + expect(ref?.id).toBeTruthy() + expect(ref?.label).toBe('after-setup') + + restored = await provider.restoreSnapshot!({ snapshotId: ref!.id }) + expect(restored.id).not.toBe(source.id) + expect(await restored.fs.read('/workspace/keep.txt')).toBe( + 'survives snapshot', + ) + } finally { + await Promise.allSettled([source?.destroy(), restored?.destroy()]) + } + }, 600_000) + + // MEASURES fork: the copy carries state and then diverges. + it('fork branches a sandbox from current state', async () => { + const provider = tangleSandbox({ apiKey }) + let src: SandboxHandle | undefined + let forked: SandboxHandle | undefined + try { + src = await provider.create({}) + await src.fs.write('/workspace/before-fork.txt', 'carried over') + forked = await src.fork!() + expect(forked.id).not.toBe(src.id) + expect(await forked.fs.read('/workspace/before-fork.txt')).toBe( + 'carried over', + ) + await forked.fs.write('/workspace/only-in-fork.txt', 'x') + expect(await src.fs.exists('/workspace/only-in-fork.txt')).toBe(false) + } finally { + await Promise.allSettled([src?.destroy(), forked?.destroy()]) + } + }, 600_000) + + it('resume reconnects to a live sandbox and returns null once it is destroyed', async () => { + const provider = tangleSandbox({ apiKey }) + const sbx = await provider.create({}) + try { + await sbx.fs.write('/workspace/state.txt', 'still here') + const resumed = await provider.resume({ id: sbx.id }) + expect(resumed?.id).toBe(sbx.id) + expect(await resumed!.fs.read('/workspace/state.txt')).toBe('still here') + } finally { + await sbx.destroy() + } + expect(await provider.resume({ id: sbx.id })).toBeNull() + // Destroying an already-destroyed sandbox is not an error. + await expect(provider.destroy({ id: sbx.id })).resolves.toBeUndefined() + }, 300_000) + + // MEASURES networkPolicy: a deny policy must actually block egress. + it('a deny network policy blocks outbound traffic', async () => { + const provider = tangleSandbox({ apiKey }) + const PROBE = + 'curl -s -m 10 -o /dev/null -w "%{http_code}" https://example.com' + let denied: SandboxHandle | undefined + let control: SandboxHandle | undefined + try { + const [deniedRes, controlRes] = await Promise.allSettled([ + provider.create({ policy: { capabilities: { network: 'deny' } } }), + provider.create({}), + ]) + if (deniedRes.status === 'fulfilled') denied = deniedRes.value + if (controlRes.status === 'fulfilled') control = controlRes.value + if (deniedRes.status === 'rejected') throw deniedRes.reason + if (controlRes.status === 'rejected') throw controlRes.reason + + // POSITIVE CONTROL: without it the test passes whenever the probe fails + // for an unrelated reason even though egress is wide open. + const reachable = await control!.process.exec(PROBE) + expect(reachable.stdout).toContain('200') + + expect((await denied!.process.exec('command -v curl')).exitCode).toBe(0) + const blocked = await denied!.process.exec(PROBE) + expect(blocked.stdout).not.toContain('200') + } finally { + await Promise.allSettled([denied?.destroy(), control?.destroy()]) + } + }, 600_000) +}) diff --git a/packages/ai-sandbox-tangle/tsconfig.json b/packages/ai-sandbox-tangle/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /dev/null +++ b/packages/ai-sandbox-tangle/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-tangle/vite.config.ts b/packages/ai-sandbox-tangle/vite.config.ts new file mode 100644 index 0000000000..11f5b20b70 --- /dev/null +++ b/packages/ai-sandbox-tangle/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 96f8262d37..718cff2731 100644 --- a/packages/ai-sandbox/README.md +++ b/packages/ai-sandbox/README.md @@ -78,6 +78,7 @@ Pick a **provider** package for where the sandbox runs: | `@tanstack/ai-sandbox-boxd` | boxd microVMs, live fork, snapshots | | `@tanstack/ai-sandbox-e2b` | E2B microVMs, snapshots and fork | | `@tanstack/ai-sandbox-railway` | Railway sandboxes, private networking | +| `@tanstack/ai-sandbox-tangle` | Tangle sandboxes, snapshots and fork | Install the provider you select separately. For Blaxel: From 3453a4c5e2a9c334e1f7cc7f654ed194991de9f8 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 13:29:10 -0800 Subject: [PATCH 2/9] chore(ai-sandbox-tangle): lockfile and formatting --- packages/ai-sandbox-tangle/README.md | 46 ++-- packages/ai-sandbox-tangle/src/handle.ts | 4 +- pnpm-lock.yaml | 305 +++++++++++++++++------ 3 files changed, 254 insertions(+), 101 deletions(-) diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md index 6e9bf6a398..648f94454f 100644 --- a/packages/ai-sandbox-tangle/README.md +++ b/packages/ai-sandbox-tangle/README.md @@ -81,33 +81,33 @@ try { ## Configuration -| Option | Default | Notes | -| -------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| `apiKey` | `TANGLE_API_KEY` | Tangle API key. | -| `baseUrl` | `https://sandbox.tangle.tools` | Sandbox API URL. | -| `environment` | platform default | A catalog environment or a container image reference, such as `node:22`. | -| `resources` | platform default | `{ cpuCores, memoryMB, diskGB }`. | -| `maxLifetimeSeconds` | platform default | Run time before the platform suspends the sandbox. The workspace is kept, and `resume` starts it again. | -| `idleTimeoutSeconds` | platform default | Idle time before the platform suspends the sandbox. | -| `egressPolicy` | `{ mode: 'open' }` | Outbound network policy (`open`, `strict` with `allowDomains`, or `blocked`). A `network: 'deny'` policy wins. | -| `workdir` | `/home/agent/workspace` | Where the `/workspace` virtual root maps. | +| Option | Default | Notes | +| -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `apiKey` | `TANGLE_API_KEY` | Tangle API key. | +| `baseUrl` | `https://sandbox.tangle.tools` | Sandbox API URL. | +| `environment` | platform default | A catalog environment or a container image reference, such as `node:22`. | +| `resources` | platform default | `{ cpuCores, memoryMB, diskGB }`. | +| `maxLifetimeSeconds` | platform default | Run time before the platform suspends the sandbox. The workspace is kept, and `resume` starts it again. | +| `idleTimeoutSeconds` | platform default | Idle time before the platform suspends the sandbox. | +| `egressPolicy` | `{ mode: 'open' }` | Outbound network policy (`open`, `strict` with `allowDomains`, or `blocked`). A `network: 'deny'` policy wins. | +| `workdir` | `/home/agent/workspace` | Where the `/workspace` virtual root maps. | | `metadata` | none | Extra metadata on every created sandbox. The deterministic sandbox id from `ensure()` is stored under `tanstack-ai-key`. | ## Capabilities -| Capability | Supported | Notes | -| --------------------- | --------- | ----------------------------------------------------------------------------------------------------------- | -| `fs` | ✅ | Native file API for `read`, `readBytes`, and `write`; the rest runs as commands. `lstat` reports symlinks. | -| `exec` | ✅ | Separate `stdout` and `stderr`, no run limit. An abort kills the process in the sandbox. | -| `env` | ✅ | Env for each command, plus the `env.set` overlay. Values are never stored with the sandbox. | -| `ports` | ✅ | `ports.connect(port)` creates a preview link and returns its public HTTPS URL. | -| `snapshots` | ✅ | `snapshot()` captures the workspace. `restoreSnapshot()` creates a new sandbox from it. | -| `durableFilesystem` | ✅ | The workspace persists across stop and resume. | -| `backgroundProcesses` | ✅ | `spawn()` returns a real sandbox pid and streams `stdout` and `stderr` separately. | -| `writableStdin` | ❌ | The process API takes no stdin stream; harnesses get their prompt from a file. | -| `killableProcesses` | ✅ | `kill()` signals the process tree inside the sandbox, measured against a live sandbox. | -| `networkPolicy` | ✅ | `policy.capabilities.network: 'deny'` maps to egress mode `blocked`. | -| `fork` | ✅ | `fork()` branches the running sandbox into a new one. | +| Capability | Supported | Notes | +| --------------------- | --------- | ---------------------------------------------------------------------------------------------------------- | +| `fs` | ✅ | Native file API for `read`, `readBytes`, and `write`; the rest runs as commands. `lstat` reports symlinks. | +| `exec` | ✅ | Separate `stdout` and `stderr`, no run limit. An abort kills the process in the sandbox. | +| `env` | ✅ | Env for each command, plus the `env.set` overlay. Values are never stored with the sandbox. | +| `ports` | ✅ | `ports.connect(port)` creates a preview link and returns its public HTTPS URL. | +| `snapshots` | ✅ | `snapshot()` captures the workspace. `restoreSnapshot()` creates a new sandbox from it. | +| `durableFilesystem` | ✅ | The workspace persists across stop and resume. | +| `backgroundProcesses` | ✅ | `spawn()` returns a real sandbox pid and streams `stdout` and `stderr` separately. | +| `writableStdin` | ❌ | The process API takes no stdin stream; harnesses get their prompt from a file. | +| `killableProcesses` | ✅ | `kill()` signals the process tree inside the sandbox, measured against a live sandbox. | +| `networkPolicy` | ✅ | `policy.capabilities.network: 'deny'` maps to egress mode `blocked`. | +| `fork` | ✅ | `fork()` branches the running sandbox into a new one. | The native file calls reach the sandbox user's home (`/home/agent`) and `/tmp`. Read other paths with `process.exec`. diff --git a/packages/ai-sandbox-tangle/src/handle.ts b/packages/ai-sandbox-tangle/src/handle.ts index c9be53a592..835d208be8 100644 --- a/packages/ai-sandbox-tangle/src/handle.ts +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -394,9 +394,7 @@ export class TangleHandle implements SandboxHandle { ): Promise => { options?.signal?.throwIfAborted() const tag = label !== undefined ? slug(label) : '' - const snap = await this.sandbox.snapshot( - tag !== '' ? { tags: [tag] } : {}, - ) + const snap = await this.sandbox.snapshot(tag !== '' ? { tags: [tag] } : {}) return { id: snapshotRefId(this.id, snap.snapshotId), ...(label !== undefined ? { label } : {}), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 071fcf637a..eafe72b01c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,7 +109,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) codemods: devDependencies: @@ -130,7 +130,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) examples/ag-ui: dependencies: @@ -1009,10 +1009,10 @@ importers: devDependencies: '@analogjs/vite-plugin-angular': specifier: ^2.9.6 - version: 2.9.6(@angular/build@22.2.4(8a483054860a32cf0c0a2a4335861de0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 2.9.6(@angular/build@22.2.4(db57fa572e6b85c62f8ef62b0c4eeb32))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@angular/build': specifier: 22.2.4 - version: 22.2.4(8a483054860a32cf0c0a2a4335861de0) + version: 22.2.4(db57fa572e6b85c62f8ef62b0c4eeb32) '@angular/compiler-cli': specifier: 22.2.4 version: 22.2.4(@angular/compiler@22.2.4)(typescript@6.0.2) @@ -1257,7 +1257,7 @@ importers: version: 6.2.1(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) typescript: specifier: 5.9.3 version: 5.9.3 @@ -1266,7 +1266,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -1483,7 +1483,7 @@ importers: version: 9.2.1 jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) typescript: specifier: 5.9.3 version: 5.9.3 @@ -1492,7 +1492,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -1592,7 +1592,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) examples/ts-react-native-chat: dependencies: @@ -1805,7 +1805,7 @@ importers: version: 6.2.1(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) typescript: specifier: 5.9.3 version: 5.9.3 @@ -1814,7 +1814,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -2042,7 +2042,7 @@ importers: version: 24.10.3 jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) typescript: specifier: 5.9.3 version: 5.9.3 @@ -2054,7 +2054,7 @@ importers: version: 2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -2318,7 +2318,7 @@ importers: version: 24.10.3 jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) ng-packagr: specifier: ^22.2.1 version: 22.2.6(@angular/compiler-cli@22.2.4(@angular/compiler@22.2.4)(typescript@6.0.2))(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@6.0.2) @@ -2327,7 +2327,7 @@ importers: version: 6.0.2 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) zod: specifier: ^4.2.0 version: 4.3.6 @@ -2708,7 +2708,7 @@ importers: version: 4.1.10(vitest@4.1.11) jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) tsup: specifier: ^8.5.1 version: 8.5.1(@microsoft/api-extractor@7.47.7(@types/node@24.13.6))(jiti@2.7.0)(postcss@8.5.26)(supports-color@7.2.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) @@ -3145,7 +3145,7 @@ importers: version: 6.3.1(@opentelemetry/api@1.9.1) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-mistral: dependencies: @@ -3366,7 +3366,7 @@ importers: version: 8.23.1 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-preact: dependencies: @@ -3388,7 +3388,7 @@ importers: version: 4.1.10(vitest@4.1.11) jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) preact: specifier: ^11.0.1 version: 11.0.1 @@ -3425,7 +3425,7 @@ importers: version: 4.1.10(vitest@4.1.11) jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) react: specifier: ^19.2.3 version: 19.2.3 @@ -3478,7 +3478,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-sandbox: dependencies: @@ -3512,7 +3512,7 @@ importers: version: 4.1.10(vitest@4.1.10) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-sandbox-blaxel: dependencies: @@ -3648,6 +3648,19 @@ importers: specifier: 4.1.10 version: 4.1.10(vitest@4.1.11) + packages/ai-sandbox-tangle: + dependencies: + '@tangle-network/sandbox': + specifier: ^1.0.313 + version: 1.0.314 + devDependencies: + '@tanstack/ai-sandbox': + specifier: workspace:* + version: link:../ai-sandbox + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.11) + packages/ai-sandbox-upstash-box: dependencies: '@upstash/box': @@ -3688,7 +3701,7 @@ importers: version: 4.1.10(vitest@4.1.10) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) zod: specifier: ^4.2.0 version: 4.3.6 @@ -3719,7 +3732,7 @@ importers: version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -3737,7 +3750,7 @@ importers: version: 2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) packages/ai-svelte: dependencies: @@ -3765,7 +3778,7 @@ importers: version: 4.1.10(vitest@4.1.11) jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) svelte: specifier: ^5.56.9 version: 5.56.9(@typescript-eslint/types@8.59.4) @@ -3837,7 +3850,7 @@ importers: devDependencies: '@google/genai': specifier: ^2.10.0 - version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)) + version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.7)) '@tanstack/ai': specifier: workspace:* version: link:../ai @@ -3877,7 +3890,7 @@ importers: version: 2.4.6 jsdom: specifier: ^27.4.0 - version: 27.4.0(@noble/hashes@2.3.0) + version: 27.4.0(@noble/hashes@2.4.0) tsdown: specifier: ^0.17.0-beta.6 version: 0.17.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(oxc-resolver@11.21.3)(publint@0.3.16)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) @@ -3889,7 +3902,7 @@ importers: version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) vue: specifier: ^3.5.25 version: 3.5.25(typescript@5.9.3) @@ -4051,7 +4064,7 @@ importers: version: 0.135.0(zod@4.3.6) '@copilotkit/aimock': specifier: ^1.34.0 - version: 1.34.0(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))) + version: 1.34.0(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))) '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) @@ -7499,6 +7512,10 @@ packages: resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -11056,7 +11073,9 @@ packages: '@smithy/core@3.26.0': resolution: {integrity: sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==} engines: {node: '>=18.0.0'} - deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. + deprecated: |- + Deprecated due to a bug in custom logger data redaction. Upgrade to the latest version. If you used a custom logger with this + version, review your logs and rotate any credentials or other secrets. See https://github.com/aws/aws-sdk-js-v3/issues/8393. '@smithy/credential-provider-imds@4.4.2': resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} @@ -11312,6 +11331,40 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tangle-network/agent-core@1.0.3': + resolution: {integrity: sha512-XEYUcGi2XZTCBcVKb6OK8PFbtW2xcI/0VtvRPWQtuVuBZWTb5PeI4HQ+r+rZB9IBuJpFzfm9wWfWum3zjWbsyQ==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.30.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@tangle-network/agent-interface@5.10.21': + resolution: {integrity: sha512-KuSotY035tkZjrU2MVmbDkIEbnMOjjccL2RlZrspNF9gLUthNcXnmf3biUHYgYyYqj7Y8jsv1OymxWQtbbcSlw==} + + '@tangle-network/agent-trace-contract@2.2.2': + resolution: {integrity: sha512-46CGFTHw1E+Fvykjdf3tS0ysQugv+DdM9TptHjALVeFzO+Z4Baiw6XWJLYiP3fx/UEk3QCIiITD1qoPi8XfgkA==} + + '@tangle-network/sandbox@1.0.314': + resolution: {integrity: sha512-Bn0ZzyEDM8KTI8SKPg6zI5ivNXeaqlHXzTqFck4JiqoYccvjXVFs8pxmB//K7sv+UQjyocGAzFOhhHfe7cvnEA==} + peerDependencies: + '@mastra/core': ^1.36.0 + '@modelcontextprotocol/sdk': ^1.30.0 + ai: ^6.0.175 || ^7.0.107 + openai: ^6.36.0 + viem: ^2.0.0 + peerDependenciesMeta: + '@mastra/core': + optional: true + '@modelcontextprotocol/sdk': + optional: true + ai: + optional: true + openai: + optional: true + viem: + optional: true + '@tanstack/db-ivm@0.1.18': resolution: {integrity: sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw==} peerDependencies: @@ -12952,6 +13005,14 @@ packages: react-native-b4a: optional: true + b4a@1.9.0: + resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -13047,6 +13108,15 @@ packages: bare-buffer: optional: true + bare-fs@4.8.4: + resolution: {integrity: sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-os@3.8.0: resolution: {integrity: sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==} engines: {bare: '>=1.14.0'} @@ -18679,6 +18749,15 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + + spdx-license-ids@3.0.24: + resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -18773,6 +18852,9 @@ packages: streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -18942,6 +19024,9 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar-stream@3.2.2: + resolution: {integrity: sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==} + tar@7.5.16: resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} engines: {node: '>=18'} @@ -20335,6 +20420,9 @@ packages: zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -20363,7 +20451,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@analogjs/vite-plugin-angular@2.9.6(@angular/build@22.2.4(8a483054860a32cf0c0a2a4335861de0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@analogjs/vite-plugin-angular@2.9.6(@angular/build@22.2.4(db57fa572e6b85c62f8ef62b0c4eeb32))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: magic-string: 0.30.21 obug: 2.2.1 @@ -20371,7 +20459,7 @@ snapshots: tinyglobby: 0.2.17 typescript: 6.0.2 optionalDependencies: - '@angular/build': 22.2.4(8a483054860a32cf0c0a2a4335861de0) + '@angular/build': 22.2.4(db57fa572e6b85c62f8ef62b0c4eeb32) vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' @@ -20409,7 +20497,7 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular/build@22.2.4(8a483054860a32cf0c0a2a4335861de0)': + '@angular/build@22.2.4(@angular/compiler-cli@22.2.4(@angular/compiler@22.2.4)(typescript@6.0.2))(@angular/compiler@22.2.4)(@angular/core@22.2.4(@angular/compiler@22.2.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@22.2.4(@angular/common@22.2.4(@angular/core@22.2.4(@angular/compiler@22.2.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@22.2.4(@angular/compiler@22.2.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@24.10.3)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.6)(ng-packagr@22.2.6(@angular/compiler-cli@22.2.4(@angular/compiler@22.2.4)(typescript@6.0.2))(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@6.0.2))(postcss@8.5.28)(rollup@4.60.1)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@6.0.2)(vitest@4.1.10)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2202.4(chokidar@5.0.0) @@ -20418,7 +20506,7 @@ snapshots: '@babel/core': 8.0.5 '@inquirer/confirm': 6.3.2(@types/node@24.10.3) '@parcel/watcher': 2.6.0 - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) beasties: 0.5.4 browserslist: 4.29.0 chokidar: 5.0.0 @@ -20452,7 +20540,7 @@ snapshots: postcss: 8.5.28 rollup: 4.60.1 tailwindcss: 4.1.18 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -20465,7 +20553,7 @@ snapshots: - tsx - yaml - '@angular/build@22.2.4(@angular/compiler-cli@22.2.4(@angular/compiler@22.2.4)(typescript@6.0.2))(@angular/compiler@22.2.4)(@angular/core@22.2.4(@angular/compiler@22.2.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@22.2.4(@angular/common@22.2.4(@angular/core@22.2.4(@angular/compiler@22.2.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@22.2.4(@angular/compiler@22.2.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@24.10.3)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.6)(ng-packagr@22.2.6(@angular/compiler-cli@22.2.4(@angular/compiler@22.2.4)(typescript@6.0.2))(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tailwindcss@4.1.18)(tslib@2.8.1)(typescript@6.0.2))(postcss@8.5.28)(rollup@4.60.1)(tailwindcss@4.1.18)(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@6.0.2)(vitest@4.1.10)(yaml@2.9.0)': + '@angular/build@22.2.4(db57fa572e6b85c62f8ef62b0c4eeb32)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2202.4(chokidar@5.0.0) @@ -20474,7 +20562,7 @@ snapshots: '@babel/core': 8.0.5 '@inquirer/confirm': 6.3.2(@types/node@24.10.3) '@parcel/watcher': 2.6.0 - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) beasties: 0.5.4 browserslist: 4.29.0 chokidar: 5.0.0 @@ -20508,7 +20596,7 @@ snapshots: postcss: 8.5.28 rollup: 4.60.1 tailwindcss: 4.1.18 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -22718,9 +22806,9 @@ snapshots: dependencies: '@bufbuild/protobuf': 2.15.0 - '@copilotkit/aimock@1.34.0(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))': + '@copilotkit/aimock@1.34.0(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))': optionalDependencies: - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@cspotcode/source-map-support@0.8.1': dependencies: @@ -23262,9 +23350,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.2(@noble/hashes@2.3.0)': + '@exodus/bytes@1.15.2(@noble/hashes@2.4.0)': optionalDependencies: - '@noble/hashes': 2.3.0 + '@noble/hashes': 2.4.0 '@expo/cli@56.1.12(@expo/dom-webview@56.0.5)(expo-constants@56.0.16(expo@56.0.5)(react-native@0.85.3(@babel/core@8.0.5)(@types/react@19.2.7)(react@19.2.3)(supports-color@7.2.0)))(expo-font@56.0.5(expo@56.0.5)(react-native@0.85.3(@babel/core@8.0.5)(@types/react@19.2.7)(react@19.2.3)(supports-color@7.2.0))(react@19.2.3))(expo@56.0.5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@8.0.5)(@types/react@19.2.7)(react@19.2.3)(supports-color@7.2.0))(react@19.2.3)(supports-color@7.2.0)(typescript@5.9.3)': dependencies: @@ -23774,14 +23862,14 @@ snapshots: - supports-color - utf-8-validate - '@google/genai@2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))': + '@google/genai@2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.7))': dependencies: google-auth-library: 10.5.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.7) transitivePeerDependencies: - bufferutil - supports-color @@ -24369,7 +24457,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.7)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.8) ajv: 8.20.0 @@ -24386,8 +24474,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.6.5 - zod-to-json-schema: 3.25.2(zod@4.6.5) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: @@ -24575,6 +24663,8 @@ snapshots: '@noble/hashes@2.3.0': {} + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -28614,6 +28704,32 @@ snapshots: tailwindcss: 4.1.18 vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + '@tangle-network/agent-core@1.0.3': + dependencies: + '@tangle-network/agent-interface': 5.10.21 + '@tangle-network/agent-trace-contract': 2.2.2 + zod: 4.6.7 + + '@tangle-network/agent-interface@5.10.21': + dependencies: + '@noble/hashes': 2.4.0 + spdx-expression-parse: 5.0.0 + zod: 4.6.7 + + '@tangle-network/agent-trace-contract@2.2.2': {} + + '@tangle-network/sandbox@1.0.314': + dependencies: + '@tangle-network/agent-core': 1.0.3 + '@tangle-network/agent-interface': 5.10.21 + '@tangle-network/agent-trace-contract': 2.2.2 + tar-stream: 3.2.2 + zod: 4.6.7 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + '@tanstack/db-ivm@0.1.18(typescript@5.9.3)': dependencies: fractional-indexing: 3.2.0 @@ -30436,7 +30552,7 @@ snapshots: obug: 2.2.1 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/coverage-v8@4.1.10(vitest@4.1.11)': dependencies: @@ -30450,7 +30566,7 @@ snapshots: obug: 2.2.1 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.6)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.6)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -31094,6 +31210,8 @@ snapshots: b4a@1.7.3: {} + b4a@1.9.0: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -31312,13 +31430,22 @@ snapshots: - react-native-b4a optional: true - bare-os@3.8.0: - optional: true + bare-fs@4.8.4: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.8.1(bare-events@2.8.2) + bare-url: 2.3.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.8.0: {} bare-path@3.0.0: dependencies: bare-os: 3.8.0 - optional: true bare-stream@2.8.1(bare-events@2.8.2): dependencies: @@ -31329,12 +31456,10 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a - optional: true bare-url@2.3.2: dependencies: bare-path: 3.0.0 - optional: true base64-js@1.5.1: {} @@ -33716,9 +33841,9 @@ snapshots: dependencies: lru-cache: 10.4.3 - html-encoding-sniffer@6.0.0(@noble/hashes@2.3.0): + html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): dependencies: - '@exodus/bytes': 1.15.2(@noble/hashes@2.3.0) + '@exodus/bytes': 1.15.2(@noble/hashes@2.4.0) transitivePeerDependencies: - '@noble/hashes' @@ -34284,15 +34409,15 @@ snapshots: transitivePeerDependencies: - supports-color - jsdom@27.4.0(@noble/hashes@2.3.0): + jsdom@27.4.0(@noble/hashes@2.4.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 - '@exodus/bytes': 1.15.2(@noble/hashes@2.3.0) + '@exodus/bytes': 1.15.2(@noble/hashes@2.4.0) cssstyle: 5.3.7 data-urls: 6.0.1 decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0(@noble/hashes@2.3.0) + html-encoding-sniffer: 6.0.0(@noble/hashes@2.4.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -38809,6 +38934,15 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.24 + + spdx-license-ids@3.0.24: {} + split-ca@1.0.1: {} split2@4.2.0: {} @@ -38888,6 +39022,15 @@ snapshots: - bare-abort-controller - react-native-b4a + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-argv@0.3.2: optional: true @@ -39098,6 +39241,17 @@ snapshots: - bare-abort-controller - react-native-b4a + tar-stream@3.2.2: + dependencies: + b4a: 1.9.0 + bare-fs: 4.8.4 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + tar@7.5.16: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -39120,7 +39274,6 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a - optional: true term-size@2.2.1: {} @@ -40098,7 +40251,7 @@ snapshots: optionalDependencies: vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -40125,11 +40278,11 @@ snapshots: '@types/node': 24.10.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) happy-dom: 20.11.2 - jsdom: 27.4.0(@noble/hashes@2.3.0) + jsdom: 27.4.0(@noble/hashes@2.4.0) transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.3.0(@types/node@24.10.3)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -40156,11 +40309,11 @@ snapshots: '@types/node': 24.10.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) happy-dom: 20.11.2 - jsdom: 27.4.0(@noble/hashes@2.3.0) + jsdom: 27.4.0(@noble/hashes@2.4.0) transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -40187,11 +40340,11 @@ snapshots: '@types/node': 24.10.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) happy-dom: 20.11.2 - jsdom: 27.4.0(@noble/hashes@2.3.0) + jsdom: 27.4.0(@noble/hashes@2.4.0) transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -40218,12 +40371,12 @@ snapshots: '@types/node': 24.10.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) happy-dom: 20.11.2 - jsdom: 27.4.0(@noble/hashes@2.3.0) + jsdom: 27.4.0(@noble/hashes@2.4.0) transitivePeerDependencies: - msw optional: true - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10(vitest@4.1.10))(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -40250,11 +40403,11 @@ snapshots: '@types/node': 24.10.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) happy-dom: 20.11.2 - jsdom: 27.4.0(@noble/hashes@2.3.0) + jsdom: 27.4.0(@noble/hashes@2.4.0) transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.6)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.6)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.107.1)(sass@1.107.1)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -40281,7 +40434,7 @@ snapshots: '@types/node': 24.13.6 '@vitest/coverage-v8': 4.1.10(vitest@4.1.11) happy-dom: 20.11.2 - jsdom: 27.4.0(@noble/hashes@2.3.0) + jsdom: 27.4.0(@noble/hashes@2.4.0) transitivePeerDependencies: - msw @@ -40694,9 +40847,9 @@ snapshots: dependencies: zod: 4.3.6 - zod-to-json-schema@3.25.2(zod@4.6.5): + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: - zod: 4.6.5 + zod: 4.6.7 optional: true zod@3.25.76: {} @@ -40709,6 +40862,8 @@ snapshots: zod@4.6.5: {} + zod@4.6.7: {} + zone.js@0.15.1: {} zwitch@2.0.4: {} From 6c43ab9d235ac590b008a206937da2df765de4c8 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 13:31:01 -0800 Subject: [PATCH 3/9] docs(ai-sandbox-tangle): say env is not sent at create --- docs/sandbox/providers-advanced.md | 2 +- packages/ai-sandbox-tangle/README.md | 2 +- packages/ai-sandbox-tangle/src/provider.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index 4f095e34c3..613dd3b538 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -374,7 +374,7 @@ const tangle = tangleSandbox({ ``` - **Isolation:** a managed [Tangle](https://tangle.tools) sandbox that you do not run. Pick the image with `environment`: a catalog environment or a container image reference. The default is the platform's default environment. -- **Auth and env:** needs `TANGLE_API_KEY` (or `apiKey`). Set `baseUrl` for another endpoint. Sandboxes are created without platform model credentials or startup scripts. Harness credentials are [workspace secrets](./provisioning) and travel as env for each command. They are not stored with the sandbox. +- **Auth and env:** needs `TANGLE_API_KEY` (or `apiKey`). Set `baseUrl` for another endpoint. Sandboxes are created without platform model credentials or startup scripts. Harness credentials are [workspace secrets](./provisioning) and travel as env for each command. They are not sent at create. - **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again. - **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit, and stdout and stderr stay separate. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`. - **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A missing, `deleted`, or `failed` sandbox resumes as `null`. diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md index 648f94454f..d0b1e3998d 100644 --- a/packages/ai-sandbox-tangle/README.md +++ b/packages/ai-sandbox-tangle/README.md @@ -99,7 +99,7 @@ try { | --------------------- | --------- | ---------------------------------------------------------------------------------------------------------- | | `fs` | ✅ | Native file API for `read`, `readBytes`, and `write`; the rest runs as commands. `lstat` reports symlinks. | | `exec` | ✅ | Separate `stdout` and `stderr`, no run limit. An abort kills the process in the sandbox. | -| `env` | ✅ | Env for each command, plus the `env.set` overlay. Values are never stored with the sandbox. | +| `env` | ✅ | Env for each command, plus the `env.set` overlay. Nothing is sent at create. | | `ports` | ✅ | `ports.connect(port)` creates a preview link and returns its public HTTPS URL. | | `snapshots` | ✅ | `snapshot()` captures the workspace. `restoreSnapshot()` creates a new sandbox from it. | | `durableFilesystem` | ✅ | The workspace persists across stop and resume. | diff --git a/packages/ai-sandbox-tangle/src/provider.ts b/packages/ai-sandbox-tangle/src/provider.ts index 235383dfe7..7764c542a3 100644 --- a/packages/ai-sandbox-tangle/src/provider.ts +++ b/packages/ai-sandbox-tangle/src/provider.ts @@ -209,8 +209,8 @@ class TangleProvider implements SandboxProvider { }), input.signal, ) - // Env stays in the handle and travels with each command; it is never - // stored with the sandbox. + // Env stays in the handle and travels with each command; it is not + // sent at create. return this.prepare(sandbox, input.env, input.signal) } From ae34ef558fa5fce06a2593f145802d298661d019 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 14:17:54 -0800 Subject: [PATCH 4/9] fix(ai-sandbox-tangle): survive dropped reads, fail on lost output, isolate secrets Read each process through one log stream and fail on a gap, retry a dropped log stream or status poll, run commands with cd -- && exec sh -c, pass abort signals through, require https for baseUrl, create fresh sandboxes with ownerContext isolated, and measure restore after the source is destroyed, a fork of a blocked sandbox and an aborted create. --- .agent/domain-map.json | 1 + docs/sandbox/providers-advanced.md | 14 +- packages/ai-sandbox-tangle/README.md | 10 +- packages/ai-sandbox-tangle/package.json | 2 +- packages/ai-sandbox-tangle/src/handle.ts | 210 ++++++++++++++---- packages/ai-sandbox-tangle/src/provider.ts | 44 ++-- .../ai-sandbox-tangle/tests/tangle.test.ts | 55 ++++- pnpm-lock.yaml | 2 +- 8 files changed, 267 insertions(+), 71 deletions(-) diff --git a/.agent/domain-map.json b/.agent/domain-map.json index 9ba603e418..945a995fbe 100644 --- a/.agent/domain-map.json +++ b/.agent/domain-map.json @@ -93,6 +93,7 @@ "packages/ai-sandbox-local-process", "packages/ai-sandbox-railway", "packages/ai-sandbox-sprites", + "packages/ai-sandbox-tangle", "packages/ai-sandbox-upstash-box", "packages/ai-sandbox-vercel" ], diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index 613dd3b538..c944feb55e 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -373,15 +373,15 @@ const tangle = tangleSandbox({ }) ``` -- **Isolation:** a managed [Tangle](https://tangle.tools) sandbox that you do not run. Pick the image with `environment`: a catalog environment or a container image reference. The default is the platform's default environment. -- **Auth and env:** needs `TANGLE_API_KEY` (or `apiKey`). Set `baseUrl` for another endpoint. Sandboxes are created without platform model credentials or startup scripts. Harness credentials are [workspace secrets](./provisioning) and travel as env for each command. They are not sent at create. +- **Isolation:** a managed [Tangle](https://tangle.tools) sandbox that you do not run. Pick the image with `environment`: a catalog environment or a container image reference. The default is the platform's default environment. A container image runs with a read-only root filesystem as the image's user, and `fork()` on it takes minutes (measured: 551 s for `node:22`, against 3 to 8 s on the default environment). +- **Auth and env:** needs `TANGLE_API_KEY` (or `apiKey`). Set `baseUrl` for another endpoint; it must use HTTPS. Sandboxes are created without platform model credentials, startup scripts, or the account's stored secrets. A restore inherits the account's secrets, because the API refuses an isolated restore. Harness credentials are [workspace secrets](./provisioning) and travel as env for each command. They are not sent at create. - **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again. -- **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit, and stdout and stderr stay separate. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`. -- **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A missing, `deleted`, or `failed` sandbox resumes as `null`. -- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent. +- **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit (measured: a 10.5-minute command with an 8-minute silent stretch), and stdout and stderr stay separate. A dropped status poll or log stream is read again. Output that the sandbox dropped before it was read fails the command, so a result is never cut short without an error. A relative `cwd` resolves against the workdir, and a missing `cwd` fails the command. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`, and a command that reads stdin gets end of file. +- **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A snapshot outlives its source sandbox. A missing, `deleted`, or `failed` sandbox resumes as `null`. +- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. - **Ports:** `ports.connect(port)` creates a preview link for the port, or reuses the existing one, and returns its HTTPS URL. The URL is public: anyone who has it can reach the port. -- **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. -- **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it. Commands run as the `agent` user. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`. +- **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. The platform's own default, `strict`, allows only package registries and code hosts, so a harness that calls a model API needs `open` or `allowDomains`. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. +- **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it, and keep it under `/home/agent` or `/tmp`. Commands run as the `agent` user on the default environment. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`. - **Bridge:** a remote sandbox, so bridged tools need the tunnel in local dev. See [Tools](./tools). ## Cloudflare diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md index d0b1e3998d..e31334117b 100644 --- a/packages/ai-sandbox-tangle/README.md +++ b/packages/ai-sandbox-tangle/README.md @@ -84,13 +84,13 @@ try { | Option | Default | Notes | | -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `apiKey` | `TANGLE_API_KEY` | Tangle API key. | -| `baseUrl` | `https://sandbox.tangle.tools` | Sandbox API URL. | -| `environment` | platform default | A catalog environment or a container image reference, such as `node:22`. | +| `baseUrl` | `https://sandbox.tangle.tools` | Sandbox API URL. Must use HTTPS. | +| `environment` | platform default | A catalog environment or a container image, such as `node:22`. An image has a read-only root, and its `fork()` is slow. | | `resources` | platform default | `{ cpuCores, memoryMB, diskGB }`. | | `maxLifetimeSeconds` | platform default | Run time before the platform suspends the sandbox. The workspace is kept, and `resume` starts it again. | | `idleTimeoutSeconds` | platform default | Idle time before the platform suspends the sandbox. | -| `egressPolicy` | `{ mode: 'open' }` | Outbound network policy (`open`, `strict` with `allowDomains`, or `blocked`). A `network: 'deny'` policy wins. | -| `workdir` | `/home/agent/workspace` | Where the `/workspace` virtual root maps. | +| `egressPolicy` | `{ mode: 'open' }` | `open`, `strict` with `allowDomains`, or `blocked`. The platform default is `strict`. A `network: 'deny'` policy wins. | +| `workdir` | `/home/agent/workspace` | Where the `/workspace` virtual root maps. Keep it under `/home/agent` or `/tmp`. | | `metadata` | none | Extra metadata on every created sandbox. The deterministic sandbox id from `ensure()` is stored under `tanstack-ai-key`. | ## Capabilities @@ -98,7 +98,7 @@ try { | Capability | Supported | Notes | | --------------------- | --------- | ---------------------------------------------------------------------------------------------------------- | | `fs` | ✅ | Native file API for `read`, `readBytes`, and `write`; the rest runs as commands. `lstat` reports symlinks. | -| `exec` | ✅ | Separate `stdout` and `stderr`, no run limit. An abort kills the process in the sandbox. | +| `exec` | ✅ | Separate `stdout` and `stderr`, no run limit. Lost output is an error. An abort kills the process. | | `env` | ✅ | Env for each command, plus the `env.set` overlay. Nothing is sent at create. | | `ports` | ✅ | `ports.connect(port)` creates a preview link and returns its public HTTPS URL. | | `snapshots` | ✅ | `snapshot()` captures the workspace. `restoreSnapshot()` creates a new sandbox from it. | diff --git a/packages/ai-sandbox-tangle/package.json b/packages/ai-sandbox-tangle/package.json index 160e715ced..14be371889 100644 --- a/packages/ai-sandbox-tangle/package.json +++ b/packages/ai-sandbox-tangle/package.json @@ -43,7 +43,7 @@ "test:types": "tsc" }, "dependencies": { - "@tangle-network/sandbox": "^1.0.313" + "@tangle-network/sandbox": "^1.0.314" }, "peerDependencies": { "@tanstack/ai-sandbox": "workspace:^" diff --git a/packages/ai-sandbox-tangle/src/handle.ts b/packages/ai-sandbox-tangle/src/handle.ts index 835d208be8..d09fc1bb71 100644 --- a/packages/ai-sandbox-tangle/src/handle.ts +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -5,9 +5,9 @@ * * Every command, blocking or not, goes through the runtime's process API * (`process.spawn`): it has no run limit (the one-shot exec API stops at 10 - * minutes), stdout and stderr arrive on separate streams, and `kill()` signals - * the process tree inside the sandbox. File reads and writes use the native - * file API; the rest of `fs` desugars to commands. + * minutes), stdout and stderr arrive separately, and `kill()` signals the + * process tree inside the sandbox. File reads and writes use the native file + * API; the rest of `fs` desugars to commands. */ import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' @@ -73,6 +73,8 @@ const SIGNAL_NUMBERS: Record = { 2: 'SIGINT', 3: 'SIGQUIT', 9: 'SIGKILL', + 10: 'SIGUSR1', + 12: 'SIGUSR2', 15: 'SIGTERM', } @@ -83,7 +85,7 @@ function processSignal(signal?: NodeJS.Signals | number): ProcessSignal { } /** POSIX single-quote escape for embedding text in `sh -c`. */ -function q(value: string): string { +export function q(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } @@ -91,10 +93,110 @@ function errText(r: { stdout: string; stderr: string }): string { return r.stderr.trim() || r.stdout.trim() || '(no output)' } -async function collect(source: AsyncIterable): Promise { - let out = '' - for await (const chunk of source) out += chunk - return out +/** Drops in a row, each within a minute, before a read gives up. */ +const MAX_QUICK_DROPS = 3 + +/** + * Run `attempt` again when it fails. A status poll or log stream through the + * edge can drop during a long silent command (measured: a status poll timed + * out at 3.5 minutes and the log stream ended at 4.2 minutes while the + * process ran on). An attempt that made progress, or ran for a minute, resets + * the count, so only a dead API ends the read. + */ +async function retrying( + attempt: (progress: () => void) => Promise, +): Promise { + for (let drops = 0; ;) { + const started = Date.now() + let progressed = false + try { + return await attempt(() => { + progressed = true + }) + } catch (error) { + if (error instanceof OutputLostError) throw error + drops = progressed || Date.now() - started > 60_000 ? 1 : drops + 1 + if (drops >= MAX_QUICK_DROPS) throw error + } + } +} + +/** The sandbox dropped output before it was read. */ +class OutputLostError extends Error { + constructor(missedBytes: number | undefined) { + super( + `tangle: the sandbox dropped ${missedBytes ?? 'some'} bytes of process output before they were read`, + ) + } +} + +/** + * Read a process's output from one log stream, split into stdout and stderr. + * A stream read again starts from the oldest output the sandbox kept, so the + * output already delivered is skipped. A `gap` is an error, not a silent cut. + */ +function readOutput( + proc: TangleProcess, + onChunk: (stream: 'stdout' | 'stderr', data: string) => void, +): Promise { + const delivered = { stdout: 0, stderr: 0 } + return retrying(async (progress) => { + const skip = { ...delivered } + for await (const entry of proc.logs()) { + if (entry.type === 'gap') throw new OutputLostError(entry.missedBytes) + const data = entry.data.slice(skip[entry.type]) + skip[entry.type] = Math.max(0, skip[entry.type] - entry.data.length) + if (data === '') continue + delivered[entry.type] += data.length + progress() + onChunk(entry.type, data) + } + }) +} + +/** A push-fed async iterable, so one log stream can feed stdout and stderr. */ +class ChunkQueue implements AsyncIterable { + private readonly chunks: Array = [] + private readonly waiters: Array<{ + resolve: (r: IteratorResult) => void + reject: (error: unknown) => void + }> = [] + private closed = false + private error: unknown + + push(chunk: string): void { + const waiter = this.waiters.shift() + if (waiter) waiter.resolve({ value: chunk, done: false }) + else this.chunks.push(chunk) + } + + close(error?: unknown): void { + this.closed = true + this.error = error + for (const waiter of this.waiters.splice(0)) { + if (error === undefined) waiter.resolve({ value: undefined, done: true }) + else waiter.reject(error) + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const chunk = this.chunks.shift() + if (chunk !== undefined) { + return Promise.resolve({ value: chunk, done: false }) + } + if (this.closed) { + return this.error === undefined + ? Promise.resolve({ value: undefined, done: true }) + : Promise.reject(this.error) + } + return new Promise((resolve, reject) => + this.waiters.push({ resolve, reject }), + ) + }, + } + } } /** A snapshot needs its source sandbox id to restore, so the ref carries both. */ @@ -187,7 +289,7 @@ export class TangleHandle implements SandboxHandle { }, list: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`ls -1Ap -- ${q(this.abs(p))}`) + const r = await this.exec(`ls -1Ap -- ${q(this.abs(p))}`, o) if (r.exitCode !== 0) throw new Error(`list failed: ${errText(r)}`) const base = p.replace(/\/$/, '') return r.stdout @@ -205,28 +307,29 @@ export class TangleHandle implements SandboxHandle { }, lstat: async (p, o) => { o?.signal?.throwIfAborted() - return this.lstat(this.abs(p)) + return this.lstat(this.abs(p), o?.signal) }, mkdir: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`mkdir -p -- ${q(this.abs(p))}`) + const r = await this.exec(`mkdir -p -- ${q(this.abs(p))}`, o) if (r.exitCode !== 0) throw new Error(`mkdir failed: ${errText(r)}`) }, remove: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`rm -rf -- ${q(this.abs(p))}`) + const r = await this.exec(`rm -rf -- ${q(this.abs(p))}`, o) if (r.exitCode !== 0) throw new Error(`remove failed: ${errText(r)}`) }, rename: async (from, to, o) => { o?.signal?.throwIfAborted() const r = await this.exec( `mv -- ${q(this.abs(from))} ${q(this.abs(to))}`, + o, ) if (r.exitCode !== 0) throw new Error(`rename failed: ${errText(r)}`) }, exists: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`test -e ${q(this.abs(p))}`) + const r = await this.exec(`test -e ${q(this.abs(p))}`, o) return r.exitCode === 0 }, } @@ -266,17 +369,21 @@ export class TangleHandle implements SandboxHandle { } } - private async lstat(path: string): Promise { - const r = await this.exec(lstatCommand(path)) + private async lstat( + path: string, + signal?: AbortSignal, + ): Promise { + const r = await this.exec(lstatCommand(path), signal ? { signal } : {}) if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return undefined if (r.exitCode !== 0) throw new Error(`lstat failed: ${errText(r)}`) return parseLstatOutput(r.stdout) } /** - * Start `command` in `cwd`. The runtime only accepts a `cwd` inside the - * user's home, so the command changes directory itself. `timeoutMs: 0` turns - * the runtime's run limit off; the sandbox lifetime still bounds it. + * Start `command` in `cwd` (relative to the workdir). The runtime only + * accepts a `cwd` inside the user's home, so the shell changes directory + * itself, and a missing `cwd` fails the command. `timeoutMs: 0` turns the + * runtime's run limit off; the sandbox lifetime still bounds it. */ private start( command: string, @@ -284,21 +391,28 @@ export class TangleHandle implements SandboxHandle { ): Promise { opts?.signal?.throwIfAborted() const cwd = opts?.cwd ? this.abs(opts.cwd) : this.workdir + const dir = cwd.startsWith('/') ? cwd : `${this.workdir}/${cwd}` const env = { ...this.envVars, ...opts?.env } - return this.sandbox.process.spawn(`cd ${q(cwd)} && ${command}`, { - ...(Object.keys(env).length > 0 ? { env } : {}), - timeoutMs: 0, - }) + return this.sandbox.process.spawn( + `cd -- ${q(dir)} && exec sh -c ${q(command)}`, + { + ...(Object.keys(env).length > 0 ? { env } : {}), + timeoutMs: 0, + }, + ) } - /** Signal the whole process tree. A process that already exited is not an error. */ + /** Signal the whole process tree. The SDK treats an exited process as done. */ private kill( proc: TangleProcess, signal?: NodeJS.Signals | number, ): Promise { - return proc - .kill(processSignal(signal), { tree: true }) - .catch(() => undefined) + return proc.kill(processSignal(signal), { tree: true }) + } + + /** Stop a process on a failure path, where a kill error must not hide the cause. */ + private stop(proc: TangleProcess): Promise { + return this.kill(proc).catch(() => undefined) } /** @@ -312,21 +426,21 @@ export class TangleHandle implements SandboxHandle { ): Promise { const proc = await this.start(command, opts) const onAbort = (): void => { - void this.kill(proc) + void this.stop(proc) } opts?.signal?.addEventListener('abort', onAbort, { once: true }) // An abort that landed during the start round trip has no listener yet. if (opts?.signal?.aborted === true) onAbort() try { - const [stdout, stderr] = await Promise.all([ - collect(proc.stdout()), - collect(proc.stderr()), - ]) - return { stdout, stderr, exitCode: await proc.wait() } + const out = { stdout: '', stderr: '' } + await readOutput(proc, (stream, data) => { + out[stream] += data + }) + return { ...out, exitCode: await retrying(() => proc.wait()) } } catch (error) { - // A dropped stream says nothing about the process: stop it rather than - // leave it running until the sandbox expires. - await this.kill(proc) + // Lost output or a dead API: stop the process rather than leave it + // running until the sandbox expires. + await this.stop(proc) throw error } finally { opts?.signal?.removeEventListener('abort', onAbort) @@ -339,13 +453,27 @@ export class TangleHandle implements SandboxHandle { ): Promise { const proc = await this.start(command, opts) const onAbort = (): void => { - void this.kill(proc) + void this.stop(proc) } opts?.signal?.addEventListener('abort', onAbort, { once: true }) - const exit = proc - .wait() + const stdout = new ChunkQueue() + const stderr = new ChunkQueue() + void readOutput(proc, (stream, data) => { + ;(stream === 'stdout' ? stdout : stderr).push(data) + }).then( + () => { + stdout.close() + stderr.close() + }, + async (error: unknown) => { + stdout.close(error) + stderr.close(error) + await this.stop(proc) + }, + ) + const exit = retrying(() => proc.wait()) .catch(async (error: unknown) => { - await this.kill(proc) + await this.stop(proc) throw error }) .finally(() => { @@ -358,8 +486,8 @@ export class TangleHandle implements SandboxHandle { } return { pid: proc.pid, - stdout: proc.stdout(), - stderr: proc.stderr(), + stdout, + stderr, stdin: { write: () => Promise.reject( diff --git a/packages/ai-sandbox-tangle/src/provider.ts b/packages/ai-sandbox-tangle/src/provider.ts index 7764c542a3..6430bb3163 100644 --- a/packages/ai-sandbox-tangle/src/provider.ts +++ b/packages/ai-sandbox-tangle/src/provider.ts @@ -4,6 +4,7 @@ import { TANGLE_CAPS, TangleHandle, parseSnapshotRefId, + q, } from './handle' import type { CreateSandboxOptions, @@ -60,9 +61,18 @@ export const INSTANCE_KEY_METADATA = 'tanstack-ai-key' const DEFAULT_BASE_URL = 'https://sandbox.tangle.tools' -/** POSIX single-quote escape for embedding a path in a shell command. */ -function shQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` +/** + * The SDK sends the API key as a header to `baseUrl`, so a plain-http URL + * would leak it. Loopback is allowed for a local API. + */ +function assertHttps(baseUrl: string): void { + const url = new URL(baseUrl) + const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' + if (url.protocol !== 'https:' && !loopback) { + throw new Error( + `tangle: baseUrl must use https (got ${url.protocol}//${url.host}); the API key travels in a request header.`, + ) + } } /** A deleted sandbox can still be read for a while; it is gone for `resume`. */ @@ -120,10 +130,9 @@ class TangleProvider implements SandboxProvider { 'tangle: no API key. Pass `apiKey` or set the TANGLE_API_KEY env var.', ) } - this.sdk = new Sandbox({ - apiKey, - baseUrl: this.config.baseUrl ?? DEFAULT_BASE_URL, - }) + const baseUrl = this.config.baseUrl ?? DEFAULT_BASE_URL + assertHttps(baseUrl) + this.sdk = new Sandbox({ apiKey, baseUrl }) return this.sdk } @@ -141,6 +150,11 @@ class TangleProvider implements SandboxProvider { // No model credentials, coding-agent backend or startup scripts: the // workspace brings its own. agent: false, + // Nor the account's stored secrets. The API refuses `isolated` on a + // restore (measured: 400 VALIDATION_ERROR), so a restore inherits. + ...(input.snapshot === undefined + ? { ownerContext: 'isolated' as const } + : {}), ...(this.config.environment !== undefined ? { environment: this.config.environment } : {}), @@ -182,10 +196,13 @@ class TangleProvider implements SandboxProvider { ...(env !== undefined ? { env } : {}), }) try { - await sandbox.waitFor('running') + await sandbox.waitFor('running', signal ? { signal } : {}) // Not through the handle: its commands run in the workdir, which does // not exist yet. - const r = await sandbox.exec(`mkdir -p -- ${shQuote(this.workdir)}`) + const r = await sandbox.exec( + `mkdir -p -- ${q(this.workdir)}`, + signal ? { signal } : {}, + ) if (r.exitCode !== 0) { throw new Error(`tangle: could not create ${this.workdir}: ${r.stderr}`) } @@ -229,14 +246,15 @@ class TangleProvider implements SandboxProvider { } async resume(input: SandboxResumeInput): Promise { - input.signal?.throwIfAborted() + const { signal } = input + signal?.throwIfAborted() const sandbox = await this.client.get(input.id) if (sandbox === null || GONE_STATUSES.has(sandbox.status)) return null // A stopped or expired sandbox kept its workspace; start it again. if (sandbox.status === 'stopped' || sandbox.status === 'expired') { - await sandbox.resume() + await sandbox.resume(signal ? { signal } : {}) } - await sandbox.waitFor('running') + await sandbox.waitFor('running', signal ? { signal } : {}) return new TangleHandle({ sandbox, workdir: this.workdir }) } @@ -245,7 +263,7 @@ class TangleProvider implements SandboxProvider { const sandbox = await this.client.get(input.id) if (sandbox === null || sandbox.status === 'deleted') return // Resolves `already_absent` for a sandbox that is already gone. - await sandbox.delete() + await sandbox.delete(input.signal ? { signal: input.signal } : {}) } } diff --git a/packages/ai-sandbox-tangle/tests/tangle.test.ts b/packages/ai-sandbox-tangle/tests/tangle.test.ts index 620270d934..b5f711c848 100644 --- a/packages/ai-sandbox-tangle/tests/tangle.test.ts +++ b/packages/ai-sandbox-tangle/tests/tangle.test.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto' +import { Sandbox } from '@tangle-network/sandbox' import { describe, expect, it } from 'vitest' import { tangleSandbox } from '../src/index' import type { SandboxHandle } from '@tanstack/ai-sandbox' @@ -24,9 +26,18 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { expect(failed.stderr).toContain('nope') expect(failed.stdout).not.toContain('nope') - // A cwd outside the user's home works too. + // A cwd outside the user's home works too; a relative one resolves + // against the workdir, and a missing one fails the command. const tmp = await sbx.process.exec('pwd', { cwd: '/tmp' }) expect(tmp.stdout.trim()).toBe('/tmp') + await sbx.fs.mkdir('/workspace/sub') + const rel = await sbx.process.exec('pwd', { cwd: 'sub' }) + expect(rel.stdout.trim()).toBe('/home/agent/workspace/sub') + const missing = await sbx.process.exec('echo ran', { + cwd: '/nonexistent', + }) + expect(missing.exitCode).not.toBe(0) + expect(missing.stdout).not.toContain('ran') // Per-command env arrives, and env.set overlays it. await sbx.env.set({ BASE_VAR: 'base' }) @@ -156,7 +167,9 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { } }, 300_000) - it('snapshots a sandbox and restores it into a new one', async () => { + // `ensure()` restores only after `resume` found the source gone, so the + // source is destroyed first. + it('restores a snapshot into a new sandbox after its source is destroyed', async () => { const provider = tangleSandbox({ apiKey }) let source: SandboxHandle | undefined let restored: SandboxHandle | undefined @@ -167,6 +180,8 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { const ref = await source.snapshot?.('after-setup') expect(ref?.id).toBeTruthy() expect(ref?.label).toBe('after-setup') + await source.destroy() + expect(await provider.resume({ id: source.id })).toBeNull() restored = await provider.restoreSnapshot!({ snapshotId: ref!.id }) expect(restored.id).not.toBe(source.id) @@ -221,6 +236,7 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { 'curl -s -m 10 -o /dev/null -w "%{http_code}" https://example.com' let denied: SandboxHandle | undefined let control: SandboxHandle | undefined + let forked: SandboxHandle | undefined try { const [deniedRes, controlRes] = await Promise.allSettled([ provider.create({ policy: { capabilities: { network: 'deny' } } }), @@ -239,8 +255,41 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { expect((await denied!.process.exec('command -v curl')).exitCode).toBe(0) const blocked = await denied!.process.exec(PROBE) expect(blocked.stdout).not.toContain('200') + + // A fork keeps the blocked egress of its parent. + forked = await denied!.fork!() + expect((await forked.process.exec(PROBE)).stdout).not.toContain('200') } finally { - await Promise.allSettled([denied?.destroy(), control?.destroy()]) + await Promise.allSettled([ + denied?.destroy(), + control?.destroy(), + forked?.destroy(), + ]) } }, 600_000) + + // MEASURES the create-abort path: a sandbox the platform accepted after the + // caller gave up is deleted, not left running. + it('aborting create leaves no running sandbox behind', async () => { + const run = `tanstack-ai-abort-${randomUUID()}` + const provider = tangleSandbox({ apiKey, metadata: { run } }) + const controller = new AbortController() + const pending = provider.create({ signal: controller.signal }) + setTimeout(() => controller.abort(), 200) + await expect(pending).rejects.toThrow() + + const client = new Sandbox({ + apiKey: apiKey!, + baseUrl: 'https://sandbox.tangle.tools', + }) + let live = ['unchecked'] + for (let i = 0; i < 30 && live.length > 0; i++) { + await new Promise((r) => setTimeout(r, 2000)) + const listed = await client.list({ run }) + live = listed + .filter((s) => s.status !== 'deleted' && s.status !== 'failed') + .map((s) => s.id) + } + expect(live).toEqual([]) + }, 300_000) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eafe72b01c..92008a3c0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3651,7 +3651,7 @@ importers: packages/ai-sandbox-tangle: dependencies: '@tangle-network/sandbox': - specifier: ^1.0.313 + specifier: ^1.0.314 version: 1.0.314 devDependencies: '@tanstack/ai-sandbox': From d367bab7474104104e8902a9fc741b6b51bd14a0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 14:53:21 -0800 Subject: [PATCH 5/9] fix(ai-sandbox-tangle): align re-reads by byte position, cap spawned streams A re-read of a dropped log stream now skips by UTF-8 byte position across both streams and accepts a leading gap that only covers delivered output. Spawned streams stop at 8 MiB with a marker and kill the process. Aborted fs calls reject. The create-abort test waits for the platform to list the sandbox and deletes any it leaves open. --- docs/sandbox/providers-advanced.md | 6 +- packages/ai-sandbox-tangle/README.md | 5 +- packages/ai-sandbox-tangle/src/handle.ts | 97 +++++++++++++++---- .../ai-sandbox-tangle/tests/tangle.test.ts | 36 ++++--- 4 files changed, 105 insertions(+), 39 deletions(-) diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index c944feb55e..ce3c85d9a6 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -376,11 +376,11 @@ const tangle = tangleSandbox({ - **Isolation:** a managed [Tangle](https://tangle.tools) sandbox that you do not run. Pick the image with `environment`: a catalog environment or a container image reference. The default is the platform's default environment. A container image runs with a read-only root filesystem as the image's user, and `fork()` on it takes minutes (measured: 551 s for `node:22`, against 3 to 8 s on the default environment). - **Auth and env:** needs `TANGLE_API_KEY` (or `apiKey`). Set `baseUrl` for another endpoint; it must use HTTPS. Sandboxes are created without platform model credentials, startup scripts, or the account's stored secrets. A restore inherits the account's secrets, because the API refuses an isolated restore. Harness credentials are [workspace secrets](./provisioning) and travel as env for each command. They are not sent at create. - **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again. -- **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit (measured: a 10.5-minute command with an 8-minute silent stretch), and stdout and stderr stay separate. A dropped status poll or log stream is read again. Output that the sandbox dropped before it was read fails the command, so a result is never cut short without an error. A relative `cwd` resolves against the workdir, and a missing `cwd` fails the command. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`, and a command that reads stdin gets end of file. -- **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A snapshot outlives its source sandbox. A missing, `deleted`, or `failed` sandbox resumes as `null`. +- **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit (measured: a 10.5-minute command with an 8-minute silent stretch), and stdout and stderr stay separate. A dropped status poll or log stream is read again. Output that the sandbox dropped before it was read fails the command, so a result is never cut short without an error. A spawned process's `stdout` or `stderr` past 8 MiB is cut with a marker, and the process is killed. A relative `cwd` resolves against the workdir, and a missing `cwd` fails the command. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`, and a command that reads stdin gets end of file. +- **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A restore works after the source sandbox is destroyed (measured a few seconds after the delete). A missing, `deleted`, or `failed` sandbox resumes as `null`. - **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. - **Ports:** `ports.connect(port)` creates a preview link for the port, or reuses the existing one, and returns its HTTPS URL. The URL is public: anyone who has it can reach the port. -- **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. The platform's own default, `strict`, allows only package registries and code hosts, so a harness that calls a model API needs `open` or `allowDomains`. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. +- **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. The platform's own default, `strict`, allows only package registries and code hosts, so a harness that calls a model API needs `open` or `allowDomains`. The provider always sends an egress policy, so a team default policy does not apply. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. - **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it, and keep it under `/home/agent` or `/tmp`. Commands run as the `agent` user on the default environment. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`. - **Bridge:** a remote sandbox, so bridged tools need the tunnel in local dev. See [Tools](./tools). diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md index e31334117b..3dafa99993 100644 --- a/packages/ai-sandbox-tangle/README.md +++ b/packages/ai-sandbox-tangle/README.md @@ -103,7 +103,7 @@ try { | `ports` | ✅ | `ports.connect(port)` creates a preview link and returns its public HTTPS URL. | | `snapshots` | ✅ | `snapshot()` captures the workspace. `restoreSnapshot()` creates a new sandbox from it. | | `durableFilesystem` | ✅ | The workspace persists across stop and resume. | -| `backgroundProcesses` | ✅ | `spawn()` returns a real sandbox pid and streams `stdout` and `stderr` separately. | +| `backgroundProcesses` | ✅ | `spawn()` returns a real pid and separate streams. Past 8 MiB a stream is cut and the process killed. | | `writableStdin` | ❌ | The process API takes no stdin stream; harnesses get their prompt from a file. | | `killableProcesses` | ✅ | `kill()` signals the process tree inside the sandbox, measured against a live sandbox. | | `networkPolicy` | ✅ | `policy.capabilities.network: 'deny'` maps to egress mode `blocked`. | @@ -111,3 +111,6 @@ try { The native file calls reach the sandbox user's home (`/home/agent`) and `/tmp`. Read other paths with `process.exec`. + +A new sandbox is created without the account's stored secrets. A restore from +a snapshot inherits them, because the API refuses an isolated restore. diff --git a/packages/ai-sandbox-tangle/src/handle.ts b/packages/ai-sandbox-tangle/src/handle.ts index d09fc1bb71..ff6fdf11a6 100644 --- a/packages/ai-sandbox-tangle/src/handle.ts +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -132,29 +132,50 @@ class OutputLostError extends Error { /** * Read a process's output from one log stream, split into stdout and stderr. - * A stream read again starts from the oldest output the sandbox kept, so the - * output already delivered is skipped. A `gap` is an error, not a silent cut. + * Positions count UTF-8 bytes across both streams in log order. A stream read + * again replays from the oldest output the sandbox kept, after a `gap` for + * what it evicted, so output before the delivered position is skipped. A gap + * that reaches past that position lost output, and that is an error. */ function readOutput( proc: TangleProcess, onChunk: (stream: 'stdout' | 'stderr', data: string) => void, ): Promise { - const delivered = { stdout: 0, stderr: 0 } + let delivered = 0 return retrying(async (progress) => { - const skip = { ...delivered } + let at = 0 for await (const entry of proc.logs()) { - if (entry.type === 'gap') throw new OutputLostError(entry.missedBytes) - const data = entry.data.slice(skip[entry.type]) - skip[entry.type] = Math.max(0, skip[entry.type] - entry.data.length) - if (data === '') continue - delivered[entry.type] += data.length - progress() - onChunk(entry.type, data) + if (entry.type === 'gap') { + if (entry.missedBytes === undefined) { + throw new OutputLostError(undefined) + } + at += entry.missedBytes + if (at > delivered) throw new OutputLostError(at - delivered) + continue + } + const bytes = Buffer.from(entry.data) + const end = at + bytes.length + if (end > delivered) { + const fresh = + at >= delivered + ? entry.data + : bytes.subarray(delivered - at).toString() + delivered = end + progress() + onChunk(entry.type, fresh) + } + at = end } }) } -/** A push-fed async iterable, so one log stream can feed stdout and stderr. */ +/** Byte cap per spawned stream; past it the stream is cut and the process killed. */ +const MAX_STREAM_BYTES = 8 * 1024 * 1024 + +/** + * A push-fed async iterable with a byte cap, so one log stream can feed + * stdout and stderr without holding unread output without limit. + */ class ChunkQueue implements AsyncIterable { private readonly chunks: Array = [] private readonly waiters: Array<{ @@ -163,14 +184,35 @@ class ChunkQueue implements AsyncIterable { }> = [] private closed = false private error: unknown + private bytes = 0 + + constructor( + private readonly label: string, + private readonly onOverflow: () => void, + ) {} push(chunk: string): void { + if (this.closed) return + this.bytes += Buffer.byteLength(chunk) + if (this.bytes > MAX_STREAM_BYTES) { + this.emit( + `\n[tangle] ${this.label} exceeded ${MAX_STREAM_BYTES} bytes; output truncated\n`, + ) + this.close() + this.onOverflow() + return + } + this.emit(chunk) + } + + private emit(chunk: string): void { const waiter = this.waiters.shift() if (waiter) waiter.resolve({ value: chunk, done: false }) else this.chunks.push(chunk) } close(error?: unknown): void { + if (this.closed) return this.closed = true this.error = error for (const waiter of this.waiters.splice(0)) { @@ -289,7 +331,7 @@ export class TangleHandle implements SandboxHandle { }, list: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`ls -1Ap -- ${q(this.abs(p))}`, o) + const r = await this.fsExec(`ls -1Ap -- ${q(this.abs(p))}`, o?.signal) if (r.exitCode !== 0) throw new Error(`list failed: ${errText(r)}`) const base = p.replace(/\/$/, '') return r.stdout @@ -311,25 +353,25 @@ export class TangleHandle implements SandboxHandle { }, mkdir: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`mkdir -p -- ${q(this.abs(p))}`, o) + const r = await this.fsExec(`mkdir -p -- ${q(this.abs(p))}`, o?.signal) if (r.exitCode !== 0) throw new Error(`mkdir failed: ${errText(r)}`) }, remove: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`rm -rf -- ${q(this.abs(p))}`, o) + const r = await this.fsExec(`rm -rf -- ${q(this.abs(p))}`, o?.signal) if (r.exitCode !== 0) throw new Error(`remove failed: ${errText(r)}`) }, rename: async (from, to, o) => { o?.signal?.throwIfAborted() - const r = await this.exec( + const r = await this.fsExec( `mv -- ${q(this.abs(from))} ${q(this.abs(to))}`, - o, + o?.signal, ) if (r.exitCode !== 0) throw new Error(`rename failed: ${errText(r)}`) }, exists: async (p, o) => { o?.signal?.throwIfAborted() - const r = await this.exec(`test -e ${q(this.abs(p))}`, o) + const r = await this.fsExec(`test -e ${q(this.abs(p))}`, o?.signal) return r.exitCode === 0 }, } @@ -348,6 +390,16 @@ export class TangleHandle implements SandboxHandle { } } + /** A command for an `fs` call: an abort rejects, as the contract asks. */ + private async fsExec( + command: string, + signal: AbortSignal | undefined, + ): Promise { + const r = await this.exec(command, signal ? { signal } : {}) + signal?.throwIfAborted() + return r + } + /** Map the conventional `/workspace` virtual root to the sandbox workdir. */ private abs(p: string): string { if (this.workdir === '/workspace') return p @@ -373,7 +425,7 @@ export class TangleHandle implements SandboxHandle { path: string, signal?: AbortSignal, ): Promise { - const r = await this.exec(lstatCommand(path), signal ? { signal } : {}) + const r = await this.fsExec(lstatCommand(path), signal) if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return undefined if (r.exitCode !== 0) throw new Error(`lstat failed: ${errText(r)}`) return parseLstatOutput(r.stdout) @@ -456,8 +508,11 @@ export class TangleHandle implements SandboxHandle { void this.stop(proc) } opts?.signal?.addEventListener('abort', onAbort, { once: true }) - const stdout = new ChunkQueue() - const stderr = new ChunkQueue() + const onOverflow = (): void => { + void this.stop(proc) + } + const stdout = new ChunkQueue('stdout', onOverflow) + const stderr = new ChunkQueue('stderr', onOverflow) void readOutput(proc, (stream, data) => { ;(stream === 'stdout' ? stdout : stderr).push(data) }).then( diff --git a/packages/ai-sandbox-tangle/tests/tangle.test.ts b/packages/ai-sandbox-tangle/tests/tangle.test.ts index b5f711c848..2b63b03974 100644 --- a/packages/ai-sandbox-tangle/tests/tangle.test.ts +++ b/packages/ai-sandbox-tangle/tests/tangle.test.ts @@ -269,27 +269,35 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { }, 600_000) // MEASURES the create-abort path: a sandbox the platform accepted after the - // caller gave up is deleted, not left running. + // caller gave up is deleted, not left running. The SDK create takes no + // signal, so the platform always receives the request. it('aborting create leaves no running sandbox behind', async () => { const run = `tanstack-ai-abort-${randomUUID()}` const provider = tangleSandbox({ apiKey, metadata: { run } }) - const controller = new AbortController() - const pending = provider.create({ signal: controller.signal }) - setTimeout(() => controller.abort(), 200) - await expect(pending).rejects.toThrow() - const client = new Sandbox({ apiKey: apiKey!, baseUrl: 'https://sandbox.tangle.tools', }) - let live = ['unchecked'] - for (let i = 0; i < 30 && live.length > 0; i++) { - await new Promise((r) => setTimeout(r, 2000)) - const listed = await client.list({ run }) - live = listed - .filter((s) => s.status !== 'deleted' && s.status !== 'failed') - .map((s) => s.id) + const controller = new AbortController() + const pending = provider.create({ signal: controller.signal }) + setTimeout(() => controller.abort(), 200) + let generations: Awaited> = [] + try { + await expect(pending).rejects.toThrow() + // Wait until the platform lists the sandbox, then until it is closed. + for (let i = 0; i < 60; i++) { + generations = await client.runGenerations(run) + if (generations.length > 0 && generations.every((g) => g.closedAt)) { + break + } + await new Promise((r) => setTimeout(r, 2000)) + } + expect(generations.length).toBeGreaterThan(0) + expect(generations.filter((g) => !g.closedAt)).toEqual([]) + } finally { + for (const g of generations.filter((x) => !x.closedAt)) { + await client.delete(g.sandboxId).catch(() => undefined) + } } - expect(live).toEqual([]) }, 300_000) }) From 648abf90c6ae97f3e1d3c261c261a0c1baca4adf Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 15:07:36 -0800 Subject: [PATCH 6/9] test(ai-sandbox-tangle): check the aborted create was deleted and clean up a create that wins --- packages/ai-sandbox-tangle/src/handle.ts | 3 ++- packages/ai-sandbox-tangle/tests/tangle.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/ai-sandbox-tangle/src/handle.ts b/packages/ai-sandbox-tangle/src/handle.ts index ff6fdf11a6..241cd39be2 100644 --- a/packages/ai-sandbox-tangle/src/handle.ts +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -132,7 +132,8 @@ class OutputLostError extends Error { /** * Read a process's output from one log stream, split into stdout and stderr. - * Positions count UTF-8 bytes across both streams in log order. A stream read + * Positions count UTF-8 bytes across both streams in log order. They match the + * server's `missedBytes` only for UTF-8 output; other bytes arrive decoded. A stream read * again replays from the oldest output the sandbox kept, after a `gap` for * what it evicted, so output before the delivered position is skipped. A gap * that reaches past that position lost output, and that is an error. diff --git a/packages/ai-sandbox-tangle/tests/tangle.test.ts b/packages/ai-sandbox-tangle/tests/tangle.test.ts index 2b63b03974..e13f949694 100644 --- a/packages/ai-sandbox-tangle/tests/tangle.test.ts +++ b/packages/ai-sandbox-tangle/tests/tangle.test.ts @@ -294,7 +294,16 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { } expect(generations.length).toBeGreaterThan(0) expect(generations.filter((g) => !g.closedAt)).toEqual([]) + // Closed could also mean stopped: the sandbox must be gone. + for (const g of generations) { + const sandbox = await client.get(g.sandboxId) + expect(sandbox === null || sandbox.status === 'deleted').toBe(true) + } } finally { + // A create that beat the abort leaves an open sandbox to delete. + if (generations.length === 0) { + generations = await client.runGenerations(run).catch(() => []) + } for (const g of generations.filter((x) => !x.closedAt)) { await client.delete(g.sandboxId).catch(() => undefined) } From 006e65bfc4c59cfa14b1f85b36a3ef190ca40712 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 16:15:07 -0800 Subject: [PATCH 7/9] fix(ai-sandbox-tangle): retry a fork parent's delete, start a server in the README preview example The API refuses to delete a fork parent while a fork exists (CHILDREN_EXIST) and leaves the parent stopped, so destroying parent and fork at once leaked the parent. destroy() now retries that refusal for 10 seconds, and the live tests check that every destroy succeeds and both sandboxes are gone. Also adds short docs to the remaining functions. --- docs/sandbox/providers-advanced.md | 2 +- packages/ai-sandbox-tangle/README.md | 2 + packages/ai-sandbox-tangle/src/handle.ts | 39 ++++++++++++++++++- packages/ai-sandbox-tangle/src/provider.ts | 8 +++- .../ai-sandbox-tangle/tests/tangle.test.ts | 12 +++++- 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index ce3c85d9a6..11a6b7d6b7 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -378,7 +378,7 @@ const tangle = tangleSandbox({ - **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again. - **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit (measured: a 10.5-minute command with an 8-minute silent stretch), and stdout and stderr stay separate. A dropped status poll or log stream is read again. Output that the sandbox dropped before it was read fails the command, so a result is never cut short without an error. A spawned process's `stdout` or `stderr` past 8 MiB is cut with a marker, and the process is killed. A relative `cwd` resolves against the workdir, and a missing `cwd` fails the command. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`, and a command that reads stdin gets end of file. - **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A restore works after the source sandbox is destroyed (measured a few seconds after the delete). A missing, `deleted`, or `failed` sandbox resumes as `null`. -- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. +- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. The API refuses to delete a parent while a fork of it exists, so `destroy()` on a parent tries again for 10 seconds while its forks are destroyed. - **Ports:** `ports.connect(port)` creates a preview link for the port, or reuses the existing one, and returns its HTTPS URL. The URL is public: anyone who has it can reach the port. - **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. The platform's own default, `strict`, allows only package registries and code hosts, so a harness that calls a model API needs `open` or `allowDomains`. The provider always sends an egress policy, so a team default policy does not apply. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. - **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it, and keep it under `/home/agent` or `/tmp`. Commands run as the `agent` user on the default environment. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`. diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md index 3dafa99993..9eaf27e3a7 100644 --- a/packages/ai-sandbox-tangle/README.md +++ b/packages/ai-sandbox-tangle/README.md @@ -72,8 +72,10 @@ try { const run = await sbx.process.exec('node --version') console.log('node', run.stdout.trim(), '(exit', run.exitCode, ')') + const server = await sbx.process.spawn('python3 -m http.server 3000') const channel = await sbx.ports.connect(3000) console.log('preview url:', channel.url) + await server.kill() } finally { await sbx.destroy() } diff --git a/packages/ai-sandbox-tangle/src/handle.ts b/packages/ai-sandbox-tangle/src/handle.ts index 241cd39be2..fb6e8735c8 100644 --- a/packages/ai-sandbox-tangle/src/handle.ts +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -89,6 +89,7 @@ export function q(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } +/** The most useful text of a failed command for an error message. */ function errText(r: { stdout: string; stderr: string }): string { return r.stderr.trim() || r.stdout.trim() || '(no output)' } @@ -192,6 +193,7 @@ class ChunkQueue implements AsyncIterable { private readonly onOverflow: () => void, ) {} + /** Queue a chunk; past the byte cap, emit the marker, close, and overflow. */ push(chunk: string): void { if (this.closed) return this.bytes += Buffer.byteLength(chunk) @@ -206,12 +208,14 @@ class ChunkQueue implements AsyncIterable { this.emit(chunk) } + /** Hand a chunk to a waiting reader, or keep it for the next one. */ private emit(chunk: string): void { const waiter = this.waiters.shift() if (waiter) waiter.resolve({ value: chunk, done: false }) else this.chunks.push(chunk) } + /** End the stream once: readers finish, or reject with `error`. */ close(error?: unknown): void { if (this.closed) return this.closed = true @@ -242,11 +246,37 @@ class ChunkQueue implements AsyncIterable { } } +/** Delete attempts, a second apart, while a fork parent's children go away. */ +const DELETE_ATTEMPTS = 10 + +/** + * Delete a sandbox. The API refuses to delete a fork parent while a fork of it + * exists (measured: `CHILDREN_EXIST` when parent and fork are destroyed at + * once, and the parent is left stopped), so that delete is tried again. + */ +export async function deleteSandbox( + sandbox: SandboxInstance, + signal?: AbortSignal, +): Promise { + for (let attempt = 1; ; attempt++) { + try { + await sandbox.delete(signal ? { signal } : {}) + return + } catch (error) { + const code = (error as { code?: unknown } | null)?.code + if (code !== 'CHILDREN_EXIST' || attempt >= DELETE_ATTEMPTS) throw error + await new Promise((r) => setTimeout(r, 1000)) + signal?.throwIfAborted() + } + } +} + /** A snapshot needs its source sandbox id to restore, so the ref carries both. */ export function snapshotRefId(sandboxId: string, snapshotId: string): string { return `${sandboxId}:${snapshotId}` } +/** Split a snapshot ref id back into its source sandbox id and snapshot id. */ export function parseSnapshotRefId(id: string): { sandboxId: string snapshotId: string @@ -422,6 +452,7 @@ export class TangleHandle implements SandboxHandle { } } + /** `lstat` through a command; a missing path is `undefined`. */ private async lstat( path: string, signal?: AbortSignal, @@ -500,6 +531,10 @@ export class TangleHandle implements SandboxHandle { } } + /** + * Start a background process. One log stream feeds the capped `stdout` and + * `stderr` queues; lost output or a dead API kills the process. + */ private async spawnProcess( command: string, opts?: ProcessOptions, @@ -600,8 +635,9 @@ export class TangleHandle implements SandboxHandle { }) } + /** Delete the sandbox, waiting out a fork that is being destroyed at the same time. */ async destroy(): Promise { - await this.sandbox.delete() + await deleteSandbox(this.sandbox) } } @@ -612,6 +648,7 @@ function lstatCommand(path: string): string { return `tanstack_lstat_path=${q(path)}; tanstack_lstat_output=$(stat -c '%f:%s' -- "$tanstack_lstat_path" 2>&1); tanstack_lstat_status=$?; if [ "$tanstack_lstat_status" -eq 0 ]; then printf '%s\n' "$tanstack_lstat_output"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case "$tanstack_missing_path" in /|.) return 1 ;; */*) tanstack_parent=${'$'}{tanstack_missing_path%/*}; tanstack_name=${'$'}{tanstack_missing_path##*/}; [ -n "$tanstack_parent" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- "$tanstack_parent" 2>/dev/null); tanstack_parent_status=$?; if [ "$tanstack_parent_status" -ne 0 ]; then tanstack_lstat_missing "$tanstack_parent"; else case "$tanstack_parent_mode" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case "$tanstack_parent" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H "$tanstack_find_parent" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ "${'$'}{tanstack_candidate##*/}" = "$tanstack_target" ] && { printf 1; exit 0; }; done; exit 0' sh "$tanstack_name" '{}' + 2>/dev/null); tanstack_find_status=$?; [ "$tanstack_find_status" -eq 0 ] && [ -z "$tanstack_match" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing "$tanstack_lstat_path"; then printf '%s' '${LSTAT_MISSING}'; else printf '%s\n' "$tanstack_lstat_output" >&2; exit "$tanstack_lstat_status"; fi; fi` } +/** Parse `stat -c '%f:%s'` output into a typed stat. */ function parseLstatOutput(output: string): SandboxFsStat { const fields = /^(?[0-9a-fA-F]{4}):(?\d+)\n?$/.exec(output) const mode = fields?.groups?.mode diff --git a/packages/ai-sandbox-tangle/src/provider.ts b/packages/ai-sandbox-tangle/src/provider.ts index 6430bb3163..ce4e37396b 100644 --- a/packages/ai-sandbox-tangle/src/provider.ts +++ b/packages/ai-sandbox-tangle/src/provider.ts @@ -3,6 +3,7 @@ import { DEFAULT_WORKDIR, TANGLE_CAPS, TangleHandle, + deleteSandbox, parseSnapshotRefId, q, } from './handle' @@ -136,6 +137,7 @@ class TangleProvider implements SandboxProvider { return this.sdk } + /** The SDK create options for a fresh sandbox or a snapshot restore. */ private createOptions(input: { id?: string policy?: SandboxPolicy @@ -217,6 +219,7 @@ class TangleProvider implements SandboxProvider { return handle } + /** Create a sandbox and its workdir; `input.id` rides along as metadata. */ async create(input: SandboxCreateInput): Promise { const sandbox = await createSandbox( this.client, @@ -231,6 +234,7 @@ class TangleProvider implements SandboxProvider { return this.prepare(sandbox, input.env, input.signal) } + /** Create a new sandbox from a snapshot ref that `snapshot()` returned. */ async restoreSnapshot(input: SandboxRestoreInput): Promise { // `SandboxRestoreInput` carries a policy too. Dropping it would restore a // snapshot taken under `network: 'deny'` into a sandbox with open egress. @@ -245,6 +249,7 @@ class TangleProvider implements SandboxProvider { return this.prepare(sandbox, input.env, input.signal) } + /** Reconnect to a sandbox by id, starting it if it stopped; `null` once it is gone. */ async resume(input: SandboxResumeInput): Promise { const { signal } = input signal?.throwIfAborted() @@ -258,12 +263,13 @@ class TangleProvider implements SandboxProvider { return new TangleHandle({ sandbox, workdir: this.workdir }) } + /** Delete a sandbox by id; one that is already gone is not an error. */ async destroy(input: SandboxDestroyInput): Promise { input.signal?.throwIfAborted() const sandbox = await this.client.get(input.id) if (sandbox === null || sandbox.status === 'deleted') return // Resolves `already_absent` for a sandbox that is already gone. - await sandbox.delete(input.signal ? { signal: input.signal } : {}) + await deleteSandbox(sandbox, input.signal) } } diff --git a/packages/ai-sandbox-tangle/tests/tangle.test.ts b/packages/ai-sandbox-tangle/tests/tangle.test.ts index e13f949694..7a95c9f577 100644 --- a/packages/ai-sandbox-tangle/tests/tangle.test.ts +++ b/packages/ai-sandbox-tangle/tests/tangle.test.ts @@ -209,8 +209,15 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { await forked.fs.write('/workspace/only-in-fork.txt', 'x') expect(await src.fs.exists('/workspace/only-in-fork.txt')).toBe(false) } finally { - await Promise.allSettled([src?.destroy(), forked?.destroy()]) + // Parent and fork at once: the parent's delete waits out the fork. + const destroyed = await Promise.allSettled([ + src?.destroy(), + forked?.destroy(), + ]) + expect(destroyed.filter((r) => r.status === 'rejected')).toEqual([]) } + expect(await provider.resume({ id: src!.id })).toBeNull() + expect(await provider.resume({ id: forked!.id })).toBeNull() }, 600_000) it('resume reconnects to a live sandbox and returns null once it is destroyed', async () => { @@ -260,11 +267,12 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { forked = await denied!.fork!() expect((await forked.process.exec(PROBE)).stdout).not.toContain('200') } finally { - await Promise.allSettled([ + const destroyed = await Promise.allSettled([ denied?.destroy(), control?.destroy(), forked?.destroy(), ]) + expect(destroyed.filter((r) => r.status === 'rejected')).toEqual([]) } }, 600_000) From 7f72f36839567a4ff2d19ab4ba592fea75251069 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 16:29:43 -0800 Subject: [PATCH 8/9] fix(ai-sandbox-tangle): bound a fork parent's delete retry by time and say to destroy forks first A parent cannot be deleted while a fork of it exists. destroy() now retries for 30 s instead of 10 attempts, then rejects with an error that names the cause. The fork test destroys the parent alone first and checks that error. Test asserts move out of finally blocks. --- docs/sandbox/providers-advanced.md | 2 +- packages/ai-sandbox-tangle/README.md | 4 +++ packages/ai-sandbox-tangle/src/handle.ts | 22 ++++++++++---- .../ai-sandbox-tangle/tests/tangle.test.ts | 30 +++++++++++++------ 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index 11a6b7d6b7..5a67ac203d 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -378,7 +378,7 @@ const tangle = tangleSandbox({ - **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again. - **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit (measured: a 10.5-minute command with an 8-minute silent stretch), and stdout and stderr stay separate. A dropped status poll or log stream is read again. Output that the sandbox dropped before it was read fails the command, so a result is never cut short without an error. A spawned process's `stdout` or `stderr` past 8 MiB is cut with a marker, and the process is killed. A relative `cwd` resolves against the workdir, and a missing `cwd` fails the command. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`, and a command that reads stdin gets end of file. - **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A restore works after the source sandbox is destroyed (measured a few seconds after the delete). A missing, `deleted`, or `failed` sandbox resumes as `null`. -- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. The API refuses to delete a parent while a fork of it exists, so `destroy()` on a parent tries again for 10 seconds while its forks are destroyed. +- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. A parent cannot be destroyed while a fork of it exists: the API stops the parent and refuses the delete. `destroy()` on a parent tries again for 30 seconds, which covers a parent and its forks destroyed at once, then rejects with an error that says to destroy the forks first. Destroy the forks first; destroying the parent again then works. - **Ports:** `ports.connect(port)` creates a preview link for the port, or reuses the existing one, and returns its HTTPS URL. The URL is public: anyone who has it can reach the port. - **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. The platform's own default, `strict`, allows only package registries and code hosts, so a harness that calls a model API needs `open` or `allowDomains`. The provider always sends an egress policy, so a team default policy does not apply. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. - **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it, and keep it under `/home/agent` or `/tmp`. Commands run as the `agent` user on the default environment. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`. diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md index 9eaf27e3a7..6cbb2dcc36 100644 --- a/packages/ai-sandbox-tangle/README.md +++ b/packages/ai-sandbox-tangle/README.md @@ -116,3 +116,7 @@ The native file calls reach the sandbox user's home (`/home/agent`) and A new sandbox is created without the account's stored secrets. A restore from a snapshot inherits them, because the API refuses an isolated restore. + +A parent cannot be destroyed while a fork of it exists. The API stops the +parent and refuses the delete; `destroy()` tries again for 30 seconds, then +rejects. Destroy the forks first, or destroy them together with the parent. diff --git a/packages/ai-sandbox-tangle/src/handle.ts b/packages/ai-sandbox-tangle/src/handle.ts index fb6e8735c8..37492ca60f 100644 --- a/packages/ai-sandbox-tangle/src/handle.ts +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -246,25 +246,35 @@ class ChunkQueue implements AsyncIterable { } } -/** Delete attempts, a second apart, while a fork parent's children go away. */ -const DELETE_ATTEMPTS = 10 +/** How long a fork parent's delete is tried again while its forks go away. */ +const DELETE_RETRY_MS = 30_000 /** * Delete a sandbox. The API refuses to delete a fork parent while a fork of it - * exists (measured: `CHILDREN_EXIST` when parent and fork are destroyed at - * once, and the parent is left stopped), so that delete is tried again. + * exists: it stops the parent and answers `CHILDREN_EXIST`. Measured: the + * first refusal takes about 1 s and each later one 8 s, and once the fork's + * delete returns the parent's delete succeeds on the next try. So the delete + * is tried again for 30 s, which covers a parent and its forks destroyed at + * once, and then fails with an error that says to destroy the forks first. */ export async function deleteSandbox( sandbox: SandboxInstance, signal?: AbortSignal, ): Promise { - for (let attempt = 1; ; attempt++) { + const started = Date.now() + for (;;) { try { await sandbox.delete(signal ? { signal } : {}) return } catch (error) { const code = (error as { code?: unknown } | null)?.code - if (code !== 'CHILDREN_EXIST' || attempt >= DELETE_ATTEMPTS) throw error + if (code !== 'CHILDREN_EXIST') throw error + if (Date.now() - started >= DELETE_RETRY_MS) { + throw new Error( + `tangle: sandbox ${sandbox.id} has a live fork; destroy the fork, then destroy this sandbox again`, + { cause: error }, + ) + } await new Promise((r) => setTimeout(r, 1000)) signal?.throwIfAborted() } diff --git a/packages/ai-sandbox-tangle/tests/tangle.test.ts b/packages/ai-sandbox-tangle/tests/tangle.test.ts index 7a95c9f577..a6ca5ffc06 100644 --- a/packages/ai-sandbox-tangle/tests/tangle.test.ts +++ b/packages/ai-sandbox-tangle/tests/tangle.test.ts @@ -193,11 +193,14 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { } }, 600_000) - // MEASURES fork: the copy carries state and then diverges. + // MEASURES fork: the copy carries state and then diverges, and a parent + // cannot be destroyed while its fork exists. it('fork branches a sandbox from current state', async () => { const provider = tangleSandbox({ apiKey }) let src: SandboxHandle | undefined let forked: SandboxHandle | undefined + let parentAlone: unknown + let destroyed: Array> = [] try { src = await provider.create({}) await src.fs.write('/workspace/before-fork.txt', 'carried over') @@ -208,14 +211,21 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { ) await forked.fs.write('/workspace/only-in-fork.txt', 'x') expect(await src.fs.exists('/workspace/only-in-fork.txt')).toBe(false) + parentAlone = await src.destroy().then( + () => 'destroyed', + (error: unknown) => error, + ) } finally { - // Parent and fork at once: the parent's delete waits out the fork. - const destroyed = await Promise.allSettled([ - src?.destroy(), - forked?.destroy(), - ]) - expect(destroyed.filter((r) => r.status === 'rejected')).toEqual([]) + // The fork first, then the parent. + destroyed = await Promise.allSettled([forked?.destroy()]) + destroyed.push(...(await Promise.allSettled([src?.destroy()]))) } + expect(parentAlone).toBeInstanceOf(Error) + expect((parentAlone as Error).message).toContain('has a live fork') + expect(((parentAlone as Error).cause as { code?: string }).code).toBe( + 'CHILDREN_EXIST', + ) + expect(destroyed.filter((r) => r.status === 'rejected')).toEqual([]) expect(await provider.resume({ id: src!.id })).toBeNull() expect(await provider.resume({ id: forked!.id })).toBeNull() }, 600_000) @@ -244,6 +254,7 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { let denied: SandboxHandle | undefined let control: SandboxHandle | undefined let forked: SandboxHandle | undefined + let destroyed: Array> = [] try { const [deniedRes, controlRes] = await Promise.allSettled([ provider.create({ policy: { capabilities: { network: 'deny' } } }), @@ -267,13 +278,14 @@ describe.skipIf(!apiKey)('tangle provider (gated on TANGLE_API_KEY)', () => { forked = await denied!.fork!() expect((await forked.process.exec(PROBE)).stdout).not.toContain('200') } finally { - const destroyed = await Promise.allSettled([ + // Parent and fork at once: the parent's delete waits out the fork. + destroyed = await Promise.allSettled([ denied?.destroy(), control?.destroy(), forked?.destroy(), ]) - expect(destroyed.filter((r) => r.status === 'rejected')).toEqual([]) } + expect(destroyed.filter((r) => r.status === 'rejected')).toEqual([]) }, 600_000) // MEASURES the create-abort path: a sandbox the platform accepted after the From 7ff1edd84cfc9317b624d4ed9738dfb0c567aa17 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 9 Nov 2026 16:34:50 -0800 Subject: [PATCH 9/9] docs(ai-sandbox-tangle): state the measured fork-parent destroy time and that the parent stays stopped --- docs/sandbox/providers-advanced.md | 2 +- packages/ai-sandbox-tangle/README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/sandbox/providers-advanced.md b/docs/sandbox/providers-advanced.md index 5a67ac203d..08fff4af36 100644 --- a/docs/sandbox/providers-advanced.md +++ b/docs/sandbox/providers-advanced.md @@ -378,7 +378,7 @@ const tangle = tangleSandbox({ - **Lifetime:** the platform suspends a sandbox after `maxLifetimeSeconds` of run time or `idleTimeoutSeconds` without activity, and keeps the workspace. If you omit them, the platform defaults apply. Resume by id starts a stopped or expired sandbox again. - **Processes:** every command, blocking or not, runs through the runtime process API. It has no run limit (measured: a 10.5-minute command with an 8-minute silent stretch), and stdout and stderr stay separate. A dropped status poll or log stream is read again. Output that the sandbox dropped before it was read fails the command, so a result is never cut short without an error. A spawned process's `stdout` or `stderr` past 8 MiB is cut with a marker, and the process is killed. A relative `cwd` resolves against the workdir, and a missing `cwd` fails the command. `kill()` signals the process tree inside the sandbox, and an aborted `exec` does the same. The requested signal is used when the runtime supports it (`TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, `USR2`), and anything else is `KILL`. The process API takes no stdin stream, so `writableStdin` is `false`, and a command that reads stdin gets end of file. - **Snapshot and resume:** `snapshot()` captures the workspace. The returned id names the snapshot and its source sandbox, and `restoreSnapshot()` creates a new sandbox from it. A restore works after the source sandbox is destroyed (measured a few seconds after the delete). A missing, `deleted`, or `failed` sandbox resumes as `null`. -- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. A parent cannot be destroyed while a fork of it exists: the API stops the parent and refuses the delete. `destroy()` on a parent tries again for 30 seconds, which covers a parent and its forks destroyed at once, then rejects with an error that says to destroy the forks first. Destroy the forks first; destroying the parent again then works. +- **Fork:** `fork()` branches the running sandbox into a new one with the same files. The fork gets a copy of the env overlay of the parent, and keeps its egress policy. A parent cannot be destroyed while a fork of it exists: the API stops the parent and refuses the delete. `destroy()` on a parent tries again for 30 to 40 seconds (a refusal takes about 8 seconds), which covers a parent and its forks destroyed at once. Then it rejects with an error that says to destroy the forks first, and the parent stays stopped. Destroy the forks first; destroying the parent again then works. - **Ports:** `ports.connect(port)` creates a preview link for the port, or reuses the existing one, and returns its HTTPS URL. The URL is public: anyone who has it can reach the port. - **Network:** egress is `{ mode: 'open' }` unless you set `egressPolicy`. The platform's own default, `strict`, allows only package registries and code hosts, so a harness that calls a model API needs `open` or `allowDomains`. The provider always sends an egress policy, so a team default policy does not apply. `policy.capabilities.network: 'deny'` maps to egress mode `blocked`, which blocks all outbound traffic. - **Paths:** `/workspace` maps to `/home/agent/workspace` by default. Set `workdir` to change it, and keep it under `/home/agent` or `/tmp`. Commands run as the `agent` user on the default environment. The native file calls (`read`, `readBytes`, and `write`) reach `/home/agent` and `/tmp`. Read other paths with `process.exec`. diff --git a/packages/ai-sandbox-tangle/README.md b/packages/ai-sandbox-tangle/README.md index 6cbb2dcc36..eaf4140f93 100644 --- a/packages/ai-sandbox-tangle/README.md +++ b/packages/ai-sandbox-tangle/README.md @@ -118,5 +118,6 @@ A new sandbox is created without the account's stored secrets. A restore from a snapshot inherits them, because the API refuses an isolated restore. A parent cannot be destroyed while a fork of it exists. The API stops the -parent and refuses the delete; `destroy()` tries again for 30 seconds, then -rejects. Destroy the forks first, or destroy them together with the parent. +parent and refuses the delete. `destroy()` tries again for 30 to 40 seconds +(a refusal takes about 8 seconds), then rejects, and the parent stays stopped. +Destroy the forks first, or destroy them together with the parent.