pi-roundtable-sandbox 0.7.9 → 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 +12 -0
- package/README.md +84 -2
- package/package.json +2 -2
- package/src/container-driver.ts +43 -12
- package/src/index.ts +22 -0
- package/src/pi-broker.ts +353 -6
- package/src/pi-container-driver.ts +74 -3
- package/src/pi-protocol.ts +118 -0
- package/src/pi-runtime.ts +67 -10
- package/src/precheck-runner.ts +401 -0
- package/worker/Dockerfile +1 -1
- package/worker/pi-compaction.ts +225 -0
- package/worker/pi-main.ts +96 -13
- package/worker/precheck-main.ts +147 -0
package/src/pi-protocol.ts
CHANGED
|
@@ -78,6 +78,124 @@ export interface PiTurnContext {
|
|
|
78
78
|
export interface PiMcpDiscovery {
|
|
79
79
|
servers: { name: string; tools: string[] }[];
|
|
80
80
|
}
|
|
81
|
+
/** What the worker learns from the host at startup; `compaction` is absent when the host has no compactor. */
|
|
82
|
+
export interface PiWorkerConfig {
|
|
83
|
+
compaction?: { engine: string };
|
|
84
|
+
}
|
|
85
|
+
/** The JSON of one Pi `AgentMessage` from the worker's session. */
|
|
86
|
+
export type PiCompactMessage = { role: string } & Record<string, unknown>;
|
|
87
|
+
/** The worker's compaction preparation, sent to the host's compactor. */
|
|
88
|
+
export interface PiCompactRequest {
|
|
89
|
+
/** What triggered the compaction: a manual compact, the context threshold, or an overflow. */
|
|
90
|
+
reason: "manual" | "threshold" | "overflow";
|
|
91
|
+
tokensBefore: number;
|
|
92
|
+
/** The first session entry the compaction keeps; a compaction must keep the same one. */
|
|
93
|
+
firstKeptEntryId: string;
|
|
94
|
+
/** Whether the cut falls inside a turn, so `turnPrefixMessages` holds that turn's start. */
|
|
95
|
+
isSplitTurn: boolean;
|
|
96
|
+
messagesToSummarize: PiCompactMessage[];
|
|
97
|
+
turnPrefixMessages: PiCompactMessage[];
|
|
98
|
+
/** The messages from `firstKeptEntryId` on, which stay in the context. */
|
|
99
|
+
keptMessages: PiCompactMessage[];
|
|
100
|
+
previousSummary?: string;
|
|
101
|
+
customInstructions?: string;
|
|
102
|
+
/** Files only read, and files written or edited, in the summarized messages. */
|
|
103
|
+
readFiles: string[];
|
|
104
|
+
modifiedFiles: string[];
|
|
105
|
+
}
|
|
106
|
+
/** A compaction the host's compactor wrote, shaped as Pi's `CompactionResult`. */
|
|
107
|
+
export interface PiCompaction {
|
|
108
|
+
summary: string;
|
|
109
|
+
firstKeptEntryId: string;
|
|
110
|
+
tokensBefore: number;
|
|
111
|
+
estimatedTokensAfter?: number;
|
|
112
|
+
details?: unknown;
|
|
113
|
+
}
|
|
114
|
+
/** The host's answer: a compaction, or a fallback to Pi's own summary with its reason. */
|
|
115
|
+
export type PiCompactResponse =
|
|
116
|
+
| { ok: true; compaction: PiCompaction }
|
|
117
|
+
| { ok: false; fallback: string };
|
|
118
|
+
/** What the worker tells the host about its compactions, so the host logs them per channel. */
|
|
119
|
+
export type PiCompactionReport =
|
|
120
|
+
| {
|
|
121
|
+
type: "bypass";
|
|
122
|
+
/** Why the hard ceiling skipped the host's compactor for Pi's summary. */
|
|
123
|
+
reason: string;
|
|
124
|
+
tokensBefore: number;
|
|
125
|
+
}
|
|
126
|
+
| {
|
|
127
|
+
type: "end";
|
|
128
|
+
reason: PiCompactRequest["reason"];
|
|
129
|
+
aborted: boolean;
|
|
130
|
+
willRetry: boolean;
|
|
131
|
+
/** Absent when the compaction failed. */
|
|
132
|
+
engine?: "extension" | "pi";
|
|
133
|
+
tokensBefore?: number;
|
|
134
|
+
tokensAfter?: number;
|
|
135
|
+
nextCompactionAt?: number;
|
|
136
|
+
error?: string;
|
|
137
|
+
};
|
|
138
|
+
const COMPACT_REASONS = ["manual", "threshold", "overflow"];
|
|
139
|
+
function isMessageList(value: unknown): value is PiCompactMessage[] {
|
|
140
|
+
return (
|
|
141
|
+
Array.isArray(value) &&
|
|
142
|
+
value.every((item) => isRecord(item) && typeof item.role === "string")
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
function isStringList(value: unknown): value is string[] {
|
|
146
|
+
return (
|
|
147
|
+
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
function isTokenCount(value: unknown): value is number {
|
|
151
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
152
|
+
}
|
|
153
|
+
export function validateCompactRequest(
|
|
154
|
+
value: unknown,
|
|
155
|
+
): asserts value is PiCompactRequest {
|
|
156
|
+
if (
|
|
157
|
+
!isRecord(value) ||
|
|
158
|
+
!COMPACT_REASONS.includes(value.reason as string) ||
|
|
159
|
+
!isTokenCount(value.tokensBefore) ||
|
|
160
|
+
typeof value.firstKeptEntryId !== "string" ||
|
|
161
|
+
value.firstKeptEntryId.length === 0 ||
|
|
162
|
+
value.firstKeptEntryId.length > 256 ||
|
|
163
|
+
typeof value.isSplitTurn !== "boolean" ||
|
|
164
|
+
!isMessageList(value.messagesToSummarize) ||
|
|
165
|
+
!isMessageList(value.turnPrefixMessages) ||
|
|
166
|
+
!isMessageList(value.keptMessages) ||
|
|
167
|
+
(value.previousSummary !== undefined &&
|
|
168
|
+
typeof value.previousSummary !== "string") ||
|
|
169
|
+
(value.customInstructions !== undefined &&
|
|
170
|
+
typeof value.customInstructions !== "string") ||
|
|
171
|
+
!isStringList(value.readFiles) ||
|
|
172
|
+
!isStringList(value.modifiedFiles)
|
|
173
|
+
)
|
|
174
|
+
throw new Error("Invalid compact request");
|
|
175
|
+
}
|
|
176
|
+
export function validateCompactionReport(
|
|
177
|
+
value: unknown,
|
|
178
|
+
): asserts value is PiCompactionReport {
|
|
179
|
+
const valid =
|
|
180
|
+
isRecord(value) &&
|
|
181
|
+
(value.type === "bypass"
|
|
182
|
+
? typeof value.reason === "string" &&
|
|
183
|
+
value.reason.length <= 1000 &&
|
|
184
|
+
isTokenCount(value.tokensBefore)
|
|
185
|
+
: value.type === "end" &&
|
|
186
|
+
COMPACT_REASONS.includes(value.reason as string) &&
|
|
187
|
+
typeof value.aborted === "boolean" &&
|
|
188
|
+
typeof value.willRetry === "boolean" &&
|
|
189
|
+
(value.engine === undefined ||
|
|
190
|
+
value.engine === "extension" ||
|
|
191
|
+
value.engine === "pi") &&
|
|
192
|
+
[value.tokensBefore, value.tokensAfter, value.nextCompactionAt].every(
|
|
193
|
+
(count) => count === undefined || isTokenCount(count),
|
|
194
|
+
) &&
|
|
195
|
+
(value.error === undefined ||
|
|
196
|
+
(typeof value.error === "string" && value.error.length <= 10_000)));
|
|
197
|
+
if (!valid) throw new Error("Invalid compaction report");
|
|
198
|
+
}
|
|
81
199
|
export function isPiThinkingLevel(value: unknown): value is PiThinkingLevel {
|
|
82
200
|
return (
|
|
83
201
|
typeof value === "string" &&
|
package/src/pi-runtime.ts
CHANGED
|
@@ -14,9 +14,14 @@ import {
|
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { isAbsolute, join } from "node:path";
|
|
16
16
|
import { AgentRunError, type ChannelKey, type Logger } from "pi-roundtable";
|
|
17
|
-
import { channelSegment } from "pi-roundtable/kit";
|
|
17
|
+
import { channelSegment, scrubDiagnostic } from "pi-roundtable/kit";
|
|
18
18
|
import { ownDirectory } from "./directory-file.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
errorText,
|
|
21
|
+
PI_COMPACT_LIMITS,
|
|
22
|
+
type PiBrokerOptions,
|
|
23
|
+
PiSandboxBroker,
|
|
24
|
+
} from "./pi-broker.ts";
|
|
20
25
|
import {
|
|
21
26
|
type PiContainerDriver,
|
|
22
27
|
type PiContainerStatus,
|
|
@@ -129,17 +134,31 @@ export class PiSandboxRuntime {
|
|
|
129
134
|
)
|
|
130
135
|
)
|
|
131
136
|
throw new Error("Invalid container prefix");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
options.startTimeoutMs ?? 90_000,
|
|
135
|
-
])
|
|
137
|
+
const turnTimeoutMs = options.turnTimeoutMs ?? 600_000;
|
|
138
|
+
for (const timeout of [turnTimeoutMs, options.startTimeoutMs ?? 90_000])
|
|
136
139
|
if (!Number.isSafeInteger(timeout) || timeout < 1000 || timeout > 600_000)
|
|
137
140
|
throw new Error("Invalid runtime deadline");
|
|
141
|
+
// A compactor that runs out of time still leaves Pi's summary half the turn.
|
|
142
|
+
const compaction = options.compaction && {
|
|
143
|
+
...options.compaction,
|
|
144
|
+
timeoutMs:
|
|
145
|
+
options.compaction.timeoutMs ??
|
|
146
|
+
Math.max(
|
|
147
|
+
1000,
|
|
148
|
+
Math.min(PI_COMPACT_LIMITS.timeoutMs, Math.floor(turnTimeoutMs / 3)),
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
if (compaction && compaction.timeoutMs > turnTimeoutMs / 2)
|
|
152
|
+
throw new Error("The compactor's timeout must leave half the turn");
|
|
138
153
|
mkdirSync(options.partyDir, { recursive: true, mode: 0o700 });
|
|
139
154
|
const info = statSync(options.partyDir);
|
|
140
155
|
if (info.uid !== process.getuid?.() || (info.mode & 0o077) !== 0)
|
|
141
156
|
throw new Error("State directory must be private and host-owned");
|
|
142
|
-
this.#options = {
|
|
157
|
+
this.#options = {
|
|
158
|
+
...options,
|
|
159
|
+
partyDir: realpathSync(options.partyDir),
|
|
160
|
+
...(compaction ? { compaction } : {}),
|
|
161
|
+
};
|
|
143
162
|
this.#driver =
|
|
144
163
|
options.driver ??
|
|
145
164
|
new PiDockerContainerDriver(undefined, options.labelProfile);
|
|
@@ -311,6 +330,22 @@ export class PiSandboxRuntime {
|
|
|
311
330
|
await Promise.all([...this.#brokers.values()].map((entry) => entry.stop()));
|
|
312
331
|
this.#brokers.clear();
|
|
313
332
|
}
|
|
333
|
+
/** The worker's last log lines, read before its container is removed, so they are not lost with it. */
|
|
334
|
+
async #logWorker(channel: ChannelKey, turnId: string): Promise<void> {
|
|
335
|
+
if (!this.#driver.logs) return;
|
|
336
|
+
try {
|
|
337
|
+
const lines = await this.#driver.logs(this.#name(channel), 200);
|
|
338
|
+
this.#options.logger.warn(
|
|
339
|
+
{ channel, turnId, lines: scrubDiagnostic(lines, 200_000) },
|
|
340
|
+
"sandbox worker log before removal",
|
|
341
|
+
);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
this.#options.logger.warn(
|
|
344
|
+
{ channel, turnId, error: scrubDiagnostic(errorText(error)) },
|
|
345
|
+
"sandbox worker log unavailable",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
314
349
|
async runTurn(turn: PiSandboxTurn): Promise<PiSandboxTurnResult> {
|
|
315
350
|
if (this.#active.has(turn.channel))
|
|
316
351
|
return { ok: false, error: new AgentRunError("Channel is busy") };
|
|
@@ -322,6 +357,10 @@ export class PiSandboxRuntime {
|
|
|
322
357
|
AbortSignal.timeout(this.#options.turnTimeoutMs ?? 600_000),
|
|
323
358
|
]);
|
|
324
359
|
signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
360
|
+
const timedOut = () =>
|
|
361
|
+
signal.aborted &&
|
|
362
|
+
signal.reason instanceof DOMException &&
|
|
363
|
+
signal.reason.name === "TimeoutError";
|
|
325
364
|
let release: (() => void) | undefined;
|
|
326
365
|
try {
|
|
327
366
|
signal.throwIfAborted();
|
|
@@ -372,20 +411,38 @@ export class PiSandboxRuntime {
|
|
|
372
411
|
});
|
|
373
412
|
const body = await entry.broker.execute(request, signal);
|
|
374
413
|
signal.throwIfAborted();
|
|
375
|
-
if (!body.ok) throw new Error(
|
|
414
|
+
if (!body.ok) throw new Error(`Worker turn failed: ${body.error}`);
|
|
376
415
|
validateReplyFiles(body.files);
|
|
377
416
|
const files = body.files.map((file) => ({
|
|
378
417
|
name: file.name,
|
|
379
418
|
data: Buffer.from(file.data, "base64"),
|
|
380
419
|
}));
|
|
381
420
|
return { ok: true, text: body.text, files };
|
|
382
|
-
} catch {
|
|
421
|
+
} catch (error) {
|
|
422
|
+
const failure = {
|
|
423
|
+
message: scrubDiagnostic(errorText(error), 4000),
|
|
424
|
+
timedOut: timedOut(),
|
|
425
|
+
cancelled: signal.aborted && !timedOut(),
|
|
426
|
+
};
|
|
427
|
+
this.#options.logger.error(
|
|
428
|
+
{ channel: turn.channel, turnId: turn.turnId, ...failure },
|
|
429
|
+
"sandbox turn failed",
|
|
430
|
+
);
|
|
383
431
|
// A timed-out Pi session must not keep consuming broker tools or corrupt the next turn.
|
|
384
432
|
if (signal.aborted) {
|
|
385
433
|
await this.#starts.get(turn.channel)?.catch(() => {});
|
|
434
|
+
await this.#logWorker(turn.channel, turn.turnId);
|
|
386
435
|
await this.#driver.remove(this.#name(turn.channel));
|
|
387
436
|
}
|
|
388
|
-
return {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
error: new AgentRunError(
|
|
440
|
+
failure.timedOut
|
|
441
|
+
? "Sandbox turn timed out"
|
|
442
|
+
: `Sandbox turn failed: ${failure.message}`,
|
|
443
|
+
{ cause: error },
|
|
444
|
+
),
|
|
445
|
+
};
|
|
389
446
|
} finally {
|
|
390
447
|
controller.abort();
|
|
391
448
|
release?.();
|
|
@@ -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"]
|