@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
text/typescript
/**
* 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,
}),
)
}