pi-roundtable-sandbox 0.7.2 → 0.7.3

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.3
4
+
5
+ - Add the opt-in Pi subscription mode (`PiSandboxRuntime`, `PiSandboxBroker`, `PiDockerContainerDriver`), worker-initiated transport, controlled `safeFetch`, bounded media in and reply files out, scoped delegation and a research worker, with the sealed default unchanged.
6
+ - Add `assertPublicUrl`, `safeFetch` `followRedirects` and `headers`, and a research-worker `fetchContent` hook for hosts that keep a library's own extractors.
7
+ - Keep upstream error status, body and back-off headers through the rich broker, re-arm the metadata-only startup window for a restarted worker, and refuse compressed Teredo addresses.
8
+ - Stream rich broker responses without fixed total caps (the turn deadline is the bound), bind the host-judged thinking level so the worker can only ask for less, and replace guest-planted symlinks on start and start-fresh instead of following them.
9
+
3
10
  ## 0.7.2
4
11
 
5
12
  - First release published by the lockstep workflow; no changes to the package. The `v0.7.1` tag published nothing.
package/README.md CHANGED
@@ -195,10 +195,134 @@ Never put confidential data in a guest workspace.
195
195
  `startFresh` resets history on the next successful turn and preserves memory; a pending reset is process-local and does not survive host restart.
196
196
  Turning mode off does not erase workspace files.
197
197
 
198
- This first version is text-only and uses a minimal Chat Completions agent loop rather than loading a full Pi session inside the container.
198
+ The default sealed mode is text-only and uses a minimal Chat Completions agent loop rather than loading a full Pi session inside the container.
199
199
  Attachments are not downloaded; the agent is told they are unsupported.
200
200
  It does not load skills or extensions and has no shell, schedules, delegation, image tools, personas, or access to the host runtime.
201
201
 
