UNPKG

@tanstack/ai-sandbox

Version:

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.

181 lines (169 loc) 6.28 kB
/** * Host-tool delegation for the CO-LOCATED ("combined") sandbox model. * * In the co-located model the harness loop AND its MCP tool-bridge run INSIDE * the container (the in-container sandbox is just `local-process`, so the * existing adapter + `nodeHttpBridgeProvisioner` serve the bridge on the * container's own `localhost` with native stdin — nothing new there). The one * thing that still must cross the container→orchestrator boundary is the * **execution** of `chat()`-provided server tools: their `execute()` closures * (DB / secrets / app state) live in the orchestrator, not the container. * * This module is that narrow seam: * - {@link remoteToolStubs} (container side) rebuilds `chat()` tools from * serialized {@link ToolDescriptor}s; each stub's `execute` delegates to a * {@link RemoteToolExecutor} instead of running locally. The adapter bridges * these stubs exactly like real tools. * - {@link httpRemoteToolExecutor} (container side) is the default executor: it * POSTs `{ name, args }` to the orchestrator's tool-exec endpoint. * - {@link executeHostTool} (orchestrator side) runs the REAL tool and returns * its raw result — the only host code the container can reach. * * So the public network surface shrinks from "the whole MCP protocol" (served * from the orchestrator in the DO-drives-container model) to "one authenticated * tool-exec call" — the MCP transport itself never leaves the container. */ import type { AnyTool } from '@tanstack/ai' import type { ToolDescriptor } from './tool-bridge' /** Per-call options forwarded to a {@link RemoteToolExecutor}. */ export interface RemoteToolExecuteOptions { /** Cancels the in-flight remote call when the in-container run aborts. */ signal?: AbortSignal } /** Runs a named host tool with the given args, returning its raw result. */ export interface RemoteToolExecutor { execute: ( name: string, args: unknown, options?: RemoteToolExecuteOptions, ) => Promise<unknown> } /** Wire shape of a tool-exec request the container POSTs to the orchestrator. */ export interface ToolExecRequest { name: string args: unknown } /** Narrow an unknown body into a {@link ToolExecRequest} (project rule: no `as`). */ export function isToolExecRequest(value: unknown): value is ToolExecRequest { return ( value !== null && typeof value === 'object' && 'name' in value && typeof value.name === 'string' ) } /** * Rebuild `chat()` tool objects (container side) from serialized descriptors. * Each stub advertises the descriptor's JSON-schema and delegates `execute` to * the executor; the harness adapter bridges them like any other tool. The * harness's `abortSignal` is forwarded so a cancelled run cancels the in-flight * remote call too. */ export function remoteToolStubs( descriptors: Array<ToolDescriptor>, executor: RemoteToolExecutor, ): Array<AnyTool> { return descriptors.map((descriptor) => ({ name: descriptor.name, description: descriptor.description ?? '', inputSchema: descriptor.inputSchema, execute: (args: unknown, options?: { abortSignal?: AbortSignal }) => executor.execute( descriptor.name, args, options?.abortSignal !== undefined ? { signal: options.abortSignal } : {}, ), })) } /** * Serialize `chat()` tools to wire descriptors to send into the container. * `inputSchema` must already be a plain JSON-schema object (convert Standard * Schemas before calling, the same way harness adapters advertise tools). */ export function toolDescriptors(tools: Array<AnyTool>): Array<ToolDescriptor> { return tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: isJsonSchemaObject(tool.inputSchema) ? tool.inputSchema : { type: 'object', properties: {} }, })) } function isJsonSchemaObject( value: unknown, ): value is { type: 'object'; [key: string]: unknown } { return ( value !== null && typeof value === 'object' && 'type' in value && (value as { type?: unknown }).type === 'object' ) } /** Wire shape of a tool-exec response from the orchestrator. */ interface ToolExecResponse { result: unknown } function isToolExecResponse(value: unknown): value is ToolExecResponse { return value !== null && typeof value === 'object' && 'result' in value } /** * The default {@link RemoteToolExecutor}: POST `{ name, args }` (bearer-gated) * to the orchestrator's tool-exec endpoint and return its `result`. A non-2xx * or malformed response throws (surfaced to the agent as a failed tool call by * the bridge) — never silently swallowed. */ export function httpRemoteToolExecutor( url: string, token: string, ): RemoteToolExecutor { return { async execute(name, args, options) { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`, }, body: JSON.stringify({ name, args }), ...(options?.signal !== undefined ? { signal: options.signal } : {}), }) if (!res.ok) { const text = await res.text() throw new Error( `remote tool "${name}" failed: ${res.status} ${text.slice(0, 200)}`, ) } const body: unknown = await res.json() if (!isToolExecResponse(body)) { throw new Error( `remote tool "${name}": malformed orchestrator response`, ) } return body.result }, } } /** * Run a host tool by name with the given args, returning its raw result * (orchestrator side of {@link httpRemoteToolExecutor}). Throws for an unknown * tool or one with no `execute` — the orchestrator surfaces that as a 4xx/5xx. */ export function executeHostTool( tools: Array<AnyTool>, name: string, args: unknown, options: { context?: unknown; signal?: AbortSignal } = {}, ): Promise<unknown> { const tool = tools.find((candidate) => candidate.name === name) if (!tool?.execute) { return Promise.reject(new Error(`Unknown tool: ${name}`)) } return Promise.resolve( tool.execute(args ?? {}, { context: options.context, abortSignal: options.signal, }), ) }