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 CHANGED
@@ -1,5 +1,17 @@
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
+
9
+ ## 0.7.10
10
+
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.
12
+ - A failed `runTurn` keeps its cause (`AgentRunError` message and `cause`, whether it timed out) and logs it; the broker logs upstream failures with channel, status, latency and the error body; a timed-out worker's last 200 log lines are logged before its container is removed (`PiContainerDriver.logs`).
13
+ - Behavior change: Pi containers log to journald tagged `sandbox/<channel>` by default, instead of two 10 MiB `local` files, so the Docker daemon must run with journald (pass `PiDockerContainerDriver` a `log` option for another driver) (`PiContainerSpec.log`, `PiDockerContainerDriver` option `log`, `defaultContainerLog`), and the worker logs model, broker, tool and compaction failures.
14
+
3
15
  ## 0.7.5
4
16
 
5
17
  - `ScopedSandboxDelegator` limits a title to 200 characters by default again, and accepts `maxTitleChars`, `maxReportChars` and `diagnosticChars`.
package/README.md CHANGED
@@ -226,7 +226,8 @@ Other model transports are not implicitly proxied by this broker.
226
226
  | `timeZone` | Explicit worker time zone; default UTC. |
227
227
  | `containerPrefix`, `labelChannel`, `labelProfile` | Operator compatibility names for existing containers, not guest data. |
228
228
  | `driver` | Trusted local Docker driver or offline fixture; no remote bind mounts. |
