@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.
Files changed (61) hide show
  1. package/dist/esm/agent.d.ts +30 -0
  2. package/dist/esm/agent.js +25 -0
  3. package/dist/esm/agent.js.map +1 -0
  4. package/dist/esm/chat-coordinator.d.ts +75 -0
  5. package/dist/esm/chat-coordinator.js +135 -0
  6. package/dist/esm/chat-coordinator.js.map +1 -0
  7. package/dist/esm/container-coordinator.d.ts +114 -0
  8. package/dist/esm/container-coordinator.js +256 -0
  9. package/dist/esm/container-coordinator.js.map +1 -0
  10. package/dist/esm/coordinator.d.ts +68 -0
  11. package/dist/esm/coordinator.js +188 -0
  12. package/dist/esm/coordinator.js.map +1 -0
  13. package/dist/esm/factory.d.ts +80 -0
  14. package/dist/esm/factory.js +69 -0
  15. package/dist/esm/factory.js.map +1 -0
  16. package/dist/esm/handle.d.ts +23 -0
  17. package/dist/esm/handle.js +208 -0
  18. package/dist/esm/handle.js.map +1 -0
  19. package/dist/esm/index.d.ts +4 -0
  20. package/dist/esm/index.js +10 -0
  21. package/dist/esm/index.js.map +1 -0
  22. package/dist/esm/preview-tool.d.ts +31 -0
  23. package/dist/esm/preview-tool.js +37 -0
  24. package/dist/esm/preview-tool.js.map +1 -0
  25. package/dist/esm/protocol.d.ts +42 -0
  26. package/dist/esm/protocol.js +64 -0
  27. package/dist/esm/protocol.js.map +1 -0
  28. package/dist/esm/provider.d.ts +31 -0
  29. package/dist/esm/provider.js +65 -0
  30. package/dist/esm/provider.js.map +1 -0
  31. package/dist/esm/public-host.d.ts +67 -0
  32. package/dist/esm/public-host.js +49 -0
  33. package/dist/esm/public-host.js.map +1 -0
  34. package/dist/esm/run-log-do.d.ts +25 -0
  35. package/dist/esm/run-log-do.js +122 -0
  36. package/dist/esm/run-log-do.js.map +1 -0
  37. package/dist/esm/runner.d.ts +32 -0
  38. package/dist/esm/runner.js +107 -0
  39. package/dist/esm/runner.js.map +1 -0
  40. package/dist/esm/web-crypto.d.ts +11 -0
  41. package/dist/esm/web-crypto.js +18 -0
  42. package/dist/esm/web-crypto.js.map +1 -0
  43. package/dist/esm/worker.d.ts +8 -0
  44. package/dist/esm/worker.js +83 -0
  45. package/dist/esm/worker.js.map +1 -0
  46. package/package.json +74 -0
  47. package/src/agent.ts +66 -0
  48. package/src/chat-coordinator.ts +253 -0
  49. package/src/container-coordinator.ts +437 -0
  50. package/src/coordinator.ts +338 -0
  51. package/src/factory.ts +225 -0
  52. package/src/handle.ts +292 -0
  53. package/src/index.ts +5 -0
  54. package/src/preview-tool.ts +110 -0
  55. package/src/protocol.ts +171 -0
  56. package/src/provider.ts +111 -0
  57. package/src/public-host.ts +121 -0
  58. package/src/run-log-do.ts +171 -0
  59. package/src/runner.ts +226 -0
  60. package/src/web-crypto.ts +31 -0
  61. package/src/worker.ts +173 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Host resolution for the two DISTINCT public surfaces the sandbox layer exposes.
