@tanstack/ai-sandbox 0.5.6 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/esm/tool-bridge.js +2 -1
- package/dist/esm/tool-bridge.js.map +1 -1
- package/package.json +6 -6
- package/skills/ai-sandbox/SKILL.md +111 -31
- package/src/tool-bridge.ts +2 -3
package/README.md
CHANGED
|
@@ -74,6 +74,13 @@ Pick a **provider** package for where the sandbox runs:
|
|
|
74
74
|
| `@tanstack/ai-sandbox-daytona` | Daytona cloud sandboxes, snapshots |
|
|
75
75
|
| `@tanstack/ai-sandbox-upstash-box` | Upstash Box cloud sandboxes, snapshots |
|
|
76
76
|
| `@tanstack/ai-sandbox-sprites` | Sprites stateful sandboxes |
|
|
77
|
+
| `@tanstack/ai-sandbox-blaxel` | Blaxel cloud sandboxes and previews |
|
|
78
|
+
|
|
79
|
+
Install the provider you select separately. For Blaxel:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install @tanstack/ai-sandbox-blaxel
|
|
83
|
+
```
|
|
77
84
|
|
|
78
85
|
**Harness adapters** are separate packages. The default path is **Grok Build** (`@tanstack/ai-grok-build`); others include `@tanstack/ai-claude-code`, `@tanstack/ai-codex`, and `@tanstack/ai-opencode`. All require `withSandbox(...)` middleware — `chat()` fails fast without it.
|
|
79
86
|
|
package/dist/esm/tool-bridge.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { once } from "node:events";
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
6
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -221,7 +222,7 @@ async function startHostToolBridge(tools, options) {
|
|
|
221
222
|
if (!res.headersSent) res.writeHead(500).end("bridge error");
|
|
222
223
|
});
|
|
223
224
|
});
|
|
224
|
-
await
|
|
225
|
+
await once(httpServer.listen(0, bindAddress), "listening");
|
|
225
226
|
const port = httpServer.address().port;
|
|
226
227
|
return {
|
|
227
228
|
name: BRIDGED_MCP_SERVER_NAME,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-bridge.js","names":[],"sources":["../../src/tool-bridge.ts"],"sourcesContent":["/**\n * MCP tool-proxy bridge, shared by all harness adapters.\n *\n * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server.\n * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is\n * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the\n * orchestrator process (with its closures / DB / secrets), and the result is\n * returned into the sandbox.\n *\n * The bridge is split into a transport-agnostic CORE and a TRANSPORT:\n * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver\n * (no I/O). It is what makes the bridge portable.\n * - {@link startHostToolBridge} is the `node:http` transport for a long-running\n * host (laptop / CI / Docker orchestrator). It binds loopback unless the\n * sandbox must reach it via `host.docker.internal`, and authenticates with a\n * constant-time bearer check.\n * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the\n * SAME core from its own `fetch` handler — no raw TCP listener — see\n * {@link handleBridgeJsonRpc} and the Cloudflare example.\n */\nimport { createServer } from 'node:http'\nimport { randomBytes, timingSafeEqual } from 'node:crypto'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport type { AddressInfo } from 'node:net'\nimport type { AnyTool } from '@tanstack/ai'\n\n/**\n * Name of the bridged MCP server. The agent sees tools as\n * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix\n * so tool-call events match the names the application registered.\n */\nexport const BRIDGED_MCP_SERVER_NAME = 'tanstack'\n\n/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */\nexport function hostForSandbox(provider: string): string {\n return provider === 'docker' || provider === 'sbx'\n ? 'host.docker.internal'\n : '127.0.0.1'\n}\n\n/** Result of a permission decision returned to the harness's prompt tool. */\nexport interface PermissionToolResult {\n behavior: 'allow' | 'deny'\n message?: string\n updatedInput?: unknown\n}\n\nexport interface BridgePermission {\n toolName: string\n resolve: (input: {\n tool_name?: string\n input?: unknown\n }) => PermissionToolResult | Promise<PermissionToolResult>\n}\n\nexport interface ToolBridgeCoreOptions {\n /** Runtime context forwarded to each tool's `execute()`. */\n context?: unknown\n /** Abort signal forwarded to each tool's `execute()`. */\n signal?: AbortSignal\n /**\n * Forwarded to each tool's `execute()` so a bridged tool can stream progress /\n * custom events back to the client mid-execution (e.g. code mode's\n * `code_mode:console` logs). Without it those events are silently dropped — the\n * bridge runs out-of-band from the main tool executor, so the executor's own\n * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies\n * one that injects a CUSTOM chunk into its live output stream.\n */\n emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void\n /**\n * Optional permission-prompt tool (e.g. for Claude Code's\n * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool\n * `<name>` whose handler returns the orchestrator's allow/deny decision.\n */\n permission?: BridgePermission\n}\n\n/** An MCP tool descriptor as advertised to the in-sandbox agent. */\nexport interface ToolDescriptor {\n name: string\n description?: string\n inputSchema: { type: 'object'; [key: string]: unknown }\n}\n\n/**\n * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,\n * substituting an empty object schema when it isn't already a JSON-schema object\n * (project rule: a guard, not an `as` cast).\n */\nfunction toObjectSchema(schema: unknown): {\n type: 'object'\n [key: string]: unknown\n} {\n if (\n schema !== null &&\n typeof schema === 'object' &&\n 'type' in schema &&\n schema.type === 'object'\n ) {\n return { ...schema, type: 'object' }\n }\n return { type: 'object', properties: {} }\n}\n\n/** MCP `tools/call` result shape. */\nexport interface ToolCallResult {\n content: Array<{ type: 'text'; text: string }>\n isError?: boolean\n}\n\n/**\n * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission\n * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a\n * `fetch` handler) wraps this and owns I/O + the bearer check.\n */\nexport interface ToolBridgeCore {\n listTools: () => Array<ToolDescriptor>\n callTool: (name: string, args: unknown) => Promise<ToolCallResult>\n}\n\n/** Build the transport-agnostic bridge core for the given tools. */\nexport function createToolBridgeCore(\n tools: Array<AnyTool>,\n options: ToolBridgeCoreOptions = {},\n): ToolBridgeCore {\n const toolsByName = new Map(tools.map((tool) => [tool.name, tool]))\n const permission = options.permission\n\n const permissionDescriptor: ToolDescriptor | undefined = permission\n ? {\n name: permission.toolName,\n description:\n 'Permission prompt: returns {behavior:\"allow\"|\"deny\"} for a requested action.',\n inputSchema: { type: 'object', properties: {} },\n }\n : undefined\n\n return {\n listTools() {\n return [\n ...tools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n inputSchema: toObjectSchema(tool.inputSchema),\n })),\n ...(permissionDescriptor ? [permissionDescriptor] : []),\n ]\n },\n\n async callTool(name, args) {\n if (permission && name === permission.toolName) {\n const result = await permission.resolve(args ?? {})\n return { content: [{ type: 'text', text: JSON.stringify(result) }] }\n }\n const tool = toolsByName.get(name)\n if (!tool?.execute) throw new Error(`Unknown tool: ${name}`)\n try {\n const result: unknown = await tool.execute(args ?? {}, {\n context: options.context,\n abortSignal: options.signal,\n // No-op default so tools that always call it (e.g. code mode) don't\n // crash when the transport didn't wire a sink.\n emitCustomEvent: options.emitCustomEvent ?? (() => {}),\n })\n const text =\n typeof result === 'string' ? result : JSON.stringify(result)\n return { content: [{ type: 'text', text }] }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n isError: true,\n content: [\n { type: 'text', text: `Tool execution failed: ${message}` },\n ],\n }\n }\n },\n }\n}\n\n/**\n * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based\n * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list`\n * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC\n * response object, or `null` for a notification (no `id`).\n */\nexport async function handleBridgeJsonRpc(\n core: ToolBridgeCore,\n message: unknown,\n): Promise<unknown> {\n if (message === null || typeof message !== 'object') {\n return {\n jsonrpc: '2.0',\n id: null,\n error: { code: -32600, message: 'Invalid Request' },\n }\n }\n const rpc = message as { id?: unknown; method?: unknown; params?: unknown }\n const id = rpc.id ?? null\n const respond = (result: unknown): unknown => ({ jsonrpc: '2.0', id, result })\n switch (rpc.method) {\n case 'initialize':\n return respond({\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n })\n case 'notifications/initialized':\n return null\n case 'tools/list':\n return respond({ tools: core.listTools() })\n case 'tools/call': {\n const params = (rpc.params ?? {}) as {\n name?: unknown\n arguments?: unknown\n }\n if (typeof params.name !== 'string') {\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32602, message: 'Invalid params: name' },\n }\n }\n return respond(await core.callTool(params.name, params.arguments ?? {}))\n }\n default:\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32601, message: 'Method not found' },\n }\n }\n}\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. Length mismatch returns false early (token length is not\n * secret); equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqual(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = Buffer.from(header)\n const b = Buffer.from(`Bearer ${token}`)\n if (a.length !== b.length) return false\n return timingSafeEqual(a, b)\n}\n\nexport interface HostToolBridge {\n /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */\n name: string\n /** URL the SANDBOX uses to reach this bridge. */\n url: string\n /** Per-run bearer token gating the endpoint. */\n token: string\n close: () => Promise<void>\n}\n\nexport interface StartBridgeOptions extends ToolBridgeCoreOptions {\n /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */\n hostForSandbox: string\n /**\n * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is\n * widened to `0.0.0.0` only when the sandbox reaches the host via\n * `host.docker.internal` (a container can't reach the host's loopback).\n */\n bindAddress?: string\n}\n\nfunction buildMcpServer(core: ToolBridgeCore): McpServer {\n const server = new McpServer(\n { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n { capabilities: { tools: {} } },\n )\n server.server.setRequestHandler(ListToolsRequestSchema, () => ({\n tools: core.listTools(),\n }))\n server.server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const result = await core.callTool(\n request.params.name,\n request.params.arguments ?? {},\n )\n return {\n content: result.content,\n ...(result.isError ? { isError: true } : {}),\n }\n })\n return server\n}\n\n/**\n * Start the `node:http` MCP tool-proxy bridge for the given tools. For a\n * long-running host (laptop / CI / Docker orchestrator). Serverless/edge\n * orchestrators serve {@link createToolBridgeCore} from their own `fetch`\n * handler instead.\n */\nexport async function startHostToolBridge(\n tools: Array<AnyTool>,\n options: StartBridgeOptions,\n): Promise<HostToolBridge> {\n const token = randomBytes(24).toString('hex')\n const core = createToolBridgeCore(tools, options)\n // Loopback by default; widen to all interfaces only for the Docker bridge,\n // which a container reaches via host.docker.internal (host gateway).\n const bindAddress =\n options.bindAddress ??\n (options.hostForSandbox === 'host.docker.internal'\n ? '0.0.0.0'\n : '127.0.0.1')\n\n const httpServer = createServer((req, res) => {\n void (async () => {\n if (!timingSafeBearerEqual(req.headers['authorization'], token)) {\n res.writeHead(401).end('unauthorized')\n return\n }\n const server = buildMcpServer(core)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n })\n res.on('close', () => {\n void transport.close()\n void server.close()\n })\n await server.connect(transport)\n\n let body = ''\n for await (const chunk of req) body += chunk\n let parsed: unknown\n try {\n parsed = body ? JSON.parse(body) : undefined\n } catch {\n // Malformed agent request → 400, distinct from an internal 500.\n if (!res.headersSent) res.writeHead(400).end('invalid JSON body')\n return\n }\n await transport.handleRequest(req, res, parsed)\n })().catch((error: unknown) => {\n // Log the underlying fault — on the host/Docker path there is no run-log\n // capturing it, so swallowing it leaves an operator with nothing.\n console.error('[tool-bridge] request handler failed:', error)\n if (!res.headersSent) res.writeHead(500).end('bridge error')\n })\n })\n\n await new Promise<void>((resolve) =>\n httpServer.listen(0, bindAddress, resolve),\n )\n const port = (httpServer.address() as AddressInfo).port\n const url = `http://${options.hostForSandbox}:${port}/mcp`\n\n return {\n name: BRIDGED_MCP_SERVER_NAME,\n url,\n token,\n close: () =>\n new Promise<void>((resolve) => httpServer.close(() => resolve())),\n }\n}\n\n/** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */\nexport type ProvisionedBridge = HostToolBridge\n\nexport interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions {\n /** Sandbox provider name, to derive how the sandbox reaches the bridge. */\n provider: string\n}\n\n/**\n * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge\n * portable across runtimes: a harness adapter asks its capability context for a\n * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host /\n * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable\n * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc}\n * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP\n * listener is needed.\n */\nexport interface ToolBridgeProvisioner {\n provision: (\n tools: Array<AnyTool>,\n options: ToolBridgeProvisionOptions,\n ) => Promise<ProvisionedBridge>\n}\n\n/** Default provisioner: a `node:http` listener on the host. */\nexport const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = {\n provision(tools, options) {\n const { provider, ...core } = options\n return startHostToolBridge(tools, {\n hostForSandbox: hostForSandbox(provider),\n ...core,\n })\n },\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tool-bridge.js","names":[],"sources":["../../src/tool-bridge.ts"],"sourcesContent":["/**\n * MCP tool-proxy bridge, shared by all harness adapters.\n *\n * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server.\n * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is\n * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the\n * orchestrator process (with its closures / DB / secrets), and the result is\n * returned into the sandbox.\n *\n * The bridge is split into a transport-agnostic CORE and a TRANSPORT:\n * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver\n * (no I/O). It is what makes the bridge portable.\n * - {@link startHostToolBridge} is the `node:http` transport for a long-running\n * host (laptop / CI / Docker orchestrator). It binds loopback unless the\n * sandbox must reach it via `host.docker.internal`, and authenticates with a\n * constant-time bearer check.\n * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the\n * SAME core from its own `fetch` handler — no raw TCP listener — see\n * {@link handleBridgeJsonRpc} and the Cloudflare example.\n */\nimport { createServer } from 'node:http'\nimport { randomBytes, timingSafeEqual } from 'node:crypto'\nimport { once } from 'node:events'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport type { AddressInfo } from 'node:net'\nimport type { AnyTool } from '@tanstack/ai'\n\n/**\n * Name of the bridged MCP server. The agent sees tools as\n * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix\n * so tool-call events match the names the application registered.\n */\nexport const BRIDGED_MCP_SERVER_NAME = 'tanstack'\n\n/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */\nexport function hostForSandbox(provider: string): string {\n return provider === 'docker' || provider === 'sbx'\n ? 'host.docker.internal'\n : '127.0.0.1'\n}\n\n/** Result of a permission decision returned to the harness's prompt tool. */\nexport interface PermissionToolResult {\n behavior: 'allow' | 'deny'\n message?: string\n updatedInput?: unknown\n}\n\nexport interface BridgePermission {\n toolName: string\n resolve: (input: {\n tool_name?: string\n input?: unknown\n }) => PermissionToolResult | Promise<PermissionToolResult>\n}\n\nexport interface ToolBridgeCoreOptions {\n /** Runtime context forwarded to each tool's `execute()`. */\n context?: unknown\n /** Abort signal forwarded to each tool's `execute()`. */\n signal?: AbortSignal\n /**\n * Forwarded to each tool's `execute()` so a bridged tool can stream progress /\n * custom events back to the client mid-execution (e.g. code mode's\n * `code_mode:console` logs). Without it those events are silently dropped — the\n * bridge runs out-of-band from the main tool executor, so the executor's own\n * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies\n * one that injects a CUSTOM chunk into its live output stream.\n */\n emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void\n /**\n * Optional permission-prompt tool (e.g. for Claude Code's\n * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool\n * `<name>` whose handler returns the orchestrator's allow/deny decision.\n */\n permission?: BridgePermission\n}\n\n/** An MCP tool descriptor as advertised to the in-sandbox agent. */\nexport interface ToolDescriptor {\n name: string\n description?: string\n inputSchema: { type: 'object'; [key: string]: unknown }\n}\n\n/**\n * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,\n * substituting an empty object schema when it isn't already a JSON-schema object\n * (project rule: a guard, not an `as` cast).\n */\nfunction toObjectSchema(schema: unknown): {\n type: 'object'\n [key: string]: unknown\n} {\n if (\n schema !== null &&\n typeof schema === 'object' &&\n 'type' in schema &&\n schema.type === 'object'\n ) {\n return { ...schema, type: 'object' }\n }\n return { type: 'object', properties: {} }\n}\n\n/** MCP `tools/call` result shape. */\nexport interface ToolCallResult {\n content: Array<{ type: 'text'; text: string }>\n isError?: boolean\n}\n\n/**\n * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission\n * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a\n * `fetch` handler) wraps this and owns I/O + the bearer check.\n */\nexport interface ToolBridgeCore {\n listTools: () => Array<ToolDescriptor>\n callTool: (name: string, args: unknown) => Promise<ToolCallResult>\n}\n\n/** Build the transport-agnostic bridge core for the given tools. */\nexport function createToolBridgeCore(\n tools: Array<AnyTool>,\n options: ToolBridgeCoreOptions = {},\n): ToolBridgeCore {\n const toolsByName = new Map(tools.map((tool) => [tool.name, tool]))\n const permission = options.permission\n\n const permissionDescriptor: ToolDescriptor | undefined = permission\n ? {\n name: permission.toolName,\n description:\n 'Permission prompt: returns {behavior:\"allow\"|\"deny\"} for a requested action.',\n inputSchema: { type: 'object', properties: {} },\n }\n : undefined\n\n return {\n listTools() {\n return [\n ...tools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n inputSchema: toObjectSchema(tool.inputSchema),\n })),\n ...(permissionDescriptor ? [permissionDescriptor] : []),\n ]\n },\n\n async callTool(name, args) {\n if (permission && name === permission.toolName) {\n const result = await permission.resolve(args ?? {})\n return { content: [{ type: 'text', text: JSON.stringify(result) }] }\n }\n const tool = toolsByName.get(name)\n if (!tool?.execute) throw new Error(`Unknown tool: ${name}`)\n try {\n const result: unknown = await tool.execute(args ?? {}, {\n context: options.context,\n abortSignal: options.signal,\n // No-op default so tools that always call it (e.g. code mode) don't\n // crash when the transport didn't wire a sink.\n emitCustomEvent: options.emitCustomEvent ?? (() => {}),\n })\n const text =\n typeof result === 'string' ? result : JSON.stringify(result)\n return { content: [{ type: 'text', text }] }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n isError: true,\n content: [\n { type: 'text', text: `Tool execution failed: ${message}` },\n ],\n }\n }\n },\n }\n}\n\n/**\n * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based\n * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list`\n * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC\n * response object, or `null` for a notification (no `id`).\n */\nexport async function handleBridgeJsonRpc(\n core: ToolBridgeCore,\n message: unknown,\n): Promise<unknown> {\n if (message === null || typeof message !== 'object') {\n return {\n jsonrpc: '2.0',\n id: null,\n error: { code: -32600, message: 'Invalid Request' },\n }\n }\n const rpc = message as { id?: unknown; method?: unknown; params?: unknown }\n const id = rpc.id ?? null\n const respond = (result: unknown): unknown => ({ jsonrpc: '2.0', id, result })\n switch (rpc.method) {\n case 'initialize':\n return respond({\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n })\n case 'notifications/initialized':\n return null\n case 'tools/list':\n return respond({ tools: core.listTools() })\n case 'tools/call': {\n const params = (rpc.params ?? {}) as {\n name?: unknown\n arguments?: unknown\n }\n if (typeof params.name !== 'string') {\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32602, message: 'Invalid params: name' },\n }\n }\n return respond(await core.callTool(params.name, params.arguments ?? {}))\n }\n default:\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32601, message: 'Method not found' },\n }\n }\n}\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. Length mismatch returns false early (token length is not\n * secret); equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqual(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = Buffer.from(header)\n const b = Buffer.from(`Bearer ${token}`)\n if (a.length !== b.length) return false\n return timingSafeEqual(a, b)\n}\n\nexport interface HostToolBridge {\n /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */\n name: string\n /** URL the SANDBOX uses to reach this bridge. */\n url: string\n /** Per-run bearer token gating the endpoint. */\n token: string\n close: () => Promise<void>\n}\n\nexport interface StartBridgeOptions extends ToolBridgeCoreOptions {\n /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */\n hostForSandbox: string\n /**\n * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is\n * widened to `0.0.0.0` only when the sandbox reaches the host via\n * `host.docker.internal` (a container can't reach the host's loopback).\n */\n bindAddress?: string\n}\n\nfunction buildMcpServer(core: ToolBridgeCore): McpServer {\n const server = new McpServer(\n { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n { capabilities: { tools: {} } },\n )\n server.server.setRequestHandler(ListToolsRequestSchema, () => ({\n tools: core.listTools(),\n }))\n server.server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const result = await core.callTool(\n request.params.name,\n request.params.arguments ?? {},\n )\n return {\n content: result.content,\n ...(result.isError ? { isError: true } : {}),\n }\n })\n return server\n}\n\n/**\n * Start the `node:http` MCP tool-proxy bridge for the given tools. For a\n * long-running host (laptop / CI / Docker orchestrator). Serverless/edge\n * orchestrators serve {@link createToolBridgeCore} from their own `fetch`\n * handler instead.\n */\nexport async function startHostToolBridge(\n tools: Array<AnyTool>,\n options: StartBridgeOptions,\n): Promise<HostToolBridge> {\n const token = randomBytes(24).toString('hex')\n const core = createToolBridgeCore(tools, options)\n // Loopback by default; widen to all interfaces only for the Docker bridge,\n // which a container reaches via host.docker.internal (host gateway).\n const bindAddress =\n options.bindAddress ??\n (options.hostForSandbox === 'host.docker.internal'\n ? '0.0.0.0'\n : '127.0.0.1')\n\n const httpServer = createServer((req, res) => {\n void (async () => {\n if (!timingSafeBearerEqual(req.headers['authorization'], token)) {\n res.writeHead(401).end('unauthorized')\n return\n }\n const server = buildMcpServer(core)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n })\n res.on('close', () => {\n void transport.close()\n void server.close()\n })\n await server.connect(transport)\n\n let body = ''\n for await (const chunk of req) body += chunk\n let parsed: unknown\n try {\n parsed = body ? JSON.parse(body) : undefined\n } catch {\n // Malformed agent request → 400, distinct from an internal 500.\n if (!res.headersSent) res.writeHead(400).end('invalid JSON body')\n return\n }\n await transport.handleRequest(req, res, parsed)\n })().catch((error: unknown) => {\n // Log the underlying fault — on the host/Docker path there is no run-log\n // capturing it, so swallowing it leaves an operator with nothing.\n console.error('[tool-bridge] request handler failed:', error)\n if (!res.headersSent) res.writeHead(500).end('bridge error')\n })\n })\n\n await once(httpServer.listen(0, bindAddress), 'listening')\n const port = (httpServer.address() as AddressInfo).port\n const url = `http://${options.hostForSandbox}:${port}/mcp`\n\n return {\n name: BRIDGED_MCP_SERVER_NAME,\n url,\n token,\n close: () =>\n new Promise<void>((resolve) => httpServer.close(() => resolve())),\n }\n}\n\n/** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */\nexport type ProvisionedBridge = HostToolBridge\n\nexport interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions {\n /** Sandbox provider name, to derive how the sandbox reaches the bridge. */\n provider: string\n}\n\n/**\n * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge\n * portable across runtimes: a harness adapter asks its capability context for a\n * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host /\n * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable\n * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc}\n * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP\n * listener is needed.\n */\nexport interface ToolBridgeProvisioner {\n provision: (\n tools: Array<AnyTool>,\n options: ToolBridgeProvisionOptions,\n ) => Promise<ProvisionedBridge>\n}\n\n/** Default provisioner: a `node:http` listener on the host. */\nexport const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = {\n provision(tools, options) {\n const { provider, ...core } = options\n return startHostToolBridge(tools, {\n hostForSandbox: hostForSandbox(provider),\n ...core,\n })\n },\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-sandbox",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
|
-
"@tanstack/ai-skills": "^0.1.
|
|
59
|
+
"@tanstack/ai-skills": "^0.1.4"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"@ngrok/ngrok": "^1.0.0",
|
|
63
63
|
"vitest": "^4.1.10",
|
|
64
|
-
"@tanstack/ai": "^0.
|
|
65
|
-
"@tanstack/ai-persistence": "^0.
|
|
64
|
+
"@tanstack/ai": "^0.55.0",
|
|
65
|
+
"@tanstack/ai-persistence": "^0.6.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"@ngrok/ngrok": {
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
"@ngrok/ngrok": "^1.7.0",
|
|
80
80
|
"@vitest/coverage-v8": "4.1.10",
|
|
81
81
|
"vitest": "^4.1.10",
|
|
82
|
-
"@tanstack/ai": "0.
|
|
83
|
-
"@tanstack/ai
|
|
82
|
+
"@tanstack/ai-persistence": "0.6.0",
|
|
83
|
+
"@tanstack/ai": "0.55.0"
|
|
84
84
|
},
|
|
85
85
|
"scripts": {
|
|
86
86
|
"build": "vite build",
|
|
@@ -48,9 +48,10 @@ agent CLI **inside** the sandbox and streams its events back.
|
|
|
48
48
|
## Setup — Claude Code in a Docker sandbox
|
|
49
49
|
|
|
50
50
|
```typescript
|
|
51
|
-
import { chat } from '@tanstack/ai'
|
|
51
|
+
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
|
|
52
52
|
import { claudeCodeText } from '@tanstack/ai-claude-code'
|
|
53
53
|
import {
|
|
54
|
+
createSecrets,
|
|
54
55
|
defineSandbox,
|
|
55
56
|
defineWorkspace,
|
|
56
57
|
withSandbox,
|
|
@@ -65,17 +66,25 @@ const sandbox = defineSandbox({
|
|
|
65
66
|
packageManager: 'pnpm',
|
|
66
67
|
setup: ['corepack enable', 'pnpm install'],
|
|
67
68
|
scripts: { test: 'pnpm test' },
|
|
68
|
-
secrets: {
|
|
69
|
+
secrets: createSecrets({
|
|
70
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '',
|
|
71
|
+
}),
|
|
69
72
|
}),
|
|
70
73
|
lifecycle: { reuse: 'thread', snapshot: 'after-setup', keepAlive: '30m' },
|
|
71
74
|
})
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
threadId,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
export async function POST(request: Request) {
|
|
77
|
+
const { threadId, messages } = await request.json()
|
|
78
|
+
|
|
79
|
+
const stream = chat({
|
|
80
|
+
threadId,
|
|
81
|
+
adapter: claudeCodeText('sonnet'),
|
|
82
|
+
messages,
|
|
83
|
+
middleware: [withSandbox(sandbox)],
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return toServerSentEventsResponse(stream)
|
|
87
|
+
}
|
|
79
88
|
```
|
|
80
89
|
|
|
81
90
|
## Type-safe secrets
|
|
@@ -165,6 +174,8 @@ serial and parallel groups over a **persistent shell** whose cwd/env carry over
|
|
|
165
174
|
between serial steps:
|
|
166
175
|
|
|
167
176
|
```typescript
|
|
177
|
+
import { githubRepo, defineWorkspace } from '@tanstack/ai-sandbox'
|
|
178
|
+
|
|
168
179
|
defineWorkspace({
|
|
169
180
|
source: githubRepo({ repo: 'owner/app' }),
|
|
170
181
|
setup: ({ serial, parallel }) => {
|
|
@@ -183,10 +194,17 @@ When the provider supports snapshots, bootstrap takes one automatically after
|
|
|
183
194
|
Override or add a TTL:
|
|
184
195
|
|
|
185
196
|
```typescript
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
197
|
+
import { defineSandbox } from '@tanstack/ai-sandbox'
|
|
198
|
+
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
|
|
199
|
+
|
|
200
|
+
const sandbox = defineSandbox({
|
|
201
|
+
id: 'repo-agent',
|
|
202
|
+
provider: dockerSandbox({ image: 'node:22' }),
|
|
203
|
+
lifecycle: {
|
|
204
|
+
snapshot: 'after-setup', // default when provider.capabilities().snapshots
|
|
205
|
+
snapshotMaxAge: '24h', // re-create when the snapshot is older than this
|
|
206
|
+
},
|
|
207
|
+
})
|
|
190
208
|
```
|
|
191
209
|
|
|
192
210
|
Providers without snapshot support skip the step silently.
|
|
@@ -199,8 +217,15 @@ middleware in this order, with the same persistence value in both places:
|
|
|
199
217
|
|
|
200
218
|
```typescript
|
|
201
219
|
import { withPersistence } from '@tanstack/ai-persistence'
|
|
202
|
-
import {
|
|
220
|
+
import {
|
|
221
|
+
InMemorySandboxInstanceStore,
|
|
222
|
+
memorySandboxSnapshots,
|
|
223
|
+
withSandbox,
|
|
224
|
+
} from '@tanstack/ai-sandbox'
|
|
225
|
+
// Your `defineSandbox(...)` result.
|
|
226
|
+
import { sandbox } from './sandbox'
|
|
203
227
|
|
|
228
|
+
const instances = new InMemorySandboxInstanceStore()
|
|
204
229
|
const snapshots = await memorySandboxSnapshots({ sandbox, instances })
|
|
205
230
|
|
|
206
231
|
const middleware = [
|
|
@@ -321,20 +346,30 @@ distributed lock: either `withLocks` from `@tanstack/ai/locks` (ordered
|
|
|
321
346
|
**before** `withSandbox`) or the `locks` option.
|
|
322
347
|
|
|
323
348
|
```typescript
|
|
324
|
-
import { chat } from '@tanstack/ai'
|
|
349
|
+
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
|
|
325
350
|
import { InMemoryLockStore, withLocks } from '@tanstack/ai/locks'
|
|
351
|
+
import { claudeCodeText } from '@tanstack/ai-claude-code'
|
|
326
352
|
import { withSandbox } from '@tanstack/ai-sandbox'
|
|
353
|
+
// Your `defineSandbox(...)` result.
|
|
354
|
+
import { sandbox } from './sandbox'
|
|
327
355
|
// Production: your BYO store — docs/sandbox/durability.md
|
|
328
356
|
import { instanceStore } from './sandbox-instance-store'
|
|
329
357
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
358
|
+
export async function POST(request: Request) {
|
|
359
|
+
const { threadId, messages } = await request.json()
|
|
360
|
+
|
|
361
|
+
const stream = chat({
|
|
362
|
+
threadId,
|
|
363
|
+
adapter: claudeCodeText('sonnet'),
|
|
364
|
+
messages,
|
|
365
|
+
middleware: [
|
|
366
|
+
withLocks(new InMemoryLockStore()), // multi-replica: distributed lock
|
|
367
|
+
withSandbox(sandbox, { instances: instanceStore }),
|
|
368
|
+
],
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
return toServerSentEventsResponse(stream)
|
|
372
|
+
}
|
|
338
373
|
```
|
|
339
374
|
|
|
340
375
|
The store option takes precedence over an ambient `SandboxInstanceStoreCapability`
|
|
@@ -360,8 +395,11 @@ middleware via the `sandbox` group (run-scoped):
|
|
|
360
395
|
import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox'
|
|
361
396
|
// `defineChatMiddleware` is core's, not this package's — `@tanstack/ai-sandbox`
|
|
362
397
|
// consumes it too (see its own `src/middleware.ts`).
|
|
363
|
-
import { defineChatMiddleware } from '@tanstack/ai'
|
|
398
|
+
import { chat, defineChatMiddleware } from '@tanstack/ai'
|
|
399
|
+
import { claudeCodeText } from '@tanstack/ai-claude-code'
|
|
364
400
|
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
|
|
401
|
+
import { db } from './db'
|
|
402
|
+
import { metrics } from './metrics'
|
|
365
403
|
|
|
366
404
|
// Sandbox-scoped hooks (all optional):
|
|
367
405
|
const sandbox = defineSandbox({
|
|
@@ -392,6 +430,13 @@ const auditMiddleware = defineChatMiddleware({
|
|
|
392
430
|
|
|
393
431
|
// No extra middleware needed — sandbox.file CUSTOM events are emitted
|
|
394
432
|
// automatically. Read them from the stream:
|
|
433
|
+
const stream = chat({
|
|
434
|
+
threadId: 'thread-1',
|
|
435
|
+
adapter: claudeCodeText('sonnet'),
|
|
436
|
+
messages: [{ role: 'user', content: 'Add a README.' }],
|
|
437
|
+
middleware: [auditMiddleware, withSandbox(sandbox)],
|
|
438
|
+
})
|
|
439
|
+
|
|
395
440
|
for await (const chunk of stream) {
|
|
396
441
|
if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') {
|
|
397
442
|
const value = chunk.value
|
|
@@ -412,7 +457,10 @@ outside a `chat()` run:
|
|
|
412
457
|
|
|
413
458
|
```typescript
|
|
414
459
|
import { watchWorkspace } from '@tanstack/ai-sandbox'
|
|
460
|
+
// Your `defineSandbox(...)` result.
|
|
461
|
+
import { sandbox } from './sandbox'
|
|
415
462
|
|
|
463
|
+
const handle = await sandbox.ensure({ threadId: 'thread-1', runId: 'run-1' })
|
|
416
464
|
const watcher = await watchWorkspace(handle, {
|
|
417
465
|
onEvent: (e) => console.log(e.type, e.path),
|
|
418
466
|
ignore: ['.git', 'node_modules'], // default
|
|
@@ -424,8 +472,24 @@ Enable the `sandbox` debug category to log watcher start/stop, event dispatch,
|
|
|
424
472
|
and lifecycle transitions:
|
|
425
473
|
|
|
426
474
|
```typescript
|
|
427
|
-
|
|
428
|
-
|
|
475
|
+
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
|
|
476
|
+
import { claudeCodeText } from '@tanstack/ai-claude-code'
|
|
477
|
+
import { withSandbox } from '@tanstack/ai-sandbox'
|
|
478
|
+
import { sandbox } from './sandbox'
|
|
479
|
+
|
|
480
|
+
export async function POST(request: Request) {
|
|
481
|
+
const { threadId, messages } = await request.json()
|
|
482
|
+
|
|
483
|
+
const stream = chat({
|
|
484
|
+
threadId,
|
|
485
|
+
adapter: claudeCodeText('sonnet'),
|
|
486
|
+
messages,
|
|
487
|
+
middleware: [withSandbox(sandbox)],
|
|
488
|
+
debug: { sandbox: true }, // or debug: true to enable all categories
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
return toServerSentEventsResponse(stream)
|
|
492
|
+
}
|
|
429
493
|
```
|
|
430
494
|
|
|
431
495
|
## Edge / serverless execution
|
|
@@ -551,12 +615,17 @@ file from byte 0 at any point, including after the original host has died.
|
|
|
551
615
|
|
|
552
616
|
```typescript
|
|
553
617
|
import { spawnNdjson } from '@tanstack/ai-sandbox'
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
618
|
+
import type { SandboxHandle } from '@tanstack/ai-sandbox'
|
|
619
|
+
|
|
620
|
+
export async function runAgent(handle: SandboxHandle, runId: string) {
|
|
621
|
+
const agentCommand = 'claude -p --output-format stream-json'
|
|
622
|
+
for await (const event of spawnNdjson(handle, agentCommand, {
|
|
623
|
+
cwd: '/workspace',
|
|
624
|
+
journal: { runId }, // durability is opt-in: pass `journal` to route through it
|
|
625
|
+
})) {
|
|
626
|
+
// parsed NDJSON objects, translated by the harness adapter as usual
|
|
627
|
+
console.log(event)
|
|
628
|
+
}
|
|
560
629
|
}
|
|
561
630
|
```
|
|
562
631
|
|
|
@@ -1006,6 +1075,17 @@ import {
|
|
|
1006
1075
|
} from '@tanstack/ai-sandbox'
|
|
1007
1076
|
import type { RunRecord } from '@tanstack/ai'
|
|
1008
1077
|
import type { ReapResult, RunExitProbe } from '@tanstack/ai-sandbox'
|
|
1078
|
+
// Your distributed LockStore, the same one `withSandbox` gets.
|
|
1079
|
+
import { locks } from './locks'
|
|
1080
|
+
// Your persistence — the SAME RunStore the chat routes use.
|
|
1081
|
+
import { runs } from './persistence'
|
|
1082
|
+
// Your `defineSandbox(...)` result and the `SandboxInstanceStore` you passed to
|
|
1083
|
+
// `withSandbox(sandbox, { instances })`.
|
|
1084
|
+
import { instances, sandbox } from './sandbox'
|
|
1085
|
+
// The per-run log factory, resolving the SAME log the producing route wrote.
|
|
1086
|
+
import { durabilityFor } from './durability'
|
|
1087
|
+
// The same `drive` the attach route passes to `sandboxRunDriver`.
|
|
1088
|
+
import { driveRun } from './drive-run'
|
|
1009
1089
|
|
|
1010
1090
|
async function hasFinished(record: RunRecord): Promise<RunExitProbe> {
|
|
1011
1091
|
if (record.sandboxKey === undefined) return { state: 'unknown' }
|
package/src/tool-bridge.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { createServer } from 'node:http'
|
|
22
22
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
23
|
+
import { once } from 'node:events'
|
|
23
24
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
24
25
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
25
26
|
import {
|
|
@@ -350,9 +351,7 @@ export async function startHostToolBridge(
|
|
|
350
351
|
})
|
|
351
352
|
})
|
|
352
353
|
|
|
353
|
-
await
|
|
354
|
-
httpServer.listen(0, bindAddress, resolve),
|
|
355
|
-
)
|
|
354
|
+
await once(httpServer.listen(0, bindAddress), 'listening')
|
|
356
355
|
const port = (httpServer.address() as AddressInfo).port
|
|
357
356
|
const url = `http://${options.hostForSandbox}:${port}/mcp`
|
|
358
357
|
|