@tanstack/ai-sandbox-cloudflare 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.
- package/dist/esm/agent.d.ts +30 -0
- package/dist/esm/agent.js +25 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/chat-coordinator.d.ts +75 -0
- package/dist/esm/chat-coordinator.js +135 -0
- package/dist/esm/chat-coordinator.js.map +1 -0
- package/dist/esm/container-coordinator.d.ts +114 -0
- package/dist/esm/container-coordinator.js +256 -0
- package/dist/esm/container-coordinator.js.map +1 -0
- package/dist/esm/coordinator.d.ts +68 -0
- package/dist/esm/coordinator.js +188 -0
- package/dist/esm/coordinator.js.map +1 -0
- package/dist/esm/factory.d.ts +80 -0
- package/dist/esm/factory.js +69 -0
- package/dist/esm/factory.js.map +1 -0
- package/dist/esm/handle.d.ts +23 -0
- package/dist/esm/handle.js +208 -0
- package/dist/esm/handle.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/preview-tool.d.ts +31 -0
- package/dist/esm/preview-tool.js +37 -0
- package/dist/esm/preview-tool.js.map +1 -0
- package/dist/esm/protocol.d.ts +42 -0
- package/dist/esm/protocol.js +64 -0
- package/dist/esm/protocol.js.map +1 -0
- package/dist/esm/provider.d.ts +31 -0
- package/dist/esm/provider.js +65 -0
- package/dist/esm/provider.js.map +1 -0
- package/dist/esm/public-host.d.ts +67 -0
- package/dist/esm/public-host.js +49 -0
- package/dist/esm/public-host.js.map +1 -0
- package/dist/esm/run-log-do.d.ts +25 -0
- package/dist/esm/run-log-do.js +122 -0
- package/dist/esm/run-log-do.js.map +1 -0
- package/dist/esm/runner.d.ts +32 -0
- package/dist/esm/runner.js +107 -0
- package/dist/esm/runner.js.map +1 -0
- package/dist/esm/web-crypto.d.ts +11 -0
- package/dist/esm/web-crypto.js +18 -0
- package/dist/esm/web-crypto.js.map +1 -0
- package/dist/esm/worker.d.ts +8 -0
- package/dist/esm/worker.js +83 -0
- package/dist/esm/worker.js.map +1 -0
- package/package.json +74 -0
- package/src/agent.ts +66 -0
- package/src/chat-coordinator.ts +253 -0
- package/src/container-coordinator.ts +437 -0
- package/src/coordinator.ts +338 -0
- package/src/factory.ts +225 -0
- package/src/handle.ts +292 -0
- package/src/index.ts +5 -0
- package/src/preview-tool.ts +110 -0
- package/src/protocol.ts +171 -0
- package/src/provider.ts +111 -0
- package/src/public-host.ts +121 -0
- package/src/run-log-do.ts +171 -0
- package/src/runner.ts +226 -0
- package/src/web-crypto.ts +31 -0
- package/src/worker.ts +173 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ContainerSandboxCoordinator` — the concrete {@link SandboxCoordinator} for
|
|
3
|
+
* the CO-LOCATED ("combined") model: the harness loop AND its MCP tool-bridge
|
|
4
|
+
* run INSIDE the container; this DO stays OUTSIDE as a thin durable coordinator.
|
|
5
|
+
*
|
|
6
|
+
* Worker (stateless trigger)
|
|
7
|
+
* → ContainerSandboxCoordinator (this DO: thin durable coordinator)
|
|
8
|
+
* → Container (runs the in-container harness runner that runs chat())
|
|
9
|
+
*
|
|
10
|
+
* The defining difference from {@link ChatSandboxCoordinator}: this DO does NOT
|
|
11
|
+
* call `chat()` / the adapter itself. It implements the one per-model seam,
|
|
12
|
+
* {@link buildRunStream}, by POSTing `/run` to the in-container runner over the
|
|
13
|
+
* sandbox binding and adapting its NDJSON `StreamChunk` stream — so the base's
|
|
14
|
+
* `RunController` / run-log / streaming tail all work unchanged.
|
|
15
|
+
*
|
|
16
|
+
* TWO channels cross the container ↔ DO boundary; everything else (the MCP
|
|
17
|
+
* transport, native stdin) is in-container localhost:
|
|
18
|
+
* • events OUT: runner → DO (NDJSON of StreamChunk, appended to the run-log)
|
|
19
|
+
* • host-tool EXECUTION: container → DO (`/tool-exec/:runId`, bearer-gated) —
|
|
20
|
+
* the REAL tool `execute()` (DB / secrets / app state) lives HERE.
|
|
21
|
+
*
|
|
22
|
+
* The per-run config — host tools, workspace, harness, model — is the subclass's
|
|
23
|
+
* {@link config} method.
|
|
24
|
+
*
|
|
25
|
+
* NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack
|
|
26
|
+
* AI types; not runtime-verified in this repo (no Workers runtime / container
|
|
27
|
+
* build here). It follows the proven run-log / remote-tool contracts.
|
|
28
|
+
*/
|
|
29
|
+
import { EventType } from '@tanstack/ai'
|
|
30
|
+
import {
|
|
31
|
+
executeHostTool,
|
|
32
|
+
isToolExecRequest,
|
|
33
|
+
toolDescriptors,
|
|
34
|
+
} from '@tanstack/ai-sandbox'
|
|
35
|
+
import { getSandbox } from '@cloudflare/sandbox'
|
|
36
|
+
import { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'
|
|
37
|
+
import { timingSafeBearerEqualWeb } from './web-crypto'
|
|
38
|
+
import type { StartRunInput } from './coordinator'
|
|
39
|
+
import type { ContainerRunRequest, HarnessId } from './protocol'
|
|
40
|
+
import type { AnyTool, StreamChunk } from '@tanstack/ai'
|
|
41
|
+
import type { WorkspaceDefinition } from '@tanstack/ai-sandbox'
|
|
42
|
+
import type { Sandbox } from '@cloudflare/sandbox'
|
|
43
|
+
|
|
44
|
+
/** Port the in-container runner listens on (matches RUNNER_PORT in the image). */
|
|
45
|
+
const RUNNER_PORT = 8080
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Env bindings a {@link ContainerSandboxCoordinator} requires. The
|
|
49
|
+
* `tool-exec` URL the CONTAINER calls back on needs a hostname; `PUBLIC_HOSTNAME`
|
|
50
|
+
* is OPTIONAL (request-derived when unset; locally → `host.docker.internal` — see
|
|
51
|
+
* {@link resolveBridgeOrigin}).
|
|
52
|
+
*
|
|
53
|
+
* Auth is HARNESS-AGNOSTIC: the in-container CLI's API key is NOT a fixed field on
|
|
54
|
+
* this env. Instead each run's workspace DECLARES the secret names it needs (via
|
|
55
|
+
* `createSecrets`), and the coordinator copies those names out of the Worker `env`
|
|
56
|
+
* into the container env at boot. So a Claude run declares `ANTHROPIC_API_KEY`, a
|
|
57
|
+
* codex run declares `CODEX_API_KEY`, and neither name is baked into the package —
|
|
58
|
+
* the concrete key binding lives on the APP's env type, not here.
|
|
59
|
+
*/
|
|
60
|
+
export interface ContainerCoordinatorEnv {
|
|
61
|
+
/** The `@cloudflare/sandbox` Sandbox DO namespace (the container hosts). */
|
|
62
|
+
Sandbox: DurableObjectNamespace<Sandbox>
|
|
63
|
+
/**
|
|
64
|
+
* Hostname the container uses to reach the DO's `/tool-exec` endpoint. Optional:
|
|
65
|
+
* unset → derived from the trigger request (deployed: request host; local dev:
|
|
66
|
+
* `host.docker.internal`). Set it only to override. See {@link resolveBridgeOrigin}.
|
|
67
|
+
*/
|
|
68
|
+
PUBLIC_HOSTNAME?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What {@link ContainerSandboxCoordinator.config} returns for one run. */
|
|
72
|
+
export interface ContainerRunConfig {
|
|
73
|
+
/**
|
|
74
|
+
* The REAL host tools. Their `execute()` runs HERE, in the DO — the
|
|
75
|
+
* in-container agent only ever reaches them via `/tool-exec/:runId`. Only the
|
|
76
|
+
* serialized descriptors cross to the container.
|
|
77
|
+
*/
|
|
78
|
+
hostTools: Array<AnyTool>
|
|
79
|
+
/** Workspace the in-container runner bootstraps for the agent. */
|
|
80
|
+
workspace: WorkspaceDefinition
|
|
81
|
+
/** Which in-sandbox harness the runner spawns. */
|
|
82
|
+
harness: HarnessId
|
|
83
|
+
/** Model id passed to that harness. */
|
|
84
|
+
model: string
|
|
85
|
+
/** Runtime context forwarded to each host tool's `execute()` (DB / app state). */
|
|
86
|
+
context?: unknown
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Per-run tool-exec state; gates `/tool-exec/:runId` and runs the host tools. */
|
|
90
|
+
interface ToolExecState {
|
|
91
|
+
token: string
|
|
92
|
+
hostTools: Array<AnyTool>
|
|
93
|
+
context?: unknown
|
|
94
|
+
/** Aborted once the run is terminal so a still-running host tool is cancelled. */
|
|
95
|
+
abort: AbortController
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Narrow one NDJSON line into a StreamChunk (project rule: no `as`). */
|
|
99
|
+
function isStreamChunk(value: unknown): value is StreamChunk {
|
|
100
|
+
return value !== null && typeof value === 'object' && 'type' in value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Adapt the runner's NDJSON response body into an `AsyncIterable<StreamChunk>`
|
|
105
|
+
* so the DO can drive it through the SAME base `RunController` / `pipeToRunLog`
|
|
106
|
+
* the DO-drives coordinator uses — terminal-status handling, RUN_ERROR
|
|
107
|
+
* detection, and never-rejects semantics all come for free. A malformed line
|
|
108
|
+
* (unparseable JSON, or valid JSON that isn't a chunk) is surfaced as a terminal
|
|
109
|
+
* RUN_ERROR chunk, never silently dropped.
|
|
110
|
+
*/
|
|
111
|
+
async function* ndjsonToChunks(
|
|
112
|
+
body: ReadableStream<Uint8Array>,
|
|
113
|
+
): AsyncIterable<StreamChunk> {
|
|
114
|
+
const reader = body.getReader()
|
|
115
|
+
// Decode incrementally with `stream: true` so a multi-byte char split across
|
|
116
|
+
// two reads is reassembled correctly (TextDecoderStream's DOM/Workers typings
|
|
117
|
+
// disagree across versions; a plain TextDecoder is version-robust and no-cast).
|
|
118
|
+
const decoder = new TextDecoder()
|
|
119
|
+
let buffer = ''
|
|
120
|
+
let result = await reader.read()
|
|
121
|
+
while (!result.done) {
|
|
122
|
+
buffer += decoder.decode(result.value, { stream: true })
|
|
123
|
+
let newline = buffer.indexOf('\n')
|
|
124
|
+
while (newline !== -1) {
|
|
125
|
+
const line = buffer.slice(0, newline).trim()
|
|
126
|
+
buffer = buffer.slice(newline + 1)
|
|
127
|
+
newline = buffer.indexOf('\n')
|
|
128
|
+
if (line === '') continue
|
|
129
|
+
const chunk = parseChunkLine(line)
|
|
130
|
+
yield chunk
|
|
131
|
+
if (chunk.type === EventType.RUN_ERROR) return
|
|
132
|
+
}
|
|
133
|
+
result = await reader.read()
|
|
134
|
+
}
|
|
135
|
+
buffer += decoder.decode()
|
|
136
|
+
const tail = buffer.trim()
|
|
137
|
+
if (tail !== '') yield parseChunkLine(tail)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse one NDJSON line into a {@link StreamChunk}, turning a truncated/garbled
|
|
142
|
+
* line (a crashed container's last write) or a non-chunk object into a terminal
|
|
143
|
+
* RUN_ERROR chunk rather than throwing or silently dropping it.
|
|
144
|
+
*/
|
|
145
|
+
function parseChunkLine(line: string): StreamChunk {
|
|
146
|
+
let parsed: unknown
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(line)
|
|
149
|
+
} catch {
|
|
150
|
+
return {
|
|
151
|
+
type: EventType.RUN_ERROR,
|
|
152
|
+
message: `runner sent unparseable NDJSON: ${line.slice(0, 200)}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!isStreamChunk(parsed)) {
|
|
156
|
+
return {
|
|
157
|
+
type: EventType.RUN_ERROR,
|
|
158
|
+
message: 'runner sent a non-chunk line',
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return parsed
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export abstract class ContainerSandboxCoordinator<
|
|
165
|
+
TEnv extends ContainerCoordinatorEnv = ContainerCoordinatorEnv,
|
|
166
|
+
> extends SandboxCoordinator<TEnv> {
|
|
167
|
+
/**
|
|
168
|
+
* Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's
|
|
169
|
+
* tool-exec endpoint is only reachable while the run is in flight, and
|
|
170
|
+
* `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so
|
|
171
|
+
* the container's callbacks always hit the instance that minted the token.
|
|
172
|
+
*/
|
|
173
|
+
private readonly toolExec = new Map<string, ToolExecState>()
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* In-flight runner boot, memoized so two runs starting near-simultaneously on
|
|
177
|
+
* this instance don't both spawn `container-runner` (the second would hit
|
|
178
|
+
* EADDRINUSE on RUNNER_PORT). Cleared once boot settles.
|
|
179
|
+
*/
|
|
180
|
+
private runnerBoot?: Promise<void>
|
|
181
|
+
|
|
182
|
+
/** Last `/health` probe error, surfaced if the runner never comes up. */
|
|
183
|
+
private lastProbeError?: unknown
|
|
184
|
+
|
|
185
|
+
// ===========================================================================
|
|
186
|
+
// Subclass seam: the per-run configuration
|
|
187
|
+
// ===========================================================================
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolve the host tools, workspace, harness, and model for one run.
|
|
191
|
+
* Implemented by the app subclass (or supplied by
|
|
192
|
+
* {@link createCloudflareSandboxAgent}).
|
|
193
|
+
*/
|
|
194
|
+
protected abstract config(input: StartRunInput): ContainerRunConfig
|
|
195
|
+
|
|
196
|
+
// ===========================================================================
|
|
197
|
+
// The one per-model seam: drive the in-container runner
|
|
198
|
+
// ===========================================================================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Mint the per-run tool-exec token, POST `/run` to the in-container runner, and
|
|
202
|
+
* yield its NDJSON chunks. The token is registered BEFORE the container is told
|
|
203
|
+
* to run, so a tool callback can never arrive before the token exists.
|
|
204
|
+
*/
|
|
205
|
+
protected override buildRunStream(
|
|
206
|
+
input: StartRunInput,
|
|
207
|
+
): AsyncIterable<StreamChunk> {
|
|
208
|
+
const runConfig = this.config(input)
|
|
209
|
+
// Mint the token BEFORE driving the container, registering the real tools so
|
|
210
|
+
// `/tool-exec/:runId` can execute them.
|
|
211
|
+
const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')
|
|
212
|
+
this.toolExec.set(input.runId, {
|
|
213
|
+
token,
|
|
214
|
+
hostTools: runConfig.hostTools,
|
|
215
|
+
...(runConfig.context !== undefined
|
|
216
|
+
? { context: runConfig.context }
|
|
217
|
+
: {}),
|
|
218
|
+
abort: new AbortController(),
|
|
219
|
+
})
|
|
220
|
+
return this.driveContainer(input, runConfig, token)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Once the run is terminal, abort any host tool still running on its behalf
|
|
225
|
+
* (so a tool that outlived the run doesn't leak), then drop the per-run state.
|
|
226
|
+
*/
|
|
227
|
+
protected override onRunSettled(runId: string): void {
|
|
228
|
+
const state = this.toolExec.get(runId)
|
|
229
|
+
if (state) state.abort.abort()
|
|
230
|
+
this.toolExec.delete(runId)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* POST `/run` to the in-container runner and yield its NDJSON chunks. The DO
|
|
235
|
+
* reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to
|
|
236
|
+
* RUNNER_PORT) — this internal channel needs no public hostname. The runner
|
|
237
|
+
* gets the host-tool descriptors plus the `/tool-exec` URL + token it calls
|
|
238
|
+
* back on.
|
|
239
|
+
*/
|
|
240
|
+
private async *driveContainer(
|
|
241
|
+
input: StartRunInput,
|
|
242
|
+
runConfig: ContainerRunConfig,
|
|
243
|
+
token: string,
|
|
244
|
+
): AsyncIterable<StreamChunk> {
|
|
245
|
+
const sandbox = getSandbox(this.env.Sandbox, input.threadId)
|
|
246
|
+
await this.ensureRunner(sandbox, runConfig.workspace)
|
|
247
|
+
// Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the
|
|
248
|
+
// trigger request (locally → host.docker.internal). The tool-exec token rides
|
|
249
|
+
// this URL. See `resolveBridgeOrigin`.
|
|
250
|
+
const origin = resolveBridgeOrigin(this.env, input)
|
|
251
|
+
const body: ContainerRunRequest = {
|
|
252
|
+
runId: input.runId,
|
|
253
|
+
threadId: input.threadId,
|
|
254
|
+
messages: input.messages,
|
|
255
|
+
harness: runConfig.harness,
|
|
256
|
+
model: runConfig.model,
|
|
257
|
+
workspace: runConfig.workspace,
|
|
258
|
+
// Serialize the DO's real tools to wire descriptors for the container.
|
|
259
|
+
toolDescriptors: toolDescriptors(runConfig.hostTools),
|
|
260
|
+
// The container calls back here for host-tool EXECUTION. It must be a URL
|
|
261
|
+
// the CONTAINER can reach, so it goes via the Worker's public hostname.
|
|
262
|
+
toolExecUrl: `${origin}/tool-exec/${input.runId}?threadId=${encodeURIComponent(input.threadId)}`,
|
|
263
|
+
toolExecToken: token,
|
|
264
|
+
}
|
|
265
|
+
const response = await sandbox.containerFetch(
|
|
266
|
+
'http://runner/run',
|
|
267
|
+
{
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: { 'content-type': 'application/json' },
|
|
270
|
+
body: JSON.stringify(body),
|
|
271
|
+
},
|
|
272
|
+
RUNNER_PORT,
|
|
273
|
+
)
|
|
274
|
+
if (!response.ok || !response.body) {
|
|
275
|
+
const text = await response.text()
|
|
276
|
+
// Surface as a terminal RUN_ERROR chunk; the base run driver finishes the
|
|
277
|
+
// run as `error` and tailing clients observe it.
|
|
278
|
+
yield {
|
|
279
|
+
type: EventType.RUN_ERROR,
|
|
280
|
+
message: `container runner failed: ${response.status} ${text.slice(0, 200)}`,
|
|
281
|
+
}
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
yield* ndjsonToChunks(response.body)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Ensure the in-container runner is listening on RUNNER_PORT. The base image's
|
|
289
|
+
* ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the
|
|
290
|
+
* bundled runner as a background process via that control server. Idempotent
|
|
291
|
+
* for a thread-reused container: if `/health` already answers, we skip spawn.
|
|
292
|
+
*/
|
|
293
|
+
private ensureRunner(
|
|
294
|
+
sandbox: Sandbox,
|
|
295
|
+
workspace: WorkspaceDefinition,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
// Memoize so concurrent runs on this instance share ONE boot.
|
|
298
|
+
if (this.runnerBoot) return this.runnerBoot
|
|
299
|
+
const boot = this.bootRunner(sandbox, workspace).finally(() => {
|
|
300
|
+
this.runnerBoot = undefined
|
|
301
|
+
})
|
|
302
|
+
this.runnerBoot = boot
|
|
303
|
+
return boot
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Copy the run's DECLARED secret names out of the Worker `env` into a plain
|
|
308
|
+
* record for the container env. The workspace's `createSecrets` carries only the
|
|
309
|
+
* names across the `/run` boundary; the VALUES come from `env` by that name —
|
|
310
|
+
* which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the
|
|
311
|
+
* CLI without the package hardcoding which one. A declared name missing from
|
|
312
|
+
* `env` is skipped here and fails loudly later in the runner's
|
|
313
|
+
* `reconstituteWorkspace` (never a silent keyless run).
|
|
314
|
+
*/
|
|
315
|
+
private secretEnvFromWorkspace(
|
|
316
|
+
workspace: WorkspaceDefinition,
|
|
317
|
+
): Record<string, string> {
|
|
318
|
+
const env = this.env as Record<string, unknown>
|
|
319
|
+
const out: Record<string, string> = {}
|
|
320
|
+
for (const name of Object.keys(workspace.secrets ?? {})) {
|
|
321
|
+
const value = env[name]
|
|
322
|
+
if (typeof value === 'string' && value !== '') out[name] = value
|
|
323
|
+
}
|
|
324
|
+
return out
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async bootRunner(
|
|
328
|
+
sandbox: Sandbox,
|
|
329
|
+
workspace: WorkspaceDefinition,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
if (await this.runnerHealthy(sandbox)) return
|
|
332
|
+
// Inject the run's declared secrets into the container env so the in-container
|
|
333
|
+
// CLI can authenticate. Values never land in argv or the run-log. Harness-
|
|
334
|
+
// agnostic: whichever secret names the workspace declared (ANTHROPIC_API_KEY,
|
|
335
|
+
// CODEX_API_KEY, …) are read from `env` by name. The runner process inherits
|
|
336
|
+
// this env at boot, so secrets must be set BEFORE startProcess.
|
|
337
|
+
const secretEnv = this.secretEnvFromWorkspace(workspace)
|
|
338
|
+
if (Object.keys(secretEnv).length > 0) {
|
|
339
|
+
await sandbox.setEnvVars(secretEnv)
|
|
340
|
+
}
|
|
341
|
+
// The Dockerfile copies the bundled runner to /app/container-runner.mjs.
|
|
342
|
+
await sandbox.startProcess(`node /app/container-runner.mjs`, {
|
|
343
|
+
env: { RUNNER_PORT: String(RUNNER_PORT) },
|
|
344
|
+
})
|
|
345
|
+
// Poll until it answers /health (container cold-start + node boot). A run
|
|
346
|
+
// that never comes up surfaces as a failed containerFetch above — not a hang.
|
|
347
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
348
|
+
if (await this.runnerHealthy(sandbox)) return
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
350
|
+
}
|
|
351
|
+
// Include the last probe error so a real misconfig (missing binding, image
|
|
352
|
+
// without the runner) is distinguishable from a plain slow cold-start.
|
|
353
|
+
const detail =
|
|
354
|
+
this.lastProbeError instanceof Error
|
|
355
|
+
? `: ${this.lastProbeError.message}`
|
|
356
|
+
: this.lastProbeError !== undefined
|
|
357
|
+
? `: ${String(this.lastProbeError)}`
|
|
358
|
+
: ''
|
|
359
|
+
throw new Error(
|
|
360
|
+
`in-container runner did not become healthy in time${detail}`,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async runnerHealthy(sandbox: Sandbox): Promise<boolean> {
|
|
365
|
+
try {
|
|
366
|
+
const res = await sandbox.containerFetch(
|
|
367
|
+
'http://runner/health',
|
|
368
|
+
{ method: 'GET' },
|
|
369
|
+
RUNNER_PORT,
|
|
370
|
+
)
|
|
371
|
+
return res.ok
|
|
372
|
+
} catch (error) {
|
|
373
|
+
this.lastProbeError = error
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ===========================================================================
|
|
379
|
+
// The host-tool-exec callback (`/tool-exec/:runId`), from the base fetch
|
|
380
|
+
// ===========================================================================
|
|
381
|
+
|
|
382
|
+
protected override handleRoute(
|
|
383
|
+
request: Request,
|
|
384
|
+
parts: Array<string>,
|
|
385
|
+
): Promise<Response> | Response {
|
|
386
|
+
if (parts[0] === 'tool-exec' && typeof parts[1] === 'string') {
|
|
387
|
+
return this.serveToolExec(parts[1], request)
|
|
388
|
+
}
|
|
389
|
+
return super.handleRoute(request, parts)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Execute a host tool the in-container agent called back for. The token gates
|
|
394
|
+
* it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here
|
|
395
|
+
* via {@link executeHostTool} and its raw result returns as `{ result }`. An
|
|
396
|
+
* unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.
|
|
397
|
+
*/
|
|
398
|
+
private async serveToolExec(
|
|
399
|
+
runId: string,
|
|
400
|
+
request: Request,
|
|
401
|
+
): Promise<Response> {
|
|
402
|
+
const state = this.toolExec.get(runId)
|
|
403
|
+
if (!state) return new Response('no active run', { status: 404 })
|
|
404
|
+
if (
|
|
405
|
+
!timingSafeBearerEqualWeb(
|
|
406
|
+
request.headers.get('authorization') ?? undefined,
|
|
407
|
+
state.token,
|
|
408
|
+
)
|
|
409
|
+
) {
|
|
410
|
+
return new Response('unauthorized', { status: 401 })
|
|
411
|
+
}
|
|
412
|
+
let payload: unknown
|
|
413
|
+
try {
|
|
414
|
+
payload = await request.json()
|
|
415
|
+
} catch {
|
|
416
|
+
return this.jsonResponse({ error: 'body must be valid JSON' }, 400)
|
|
417
|
+
}
|
|
418
|
+
if (!isToolExecRequest(payload)) {
|
|
419
|
+
return this.jsonResponse({ error: 'body must be { name, args }' }, 400)
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const result = await executeHostTool(
|
|
423
|
+
state.hostTools,
|
|
424
|
+
payload.name,
|
|
425
|
+
payload.args,
|
|
426
|
+
{
|
|
427
|
+
...(state.context !== undefined ? { context: state.context } : {}),
|
|
428
|
+
signal: state.abort.signal,
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
return this.jsonResponse({ result })
|
|
432
|
+
} catch (error) {
|
|
433
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
434
|
+
return this.jsonResponse({ error: message }, 500)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|