229
- | `logger` | Host logger; package failures avoid credentials and upstream payloads. |
229
+ | `logger` | Host logger: failed turns with their cause, upstream failures (channel, status, latency, error body cut to 2,000 characters with credentials masked), a timed-out worker's last 200 log lines, and every compaction. |
230
+ | `compaction` | Optional host compactor (`PiCompactor`), see [Compaction](#compaction). |
230
231
  | `startTimeoutMs`, `turnTimeoutMs` | 1–600 seconds; defaults 90 and 600 seconds. |
231
232
  | `maxCalls`, `maxOutputTokens` | Default 128 credential-bearing calls and 128,000 output tokens per model call; configurable 1–1,000 calls and 1,025–200,000 tokens. |
232
233
 
@@ -238,6 +239,37 @@ Callbacks must still validate input and enforce per-person quotas, memory author
238
239
  Optional person/notes/moments tools can use an existing PostgreSQL store without copying the connection, schema, migration ledger or owner memory into the image.
239
240
  Schedules can be host tools with a declared channel-local background target and host-bound author; no scheduler is enabled automatically.
240
241
 
242
+ ### Compaction
243
+
244
+ Worker sessions compact with the core's tiers, as the host's own sessions do: a model whose window leaves more than 300,000 tokens compacts at 300,000 through the host compactor, and past 500,000 through Pi's own summary, whose model calls go through the broker like any other.
245
+ A compaction that left the context within 50,000 tokens of its threshold moves the next one to the hard ceiling, so it does not repeat on the next request.
246
+
247
+ The host compactor runs on the host, where the worker cannot reach (a remote compaction service, say):
248
+
249
+ ```ts
250
+ new PiSandboxRuntime({
251
+ // ...
252
+ compaction: {
253
+ engine: "my-compaction", // recorded as details.engine of its compactions
254
+ compact: async (request, { channel, signal }) => {
255
+ // request: PiCompactRequest; return a PiCompaction, or undefined for Pi's summary
256
+ },
257
+ timeoutMs: 120_000, // optional; default the smaller of 120 s and a third of turnTimeoutMs
258
+ maxRequestBytes: 32 * 1024 * 1024, // optional; default 32 MiB
259
+ },
260
+ });
261
+ ```
262
+
263
+ `PiCompactRequest` carries Pi's preparation: `reason`, `tokensBefore`, `firstKeptEntryId`, `isSplitTurn`, `messagesToSummarize`, `turnPrefixMessages`, `keptMessages` (the messages from `firstKeptEntryId` on), `previousSummary?`, `customInstructions?`, `readFiles` and `modifiedFiles`.
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.
266
+ The timeout may be at most half of `turnTimeoutMs`, so Pi's summary keeps time to run.
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
+ Without `compaction` the worker registers no compaction handler; the tiers and Pi's summary still apply.
269
+ Compactions are written to the session file in the channel workspace, so they survive container removal and restarts.
270
+
271
+ The host logs, per channel: `conversation compacted` (`trigger`, `engine` `extension` or `pi`, `tokensBefore`, `tokensAfter`, `nextCompactionAt`), `compaction failed`, `compaction skips the extension for Pi's summary` past the ceiling, and `compaction falls back to Pi's summary` with its `fallback` reason.
272
+
241
273
  ### Worker content and files
242
274
 
243
275
  The trusted image exports `workerContent(profile): PiWorkerContent` from `/app/worker/content.ts`.
@@ -268,7 +300,10 @@ Never include real auth files or host source in those content directories.
268
300
  ### Pi isolation and continuity
269
301
 
270
302
  Pi containers remain network-none, non-root, read-only-root, capability-free and `no-new-privileges`.
271
- They have 1.5 GiB RAM with no additional swap, one CPU, 256 PIDs, a 512 MiB temporary filesystem, and bounded Docker logs (two 10 MiB local log files).
303
+ They have 1.5 GiB RAM with no additional swap, one CPU, 256 PIDs, and a 512 MiB temporary filesystem.
304
+ Their logs go to journald tagged `sandbox/<channel>`, so they outlive the container; the Docker daemon must then run under systemd with journald, or containers fail to start.
305
+ journald bounds the lines through its own rate limit and `SystemMaxUse`, not per container; `PiDockerContainerDriver`'s third argument, `{ log: { driver, options } }` (or a function of the channel), names another Docker log driver.
306
+ The worker logs model errors, broker call failures, tool failures and compactions to stdout as JSON lines.
272
307
  Only the channel workspace (writable) and host-created broker directory (read-only) are mounted.
273
308
  Worker-initiated `/worker/ready`, `/worker/next` and `/worker/result` requests transport turns and byte-backed replies; the host never connects to a guest-created socket or reads guest reply paths.
274
309
  Only one idle long poll, one admitted turn and one incoming reply packet per channel are allowed.
@@ -329,6 +364,53 @@ One of `search` with a fetch option, or `tools`, is required.
329
364
  Host search/model credentials stay in the trusted host process, and report delivery must remain in the declared guest channel.
330
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.
331
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
+
332
414
  ## Development and verification
333
415
 
334
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.9",
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.9",
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
@@ -38,13 +38,18 @@ export {
38
38
  type PiAttachmentOptions,
39
39
  } from "./pi-attachments.ts";
40
40
  export {
41
+ PI_COMPACT_LIMITS,
41
42
  type PiBrokerOptions,
43
+ type PiCompactor,
42
44
  type PiHostContext,
43
45
  type PiMcpServer,
44
46
  PiSandboxBroker,
45
47
  } from "./pi-broker.ts";
46
48
  export {
49
+ defaultContainerLog,
50
+ demuxDockerLog,
47
51
  type PiContainerDriver,
52
+ type PiContainerLog,
48
53
  type PiContainerSpec,
49
54
  type PiContainerStatus,
50
55
  PiDockerContainerDriver,
@@ -60,6 +65,11 @@ export {
60
65
  PI_RUN_DIR,
61
66
  PI_THINKING_LEVELS,
62
67
  PI_WORKSPACE,
68
+ type PiCompaction,
69
+ type PiCompactionReport,
70
+ type PiCompactMessage,
71
+ type PiCompactRequest,
72
+ type PiCompactResponse,
63
73
  type PiMcpDiscovery,
64
74
  type PiReplyFile,
65
75
  type PiThinkingLevel,
@@ -67,7 +77,10 @@ export {
67
77
  type PiTurnContext,
68
78
  type PiTurnRequest,
69
79
  type PiTurnResponse,
80
+ type PiWorkerConfig,
70
81
  safeFileName,
82
+ validateCompactionReport,
83
+ validateCompactRequest,
71
84
  validateImages,
72
85
  validateReplyFiles,
73
86
  } from "./pi-protocol.ts";
@@ -84,6 +97,15 @@ export {
84
97
  type SandboxService,
85
98
  sandbox,
86
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";
87
109
  export type { SandboxReply, SandboxTurn, ToolSpec } from "./protocol.ts";
88
110
  export {
89
111
  type ResearchTools,