pi-roundtable-sandbox 0.7.11 → 0.7.13
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 +9 -0
- package/README.md +3 -2
- package/package.json +2 -2
- package/src/pi-broker.ts +72 -4
- package/src/pi-runtime.ts +4 -1
- package/src/precheck-runner.ts +23 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.13
|
|
4
|
+
|
|
5
|
+
- `precheckScriptRunner` forwards only the tools approved with the script (pi-roundtable 0.7.13's `PrecheckScriptContext.tools`) among those `grant` allows, and takes `toolName(server, tool)`, the name the host's hold rules know a tool by (default the tool's own name).
|
|
6
|
+
|
|
7
|
+
## 0.7.12
|
|
8
|
+
|
|
9
|
+
- A host compactor gets at most half the time the turn has left (the runtime passes the broker its `deadline`, `PiHostContext.deadline`), so Pi's own summary still fits after it runs out; with under two seconds left the broker falls back to Pi's summary at once.
|
|
10
|
+
- The broker logs the first `error` event of a server-sent event stream that started with a 200 (`sandbox upstream call failed`, with the event's data, credentials removed), which the status alone never showed; the worker still receives the whole stream.
|
|
11
|
+
|
|
3
12
|
## 0.7.11
|
|
4
13
|
|
|
5
14
|
- `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`.
|
package/README.md
CHANGED
|
@@ -262,7 +262,7 @@ new PiSandboxRuntime({
|
|
|
262
262
|
|
|
263
263
|
`PiCompactRequest` carries Pi's preparation: `reason`, `tokensBefore`, `firstKeptEntryId`, `isSplitTurn`, `messagesToSummarize`, `turnPrefixMessages`, `keptMessages` (the messages from `firstKeptEntryId` on), `previousSummary?`, `customInstructions?`, `readFiles` and `modifiedFiles`.
|
|
264
264
|
A `PiCompaction` is Pi's `CompactionResult`: `summary`, `firstKeptEntryId` (the request's), `tokensBefore`, `estimatedTokensAfter?` and `details?` (an object; the broker sets its `engine`).
|
|
265
|
-
A compactor that returns `undefined`, throws, answers out of shape, outlasts `timeoutMs` (its `signal` aborts), or gets a request over `maxRequestBytes` falls back to Pi's summary, and the host logs the reason.
|
|
265
|
+
A compactor that returns `undefined`, throws, answers out of shape, outlasts `timeoutMs` or half the time the turn has left, whichever is shorter (its `signal` aborts), or gets a request over `maxRequestBytes` falls back to Pi's summary, and the host logs the reason.
|
|
266
266
|
The timeout may be at most half of `turnTimeoutMs`, so Pi's summary keeps time to run.
|
|
267
267
|
A turn may ask the host for three compactions and send sixteen compaction reports; later compactions fall back to Pi's summary, and while a compactor that ignored its signal still runs, a new request falls back too.
|
|
268
268
|
Without `compaction` the worker registers no compaction handler; the tiers and Pi's summary still apply.
|
|
@@ -391,7 +391,8 @@ export const precheckScripts = definePlugin({
|
|
|
391
391
|
});
|
|
392
392
|
```
|
|
393
393
|
|
|
394
|
-
`grant({ channel, target })` returns the `PrecheckMcpServer`s a script for that schedule may call: `{ name, url, tools, token? }`.
|
|
394
|
+
`grant({ channel, target, tier })` returns the `PrecheckMcpServer`s a script for that schedule may call: `{ name, url, tools, token? }`.
|
|
395
|
+
Each run reaches only the granted tools that were approved with the script: the core reads a script's calls when it is saved, and saving one that calls a tool the host's hold rules hold waits for the owner's approval. `toolName(server, tool)` gives the name those rules know a tool by, the name the host's own agent calls it by; the default is the tool's own name, so pass it when your agent sees MCP tools under another name, such as `${server}_${tool}`.
|
|
395
396
|
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
397
|
`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
398
|
Calls run one at a time; a second call while one is pending is refused.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-sandbox",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
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.
|
|
55
|
+
"pi-roundtable": "0.7.13",
|
|
56
56
|
"typebox": "1.3.34",
|
|
57
57
|
"typescript": "7.0.2"
|
|
58
58
|
}
|
package/src/pi-broker.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface PiHostContext {
|
|
|
27
27
|
/** The host-judged level for this turn; the worker can request less but never more. */
|
|
28
28
|
thinking: PiThinkingLevel;
|
|
29
29
|
signal: AbortSignal;
|
|
30
|
+
/** When the turn must end, in epoch milliseconds; a host compactor gets at most half the time left. */
|
|
31
|
+
deadline?: number;
|
|
30
32
|
}
|
|
31
33
|
export interface PiMcpServer {
|
|
32
34
|
name: string;
|
|
@@ -127,12 +129,55 @@ function endpoint(raw: string, allowHttp = false): URL {
|
|
|
127
129
|
throw new Error("Invalid trusted endpoint");
|
|
128
130
|
return url;
|
|
129
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Watches a server-sent event stream as it passes and reports its first `error` event: an
|
|
134
|
+
* upstream can fail mid-answer after a 200, which the status alone never shows.
|
|
135
|
+
*/
|
|
136
|
+
function sseErrorWatcher(
|
|
137
|
+
onError: (data: string) => void,
|
|
138
|
+
): (chunk: Uint8Array, done: boolean) => void {
|
|
139
|
+
const decoder = new TextDecoder();
|
|
140
|
+
let pending = "";
|
|
141
|
+
let reported = false;
|
|
142
|
+
const scan = (event: string) => {
|
|
143
|
+
const lines = event.split(/\r?\n/);
|
|
144
|
+
const type = lines
|
|
145
|
+
.find((line) => line.startsWith("event:"))
|
|
146
|
+
?.slice(6)
|
|
147
|
+
.trim();
|
|
148
|
+
const data = lines
|
|
149
|
+
.filter((line) => line.startsWith("data:"))
|
|
150
|
+
.map((line) => line.slice(5).replace(/^ /, ""))
|
|
151
|
+
.join("\n");
|
|
152
|
+
let typed: unknown;
|
|
153
|
+
try {
|
|
154
|
+
typed = JSON.parse(data);
|
|
155
|
+
} catch {
|
|
156
|
+
typed = undefined;
|
|
157
|
+
}
|
|
158
|
+
if (type === "error" || (isRecord(typed) && typed.type === "error")) {
|
|
159
|
+
reported = true;
|
|
160
|
+
onError(data);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
return (chunk, done) => {
|
|
164
|
+
if (reported) return;
|
|
165
|
+
pending += decoder.decode(chunk, { stream: !done });
|
|
166
|
+
const events = pending.split(/\r?\n\r?\n/);
|
|
167
|
+
pending = done ? "" : (events.pop() ?? "");
|
|
168
|
+
// An event this long is no error report; keep scanning without holding it.
|
|
169
|
+
if (pending.length > 64 * 1024) pending = "";
|
|
170
|
+
for (const event of events) if (!reported) scan(event);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
130
174
|
function streamBounded(
|
|
131
175
|
response: Response,
|
|
132
176
|
signal: AbortSignal,
|
|
133
177
|
maxBytes: number,
|
|
134
178
|
secret: string,
|
|
135
179
|
onFailure: (error: unknown) => void,
|
|
180
|
+
watch?: (chunk: Uint8Array, done: boolean) => void,
|
|
136
181
|
): ReadableStream<Uint8Array> {
|
|
137
182
|
const reader = response.body?.getReader();
|
|
138
183
|
let total = 0;
|
|
@@ -157,6 +202,7 @@ function streamBounded(
|
|
|
157
202
|
throw new Error("Credential reflected");
|
|
158
203
|
total += result?.value?.byteLength ?? 0;
|
|
159
204
|
if (total > maxBytes) throw new Error("Response too large");
|
|
205
|
+
watch?.(result?.value ?? new Uint8Array(), !result || result.done);
|
|
160
206
|
if (!result || result.done) {
|
|
161
207
|
if (data.length) controller.enqueue(data);
|
|
162
208
|
controller.close();
|
|
@@ -632,6 +678,17 @@ export class PiSandboxBroker {
|
|
|
632
678
|
PI_MEDIA_LIMITS.totalFileBytes,
|
|
633
679
|
secret,
|
|
634
680
|
(error) => failed(secret, { status: upstream.status, error }),
|
|
681
|
+
upstream.status < 400 &&
|
|
682
|
+
(upstream.headers.get("content-type") ?? "").includes(
|
|
683
|
+
"text/event-stream",
|
|
684
|
+
)
|
|
685
|
+
? sseErrorWatcher((body) =>
|
|
686
|
+
failed(secret, {
|
|
687
|
+
status: upstream.status,
|
|
688
|
+
body: `stream error event: ${body}`,
|
|
689
|
+
}),
|
|
690
|
+
)
|
|
691
|
+
: undefined,
|
|
635
692
|
),
|
|
636
693
|
{ headers: outgoing, status: upstream.status },
|
|
637
694
|
);
|
|
@@ -725,11 +782,22 @@ export class PiSandboxBroker {
|
|
|
725
782
|
compactor.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes;
|
|
726
783
|
if (Number(request.headers.get("content-length") ?? 0) > maxBytes)
|
|
727
784
|
return fallback(`the request is over ${maxBytes} bytes`);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
785
|
+
// Pi's own summary must still fit after a compactor that runs out of time.
|
|
786
|
+
const left =
|
|
787
|
+
turn.context.deadline === undefined
|
|
788
|
+
? Number.POSITIVE_INFINITY
|
|
789
|
+
: turn.context.deadline - Date.now();
|
|
790
|
+
const timeoutMs = Math.min(
|
|
731
791
|
compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs,
|
|
792
|
+
Math.floor(left / 2),
|
|
732
793
|
);
|
|
794
|
+
if (timeoutMs < 1000)
|
|
795
|
+
return fallback(
|
|
796
|
+
"the turn has too little time left for the host compactor",
|
|
797
|
+
);
|
|
798
|
+
let running: Promise<unknown> = Promise.resolve();
|
|
799
|
+
this.#compacting = running;
|
|
800
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
733
801
|
const signal = AbortSignal.any([
|
|
734
802
|
turn.context.signal,
|
|
735
803
|
request.signal,
|
|
@@ -797,7 +865,7 @@ export class PiSandboxBroker {
|
|
|
797
865
|
} catch (error) {
|
|
798
866
|
return fallback(
|
|
799
867
|
timeout.aborted
|
|
800
|
-
? `the compactor took over ${
|
|
868
|
+
? `the compactor took over ${timeoutMs} ms`
|
|
801
869
|
: signal.aborted
|
|
802
870
|
? "the compaction was aborted"
|
|
803
871
|
: `the compactor failed: ${scrubDiagnostic(errorText(error), 2000)}`,
|
package/src/pi-runtime.ts
CHANGED
|
@@ -351,10 +351,12 @@ export class PiSandboxRuntime {
|
|
|
351
351
|
return { ok: false, error: new AgentRunError("Channel is busy") };
|
|
352
352
|
const controller = new AbortController();
|
|
353
353
|
this.#active.set(turn.channel, controller);
|
|
354
|
+
const turnTimeoutMs = this.#options.turnTimeoutMs ?? 600_000;
|
|
355
|
+
const deadline = Date.now() + turnTimeoutMs;
|
|
354
356
|
const signal = AbortSignal.any([
|
|
355
357
|
controller.signal,
|
|
356
358
|
...(turn.signal ? [turn.signal] : []),
|
|
357
|
-
AbortSignal.timeout(
|
|
359
|
+
AbortSignal.timeout(turnTimeoutMs),
|
|
358
360
|
]);
|
|
359
361
|
signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
360
362
|
const timedOut = () =>
|
|
@@ -408,6 +410,7 @@ export class PiSandboxRuntime {
|
|
|
408
410
|
speaker: turn.author,
|
|
409
411
|
thinking,
|
|
410
412
|
signal,
|
|
413
|
+
deadline,
|
|
411
414
|
});
|
|
412
415
|
const body = await entry.broker.execute(request, signal);
|
|
413
416
|
signal.throwIfAborted();
|
package/src/precheck-runner.ts
CHANGED
|
@@ -51,6 +51,12 @@ export interface PrecheckScriptRunnerOptions {
|
|
|
51
51
|
grant(
|
|
52
52
|
scope: PrecheckScope,
|
|
53
53
|
): readonly PrecheckMcpServer[] | Promise<readonly PrecheckMcpServer[]>;
|
|
54
|
+
/**
|
|
55
|
+
* The name the host's hold rules know `mcp.call(server, tool)` by, which is the name its own
|
|
56
|
+
* agent calls that tool by; default `tool` itself. A script calling a tool the rules hold needs
|
|
57
|
+
* the owner's approval to be saved.
|
|
58
|
+
*/
|
|
59
|
+
toolName?: (server: string, tool: string) => string;
|
|
54
60
|
/** The container's command; default the package's worker in the Pi image's layout. */
|
|
55
61
|
entrypoint?: readonly string[];
|
|
56
62
|
/** How long a script may run; default 60 seconds. */
|
|
@@ -286,6 +292,7 @@ function guide(servers: readonly PrecheckMcpServer[]): string {
|
|
|
286
292
|
"`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
293
|
"`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
294
|
"Await each call before making the next; calls do not run in parallel. console output is discarded; only the returned result counts.",
|
|
295
|
+
'Write each call as mcp.call("server", "tool", args) or mcp.json(...), with server and tool as strings: the host reads them when the script is saved, and each run may call only those. A script that calls a tool needing the owner\'s approval waits for it when saved, once.',
|
|
289
296
|
"`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
297
|
"The script runs in a container with no network, no files of the host, and no credentials; it reaches only the tools below.",
|
|
291
298
|
reach,
|
|
@@ -315,6 +322,7 @@ export function precheckScriptRunner(
|
|
|
315
322
|
? {}
|
|
316
323
|
: { timeoutMs: options.timeoutMs }),
|
|
317
324
|
describe: async (scope) => guide(await grant(scope)),
|
|
325
|
+
toolName: options.toolName ?? ((_server, tool) => tool),
|
|
318
326
|
run: async (script, context) => runOne(script, context),
|
|
319
327
|
};
|
|
320
328
|
|
|
@@ -323,10 +331,21 @@ export function precheckScriptRunner(
|
|
|
323
331
|
context: PrecheckScriptContext,
|
|
324
332
|
): Promise<PrecheckResult> {
|
|
325
333
|
const { schedule, signal } = context;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
334
|
+
// Only what the grant allows and the owner approved with the script: the broker refuses the rest.
|
|
335
|
+
const approved = new Set(
|
|
336
|
+
context.tools.map(({ server, tool }) => `${server}\u0000${tool}`),
|
|
337
|
+
);
|
|
338
|
+
const servers = (
|
|
339
|
+
await grant({
|
|
340
|
+
channel: schedule.channel,
|
|
341
|
+
target: schedule.target,
|
|
342
|
+
tier: schedule.createdTier,
|
|
343
|
+
})
|
|
344
|
+
).flatMap((server) => {
|
|
345
|
+
const tools = server.tools.filter((tool) =>
|
|
346
|
+
approved.has(`${server.name}\u0000${tool}`),
|
|
347
|
+
);
|
|
348
|
+
return tools.length > 0 ? [{ ...server, tools }] : [];
|
|
330
349
|
});
|
|
331
350
|
const dirs: string[] = [];
|
|
332
351
|
let listener: Awaited<ReturnType<typeof listenBroker>> | undefined;
|