202
+ ## Explicit Pi/subscription mode
203
+
204
+ `PiSandboxRuntime` is a separate, explicit opt-in API; `sandbox()` and its sealed defaults do not change.
205
+ Use it behind your own channel claim/store when you need existing Pi JSONL sessions, personas, subscription auth, media, or host-scoped tools.
206
+ There is no automatic access to the owner's agent or global tool registry.
207
+ This mode supports a Pi `claude-bridge` model through a host Anthropic OAuth broker, including streamed messages, token counting, custom tools and per-turn thinking.
208
+ Install compatible `@earendil-works/pi-coding-agent`, `typebox`, and `pi-claude-bridge` (including its bundled Claude Agent SDK runtime) in the worker dependency image.
209
+ MCP profiles additionally require `pi-mcp-adapter`.
210
+ The host requires the core's SDK dependencies; the bridge is loaded only by the opt-in worker, never by host setup.
211
+ Other model transports are not implicitly proxied by this broker.
212
+
213
+ ### Host options and hooks
214
+
215
+ | Option | Meaning and scope |
216
+ | --- | --- |
217
+ | `partyDir`, `image` | Dedicated private, host-owned directory and operator-built Pi image. |
218
+ | `profiles` | Host allow-list mapping profile names to fixed Anthropic `model` and optional MCP server names. |
219
+ | `oauthToken()` | Host-only credential getter called for each model request; supports refresh without container credentials. |
220
+ | `memory.promptBlock(channel, id, name)` | Current admitted speaker's context, at most 100,000 characters; database implementations remain host adapters. |
221
+ | `effort.judge(text, { level })` | Host-selected `low`, `medium`, `high`, or `xhigh`; the previous channel choice is retained for the next judgment. |
222
+ | `tools.names`, `tools.call` | Explicit host tool allow-list and callback receiving fixed channel/profile/speaker plus cancellation signal. |
223
+ | `mcp.servers`, `mcp.token()` | Fixed server URLs and tool-name allow-lists; host credential getter. |
224
+ | `upstream`, `fetchImpl` | Trusted Anthropic endpoint and test transport override, not guest inputs. |
225
+ | `allowHttpMcp` | Opt-in cleartext trusted MCP endpoints; avoid it unless your deployment protects that network. |
226
+ | `timeZone` | Explicit worker time zone; default UTC. |
227
+ | `containerPrefix`, `labelChannel`, `labelProfile` | Operator compatibility names for existing containers, not guest data. |
228
+ | `driver` | Trusted local Docker driver or offline fixture; no remote bind mounts. |
229
+ | `logger` | Host logger; package failures avoid credentials and upstream payloads. |
230
+ | `startTimeoutMs`, `turnTimeoutMs` | 1–600 seconds; defaults 90 and 600 seconds. |
231
+ | `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
+ `runTurn({ channel, profile, turnId, author, text, images, signal? })` returns text and bounded byte-backed reply files.
234
+ `start`, `stop`, `status`, `startFresh`, `sessionsDir`, `attachmentDir`, and `stopBrokers` support trusted channel lifecycle adapters.
235
+ The host binds identity immediately before enqueueing a turn and revokes it when the turn settles.
236
+ Guest `author`, `channel`, `target`, and credential fields cannot replace host tool identity.
237
+ Callbacks must still validate input and enforce per-person quotas, memory authorization and cancellation.
238
+ 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
+ Schedules can be host tools with a declared channel-local background target and host-bound author; no scheduler is enabled automatically.
240
+
241
+ ### Worker content and files
242
+
243
+ The trusted image exports `workerContent(profile): PiWorkerContent` from `/app/worker/content.ts`.
244
+ Its `model: { provider, id }` selects the installed Pi model; align it with the host profile's Anthropic model.
245
+ Optional `prompt` supplies persona/profile text, `skillsDir` loads a skill index and bounded `read_skill`, `brokerTools` supplies tool descriptions/schemas, and `toolNames` plus `extensions(turn)` declares local operator extensions.
246
+ Skill tool gates remain active until the corresponding skill is read; reads are confined to the baked-in skills tree, at most 256 KiB per file and 40,000 returned characters.
247
+ Automatic extensions, context files, prompt-template discovery and built-in shell/read/write/edit tools are disabled.
248
+ App dice/TRPG tools belong in `extensions`, not in a sandbox fork.
249
+ `contributionExtension` wraps public core `ToolContribution`s, collects their `ToolTurn.attachFile` output through `withReplyFiles`, and writes bounded worker outbox files.
250
+ Use official drawing contributions this way instead of copied renderers; content and assets stay operator-owned.
251
+
252
+ `collectPiAttachments` downloads at most ten attachments, 25 MiB each and 50 MiB total, using controlled fetch.
253
+ It creates files exclusively through a no-follow directory descriptor and prepares up to four images from downloaded bytes, never by reopening guest paths.
254
+ Its `prepareImage` hook can use core `prepareImageBytes`; native decoders remain a trusted host boundary, and compressed image dimensions can require additional resource controls.
255
+ Its `fetchImpl` override is for trusted offline fixtures only.
256
+ Optional `describeFailure(error)` supplies application-owned wording, never raw host exception details.
257
+ Rich turns accept at most eight PNG/JPEG/WebP/GIF images, 20 MiB each and 64 MiB decoded total.
258
+ Current text, speaker-memory context and final text each have a 100,000-character limit; speaker identifiers/names have 256-character limits.
259
+ Reply files use the core limits: ten files, 10 MiB each and 50 MiB total, credential-free base64 on the broker wire.
260
+ No arbitrary host file path is attached or reopened.
261
+ Apply a channel filesystem quota: persisted attachments, outbox files and Pi sessions have no automatic retention policy or total disk quota.
262
+
263
+ Build the dependency image with `worker/Dockerfile.deps.pi` and the installed `node_modules` directory as context.
264
+ Build the runtime image with `worker/Dockerfile.pi`, `--build-arg DEPS_IMAGE=<your-dependency-image>`, and an application release tree containing trusted `assets`, `persona`, `shared`, and `worker` content.
265
+ The runtime entry is the installed package's `worker/pi-main.ts`; no application runtime copy is needed.
266
+ Never include real auth files or host source in those content directories.
267
+
268
+ ### Pi isolation and continuity
269
+
270
+ 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).
272
+ Only the channel workspace (writable) and host-created broker directory (read-only) are mounted.
273
+ 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
+ Only one idle long poll, one admitted turn and one incoming reply packet per channel are allowed.
275
+ Reply packets are refused before body reading outside that turn and are cancelled with it.
276
+ The worker re-announces readiness after host restart; cancellation force-removes the exact channel container.
277
+ The broker has 16 connections and a ten-second header deadline.
278
+ The rich listener streams responses with backpressure, so server-sent events reach the worker as they are produced; it limits silence (120 seconds with no request-body read or response chunk), not total time.
279
+ The turn deadline (default 600 seconds) is the hard bound for every model, MCP and host-tool call.
280
+ Credential-bearing calls have four in-flight slots, 96 MiB input bodies and 50 MiB upstream-response limits.
281
+ Worker replies have a 72 MiB encoded packet limit.
282
+ Budgets are not streaming memory or billing quotas.
283
+
284
+ Only `POST /anthropic/v1/messages` and `/anthropic/v1/messages/count_tokens` (optionally `?beta=true`) use the model credential.
285
+ The broker rebuilds model traffic as ordinary text/base64-image/custom-tool blocks, fixes the model and output limit, preserves supported thinking/effort but never above the host-judged level (the worker may ask for less, not more; adaptive thinking without an effort is capped below the model default for `low` and `medium`), and refuses native server tools, remote media, remote schema references and unrelated account routes.
286
+ MCP initialization, initialized notification, ping, tool listing and explicitly listed tool calls are supported; other RPC methods and batches are refused.
287
+ Before the first admitted turn, a 90-second, 32-call metadata-only startup scope permits eager MCP initialization/listing but never tool calls or model calls.
288
+ That scope is revoked on the first bind.
289
+ Upstream redirects are refused, headers are rebuilt, and bounded response streams reject obvious credential reflections across chunk boundaries.
290
+ Trusted model/MCP services must not deliberately encode credentials; reflection filtering is defense in depth, not a guarantee against arbitrary encodings.
291
+
292
+ Sessions keep `partyDir/channelSegment(channel)/workspace/sessions`, with `/workspace` as Pi's session cwd.
293
+ The same JSONL files are continued by `SessionManager.continueRecent`; no database migrations or workspace renames are performed.
294
+ A host-only `.channel-key` record beside the workspace prevents legacy `channelSegment` collisions across every lifecycle/session/attachment operation; include it in backups.
295
+ Keep the complete broker socket path within 100 characters.
296
+ `startFresh` archives top-level plain `.jsonl` session files after the container is removed, replacing any symlink the guest planted at `sessions`, `sessions/archive` or `attachments` instead of following it; database memory stays intact.
297
+ Profiles, database tables, migration receipts, quotas and ingress routing remain the application's trusted adapters.
298
+ All guests in a channel share that channel's session/workspace; speaker hooks prevent tool identity spoofing, not confidentiality against compromised code in the same channel.
299
+
300
+ ### Controlled fetch and scoped delegation
301
+
302
+ `safeFetch(url, { signal?, timeoutMs?, maxBytes?, maxRedirects? })` allows credential-free HTTP(S) only.
303
+ Every hop resolves all addresses and refuses private, loopback, link-local, metadata, multicast, reserved and transition/documentation ranges, including encoded IPv4 and IPv4-mapped IPv6.
304
+ The actual socket lookup is pinned to a vetted address while HTTPS verifies the original hostname; environment proxies and a second DNS resolution are not used.
305
+ Connection failures may fall back only within that already-vetted list, with a three-second TCP/TLS connection deadline per address and the same whole-request deadline.
306
+ Every redirect is checked again, including redirects to the same hostname after DNS rebinding.
307
+ Defaults are 60 seconds, five redirects, and 5 MiB for both wire and decoded body; gzip, deflate and Brotli decoding are bounded too.
308
+ Allowed ranges are 1 millisecond–120 seconds, 0–10 redirects and 1 byte–32 MiB bodies.
309
+ `resolve` and `transport` overrides are trusted test seams, never guest inputs.
310
+ `SafeFetchResult` supplies bytes and the final URL; adapt those bytes to your HTML/PDF parser instead of handing an unchecked URL to a library that fetches again.
311
+ `followRedirects: false` returns a redirect response instead of following it, so a host that drives a library's own redirect handling can send each hop back through `safeFetch` and keep every hop vetted and pinned; `headers` sets trusted host request headers (never guest input; `accept-encoding` stays fixed).
312
+ `assertPublicUrl(url)` refuses a URL that is not credential-free HTTP(S) or does not resolve only to public addresses. Use it before handing a model-supplied URL to a third-party reader; it does not pin a later connection, so host-side fetches of that URL should still use `safeFetch`.
313
+
314
+ `ScopedSandboxDelegator` fixes one declared target, channel-local report destination and host-bound author, with no owner/agent dispatcher or origin thread.
315
+ Its default limit is two jobs per channel, 4,000 task characters, 200 title characters, 80,000 report characters and ten minutes.
316
+ Configure `maxRunning` (1–10) and `timeoutMs` (1–1,200 seconds) explicitly when preserving an application's existing limits.
317
+ `run(task, context)` receives only bound channel/author/signal, and `deliver(job, result)` posts through the application's background-report adapter.
318
+ `runningChannels`, `idle` and `dispose` support host lifecycle handling; jobs are process-local and are cancelled on disposal.
319
+ `SandboxResearchWorker` is an optional host subscription adapter with explicit `modelRuntime`, `agentDir`, `workDir`, `model`, `thinking`, `search`, and `extractFetched` options.
320
+ It creates an unsaved Pi session with only `web_search` and controlled `fetch_content`, no shell, host memory, skills/context discovery or owner tools.
321
+ `extractFetched` receives already bounded, pinned-fetch bytes and must not re-fetch their URL.
322
+ Alternatively `fetchContent(url, signal)` replaces the built-in fetch plus `extractFetched` with a host-owned fetch-and-extract; the host is then responsible for refusing unsafe and private addresses and for bounding time and size. One of the two is required.
323
+ Host search/model credentials stay in the trusted host process, and report delivery must remain in the declared guest channel.
324
+ 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.
325
+
202
326
  ## Development and verification
203
327
 
204
328
  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.2",
3
+ "version": "0.7.3",
4
4
  "description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,13 +35,25 @@
35
35
  "lint": "biome check ."
36
36
  },
37
37
  "peerDependencies": {
38
- "pi-roundtable": ">=0.7.0 <0.8.0"
38
+ "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
39
+ "pi-roundtable": ">=0.7.0 <0.8.0",
40
+ "typebox": ">=1.3.34 <2"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@earendil-works/pi-coding-agent": {
44
+ "optional": true
45
+ },
46
+ "typebox": {
47
+ "optional": true
48
+ }
39
49
  },
40
50
  "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "1.0.0",
41
52
  "@biomejs/biome": "2.5.15",
42
53
  "@types/bun": "1.4.2",
43
54
  "discord.js": "14.27.0",
44
- "pi-roundtable": "0.7.2",
55
+ "pi-roundtable": "0.7.3",
56
+ "typebox": "1.3.34",
45
57
  "typescript": "7.0.2"
46
58
  }
47
59
  }
package/src/broker.ts CHANGED
@@ -292,96 +292,162 @@ export class SandboxBroker {
292
292
  }
293
293
 
294
294
  async listen(socketPath: string): Promise<BrokerListener> {
295
- const sockets = new Set<Socket>();
296
- const headerTimers = new Map<Socket, ReturnType<typeof setTimeout>>();
297
- const server = createServer(async (incoming, outgoing) => {
298
- clearTimeout(headerTimers.get(incoming.socket));
299
- headerTimers.delete(incoming.socket);
300
- const requestController = new AbortController();
301
- const requestTimer = setTimeout(() => {
302
- requestController.abort();
303
- incoming.destroy();
304
- outgoing.destroy();
305
- }, 60_000);
306
- const finish = () => {
295
+ return listenBroker(socketPath, (request) => this.handle(request));
296
+ }
297
+ }
298
+
299
+ export interface ListenOptions {
300
+ /**
301
+ * Stream the response with backpressure instead of buffering it. Time is then bounded by
302
+ * the host's own signal in `handle` plus an idle limit on each body read and write.
303
+ */
304
+ stream?: boolean;
305
+ /** Idle limit between body chunks in stream mode. Default 120 000 ms. */
306
+ idleMs?: number;
307
+ }
308
+
309
+ export async function listenBroker(
310
+ socketPath: string,
311
+ handle: (request: Request) => Promise<Response>,
312
+ options: ListenOptions = {},
313
+ ): Promise<BrokerListener> {
314
+ const stream = options.stream === true;
315
+ const idleMs = options.idleMs ?? 120_000;
316
+ const sockets = new Set<Socket>();
317
+ const headerTimers = new Map<Socket, ReturnType<typeof setTimeout>>();
318
+ const server = createServer(async (incoming, outgoing) => {
319
+ clearTimeout(headerTimers.get(incoming.socket));
320
+ headerTimers.delete(incoming.socket);
321
+ const requestController = new AbortController();
322
+ const expire = () => {
323
+ requestController.abort();
324
+ incoming.destroy();
325
+ outgoing.destroy();
326
+ };
327
+ let requestTimer = setTimeout(expire, stream ? idleMs : 60_000);
328
+ // Stream mode only limits silence: slow progress is allowed, a stalled peer is not.
329
+ const touch = () => {
330
+ if (!stream) return;
331
+ clearTimeout(requestTimer);
332
+ requestTimer = setTimeout(expire, idleMs);
333
+ };
334
+ const finish = () => {
335
+ clearTimeout(requestTimer);
336
+ requestController.abort();
337
+ };
338
+ if (stream) {
339
+ incoming.on("data", touch);
340
+ // The host handler may take as long as its own signal allows; only body silence is limited.
341
+ incoming.once("end", () => clearTimeout(requestTimer));
342
+ if (incoming.method === "GET" || incoming.method === "HEAD")
307
343
  clearTimeout(requestTimer);
308
- requestController.abort();
309
- };
310
- outgoing.once("finish", finish);
311
- outgoing.once("close", finish);
312
- outgoing.on("error", () => outgoing.destroy());
313
- outgoing.setHeader("connection", "close");
314
- try {
315
- const headers = new Headers();
316
- for (const [name, value] of Object.entries(incoming.headers)) {
317
- if (value !== undefined)
318
- headers.set(name, Array.isArray(value) ? value.join(", ") : value);
319
- }
320
- const request = new Request(`http://broker${incoming.url ?? "/"}`, {
321
- method: incoming.method ?? "GET",
322
- headers,
323
- signal: requestController.signal,
324
- ...(incoming.method === "GET" || incoming.method === "HEAD"
325
- ? {}
326
- : { body: Readable.toWeb(incoming), duplex: "half" }),
327
- });
328
- const response = await this.handle(request);
329
- const bytes = Buffer.from(await response.arrayBuffer());
330
- if (!outgoing.destroyed && !outgoing.writableEnded) {
331
- outgoing.writeHead(
332
- response.status,
333
- Object.fromEntries(response.headers),
334
- );
335
- outgoing.end(bytes);
344
+ }
345
+ outgoing.once("finish", finish);
346
+ outgoing.once("close", finish);
347
+ outgoing.on("error", () => outgoing.destroy());
348
+ outgoing.setHeader("connection", "close");
349
+ try {
350
+ const headers = new Headers();
351
+ for (const [name, value] of Object.entries(incoming.headers)) {
352
+ if (value !== undefined)
353
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
354
+ }
355
+ const request = new Request(`http://broker${incoming.url ?? "/"}`, {
356
+ method: incoming.method ?? "GET",
357
+ headers,
358
+ signal: requestController.signal,
359
+ ...(incoming.method === "GET" || incoming.method === "HEAD"
360
+ ? {}
361
+ : { body: Readable.toWeb(incoming), duplex: "half" }),
362
+ });
363
+ const response = await handle(request);
364
+ if (stream) {
365
+ if (outgoing.destroyed || outgoing.writableEnded) {
366
+ await response.body?.cancel();
367
+ return;
336
368
  }
337
- } catch {
338
- if (!outgoing.destroyed && !outgoing.writableEnded) {
339
- if (!outgoing.headersSent) outgoing.writeHead(400);
340
- outgoing.end("bad request");
369
+ outgoing.writeHead(
370
+ response.status,
371
+ Object.fromEntries(response.headers),
372
+ );
373
+ touch();
374
+ const reader = response.body?.getReader();
375
+ outgoing.once("close", () => void reader?.cancel().catch(() => {}));
376
+ for (;;) {
377
+ const chunk = await reader?.read();
378
+ if (!chunk || chunk.done) break;
379
+ touch();
380
+ if (outgoing.destroyed) break;
381
+ if (!outgoing.write(chunk.value))
382
+ await new Promise<void>((resolve) => {
383
+ const done = () => {
384
+ outgoing.off("drain", done);
385
+ outgoing.off("close", done);
386
+ resolve();
387
+ };
388
+ outgoing.once("drain", done);
389
+ outgoing.once("close", done);
390
+ });
341
391
  }
342
- }
343
- });
344
- server.on("connection", (socket) => {
345
- if (sockets.size >= 16) {
346
- socket.destroy();
392
+ if (!outgoing.destroyed) outgoing.end();
347
393
  return;
348
394
  }
349
- sockets.add(socket);
350
- headerTimers.set(
351
- socket,
352
- setTimeout(() => socket.destroy(), 10_000),
353
- );
354
- socket.once("close", () => {
355
- sockets.delete(socket);
356
- clearTimeout(headerTimers.get(socket));
357
- headerTimers.delete(socket);
358
- });
359
- });
360
- server.maxConnections = 16;
361
- server.headersTimeout = 10_000;
362
- server.requestTimeout = 60_000;
363
- server.keepAliveTimeout = 1000;
364
- await new Promise<void>((resolve, reject) => {
365
- server.once("error", reject);
366
- server.listen(socketPath, resolve);
367
- });
368
- try {
369
- chmodSync(socketPath, 0o600);
370
- } catch (error) {
371
- for (const socket of sockets) socket.destroy();
372
- server.closeAllConnections();
373
- await new Promise<void>((resolve) => server.close(() => resolve()));
374
- throw error;
395
+ const bytes = Buffer.from(await response.arrayBuffer());
396
+ if (!outgoing.destroyed && !outgoing.writableEnded) {
397
+ outgoing.writeHead(
398
+ response.status,
399
+ Object.fromEntries(response.headers),
400
+ );
401
+ outgoing.end(bytes);
402
+ }
403
+ } catch {
404
+ if (outgoing.headersSent) outgoing.destroy();
405
+ else if (!outgoing.destroyed && !outgoing.writableEnded) {
406
+ outgoing.writeHead(400);
407
+ outgoing.end("bad request");
408
+ }
375
409
  }
