pi-roundtable-sandbox 0.7.10 → 0.7.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.11
4
+
5
+ - `precheckScriptRunner` runs agents' precheck scripts (pi-roundtable 0.7.11) in a sealed container per run: no network, a read-only root, no capabilities, a non-root user, an empty workspace, and a per-run broker (`precheckBroker`) that forwards only single `tools/call` requests of the tools `grant(scope)` allows, with the host's credential (`PrecheckMcpServer`). The container runs `worker/precheck-main.ts` (`PRECHECK_ENTRYPOINT`, `PrecheckWorkerInput`), which gives the script `mcp.call`, `mcp.json`, `firedAt`, `timeZone`, `today`, and `schedule`.
6
+ - A script's workspace is mounted read-only (`ContainerSpec.workspaceReadOnly`), so it writes only to the container's bounded `/tmp`. The broker refuses an answer that carries the credential plainly, escaped, inside JSON text, base64-encoded, or as a key; joins an SSE event's `data:` lines; and refuses a second call while one is pending (409) without spending the budget. The script's console output goes to stderr and never into its answer, and `grant` receives the creator's `tier`.
7
+ - `ContainerSpec` takes `entrypoint`, and `DockerContainerDriver.exec` runs a sealed container with any input and a bounded output (`PrecheckContainerDriver`); `worker/Dockerfile` includes the precheck worker.
8
+
3
9
  ## 0.7.10
4
10
 
5
11
  - Compact Pi worker sessions with the core's tiers (300,000 tokens through a host compactor, Pi's summary past 500,000), through the new `compaction` option (`PiCompactor`: `engine`, `compact(request, { channel, signal })`, `timeoutMs`, `maxRequestBytes`) and the protocol types `PiCompactRequest`, `PiCompaction`, `PiCompactResponse`, `PiCompactionReport`, `PiCompactMessage` and `PiWorkerConfig`; the host logs every compaction and fallback per channel.
package/README.md CHANGED
@@ -364,6 +364,53 @@ One of `search` with a fetch option, or `tools`, is required.
364
364
  Host search/model credentials stay in the trusted host process, and report delivery must remain in the declared guest channel.
365
365
  These hooks broaden the sealed threat model: review every adapter, apply provider spend limits and host quotas, and never substitute an unrestricted default delegation worker.
366
366
 
367
+ ## Precheck scripts
368
+
369
+ `precheckScriptRunner` lets agents write a schedule's precheck themselves (pi-roundtable 0.7.11 or later): a short JavaScript module that decides, before the scheduled turn, whether the agent is woken at all.
370
+ The core stores and parses the script but never runs it; this runner runs it in a sealed container, one per run.
371
+ Register it from a trusted plugin of your own:
372
+
373
+ ```ts
374
+ import { PRECHECKS, definePlugin } from "pi-roundtable";
375
+ import { precheckScriptRunner } from "pi-roundtable-sandbox";
376
+
377
+ export const precheckScripts = definePlugin({
378
+ name: "precheck-scripts",
379
+ setup: ({ services }) => {
380
+ services.get(PRECHECKS).useScriptRunner(precheckScriptRunner({
381
+ image: "pi-roundtable-sandbox:pi",
382
+ runRoot: "/tmp/roundtable-precheck",
383
+ // The host's non-root user, who owns runRoot; root is refused.
384
+ uid: 1000,
385
+ gid: 1000,
386
+ // Never more than the schedule's agent may call itself; [] leaves the script no way out.
387
+ grant: ({ channel, target }) => grantsFor(channel, target),
388
+ }));
389
+ return {};
390
+ },
391
+ });
392
+ ```
393
+
394
+ `grant({ channel, target })` returns the `PrecheckMcpServer`s a script for that schedule may call: `{ name, url, tools, token? }`.
395
+ The script calls a server by `name`, only the listed `tools`, and only with single `tools/call` requests; every other server, tool, method, query, and extra credential is refused, and refused calls count against the run's budget (`maxCalls`, default 16).
396
+ `url` is a fixed HTTPS Streamable HTTP endpoint answering with JSON or one SSE event; `token` is read on the host for each call and inserted by the broker. An answer that carries it, plainly, inside JSON text, or base64-encoded, is refused; this catches an echo, not an upstream set on leaking it, so grant only upstreams you trust.
397
+ Calls run one at a time; a second call while one is pending is refused.
398
+ Neither the endpoint nor the credential reaches the container.
399
+
400
+ Each run gets:
401
+
402
+ - The same sealed `docker run` as a guest turn: `--network none`, a read-only root, all capabilities dropped, `no-new-privileges`, the host's non-root user, 256 MiB, one CPU, and 32 processes by default (`limits`).
403
+ - A fresh broker socket and an empty workspace under `runRoot`, both removed afterwards; the workspace is mounted read-only, so a script writes only to the container's 64 MiB `/tmp`.
404
+ - When the host stops, the scheduler aborts running scripts and waits for their containers to be removed.
405
+ - The worker `worker/precheck-main.ts` as its entrypoint (`PRECHECK_ENTRYPOINT`, the Pi image's layout; pass `entrypoint` for another image, such as `["bun", "/app/worker/precheck-main.ts"]` for `worker/Dockerfile`'s).
406
+ - At most `timeoutMs` (default 60 seconds); on timeout the container is killed and the turn wakes with the error.
407
+
408
+ The script is `export default async ({ mcp, firedAt, timeZone, today, schedule }) => result`, where `result` is `{ wake: false, note? }` or `{ wake: true, context }`.
409
+ `today` is the date in the host's time zone, so a script never reads a UTC date by mistake.
410
+ `mcp.call(server, tool, args)` returns the MCP tool result and `mcp.json(server, tool, args)` its structured content or its first text content parsed as JSON; a tool error throws.
411
+ A throw, a wrong answer, or a timeout wakes the turn with the error.
412
+ The runner's `describe` tells the model this contract and what it may call, through `schedule_list`.
413
+
367
414
  ## Development and verification