3
+ * Kept in its own (Workers-free) module so it stays pure and unit-testable.
4
+ *
5
+ * These were once a single `PUBLIC_HOSTNAME`, but they have different reachers and
6
+ * therefore different correct values:
7
+ *
8
+ * - **Bridge / tool-exec** — the off-isolate CONTAINER calls back into the Worker
9
+ * (`/_bridge`, `/tool-exec`). It must reach the Worker, so locally that's
10
+ * `host.docker.internal` (the container can't reach the host's `localhost`).
11
+ * - **Preview** — the BROWSER opens an `exposePort` URL that `proxyToSandbox`
12
+ * routes into the container. It needs WILDCARD DNS, so locally that's
13
+ * `*.localhost` (browsers resolve it to loopback with zero setup) and in
14
+ * production a CUSTOM DOMAIN (`*.workers.dev` has no wildcard subdomains).
15
+ */
16
+
17
+ /** Hostnames that mean "this machine" (the loopback the container can't reach). */
18
+ function isLoopbackHost(host: string): boolean {
19
+ const name = host.split(':')[0]
20
+ return name === 'localhost' || name === '127.0.0.1' || name === '0.0.0.0'
21
+ }
22
+
23
+ /** The port portion of a `host[:port]`, or `fallback` when none is present. */
24
+ function portOf(host: string, fallback: string): string {
25
+ const colon = host.indexOf(':')
26
+ return colon === -1 ? fallback : host.slice(colon + 1)
27
+ }
28
+
29
+ /** `http://` for local hosts (loopback / host.docker.internal), `https://` else. */
30
+ function originForHost(host: string): string {
31
+ const name = host.split(':')[0]
32
+ const scheme =
33
+ isLoopbackHost(host) || name === 'host.docker.internal' ? 'http' : 'https'
34
+ return `${scheme}://${host}`
35
+ }
36
+
37
+ /**
38
+ * Resolve the ORIGIN the off-isolate sandbox CONTAINER uses to call back into the
39
+ * Worker — the MCP tool-bridge (`/_bridge`) and host-tool execution (`/tool-exec`).
40
+ * Returns a full origin (scheme + host + optional port), e.g.
41
+ * `http://host.docker.internal:3001` locally or `https://app.example.com` deployed.
42
+ *
43
+ * `PUBLIC_HOSTNAME` wins when set; otherwise we derive from the host the trigger
44
+ * request arrived on (`input.publicHost`).
45
+ *
46
+ * ── Why a callback hostname is unavoidable ──────────────────────────────────────
47
+ * The container is SEPARATE compute from the Worker isolate; it can only reach the
48
+ * Worker over the network, so the callback URL must be an absolute host.
49
+ *
50
+ * ── Why request-derivation is SAFE on Cloudflare ────────────────────────────────
51
+ * On a generic Node server the `Host` header is attacker-controlled and trusting it
52
+ * is a Host-injection / token-exfil vector (the per-run bearer token rides this
53
+ * URL). Not so behind Cloudflare: the edge dispatches a request to your Worker only
54
+ * when its hostname matches a route you OWN, so `input.publicHost` is always one of
55
+ * your own hostnames — never an attacker's.
56
+ *
57
+ * ── Local dev: localhost → host.docker.internal ─────────────────────────────────
58
+ * Locally the trigger arrives on `localhost`, which the container CANNOT reach
59
+ * (that's the container's own loopback). So we rewrite it to `host.docker.internal`
60
+ * (the Docker host gateway), keeping the port, over `http`. This removes the need
61
+ * for a dev tunnel for the bridge entirely.
62
+ */
63
+ export function resolveBridgeOrigin(
64
+ env: { PUBLIC_HOSTNAME?: string },
65
+ input: { publicHost?: string },
66
+ ): string {
67
+ const configured = env.PUBLIC_HOSTNAME?.trim()
68
+ if (configured) return originForHost(configured)
69
+ const host = input.publicHost
70
+ if (!host) {
71
+ throw new Error(
72
+ 'sandbox agent: no bridge host available — set PUBLIC_HOSTNAME, or run ' +
73
+ 'behind Cloudflare so the Worker can derive it from the trigger request.',
74
+ )
75
+ }
76
+ // Local dev: the container reaches the host machine via the Docker host gateway.
77
+ if (isLoopbackHost(host)) {
78
+ return `http://host.docker.internal:${portOf(host, '3001')}`
79
+ }
80
+ return originForHost(host)
81
+ }
82
+
83
+ /**
84
+ * Resolve the HOST passed to `exposePort` for browser-facing preview URLs (the app
85
+ * the agent builds). Returns a bare host (the `@cloudflare/sandbox` SDK builds the
86
+ * `<port>-<id>-<token>.<host>` URL + scheme itself).
87
+ *
88
+ * `PREVIEW_HOSTNAME` wins when set; otherwise we derive from the trigger request.
89
+ *
90
+ * Preview URLs require WILDCARD DNS, which constrains the value:
91
+ * - **Local** → `localhost:<port>`. The SDK's localhost path yields
92
+ * `http://<port>-<id>-<token>.localhost:<port>`, which browsers resolve to
93
+ * loopback with no DNS setup — so previews work locally with no tunnel.
94
+ * - **Deployed** → a CUSTOM DOMAIN with a `*.<domain>` route. `*.workers.dev` has
95
+ * no wildcard subdomains (the SDK's `exposePort` throws on it), so we throw a
96
+ * clear error pointing at `PREVIEW_HOSTNAME` rather than letting the run fail
97
+ * deep in the agent.
98
+ */
99
+ export function resolvePreviewHost(
100
+ env: { PREVIEW_HOSTNAME?: string },
101
+ input: { publicHost?: string },
102
+ ): string {
103
+ const configured = env.PREVIEW_HOSTNAME?.trim()
104
+ if (configured) return configured
105
+ const host = input.publicHost
106
+ if (!host) {
107
+ throw new Error(
108
+ 'sandbox agent: no preview host available — set PREVIEW_HOSTNAME to a ' +
109
+ 'custom domain with a wildcard route.',
110
+ )
111
+ }
112
+ if (isLoopbackHost(host)) return host
113
+ if (host.endsWith('.workers.dev')) {
114
+ throw new Error(
115
+ 'sandbox agent: preview URLs need a custom domain with wildcard DNS — ' +
116
+ '*.workers.dev has no wildcard subdomains. Set PREVIEW_HOSTNAME to your ' +
117
+ 'custom domain and add a `*.<domain>` route to the Worker.',
118
+ )
119
+ }
120
+ return host
121
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * A durable {@link RunEventLog} backed by Durable Object storage — the storage
3
+ * half of the serverless/edge run model. The coordinator appends every
4
+ * {@link StreamChunk} the agent emits under a monotonic `seq`; clients tail from
5
+ * a cursor. Because events are PERSISTED (not held in a caller's open stream), a
6
+ * reconnecting tab, a dropped WebSocket, or a coordinator that hibernated
7
+ * between chunks all resume cleanly: replay everything after the client's
8
+ * `lastSeq`, then live-tail to terminal.
9
+ *
10
+ * Mirrors {@link InMemoryRunEventLog} from `@tanstack/ai-sandbox` exactly.
11
+ * Storage layout (keys scoped by `runId` so one DO can host many runs):
12
+ * - `rec:<runId>` → the {@link RunRecord}
13
+ * - `evt:<runId>:<seq8>` → the chunk for that seq (seq zero-padded to 8 digits
14
+ * so `list({ prefix })` returns events in seq order).
15
+ *
16
+ * The live-tail wake-up (the in-memory waiter set) is per-INSTANCE; if the
17
+ * instance is evicted mid-run, a reader re-reads the persisted backlog and the
18
+ * `TAIL_POLL_MS` fallback poll keeps it progressing. No event is ever lost.
19
+ *
20
+ * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`.
21
+ */
22
+ import { isTerminalRunStatus } from '@tanstack/ai-sandbox'
23
+ import type {
24
+ RunError,
25
+ RunEvent,
26
+ RunEventLog,
27
+ RunEventLogReadOptions,
28
+ RunRecord,
29
+ TerminalRunStatus,
30
+ } from '@tanstack/ai-sandbox'
31
+ import type { StreamChunk } from '@tanstack/ai'
32
+
33
+ /** How long a post-eviction reader waits before re-polling storage (ms). */
34
+ const TAIL_POLL_MS = 250
35
+
36
+ const recKey = (runId: string): string => `rec:${runId}`
37
+ const evtKey = (runId: string, seq: number): string =>
38
+ `evt:${runId}:${String(seq).padStart(8, '0')}`
39
+ const evtPrefix = (runId: string): string => `evt:${runId}:`
40
+
41
+ export class DurableObjectRunEventLog implements RunEventLog {
42
+ /** Per-run wake-ups for live-tailing readers on THIS instance. */
43
+ private readonly waiters = new Map<string, Set<() => void>>()
44
+
45
+ constructor(private readonly storage: DurableObjectStorage) {}
46
+
47
+ private async require(runId: string): Promise<RunRecord> {
48
+ const record = await this.storage.get<RunRecord>(recKey(runId))
49
+ if (!record) throw new Error(`run-log: unknown runId "${runId}"`)
50
+ return record
51
+ }
52
+
53
+ /** Wake (and clear) every reader blocked on this run. */
54
+ private wake(runId: string): void {
55
+ const set = this.waiters.get(runId)
56
+ if (!set) return
57
+ const pending = [...set]
58
+ set.clear()
59
+ for (const resolve of pending) resolve()
60
+ }
61
+
62
+ async open(input: { runId: string; threadId?: string }): Promise<RunRecord> {
63
+ const existing = await this.storage.get<RunRecord>(recKey(input.runId))
64
+ if (existing) return existing
65
+ const now = Date.now()
66
+ const record: RunRecord = {
67
+ runId: input.runId,
68
+ ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
69
+ status: 'running',
70
+ lastSeq: -1,
71
+ createdAt: now,
72
+ updatedAt: now,
73
+ }
74
+ await this.storage.put(recKey(input.runId), record)
75
+ return record
76
+ }
77
+
78
+ async append(runId: string, chunk: StreamChunk): Promise<number> {
79
+ const record = await this.require(runId)
80
+ if (isTerminalRunStatus(record.status)) {
81
+ throw new Error(
82
+ `run-log: cannot append to terminal run "${runId}" (status=${record.status})`,
83
+ )
84
+ }
85
+ const seq = record.lastSeq + 1
86
+ const next: RunRecord = { ...record, lastSeq: seq, updatedAt: Date.now() }
87
+ // One transaction so the appended event and its bumped record commit
88
+ // together — a reader never sees a lastSeq pointing at a missing event.
89
+ await this.storage.transaction(async (txn) => {
90
+ await txn.put(evtKey(runId, seq), chunk)
91
+ await txn.put(recKey(runId), next)
92
+ })
93
+ this.wake(runId)
94
+ return seq
95
+ }
96
+
97
+ async finish(
98
+ runId: string,
99
+ status: TerminalRunStatus,
100
+ error?: RunError,
101
+ ): Promise<void> {
102
+ const record = await this.require(runId)
103
+ if (isTerminalRunStatus(record.status)) return
104
+ const next: RunRecord = {
105
+ ...record,
106
+ status,
107
+ ...(error !== undefined ? { error } : {}),
108
+ updatedAt: Date.now(),
109
+ }
110
+ await this.storage.put(recKey(runId), next)
111
+ this.wake(runId)
112
+ }
113
+
114
+ async get(runId: string): Promise<RunRecord | null> {
115
+ return (await this.storage.get<RunRecord>(recKey(runId))) ?? null
116
+ }
117
+
118
+ async *read(
119
+ runId: string,
120
+ options?: RunEventLogReadOptions,
121
+ ): AsyncIterable<RunEvent> {
122
+ await this.require(runId)
123
+ const signal = options?.signal
124
+ let cursor = options?.fromSeq ?? -1
125
+
126
+ while (!signal?.aborted) {
127
+ const record = await this.require(runId)
128
+ // Drain the persisted backlog after the cursor in seq order. The
129
+ // zero-padded keys make the prefix list naturally ordered.
130
+ if (cursor < record.lastSeq) {
131
+ const events = await this.storage.list<StreamChunk>({
132
+ prefix: evtPrefix(runId),
133
+ start: evtKey(runId, cursor + 1),
134
+ })
135
+ for (const [, chunk] of events) {
136
+ cursor += 1
137
+ yield { seq: cursor, chunk }
138
+ if (signal?.aborted) return
139
+ }
140
+ continue
141
+ }
142
+ if (isTerminalRunStatus(record.status)) return
143
+ await this.waitForChange(runId, signal)
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Resolve when an append/finish wakes this run, the signal aborts, or the
149
+ * fallback poll fires (the poll lets a reader that outlived its in-memory
150
+ * waiter — e.g. after the appending instance was evicted — keep progressing).
151
+ */
152
+ private waitForChange(runId: string, signal?: AbortSignal): Promise<void> {
153
+ return new Promise<void>((resolve) => {
154
+ let set = this.waiters.get(runId)
155
+ if (!set) {
156
+ set = new Set()
157
+ this.waiters.set(runId, set)
158
+ }
159
+ const localSet = set
160
+ const wake = (): void => {
161
+ localSet.delete(wake)
162
+ clearTimeout(timer)
163
+ if (signal) signal.removeEventListener('abort', wake)
164
+ resolve()
165
+ }
166
+ const timer = setTimeout(wake, TAIL_POLL_MS)
167
+ localSet.add(wake)
168
+ if (signal) signal.addEventListener('abort', wake, { once: true })
169
+ })
170
+ }
171
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `runInContainerHarness` — the IN-CONTAINER harness runner, shipped from the
3
+ * package so a co-located app's container program is a single function call.
4
+ *
5
+ * This is the heart of the CO-LOCATED model: the agent harness loop AND its MCP
6
+ * tool-bridge run HERE, on the container's own localhost. The Durable Object
7
+ * outside never calls `chat()`; it POSTs `/run` to this server and reads the
8
+ * NDJSON stream back.
9
+ *
10
+ * DO ── POST /run {messages, harness, model, workspace, toolDescriptors,
11
+ * toolExecUrl, toolExecToken} ──▶ THIS
12
+ * THIS ── NDJSON stream of StreamChunk ──────────────────────────────────▶ DO
13
+ *
14
+ * It is a tiny `node:http` server (NODE/container side — NOT Workers; it uses
15
+ * `localProcessSandbox`). On `POST /run` it validates the {@link
16
+ * ContainerRunRequest}, builds `chat()` with the in-container `local-process`
17
+ * sandbox and the adapter the CALLER resolves, and streams each {@link
18
+ * StreamChunk} back as NDJSON (one JSON object per line).
19
+ *
20
+ * Why the MCP bridge is genuinely in-container: the in-container sandbox is
21
+ * `localProcessSandbox()` — the container IS the host — so the harness adapter
22
+ * serves its tool-bridge over the container's own `localhost` and feeds the
23
+ * prompt over NATIVE writable stdin (no file-redirect; the bridge URL/token
24
+ * never leave the container). The MCP protocol never crosses the network.
25
+ *
26
+ * The ONE thing that still crosses back to the DO is host-tool EXECUTION: each
27
+ * tool rebuilt by {@link remoteToolStubs} delegates its `execute()` to {@link
28
+ * httpRemoteToolExecutor}, which POSTs `{ name, args }` (bearer-gated) to the
29
+ * DO's `toolExecUrl`:
30
+ *
31
+ * agent → in-container MCP bridge → stub.execute → httpRemoteToolExecutor → DO
32
+ *
33
+ * The app supplies only `resolveAdapter` — which `*Text` adapter to build for a
34
+ * given `{ harness, model }`. The server + `chat()` wiring lives here, so the
35
+ * package doesn't depend on every adapter package.
36
+ *
37
+ * NOTE: container-side Node code — compiles against the real TanStack AI types;
38
+ * not runtime-verified in this repo (no container build in CI).
39
+ */
40
+ import { createServer } from 'node:http'
41
+ import { EventType, chat } from '@tanstack/ai'
42
+ import {
43
+ createSecrets,
44
+ defineSandbox,
45
+ defineWorkspace,
46
+ httpRemoteToolExecutor,
47
+ remoteToolStubs,
48
+ withSandbox,
49
+ } from '@tanstack/ai-sandbox'
50
+ import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
51
+ import { parseContainerRunRequest } from './protocol'
52
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
53
+ import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai'
54
+ import type { WorkspaceDefinition } from '@tanstack/ai-sandbox'
55
+ import type { ContainerRunRequest, HarnessId } from './protocol'
56
+
57
+ /** The `{ harness, model }` the caller maps to a concrete `*Text` adapter. */
58
+ export interface ResolveAdapterInput {
59
+ harness: HarnessId
60
+ model: string
61
+ }
62
+
63
+ /** Options for {@link runInContainerHarness}. */
64
+ export interface RunInContainerHarnessOptions {
65
+ /**
66
+ * Build the text adapter `chat()` runs for one request's `{ harness, model }`.
67
+ * The app supplies this so the package doesn't depend on every adapter package
68
+ * — e.g. `({ model }) => claudeCodeText(model)`.
69
+ */
70
+ resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter
71
+ /** Port to listen on. Defaults to `RUNNER_PORT` env, then `8080`. */
72
+ port?: number
73
+ }
74
+
75
+ /** What {@link runInContainerHarness} returns: the listening `node:http` server. */
76
+ export interface ContainerHarnessServer {
77
+ /** The underlying `node:http` server (already `listen()`ing). */
78
+ server: Server
79
+ /** The port it is listening on. */
80
+ port: number
81
+ }
82
+
83
+ /** Read a request body fully into a string (small JSON payloads only). */
84
+ function readBody(req: IncomingMessage): Promise<string> {
85
+ return new Promise((resolve, reject) => {
86
+ let body = ''
87
+ req.setEncoding('utf8')
88
+ req.on('data', (chunk: string) => {
89
+ body += chunk
90
+ })
91
+ req.on('end', () => resolve(body))
92
+ req.on('error', reject)
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Rebuild the request's workspace with a real `createSecrets`, pulling each
98
+ * referenced secret's VALUE from the container env. Secret values never cross
99
+ * the `POST /run` boundary (`createSecrets` stores them under a non-enumerable
100
+ * symbol, so serializing the workspace carries only the names) — the DO injects
101
+ * them into the container env via `sandbox.setEnvVars`, and we reconstitute them
102
+ * here. A referenced secret missing from the env is a hard error, never a silent
103
+ * keyless run.
104
+ */
105
+ function reconstituteWorkspace(
106
+ workspace: WorkspaceDefinition,
107
+ ): WorkspaceDefinition {
108
+ if (workspace.secrets === undefined) return workspace
109
+ const names = Object.keys(workspace.secrets)
110
+ if (names.length === 0) return workspace
111
+ const values: Record<string, string> = {}
112
+ for (const name of names) {
113
+ const value = process.env[name]
114
+ if (value === undefined || value === '') {
115
+ throw new Error(
116
+ `runInContainerHarness: secret "${name}" is not set in the container env`,
117
+ )
118
+ }
119
+ values[name] = value
120
+ }
121
+ return defineWorkspace({ ...workspace, secrets: createSecrets(values) })
122
+ }
123
+
124
+ /**
125
+ * Build the `chat()` stream that runs the harness on THIS container via the
126
+ * `local-process` sandbox. The agent's `chat()` tools are stubs that delegate
127
+ * back to the DO; everything else (the harness loop, the MCP bridge, stdin)
128
+ * stays on localhost.
129
+ */
130
+ function runAgent(
131
+ request: ContainerRunRequest,
132
+ resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter,
133
+ ): AsyncIterable<StreamChunk> {
134
+ const sandbox = defineSandbox({
135
+ // The container IS the host: no isolation, just run on its own filesystem.
136
+ id: 'colocated-in-container',
137
+ provider: localProcessSandbox(),
138
+ // Honor the app's workspace (source / setup / skills / …), with the secrets
139
+ // re-resolved from the container env.
140
+ workspace: reconstituteWorkspace(request.workspace),
141
+ })
142
+
143
+ // `stream: true` (no outputSchema) makes chat() return AsyncIterable<StreamChunk>.
144
+ return chat({
145
+ threadId: request.threadId,
146
+ adapter: resolveAdapter({
147
+ harness: request.harness,
148
+ model: request.model,
149
+ }),
150
+ messages: request.messages,
151
+ stream: true,
152
+ // Rebuild the DO's host tools as stubs whose execute() POSTs back to the DO.
153
+ // The adapter bridges them over the in-container localhost MCP transport.
154
+ tools: remoteToolStubs(
155
+ request.toolDescriptors,
156
+ httpRemoteToolExecutor(request.toolExecUrl, request.toolExecToken),
157
+ ),
158
+ // Provide the in-container local-process sandbox handle the adapter needs.
159
+ middleware: [withSandbox(sandbox)],
160
+ })
161
+ }
162
+
163
+ /** Stream the agent's chunks to the response as NDJSON, one object per line. */
164
+ async function handleRun(
165
+ req: IncomingMessage,
166
+ res: ServerResponse,
167
+ resolveAdapter: (input: ResolveAdapterInput) => AnyTextAdapter,
168
+ ): Promise<void> {
169
+ const parsed: unknown = JSON.parse(await readBody(req))
170
+ const request = parseContainerRunRequest(parsed)
171
+ res.writeHead(200, {
172
+ 'content-type': 'application/x-ndjson',
173
+ 'cache-control': 'no-cache',
174
+ })
175
+ // The DO appends each line to its durable run-log; here we are the producer,
176
+ // so we surface a mid-stream failure as a terminal RUN_ERROR line the DO will
177
+ // append + finish on, never a silently truncated stream.
178
+ try {
179
+ for await (const chunk of runAgent(request, resolveAdapter)) {
180
+ res.write(`${JSON.stringify(chunk)}\n`)
181
+ }
182
+ } catch (error) {
183
+ const message = error instanceof Error ? error.message : String(error)
184
+ res.write(`${JSON.stringify({ type: EventType.RUN_ERROR, message })}\n`)
185
+ } finally {
186
+ res.end()
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Start the in-container harness runner: a `node:http` server with `GET /health`
192
+ * and `POST /run`. Call this as the container's program; the app supplies only
193
+ * `resolveAdapter`.
194
+ */
195
+ export function runInContainerHarness(
196
+ options: RunInContainerHarnessOptions,
197
+ ): ContainerHarnessServer {
198
+ const port =
199
+ options.port ?? Number.parseInt(process.env.RUNNER_PORT ?? '8080', 10)
200
+
201
+ const server = createServer((req, res) => {
202
+ if (req.method === 'POST' && req.url === '/run') {
203
+ handleRun(req, res, options.resolveAdapter).catch((error: unknown) => {
204
+ // A failure BEFORE we start streaming (e.g. a malformed body) is a 400 —
205
+ // surfaced, never swallowed.
206
+ const message = error instanceof Error ? error.message : String(error)
207
+ if (!res.headersSent) {
208
+ res.writeHead(400, { 'content-type': 'text/plain' })
209
+ }
210
+ res.end(message)
211
+ })
212
+ return
213
+ }
214
+ if (req.method === 'GET' && req.url === '/health') {
215
+ res.writeHead(200).end('ok')
216
+ return
217
+ }
218
+ res.writeHead(404).end('not found')
219
+ })
220
+
221
+ server.listen(port, () => {
222
+ console.log(`[container-runner] listening on :${port}`)
223
+ })
224
+
225
+ return { server, port }
226
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Web Crypto helpers for the Workers runtime, where `node:crypto` is
3
+ * unavailable. The sandbox layer's `timingSafeBearerEqual` is node-based; this
4
+ * is the equivalent for a Worker / Durable Object.
5
+ */
6
+
7
+ /**
8
+ * Constant-time check of an `Authorization: Bearer <token>` header against the
9
+ * expected token. A length mismatch returns false early (token length is not
10
+ * secret); the equal-length comparison is timing-safe.
11
+ */
12
+ export function timingSafeBearerEqualWeb(
13
+ header: string | undefined,
14
+ token: string,
15
+ ): boolean {
16
+ if (header === undefined) return false
17
+ const a = new TextEncoder().encode(header)
18
+ const b = new TextEncoder().encode(`Bearer ${token}`)
19
+ if (a.length !== b.length) return false
20
+ let diff = 0
21
+ for (let i = 0; i < a.length; i += 1) {
22
+ const ai = a[i]
23
+ const bi = b[i]
24
+ // In-bounds by construction (i < a.length === b.length); the guard satisfies
25
+ // `noUncheckedIndexedAccess` without a non-null assertion and treats any
26
+ // impossible out-of-bounds read as "not equal".
27
+ if (ai === undefined || bi === undefined) return false
28
+ diff |= ai ^ bi
29
+ }
30
+ return diff === 0
31
+ }