376
- return {
377
- stop: (force = true) =>
378
- new Promise<void>((resolve) => {
379
- if (force) {
380
- for (const socket of sockets) socket.destroy();
381
- server.closeAllConnections();
382
- }
383
- server.close(() => resolve());
384
- }),
385
- };
410
+ });
411
+ server.on("connection", (socket) => {
412
+ if (sockets.size >= 16) {
413
+ socket.destroy();
414
+ return;
415
+ }
416
+ sockets.add(socket);
417
+ headerTimers.set(
418
+ socket,
419
+ setTimeout(() => socket.destroy(), 10_000),
420
+ );
421
+ socket.once("close", () => {
422
+ sockets.delete(socket);
423
+ clearTimeout(headerTimers.get(socket));
424
+ headerTimers.delete(socket);
425
+ });
426
+ });
427
+ server.maxConnections = 16;
428
+ server.headersTimeout = 10_000;
429
+ server.requestTimeout = stream ? 0 : 60_000;
430
+ server.keepAliveTimeout = 1000;
431
+ await new Promise<void>((resolve, reject) => {
432
+ server.once("error", reject);
433
+ server.listen(socketPath, resolve);
434
+ });
435
+ try {
436
+ chmodSync(socketPath, 0o600);
437
+ } catch (error) {
438
+ for (const socket of sockets) socket.destroy();
439
+ server.closeAllConnections();
440
+ await new Promise<void>((resolve) => server.close(() => resolve()));
441
+ throw error;
386
442
  }