368
415
 
369
416
  From the pi-roundtable repository root:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-sandbox",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "@biomejs/biome": "2.5.15",
53
53
  "@types/bun": "1.4.2",
54
54
  "discord.js": "14.27.0",
55
- "pi-roundtable": "0.7.10",
55
+ "pi-roundtable": "0.7.11",
56
56
  "typebox": "1.3.34",
57
57
  "typescript": "7.0.2"
58
58
  }
@@ -17,6 +17,10 @@ export interface ContainerSpec {
17
17
  memoryMb?: number;
18
18
  cpus?: number;
19
19
  pids?: number;
20
+ /** Runs this command instead of the image's own, such as a precheck script's runner. */
21
+ entrypoint?: readonly string[];
22
+ /** Mounts the workspace read-only, for a run that may write nothing to the host. */
23
+ workspaceReadOnly?: boolean;
20
24
  }
21
25
  export interface ContainerDriver {
22
26
  run(
@@ -64,6 +68,15 @@ export function containerRunArgs(spec: ContainerSpec): string[] {
64
68
  pids < 8
65
69
  )
66
70
  throw new Error("invalid resource limits");
71
+ const [command, ...args] = spec.entrypoint ?? [];
72
+ if (
73
+ spec.entrypoint &&
74
+ (!command ||
75
+ spec.entrypoint.some(
76
+ (part) => !part || part.startsWith("-") || /[\0\r\n]/.test(part),
77
+ ))
78
+ )
79
+ throw new Error("invalid entrypoint");
67
80
  return [
68
81
  "run",
69
82
  "--rm",
@@ -99,10 +112,12 @@ export function containerRunArgs(spec: ContainerSpec): string[] {
99
112
  "--mount",
100
113
  `type=bind,src=${spec.runDir},dst=/broker,readonly,bind-propagation=rprivate`,
101
114
  "--mount",
102
- `type=bind,src=${spec.workspaceDir},dst=/workspace,bind-propagation=rprivate`,
115
+ `type=bind,src=${spec.workspaceDir},dst=/workspace,${spec.workspaceReadOnly ? "readonly," : ""}bind-propagation=rprivate`,
103
116
  "--env",
104
117
  "HOME=/tmp/home",
118
+ ...(command ? ["--entrypoint", command] : []),
105
119
  spec.image,
120
+ ...args,
106
121
  ];
107
122
  }
108
123
 
@@ -117,6 +132,30 @@ export class DockerContainerDriver implements ContainerDriver {
117
132
  turn: SandboxTurn,
118
133
  signal: AbortSignal,
119
134
  ): Promise<SandboxReply> {
135
+ const output = await this.exec(spec, JSON.stringify(turn), signal, {
136
+ stdoutBytes: 2 * 1024 * 1024,
137
+ });
138
+ const reply: unknown = JSON.parse(output);
139
+ if (
140
+ !isRecord(reply) ||
141
+ typeof reply.ok !== "boolean" ||
142
+ typeof reply.text !== "string" ||
143
+ reply.text.length > 100_000
144
+ )
145
+ throw new Error("invalid sandbox reply");
146
+ return { ok: reply.ok, text: reply.text };
147
+ }
148
+
149
+ /**
150
+ * Runs one sealed container with `input` on its stdin and returns its stdout, bounded; a
151
+ * nonzero exit or an abort throws, and the container is force-removed either way.
152
+ */
153
+ async exec(
154
+ spec: ContainerSpec,
155
+ input: string,
156
+ signal: AbortSignal,
157
+ limits: { stdoutBytes: number },
158
+ ): Promise<string> {
120
159
  // Canonical paths prevent symlinks in operator configuration selecting a different mount.
121
160
  const args = containerRunArgs({
122
161
  ...spec,
@@ -133,24 +172,16 @@ export class DockerContainerDriver implements ContainerDriver {
133
172
  const abort = () => child.kill("SIGKILL");
134
173
  signal.addEventListener("abort", abort, { once: true });
135
174
  try {
136
- child.stdin.write(JSON.stringify(turn));
175
+ child.stdin.write(input);
137
176
  child.stdin.end();
138
177
  const [output, , code] = await Promise.all([
139
- boundedText(child.stdout, 2 * 1024 * 1024),
178
+ boundedText(child.stdout, limits.stdoutBytes),
140
179
  boundedText(child.stderr, 128 * 1024),
141
180
  child.exited,
142
181
  ]);
143
182
  signal.throwIfAborted();
144
183
  if (code !== 0) throw new Error("sandbox worker failed");
145
- const reply: unknown = JSON.parse(output);
146
- if (
147
- !isRecord(reply) ||
148
- typeof reply.ok !== "boolean" ||
149
- typeof reply.text !== "string" ||
150
- reply.text.length > 100_000
151
- )
152
- throw new Error("invalid sandbox reply");
153
- return { ok: reply.ok, text: reply.text };
184
+ return output;
154
185
  } finally {
155
186
  signal.removeEventListener("abort", abort);
156
187
  child.kill("SIGKILL");
package/src/index.ts CHANGED
@@ -97,6 +97,15 @@ export {
97
97
  type SandboxService,
98
98
  sandbox,
99
99
  } from "./plugin.ts";
100
+ export {
101
+ PRECHECK_ENTRYPOINT,
102
+ type PrecheckContainerDriver,
103
+ type PrecheckMcpServer,
104
+ type PrecheckScriptRunnerOptions,
105
+ type PrecheckWorkerInput,
106
+ precheckBroker,
107
+ precheckScriptRunner,
108
+ } from "./precheck-runner.ts";
100
109
  export type { SandboxReply, SandboxTurn, ToolSpec } from "./protocol.ts";
101
110
  export {
102
111
  type ResearchTools,
@@ -0,0 +1,401 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type {
5
+ PrecheckResult,
6
+ PrecheckScope,
7
+ PrecheckScriptContext,
8
+ PrecheckScriptRunner,
9
+ } from "pi-roundtable";
10
+ import { listenBroker } from "./broker.ts";
11
+ import {
12
+ type ContainerSpec,
13
+ DockerContainerDriver,
14
+ } from "./container-driver.ts";
15
+ import { boundedText, DUMMY_KEY, isRecord } from "./protocol.ts";
16
+
17
+ /** An MCP server a precheck script may call, with only the listed tools. */
18
+ export interface PrecheckMcpServer {
19
+ /** What the script calls it by, `mcp.call(name, tool, args)`: letters, digits, `_`, `-`. */
20
+ name: string;
21
+ /** A fixed Streamable HTTP endpoint answering a single `tools/call` with JSON or one SSE event. */
22
+ url: string;
23
+ /** The tools the script may call; every other method and tool is refused. */
24
+ tools: readonly string[];
25
+ /** The bearer credential, read on each call on the host; never sent to the container. */
26
+ token?: () => string | undefined | Promise<string | undefined>;
27
+ }
28
+
29
+ /** Runs one sealed container: its stdin is `input`, and its bounded stdout comes back. */
30
+ export interface PrecheckContainerDriver {
31
+ exec(
32
+ spec: ContainerSpec,
33
+ input: string,
34
+ signal: AbortSignal,
35
+ limits: { stdoutBytes: number },
36
+ ): Promise<string>;
37
+ }
38
+
39
+ export interface PrecheckScriptRunnerOptions {
40
+ /** An image with Bun and this package installed, such as the Pi sandbox image. */
41
+ image: string;
42
+ /** A dedicated, short host directory for each run's broker socket and empty workspace. */
43
+ runRoot: string;
44
+ /** The non-root host user the container runs as; it owns `runRoot`. */
45
+ uid: number;
46
+ gid: number;
47
+ /**
48
+ * The MCP servers and tools a script for this scope may call: never more than the schedule's
49
+ * agent can use itself. An empty list leaves the script no way out of its container.
50
+ */
51
+ grant(
52
+ scope: PrecheckScope,
53
+ ): readonly PrecheckMcpServer[] | Promise<readonly PrecheckMcpServer[]>;
54
+ /** The container's command; default the package's worker in the Pi image's layout. */
55
+ entrypoint?: readonly string[];
56
+ /** How long a script may run; default 60 seconds. */
57
+ timeoutMs?: number;
58
+ /** MCP calls one run may make, refused ones included; default 16. */
59
+ maxCalls?: number;
60
+ /** Allow cleartext MCP endpoints, for a server on the same host only. */
61
+ allowHttpMcp?: boolean;
62
+ /** The container's limits; default 256 MiB, one CPU, 32 processes. */
63
+ limits?: { memoryMb?: number; cpus?: number; pids?: number };
64
+ /** Replaceable in tests. */
65
+ driver?: PrecheckContainerDriver;
66
+ fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
67
+ }
68
+
69
+ /** Where the Pi sandbox image keeps this package's worker. */
70
+ export const PRECHECK_ENTRYPOINT = Object.freeze([
71
+ "bun",
72
+ "/app/node_modules/pi-roundtable-sandbox/worker/precheck-main.ts",
73
+ ]);
74
+
75
+ /** What the worker reads on its stdin. */
76
+ export interface PrecheckWorkerInput {
77
+ script: string;
78
+ firedAt: string;
79
+ timeZone: string;
80
+ today: string;
81
+ schedule: { id: number; title: string };
82
+ servers: { name: string; tools: string[] }[];
83
+ }
84
+
85
+ const SERVER_NAME = /^[a-zA-Z0-9_-]{1,100}$/;
86
+ const RESPONSE_BYTES = 1024 * 1024;
87
+
88
+ function checkServers(
89
+ servers: readonly PrecheckMcpServer[],
90
+ allowHttp: boolean,
91
+ ): void {
92
+ const names = new Set<string>();
93
+ for (const server of servers) {
94
+ if (!SERVER_NAME.test(server.name) || names.has(server.name))
95
+ throw new Error("invalid or repeated precheck MCP server name");
96
+ names.add(server.name);
97
+ let url: URL;
98
+ try {
99
+ url = new URL(server.url);
100
+ } catch {
101
+ throw new Error("invalid precheck MCP endpoint");
102
+ }
103
+ if (
104
+ !(url.protocol === "https:" || (allowHttp && url.protocol === "http:")) ||
105
+ url.username ||
106
+ url.password ||
107
+ url.hash
108
+ )
109
+ throw new Error("precheck MCP endpoints must be credential-free HTTPS");
110
+ if (
111
+ !Array.isArray(server.tools) ||
112
+ server.tools.some(
113
+ (tool) => typeof tool !== "string" || !/^[\w.-]{1,128}$/.test(tool),
114
+ )
115
+ )
116
+ throw new Error("invalid precheck MCP tool list");
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Whether a parsed answer carries the credential: in any key or string, also inside a string
122
+ * holding JSON (as MCP text content does), plainly or base64-encoded. This catches an upstream
123
+ * that echoes the credential; the grant must still name only upstreams the host trusts.
124
+ */
125
+ function carries(value: unknown, secret: string, depth = 0): boolean {
126
+ if (depth > 64) return true;
127
+ const forms = [
128
+ secret,
129
+ Buffer.from(secret).toString("base64"),
130
+ Buffer.from(secret).toString("base64url"),
131
+ ];
132
+ if (typeof value === "string") {
133
+ if (forms.some((form) => value.includes(form))) return true;
134
+ const trimmed = value.trim();
135
+ if (!/^[[{"]/.test(trimmed)) return false;
136
+ try {
137
+ return carries(JSON.parse(trimmed), secret, depth + 1);
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+ if (Array.isArray(value))
143
+ return value.some((item) => carries(item, secret, depth + 1));
144
+ if (isRecord(value))
145
+ return Object.entries(value).some(
146
+ ([key, item]) =>
147
+ forms.some((form) => key.includes(form)) ||
148
+ carries(item, secret, depth + 1),
149
+ );
150
+ return false;
151
+ }
152
+
153
+ /** The JSON-RPC message of an answer: the body, or an SSE answer's last `data:` event that parses. */
154
+ function jsonRpcMessage(
155
+ text: string,
156
+ eventStream: boolean,
157
+ ): Record<string, unknown> | undefined {
158
+ const parsed = (raw: string): Record<string, unknown> | undefined => {
159
+ try {
160
+ const value: unknown = JSON.parse(raw);
161
+ return isRecord(value) ? value : undefined;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ };
166
+ if (!eventStream) return parsed(text);
167
+ // An event's `data:` lines join with newlines; a blank line ends the event.
168
+ let found: Record<string, unknown> | undefined;
169
+ let data: string[] = [];
170
+ for (const line of [...text.split(/\r?\n/), ""]) {
171
+ if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
172
+ else if (line === "" && data.length) {
173
+ found = parsed(data.join("\n")) ?? found;
174
+ data = [];
175
+ }
176
+ }
177
+ return found;
178
+ }
179
+
180
+ /**
181
+ * The broker of one precheck run: only `POST /mcp/<server>` with a single `tools/call` of a
182
+ * granted tool, within the call budget. The host inserts the credential and checks nothing of
183
+ * it comes back.
184
+ */
185
+ export function precheckBroker(options: {
186
+ servers: readonly PrecheckMcpServer[];
187
+ signal: AbortSignal;
188
+ maxCalls: number;
189
+ fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
190
+ }): (request: Request) => Promise<Response> {
191
+ let remaining = options.maxCalls;
192
+ let active = false;
193
+ return async (request) => {
194
+ let url: URL;
195
+ try {
196
+ url = new URL(request.url);
197
+ } catch {
198
+ return new Response("invalid URL", { status: 400 });
199
+ }
200
+ const server = url.pathname.startsWith("/mcp/")
201
+ ? options.servers.find((s) => s.name === url.pathname.slice(5))
202
+ : undefined;
203
+ const auth = request.headers.get("authorization");
204
+ if (
205
+ request.method !== "POST" ||
206
+ url.search ||
207
+ !server ||
208
+ (auth !== null && auth !== `Bearer ${DUMMY_KEY}`)
209
+ )
210
+ return new Response("not found", { status: 404 });
211
+ if (options.signal.aborted)
212
+ return new Response("run ended", { status: 410 });
213
+ if (active) return new Response("one call at a time", { status: 409 });
214
+ if (remaining <= 0)
215
+ return new Response("call budget exhausted", { status: 429 });
216
+ active = true;
217
+ remaining--;
218
+ let secret = "";
219
+ try {
220
+ const body: unknown = JSON.parse(
221
+ await boundedText(request.body, 64 * 1024),
222
+ );
223
+ const params = isRecord(body) ? body.params : undefined;
224
+ if (
225
+ !isRecord(body) ||
226
+ body.method !== "tools/call" ||
227
+ !isRecord(params) ||
228
+ typeof params.name !== "string" ||
229
+ !server.tools.includes(params.name) ||
230
+ !(params.arguments === undefined || isRecord(params.arguments))
231
+ )
232
+ return new Response("MCP method or tool refused", { status: 403 });
233
+ secret = (await server.token?.()) ?? "";
234
+ const upstream = await (options.fetchImpl ?? fetch)(server.url, {
235
+ method: "POST",
236
+ redirect: "error",
237
+ headers: {
238
+ "content-type": "application/json",
239
+ accept: "application/json, text/event-stream",
240
+ ...(secret ? { authorization: `Bearer ${secret}` } : {}),
241
+ },
242
+ body: JSON.stringify({
243
+ jsonrpc: "2.0",
244
+ id: 1,
245
+ method: "tools/call",
246
+ params: { name: params.name, arguments: params.arguments ?? {} },
247
+ }),
248
+ signal: AbortSignal.any([options.signal, request.signal]),
249
+ });
250
+ const text = await boundedText(upstream.body, RESPONSE_BYTES);
251
+ if (!upstream.ok)
252
+ return new Response("upstream refused", { status: 502 });
253
+ if (secret && text.includes(secret))
254
+ return new Response("credential reflected", { status: 502 });
255
+ const message = jsonRpcMessage(
256
+ text,
257
+ (upstream.headers.get("content-type") ?? "").includes(
258
+ "text/event-stream",
259
+ ),
260
+ );
261
+ if (!message)
262
+ return new Response("upstream returned no JSON-RPC message", {
263
+ status: 502,
264
+ });
265
+ if (secret && carries(message, secret))
266
+ return new Response("credential reflected", { status: 502 });
267
+ // Only the JSON-RPC message leaves: no upstream headers, cookies, or redirects.
268
+ return Response.json(message);
269
+ } catch {
270
+ // Never expose upstream URLs, headers, credentials, or exception text.
271
+ return new Response("broker call failed", { status: 502 });
272
+ } finally {
273
+ active = false;
274
+ }
275
+ };
276
+ }
277
+
278
+ /** What a script for a scope is told: the contract, and the servers and tools it may call. */
279
+ function guide(servers: readonly PrecheckMcpServer[]): string {
280
+ const reach =
281
+ servers.length === 0
282
+ ? "Scripts for this schedule can call no MCP server, so they can only decide from the date and time."
283
+ : `Scripts for this schedule may call only these MCP servers and tools:\n${servers.map((s) => `- ${s.name}: ${s.tools.join(", ")}`).join("\n")}`;
284
+ return [
285
+ "Write `export default async ({ mcp, firedAt, timeZone, today, schedule }) => result`.",
286
+ "`result` is `{ wake: false, note? }` to skip the turn (the note, if any, is posted in small text) or `{ wake: true, context }` to wake you with `context`. Anything else, a throw, or running too long wakes you with the error.",
287
+ "`today` is the date in the host's time zone (YYYY-MM-DD); pass it to tools instead of computing dates in UTC. `firedAt` is a Date and `timeZone` an IANA zone.",
288
+ "Await each call before making the next; calls do not run in parallel. console output is discarded; only the returned result counts.",
289
+ "`await mcp.call(server, tool, args)` returns the MCP tool result; `await mcp.json(server, tool, args)` returns its structured content, or its first text content parsed as JSON. A tool error throws.",
290
+ "The script runs in a container with no network, no files of the host, and no credentials; it reaches only the tools below.",
291
+ reach,
292
+ ].join("\n");
293
+ }
294
+
295
+ /**
296
+ * Runs agents' precheck scripts in a sealed container, one per run: no network, a read-only
297
+ * root, all capabilities dropped, a non-root user, and an empty workspace. Its only way out is a
298
+ * per-run broker that forwards `tools/call` of the tools `grant` allows. Register it with
299
+ * `services.get(PRECHECKS).useScriptRunner(precheckScriptRunner({ ... }))`.
300
+ */
301
+ export function precheckScriptRunner(
302
+ options: PrecheckScriptRunnerOptions,
303
+ ): PrecheckScriptRunner {
304
+ const driver = options.driver ?? new DockerContainerDriver();
305
+ const maxCalls = options.maxCalls ?? 16;
306
+ if (!Number.isSafeInteger(maxCalls) || maxCalls < 1 || maxCalls > 100)
307
+ throw new Error("invalid precheck MCP call budget");
308
+ const grant = async (scope: PrecheckScope) => {
309
+ const servers = await options.grant(scope);
310
+ checkServers(servers, options.allowHttpMcp ?? false);
311
+ return servers;
312
+ };
313
+ return {
314
+ ...(options.timeoutMs === undefined
315
+ ? {}
316
+ : { timeoutMs: options.timeoutMs }),
317
+ describe: async (scope) => guide(await grant(scope)),
318
+ run: async (script, context) => runOne(script, context),
319
+ };
320
+
321
+ async function runOne(
322
+ script: string,
323
+ context: PrecheckScriptContext,
324
+ ): Promise<PrecheckResult> {
325
+ const { schedule, signal } = context;
326
+ const servers = await grant({
327
+ channel: schedule.channel,
328
+ target: schedule.target,
329
+ tier: schedule.createdTier,
330
+ });
331
+ const dirs: string[] = [];
332
+ let listener: Awaited<ReturnType<typeof listenBroker>> | undefined;
333
+ try {
334
+ const runDir = mkdtempSync(join(options.runRoot, "precheck-"));
335
+ dirs.push(runDir);
336
+ // Mounted read-only and left empty: a script can write only its container's bounded /tmp.
337
+ const workspaceDir = mkdtempSync(join(options.runRoot, "precheck-ws-"));
338
+ dirs.push(workspaceDir);
339
+ const socket = join(runDir, "broker.sock");
340
+ if (socket.length > 100) throw new Error("broker socket path too long");
341
+ listener = await listenBroker(
342
+ socket,
343
+ precheckBroker({
344
+ servers,
345
+ signal,
346
+ maxCalls,
347
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
348
+ }),
349
+ );
350
+ const input: PrecheckWorkerInput = {
351
+ script,
352
+ firedAt: context.firedAt.toISOString(),
353
+ timeZone: context.timeZone,
354
+ today: context.today,
355
+ schedule: { id: schedule.id, title: schedule.title },
356
+ servers: servers.map(({ name, tools }) => ({
357
+ name,
358
+ tools: [...tools],
359
+ })),
360
+ };
361
+ const output = await driver.exec(
362
+ {
363
+ name: `roundtable-precheck-${schedule.id}-${randomUUID().slice(0, 8)}`,
364
+ image: options.image,
365
+ runDir,
366
+ workspaceDir,
367
+ uid: options.uid,
368
+ gid: options.gid,
369
+ memoryMb: options.limits?.memoryMb ?? 256,
370
+ cpus: options.limits?.cpus ?? 1,
371
+ pids: options.limits?.pids ?? 32,
372
+ entrypoint: options.entrypoint ?? PRECHECK_ENTRYPOINT,
373
+ workspaceReadOnly: true,
374
+ },
375
+ JSON.stringify(input),
376
+ signal,
377
+ { stdoutBytes: 64 * 1024 },
378
+ );
379
+ const answer: unknown = JSON.parse(output);
380
+ if (!isRecord(answer) || typeof answer.ok !== "boolean")
381
+ throw new Error("the precheck worker answered nothing it could read");
382
+ if (!answer.ok)
383
+ throw new Error(
384
+ typeof answer.error === "string"
385
+ ? answer.error.slice(0, 1000)
386
+ : "the script failed",
387
+ );
388
+ // The core checks the result's shape; a wrong one wakes the turn with what it was.
389
+ return answer.result as PrecheckResult;
390
+ } finally {
391
+ await listener?.stop(true);
392
+ // A cleanup failure is logged by nobody but must not replace the script's answer.
393
+ for (const dir of dirs)
394
+ try {
395
+ rmSync(dir, { recursive: true, force: true });
396
+ } catch {
397
+ // Left for the operator; runRoot is dedicated to these runs.
398
+ }
399
+ }
400
+ }
401
+ }
package/worker/Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
1
  FROM oven/bun:1.4.2-alpine
2
2
  WORKDIR /app
3
3
  COPY src/protocol.ts /app/src/protocol.ts
4
- COPY worker/main.ts worker/agent.ts worker/memory.ts worker/transport.ts /app/worker/
4
+ COPY worker/main.ts worker/agent.ts worker/memory.ts worker/transport.ts worker/precheck-main.ts /app/worker/
5
5
  USER 1000:1000
6
6
  ENTRYPOINT ["bun", "/app/worker/main.ts"]
@@ -0,0 +1,147 @@
1
+ // Runs one schedule's precheck script inside its sealed container and prints its answer.
2
+ // The script is untrusted: the container, not this file, is the boundary.
3
+ import { randomUUID } from "node:crypto";
4
+ import { rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { BROKER_PATH, boundedText, isRecord } from "../src/protocol.ts";
8
+ import { unixBrokerRequest } from "./transport.ts";
9
+
10
+ interface Input {
11
+ script: string;
12
+ firedAt: string;
13
+ timeZone: string;
14
+ today: string;
15
+ schedule: { id: number; title: string };
16
+ servers: { name: string; tools: string[] }[];
17
+ }
18
+
19
+ function isInput(value: unknown): value is Input {
20
+ return (
21
+ isRecord(value) &&
22
+ typeof value.script === "string" &&
23
+ typeof value.firedAt === "string" &&
24
+ typeof value.timeZone === "string" &&
25
+ typeof value.today === "string" &&
26
+ isRecord(value.schedule) &&
27
+ Array.isArray(value.servers)
28
+ );
29
+ }
30
+
31
+ const REFUSALS: Record<number, string> = {
32
+ 403: "that server or tool is not granted to this script",
33
+ 404: "that server is not granted to this script",
34
+ 410: "the run ended",
35
+ 409: "the script made two MCP calls at once; await each before the next",
36
+ 429: "the script made too many MCP calls",
37
+ };
38
+
39
+ /** The MCP calls a script makes, each through the host's broker. */
40
+ function mcpClient(servers: Input["servers"]) {
41
+ const call = async (
42
+ server: string,
43
+ tool: string,
44
+ args: Record<string, unknown> = {},
45
+ ): Promise<Record<string, unknown>> => {
46
+ const granted = servers.find((s) => s.name === server);
47
+ if (!granted?.tools.includes(tool))
48
+ throw new Error(`${server}/${tool} is not granted to this script`);
49
+ const { status, body } = await unixBrokerRequest(
50
+ `/mcp/${server}`,
51
+ {
52
+ jsonrpc: "2.0",
53
+ id: 1,
54
+ method: "tools/call",
55
+ params: { name: tool, arguments: args },
56
+ },
57
+ // Tests run the worker outside a container; there the host sets only HOME.
58
+ process.env.PRECHECK_BROKER_SOCKET ?? BROKER_PATH,
59
+ );
60
+ if (status !== 200)
61
+ throw new Error(
62
+ `${server}/${tool}: ${REFUSALS[status] ?? "the broker call failed"}`,
63
+ );
64
+ if (!isRecord(body)) throw new Error(`${server}/${tool}: no answer`);
65
+ if (isRecord(body.error))
66
+ throw new Error(`${server}/${tool}: ${String(body.error.message)}`);
67
+ const result = body.result;
68
+ if (!isRecord(result)) throw new Error(`${server}/${tool}: no result`);
69
+ if (result.isError === true)
70
+ throw new Error(`${server}/${tool}: ${firstText(result) ?? "failed"}`);
71
+ return result;
72
+ };
73
+ return {
74
+ call,
75
+ /** The result's structured content, or its first text content parsed as JSON (or as text). */
76
+ async json(
77
+ server: string,
78
+ tool: string,
79
+ args: Record<string, unknown> = {},
80
+ ): Promise<unknown> {
81
+ const result = await call(server, tool, args);
82
+ if (result.structuredContent !== undefined)
83
+ return result.structuredContent;
84
+ const text = firstText(result);
85
+ if (text === undefined) return undefined;
86
+ try {
87
+ return JSON.parse(text);
88
+ } catch {
89
+ return text;
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ function firstText(result: Record<string, unknown>): string | undefined {
96
+ const content = Array.isArray(result.content) ? result.content : [];
97
+ const text = content.find(
98
+ (item): item is { text: string } =>
99
+ isRecord(item) && item.type === "text" && typeof item.text === "string",
100
+ );
101
+ return text?.text;
102
+ }
103
+
104
+ function message(error: unknown): string {
105
+ return (error instanceof Error ? error.message : String(error)).slice(
106
+ 0,
107
+ 1000,
108
+ );
109
+ }
110
+
111
+ /** Runs the script and returns what to print; never throws. */
112
+ async function answer(): Promise<string> {
113
+ try {
114
+ const input: unknown = JSON.parse(
115
+ await boundedText(Bun.stdin.stream(), 64 * 1024),
116
+ );
117
+ if (!isInput(input)) throw new Error("invalid precheck input");
118
+ // stdout carries only the answer; whatever the script logs goes to stderr.
119
+ for (const level of ["log", "info", "debug", "warn"] as const)
120
+ console[level] = console.error;
121
+ const path = join(tmpdir(), `precheck-${randomUUID()}.mjs`);
122
+ await Bun.write(path, input.script);
123
+ let module: unknown;
124
+ try {
125
+ module = await import(path);
126
+ } finally {
127
+ await rm(path, { force: true });
128
+ }
129
+ const check = isRecord(module) ? module.default : undefined;
130
+ if (typeof check !== "function")
131
+ throw new Error("the script has no default export function");
132
+ const result: unknown = await check({
133
+ mcp: mcpClient(input.servers),
134
+ firedAt: new Date(input.firedAt),
135
+ timeZone: input.timeZone,
136
+ today: input.today,
137
+ schedule: input.schedule,
138
+ });
139
+ return JSON.stringify({ ok: true, result: result ?? null });
140
+ } catch (error) {
141
+ return JSON.stringify({ ok: false, error: message(error) });
142
+ }
143
+ }
144
+
145
+ // Exit once the answer is written, even if the script left timers or sockets behind.
146
+ await Bun.write(Bun.stdout, await answer());
147
+ process.exit(0);