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 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.11",
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.11",
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
- let running: Promise<unknown> = Promise.resolve();
729
- this.#compacting = running;
730
- const timeout = AbortSignal.timeout(
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 ${compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs} ms`
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(this.#options.turnTimeoutMs ?? 600_000),
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();
@@ -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
- const servers = await grant({
327
- channel: schedule.channel,
328
- target: schedule.target,
329
- tier: schedule.createdTier,
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;