@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,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
|
+
}
|