@tanstack/ai-sandbox 0.1.0

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.
Files changed (109) hide show
  1. package/README.md +182 -0
  2. package/dist/esm/agents-file.d.ts +36 -0
  3. package/dist/esm/agents-file.js +44 -0
  4. package/dist/esm/agents-file.js.map +1 -0
  5. package/dist/esm/approvals.d.ts +38 -0
  6. package/dist/esm/approvals.js +36 -0
  7. package/dist/esm/approvals.js.map +1 -0
  8. package/dist/esm/bootstrap.d.ts +17 -0
  9. package/dist/esm/bootstrap.js +124 -0
  10. package/dist/esm/bootstrap.js.map +1 -0
  11. package/dist/esm/bridge-events.d.ts +21 -0
  12. package/dist/esm/bridge-events.js +76 -0
  13. package/dist/esm/bridge-events.js.map +1 -0
  14. package/dist/esm/capabilities.d.ts +26 -0
  15. package/dist/esm/capabilities.js +29 -0
  16. package/dist/esm/capabilities.js.map +1 -0
  17. package/dist/esm/contracts.d.ts +211 -0
  18. package/dist/esm/errors.d.ts +16 -0
  19. package/dist/esm/errors.js +25 -0
  20. package/dist/esm/errors.js.map +1 -0
  21. package/dist/esm/git-exec.d.ts +2 -0
  22. package/dist/esm/git-exec.js +68 -0
  23. package/dist/esm/git-exec.js.map +1 -0
  24. package/dist/esm/harness-cwd.d.ts +2 -0
  25. package/dist/esm/harness-cwd.js +24 -0
  26. package/dist/esm/harness-cwd.js.map +1 -0
  27. package/dist/esm/index.d.ts +39 -0
  28. package/dist/esm/index.js +103 -0
  29. package/dist/esm/index.js.map +1 -0
  30. package/dist/esm/key.d.ts +20 -0
  31. package/dist/esm/key.js +41 -0
  32. package/dist/esm/key.js.map +1 -0
  33. package/dist/esm/middleware.d.ts +5 -0
  34. package/dist/esm/middleware.js +140 -0
  35. package/dist/esm/middleware.js.map +1 -0
  36. package/dist/esm/ngrok.d.ts +16 -0
  37. package/dist/esm/ngrok.js +54 -0
  38. package/dist/esm/ngrok.js.map +1 -0
  39. package/dist/esm/policy.d.ts +47 -0
  40. package/dist/esm/policy.js +44 -0
  41. package/dist/esm/policy.js.map +1 -0
  42. package/dist/esm/projection.d.ts +31 -0
  43. package/dist/esm/projection.js +9 -0
  44. package/dist/esm/projection.js.map +1 -0
  45. package/dist/esm/remote-tools.d.ts +48 -0
  46. package/dist/esm/remote-tools.js +76 -0
  47. package/dist/esm/remote-tools.js.map +1 -0
  48. package/dist/esm/run-log.d.ts +81 -0
  49. package/dist/esm/run-log.js +107 -0
  50. package/dist/esm/run-log.js.map +1 -0
  51. package/dist/esm/run.d.ts +58 -0
  52. package/dist/esm/run.js +89 -0
  53. package/dist/esm/run.js.map +1 -0
  54. package/dist/esm/runner.d.ts +21 -0
  55. package/dist/esm/runner.js +54 -0
  56. package/dist/esm/runner.js.map +1 -0
  57. package/dist/esm/sandbox.d.ts +79 -0
  58. package/dist/esm/sandbox.js +125 -0
  59. package/dist/esm/sandbox.js.map +1 -0
  60. package/dist/esm/secrets.d.ts +37 -0
  61. package/dist/esm/secrets.js +59 -0
  62. package/dist/esm/secrets.js.map +1 -0
  63. package/dist/esm/setup-plan.d.ts +13 -0
  64. package/dist/esm/setup-plan.js +16 -0
  65. package/dist/esm/setup-plan.js.map +1 -0
  66. package/dist/esm/shell.d.ts +45 -0
  67. package/dist/esm/shell.js +164 -0
  68. package/dist/esm/shell.js.map +1 -0
  69. package/dist/esm/store.d.ts +53 -0
  70. package/dist/esm/store.js +34 -0
  71. package/dist/esm/store.js.map +1 -0
  72. package/dist/esm/tool-bridge.d.ts +130 -0
  73. package/dist/esm/tool-bridge.js +197 -0
  74. package/dist/esm/tool-bridge.js.map +1 -0
  75. package/dist/esm/watch.d.ts +36 -0
  76. package/dist/esm/watch.js +144 -0
  77. package/dist/esm/watch.js.map +1 -0
  78. package/dist/esm/workspace.d.ts +128 -0
  79. package/dist/esm/workspace.js +42 -0
  80. package/dist/esm/workspace.js.map +1 -0
  81. package/package.json +72 -0
  82. package/skills/ai-sandbox/SKILL.md +366 -0
  83. package/src/agents-file.ts +101 -0
  84. package/src/approvals.ts +96 -0
  85. package/src/bootstrap.ts +196 -0
  86. package/src/bridge-events.ts +112 -0
  87. package/src/capabilities.ts +47 -0
  88. package/src/contracts.ts +236 -0
  89. package/src/errors.ts +31 -0
  90. package/src/git-exec.ts +114 -0
  91. package/src/harness-cwd.ts +38 -0
  92. package/src/index.ts +222 -0
  93. package/src/key.ts +70 -0
  94. package/src/middleware.ts +233 -0
  95. package/src/ngrok.ts +85 -0
  96. package/src/policy.ts +111 -0
  97. package/src/projection.ts +46 -0
  98. package/src/remote-tools.ts +180 -0
  99. package/src/run-log.ts +224 -0
  100. package/src/run.ts +167 -0
  101. package/src/runner.ts +99 -0
  102. package/src/sandbox.ts +259 -0
  103. package/src/secrets.ts +101 -0
  104. package/src/setup-plan.ts +25 -0
  105. package/src/shell.ts +288 -0
  106. package/src/store.ts +83 -0
  107. package/src/tool-bridge.ts +399 -0
  108. package/src/watch.ts +256 -0
  109. package/src/workspace.ts +151 -0
