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/.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..08fff4af36 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. 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 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 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`. +- **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..eaf4140f93 --- /dev/null +++ b/packages/ai-sandbox-tangle/README.md @@ -0,0 +1,123 @@ +
+ + + + 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 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() +} +``` + +## Configuration + +| Option | Default | Notes | +| -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `apiKey` | `TANGLE_API_KEY` | Tangle API key. | +| `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' }` | `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 + +| 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. 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. | +| `durableFilesystem` | ✅ | The workspace persists across stop and resume. | +| `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`. | +| `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`. + +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 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. diff --git a/packages/ai-sandbox-tangle/package.json b/packages/ai-sandbox-tangle/package.json new file mode 100644 index 0000000000..14be371889 --- /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.314" + }, + "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..37492ca60f --- /dev/null +++ b/packages/ai-sandbox-tangle/src/handle.ts @@ -0,0 +1,681 @@ +/** + * 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 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' +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', + 10: 'SIGUSR1', + 12: 'SIGUSR2', + 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`. */ +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)' +} + +/** 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. + * 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. + */ +function readOutput( + proc: TangleProcess, + onChunk: (stream: 'stdout' | 'stderr', data: string) => void, +): Promise { + let delivered = 0 + return retrying(async (progress) => { + let at = 0 + for await (const entry of proc.logs()) { + 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 + } + }) +} + +/** 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<{ + resolve: (r: IteratorResult) => void + reject: (error: unknown) => void + }> = [] + private closed = false + private error: unknown + private bytes = 0 + + constructor( + private readonly label: string, + 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) + 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) + } + + /** 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 + 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 }), + ) + }, + } + } +} + +/** 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: 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 { + 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') 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() + } + } +} + +/** 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 +} { + 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.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 + .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), o?.signal) + }, + mkdir: async (p, o) => { + o?.signal?.throwIfAborted() + 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.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.fsExec( + `mv -- ${q(this.abs(from))} ${q(this.abs(to))}`, + o?.signal, + ) + if (r.exitCode !== 0) throw new Error(`rename failed: ${errText(r)}`) + }, + exists: async (p, o) => { + o?.signal?.throwIfAborted() + const r = await this.fsExec(`test -e ${q(this.abs(p))}`, o?.signal) + 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() + }, + } + } + + /** 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 + 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 }) + } + } + + /** `lstat` through a command; a missing path is `undefined`. */ + private async lstat( + path: string, + signal?: AbortSignal, + ): Promise { + 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) + } + + /** + * 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, + opts: ProcessOptions | undefined, + ): 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(dir)} && exec sh -c ${q(command)}`, + { + ...(Object.keys(env).length > 0 ? { env } : {}), + timeoutMs: 0, + }, + ) + } + + /** 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 }) + } + + /** 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) + } + + /** + * 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.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 out = { stdout: '', stderr: '' } + await readOutput(proc, (stream, data) => { + out[stream] += data + }) + return { ...out, exitCode: await retrying(() => proc.wait()) } + } catch (error) { + // 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) + } + } + + /** + * 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, + ): Promise { + const proc = await this.start(command, opts) + const onAbort = (): void => { + void this.stop(proc) + } + opts?.signal?.addEventListener('abort', onAbort, { once: true }) + 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( + () => { + 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.stop(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, + 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, + }) + } + + /** Delete the sandbox, waiting out a fork that is being destroyed at the same time. */ + async destroy(): Promise { + await deleteSandbox(this.sandbox) + } +} + +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` +} + +/** 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 + 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..ce4e37396b --- /dev/null +++ b/packages/ai-sandbox-tangle/src/provider.ts @@ -0,0 +1,285 @@ +import { Sandbox } from '@tangle-network/sandbox' +import { + DEFAULT_WORKDIR, + TANGLE_CAPS, + TangleHandle, + deleteSandbox, + parseSnapshotRefId, + q, +} 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' + +/** + * 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`. */ +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.', + ) + } + const baseUrl = this.config.baseUrl ?? DEFAULT_BASE_URL + assertHttps(baseUrl) + this.sdk = new Sandbox({ apiKey, baseUrl }) + return this.sdk + } + + /** The SDK create options for a fresh sandbox or a snapshot restore. */ + 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, + // 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 } + : {}), + ...(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', signal ? { signal } : {}) + // Not through the handle: its commands run in the workdir, which does + // not exist yet. + 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}`) + } + } catch (error) { + await sandbox.delete().catch(() => undefined) + throw error + } + if (signal?.aborted === true) { + await sandbox.delete().catch(() => undefined) + signal.throwIfAborted() + } + 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, + 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 not + // sent at create. + 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. + 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) + } + + /** 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() + 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(signal ? { signal } : {}) + } + await sandbox.waitFor('running', signal ? { signal } : {}) + 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 deleteSandbox(sandbox, input.signal) + } +} + +/** + * 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..a6ca5ffc06 --- /dev/null +++ b/packages/ai-sandbox-tangle/tests/tangle.test.ts @@ -0,0 +1,332 @@ +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' + +// 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; 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' }) + 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) + + // `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 + 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') + 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) + 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, 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') + 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) + parentAlone = await src.destroy().then( + () => 'destroyed', + (error: unknown) => error, + ) + } finally { + // 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) + + 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 + let forked: SandboxHandle | undefined + let destroyed: Array> = [] + 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') + + // A fork keeps the blocked egress of its parent. + forked = await denied!.fork!() + expect((await forked.process.exec(PROBE)).stdout).not.toContain('200') + } finally { + // 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([]) + }, 600_000) + + // MEASURES the create-abort path: a sandbox the platform accepted after the + // 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 client = new Sandbox({ + apiKey: apiKey!, + baseUrl: 'https://sandbox.tangle.tools', + }) + 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([]) + // 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) + } + } + }, 300_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: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 071fcf637a..92008a3c0a 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.314 + 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: {}