From 94889cb8d4fb24e09a5157de6aac6e54aa38e4b6 Mon Sep 17 00:00:00 2001 From: mon Date: Thu, 30 Jul 2026 17:11:25 +0800 Subject: [PATCH] feat(sandbox-tenki): add Tenki sandbox provider Add @voltagent/sandbox-tenki, a WorkspaceSandbox provider that runs each execute_command inside a disposable Tenki Linux microVM via @tenkicloud/sandbox, mirroring the existing E2B / Blaxel / Daytona providers. TenkiSandbox is built on the SDK's session run() API, which returns a process handle and is the only primitive that can enforce a per-command timeout and AbortSignal. It forwards cwd/env/stdin natively, keeps stdout and stderr separate, truncates each stream at maxOutputBytes on a UTF-8 character boundary, and serializes start/stop/destroy so concurrent lifecycle calls observe one transition at a time. createTenkiToolkit adds optional expose_preview_url and authorize_ssh_key tools, kept in tools.ts so consumers can omit them. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/sandbox-tenki.md | 18 + packages/sandbox-tenki/README.md | 153 +++ packages/sandbox-tenki/package.json | 53 + packages/sandbox-tenki/src/index.spec.ts | 15 + packages/sandbox-tenki/src/index.ts | 3 + packages/sandbox-tenki/src/sandbox.spec.ts | 1398 ++++++++++++++++++++ packages/sandbox-tenki/src/sandbox.ts | 830 ++++++++++++ packages/sandbox-tenki/src/tools.spec.ts | 44 + packages/sandbox-tenki/src/tools.ts | 76 ++ packages/sandbox-tenki/src/utils.spec.ts | 322 +++++ packages/sandbox-tenki/src/utils.ts | 333 +++++ packages/sandbox-tenki/tsconfig.json | 36 + packages/sandbox-tenki/tsup.config.ts | 18 + packages/sandbox-tenki/vitest.config.ts | 23 + pnpm-lock.yaml | 77 ++ website/docs/workspaces/sandbox.md | 138 +- 16 files changed, 3531 insertions(+), 6 deletions(-) create mode 100644 .changeset/sandbox-tenki.md create mode 100644 packages/sandbox-tenki/README.md create mode 100644 packages/sandbox-tenki/package.json create mode 100644 packages/sandbox-tenki/src/index.spec.ts create mode 100644 packages/sandbox-tenki/src/index.ts create mode 100644 packages/sandbox-tenki/src/sandbox.spec.ts create mode 100644 packages/sandbox-tenki/src/sandbox.ts create mode 100644 packages/sandbox-tenki/src/tools.spec.ts create mode 100644 packages/sandbox-tenki/src/tools.ts create mode 100644 packages/sandbox-tenki/src/utils.spec.ts create mode 100644 packages/sandbox-tenki/src/utils.ts create mode 100644 packages/sandbox-tenki/tsconfig.json create mode 100644 packages/sandbox-tenki/tsup.config.ts create mode 100644 packages/sandbox-tenki/vitest.config.ts diff --git a/.changeset/sandbox-tenki.md b/.changeset/sandbox-tenki.md new file mode 100644 index 000000000..c30afac26 --- /dev/null +++ b/.changeset/sandbox-tenki.md @@ -0,0 +1,18 @@ +--- +"@voltagent/sandbox-tenki": minor +--- + +Add `@voltagent/sandbox-tenki` — a new workspace sandbox provider that runs your agents' shell commands inside disposable [Tenki](https://tenki.cloud) Linux microVMs. + +```ts +import { Workspace } from "@voltagent/core"; +import { TenkiSandbox } from "@voltagent/sandbox-tenki"; + +const workspace = new Workspace({ + sandbox: new TenkiSandbox({ + apiKey: process.env.TENKI_API_KEY, + }), +}); +``` + +Supports streaming stdout/stderr, per-call timeouts and `AbortSignal`, `cwd`/`env`/`stdin` forwarding, output truncation via `maxOutputBytes`, and lazy provisioning with explicit `start()`/`stop()`/`destroy()` control. `createTenkiToolkit(sandbox)` adds optional `expose_preview_url` and `authorize_ssh_key` tools, and `sandbox.getSandbox()` hands you the underlying Tenki session for provider-specific APIs (filesystem, port exposure, SSH). Tenki sessions are billed resources, so call `workspace.destroy()` when you are done. diff --git a/packages/sandbox-tenki/README.md b/packages/sandbox-tenki/README.md new file mode 100644 index 000000000..77de4d045 --- /dev/null +++ b/packages/sandbox-tenki/README.md @@ -0,0 +1,153 @@ +
+ +voltagent + + +

+AI Agent Engineering Platform +

+ +
+ Home Page | + Documentation | + Examples +
+
+ +
+ +
+ +[![GitHub issues](https://img.shields.io/github/issues/voltagent/voltagent)](https://github.com/voltagent/voltagent/issues) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/voltagent/voltagent)](https://github.com/voltagent/voltagent/pulls) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![npm version](https://img.shields.io/npm/v/@voltagent/sandbox-tenki.svg)](https://www.npmjs.com/package/@voltagent/sandbox-tenki) +[![npm downloads](https://img.shields.io/npm/dm/@voltagent/sandbox-tenki.svg)](https://www.npmjs.com/package/@voltagent/sandbox-tenki) +[![Discord](https://img.shields.io/discord/1361559153780195478.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://s.voltagent.dev/discord) + +
+ +## @voltagent/sandbox-tenki + +A [Tenki](https://tenki.cloud/) sandbox provider for VoltAgent's Workspace sandbox feature. `TenkiSandbox` implements VoltAgent's `WorkspaceSandbox` contract, letting agents execute shell commands inside a disposable Tenki Linux microVM instead of the local machine. + +--- + +## Install + +```bash +npm install @voltagent/sandbox-tenki +# or +yarn add @voltagent/sandbox-tenki +# or +pnpm add @voltagent/sandbox-tenki +``` + +## Usage + +```typescript +import { Agent, Workspace } from "@voltagent/core"; +import { TenkiSandbox } from "@voltagent/sandbox-tenki"; +import { openai } from "@ai-sdk/openai"; + +const sandbox = new TenkiSandbox({ + apiKey: process.env.TENKI_API_KEY, +}); + +const agent = new Agent({ + name: "my-agent", + instructions: "A helpful assistant with sandboxed shell access", + model: openai("gpt-4o-mini"), + workspace: new Workspace({ sandbox }), +}); +``` + +The API key defaults to the `TENKI_API_KEY` (or `TENKI_AUTH_TOKEN`) environment variable when `apiKey` is omitted. Tenki keys are prefixed `tk_`. Ordinary workspace API keys infer their workspace scope server-side, so they do not need a `workspaceId`. When using trusted service credentials that can access multiple workspaces, pass `workspaceId` to select one explicitly: + +```typescript +const sandbox = new TenkiSandbox({ + apiKey: process.env.TENKI_SERVICE_API_KEY, + workspaceId: process.env.TENKI_WORKSPACE_ID, +}); +``` + +## Configuration + +`TenkiSandboxOptions`: + +| Option | Type | Default | Description | +| ------------------- | ------------------------ | ------------------------- | ------------------------------------------------------------------------------------- | +| `apiKey` | `string` | `TENKI_API_KEY` env | Tenki API key (forwarded to the SDK as `authToken`) | +| `authToken` | `string` | — | Alias for `apiKey`; `apiKey` wins when both are set | +| `baseUrl` | `string` | — | Override the Tenki API base URL | +| `workspaceId` | `string` | — | Explicit workspace scope for trusted service credentials; omit for workspace API keys | +| `name` | `string` | — | Human-readable session name | +| `cpuCores` | `number` | — | vCPUs to allocate to the microVM | +| `memoryMb` | `number` | — | Memory (MiB) to allocate to the microVM | +| `env` | `Record` | — | Default env vars merged into every `execute()` call (and passed at session create) | +| `cwd` | `string` | — | Default working directory; per-call `cwd` overrides it | +| `allowInbound` | `boolean` | `true` | Allow inbound connections (required for preview URLs) | +| `allowOutbound` | `boolean` | `true` | Allow outbound network egress | +| `sshAuthorizedKeys` | `string[]` | — | SSH public keys authorized at session creation | +| `image` | `string` | — | Container image to boot the microVM from | +| `snapshotId` | `string` | — | Snapshot to restore the microVM from | +| `defaultTimeoutMs` | `number` | `60000` | Default command timeout; per-call `timeoutMs` overrides it. `0` disables it | +| `maxOutputBytes` | `number` | `5 * 1024 * 1024` (5 MiB) | Max stdout/stderr bytes kept per stream before truncation | +| `createOptions` | `CreateOptions` | — | Extra options forwarded verbatim to the SDK's `createAndWait` | +| `session` | `Session` | — | Pre-resolved Tenki session to reuse instead of creating a new one | + +The session is created lazily on the first `execute()` / `getSandbox()` call via the SDK's `createAndWait`. Use `getSandbox()` to access the underlying Tenki SDK session directly for Tenki-specific APIs (filesystem, port exposure, SSH, etc.). + +Tenki sessions are billed resources — call `sandbox.destroy()` (or `workspace.destroy()`) to close the microVM when you are done. `stop()` pauses it. + +### Exec failures + +Tenki reports a fork/exec/wait failure as a _completed_ run carrying an `errno` (`ENOENT`, `EACCES`, `EMFILE`, …) rather than as an error, and the process itself never writes anything — so an exit code alone cannot tell "command not found" from "ran and failed silently". `WorkspaceSandboxResult` has no field for that errno, so the adapter appends it to `stderr` as a single line: + +``` +tenki: exec failed: ENOENT (errno 2), reason=exec_failed +``` + +The guest agent's `reason` is also appended on its own when a run ends abnormally (non-zero exit or a signal) for a reason the result's `exitCode` / `signal` do not already explain, e.g. `tenki: run ended: reason=oom_killed`. A successful command never gets a diagnostic line, and the line is adapter metadata, so it is not counted against `maxOutputBytes`. + +## Preview URLs and SSH + +`createTenkiToolkit` returns an optional [Toolkit](https://voltagent.dev/docs/agents/tools/) with two extra tools that reach past the `execute_command` seam. Add it to the same agent that uses the workspace: + +```typescript +import { Agent, Workspace } from "@voltagent/core"; +import { TenkiSandbox, createTenkiToolkit } from "@voltagent/sandbox-tenki"; +import { openai } from "@ai-sdk/openai"; + +const sandbox = new TenkiSandbox({ apiKey: process.env.TENKI_API_KEY }); + +const agent = new Agent({ + name: "my-agent", + instructions: "A helpful assistant with sandboxed shell access", + model: openai("gpt-4o-mini"), + workspace: new Workspace({ sandbox }), + tools: [createTenkiToolkit(sandbox)], +}); +``` + +- `expose_preview_url` — expose a port inside the microVM and return a public preview URL (requires `allowInbound`, the default). +- `authorize_ssh_key` — authorize an SSH public key on the microVM. + +For advanced programmatic use, raw interactive SSH is available via `getSandbox().ssh()`, which returns a duplex byte stream (`read`/`write`/`close`) rather than a natural single agent tool: + +```typescript +const session = await sandbox.getSandbox(); +const ssh = await session.ssh(); +await ssh.write(new TextEncoder().encode("uname -a\n")); +const chunk = await ssh.read(); +ssh.close(); +``` + +## Documentation + +- [VoltAgent Documentation](https://voltagent.dev/docs/) +- [Tenki Sandbox SDK](https://tenki.cloud/docs/sandbox/sdk) + +## License + +Licensed under the MIT License, Copyright © 2026-present VoltAgent. diff --git a/packages/sandbox-tenki/package.json b/packages/sandbox-tenki/package.json new file mode 100644 index 000000000..9a87b3021 --- /dev/null +++ b/packages/sandbox-tenki/package.json @@ -0,0 +1,53 @@ +{ + "name": "@voltagent/sandbox-tenki", + "description": "VoltAgent Tenki sandbox provider", + "version": "2.0.0", + "dependencies": { + "@tenkicloud/sandbox": "^0.5.1" + }, + "devDependencies": { + "@types/node": "^24.2.1", + "@vitest/coverage-v8": "^3.2.4", + "@voltagent/core": "^2.8.1", + "tsup": "^8.5.0", + "typescript": "^5.8.2", + "vitest": "^3.2.4", + "zod": "^3.25.76" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": [ + "dist" + ], + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "peerDependencies": { + "@voltagent/core": "^2.4.1", + "zod": "^3.25.0 || ^4.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/VoltAgent/voltagent.git", + "directory": "packages/sandbox-tenki" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "types": "dist/index.d.ts" +} diff --git a/packages/sandbox-tenki/src/index.spec.ts b/packages/sandbox-tenki/src/index.spec.ts new file mode 100644 index 000000000..fc68256de --- /dev/null +++ b/packages/sandbox-tenki/src/index.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@tenkicloud/sandbox", () => ({ + TenkiSandbox: class {}, + CommandTimeoutError: class extends Error {}, +})); + +import * as api from "./index"; + +describe("public API", () => { + it("re-exports the adapter and toolkit factory", () => { + expect(typeof api.TenkiSandbox).toBe("function"); + expect(typeof api.createTenkiToolkit).toBe("function"); + }); +}); diff --git a/packages/sandbox-tenki/src/index.ts b/packages/sandbox-tenki/src/index.ts new file mode 100644 index 000000000..0f0ab37b5 --- /dev/null +++ b/packages/sandbox-tenki/src/index.ts @@ -0,0 +1,3 @@ +export { TenkiSandbox } from "./sandbox"; +export type { TenkiSandboxOptions, TenkiSandboxInstance } from "./sandbox"; +export { createTenkiToolkit } from "./tools"; diff --git a/packages/sandbox-tenki/src/sandbox.spec.ts b/packages/sandbox-tenki/src/sandbox.spec.ts new file mode 100644 index 000000000..0096a0648 --- /dev/null +++ b/packages/sandbox-tenki/src/sandbox.spec.ts @@ -0,0 +1,1398 @@ +import { CommandTimeoutError } from "@tenkicloud/sandbox"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TenkiSandbox } from "./sandbox"; +import { createTenkiToolkit } from "./tools"; + +const mocks = vi.hoisted(() => ({ + createAndWait: vi.fn(), + clientClose: vi.fn(), + clientCtor: vi.fn(), +})); + +vi.mock("@tenkicloud/sandbox", () => { + class CommandTimeoutError extends Error { + constructor(message?: string) { + super(message); + this.name = "CommandTimeoutError"; + } + } + class TenkiSandbox { + createAndWait = mocks.createAndWait; + close = mocks.clientClose; + constructor(options: unknown) { + mocks.clientCtor(options); + } + } + return { TenkiSandbox, CommandTimeoutError }; +}); + +const enc = new TextEncoder(); + +const concat = (chunks: Array): Uint8Array => { + const buffers = chunks.map((c) => + typeof c === "string" ? Buffer.from(enc.encode(c)) : Buffer.from(c), + ); + return new Uint8Array(Buffer.concat(buffers)); +}; + +type HandleOptions = { + stdout?: Array; + stderr?: Array; + exitCode?: number; + signal?: string; + durationMs?: number; + reason?: string; + errno?: number; + hangUntilKill?: boolean; + keepStreamsOpen?: boolean; + killHangs?: boolean; + killRejectWith?: unknown; + killThrowsWith?: unknown; + rejectWith?: unknown; + errorStdout?: boolean; +}; + +const makeHandle = (options: HandleOptions = {}) => { + const { + stdout = [], + stderr = [], + exitCode = 0, + signal, + durationMs = 5, + reason = "exit", + errno = 0, + hangUntilKill = false, + keepStreamsOpen = false, + killHangs = false, + killRejectWith, + killThrowsWith, + rejectWith, + errorStdout = false, + } = options; + + let stdoutCtl!: ReadableStreamDefaultController; + let stderrCtl!: ReadableStreamDefaultController; + const stdoutStream = errorStdout + ? new ReadableStream({ + start(controller) { + stdoutCtl = controller; + controller.error(new Error("stream boom")); + }, + }) + : new ReadableStream({ + start(controller) { + stdoutCtl = controller; + }, + }); + const stderrStream = new ReadableStream({ + start(controller) { + stderrCtl = controller; + }, + }); + for (const chunk of stdout) { + stdoutCtl.enqueue(typeof chunk === "string" ? enc.encode(chunk) : chunk); + } + for (const chunk of stderr) { + stderrCtl.enqueue(typeof chunk === "string" ? enc.encode(chunk) : chunk); + } + + const result = { + exitCode, + signal, + durationMs, + reason, + errno, + stdout: concat(stdout), + stderr: concat(stderr), + }; + + let resolveResult!: (value: typeof result) => void; + let rejectResult!: (reason: unknown) => void; + const resultPromise = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + const safeClose = (controller: ReadableStreamDefaultController) => { + try { + controller.close(); + } catch { + // controller may already be errored (errorStdout case) + } + }; + + if (rejectWith !== undefined) { + safeClose(stdoutCtl); + safeClose(stderrCtl); + rejectResult(rejectWith); + } else if (!hangUntilKill) { + if (!keepStreamsOpen) { + safeClose(stdoutCtl); + safeClose(stderrCtl); + } + resolveResult(result); + } + + const kill = + killThrowsWith !== undefined + ? vi.fn(() => { + throw killThrowsWith; + }) + : vi.fn(async () => { + if (killRejectWith !== undefined) { + throw killRejectWith; + } + if (killHangs) { + await new Promise(() => {}); + } + if (hangUntilKill) { + safeClose(stdoutCtl); + safeClose(stderrCtl); + resolveResult({ ...result, signal: signal ?? "KILL" }); + } + }); + + const writeSpy = vi.fn(); + const stdin = new WritableStream({ + write(chunk) { + writeSpy(chunk); + }, + }); + + return { + stdout: stdoutStream, + stderr: stderrStream, + stdin, + pid: Promise.resolve(1), + signal: vi.fn(async () => {}), + kill, + // biome-ignore lint/suspicious/noThenProperty: mocking Tenki's PromiseLike ProcessRunHandle + then( + onfulfilled?: ((value: typeof result) => T | PromiseLike) | null, + onrejected?: ((reason: unknown) => R | PromiseLike) | null, + ) { + return resultPromise.then(onfulfilled, onrejected); + }, + _writeSpy: writeSpy, + _rejectResult: rejectResult, + }; +}; + +const settlesWithin = async (promise: Promise, timeoutMs = 250): Promise => { + let timeoutId: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`promise did not settle within ${timeoutMs}ms`)), + timeoutMs, + ); + }); + try { + return await Promise.race([promise, deadline]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +}; + +const makeSession = (overrides: Record = {}) => { + const run = vi.fn(() => makeHandle({ stdout: ["ok\n"], exitCode: 0 })); + return { + id: "sess-123", + state: "RUNNING", + run, + exposePort: vi.fn(async () => ({ port: 3000, previewUrl: "https://preview.tenki.cloud/abc" })), + updateSshAuthorizedKeys: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + close: vi.fn(async () => {}), + ...overrides, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("TenkiSandbox.execute", () => { + it("runs a command and maps stdout + exitCode", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ stdout: ["hello\n"], exitCode: 0 })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "echo hello" }); + + expect(result.stdout).toBe("hello\n"); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.aborted).toBe(false); + expect(result.stdoutTruncated).toBe(false); + expect(session.run).toHaveBeenCalledWith(["echo", "hello"], expect.objectContaining({})); + }); + + it("maps a non-zero exit code and stderr", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ stderr: ["boom\n"], exitCode: 2 })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "false" }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe("boom\n"); + }); + + it("surfaces an exec failure errno in stderr", async () => { + // Tenki reports a fork/exec failure as a resolved run with empty stderr, so + // the errno is the only thing that explains the exit code. + const session = makeSession({ + run: vi.fn(() => makeHandle({ exitCode: 127, errno: 2, reason: "exec_failed" })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "nope" }); + + expect(result.exitCode).toBe(127); + expect(result.stderr).toBe("tenki: exec failed: ENOENT (errno 2), reason=exec_failed\n"); + expect(result.stderrTruncated).toBe(false); + }); + + it("appends the exec failure diagnostic after captured stderr", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ stderr: ["partial\n"], exitCode: 1, errno: 24 })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "spawn-storm" }); + + expect(result.stderr).toBe("partial\ntenki: exec failed: EMFILE (errno 24)\n"); + }); + + it("surfaces an abnormal exit reason that exitCode alone does not explain", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ exitCode: 137, reason: "oom_killed" })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "hog" }); + + expect(result.stderr).toBe("tenki: run ended: reason=oom_killed\n"); + }); + + it("leaves stderr untouched on a clean run", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ stdout: ["ok\n"], exitCode: 0, reason: "exit", errno: 0 })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "true" }); + + expect(result.stderr).toBe(""); + }); + + it("routes streamed chunks to onStdout/onStderr and merges signal", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ stdout: ["a"], stderr: ["b"], exitCode: 0, signal: "TERM" })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + const outChunks: string[] = []; + const errChunks: string[] = []; + + const result = await sandbox.execute({ + command: "run", + onStdout: (c) => outChunks.push(c), + onStderr: (c) => errChunks.push(c), + }); + + expect(outChunks).toEqual(["a"]); + expect(errChunks).toEqual(["b"]); + expect(result.signal).toBe("TERM"); + }); + + it("swallows errors thrown by streaming callbacks", async () => { + const session = makeSession({ run: vi.fn(() => makeHandle({ stdout: ["a"] })) }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ + command: "run", + onStdout: () => { + throw new Error("callback boom"); + }, + }); + + expect(result.stdout).toBe("a"); + }); + + it("forwards cwd and merged env to run", async () => { + const run = vi.fn(() => makeHandle({ stdout: ["ok"] })); + const session = makeSession({ run }); + const sandbox = new TenkiSandbox({ + session: session as never, + env: { BASE: "1" }, + cwd: "/base", + }); + + await sandbox.execute({ command: "env", env: { EXTRA: "2" }, cwd: "/work" }); + + expect(run).toHaveBeenCalledWith(["env"], { + stdin: expect.any(ReadableStream), + env: { BASE: "1", EXTRA: "2" }, + cwd: "/work", + }); + }); + + it("omits env and cwd when neither is set", async () => { + const run = vi.fn(() => makeHandle({ stdout: ["ok"] })); + const session = makeSession({ run }); + const sandbox = new TenkiSandbox({ session: session as never }); + + await sandbox.execute({ command: "ls" }); + + expect(run).toHaveBeenCalledWith(["ls"], { stdin: expect.any(ReadableStream) }); + }); + + it("forwards stdin through the run stdin stream", async () => { + const handle = makeHandle({ stdout: ["ok"] }); + const run = vi.fn(() => handle); + const session = makeSession({ run }); + const sandbox = new TenkiSandbox({ session: session as never }); + + await sandbox.execute({ command: "cat", stdin: "piped-input" }); + + // The run() stdin option is a ReadableStream that carries the stdin bytes. + const runCall = run.mock.calls[0] as unknown as [ + string[], + { stdin: ReadableStream }, + ]; + const passedStdin = runCall[1].stdin; + const reader = passedStdin.getReader(); + const { value } = await reader.read(); + expect(new TextDecoder().decode(value)).toBe("piped-input"); + }); + + it("truncates output beyond maxOutputBytes", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ stdout: ["abcdefghij"], exitCode: 0 })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "cat big", maxOutputBytes: 4 }); + + expect(result.stdout).toBe("abcd"); + expect(result.stdoutTruncated).toBe(true); + }); + + it("throws when the command is empty", async () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never }); + await expect(sandbox.execute({ command: " " })).rejects.toThrow( + "Sandbox command is required", + ); + }); + + it("throws when the command is missing entirely", async () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never }); + await expect(sandbox.execute({ command: undefined as unknown as string })).rejects.toThrow( + "Sandbox command is required", + ); + }); + + it("tolerates an output stream that errors mid-read", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ errorStdout: true, stderr: ["side"], exitCode: 0 })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "flaky" }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe("side"); + }); + + it("returns an aborted result when the signal is already aborted", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ session: session as never }); + const controller = new AbortController(); + controller.abort(); + + const result = await sandbox.execute({ command: "sleep 1", signal: controller.signal }); + + expect(result.aborted).toBe(true); + expect(result.exitCode).toBeNull(); + expect(session.run).not.toHaveBeenCalled(); + }); + + it("aborts an in-flight command and kills the process", async () => { + const handle = makeHandle({ hangUntilKill: true, stdout: ["partial"] }); + const session = makeSession({ run: vi.fn(() => handle) }); + const sandbox = new TenkiSandbox({ session: session as never }); + const controller = new AbortController(); + + const promise = sandbox.execute({ command: "sleep 100", signal: controller.signal }); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + const result = await promise; + + expect(result.aborted).toBe(true); + expect(handle.kill).toHaveBeenCalled(); + }); + + it("handles a synchronous abort from run and a synchronous kill failure", async () => { + const controller = new AbortController(); + const reader = { + read: vi.fn(() => new Promise(() => {})), + cancel: vi.fn(() => { + throw new Error("cancel failed synchronously"); + }), + releaseLock: vi.fn(), + }; + const handle = { + ...makeHandle({ + rejectWith: new CommandTimeoutError("late timeout"), + killThrowsWith: new Error("kill failed synchronously"), + }), + stdout: { + getReader: () => reader, + } as unknown as ReadableStream, + }; + const session = makeSession({ + run: vi.fn(() => { + controller.abort(); + return handle; + }), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "sleep 100", signal: controller.signal }); + + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + expect(handle.kill).toHaveBeenCalledOnce(); + expect(reader.cancel).toHaveBeenCalledOnce(); + }); + + it("keeps the first cancellation reason when run throws after aborting", async () => { + const controller = new AbortController(); + const session = makeSession({ + run: vi.fn(() => { + controller.abort(); + throw new CommandTimeoutError("run timed out after abort"); + }), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "sleep 100", signal: controller.signal }); + + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + }); + + it("times out a long-running command and kills the process", async () => { + const handle = makeHandle({ hangUntilKill: true }); + const session = makeSession({ run: vi.fn(() => handle) }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "sleep 100", timeoutMs: 20 }); + + expect(result.timedOut).toBe(true); + expect(handle.kill).toHaveBeenCalled(); + }); + + it("settles on timeout when the run, streams, and kill never settle", async () => { + const handle = makeHandle({ + stdout: ["partial"], + hangUntilKill: true, + killHangs: true, + }); + const session = makeSession({ run: vi.fn(() => handle) }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await settlesWithin(sandbox.execute({ command: "sleep 100", timeoutMs: 10 })); + + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + expect(result.exitCode).toBeNull(); + expect(result.stdout).toBe("partial"); + expect(handle.kill).toHaveBeenCalledOnce(); + }); + + it("settles on abort and contains late run and kill rejections", async () => { + const handle = makeHandle({ + stdout: ["partial"], + hangUntilKill: true, + killRejectWith: new Error("kill failed"), + }); + const session = makeSession({ run: vi.fn(() => handle) }); + const sandbox = new TenkiSandbox({ session: session as never }); + const controller = new AbortController(); + + const promise = sandbox.execute({ command: "sleep 100", signal: controller.signal }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + const result = await settlesWithin(promise); + + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.stdout).toBe("partial"); + expect(handle.kill).toHaveBeenCalledOnce(); + + // A run failure arriving after execute() returned must remain observed. + handle._rejectResult(new Error("late run failure")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it("settles when output streams remain open after the run completes", async () => { + const handle = makeHandle({ stdout: ["complete"], keepStreamsOpen: true, killHangs: true }); + const session = makeSession({ run: vi.fn(() => handle) }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await settlesWithin( + sandbox.execute({ command: "echo complete", timeoutMs: 10 }), + ); + + expect(result.timedOut).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("complete"); + expect(handle.kill).toHaveBeenCalledOnce(); + }); + + it("tolerates a stream reader whose releaseLock throws", async () => { + const reader = { + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + cancel: vi.fn().mockResolvedValue(undefined), + releaseLock: vi.fn(() => { + throw new Error("release failed"); + }), + }; + const handle = { + ...makeHandle(), + stdout: { + getReader: () => reader, + } as unknown as ReadableStream, + }; + const session = makeSession({ run: vi.fn(() => handle) }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "echo ok" }); + + expect(result.exitCode).toBe(0); + expect(reader.releaseLock).toHaveBeenCalledOnce(); + }); + + it("treats a CommandTimeoutError as a timeout", async () => { + const session = makeSession({ + run: vi.fn(() => makeHandle({ rejectWith: new CommandTimeoutError("deadline") })), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const result = await sandbox.execute({ command: "slow" }); + + expect(result.timedOut).toBe(true); + expect(result.exitCode).toBeNull(); + }); + + it("rethrows unexpected errors", async () => { + const session = makeSession({ + run: vi.fn(() => { + throw new Error("network down"); + }), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + await expect(sandbox.execute({ command: "boom" })).rejects.toThrow("network down"); + }); + + it("disables the timeout when timeoutMs is 0", async () => { + const run = vi.fn(() => makeHandle({ stdout: ["ok"] })); + const session = makeSession({ run }); + const sandbox = new TenkiSandbox({ session: session as never, defaultTimeoutMs: 0 }); + + const result = await sandbox.execute({ command: "ok" }); + + expect(result.timedOut).toBe(false); + expect(result.stdout).toBe("ok"); + }); + + it("reassembles a multi-byte code point split across stream chunks", async () => { + const session = makeSession(); + // "😀" (U+1F600) = F0 9F 98 80, delivered split across two stream chunks. + session.run.mockReturnValue( + makeHandle({ + stdout: [new Uint8Array([0xf0, 0x9f]), new Uint8Array([0x98, 0x80])], + exitCode: 0, + }), + ); + mocks.createAndWait.mockResolvedValue(session); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const chunks: string[] = []; + const result = await sandbox.execute({ + command: "echo", + onStdout: (chunk) => chunks.push(chunk), + }); + + // Incremental decoding buffers the partial code point instead of emitting + // replacement characters on each chunk boundary. + expect(chunks.join("")).toBe("😀"); + expect(result.stdout).toBe("😀"); + }); + + it("settles as timed out while provisioning is still pending", async () => { + // createAndWait never resolves during this test: the only way execute() + // can settle is by racing provisioning against the timeout. + mocks.createAndWait.mockReturnValue(new Promise(() => {})); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const result = await sandbox.execute({ command: "echo hi", timeoutMs: 10 }); + + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + expect(result.exitCode).toBeNull(); + }); + + it("settles as aborted while provisioning is still pending", async () => { + mocks.createAndWait.mockReturnValue(new Promise(() => {})); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + const controller = new AbortController(); + + const promise = sandbox.execute({ command: "echo hi", signal: controller.signal }); + controller.abort(); + const result = await promise; + + expect(result.aborted).toBe(true); + expect(result.exitCode).toBeNull(); + }); + + it("settles as aborted when the client hands back no session", async () => { + mocks.createAndWait.mockResolvedValue(undefined); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const result = await sandbox.execute({ command: "echo hi" }); + + expect(result.aborted).toBe(true); + expect(result.exitCode).toBeNull(); + }); + + // `session.resume()` is a network RPC, so a timeout/abort can land while it is + // in flight — before `handle` exists, which makes requestKill() a no-op. The + // command must not be launched at all in that window. + it("settles as timed out when the timeout fires while resuming", async () => { + const session = makeSession({ + resume: vi.fn(() => new Promise(() => {})), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + await sandbox.stop(); + + const result = await settlesWithin(sandbox.execute({ command: "sleep 100", timeoutMs: 10 })); + + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + expect(result.exitCode).toBeNull(); + expect(session.resume).toHaveBeenCalledOnce(); + expect(session.run).not.toHaveBeenCalled(); + }); + + it("settles as aborted when the signal fires while resuming", async () => { + const session = makeSession({ + resume: vi.fn(() => new Promise(() => {})), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + await sandbox.stop(); + const controller = new AbortController(); + + const promise = sandbox.execute({ command: "sleep 100", signal: controller.signal }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + const result = await settlesWithin(promise); + + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.exitCode).toBeNull(); + expect(session.resume).toHaveBeenCalledOnce(); + expect(session.run).not.toHaveBeenCalled(); + }); +}); + +describe("TenkiSandbox lifecycle", () => { + it("provisions via createAndWait with defaulted create options", async () => { + const session = makeSession(); + mocks.createAndWait.mockResolvedValue(session); + const sandbox = new TenkiSandbox({ + apiKey: "tk_test", + name: "demo", + cpuCores: 4, + memoryMb: 8192, + sshAuthorizedKeys: ["ssh-ed25519 AAAA"], + image: "ubuntu", + snapshotId: "snap-1", + workspaceId: "ws-1", + env: { FOO: "bar" }, + }); + + await sandbox.start(); + + expect(mocks.clientCtor).toHaveBeenCalledWith({ authToken: "tk_test" }); + expect(mocks.createAndWait).toHaveBeenCalledWith( + expect.objectContaining({ + allowInbound: true, + allowOutbound: true, + name: "demo", + cpuCores: 4, + memoryMb: 8192, + sshAuthorizedKeys: ["ssh-ed25519 AAAA"], + image: "ubuntu", + snapshotId: "snap-1", + workspaceId: "ws-1", + env: { FOO: "bar" }, + }), + ); + expect(sandbox.status).toBe("ready"); + }); + + it("adopts and resumes a paused session returned by provisioning", async () => { + let resolveResume!: () => void; + const session = makeSession({ + state: "PAUSED", + resume: vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ), + }); + mocks.createAndWait.mockResolvedValue(session); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const startPromise = sandbox.start(); + await vi.waitFor(() => expect(session.resume).toHaveBeenCalledOnce()); + expect(sandbox.status).toBe("idle"); + + resolveResume(); + await startPromise; + expect(sandbox.status).toBe("ready"); + }); + + it("passes baseUrl and authToken alias through the client options", async () => { + mocks.createAndWait.mockResolvedValue(makeSession()); + const sandbox = new TenkiSandbox({ authToken: "tk_alias", baseUrl: "https://example.test" }); + await sandbox.start(); + expect(mocks.clientCtor).toHaveBeenCalledWith({ + authToken: "tk_alias", + baseUrl: "https://example.test", + }); + }); + + it("caches the session across calls and reuses the client", async () => { + const session = makeSession(); + mocks.createAndWait.mockResolvedValue(session); + const sandbox = new TenkiSandbox({}); + + const first = await sandbox.getSandbox(); + const second = await sandbox.getSandbox(); + + expect(first).toBe(second); + expect(mocks.createAndWait).toHaveBeenCalledTimes(1); + }); + + it("rejects getSandbox with a clear error when the client hands back no session", async () => { + mocks.createAndWait.mockResolvedValue(undefined); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + await expect(sandbox.getSandbox()).rejects.toThrow("Tenki client returned no session"); + }); + + it("clears the cached promise and flags error when provisioning fails", async () => { + mocks.createAndWait.mockRejectedValueOnce(new Error("create failed")); + const session = makeSession(); + mocks.createAndWait.mockResolvedValueOnce(session); + const sandbox = new TenkiSandbox({}); + + await expect(sandbox.start()).rejects.toThrow("create failed"); + expect(sandbox.status).toBe("error"); + + // Next call retries provisioning instead of replaying the rejection. + await expect(sandbox.getSandbox()).resolves.toBe(session); + expect(mocks.createAndWait).toHaveBeenCalledTimes(2); + }); + + it("pauses the session on stop", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ session: session as never }); + await sandbox.stop(); + expect(session.pause).toHaveBeenCalled(); + }); + + it("is a no-op on stop when no session exists", async () => { + const sandbox = new TenkiSandbox({}); + await expect(sandbox.stop()).resolves.toBeUndefined(); + }); + + it("is a no-op on stop after the client hands back no session", async () => { + mocks.createAndWait.mockResolvedValue(undefined); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + // Caches sessionPromise as a promise resolving to undefined. + await expect(sandbox.start()).resolves.toBeUndefined(); + await expect(sandbox.stop()).resolves.toBeUndefined(); + }); + + it("surfaces a provisioning failure to a concurrent stop", async () => { + mocks.createAndWait.mockRejectedValue(new Error("create failed")); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const startAssertion = expect(sandbox.start()).rejects.toThrow("create failed"); + const stopAssertion = expect(sandbox.stop()).rejects.toThrow("create failed"); + + await Promise.all([startAssertion, stopAssertion]); + expect(sandbox.status).toBe("error"); + }); + + it("closes the session on destroy and marks destroyed", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ session: session as never }); + + await sandbox.destroy(); + + expect(session.close).toHaveBeenCalled(); + expect(sandbox.status).toBe("destroyed"); + expect(sandbox.getInfo()).toEqual({ + provider: "tenki", + status: "destroyed", + sessionId: undefined, + }); + }); + + it("swallows active session close failures and remains destroyed", async () => { + const session = makeSession({ + close: vi.fn(async () => { + throw new Error("close failed"); + }), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + await expect(sandbox.destroy()).resolves.toBeUndefined(); + + expect(session.close).toHaveBeenCalledTimes(1); + expect(sandbox.status).toBe("destroyed"); + await expect(sandbox.start()).rejects.toThrow("Sandbox has been destroyed"); + }); + + it("retries a failed close on a later destroy without closing again after success", async () => { + const close = vi + .fn() + .mockRejectedValueOnce(new Error("close failed")) + .mockResolvedValueOnce(undefined); + const session = makeSession({ close }); + const sandbox = new TenkiSandbox({ session: session as never }); + + await expect(sandbox.destroy()).resolves.toBeUndefined(); + await expect(sandbox.destroy()).resolves.toBeUndefined(); + await expect(sandbox.destroy()).resolves.toBeUndefined(); + + expect(close).toHaveBeenCalledTimes(2); + }); + + it("destroys cleanly after the client hands back no session", async () => { + mocks.createAndWait.mockResolvedValue(undefined); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + // Caches sessionPromise as a promise resolving to undefined. + const result = await sandbox.execute({ command: "echo hi" }); + expect(result.aborted).toBe(true); + + await expect(sandbox.destroy()).resolves.toBeUndefined(); + // Repeated destroy must not be poisoned by a retained nullish entry. + await expect(sandbox.destroy()).resolves.toBeUndefined(); + expect(sandbox.status).toBe("destroyed"); + }); + + it("is a no-op on destroy when no session exists", async () => { + const sandbox = new TenkiSandbox({}); + await sandbox.destroy(); + expect(sandbox.status).toBe("destroyed"); + }); + + it("resumes a paused session on start", async () => { + const session = makeSession(); + mocks.createAndWait.mockResolvedValue(session); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + await sandbox.start(); + await sandbox.stop(); + expect(session.pause).toHaveBeenCalledTimes(1); + expect(sandbox.status).toBe("idle"); + + await sandbox.start(); + expect(session.resume).toHaveBeenCalledTimes(1); + expect(sandbox.status).toBe("ready"); + }); + + it("resumes a paused session on the next execute", async () => { + const session = makeSession(); + mocks.createAndWait.mockResolvedValue(session); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + await sandbox.start(); + await sandbox.stop(); + const result = await sandbox.execute({ command: "echo hi" }); + + expect(session.resume).toHaveBeenCalledTimes(1); + expect(sandbox.status).toBe("ready"); + expect(result.exitCode).toBe(0); + }); + + it.each(["start", "execute"] as const)( + "recognizes and resumes an injected paused session on %s", + async (operation) => { + const session = makeSession({ state: "PAUSED" }); + const sandbox = new TenkiSandbox({ session: session as never }); + + expect(sandbox.status).toBe("idle"); + if (operation === "start") { + await sandbox.start(); + } else { + await sandbox.execute({ command: "echo hi" }); + } + + expect(session.resume).toHaveBeenCalledOnce(); + expect(sandbox.status).toBe("ready"); + }, + ); + + it("shares one resume transition across concurrent callers", async () => { + let resolveResume!: () => void; + const session = makeSession({ + state: "PAUSED", + resume: vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const first = sandbox.start(); + const second = sandbox.start(); + await vi.waitFor(() => expect(session.resume).toHaveBeenCalledOnce()); + + resolveResume(); + await Promise.all([first, second]); + + expect(session.resume).toHaveBeenCalledOnce(); + expect(sandbox.status).toBe("ready"); + }); + + it("shares one pause transition across concurrent stop callers", async () => { + let resolvePause!: () => void; + const session = makeSession({ + pause: vi.fn( + () => + new Promise((resolve) => { + resolvePause = resolve; + }), + ), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const first = sandbox.stop(); + const second = sandbox.stop(); + await vi.waitFor(() => expect(session.pause).toHaveBeenCalledOnce()); + + resolvePause(); + await Promise.all([first, second]); + + expect(session.pause).toHaveBeenCalledOnce(); + expect(sandbox.status).toBe("idle"); + }); + + it("does not restore idle when a pause completes after destroy", async () => { + let resolvePause!: () => void; + const session = makeSession({ + pause: vi.fn( + () => + new Promise((resolve) => { + resolvePause = resolve; + }), + ), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const stopPromise = sandbox.stop(); + await vi.waitFor(() => expect(session.pause).toHaveBeenCalledOnce()); + await settlesWithin(sandbox.destroy()); + resolvePause(); + + await expect(stopPromise).resolves.toBeUndefined(); + expect(sandbox.status).toBe("destroyed"); + }); + + it("does not restore ready when a resume completes after destroy", async () => { + let resolveResume!: () => void; + const session = makeSession({ + state: "PAUSED", + resume: vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const firstStart = sandbox.start(); + const secondStart = sandbox.start(); + await vi.waitFor(() => expect(session.resume).toHaveBeenCalledOnce()); + await settlesWithin(sandbox.destroy()); + expect(session.close).toHaveBeenCalledOnce(); + resolveResume(); + + await expect(firstStart).rejects.toThrow("Sandbox has been destroyed"); + await expect(secondStart).rejects.toThrow("Sandbox has been destroyed"); + expect(sandbox.status).toBe("destroyed"); + }); + + it("waits for pending provisioning and pauses the resulting session on stop", async () => { + let resolveSession!: (value: unknown) => void; + mocks.createAndWait.mockReturnValue( + new Promise((resolve) => { + resolveSession = resolve; + }), + ); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const startPromise = sandbox.start(); + const stopPromise = sandbox.stop(); + const session = makeSession(); + resolveSession(session); + + await Promise.all([startPromise, stopPromise]); + expect(session.pause).toHaveBeenCalledOnce(); + expect(sandbox.status).toBe("idle"); + }); + + it("lets destroy supersede a stop waiting for provisioning", async () => { + let resolveSession!: (value: unknown) => void; + mocks.createAndWait.mockReturnValue( + new Promise((resolve) => { + resolveSession = resolve; + }), + ); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const startPromise = sandbox.start(); + void startPromise.catch(() => undefined); + const stopPromise = sandbox.stop(); + const destroyPromise = sandbox.destroy(); + const session = makeSession(); + resolveSession(session); + + await expect(destroyPromise).resolves.toBeUndefined(); + await expect(stopPromise).resolves.toBeUndefined(); + await expect(startPromise).rejects.toThrow("Sandbox has been destroyed"); + expect(session.pause).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(sandbox.status).toBe("destroyed"); + }); + + it("waits for pending provisioning and the late session close before resolving", async () => { + let resolveSession!: (value: unknown) => void; + let resolveClose!: () => void; + mocks.createAndWait.mockReturnValue( + new Promise((resolve) => { + resolveSession = resolve; + }), + ); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const startPromise = sandbox.start(); + void startPromise.catch(() => undefined); + const destroyPromise = sandbox.destroy(); + const concurrentDestroyPromise = sandbox.destroy(); + let destroySettled = false; + void destroyPromise.then( + () => { + destroySettled = true; + }, + () => { + destroySettled = true; + }, + ); + + await Promise.resolve(); + expect(destroySettled).toBe(false); + expect(sandbox.status).toBe("destroyed"); + + const lateSession = makeSession({ + id: "late", + close: vi.fn( + () => + new Promise((resolve) => { + resolveClose = resolve; + }), + ), + }); + resolveSession(lateSession); + + await vi.waitFor(() => expect(lateSession.close).toHaveBeenCalledTimes(1)); + expect(destroySettled).toBe(false); + resolveClose(); + + await expect(destroyPromise).resolves.toBeUndefined(); + await expect(concurrentDestroyPromise).resolves.toBeUndefined(); + await expect(startPromise).rejects.toThrow("Sandbox has been destroyed"); + expect(lateSession.close).toHaveBeenCalledTimes(1); + expect(sandbox.status).toBe("destroyed"); + expect(sandbox.getInfo().sessionId).toBeUndefined(); + }); + + it("swallows a late close failure and retries that session on the next destroy", async () => { + let resolveSession!: (value: unknown) => void; + let rejectClose!: (reason: unknown) => void; + mocks.createAndWait.mockReturnValue( + new Promise((resolve) => { + resolveSession = resolve; + }), + ); + const close = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectClose = reject; + }), + ) + .mockResolvedValueOnce(undefined); + const lateSession = makeSession({ id: "late", close }); + const sandbox = new TenkiSandbox({ apiKey: "tk_test" }); + + const startPromise = sandbox.start(); + void startPromise.catch(() => undefined); + const destroyPromise = sandbox.destroy(); + resolveSession(lateSession); + await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)); + rejectClose(new Error("late close failed")); + + await expect(destroyPromise).resolves.toBeUndefined(); + await expect(startPromise).rejects.toThrow("Sandbox has been destroyed"); + await expect(sandbox.destroy()).resolves.toBeUndefined(); + + expect(close).toHaveBeenCalledTimes(2); + expect(sandbox.status).toBe("destroyed"); + }); + + it("reports info and instructions", () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ session: session as never }); + expect(sandbox.getInfo()).toEqual({ + provider: "tenki", + status: "ready", + sessionId: "sess-123", + }); + expect(sandbox.getInstructions()).toContain("Tenki Linux microVM"); + expect(sandbox.name).toBe("tenki"); + }); + + it("describes base tools, the default working directory, and enabled egress by default", () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never }); + const instructions = sandbox.getInstructions(); + expect(instructions).toContain("Base tools: bash, git, node, npm, python3."); + expect(instructions).toContain("Writable working directory is /home/tenki."); + expect(instructions).toContain("Network egress is enabled."); + }); + + it("reports disabled egress when allowOutbound is false", () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never, allowOutbound: false }); + expect(sandbox.getInstructions()).toContain("Network egress is disabled."); + }); + + it("omits base tools and the default working directory for a custom image", () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never, image: "ubuntu" }); + const instructions = sandbox.getInstructions(); + expect(instructions).not.toContain("Base tools"); + expect(instructions).not.toContain("/home/tenki"); + }); + + it("treats a snapshot as a custom image and omits base tools", () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never, snapshotId: "snap-1" }); + expect(sandbox.getInstructions()).not.toContain("Base tools"); + }); + + it("reports a custom working directory when cwd is set", () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never, cwd: "/work" }); + expect(sandbox.getInstructions()).toContain("Working directory is /work."); + }); + + it("rejects execute after the sandbox is destroyed", async () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never }); + await sandbox.destroy(); + await expect(sandbox.execute({ command: "ls" })).rejects.toThrow("Sandbox has been destroyed"); + }); + + it("rejects getSandbox and start after the sandbox is destroyed", async () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never }); + await sandbox.destroy(); + await expect(sandbox.getSandbox()).rejects.toThrow("Sandbox has been destroyed"); + await expect(sandbox.start()).rejects.toThrow("Sandbox has been destroyed"); + }); +}); + +describe("createTenkiToolkit", () => { + it("builds a toolkit that exposes preview URLs and authorizes SSH keys", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ session: session as never }); + const toolkit = createTenkiToolkit(sandbox); + + expect(toolkit.name).toBe("tenki"); + expect(toolkit.addInstructions).toBe(true); + const toolNames = toolkit.tools.map((t) => (t as { name: string }).name); + expect(toolNames).toEqual(["expose_preview_url", "authorize_ssh_key"]); + + const preview = toolkit.tools[0] as { execute: (input: unknown) => Promise }; + const previewResult = await preview.execute({ port: 3000, ttlMs: 60000 }); + expect(previewResult).toEqual({ previewUrl: "https://preview.tenki.cloud/abc" }); + expect(session.exposePort).toHaveBeenCalledWith(3000, { ttlMs: 60000 }); + + const ssh = toolkit.tools[1] as { execute: (input: unknown) => Promise<{ message: string }> }; + const sshResult = await ssh.execute({ publicKey: "ssh-ed25519 AAAA user" }); + expect(session.updateSshAuthorizedKeys).toHaveBeenCalledWith(["ssh-ed25519 AAAA user"]); + expect(sshResult.message).toContain("sess-123"); + }); + + it("omits the ttl option when not provided", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ session: session as never }); + const toolkit = createTenkiToolkit(sandbox); + const preview = toolkit.tools[0] as { execute: (input: unknown) => Promise }; + + await preview.execute({ port: 8080 }); + + expect(session.exposePort).toHaveBeenCalledWith(8080, undefined); + }); + + it("describes authorize_ssh_key as additive with the out-of-band caveat", () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never }); + const toolkit = createTenkiToolkit(sandbox); + const ssh = toolkit.tools[1] as { description: string }; + expect(ssh.description).toContain("Additive"); + expect(ssh.description).toContain("out-of-band"); + }); + + it("preserves configured keys when authorize_ssh_key runs through the tool", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ + session: session as never, + sshAuthorizedKeys: ["cfg-key"], + }); + const toolkit = createTenkiToolkit(sandbox); + const ssh = toolkit.tools[1] as { execute: (input: unknown) => Promise }; + + await ssh.execute({ publicKey: "ssh-ed25519 AAAA user" }); + + expect(session.updateSshAuthorizedKeys).toHaveBeenCalledWith([ + "cfg-key", + "ssh-ed25519 AAAA user", + ]); + }); +}); + +describe("TenkiSandbox.authorizeSshKey", () => { + it("merges constructor config keys with the new key and returns the session id", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ + session: session as never, + sshAuthorizedKeys: ["cfg-key"], + }); + + const result = await sandbox.authorizeSshKey("new-key"); + + expect(session.updateSshAuthorizedKeys).toHaveBeenCalledWith(["cfg-key", "new-key"]); + expect(result).toEqual({ sessionId: "sess-123" }); + }); + + it("preserves keys supplied via the createOptions escape hatch", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ + session: session as never, + createOptions: { sshAuthorizedKeys: ["hatch-key"] }, + }); + + await sandbox.authorizeSshKey("new-key"); + + expect(session.updateSshAuthorizedKeys).toHaveBeenCalledWith(["hatch-key", "new-key"]); + }); + + it("accumulates keys across sequential adds", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ + session: session as never, + sshAuthorizedKeys: ["cfg"], + }); + + await sandbox.authorizeSshKey("k1"); + await sandbox.authorizeSshKey("k2"); + + expect(session.updateSshAuthorizedKeys).toHaveBeenNthCalledWith(1, ["cfg", "k1"]); + expect(session.updateSshAuthorizedKeys).toHaveBeenNthCalledWith(2, ["cfg", "k1", "k2"]); + }); + + it("dedupes repeated keys and keys equal to config keys", async () => { + const session = makeSession(); + const sandbox = new TenkiSandbox({ + session: session as never, + sshAuthorizedKeys: ["cfg"], + }); + + await sandbox.authorizeSshKey("k1"); + await sandbox.authorizeSshKey("k1"); + await sandbox.authorizeSshKey("cfg"); + + expect(session.updateSshAuthorizedKeys).toHaveBeenNthCalledWith(2, ["cfg", "k1"]); + expect(session.updateSshAuthorizedKeys).toHaveBeenNthCalledWith(3, ["cfg", "k1"]); + }); + + it("does not record a key whose RPC failed and keeps the queue usable", async () => { + const session = makeSession(); + session.updateSshAuthorizedKeys.mockRejectedValueOnce(new Error("rpc down")); + const sandbox = new TenkiSandbox({ session: session as never }); + + await expect(sandbox.authorizeSshKey("k1")).rejects.toThrow("rpc down"); + await sandbox.authorizeSshKey("k2"); + + expect(session.updateSshAuthorizedKeys).toHaveBeenNthCalledWith(2, ["k2"]); + }); + + it("serializes concurrent adds so neither key is lost", async () => { + let releaseFirst: () => void = () => {}; + const session = makeSession({ + updateSshAuthorizedKeys: vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve; + }), + ), + }); + const sandbox = new TenkiSandbox({ session: session as never }); + + const first = sandbox.authorizeSshKey("k1"); + const second = sandbox.authorizeSshKey("k2"); + // The second update must not be issued while the first RPC is in flight. + await vi.waitFor(() => expect(session.updateSshAuthorizedKeys).toHaveBeenCalledTimes(1)); + + releaseFirst(); + await Promise.all([first, second]); + + expect(session.updateSshAuthorizedKeys).toHaveBeenNthCalledWith(1, ["k1"]); + expect(session.updateSshAuthorizedKeys).toHaveBeenNthCalledWith(2, ["k1", "k2"]); + }); + + it("rejects after the sandbox is destroyed", async () => { + const sandbox = new TenkiSandbox({ session: makeSession() as never }); + await sandbox.destroy(); + await expect(sandbox.authorizeSshKey("k1")).rejects.toThrow("Sandbox has been destroyed"); + }); +}); diff --git a/packages/sandbox-tenki/src/sandbox.ts b/packages/sandbox-tenki/src/sandbox.ts new file mode 100644 index 000000000..7978169a1 --- /dev/null +++ b/packages/sandbox-tenki/src/sandbox.ts @@ -0,0 +1,830 @@ +import { + type ClientOptions, + type CreateOptions, + type ProcessRunHandle, + type Session, + TenkiSandbox as TenkiClient, +} from "@tenkicloud/sandbox"; +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, + WorkspaceSandboxStatus, +} from "@voltagent/core"; +import { normalizeCommandAndArgs } from "@voltagent/core"; +import { + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_TIMEOUT_MS, + type OutputBuffer, + abortedResult, + appendOutput, + appendRunDiagnostic, + decodeBytes, + extractSignal, + formatRunDiagnostic, + initOutputBuffer, + isCommandTimeoutError, + normalizeEnv, + resolveOutput, + stringToReadableStream, + timedOutResult, +} from "./utils"; + +/** + * The underlying Tenki SDK session type, re-exported for consumers that reach + * past the `WorkspaceSandbox` seam via {@link TenkiSandbox.getSandbox}. + */ +export type TenkiSandboxInstance = Session; + +/** + * Public constructor options for {@link TenkiSandbox}. + * + * The adapter only relies on Tenki's create/exec/close + preview + SSH surface; + * it never touches volumes, templates, or snapshots. `image`/`snapshotId` are + * forwarded to `createAndWait` as-is when provided but are not required. + */ +export type TenkiSandboxOptions = { + /** + * Tenki API key (keys are prefixed `tk_`). Forwarded to the SDK client as + * `authToken`. When omitted, the SDK falls back to `TENKI_AUTH_TOKEN` / + * `TENKI_API_KEY` from the environment. + */ + apiKey?: string; + /** + * Alias for {@link TenkiSandboxOptions.apiKey}. `apiKey` takes precedence when + * both are set. + */ + authToken?: string; + /** + * Override the Tenki API base URL. + */ + baseUrl?: string; + /** + * Human-readable session name. + */ + name?: string; + /** + * vCPUs to allocate to the microVM. + */ + cpuCores?: number; + /** + * Memory (MiB) to allocate to the microVM. + */ + memoryMb?: number; + /** + * Default environment variables merged into every `execute()` call. + */ + env?: Record; + /** + * Default working directory for `execute()`; per-call `cwd` overrides it. + */ + cwd?: string; + /** + * Allow inbound connections. Required for preview URLs. Default `true`. + */ + allowInbound?: boolean; + /** + * Allow outbound network egress. Default `true`. + */ + allowOutbound?: boolean; + /** + * SSH public keys authorized on session creation. + */ + sshAuthorizedKeys?: string[]; + /** + * Container image to boot the microVM from (optional). + */ + image?: string; + /** + * Snapshot to restore the microVM from (optional). + */ + snapshotId?: string; + /** + * Explicit Tenki workspace scope for trusted service credentials. Ordinary + * workspace API keys infer their workspace server-side and should omit this. + */ + workspaceId?: string; + /** + * Default command timeout (ms). Per-call `timeoutMs` overrides it. + * Default `60000`. Set to `0` to disable. + */ + defaultTimeoutMs?: number; + /** + * Max stdout/stderr bytes kept per stream before truncation. + * Default `5 * 1024 * 1024` (5 MiB). + */ + maxOutputBytes?: number; + /** + * Extra options forwarded verbatim to `createAndWait` (escape hatch for + * fields the adapter does not surface, e.g. `idleTimeoutMinutes`, `tags`). + */ + createOptions?: CreateOptions; + /** + * Pre-resolved Tenki session to reuse instead of creating a new one. + */ + session?: Session; +}; + +/** + * VoltAgent workspace sandbox provider backed by `@tenkicloud/sandbox`. + * + * Provisions a single disposable Tenki Linux microVM per instance and reuses it + * across every `execute_command` via the session `run` API (which gives native + * `cwd`/`env`/`stdin`, per-command kill for timeout/abort, and separate + * stdout/stderr streams). + */ +export class TenkiSandbox implements WorkspaceSandbox { + /** Provider identifier from the `WorkspaceSandbox` contract. Always `"tenki"`. */ + name = "tenki"; + + /** Lifecycle status surfaced to the workspace. */ + status: WorkspaceSandboxStatus = "idle"; + + private readonly clientOptions: ClientOptions; + private readonly createOptions: CreateOptions; + private readonly env: Record; + private readonly cwd?: string; + private readonly defaultTimeoutMs: number; + private readonly maxOutputBytes: number; + + private client?: TenkiClient; + private session?: Session; + private sessionPromise?: Promise; + private readonly sessionsPendingClose = new Set(); + private destroyPromise?: Promise; + private paused = false; + private generation = 0; + + /** + * Serializes pause/resume RPCs so concurrent lifecycle callers observe one + * transition at a time. The chain itself always resolves; the promise + * returned to a caller still carries that caller's transition failure. + */ + private lifecycleTransition: Promise = Promise.resolve(); + + /** + * SSH keys successfully applied via {@link authorizeSshKey}. Tenki's + * `updateSshAuthorizedKeys` replaces the whole set and the SDK has no API to + * read the current keys back, so the adapter tracks what it has applied. + */ + private readonly addedSshKeys = new Set(); + + /** + * Serializes {@link authorizeSshKey} updates: two concurrent calls would + * otherwise merge from the same stale snapshot and the later replace-RPC + * would drop the earlier key. Always resolved; failures surface on the + * caller's promise, not the chain. + */ + private sshUpdateChain: Promise = Promise.resolve(); + + constructor(options: TenkiSandboxOptions = {}) { + const authToken = options.apiKey ?? options.authToken; + this.clientOptions = {}; + if (authToken !== undefined) { + this.clientOptions.authToken = authToken; + } + if (options.baseUrl !== undefined) { + this.clientOptions.baseUrl = options.baseUrl; + } + + this.env = normalizeEnv(options.env); + this.cwd = options.cwd; + this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + + this.createOptions = { + allowInbound: options.allowInbound ?? true, + allowOutbound: options.allowOutbound ?? true, + ...(options.createOptions ?? {}), + }; + if (options.name !== undefined) { + this.createOptions.name = options.name; + } + if (options.cpuCores !== undefined) { + this.createOptions.cpuCores = options.cpuCores; + } + if (options.memoryMb !== undefined) { + this.createOptions.memoryMb = options.memoryMb; + } + if (options.sshAuthorizedKeys !== undefined) { + this.createOptions.sshAuthorizedKeys = options.sshAuthorizedKeys; + } + if (options.image !== undefined) { + this.createOptions.image = options.image; + } + if (options.snapshotId !== undefined) { + this.createOptions.snapshotId = options.snapshotId; + } + if (options.workspaceId !== undefined) { + this.createOptions.workspaceId = options.workspaceId; + } + if (Object.keys(this.env).length > 0) { + this.createOptions.env = { ...this.env, ...(this.createOptions.env ?? {}) }; + } + + if (options.session) { + this.session = options.session; + this.sessionPromise = Promise.resolve(options.session); + this.paused = options.session.state === "PAUSED"; + this.status = this.paused ? "idle" : "ready"; + } + } + + /** + * Lazily create (and cache) the Tenki client. + */ + private getClient(): TenkiClient { + if (!this.client) { + this.client = new TenkiClient(this.clientOptions); + } + return this.client; + } + + /** + * Return the cached session promise, kicking off `createAndWait` on first + * call. On failure the cached promise is cleared so the next call retries + * instead of replaying the rejected promise. + */ + private ensureSession(): Promise { + if (this.status === "destroyed") { + return Promise.reject(new Error("Sandbox has been destroyed")); + } + if (!this.sessionPromise) { + // Snapshot the generation so a `destroy()` racing this in-flight + // provisioning is detectable once `createAndWait` finally settles. + const generation = this.generation; + const promise = this.getClient() + .createAndWait(this.createOptions) + .then((session) => { + if (generation !== this.generation) { + // `destroy()` owns teardown, including sessions that finish + // provisioning after destruction begins. Retain the session so + // destroy can await close and retry it if the RPC fails. + if (session) { + this.sessionsPendingClose.add(session); + } + throw new Error("Sandbox has been destroyed"); + } + // Keep the existing defensive behavior for a malformed SDK/client + // response; execute() maps a nullish session to an aborted result. + if (!session) { + return session; + } + this.session = session; + this.paused = session.state === "PAUSED"; + this.status = this.paused ? "idle" : "ready"; + return session; + }) + .catch((error) => { + // Only surface provisioning failure as `error` if we still own the + // current generation; never clobber a `destroyed` status. + if (generation === this.generation) { + this.sessionPromise = undefined; + this.status = "error"; + } + throw error; + }); + this.sessionPromise = promise; + return promise; + } + return this.sessionPromise; + } + + /** + * Return the underlying Tenki SDK session, creating it on first call. + * Used by {@link createTenkiToolkit} and as the escape hatch for Tenki-specific + * APIs beyond `execute` (SSH, filesystem, port exposure, etc.). + */ + async getSandbox(): Promise { + const session = await this.ensureSession(); + // ensureSession's defensive path can hand back a nullish session; fail with + // a clear error instead of returning `undefined` typed as a Session. + if (!session) { + throw new Error("Tenki client returned no session"); + } + await this.resumeIfPaused(session); + return session; + } + + /** + * Authorize an SSH public key without revoking keys this adapter already + * knows about. Tenki's `updateSshAuthorizedKeys` is a full-set replace and + * the SDK cannot read the current set back, so each update re-sends the + * constructor's `sshAuthorizedKeys` plus every key previously added here; + * keys authorized out-of-band via the SDK are not preserved. A key is + * recorded only after its RPC succeeds, and updates are serialized so + * concurrent adds cannot lose keys to a stale merge. The accumulator never + * needs re-applying: a session, once provisioned, is only replaced by + * {@link destroy}, after which this method rejects. + */ + async authorizeSshKey(publicKey: string): Promise<{ sessionId: string }> { + const run = async (): Promise<{ sessionId: string }> => { + const session = await this.getSandbox(); + const merged = [ + ...new Set([ + ...(this.createOptions.sshAuthorizedKeys ?? []), + ...this.addedSshKeys, + publicKey, + ]), + ]; + await session.updateSshAuthorizedKeys(merged); + this.addedSshKeys.add(publicKey); + return { sessionId: session.id }; + }; + const task = this.sshUpdateChain.then(run); + this.sshUpdateChain = task.then( + () => undefined, + () => undefined, + ); + return task; + } + + /** Provision the session eagerly (resuming it if a prior {@link stop} paused it). */ + async start(): Promise { + const session = await this.ensureSession(); + await this.resumeIfPaused(session); + } + + /** + * Resume the microVM when a previous {@link stop} paused it, returning the + * sandbox to `ready` so commands can run again. No-op otherwise. + */ + private async resumeIfPaused(session: Session): Promise { + return this.serializeLifecycleTransition(async () => { + if (this.status === "destroyed" || this.session !== session) { + throw new Error("Sandbox has been destroyed"); + } + if (!this.paused) { + return; + } + + const generation = this.generation; + await session.resume(); + + // Destruction eagerly invalidates the generation and drops the owned + // session. A resume RPC may still finish afterward, but it must neither + // resurrect public state nor allow its caller to use the closed session. + if (generation !== this.generation || this.session !== session) { + throw new Error("Sandbox has been destroyed"); + } + this.paused = false; + this.status = "ready"; + }); + } + + private serializeLifecycleTransition(operation: () => Promise): Promise { + const transition = this.lifecycleTransition.then(operation, operation); + this.lifecycleTransition = transition.then( + () => undefined, + () => undefined, + ); + return transition; + } + + /** + * Pause the microVM (billing continues per Tenki's pause semantics) and mark + * the sandbox `idle`. Reversible: {@link start}/{@link execute} resume it. + */ + async stop(): Promise { + if (this.status === "destroyed" || !this.sessionPromise) { + return; + } + + const generation = this.generation; + let session: Session; + try { + // Do not create a session solely to stop it, but if this sandbox already + // owns an in-flight provisioning attempt, wait for that exact session and + // pause it before stop resolves. + session = await this.sessionPromise; + } catch (error) { + if (generation !== this.generation) { + return; + } + throw error; + } + // ensureSession's defensive path can hand back a nullish session; there is + // nothing to pause in that case. + if (!session) { + return; + } + + await this.serializeLifecycleTransition(async () => { + // Destroy supersedes a queued/in-flight stop and owns session teardown. + if (this.status === "destroyed" || this.session !== session || this.paused) { + return; + } + + const generation = this.generation; + await session.pause(); + if (generation !== this.generation || this.session !== session) { + return; + } + this.paused = true; + this.status = "idle"; + }); + } + + /** + * Close every owned microVM. Best-effort: `destroy()` never rejects — core's + * `Workspace.destroy()` does not guard against a throwing sandbox, and the + * sibling providers share this contract. A failed close stays retained in + * {@link sessionsPendingClose} so a later `destroy()` call retries it. + */ + async destroy(): Promise { + if (this.destroyPromise) { + return this.destroyPromise; + } + + const operation = this.destroyOwnedSessions(); + this.destroyPromise = operation; + try { + await operation; + } finally { + if (this.destroyPromise === operation) { + this.destroyPromise = undefined; + } + } + } + + private async destroyOwnedSessions(): Promise { + const pendingSession = this.sessionPromise; + if (this.session) { + this.sessionsPendingClose.add(this.session); + } + this.session = undefined; + this.sessionPromise = undefined; + this.paused = false; + + if (this.status !== "destroyed") { + this.status = "destroyed"; + // Invalidate in-flight provisioning before awaiting it so its generation + // guard records the late session for teardown instead of resurrecting it. + this.generation += 1; + } + + if (pendingSession) { + try { + // A pre-supplied or already-resolved session may not pass through the + // generation guard, so record the fulfilled value here as well. It can + // be nullish (ensureSession's defensive path); adding that would make + // close() throw and poison the retry set for every later destroy. + const settled = await pendingSession; + if (settled) { + this.sessionsPendingClose.add(settled); + } + } catch { + // Provisioning failures do not create a session to close. A late + // success rejected by the generation guard has already retained it. + } + } + + await Promise.allSettled( + [...this.sessionsPendingClose].map(async (session) => { + await session.close(); + this.sessionsPendingClose.delete(session); + }), + ); + } + + getInfo(): Record { + return { + provider: "tenki", + status: this.status, + sessionId: this.session?.id, + }; + } + + getInstructions(): string { + // Reflect the effective configuration instead of asserting fixed facts: a + // custom image/snapshot can lack the base tools and standard working + // directory, and egress can be disabled. + const usesCustomImage = + this.createOptions.image !== undefined || this.createOptions.snapshotId !== undefined; + const lines = ["Commands run in a disposable Tenki Linux microVM."]; + if (!usesCustomImage) { + lines.push("Base tools: bash, git, node, npm, python3."); + } + if (this.cwd) { + lines.push(`Working directory is ${this.cwd}.`); + } else if (!usesCustomImage) { + lines.push("Writable working directory is /home/tenki."); + } + lines.push( + this.createOptions.allowOutbound === false + ? "Network egress is disabled." + : "Network egress is enabled.", + ); + return lines.join(" "); + } + + /** + * Pump one of the process output streams into a byte-bounded buffer while + * forwarding decoded chunks to the caller's streaming callback. Errors are + * swallowed (best-effort streaming); the final result bytes act as fallback. + */ + private async pumpStream( + readable: ReadableStream, + buffer: OutputBuffer, + maxOutputBytes: number, + onChunk: ((chunk: string) => void) | undefined, + signal: AbortSignal, + ): Promise { + const reader = readable.getReader(); + // Per-stream incremental decoder so a multi-byte code point split across + // chunk boundaries is buffered and completed instead of being emitted to + // `onChunk` as replacement characters. Only allocated when streaming. + const decoder = onChunk ? new TextDecoder() : undefined; + const emit = (text: string) => { + if (!text || !onChunk) { + return; + } + try { + onChunk(text); + } catch { + // ignore streaming callback errors + } + }; + let canceled = signal.aborted; + const cancelReader = () => { + canceled = true; + // Canceling a reader releases a pending `read()` in native Web Streams. + // The underlying stream's cancellation hook may still reject or remain + // pending, so keep it best-effort and never await it here. + try { + void reader.cancel().catch(() => undefined); + } catch { + // ignore synchronous cancellation errors from non-standard streams + } + }; + if (signal.aborted) { + cancelReader(); + } else { + signal.addEventListener("abort", cancelReader, { once: true }); + } + try { + for (;;) { + const { done, value } = await reader.read(); + if (done || canceled) { + break; + } + appendOutput(buffer, value, maxOutputBytes); + if (decoder) { + emit(decoder.decode(value, { stream: true })); + } + } + } catch { + // ignore stream errors; result bytes are used as a fallback + } finally { + // Flush any bytes the decoder is still holding for a partial code point. + if (decoder) { + emit(decoder.decode()); + } + signal.removeEventListener("abort", cancelReader); + try { + reader.releaseLock(); + } catch { + // A non-standard stream may keep a canceled read pending. In that case + // its eventual settlement still has this pump attached as an observer. + } + } + } + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + if (this.status === "destroyed") { + throw new Error("Sandbox has been destroyed"); + } + + const startTime = Date.now(); + const normalized = normalizeCommandAndArgs(options.command ?? "", options.args); + const command = normalized.command.trim(); + + if (!command) { + throw new Error("Sandbox command is required"); + } + + if (options.signal?.aborted) { + return abortedResult(0); + } + + const timeoutMs = + options.timeoutMs === undefined ? this.defaultTimeoutMs : Math.max(0, options.timeoutMs); + const maxOutputBytes = + options.maxOutputBytes === undefined + ? this.maxOutputBytes + : Math.max(0, options.maxOutputBytes); + const env = { ...this.env, ...normalizeEnv(options.env) }; + const cwd = options.cwd ?? this.cwd; + + let aborted = false; + let timedOut = false; + let handle: ProcessRunHandle | undefined; + + // Every network/process await races this sentinel. The controller also + // cancels pending stream reads so pumps do not remain locked after execute + // has returned. Late operation rejections stay observed by + // `raceCancellation` even when cancellation wins first. + const cancellationController = new AbortController(); + const cancellationMarker = Symbol("execution canceled"); + let resolveCancellation!: (marker: typeof cancellationMarker) => void; + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve; + }); + const settleCancellation = () => { + cancellationController.abort(); + resolveCancellation(cancellationMarker); + }; + const raceCancellation = ( + operation: PromiseLike, + ): Promise => { + const observed = Promise.resolve(operation); + // Promise.race observes rejections too, but keep an explicit observer so + // the containment guarantee is clear when cancellation wins first. + void observed.catch(() => undefined); + return Promise.race([observed, cancellation]); + }; + + let killRequested = false; + const requestKill = () => { + if (!handle || killRequested) { + return; + } + killRequested = true; + // Killing is cleanup, not part of the caller-visible deadline: a broken + // data plane can make it reject, throw synchronously, or never settle. + try { + void Promise.resolve(handle.kill()).catch(() => undefined); + } catch { + // best-effort process cleanup + } + }; + const cancelExecution = (reason: "aborted" | "timedOut") => { + // Whichever source fires first owns the result classification. + if (aborted || timedOut) { + return; + } + if (reason === "aborted") { + aborted = true; + } else { + timedOut = true; + } + settleCancellation(); + requestKill(); + }; + + const cancellationResult = (): WorkspaceSandboxResult => { + const durationMs = Date.now() - startTime; + return timedOut ? timedOutResult(durationMs) : abortedResult(durationMs); + }; + + let abortListener: (() => void) | undefined; + if (options.signal) { + abortListener = () => { + cancelExecution("aborted"); + }; + options.signal.addEventListener("abort", abortListener, { once: true }); + } + + let timeoutId: ReturnType | undefined; + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + cancelExecution("timedOut"); + }, timeoutMs); + } + + const stdoutBuffer = initOutputBuffer(); + const stderrBuffer = initOutputBuffer(); + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (options.signal && abortListener) { + options.signal.removeEventListener("abort", abortListener); + } + }; + + let result: Awaited | undefined; + + try { + // Race provisioning against cancellation so a timeout/abort settles + // `execute()` promptly instead of blocking on `createAndWait`. When + // cancellation wins, provisioning keeps running in the background and + // caches the session (a later `destroy()` closes it via the generation + // guard). Swallow a late rejection so bailing out here cannot surface as + // an unhandled promise rejection. + const session = await raceCancellation(this.ensureSession()); + + // A timeout or abort may have fired while provisioning was pending. + // Short-circuit before launching the process. + if (session === cancellationMarker) { + return cancellationResult(); + } + if (!session) { + // Not reachable via cancellation — both paths set timedOut/aborted + // first — so this only fires if the SDK hands back a nullish session. + return abortedResult(Date.now() - startTime); + } + + // Resume before launching, and re-check: `session.resume()` is a network + // RPC, so a timeout/abort can fire while it is in flight — and `handle` + // does not exist yet, so `requestKill()` would have nothing to kill. This + // is the only await left between the guards and `session.run()`; the + // `runOptions` build below is synchronous. + const resumed = await raceCancellation(this.resumeIfPaused(session)); + if (resumed === cancellationMarker) { + return cancellationResult(); + } + + const runOptions: { + env?: Record; + cwd?: string; + stdin: ReadableStream; + } = { + stdin: stringToReadableStream(options.stdin ?? ""), + }; + if (Object.keys(env).length > 0) { + runOptions.env = env; + } + if (cwd) { + runOptions.cwd = cwd; + } + + handle = session.run([command, ...(normalized.args ?? [])], runOptions); + // Observe the thenable before touching its stream properties: malformed + // or already-failed streams must not leave a later run rejection + // unhandled. A synchronous abort triggered inside a custom `run()` also + // missed the earlier kill request because assignment had not completed. + const runCompletion = Promise.resolve(handle); + void runCompletion.catch(() => undefined); + if (aborted || timedOut) { + requestKill(); + } + + const streaming = Promise.all([ + this.pumpStream( + handle.stdout, + stdoutBuffer, + maxOutputBytes, + options.onStdout, + cancellationController.signal, + ), + this.pumpStream( + handle.stderr, + stderrBuffer, + maxOutputBytes, + options.onStderr, + cancellationController.signal, + ), + ]); + void streaming.catch(() => undefined); + + const runResult = await raceCancellation(runCompletion); + if (runResult !== cancellationMarker) { + result = runResult; + } + if (!aborted && !timedOut) { + await raceCancellation(streaming); + } + } catch (error) { + if (isCommandTimeoutError(error)) { + cancelExecution("timedOut"); + } else if (!aborted && !timedOut) { + // Release stream readers and observe any work that outlives this + // unexpected failure before propagating it. + settleCancellation(); + requestKill(); + throw error; + } + } finally { + cleanup(); + } + + const stdoutInfo = resolveOutput( + stdoutBuffer, + result ? decodeBytes(result.stdout) : undefined, + maxOutputBytes, + ); + const stderrInfo = resolveOutput( + stderrBuffer, + result ? decodeBytes(result.stderr) : undefined, + maxOutputBytes, + ); + + return { + stdout: stdoutInfo.content, + // `WorkspaceSandboxResult` has no field for Tenki's `errno`/`reason`, and + // an exec failure arrives as a resolved run with empty stderr — so fold + // the diagnostic into stderr rather than dropping the only signal that + // explains the exit code. + stderr: appendRunDiagnostic(stderrInfo.content, formatRunDiagnostic(result)), + exitCode: result ? result.exitCode : null, + signal: extractSignal(result), + durationMs: Date.now() - startTime, + timedOut, + aborted, + stdoutTruncated: stdoutInfo.truncated, + stderrTruncated: stderrInfo.truncated, + }; + } +} diff --git a/packages/sandbox-tenki/src/tools.spec.ts b/packages/sandbox-tenki/src/tools.spec.ts new file mode 100644 index 000000000..27d477746 --- /dev/null +++ b/packages/sandbox-tenki/src/tools.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TenkiSandbox } from "./sandbox"; +import { createTenkiToolkit } from "./tools"; + +type PreviewParameters = { + safeParse: (input: unknown) => { success: boolean }; +}; + +const getPreviewParameters = (): PreviewParameters => { + const sandbox = { + getSandbox: vi.fn(), + authorizeSshKey: vi.fn(), + } as unknown as TenkiSandbox; + const previewTool = createTenkiToolkit(sandbox).tools.find( + (tool) => (tool as { name?: string }).name === "expose_preview_url", + ); + + if (!previewTool) { + throw new Error("expose_preview_url tool not found"); + } + + return (previewTool as { parameters: PreviewParameters }).parameters; +}; + +describe("createTenkiToolkit preview input schema", () => { + it.each([1, 65535])("accepts boundary port %s", (port) => { + expect(getPreviewParameters().safeParse({ port }).success).toBe(true); + }); + + it.each([0, -1, 65536, 1.5])("rejects invalid port %s", (port) => { + expect(getPreviewParameters().safeParse({ port }).success).toBe(false); + }); + + it("accepts an omitted or positive integer TTL", () => { + const parameters = getPreviewParameters(); + + expect(parameters.safeParse({ port: 3000 }).success).toBe(true); + expect(parameters.safeParse({ port: 3000, ttlMs: 1 }).success).toBe(true); + }); + + it.each([0, -1, 1.5])("rejects invalid TTL %s", (ttlMs) => { + expect(getPreviewParameters().safeParse({ port: 3000, ttlMs }).success).toBe(false); + }); +}); diff --git a/packages/sandbox-tenki/src/tools.ts b/packages/sandbox-tenki/src/tools.ts new file mode 100644 index 000000000..4193c3e6b --- /dev/null +++ b/packages/sandbox-tenki/src/tools.ts @@ -0,0 +1,76 @@ +import { type Toolkit, createTool, createToolkit } from "@voltagent/core"; +import { z } from "zod"; +import type { TenkiSandbox } from "./sandbox"; + +/** + * Build a toolkit of Tenki-specific tools that reach past the + * `WorkspaceSandbox` seam: exposing a preview URL for a port, and authorizing + * an SSH public key. Both reuse the adapter's single cached session — preview + * URLs via {@link TenkiSandbox.getSandbox}, SSH authorization via + * {@link TenkiSandbox.authorizeSshKey}. + * + * These are intentionally separate from the core `execute_command` adapter so a + * consumer can opt in without them. + */ +export function createTenkiToolkit(sandbox: TenkiSandbox): Toolkit { + const exposePreviewUrl = createTool({ + name: "expose_preview_url", + description: + "Expose a TCP port inside the Tenki microVM and return a public preview URL. " + + "Requires the sandbox to allow inbound connections (the default).", + parameters: z.object({ + port: z + .number() + .int() + .min(1) + .max(65535) + .describe("Port inside the sandbox to expose (1-65535, e.g. 3000)"), + ttlMs: z + .number() + .int() + .positive() + .optional() + .describe("Optional positive time-to-live for the preview URL, in milliseconds"), + }), + outputSchema: z.object({ + previewUrl: z.string().describe("Public URL routing to the exposed port"), + }), + execute: async ({ port, ttlMs }) => { + const session = await sandbox.getSandbox(); + const exposed = await session.exposePort(port, ttlMs === undefined ? undefined : { ttlMs }); + return { previewUrl: exposed.previewUrl }; + }, + }); + + const authorizeSshKey = createTool({ + name: "authorize_ssh_key", + description: + "Authorize an SSH public key on the Tenki microVM so it can be reached over SSH. " + + "Additive: keys configured at sandbox creation and keys previously added by this " + + "tool are preserved. Keys authorized out-of-band via the Tenki SDK are not " + + "preserved (the SDK cannot read the current key set).", + parameters: z.object({ + publicKey: z + .string() + .describe("SSH public key in authorized_keys format (e.g. 'ssh-ed25519 AAAA... user')"), + }), + outputSchema: z.object({ + message: z.string().describe("Human-readable connection hint"), + }), + execute: async ({ publicKey }) => { + const { sessionId } = await sandbox.authorizeSshKey(publicKey); + return { + message: `SSH key authorized for session ${sessionId}. Connect with the matching private key.`, + }; + }, + }); + + return createToolkit({ + name: "tenki", + instructions: + "Tools for a Tenki microVM sandbox. Use expose_preview_url to get a public URL for a " + + "server listening on a port inside the sandbox, and authorize_ssh_key to grant SSH access.", + addInstructions: true, + tools: [exposePreviewUrl, authorizeSshKey], + }); +} diff --git a/packages/sandbox-tenki/src/utils.spec.ts b/packages/sandbox-tenki/src/utils.spec.ts new file mode 100644 index 000000000..e333d8e96 --- /dev/null +++ b/packages/sandbox-tenki/src/utils.spec.ts @@ -0,0 +1,322 @@ +import { CommandTimeoutError } from "@tenkicloud/sandbox"; +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_TIMEOUT_MS, + abortedResult, + appendOutput, + appendRunDiagnostic, + decodeBytes, + extractSignal, + formatRunDiagnostic, + initOutputBuffer, + isCommandTimeoutError, + normalizeEnv, + resolveOutput, + stringToReadableStream, + timedOutResult, + truncateOutput, +} from "./utils"; + +const enc = new TextEncoder(); + +describe("constants", () => { + it("exposes sane defaults", () => { + expect(DEFAULT_TIMEOUT_MS).toBe(60_000); + expect(DEFAULT_MAX_OUTPUT_BYTES).toBe(5 * 1024 * 1024); + }); +}); + +describe("decodeBytes", () => { + it("passes strings through", () => { + expect(decodeBytes("hello")).toBe("hello"); + }); + + it("decodes Uint8Array", () => { + expect(decodeBytes(enc.encode("héllo"))).toBe("héllo"); + }); + + it("coerces other values and handles nullish", () => { + expect(decodeBytes(42)).toBe("42"); + expect(decodeBytes(null)).toBe(""); + expect(decodeBytes(undefined)).toBe(""); + }); +}); + +describe("OutputBuffer", () => { + it("accumulates string, Buffer, and Uint8Array chunks", () => { + const buffer = initOutputBuffer(); + appendOutput(buffer, "a", 100); + appendOutput(buffer, Buffer.from("b"), 100); + appendOutput(buffer, enc.encode("c"), 100); + const resolved = resolveOutput(buffer, undefined, 100); + expect(resolved.content).toBe("abc"); + expect(resolved.truncated).toBe(false); + }); + + it("coerces unknown chunk types", () => { + const buffer = initOutputBuffer(); + appendOutput(buffer, 123, 100); + expect(resolveOutput(buffer, undefined, 100).content).toBe("123"); + }); + + it("flags truncation when maxBytes is 0", () => { + const buffer = initOutputBuffer(); + appendOutput(buffer, "x", 0); + expect(buffer.truncated).toBe(true); + expect(resolveOutput(buffer, undefined, 0).truncated).toBe(true); + }); + + it("truncates a chunk that overflows the remaining budget", () => { + const buffer = initOutputBuffer(); + appendOutput(buffer, "abcdef", 3); + const resolved = resolveOutput(buffer, undefined, 3); + expect(resolved.content).toBe("abc"); + expect(resolved.truncated).toBe(true); + }); + + it("drops a multi-byte code point split by the byte cap", () => { + const buffer = initOutputBuffer(); + // "😀" (U+1F600) = F0 9F 98 80 (4 bytes); a 2-byte cap lands mid-code-point. + appendOutput(buffer, new Uint8Array([0xf0, 0x9f, 0x98, 0x80]), 2); + const resolved = resolveOutput(buffer, undefined, 2); + expect(resolved.truncated).toBe(true); + // The partial code point is dropped, not decoded to a 3-byte "�" that would + // overshoot the 2-byte cap. + expect(resolved.content).toBe(""); + expect(Buffer.byteLength(resolved.content, "utf-8")).toBeLessThanOrEqual(2); + }); + + it("keeps whole code points before a byte-cap truncation", () => { + const buffer = initOutputBuffer(); + // "a😀" = 61 F0 9F 98 80 (5 bytes); a 3-byte cap keeps "a", drops the emoji. + appendOutput(buffer, new Uint8Array([0x61, 0xf0, 0x9f, 0x98, 0x80]), 3); + const resolved = resolveOutput(buffer, undefined, 3); + expect(resolved.content).toBe("a"); + expect(resolved.truncated).toBe(true); + expect(Buffer.byteLength(resolved.content, "utf-8")).toBeLessThanOrEqual(3); + }); + + it("marks truncation once the budget is already exhausted", () => { + const buffer = initOutputBuffer(); + appendOutput(buffer, "abc", 3); + appendOutput(buffer, "d", 3); + expect(buffer.truncated).toBe(true); + }); + + it("returns empty for an untouched buffer with no fallback", () => { + const resolved = resolveOutput(initOutputBuffer(), undefined, 100); + expect(resolved).toEqual({ content: "", truncated: false }); + }); + + it("falls back to the provided string when nothing was streamed", () => { + const resolved = resolveOutput(initOutputBuffer(), "fallback", 100); + expect(resolved.content).toBe("fallback"); + }); + + it("resolves to empty when every captured chunk was zero-length", () => { + const buffer = initOutputBuffer(); + appendOutput(buffer, "", 100); + appendOutput(buffer, "x", 0); + // Truncated with nothing buffered: the buffer wins over the fallback. + expect(resolveOutput(buffer, "fallback", 100)).toEqual({ content: "", truncated: true }); + }); + + it("drops a run of stray continuation bytes", () => { + const buffer = initOutputBuffer(); + // 0x80 0x80 is a code point tail with no lead byte — malformed all the way + // back to the start of the buffer. + appendOutput(buffer, new Uint8Array([0x80, 0x80]), 100); + expect(resolveOutput(buffer, undefined, 100).content).toBe(""); + }); + + it("drops a trailing invalid lead byte", () => { + const buffer = initOutputBuffer(); + // 0xF8 is not a valid UTF-8 lead byte in any length class. + appendOutput(buffer, new Uint8Array([0x61, 0xf8]), 100); + expect(resolveOutput(buffer, undefined, 100).content).toBe("a"); + }); +}); + +describe("truncateOutput", () => { + it("returns empty input untouched", () => { + expect(truncateOutput("", 10)).toEqual({ content: "", truncated: false }); + }); + + it("truncates everything when maxBytes is 0", () => { + expect(truncateOutput("abc", 0)).toEqual({ content: "", truncated: true }); + }); + + it("returns short strings intact", () => { + expect(truncateOutput("abc", 10)).toEqual({ content: "abc", truncated: false }); + }); + + it("walks back to a codepoint boundary", () => { + // "€" is 3 bytes; cutting at 2 bytes must not split it. + const result = truncateOutput("a€b", 2); + expect(result.truncated).toBe(true); + expect(result.content).toBe("a"); + }); + + it("walks back past a split two-byte codepoint", () => { + // "é" is 2 bytes (C3 A9); a 2-byte cap lands on its lead byte. + const result = truncateOutput("aé", 2); + expect(result.truncated).toBe(true); + expect(result.content).toBe("a"); + }); +}); + +describe("normalizeEnv", () => { + it("returns empty object for undefined", () => { + expect(normalizeEnv(undefined)).toEqual({}); + }); + + it("drops nullish and coerces values", () => { + expect(normalizeEnv({ A: "1", B: undefined, C: "3" })).toEqual({ A: "1", C: "3" }); + }); +}); + +describe("stringToReadableStream", () => { + const readAll = async (stream: ReadableStream): Promise => { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8"); + }; + + it("streams the string contents", async () => { + expect(await readAll(stringToReadableStream("hello"))).toBe("hello"); + }); + + it("closes immediately for empty input", async () => { + expect(await readAll(stringToReadableStream(""))).toBe(""); + }); +}); + +describe("extractSignal", () => { + it("returns the signal string when present", () => { + expect(extractSignal({ signal: "SIGKILL" })).toBe("SIGKILL"); + }); + + it("returns undefined for empty/missing/non-object", () => { + expect(extractSignal({ signal: "" })).toBeUndefined(); + expect(extractSignal({})).toBeUndefined(); + expect(extractSignal(null)).toBeUndefined(); + expect(extractSignal("nope")).toBeUndefined(); + }); +}); + +describe("formatRunDiagnostic", () => { + it("names a known errno", () => { + expect(formatRunDiagnostic({ exitCode: 127, errno: 2, reason: "exec_failed" })).toBe( + "tenki: exec failed: ENOENT (errno 2), reason=exec_failed", + ); + }); + + it("falls back to the raw number for an unmapped errno", () => { + expect(formatRunDiagnostic({ exitCode: -1, errno: 999, reason: "exit" })).toBe( + "tenki: exec failed: errno 999", + ); + }); + + it("reports an errno failure even when the exit looks normal", () => { + // A wait-stage errno can arrive with exitCode 0; the failure still matters. + expect(formatRunDiagnostic({ exitCode: 0, errno: 13, reason: "" })).toBe( + "tenki: exec failed: EACCES (errno 13)", + ); + }); + + it("stays silent on a successful run", () => { + expect(formatRunDiagnostic({ exitCode: 0, errno: 0, reason: "exit" })).toBeUndefined(); + }); + + it("stays silent for an unrecognized reason on a successful run", () => { + // `reason` is free-form guest-agent text — it must not decorate every result. + expect(formatRunDiagnostic({ exitCode: 0, errno: 0, reason: "whatever" })).toBeUndefined(); + }); + + it("surfaces an unrecognized reason when the run failed", () => { + expect(formatRunDiagnostic({ exitCode: 137, errno: 0, reason: "oom_killed" })).toBe( + "tenki: run ended: reason=oom_killed", + ); + }); + + it("surfaces an unrecognized reason when the run was signaled", () => { + expect(formatRunDiagnostic({ exitCode: 0, signal: "KILL", reason: "engine_terminated" })).toBe( + "tenki: run ended: reason=engine_terminated", + ); + }); + + it("ignores reasons already covered by exitCode/signal", () => { + expect(formatRunDiagnostic({ exitCode: 2, errno: 0, reason: "exit" })).toBeUndefined(); + expect( + formatRunDiagnostic({ exitCode: 0, signal: "TERM", reason: "signaled" }), + ).toBeUndefined(); + }); + + it("returns undefined for a missing or non-object result", () => { + expect(formatRunDiagnostic(undefined)).toBeUndefined(); + expect(formatRunDiagnostic(null)).toBeUndefined(); + expect(formatRunDiagnostic("nope")).toBeUndefined(); + expect(formatRunDiagnostic({})).toBeUndefined(); + }); +}); + +describe("appendRunDiagnostic", () => { + it("returns stderr untouched when there is no diagnostic", () => { + expect(appendRunDiagnostic("boom\n", undefined)).toBe("boom\n"); + expect(appendRunDiagnostic("", undefined)).toBe(""); + }); + + it("stands alone when stderr is empty", () => { + expect(appendRunDiagnostic("", "tenki: x")).toBe("tenki: x\n"); + }); + + it("appends after existing stderr without doubling newlines", () => { + expect(appendRunDiagnostic("boom\n", "tenki: x")).toBe("boom\ntenki: x\n"); + expect(appendRunDiagnostic("boom", "tenki: x")).toBe("boom\ntenki: x\n"); + }); +}); + +describe("isCommandTimeoutError", () => { + it("recognizes CommandTimeoutError", () => { + expect(isCommandTimeoutError(new CommandTimeoutError("boom"))).toBe(true); + }); + + it("rejects other errors", () => { + expect(isCommandTimeoutError(new Error("boom"))).toBe(false); + expect(isCommandTimeoutError("boom")).toBe(false); + }); +}); + +describe("result builders", () => { + it("abortedResult carries the aborted flag and duration", () => { + expect(abortedResult(12)).toEqual({ + stdout: "", + stderr: "", + exitCode: null, + durationMs: 12, + timedOut: false, + aborted: true, + stdoutTruncated: false, + stderrTruncated: false, + }); + }); + + it("timedOutResult carries the timedOut flag and duration", () => { + expect(timedOutResult(34)).toEqual({ + stdout: "", + stderr: "", + exitCode: null, + durationMs: 34, + timedOut: true, + aborted: false, + stdoutTruncated: false, + stderrTruncated: false, + }); + }); +}); diff --git a/packages/sandbox-tenki/src/utils.ts b/packages/sandbox-tenki/src/utils.ts new file mode 100644 index 000000000..5d0b1d2f7 --- /dev/null +++ b/packages/sandbox-tenki/src/utils.ts @@ -0,0 +1,333 @@ +import { CommandTimeoutError } from "@tenkicloud/sandbox"; +import type { WorkspaceSandboxResult } from "@voltagent/core"; + +/** + * Default per-command timeout (ms) when the caller does not provide one. + */ +export const DEFAULT_TIMEOUT_MS = 60_000; + +/** + * Default cap (bytes) on stdout/stderr kept per stream before truncation. + */ +export const DEFAULT_MAX_OUTPUT_BYTES = 5 * 1024 * 1024; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +/** + * Decode a chunk of bytes coming off a Tenki `run` output stream into a string. + * Tenki delivers `Uint8Array` chunks; anything else is coerced defensively. + */ +export const decodeBytes = (chunk: unknown): string => { + if (typeof chunk === "string") { + return chunk; + } + if (chunk instanceof Uint8Array) { + return decoder.decode(chunk); + } + return String(chunk ?? ""); +}; + +/** + * Byte-bounded accumulator for a single output stream. Mirrors the E2B + * provider's buffering so `maxOutputBytes` truncation is enforced client-side. + */ +export type OutputBuffer = { + chunks: Buffer[]; + size: number; + truncated: boolean; +}; + +export const initOutputBuffer = (): OutputBuffer => ({ chunks: [], size: 0, truncated: false }); + +export const appendOutput = (buffer: OutputBuffer, chunk: unknown, maxBytes: number): void => { + if (maxBytes <= 0) { + buffer.truncated = true; + return; + } + + const data = + typeof chunk === "string" + ? Buffer.from(chunk, "utf-8") + : Buffer.isBuffer(chunk) + ? chunk + : chunk instanceof Uint8Array + ? Buffer.from(chunk) + : Buffer.from(String(chunk), "utf-8"); + + const remaining = maxBytes - buffer.size; + if (remaining <= 0) { + buffer.truncated = true; + return; + } + + if (data.length > remaining) { + buffer.chunks.push(data.subarray(0, remaining)); + buffer.size += remaining; + buffer.truncated = true; + return; + } + + buffer.chunks.push(data); + buffer.size += data.length; +}; + +/** + * Return the largest length `<= end` at which `buffer` ends on a complete UTF-8 + * code point. A byte-level cap (`appendOutput`) or a mid-stream truncation can + * land inside a multi-byte sequence; slicing at the returned index instead of a + * raw byte offset drops the incomplete trailing code point rather than decoding + * it to a 3-byte `�` replacement character (which would also overshoot the + * requested byte cap). Assumes bytes before the final code point are valid. + */ +const validUtf8End = (buffer: Buffer, end: number): number => { + if (end <= 0) { + return 0; + } + // Walk back from the last included byte to the lead byte of its code point + // (continuation bytes match 0b10xxxxxx). + let start = end - 1; + while (start >= 0 && (buffer[start] & 0xc0) === 0x80) { + start -= 1; + } + if (start < 0) { + return 0; + } + const lead = buffer[start]; + let expected: number; + if ((lead & 0x80) === 0x00) { + expected = 1; // 0xxxxxxx + } else if ((lead & 0xe0) === 0xc0) { + expected = 2; // 110xxxxx + } else if ((lead & 0xf0) === 0xe0) { + expected = 3; // 1110xxxx + } else if ((lead & 0xf8) === 0xf0) { + expected = 4; // 11110xxx + } else { + return start; // invalid lead byte — drop it + } + // Keep the final code point only if all of its bytes are present. + return end - start >= expected ? end : start; +}; + +const toOutputString = (buffer: OutputBuffer): string => { + if (buffer.chunks.length === 0) { + return ""; + } + const bytes = Buffer.concat(buffer.chunks, buffer.size); + return bytes.subarray(0, validUtf8End(bytes, bytes.length)).toString("utf-8"); +}; + +/** + * Truncate a UTF-8 string to at most `maxBytes`, walking the cut point back to a + * codepoint boundary so the result is always valid UTF-8. + */ +export const truncateOutput = ( + value: string, + maxBytes: number, +): { content: string; truncated: boolean } => { + if (!value) { + return { content: "", truncated: false }; + } + if (maxBytes <= 0) { + return { content: "", truncated: true }; + } + const buffer = Buffer.from(value, "utf-8"); + if (buffer.length <= maxBytes) { + return { content: value, truncated: false }; + } + const end = validUtf8End(buffer, maxBytes); + return { content: buffer.subarray(0, end).toString("utf-8"), truncated: true }; +}; + +/** + * Resolve the final output for a stream: prefer the streamed buffer when we + * captured anything, otherwise fall back to the aggregated bytes on the result. + */ +export const resolveOutput = ( + buffer: OutputBuffer, + fallback: string | undefined, + maxBytes: number, +): { content: string; truncated: boolean } => { + if (buffer.size > 0 || buffer.truncated) { + return { content: toOutputString(buffer), truncated: buffer.truncated }; + } + if (!fallback) { + return { content: "", truncated: false }; + } + return truncateOutput(fallback, maxBytes); +}; + +/** + * Drop nullish entries and string-coerce the rest of an env map. + */ +export const normalizeEnv = (env?: Record): Record => { + const result: Record = {}; + if (!env) { + return result; + } + for (const [key, value] of Object.entries(env)) { + if (value === undefined || value === null) { + continue; + } + result[key] = String(value); + } + return result; +}; + +/** + * Turn a stdin string into a `ReadableStream` for Tenki's + * `run({ stdin })`. An empty string yields a stream that closes immediately, + * which signals EOF to the process (matching how `exec` closes stdin). + */ +export const stringToReadableStream = (value: string): ReadableStream => { + const bytes = value.length > 0 ? encoder.encode(value) : undefined; + return new ReadableStream({ + start(controller) { + if (bytes) { + controller.enqueue(bytes); + } + controller.close(); + }, + }); +}; + +/** + * Read a signal name off a Tenki `ProcessRunResult` if the process was killed. + */ +export const extractSignal = (result: unknown): string | undefined => { + if (!result || typeof result !== "object") { + return undefined; + } + const signal = (result as Record).signal; + return typeof signal === "string" && signal.length > 0 ? signal : undefined; +}; + +/** + * Linux errno names for the fork/exec/wait failures Tenki reports on + * `ProcessRunResult.errno`. The guest is always Linux, so these are the Linux + * ABI numbers; anything unmapped falls back to the raw value. + */ +const ERRNO_NAMES: Record = { + 1: "EPERM", + 2: "ENOENT", + 5: "EIO", + 7: "E2BIG", + 8: "ENOEXEC", + 11: "EAGAIN", + 12: "ENOMEM", + 13: "EACCES", + 20: "ENOTDIR", + 21: "EISDIR", + 22: "EINVAL", + 23: "ENFILE", + 24: "EMFILE", + 26: "ETXTBSY", + 36: "ENAMETOOLONG", + 40: "ELOOP", +}; + +/** + * Exit `reason` values that say nothing beyond what `exitCode` / `signal` + * already report. + */ +const BENIGN_RUN_REASONS = new Set(["exit", "exited", "signaled", "signal", "killed"]); + +/** + * Build a one-line diagnostic for the parts of Tenki's `ProcessRunResult` that + * `WorkspaceSandboxResult` has no field for: `errno` (raw errno when the guest + * agent could not fork/exec/wait the process) and `reason` (the guest agent's + * description of how the run ended). + * + * This matters because Tenki reports an exec failure as a *resolved* run whose + * stderr is empty — the process never wrote anything — so without the errno the + * caller sees a non-zero exit code and no output, and cannot tell "command not + * found" from "ran and failed silently". The local provider does not lose this: + * a spawn failure rejects there, so the errno rides along in the error message. + * + * `reason` is free-form guest-agent text, so it is only appended when the run + * also looks abnormal (non-zero exit or a signal) and the value is not one this + * adapter already covers — a successful command never picks up a diagnostic. + * + * Returns `undefined` when there is nothing worth reporting. + */ +export const formatRunDiagnostic = (result: unknown): string | undefined => { + if (!result || typeof result !== "object") { + return undefined; + } + const record = result as Record; + // `errno` is a non-optional proto scalar, so it arrives as 0 (not absent) + // whenever the guest agent has nothing to report. + const errno = typeof record.errno === "number" && record.errno !== 0 ? record.errno : undefined; + const rawReason = typeof record.reason === "string" ? record.reason.trim() : ""; + const reason = BENIGN_RUN_REASONS.has(rawReason.toLowerCase()) + ? undefined + : rawReason || undefined; + + if (errno !== undefined) { + const name = ERRNO_NAMES[errno]; + const label = name ? `${name} (errno ${errno})` : `errno ${errno}`; + return reason + ? `tenki: exec failed: ${label}, reason=${reason}` + : `tenki: exec failed: ${label}`; + } + + if (!reason) { + return undefined; + } + const exitCode = record.exitCode; + const abnormal = + (typeof exitCode === "number" && exitCode !== 0) || extractSignal(result) !== undefined; + return abnormal ? `tenki: run ended: reason=${reason}` : undefined; +}; + +/** + * Append {@link formatRunDiagnostic}'s line to a resolved stderr payload, + * newline-separated. The diagnostic is adapter metadata rather than process + * output, so it is added after truncation and is not counted against + * `maxOutputBytes` — an errno failure means the process never ran, so there is + * nothing to crowd out in practice. + */ +export const appendRunDiagnostic = (stderr: string, diagnostic?: string): string => { + if (!diagnostic) { + return stderr; + } + if (!stderr) { + return `${diagnostic}\n`; + } + return stderr.endsWith("\n") ? `${stderr}${diagnostic}\n` : `${stderr}\n${diagnostic}\n`; +}; + +/** + * Is this the SDK's per-command timeout error? + */ +export const isCommandTimeoutError = (error: unknown): boolean => + error instanceof CommandTimeoutError; + +/** + * Empty result returned when a command is aborted before or during execution. + */ +export const abortedResult = (durationMs: number): WorkspaceSandboxResult => ({ + stdout: "", + stderr: "", + exitCode: null, + durationMs, + timedOut: false, + aborted: true, + stdoutTruncated: false, + stderrTruncated: false, +}); + +/** + * Empty result returned when a command times out before producing a result. + */ +export const timedOutResult = (durationMs: number): WorkspaceSandboxResult => ({ + stdout: "", + stderr: "", + exitCode: null, + durationMs, + timedOut: true, + aborted: false, + stdoutTruncated: false, + stderrTruncated: false, +}); diff --git a/packages/sandbox-tenki/tsconfig.json b/packages/sandbox-tenki/tsconfig.json new file mode 100644 index 000000000..2f8553a49 --- /dev/null +++ b/packages/sandbox-tenki/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + // Node 22+ targets — supports all of ES2023 natively. + "target": "ES2023", + "lib": ["ES2023"], + + // tsup handles emission; "Preserve" + "Bundler" lets us write extension-less + // imports and keeps `import`/`export` syntax intact for the bundler. + "module": "Preserve", + "moduleResolution": "Bundler", + + // Server-only library — no DOM globals. + "types": ["node", "vitest/globals"], + + // Strict TypeScript. + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + // Modern module ergonomics. + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + + // tsup emits; tsc is typecheck-only. + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sandbox-tenki/tsup.config.ts b/packages/sandbox-tenki/tsup.config.ts new file mode 100644 index 000000000..7dcbd0624 --- /dev/null +++ b/packages/sandbox-tenki/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; +import { markAsExternalPlugin } from "../shared/tsup-plugins/mark-as-external"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + splitting: false, + sourcemap: true, + clean: false, + target: "es2023", + outDir: "dist", + dts: true, + esbuildPlugins: [markAsExternalPlugin], + esbuildOptions(options) { + options.keepNames = true; + return options; + }, +}); diff --git a/packages/sandbox-tenki/vitest.config.ts b/packages/sandbox-tenki/vitest.config.ts new file mode 100644 index 000000000..981d149b7 --- /dev/null +++ b/packages/sandbox-tenki/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.spec.ts"], + environment: "node", + globals: true, + testTimeout: 10000, + hookTimeout: 10000, + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts", "src/**/*.d.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8865a8b7..2f24be72c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4336,6 +4336,34 @@ importers: specifier: ^5.8.2 version: 5.9.3 + packages/sandbox-tenki: + dependencies: + '@tenkicloud/sandbox': + specifier: ^0.5.1 + version: 0.5.1 + devDependencies: + '@types/node': + specifier: ^24.2.1 + version: 24.6.2 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + '@voltagent/core': + specifier: ^2.8.1 + version: link:../core + tsup: + specifier: ^8.5.0 + version: 8.5.0(@swc/core@1.5.29)(typescript@5.9.3) + typescript: + specifier: ^5.8.2 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.6.2)(@vitest/ui@1.6.1)(jsdom@22.1.0) + zod: + specifier: ^3.25.76 + version: 3.25.76 + packages/scorers: dependencies: '@voltagent/core': @@ -8266,6 +8294,10 @@ packages: /@bufbuild/protobuf@2.10.1: resolution: {integrity: sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==} + /@bufbuild/protobuf@2.12.1: + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + dev: false + /@bugsnag/browser@8.6.0: resolution: {integrity: sha512-7UGqTGnQqXUQ09gOlWbDTFUSbeLIIrP+hML3kTOq8Zdc8nP/iuOEflXGLV2TxWBWW8xIUPc928caFPr9EcaDuw==} dependencies: @@ -8917,6 +8949,17 @@ packages: hasBin: true dev: false + /@connectrpc/connect-node@2.1.2(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.1.2): + resolution: {integrity: sha512-+i/aAOpsI8sIx1mbYp6d99zvxaUSF6t/jP9Ux9maAmjsZPgmIQ3JuIeYi0zJIP9zlCnBlJjkpPosshCgdRuThQ==} + engines: {node: '>=20'} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.2 + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.12.1) + dev: false + /@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.10.1)(@connectrpc/connect@2.0.0-rc.3): resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} peerDependencies: @@ -8935,6 +8978,14 @@ packages: '@bufbuild/protobuf': 2.10.1 dev: false + /@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1): + resolution: {integrity: sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + dependencies: + '@bufbuild/protobuf': 2.12.1 + dev: false + /@copilotkit/react-core@1.50.0(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)(zod@4.3.5): resolution: {integrity: sha512-JfUEvmgXgPz7wIQq9EFXWGDMtYLIVKSNqPdJROEomZXLhREDlxpg+jr5KHvoOPUlSnVLzuPObRKSdeJTwVOGsQ==} peerDependencies: @@ -20154,6 +20205,19 @@ packages: - supports-color dev: false + /@tenkicloud/sandbox@0.5.1: + resolution: {integrity: sha512-2yz11vnOcmka3+69Yw/yT/EhErjifNmvPxhgAI635mOStEDq0uNY14ibQpVl5r4brsTC/oSy4ZvntBt5cOzkOQ==} + engines: {node: '>=18'} + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.12.1) + '@connectrpc/connect-node': 2.1.2(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.1.2) + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + /@tokenizer/inflate@0.2.7: resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} engines: {node: '>=18'} @@ -42312,6 +42376,19 @@ packages: utf-8-validate: optional: true + /ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} diff --git a/website/docs/workspaces/sandbox.md b/website/docs/workspaces/sandbox.md index 9ffa727b4..68234ab1f 100644 --- a/website/docs/workspaces/sandbox.md +++ b/website/docs/workspaces/sandbox.md @@ -9,7 +9,7 @@ slug: /workspaces/sandbox The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. ::: -A sandbox is an isolated environment where an agent can run shell commands without touching the host. Usually a container, a remote VM, or an OS-level jail. VoltAgent reaches them through the `WorkspaceSandbox` interface. First-party providers exist for [Blaxel](#blaxel), [Daytona](#daytona), and [E2B](#e2b), plus `LocalSandbox` for running things locally. +A sandbox is an isolated environment where an agent can run shell commands without touching the host. Usually a container, a remote VM, or an OS-level jail. VoltAgent reaches them through the `WorkspaceSandbox` interface. First-party providers exist for [Blaxel](#blaxel), [Daytona](#daytona), [E2B](#e2b), and [Tenki](#tenki), plus `LocalSandbox` for running things locally. Agents interact with the sandbox through a tool called `execute_command`. They pass a command (plus optional env vars, working directory, and timeout), and the workspace runs it in the sandbox and returns the result. Large stdout or stderr gets truncated so the model doesn't drown in logs. @@ -114,11 +114,12 @@ Every provider implements `WorkspaceSandbox`, so the workspace toolkit drives th ### Available providers -| Provider | Package | Upstream docs | -| -------- | ---------------------------- | ---------------------------------------------- | -| Blaxel | `@voltagent/sandbox-blaxel` | [docs.blaxel.ai](https://docs.blaxel.ai) | -| Daytona | `@voltagent/sandbox-daytona` | [daytona.io/docs](https://www.daytona.io/docs) | -| E2B | `@voltagent/sandbox-e2b` | [e2b.dev/docs](https://e2b.dev/docs) | +| Provider | Package | Upstream docs | +| -------- | ---------------------------- | -------------------------------------------------------- | +| Blaxel | `@voltagent/sandbox-blaxel` | [docs.blaxel.ai](https://docs.blaxel.ai) | +| Daytona | `@voltagent/sandbox-daytona` | [daytona.io/docs](https://www.daytona.io/docs) | +| E2B | `@voltagent/sandbox-e2b` | [e2b.dev/docs](https://e2b.dev/docs) | +| Tenki | `@voltagent/sandbox-tenki` | [tenki.cloud/docs](https://tenki.cloud/docs/sandbox/sdk) | ### Blaxel @@ -419,6 +420,131 @@ const workspace = new Workspace({ }); ``` +### Tenki + +Disposable Linux microVMs driven from SDKs. Each `TenkiSandbox` instance provisions one microVM and reuses it across every `execute_command`, with native `cwd`/`env`/`stdin`, per-command timeout/abort, and separate stdout/stderr streams. Built on [`@tenkicloud/sandbox`](https://www.npmjs.com/package/@tenkicloud/sandbox). + +Install: + +```bash +pnpm add @voltagent/sandbox-tenki +``` + +_Pulls in `@tenkicloud/sandbox` automatically. No separate install._ + +Configure it on a workspace: + +```ts +import { Workspace } from "@voltagent/core"; +import { TenkiSandbox } from "@voltagent/sandbox-tenki"; + +const workspace = new Workspace({ + sandbox: new TenkiSandbox({ + apiKey: process.env.TENKI_API_KEY, + }), +}); +``` + +The API key defaults to the `TENKI_API_KEY` environment variable when omitted. Ordinary workspace API keys infer their workspace scope server-side, so omit `workspaceId` for normal usage. If you use trusted service credentials that can access multiple workspaces, pass `workspaceId` to select one explicitly: + +```ts +const sandbox = new TenkiSandbox({ + apiKey: process.env.TENKI_SERVICE_API_KEY, + workspaceId: process.env.TENKI_WORKSPACE_ID, +}); +``` + +Tenki sessions are billed resources, so call `workspace.destroy()` (which calls `sandbox.destroy()`) to close the microVM when you are done. + +#### Preview URLs and SSH + +`createTenkiToolkit` adds two Tenki-specific tools beyond the `execute_command` seam: `expose_preview_url` (expose a port and get a public URL — needs `allowInbound`, the default) and `authorize_ssh_key`. Add the toolkit to the same agent that uses the workspace: + +```ts +import { Agent, Workspace } from "@voltagent/core"; +import { TenkiSandbox, createTenkiToolkit } from "@voltagent/sandbox-tenki"; +import { openai } from "@ai-sdk/openai"; + +const sandbox = new TenkiSandbox({ apiKey: process.env.TENKI_API_KEY }); + +const agent = new Agent({ + name: "my-agent", + instructions: "A helpful assistant with sandboxed shell access", + model: openai("gpt-4o-mini"), + workspace: new Workspace({ sandbox }), + tools: [createTenkiToolkit(sandbox)], +}); +``` + +For Tenki-specific APIs (filesystem, port exposure, raw interactive SSH), grab the underlying session: + +```ts +import { TenkiSandbox } from "@voltagent/sandbox-tenki"; + +const sandbox = new TenkiSandbox({ apiKey: process.env.TENKI_API_KEY }); + +const workspace = new Workspace({ sandbox }); + +const session = await sandbox.getSandbox(); +const { previewUrl } = await session.exposePort(3000); +``` + +Multi-tenant routing: one Tenki microVM per tenant, keyed on `operationContext`. + +```ts +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; +import { Workspace } from "@voltagent/core"; +import { TenkiSandbox } from "@voltagent/sandbox-tenki"; + +class TenantTenkiSandboxRouter implements WorkspaceSandbox { + name = "tenant-tenki-router"; + status = "ready" as const; + // In production, add LRU/TTL eviction here and dispose evicted sandboxes + // (for example via stop/destroy) to avoid unbounded per-tenant growth. + private readonly sandboxes = new Map(); + + getInfo() { + return { + provider: "tenant-tenki-router", + status: this.status, + sandboxCount: this.sandboxes.size, + }; + } + + private getSandboxForTenant(tenantId: string): TenkiSandbox { + let sandbox = this.sandboxes.get(tenantId); + if (!sandbox) { + sandbox = new TenkiSandbox({ + apiKey: process.env.TENKI_API_KEY, + // Example strategy: name the microVM after the tenant + name: `tenant-${tenantId}`, + }); + this.sandboxes.set(tenantId, sandbox); + } + return sandbox; + } + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); + return this.getSandboxForTenant(tenantId).execute(options); + } + + async destroy(): Promise { + const pending = Array.from(this.sandboxes.values()).map((s) => s.destroy()); + this.sandboxes.clear(); + await Promise.allSettled(pending); + } +} + +const workspace = new Workspace({ + sandbox: new TenantTenkiSandboxRouter(), +}); +``` + ## Custom sandbox provider You can implement `WorkspaceSandbox` and plug it into `Workspace` directly.