package/src/store.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Persistence seams for the sandbox layer.
3
+ *
4
+ * v1 ships ONLY in-memory implementations (single-process resume). These are
5
+ * deliberately pluggable OPTIONAL capabilities so the future persistence
6
+ * package can `provide` durable implementations (D1/Postgres/Durable Objects)
7
+ * without the sandbox layer changing. Do NOT hardcode storage here.
8
+ */
9
+
10
+ /** One persisted sandbox instance, keyed by the compound sandbox instance key. */
11
+ export interface SandboxRecord {
12
+ /** Compound key (see computeSandboxKey). */
13
+ key: string
14
+ /** Provider name that owns `providerSandboxId`. */
15
+ provider: string
16
+ /** Provider-assigned sandbox id used to resume. */
17
+ providerSandboxId: string
18
+ /** Most recent snapshot id, when the provider supports snapshots. */
19
+ latestSnapshotId?: string
20
+ threadId: string
21
+ latestRunId?: string
22
+ /** Epoch ms of last write (for keepAlive / GC by the persistence layer). */
23
+ updatedAt: number
24
+ }
25
+
26
+ /** Maps a compound key to the provider sandbox that should be resumed. */
27
+ export interface SandboxStore {
28
+ get: (key: string) => Promise<SandboxRecord | null>
29
+ upsert: (record: SandboxRecord) => Promise<void>
30
+ delete: (key: string) => Promise<void>
31
+ }
32
+
33
+ /**
34
+ * Mutual exclusion around sandbox ensure so two concurrent runs for the same
35
+ * thread don't both create a sandbox. The in-memory default is single-process;
36
+ * the persistence layer provides a distributed lock (e.g. a Durable Object).
37
+ */
38
+ export interface LockStore {
39
+ withLock: <T>(key: string, fn: () => Promise<T>) => Promise<T>
40
+ }
41
+
42
+ /** In-memory {@link SandboxStore}. Resume works only within one process. */
43
+ export class InMemorySandboxStore implements SandboxStore {
44
+ private readonly map = new Map<string, SandboxRecord>()
45
+
46
+ get(key: string): Promise<SandboxRecord | null> {
47
+ return Promise.resolve(this.map.get(key) ?? null)
48
+ }
49
+
50
+ upsert(record: SandboxRecord): Promise<void> {
51
+ this.map.set(record.key, record)
52
+ return Promise.resolve()
53
+ }
54
+
55
+ delete(key: string): Promise<void> {
56
+ this.map.delete(key)
57
+ return Promise.resolve()
58
+ }
59
+ }
60
+
61
+ /**
62
+ * In-memory {@link LockStore} — a per-key promise chain. Correct within a
63
+ * single process; multi-instance correctness needs a distributed lock from the
64
+ * persistence layer.
65
+ */
66
+ export class InMemoryLockStore implements LockStore {
67
+ private readonly chains = new Map<string, Promise<unknown>>()
68
+
69
+ withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
70
+ const prior = this.chains.get(key) ?? Promise.resolve()
71
+ // Chain after the prior holder regardless of how it settled.
72
+ const run = prior.then(fn, fn)
73
+ // Keep the chain alive but swallow rejections so one failure doesn't poison the lock.
74
+ this.chains.set(
75
+ key,
76
+ run.then(
77
+ () => undefined,
78
+ () => undefined,
79
+ ),
80
+ )
81
+ return run
82
+ }
83
+ }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * MCP tool-proxy bridge, shared by all harness adapters.
3
+ *
4
+ * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server.
5
+ * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is
6
+ * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the
7
+ * orchestrator process (with its closures / DB / secrets), and the result is
8
+ * returned into the sandbox.
9
+ *
10
+ * The bridge is split into a transport-agnostic CORE and a TRANSPORT:
11
+ * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver
12
+ * (no I/O). It is what makes the bridge portable.
13
+ * - {@link startHostToolBridge} is the `node:http` transport for a long-running
14
+ * host (laptop / CI / Docker orchestrator). It binds loopback unless the
15
+ * sandbox must reach it via `host.docker.internal`, and authenticates with a
16
+ * constant-time bearer check.
17
+ * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the
18
+ * SAME core from its own `fetch` handler — no raw TCP listener — see
19
+ * {@link handleBridgeJsonRpc} and the Cloudflare example.
20
+ */
21
+ import { createServer } from 'node:http'
22
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
23
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
24
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
25
+ import {
26
+ CallToolRequestSchema,
27
+ ListToolsRequestSchema,
28
+ } from '@modelcontextprotocol/sdk/types.js'
29
+ import type { AddressInfo } from 'node:net'
30
+ import type { AnyTool } from '@tanstack/ai'
31
+
32
+ /**
33
+ * Name of the bridged MCP server. The agent sees tools as
34
+ * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix
35
+ * so tool-call events match the names the application registered.
36
+ */
37
+ export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
38
+
39
+ /** Hostname the sandbox uses to reach the bridge endpoint, per provider. */
40
+ export function hostForSandbox(provider: string): string {
41
+ return provider === 'docker' ? 'host.docker.internal' : '127.0.0.1'
42
+ }
43
+
44
+ /** Result of a permission decision returned to the harness's prompt tool. */
45
+ export interface PermissionToolResult {
46
+ behavior: 'allow' | 'deny'
47
+ message?: string
48
+ updatedInput?: unknown
49
+ }
50
+
51
+ export interface BridgePermission {
52
+ toolName: string
53
+ resolve: (input: {
54
+ tool_name?: string
55
+ input?: unknown
56
+ }) => PermissionToolResult | Promise<PermissionToolResult>
57
+ }
58
+
59
+ export interface ToolBridgeCoreOptions {
60
+ /** Runtime context forwarded to each tool's `execute()`. */
61
+ context?: unknown
62
+ /** Abort signal forwarded to each tool's `execute()`. */
63
+ signal?: AbortSignal
64
+ /**
65
+ * Forwarded to each tool's `execute()` so a bridged tool can stream progress /
66
+ * custom events back to the client mid-execution (e.g. code mode's
67
+ * `code_mode:console` logs). Without it those events are silently dropped — the
68
+ * bridge runs out-of-band from the main tool executor, so the executor's own
69
+ * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies
70
+ * one that injects a CUSTOM chunk into its live output stream.
71
+ */
72
+ emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void
73
+ /**
74
+ * Optional permission-prompt tool (e.g. for Claude Code's
75
+ * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool
76
+ * `<name>` whose handler returns the orchestrator's allow/deny decision.
77
+ */
78
+ permission?: BridgePermission
79
+ }
80
+
81
+ /** An MCP tool descriptor as advertised to the in-sandbox agent. */
82
+ export interface ToolDescriptor {
83
+ name: string
84
+ description?: string
85
+ inputSchema: { type: 'object'; [key: string]: unknown }
86
+ }
87
+
88
+ /**
89
+ * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,
90
+ * substituting an empty object schema when it isn't already a JSON-schema object
91
+ * (project rule: a guard, not an `as` cast).
92
+ */
93
+ function toObjectSchema(schema: unknown): {
94
+ type: 'object'
95
+ [key: string]: unknown
96
+ } {
97
+ if (
98
+ schema !== null &&
99
+ typeof schema === 'object' &&
100
+ 'type' in schema &&
101
+ schema.type === 'object'
102
+ ) {
103
+ return { ...schema, type: 'object' }
104
+ }
105
+ return { type: 'object', properties: {} }
106
+ }
107
+
108
+ /** MCP `tools/call` result shape. */
109
+ export interface ToolCallResult {
110
+ content: Array<{ type: 'text'; text: string }>
111
+ isError?: boolean
112
+ }
113
+
114
+ /**
115
+ * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission
116
+ * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a
117
+ * `fetch` handler) wraps this and owns I/O + the bearer check.
118
+ */
119
+ export interface ToolBridgeCore {
120
+ listTools: () => Array<ToolDescriptor>
121
+ callTool: (name: string, args: unknown) => Promise<ToolCallResult>
122
+ }
123
+
124
+ /** Build the transport-agnostic bridge core for the given tools. */
125
+ export function createToolBridgeCore(
126
+ tools: Array<AnyTool>,
127
+ options: ToolBridgeCoreOptions = {},
128
+ ): ToolBridgeCore {
129
+ const toolsByName = new Map(tools.map((tool) => [tool.name, tool]))
130
+ const permission = options.permission
131
+
132
+ const permissionDescriptor: ToolDescriptor | undefined = permission
133
+ ? {
134
+ name: permission.toolName,
135
+ description:
136
+ 'Permission prompt: returns {behavior:"allow"|"deny"} for a requested action.',
137
+ inputSchema: { type: 'object', properties: {} },
138
+ }
139
+ : undefined
140
+
141
+ return {
142
+ listTools() {
143
+ return [
144
+ ...tools.map((tool) => ({
145
+ name: tool.name,
146
+ description: tool.description,
147
+ inputSchema: toObjectSchema(tool.inputSchema),
148
+ })),
149
+ ...(permissionDescriptor ? [permissionDescriptor] : []),
150
+ ]
151
+ },
152
+
153
+ async callTool(name, args) {
154
+ if (permission && name === permission.toolName) {
155
+ const result = await permission.resolve(args ?? {})
156
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] }
157
+ }
158
+ const tool = toolsByName.get(name)
159
+ if (!tool?.execute) throw new Error(`Unknown tool: ${name}`)
160
+ try {
161
+ const result: unknown = await tool.execute(args ?? {}, {
162
+ context: options.context,
163
+ abortSignal: options.signal,
164
+ // No-op default so tools that always call it (e.g. code mode) don't
165
+ // crash when the transport didn't wire a sink.
166
+ emitCustomEvent: options.emitCustomEvent ?? (() => {}),
167
+ })
168
+ const text =
169
+ typeof result === 'string' ? result : JSON.stringify(result)
170
+ return { content: [{ type: 'text', text }] }
171
+ } catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error)
173
+ return {
174
+ isError: true,
175
+ content: [
176
+ { type: 'text', text: `Tool execution failed: ${message}` },
177
+ ],
178
+ }
179
+ }
180
+ },
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based
186
+ * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list`
187
+ * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC
188
+ * response object, or `null` for a notification (no `id`).
189
+ */
190
+ export async function handleBridgeJsonRpc(
191
+ core: ToolBridgeCore,
192
+ message: unknown,
193
+ ): Promise<unknown> {
194
+ if (message === null || typeof message !== 'object') {
195
+ return {
196
+ jsonrpc: '2.0',
197
+ id: null,
198
+ error: { code: -32600, message: 'Invalid Request' },
199
+ }
200
+ }
201
+ const rpc = message as { id?: unknown; method?: unknown; params?: unknown }
202
+ const id = rpc.id ?? null
203
+ const respond = (result: unknown): unknown => ({ jsonrpc: '2.0', id, result })
204
+ switch (rpc.method) {
205
+ case 'initialize':
206
+ return respond({
207
+ protocolVersion: '2024-11-05',
208
+ capabilities: { tools: {} },
209
+ serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },
210
+ })
211
+ case 'notifications/initialized':
212
+ return null
213
+ case 'tools/list':
214
+ return respond({ tools: core.listTools() })
215
+ case 'tools/call': {
216
+ const params = (rpc.params ?? {}) as {
217
+ name?: unknown
218
+ arguments?: unknown
219
+ }
220
+ if (typeof params.name !== 'string') {
221
+ return {
222
+ jsonrpc: '2.0',
223
+ id,
224
+ error: { code: -32602, message: 'Invalid params: name' },
225
+ }
226
+ }
227
+ return respond(await core.callTool(params.name, params.arguments ?? {}))
228
+ }
229
+ default:
230
+ return {
231
+ jsonrpc: '2.0',
232
+ id,
233
+ error: { code: -32601, message: 'Method not found' },
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Constant-time check of an `Authorization: Bearer <token>` header against the
240
+ * expected token. Length mismatch returns false early (token length is not
241
+ * secret); equal-length comparison is timing-safe.
242
+ */
243
+ export function timingSafeBearerEqual(
244
+ header: string | undefined,
245
+ token: string,
246
+ ): boolean {
247
+ if (header === undefined) return false
248
+ const a = Buffer.from(header)
249
+ const b = Buffer.from(`Bearer ${token}`)
250
+ if (a.length !== b.length) return false
251
+ return timingSafeEqual(a, b)
252
+ }
253
+
254
+ export interface HostToolBridge {
255
+ /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */
256
+ name: string
257
+ /** URL the SANDBOX uses to reach this bridge. */
258
+ url: string
259
+ /** Per-run bearer token gating the endpoint. */
260
+ token: string
261
+ close: () => Promise<void>
262
+ }
263
+
264
+ export interface StartBridgeOptions extends ToolBridgeCoreOptions {
265
+ /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */
266
+ hostForSandbox: string
267
+ /**
268
+ * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is
269
+ * widened to `0.0.0.0` only when the sandbox reaches the host via
270
+ * `host.docker.internal` (a container can't reach the host's loopback).
271
+ */
272
+ bindAddress?: string
273
+ }
274
+
275
+ function buildMcpServer(core: ToolBridgeCore): McpServer {
276
+ const server = new McpServer(
277
+ { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },
278
+ { capabilities: { tools: {} } },
279
+ )
280
+ server.server.setRequestHandler(ListToolsRequestSchema, () => ({
281
+ tools: core.listTools(),
282
+ }))
283
+ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
284
+ const result = await core.callTool(
285
+ request.params.name,
286
+ request.params.arguments ?? {},
287
+ )
288
+ return {
289
+ content: result.content,
290
+ ...(result.isError ? { isError: true } : {}),
291
+ }
292
+ })
293
+ return server
294
+ }
295
+
296
+ /**
297
+ * Start the `node:http` MCP tool-proxy bridge for the given tools. For a
298
+ * long-running host (laptop / CI / Docker orchestrator). Serverless/edge
299
+ * orchestrators serve {@link createToolBridgeCore} from their own `fetch`
300
+ * handler instead.
301
+ */
302
+ export async function startHostToolBridge(
303
+ tools: Array<AnyTool>,
304
+ options: StartBridgeOptions,
305
+ ): Promise<HostToolBridge> {
306
+ const token = randomBytes(24).toString('hex')
307
+ const core = createToolBridgeCore(tools, options)
308
+ // Loopback by default; widen to all interfaces only for the Docker bridge,
309
+ // which a container reaches via host.docker.internal (host gateway).
310
+ const bindAddress =
311
+ options.bindAddress ??
312
+ (options.hostForSandbox === 'host.docker.internal'
313
+ ? '0.0.0.0'
314
+ : '127.0.0.1')
315
+
316
+ const httpServer = createServer((req, res) => {
317
+ void (async () => {
318
+ if (!timingSafeBearerEqual(req.headers['authorization'], token)) {
319
+ res.writeHead(401).end('unauthorized')
320
+ return
321
+ }
322
+ const server = buildMcpServer(core)
323
+ const transport = new StreamableHTTPServerTransport({
324
+ sessionIdGenerator: undefined,
325
+ })
326
+ res.on('close', () => {
327
+ void transport.close()
328
+ void server.close()
329
+ })
330
+ await server.connect(transport)
331
+
332
+ let body = ''
333
+ for await (const chunk of req) body += chunk
334
+ let parsed: unknown
335
+ try {
336
+ parsed = body ? JSON.parse(body) : undefined
337
+ } catch {
338
+ // Malformed agent request → 400, distinct from an internal 500.
339
+ if (!res.headersSent) res.writeHead(400).end('invalid JSON body')
340
+ return
341
+ }
342
+ await transport.handleRequest(req, res, parsed)
343
+ })().catch((error: unknown) => {
344
+ // Log the underlying fault — on the host/Docker path there is no run-log
345
+ // capturing it, so swallowing it leaves an operator with nothing.
346
+ console.error('[tool-bridge] request handler failed:', error)
347
+ if (!res.headersSent) res.writeHead(500).end('bridge error')
348
+ })
349
+ })
350
+
351
+ await new Promise<void>((resolve) =>
352
+ httpServer.listen(0, bindAddress, resolve),
353
+ )
354
+ const port = (httpServer.address() as AddressInfo).port
355
+ const url = `http://${options.hostForSandbox}:${port}/mcp`
356
+
357
+ return {
358
+ name: BRIDGED_MCP_SERVER_NAME,
359
+ url,
360
+ token,
361
+ close: () =>
362
+ new Promise<void>((resolve) => httpServer.close(() => resolve())),
363
+ }
364
+ }
365
+
366
+ /** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */
367
+ export type ProvisionedBridge = HostToolBridge
368
+
369
+ export interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions {
370
+ /** Sandbox provider name, to derive how the sandbox reaches the bridge. */
371
+ provider: string
372
+ }
373
+
374
+ /**
375
+ * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge
376
+ * portable across runtimes: a harness adapter asks its capability context for a
377
+ * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host /
378
+ * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable
379
+ * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc}
380
+ * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP
381
+ * listener is needed.
382
+ */
383
+ export interface ToolBridgeProvisioner {
384
+ provision: (
385
+ tools: Array<AnyTool>,
386
+ options: ToolBridgeProvisionOptions,
387
+ ) => Promise<ProvisionedBridge>
388
+ }
389
+
390
+ /** Default provisioner: a `node:http` listener on the host. */
391
+ export const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = {
392
+ provision(tools, options) {
393
+ const { provider, ...core } = options
394
+ return startHostToolBridge(tools, {
395
+ hostForSandbox: hostForSandbox(provider),
396
+ ...core,
397
+ })
398
+ },
399
+ }