443
+ return {
444
+ stop: (force = true) =>
445
+ new Promise<void>((resolve) => {
446
+ if (force) {
447
+ for (const socket of sockets) socket.destroy();
448
+ server.closeAllConnections();
449
+ }
450
+ server.close(() => resolve());
451
+ }),
452
+ };
387
453
  }
@@ -0,0 +1,48 @@
1
+ import { lstatSync, mkdirSync, openSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** No check-then-open path: the directory descriptor is the authority, including on macOS fixtures. */
5
+ export async function openDirectoryFile(
6
+ directory: number,
7
+ name: string,
8
+ flags: number,
9
+ mode: number,
10
+ ): Promise<number> {
11
+ if (!name || /[/\\\p{Cc}]/u.test(name) || name === "." || name === "..")
12
+ throw new Error("Invalid directory entry");
13
+ if (process.platform === "linux")
14
+ return openSync(
15
+ join("/proc/self/fd", String(directory), name),
16
+ flags,
17
+ mode,
18
+ );
19
+ if (process.platform !== "darwin")
20
+ throw new Error("Directory-anchored writes require Linux or macOS");
21
+ // macOS has no /proc dirfd paths. Use its POSIX openat rather than weakening path safety.
22
+ const { dlopen } = await import("bun:ffi");
23
+ const libc = dlopen("/usr/lib/libSystem.B.dylib", {
24
+ openat: { args: ["i32", "ptr", "i32", "u32"], returns: "i32" },
25
+ });
26
+ try {
27
+ const file = libc.symbols.openat(
28
+ directory,
29
+ Buffer.from(`${name}\0`),
30
+ flags,
31
+ mode,
32
+ );
33
+ if (file < 0) throw new Error("Directory entry refused");
34
+ return file;
35
+ } finally {
36
+ libc.close();
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Entries under the guest-writable workspace may have been replaced by the guest. A symlink or
42
+ * file where a directory belongs is removed (unlink never follows it) so the host never acts through it.
43
+ */
44
+ export function ownDirectory(path: string): void {
45
+ const info = lstatSync(path, { throwIfNoEntry: false });
46
+ if (info && !info.isDirectory()) unlinkSync(path);
47
+ mkdirSync(path, { recursive: true, mode: 0o700 });
48
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,17 @@
1
+ export type { PiWorkerContent } from "../worker/pi-content.ts";
2
+ export { speakerMemoryExtension } from "../worker/pi-memory.ts";
3
+ export {
4
+ loadSkillIndex,
5
+ type SkillEntry,
6
+ skillsExtension,
7
+ skillsPromptBlock,
8
+ } from "../worker/pi-skills.ts";
9
+ export {
10
+ brokerToolsExtension,
11
+ contributionExtension,
12
+ type PiWorkerToolSpec,
13
+ saveToOutbox,
14
+ } from "../worker/pi-tools.ts";
1
15
  export {
2
16
  type BrokerListener,
3
17
  type BrokerOptions,
@@ -19,6 +33,51 @@ export {
19
33
  containerRunArgs,
20
34
  DockerContainerDriver,
21
35
  } from "./container-driver.ts";
36
+ export {
37
+ collectPiAttachments,
38
+ type PiAttachmentOptions,
39
+ } from "./pi-attachments.ts";
40
+ export {
41
+ type PiBrokerOptions,
42
+ type PiHostContext,
43
+ type PiMcpServer,
44
+ PiSandboxBroker,
45
+ } from "./pi-broker.ts";
46
+ export {
47
+ type PiContainerDriver,
48
+ type PiContainerSpec,
49
+ type PiContainerStatus,
50
+ PiDockerContainerDriver,
51
+ piContainerCreateBody,
52
+ } from "./pi-container-driver.ts";
53
+ export {
54
+ isPiThinkingLevel,
55
+ PI_ATTACHMENTS,
56
+ PI_BROKER_SOCKET,
57
+ PI_FORWARDER_PORT,
58
+ PI_MEDIA_LIMITS,
59
+ PI_OUTBOX,
60
+ PI_RUN_DIR,
61
+ PI_THINKING_LEVELS,
62
+ PI_WORKSPACE,
63
+ type PiMcpDiscovery,
64
+ type PiReplyFile,
65
+ type PiThinkingLevel,
66
+ type PiToolResponse,
67
+ type PiTurnContext,
68
+ type PiTurnRequest,
69
+ type PiTurnResponse,
70
+ safeFileName,
71
+ validateImages,
72
+ validateReplyFiles,
73
+ } from "./pi-protocol.ts";
74
+ export {
75
+ type PiProfile,
76
+ PiSandboxRuntime,
77
+ type PiSandboxRuntimeOptions,
78
+ type PiSandboxTurn,
79
+ type PiSandboxTurnResult,
80
+ } from "./pi-runtime.ts";
22
81
  export {
23
82
  SANDBOX,
24
83
  type SandboxOptions,
@@ -26,4 +85,21 @@ export {
26
85
  sandbox,
27
86
  } from "./plugin.ts";
28
87
  export type { SandboxReply, SandboxTurn, ToolSpec } from "./protocol.ts";
88
+ export {
89
+ type SandboxResearchOptions,
90
+ SandboxResearchWorker,
91
+ } from "./research-worker.ts";
29
92
  export { SandboxRuntime, type SandboxRuntimeOptions } from "./runtime.ts";
93
+ export {
94
+ assertPublicUrl,
95
+ isPublicAddress,
96
+ ResponseTooLargeError,
97
+ type SafeFetchOptions,
98
+ type SafeFetchResult,
99
+ safeFetch,
100
+ UnsafeUrlError,
101
+ } from "./safe-fetch.ts";
102
+ export {
103
+ type ScopedDelegatorOptions,
104
+ ScopedSandboxDelegator,
105
+ } from "./scoped-delegator.ts";
@@ -9,7 +9,7 @@ interface ModelInput {
9
9
  tools?: Record<string, unknown>[];
10
10
  }
11
11
 
12
- function localSchema(value: unknown): boolean {
12
+ export function localSchema(value: unknown): boolean {
13
13
  if (typeof value === "boolean") return true;
14
14
  if (!isRecord(value)) return false;
15
15
  for (const key of [