pi-roundtable-sandbox 0.8.0 → 0.9.1

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
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.1
6
+
7
+ - Release in lockstep with pi-roundtable 0.9.1; no package-specific behavior changes.
8
+
9
+ ## 0.9.0
10
+
11
+ - The sandbox claim answers only an author the host's `access` rules serve: it reads the router's `message.speaker` and ignores a message without one, as it ignores a bot's. A guest who was answered in 0.8 without any tier is now ignored until `access` gives them one, for example `access.members.roles` naming their guild role. The message is still never routed to a host agent.
12
+ - `SandboxRuntime.runTurn` takes the author's `principalId` with their `id` and `name` (`SandboxSpeaker`, a new export) and refuses a turn without one. Host tools read it as `context.speaker.principalId`, and the credential hook as `scope.speaker.principalId`, so a host can pick a key per principal. The worker still reads only the author's `id` and `name`; its memory namespaces are unchanged. A `PiSandboxRuntime` turn's scope carries no principal.
13
+ - `precheckScriptRunner` grants a script what `context.tier`, the tier of its run (pi-roundtable 0.9's `PrecheckContext.tier`, its schedule's capped at what its creator holds now), reaches, instead of the tier the schedule was set at, so a script 0.8 stored at the owner tier no longer reaches the owner's tools for a member.
14
+ - `ScopedSandboxDelegator.start` takes the author's `principalId` and the tier `member`, as pi-roundtable 0.9's `DelegationJob.author` requires them, and refuses any other tier, as it refused a tier before: a guest's report runs as the principal the host bound them to, at the member tier or theirs, whichever is lower. The worker's `run` still hears only the author's `id` and `name`.
15
+
5
16
  ## 0.8.0
6
17
 
7
18
  - `apiKey` and `oauthToken` receive `{ channel, speaker }` (`SandboxCredentialScope`, a new export) before every model call, so a host can pick a key or a subscription per channel or speaker, such as `apiKey: ({ channel }) => keys.get(channel)`. A zero-argument function keeps working. A turn keeps the scope it started with while its requests are in flight, and a getter that returns `undefined` or an empty string fails the call: no other credential is used. The worker still sees only the placeholder key.
package/README.md CHANGED
@@ -59,7 +59,8 @@ export const guestPlugins = [sandbox({
59
59
  Choose a model available to your endpoint and sign the host into its provider.
60
60
  By default, `context.apiKey(provider)` is read on every request, allowing login refresh without copying credentials into the worker.
61
61
  Alternatively supply `apiKey: () => process.env.MODEL_API_KEY` when the endpoint uses a separate key.
62
- `apiKey` receives `{ channel, speaker }` (`SandboxCredentialScope`) before every model call, so a host can pick a different key per channel or speaker, for example `apiKey: ({ channel }) => keys.get(channel)`; a zero-argument function keeps working.
62
+ `apiKey` receives `{ channel, speaker }` (`SandboxCredentialScope`) before every model call, so a host can pick a different key per channel, speaker or principal, for example `apiKey: ({ channel }) => keys.get(channel)` or `apiKey: ({ speaker }) => keys.get(speaker.principalId)`; a zero-argument function keeps working.
63
+ `speaker.principalId` is the principal the host's identity service resolved the author to; it never reaches the worker.
63
64
  When the hook has no key it should return `undefined` (the call fails closed) rather than fall back to the host's own login.
64
65
  Keep secrets in the service's protected environment, not configuration source or the worker image.
65
66
  All directories must be dedicated to this package and owned by the service account.
@@ -74,7 +75,8 @@ Only one host process may own these directories.
74
75
  Restart the bot, then run `/roundtable sandbox on` in a guild channel as the owner.
75
76
  The command root follows your `discord.rootCommand` setting.
76
77
  The claim outranks the core agent-server claim (priority 200 versus 100), including when the owner speaks there.
77
- It answers non-bot, non-integration guild messages that mention the bot or reply to it.
78
+ It answers non-bot, non-integration guild messages that mention the bot or reply to it, from an author the host's `access` rules serve.
79
+ Give guests a tier there, for example `access.members.roles` naming their guild role; the sandbox ignores an author the rules serve no one by, as it ignores a message whose author could not be resolved.
78
80
  Other messages are dropped, never routed to a host agent.
79
81
  `/roundtable sandbox status` shows the mode; `/roundtable sandbox off` retains memory and restores normal routing after queued work completes.
80
82
  These commands are guarded by the published owner-command helper and serialized through the shared channel queue.
@@ -105,7 +107,7 @@ const tools: HostTool[] = [{
105
107
  Pass `tools` to `sandbox()`.
106
108
  Host tool and MCP server/tool names must match `[a-z][a-z0-9_]{0,47}`.
107
109
  The worker exposes them as `host_clock`, while the broker accepts only `POST /tools/clock`.
108
- The host binds `context.channel` and `context.speaker` to the current admitted turn.
110
+ The host binds `context.channel` and `context.speaker` to the current admitted turn; `context.speaker.principalId` is the principal the author was admitted as.
109
111
  The callback signal combines the request's deadline/closure and the turn's cancellation, and aborts when either scope ends.
110
112
  Body fields cannot change that identity, even if a guest replaces the worker or forges a broker call.
111
113
  Callbacks must validate every input field, enforce their own authorization and rate limits, obey cancellation, and avoid returning secrets.
@@ -219,7 +221,7 @@ Other model transports are not implicitly proxied by this broker.
219
221
  | `partyDir`, `image` | Dedicated private, host-owned directory and operator-built Pi image. |
220
222
  | `profiles` | Host allow-list mapping profile names to fixed Anthropic `model` and optional MCP server names. |
221
223
  | `oauthToken({ channel, speaker })` | Host-only credential getter called before each model request with the bound turn's channel and speaker, so a host can use a different subscription per channel or speaker; supports refresh without container credentials. A zero-argument getter still works. Returning `undefined` or an empty string fails the call; no other credential is used. |
222
- | `memory.promptBlock(channel, id, name)` | Current admitted speaker's context, at most 100,000 characters; database implementations remain host adapters. |
224
+ | `memory.promptBlock(channel, id, name)`, `memory.visibility` | Context at most 100,000 characters; private to the admitted reader by default. Explicit `"shared"` declares party-wide facts public and avoids tainting reader records. Database implementations remain host adapters. |
223
225
  | `effort.judge(text, { level })` | Host-selected `low`, `medium`, `high`, or `xhigh`; the previous channel choice is retained for the next judgment. |
224
226
  | `tools.names`, `tools.call` | Explicit host tool allow-list and callback receiving fixed channel/profile/speaker plus cancellation signal. |
225
227
  | `mcp.servers`, `mcp.token()` | Fixed server URLs and tool-name allow-lists; host credential getter. |
@@ -241,6 +243,64 @@ Callbacks must still validate input and enforce per-person quotas, memory author
241
243
  Optional person/notes/moments tools can use an existing PostgreSQL store without copying the connection, schema, migration ledger or owner memory into the image.
242
244
  Schedules can be host tools with a declared channel-local background target and host-bound author; no scheduler is enabled automatically.
243
245
 
246
+ ### Principal migration in 0.9
247
+
248
+ Follow [Migrating to 0.9](../../docs/migration-0.9.md) for the core upgrade.
249
+ The sandbox claim ignores an author with no resolved tier; guests need core `access` admission, not an implicit owner fallback.
250
+ `SandboxRuntime.runTurn` requires a host-bound speaker with `principalId`, and credential/host-tool hooks receive that id; the sealed worker protocol still receives only the actor id and name.
251
+ For the separate `PiSandboxRuntime`, pass `author: { id, name, principalId }` from the admitted speaker: `id` remains the actor id and `principalId` is the reader used for private-history projection.
252
+ It is optional for compatibility, falling back to `id` only for integrations whose actor id already is the principal id.
253
+ Never take it from guest tool arguments.
254
+ This does not isolate a shared channel's workspace by principal: guests in that channel still share its raw files and stored history.
255
+ The sealed default's channel-shared memory contract is unchanged.
256
+ Per-person model credentials remain the host integration's responsibility, not a guarantee of the core release.
257
+
258
+ ### Private tool exchanges in Pi mode
259
+
260
+ The container creates its own Pi session, not the core's `SessionFactory`; the worker now explicitly applies core `memoryProjection` to requests and `privateCompaction` before summaries.
261
+ Host tool callbacks may return `PiToolResponse` with `privateTo?: string`, a principal id:
262
+
263
+ ```ts
264
+ const principalId = context.speaker.principalId;
265
+ if (!principalId) throw new Error("Private tools require a bound principal");
266
+ return { ok: true, text: "private facts", privateTo: principalId };
267
+ ```
268
+
269
+ The broker validates and preserves the field; the worker persists it as `details.privateTo` on the Pi tool result, including tagged error results (`ok: false`).
270
+ `PiSandboxRuntimeOptions.memory.visibility` declares whether `promptBlock` is `"private"` (default) or `"shared"`.
271
+ A private nonempty prompt block taints that turn's reader record even without a tool call; an explicitly shared block does not.
272
+ A host with party-wide facts must set `memory.visibility: "shared"`, including when upgrading a 0.8 integration, or another participant's later claude-bridge turn is refused.
273
+ Shared visibility applies only to that prompt block; a tool returning `privateTo` still creates a private exchange.
274
+ For another reader, the call's arguments and result become placeholders while their call id/name pairing stays valid.
275
+ The principal itself still sees the original exchange.
276
+ This applies to any custom tool name, not just core memory tools; `remember_person`, `recall_person`, and `forget_person` must be tagged by the host when their results are private.
277
+ Untagged custom exchanges are public.
278
+ Both Pi's summary and the host compactor receive no tagged private exchanges, including the kept tail sent to the host, and no earlier prompt memory or reasoning.
279
+ Calls/results split across compaction boundaries are paired for redaction.
280
+ The bridge provider bypasses request projection by replaying its own history: before calling a provider, the worker refuses a bridge turn when its raw persisted branch records private memory carried for another reader, or holds an explicit private exchange hidden from that reader, even after Pi compacted it.
281
+ Reader records cover private prompt blocks and their retained reasoning without requiring a tool call; memory exchanges, including malformed/truncated/unanswered built-in calls, belong to their turn's reader.
282
+ A turn carrying no private memory never blocks another participant merely for speaking, so explicitly shared party-wide prompt blocks with public tools remain usable across A → B → A turns.
283
+ Unlike the core host guard, sandbox has no primary-owner compatibility exception for untagged old built-in memory exchanges.
284
+ Existing summaries are not retroactively scrubbed.
285
+ Start fresh before allowing new readers into histories previously summarized without this protection, and rebuild the worker image with the upgraded core and sandbox together.
286
+ This is exchange isolation, not general secret-flow prevention or a hostile-container boundary: public replies, untagged tools, raw workspace files, and attachments/reply files remain shared.
287
+ Do not put confidential data in the guest workspace.
288
+
289
+ ### Worker capability handshake
290
+
291
+ The trusted Pi worker sends `POST /worker/ready` with `{ capabilities: { privateTo: true, readerRecords: true }, privateHistory: <boolean> }`.
292
+ `privateTo` declares tagged call/result request projection and private compaction support; `readerRecords` declares persisted per-turn readers, prompt privacy, and raw-branch bridge refusal.
293
+ `privateHistory` reports retained private history, including compacted turns, when a worker reconnects to a restarted host.
294
+ A missing or empty ready body means an older worker, not implicit support; malformed capability declarations are refused.
295
+ The host requires both capabilities only when a turn carries a nonempty private prompt block, the worker reports retained private history, or a host tool actually returns `privateTo`.
296
+ Merely configuring tools does not require capabilities, and old images can still serve empty or explicitly shared prompt blocks with only public tools.
297
+ If a host tool first returns `privateTo` to an incompatible image, its payload is not sent to the worker: the host fails the whole turn and blocks subsequent model calls, even if the old worker catches tool errors.
298
+ The error names the exact configured image to rebuild with the installed core and sandbox packages; keeping the same local image tag is not proof that it has been rebuilt.
299
+ A worker reconnecting without capabilities also invalidates an already queued private turn.
300
+ For direct `PiSandboxBroker` integrations, supply `workerImage` so errors name that image; `PiSandboxRuntime` supplies its `image` automatically.
301
+ This handshake detects mixed trusted versions, not a compromised worker that lies about its capabilities.
302
+ It does not sanitize historical data written by an old worker; start fresh before sharing previously unsafe private history.
303
+
244
304
  ### Compaction
245
305
 
246
306
  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.
@@ -394,7 +454,7 @@ export const precheckScripts = definePlugin({
394
454
  });
395
455
  ```
396
456
 
397
- `grant({ channel, target, tier })` returns the `PrecheckMcpServer`s a script for that schedule may call: `{ name, url, tools, token? }`.
457
+ `grant({ channel, target, tier })` returns the `PrecheckMcpServer`s a script for that schedule may call: `{ name, url, tools, token? }`. `tier` is the tier the run is for, its schedule's capped at what its creator holds now (or the asker's, when `schedule_list` describes what a script may call), so grant lower tiers less.
398
458
  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}`.
399
459
  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).
400
460
  `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-sandbox",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
39
- "pi-roundtable": ">=0.8.0 <0.9.0",
39
+ "pi-roundtable": ">=0.9.0 <0.10.0",
40
40
  "typebox": ">=1.3.34 <2"
41
41
  },
42
42
  "peerDependenciesMeta": {
@@ -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.8.0",
55
+ "pi-roundtable": "0.9.1",
56
56
  "typebox": "1.3.34",
57
57
  "typescript": "7.0.2"
58
58
  }
package/src/broker.ts CHANGED
@@ -16,15 +16,29 @@ export interface BrokerListener {
16
16
  stop(force?: boolean): Promise<void>;
17
17
  }
18
18
 
19
+ /**
20
+ * Whom a turn is for, as the host admitted them: the author's `id` and `name` as their surface
21
+ * knows them, which the worker also reads, and the `principalId` the host's identity service
22
+ * resolved them to, which stays on the host.
23
+ */
24
+ export interface SandboxSpeaker {
25
+ id: string;
26
+ name: string;
27
+ principalId: string;
28
+ }
19
29
  export interface HostToolContext {
20
30
  channel: ChannelKey;
21
- speaker: { id: string; name: string };
31
+ speaker: SandboxSpeaker;
22
32
  signal: AbortSignal;
23
33
  }
24
- /** Who a model call is made for; a credential hook can pick a token per channel or speaker. */
34
+ /**
35
+ * Who a model call is made for; a credential hook can pick a token per channel, speaker or
36
+ * principal. A `sandbox` plugin turn always carries the principal; a `PiSandboxRuntime` turn
37
+ * carries none, as its author names none.
38
+ */
25
39
  export interface SandboxCredentialScope {
26
40
  channel: ChannelKey;
27
- speaker: { id: string; name: string };
41
+ speaker: { id: string; name: string; principalId?: string };
28
42
  }
29
43
  export interface HostTool extends ToolSpec {
30
44
  run(
@@ -230,7 +244,11 @@ export class SandboxBroker {
230
244
  const { channel, speaker } = this.#options.context;
231
245
  const key = await this.#options.apiKey({
232
246
  channel,
233
- speaker: { id: speaker.id, name: speaker.name },
247
+ speaker: {
248
+ id: speaker.id,
249
+ name: speaker.name,
250
+ principalId: speaker.principalId,
251
+ },
234
252
  });
235
253
  if (!key) throw new Error("model credential unavailable");
236
254
  return await this.#forward(
package/src/claim.ts CHANGED
@@ -34,6 +34,10 @@ export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
34
34
  admit: (message) => {
35
35
  if (!options.channels.has(message.channel) || !isSandboxAddress(message))
36
36
  return undefined;
37
+ // The router resolved the author; one the host's access rules serve no one by goes
38
+ // unanswered, as does a message whose author it could not resolve.
39
+ const { speaker } = message;
40
+ if (!speaker) return undefined;
37
41
  return {
38
42
  kind: "turn",
39
43
  failure: "sandbox turn failed",
@@ -45,7 +49,11 @@ export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
45
49
  const text = `${message.reference?.text ? `Quoted message (untrusted): ${message.reference.text}\n\n` : ""}${message.text}${message.attachments.length ? "\n[Attachments are not supported in this sandbox.]" : ""}`;
46
50
  const reply = await options.runtime.runTurn(
47
51
  message.channel,
48
- { id: message.authorId, name: message.authorName },
52
+ {
53
+ id: message.authorId,
54
+ name: message.authorName,
55
+ principalId: speaker.principalId,
56
+ },
49
57
  text,
50
58
  );
51
59
  await options.surfaces.sendReply(message.channel, {
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  type McpServer,
21
21
  SandboxBroker,
22
22
  type SandboxCredentialScope,
23
+ type SandboxSpeaker,
23
24
  } from "./broker.ts";
24
25
  export { SandboxChannelStore } from "./channel-store.ts";
25
26
  export {
package/src/pi-broker.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type PiTurnRequest,
18
18
  type PiTurnResponse,
19
19
  type PiWorkerConfig,
20
+ type PiWorkerReady,
20
21
  validateCompactionReport,
21
22
  validateCompactRequest,
22
23
  validateImages,
@@ -27,7 +28,7 @@ import { boundedText, isRecord } from "./protocol.ts";
27
28
  export interface PiHostContext {
28
29
  channel: ChannelKey;
29
30
  profile: string;
30
- speaker: { id: string; name: string };
31
+ speaker: { id: string; name: string; principalId?: string };
31
32
  /** The host-judged level for this turn; the worker can request less but never more. */
32
33
  thinking: PiThinkingLevel;
33
34
  signal: AbortSignal;
@@ -66,6 +67,8 @@ export interface PiCompactor {
66
67
  }
67
68
  export interface PiBrokerOptions {
68
69
  model: string;
70
+ /** Actual configured image, named in capability failures so the operator can rebuild it. */
71
+ workerImage?: string;
69
72
  /** Host-only, read before each model call for its channel and speaker; a zero-argument function still works. Return `undefined` or an empty string when the scope has no token: the call fails rather than using another credential. Never sent to the worker. */
70
73
  oauthToken: (
71
74
  scope: SandboxCredentialScope,
@@ -282,6 +285,16 @@ function abortableCompact<T>(
282
285
  export class PiSandboxBroker {
283
286
  readonly #options: PiBrokerOptions;
284
287
  #ready = false;
288
+ #capabilities: NonNullable<PiWorkerReady["capabilities"]> = {};
289
+ #requiresPrivacy = false;
290
+ #capabilityProblem(): string | undefined {
291
+ const missing = (["privateTo", "readerRecords"] as const).filter(
292
+ (name) => this.#capabilities[name] !== true,
293
+ );
294
+ return missing.length === 0
295
+ ? undefined
296
+ : `Sandbox worker image ${this.#options.workerImage ?? "(configured image)"} lacks ${missing.join(", ")}; rebuild this image with the installed core and sandbox packages`;
297
+ }
285
298
  #receivingResult = false;
286
299
  #next: ((turn: PiTurnRequest) => void) | undefined;
287
300
  #pending:
@@ -301,6 +314,13 @@ export class PiSandboxBroker {
301
314
  signal: AbortSignal,
302
315
  ): Promise<PiTurnResponse> {
303
316
  if (this.#pending) return Promise.reject(new Error("Worker is busy"));
317
+ const privatePrompt =
318
+ request.memory.length > 0 && request.memoryVisibility !== "shared";
319
+ if (this.#requiresPrivacy || privatePrompt) {
320
+ const problem = this.#capabilityProblem();
321
+ if (problem) return Promise.reject(new Error(problem));
322
+ this.#requiresPrivacy = true;
323
+ }
304
324
  return new Promise((resolve, reject) => {
305
325
  const abort = () => {
306
326
  this.#pending = undefined;
@@ -320,13 +340,52 @@ export class PiSandboxBroker {
320
340
  this.#pending = undefined;
321
341
  resolve(result);
322
342
  },
323
- reject,
343
+ reject: (error) => {
344
+ signal.removeEventListener("abort", abort);
345
+ this.#pending = undefined;
346
+ reject(error);
347
+ },
324
348
  };
325
349
  this.#next?.(request);
326
350
  });
327
351
  }
328
352
  async #worker(request: Request, path: string): Promise<Response> {
329
353
  if (path === "/worker/ready" && request.method === "POST") {
354
+ try {
355
+ const text = await boundedText(request.body, 16_384, request.signal);
356
+ const value: unknown = text.trim() ? JSON.parse(text) : {};
357
+ if (
358
+ !isRecord(value) ||
359
+ (value.privateHistory !== undefined &&
360
+ typeof value.privateHistory !== "boolean")
361
+ )
362
+ throw new Error("Invalid ready handshake");
363
+ const caps = value.capabilities;
364
+ if (
365
+ caps !== undefined &&
366
+ (!isRecord(caps) ||
367
+ [caps.privateTo, caps.readerRecords].some(
368
+ (flag) => flag !== undefined && typeof flag !== "boolean",
369
+ ))
370
+ )
371
+ throw new Error("Invalid capabilities");
372
+ this.#capabilities = isRecord(caps)
373
+ ? {
374
+ privateTo: caps.privateTo === true,
375
+ readerRecords: caps.readerRecords === true,
376
+ }
377
+ : {};
378
+ if (value.privateHistory === true) this.#requiresPrivacy = true;
379
+ if (this.#requiresPrivacy && this.#pending) {
380
+ const problem = this.#capabilityProblem();
381
+ if (problem) {
382
+ if (this.#turn) this.#turn.failed = true;
383
+ this.#pending.reject(new Error(problem));
384
+ }
385
+ }
386
+ } catch {
387
+ return new Response("Invalid ready handshake", { status: 400 });
388
+ }
330
389
  this.#ready = true;
331
390
  this.#rearmStartup();
332
391
  return new Response("Ready");
@@ -416,7 +475,12 @@ export class PiSandboxBroker {
416
475
  return new Response("Not found", { status: 404 });
417
476
  }
418
477
  #turn:
419
- | { context: PiHostContext; remaining: number; active: number }
478
+ | {
479
+ context: PiHostContext;
480
+ remaining: number;
481
+ active: number;
482
+ failed?: boolean;
483
+ }
420
484
  | undefined;
421
485
  /**
422
486
  * A restarted worker connects its MCP servers before any turn, so it needs the metadata-only
@@ -535,8 +599,20 @@ export class PiSandboxBroker {
535
599
  return this.#compaction(request, url.pathname);
536
600
  const startup = !this.#turn;
537
601
  const turn = this.#turn ?? this.#startup;
538
- if (!turn || turn.context.signal.aborted)
602
+ if (
603
+ !turn ||
604
+ turn.context.signal.aborted ||
605
+ ("failed" in turn && turn.failed)
606
+ )
539
607
  return new Response("Turn ended", { status: 410 });
608
+ if (this.#requiresPrivacy) {
609
+ const problem = this.#capabilityProblem();
610
+ if (problem) {
611
+ if (this.#turn) this.#turn.failed = true;
612
+ this.#pending?.reject(new Error(problem));
613
+ return new Response(problem, { status: 410 });
614
+ }
615
+ }
540
616
  const toolName = url.pathname.startsWith("/tools/")
541
617
  ? url.pathname.slice(7)
542
618
  : "";
@@ -581,9 +657,25 @@ export class PiSandboxBroker {
581
657
  !result ||
582
658
  typeof result.ok !== "boolean" ||
583
659
  (result.text?.length ?? 0) > 1_000_000 ||
584
- (result.error?.length ?? 0) > 10_000
660
+ (result.error?.length ?? 0) > 10_000 ||
661
+ (result.privateTo !== undefined &&
662
+ (typeof result.privateTo !== "string" ||
663
+ result.privateTo.length === 0 ||
664
+ result.privateTo.length > 256))
585
665
  )
586
666
  throw new Error("Invalid tool output");
667
+ if (result.privateTo !== undefined) {
668
+ this.#requiresPrivacy = true;
669
+ const problem = this.#capabilityProblem();
670
+ if (problem) {
671
+ // Do not release private bytes to an old worker, even when it catches tool errors.
672
+ if (this.#turn === turn) {
673
+ this.#turn.failed = true;
674
+ this.#pending?.reject(new Error(problem));
675
+ }
676
+ return new Response(problem, { status: 409 });
677
+ }
678
+ }
587
679
  if (result.image) validateImages([result.image]);
588
680
  signal.throwIfAborted();
589
681
  return Response.json(result);
@@ -23,9 +23,11 @@ export interface PiImage {
23
23
  }
24
24
  export interface PiTurnRequest {
25
25
  turnId: string;
26
- author: { id: string; name: string };
26
+ author: { id: string; name: string; principalId?: string };
27
27
  text: string;
28
28
  memory: string;
29
+ /** Defaults to private for older callers; shared prompt blocks do not taint a reader record. */
30
+ memoryVisibility?: "shared" | "private";
29
31
  images: PiImage[];
30
32
  thinking: PiThinkingLevel;
31
33
  }
@@ -68,16 +70,28 @@ export interface PiToolResponse {
68
70
  text?: string;
69
71
  error?: string;
70
72
  image?: PiImage;
73
+ /** The principal who may read this tool exchange, including its arguments. */
74
+ privateTo?: string;
71
75
  }
72
76
  export interface PiTurnContext {
73
77
  authorId: string;
74
78
  authorName: string;
79
+ /** Falls back to authorId for legacy workers whose actor id is already their principal. */
80
+ authorPrincipalId?: string;
75
81
  outbox: string;
76
82
  memory: string;
83
+ memoryVisibility?: "shared" | "private";
77
84
  }
78
85
  export interface PiMcpDiscovery {
79
86
  servers: { name: string; tools: string[] }[];
80
87
  }
88
+ /** Protocol guarantees declared by the trusted image; missing fields mean an older worker. */
89
+ export interface PiWorkerReady {
90
+ capabilities?: { privateTo?: boolean; readerRecords?: boolean };
91
+ /** Raw retained branch has private memory, even if the current turn's prompt is public. */
92
+ privateHistory?: boolean;
93
+ }
94
+
81
95
  /** What the worker learns from the host at startup; `compaction` is absent when the host has no compactor. */
82
96
  export interface PiWorkerConfig {
83
97
  compaction?: { engine: string };
@@ -248,10 +262,17 @@ export function validatePiTurn(value: unknown): asserts value is PiTurnRequest {
248
262
  typeof value.author.name !== "string" ||
249
263
  value.author.id.length > 256 ||
250
264
  value.author.name.length > 256 ||
265
+ (value.author.principalId !== undefined &&
266
+ (typeof value.author.principalId !== "string" ||
267
+ value.author.principalId.length === 0 ||
268
+ value.author.principalId.length > 256)) ||
251
269
  typeof value.text !== "string" ||
252
270
  value.text.length > 100_000 ||
253
271
  typeof value.memory !== "string" ||
254
272
  value.memory.length > 100_000 ||
273
+ (value.memoryVisibility !== undefined &&
274
+ value.memoryVisibility !== "shared" &&
275
+ value.memoryVisibility !== "private") ||
255
276
  !isPiThinkingLevel(value.thinking)
256
277
  )
257
278
  throw new Error("Invalid turn");
package/src/pi-runtime.ts CHANGED
@@ -79,6 +79,8 @@ export interface PiSandboxRuntimeOptions
79
79
  driver?: PiContainerDriver;
80
80
  mcp?: PiBrokerOptions["mcp"];
81
81
  memory: {
82
+ /** Private by default; a party-wide block must explicitly declare shared visibility. */
83
+ visibility?: "shared" | "private";
82
84
  promptBlock(channel: ChannelKey, id: string, name: string): Promise<string>;
83
85
  };
84
86
  effort: {
@@ -99,7 +101,7 @@ export interface PiSandboxTurn {
99
101
  channel: ChannelKey;
100
102
  profile: string;
101
103
  turnId: string;
102
- author: { id: string; name: string };
104
+ author: { id: string; name: string; principalId?: string };
103
105
  text: string;
104
106
  images: PiImage[];
105
107
  signal?: AbortSignal;
@@ -276,6 +278,7 @@ export class PiSandboxRuntime {
276
278
  const broker = new PiSandboxBroker({
277
279
  ...this.#options,
278
280
  model: selected.model,
281
+ workerImage: this.#options.image,
279
282
  mcp: this.#options.mcp ? { ...this.#options.mcp, servers } : undefined,
280
283
  });
281
284
  const socket = join(paths.run, PI_BROKER_SOCKET);
@@ -370,7 +373,11 @@ export class PiSandboxRuntime {
370
373
  !safeFileName(turn.turnId) ||
371
374
  turn.text.length > 100_000 ||
372
375
  turn.author.id.length > 256 ||
373
- turn.author.name.length > 256
376
+ turn.author.name.length > 256 ||
377
+ (turn.author.principalId !== undefined &&
378
+ (typeof turn.author.principalId !== "string" ||
379
+ turn.author.principalId.length === 0 ||
380
+ turn.author.principalId.length > 256))
374
381
  )
375
382
  throw new Error("Invalid turn input");
376
383
  validateImages(turn.images);
@@ -399,6 +406,7 @@ export class PiSandboxRuntime {
399
406
  author: { ...turn.author },
400
407
  text: turn.text,
401
408
  memory,
409
+ memoryVisibility: this.#options.memory.visibility ?? "private",
402
410
  images: turn.images,
403
411
  thinking,
404
412
  };
@@ -339,7 +339,7 @@ export function precheckScriptRunner(
339
339
  await grant({
340
340
  channel: schedule.channel,
341
341
  target: schedule.target,
342
- tier: schedule.createdTier,
342
+ tier: context.tier,
343
343
  })
344
344
  ).flatMap((server) => {
345
345
  const tools = server.tools.filter((tool) =>
@@ -114,7 +114,7 @@ export function resolveSandboxRuntimeOptions(
114
114
  ...options,
115
115
  context: {
116
116
  channel: "sandbox:preflight",
117
- speaker: { id: "", name: "" },
117
+ speaker: { id: "", name: "", principalId: "" },
118
118
  signal: new AbortController().signal,
119
119
  },
120
120
  });
package/src/runtime.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ChannelKey } from "pi-roundtable";
2
+ import type { SandboxSpeaker } from "./broker.ts";
2
3
  import type { SandboxReply } from "./protocol.ts";
3
4
  import {
4
5
  type ResolvedSandboxRuntimeOptions,
@@ -23,17 +24,26 @@ export class SandboxRuntime {
23
24
  this.#options = resolveSandboxRuntimeOptions(options);
24
25
  }
25
26
 
27
+ /**
28
+ * Runs one turn for `speaker`, the author the host admitted, with the principal they resolved
29
+ * to; host tools and the credential hook read it, and a turn without one is refused.
30
+ */
26
31
  async runTurn(
27
32
  channel: ChannelKey,
28
- speaker: { id: string; name: string },
33
+ speaker: SandboxSpeaker,
29
34
  text: string,
30
35
  ): Promise<SandboxReply> {
31
36
  if (this.#closed || this.#active.has(channel))
32
37
  throw new Error("sandbox is stopped or channel is busy");
38
+ if (typeof speaker.principalId !== "string" || !speaker.principalId)
39
+ throw new Error(
40
+ "a sandbox turn needs the principal its speaker was admitted as",
41
+ );
33
42
  if (
34
43
  text.length > 32_000 ||
35
44
  speaker.id.length > 256 ||
36
- speaker.name.length > 256
45
+ speaker.name.length > 256 ||
46
+ speaker.principalId.length > 256
37
47
  )
38
48
  throw new Error("sandbox message too large");
39
49
  const controller = new AbortController();
@@ -48,7 +58,11 @@ export class SandboxRuntime {
48
58
  this.#options,
49
59
  {
50
60
  channel,
51
- speaker: { ...speaker },
61
+ speaker: {
62
+ id: speaker.id,
63
+ name: speaker.name,
64
+ principalId: speaker.principalId,
65
+ },
52
66
  text,
53
67
  reset: resetGeneration > 0,
54
68
  signal: controller.signal,
@@ -2,13 +2,17 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { ChannelKey } from "pi-roundtable";
5
- import { type BrokerListener, SandboxBroker } from "./broker.ts";
5
+ import {
6
+ type BrokerListener,
7
+ SandboxBroker,
8
+ type SandboxSpeaker,
9
+ } from "./broker.ts";
6
10
  import type { SandboxReply, SandboxTurn } from "./protocol.ts";
7
11
  import type { ResolvedSandboxRuntimeOptions } from "./runtime-options.ts";
8
12
 
9
13
  interface SandboxTurnRequest {
10
14
  channel: ChannelKey;
11
- speaker: { id: string; name: string };
15
+ speaker: SandboxSpeaker;
12
16
  text: string;
13
17
  reset: boolean;
14
18
  signal: AbortSignal;
@@ -42,7 +46,8 @@ export async function runSandboxTurn(
42
46
  }).listen(socket);
43
47
  const turn: SandboxTurn = {
44
48
  text,
45
- speaker,
49
+ // The worker reads who speaks; whose principal they are stays with the host.
50
+ speaker: { id: speaker.id, name: speaker.name },
46
51
  model: options.model,
47
52
  prompt:
48
53
  options.prompt ??
@@ -74,11 +74,13 @@ export class ScopedSandboxDelegator {
74
74
  start(
75
75
  request: Omit<DelegationJob, "id" | "startedAt" | "thread">,
76
76
  ): DelegationJob {
77
+ // A guest's report runs at the member tier, as the principal the host bound them to.
77
78
  if (
78
79
  this.#closed ||
79
80
  request.target !== this.#options.target ||
80
81
  request.origin ||
81
- request.author.tier
82
+ request.author.tier !== "member" ||
83
+ !request.author.principalId
82
84
  )
83
85
  throw new DelegationError("Delegation scope refused");
84
86
  const task = request.task.trim();
@@ -104,7 +106,12 @@ export class ScopedSandboxDelegator {
104
106
  id: this.#next++,
105
107
  channel: request.channel,
106
108
  target: this.#options.target,
107
- author: { id: request.author.id, name: request.author.name },
109
+ author: {
110
+ principalId: request.author.principalId,
111
+ id: request.author.id,
112
+ name: request.author.name,
113
+ tier: "member",
114
+ },
108
115
  title: request.title.trim(),
109
116
  task,
110
117
  startedAt: new Date(),
@@ -119,7 +126,7 @@ export class ScopedSandboxDelegator {
119
126
  try {
120
127
  const report = await this.#options.run(job.task, {
121
128
  channel: job.channel,
122
- author: job.author,
129
+ author: { id: job.author.id, name: job.author.name },
123
130
  signal,
124
131
  });
125
132
  signal.throwIfAborted();
@@ -13,6 +13,8 @@ import {
13
13
  type CompactionHistory,
14
14
  CompactionTiers,
15
15
  compactionEngine,
16
+ privateCompaction,
17
+ summaryProjection,
16
18
  } from "pi-roundtable/kit";
17
19
  import type {
18
20
  PiCompactionReport,
@@ -52,6 +54,12 @@ export function compactRequest(
52
54
  .flatMap((entry) =>
53
55
  entry.type === "message" ? [entry.message] : [],
54
56
  );
57
+ privateCompaction(preparation, keptMessages);
58
+ const projectedKept = summaryProjection(keptMessages, [
59
+ ...preparation.messagesToSummarize,
60
+ ...preparation.turnPrefixMessages,
61
+ ...keptMessages,
62
+ ]);
55
63
  const { read, written, edited } = preparation.fileOps;
56
64
  const modified = new Set([...written, ...edited]);
57
65
  return {
@@ -61,7 +69,7 @@ export function compactRequest(
61
69
  isSplitTurn: preparation.isSplitTurn,
62
70
  messagesToSummarize: preparation.messagesToSummarize as PiCompactMessage[],
63
71
  turnPrefixMessages: preparation.turnPrefixMessages as PiCompactMessage[],
64
- keptMessages: keptMessages as PiCompactMessage[],
72
+ keptMessages: projectedKept as PiCompactMessage[],
65
73
  ...(preparation.previousSummary === undefined
66
74
  ? {}
67
75
  : { previousSummary: preparation.previousSummary }),
@@ -105,10 +113,20 @@ export class WorkerCompaction {
105
113
  return [
106
114
  {
107
115
  name: "host-compaction",
108
- factory: this.#tiers.wrapCompactor(handler, (bypass) => {
109
- this.#options.log("compaction skips the host compactor", bypass);
110
- void this.#report({ type: "bypass", ...bypass });
111
- }),
116
+ factory: this.#tiers.wrapCompactor(
117
+ handler,
118
+ (bypass) => {
119
+ this.#options.log("compaction skips the host compactor", bypass);
120
+ void this.#report({ type: "bypass", ...bypass });
121
+ },
122
+ (event) =>
123
+ privateCompaction(
124
+ event.preparation,
125
+ event.branchEntries.flatMap((entry) =>
126
+ entry.type === "message" ? [entry.message] : [],
127
+ ),
128
+ ),
129
+ ),
112
130
  },
113
131
  ];
114
132
  }
package/worker/pi-main.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  } from "@earendil-works/pi-coding-agent";
13
13
  import {
14
14
  activeToolsExtension,
15
+ bridgeHistoryHidesMemory,
16
+ carriesMemory,
15
17
  lastAssistant,
16
18
  mcpAdapterExtension,
17
19
  packageDir,
@@ -38,7 +40,11 @@ import {
38
40
  import { boundedText } from "../src/protocol.ts";
39
41
  import { WorkerCompaction } from "./pi-compaction.ts";
40
42
  import type { PiWorkerContent } from "./pi-content.ts";
41
- import { speakerMemoryExtension } from "./pi-memory.ts";
43
+ import {
44
+ recordSandboxMemoryTurn,
45
+ sandboxBridgeRefusal,
46
+ speakerMemoryExtension,
47
+ } from "./pi-memory.ts";
42
48
  import {
43
49
  loadSkillIndex,
44
50
  skillsExtension,
@@ -312,13 +318,23 @@ async function runTurn(
312
318
  mkdirSync(outbox, { recursive: true });
313
319
  turnContext.authorId = turn.author.id;
314
320
  turnContext.authorName = turn.author.name;
321
+ turnContext.authorPrincipalId = turn.author.principalId;
322
+ const refusal = sandboxBridgeRefusal(session, turnContext);
323
+ if (refusal) return { ok: false, error: refusal };
315
324
  turnContext.outbox = outbox;
316
325
  turnContext.memory = turn.memory;
326
+ turnContext.memoryVisibility = turn.memoryVisibility;
327
+ const markPrivateMemory = recordSandboxMemoryTurn(
328
+ session.sessionManager,
329
+ turnContext,
330
+ );
317
331
  session.setActiveToolsByName([...activeTools]);
318
332
  session.setThinkingLevel(turn.thinking);
319
333
  const timer = setTimeout(() => void session.abort(), TURN_TIMEOUT_MS);
320
334
  const toolCalls: string[] = [];
321
335
  const unsubscribe = session.subscribe((event) => {
336
+ if (event.type === "message_end" && carriesMemory(event.message))
337
+ markPrivateMemory();
322
338
  if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
323
339
  else if (event.type === "tool_execution_end" && event.isError)
324
340
  log("tool failed", {
@@ -414,6 +430,14 @@ async function main(): Promise<void> {
414
430
  const ready = await fetch("http://broker/worker/ready", {
415
431
  unix: brokerSocket,
416
432
  method: "POST",
433
+ headers: { "content-type": "application/json" },
434
+ body: JSON.stringify({
435
+ capabilities: { privateTo: true, readerRecords: true },
436
+ privateHistory: bridgeHistoryHidesMemory(
437
+ session.sessionManager.getBranch(),
438
+ undefined,
439
+ ),
440
+ }),
417
441
  signal: AbortSignal.timeout(5000),
418
442
  });
419
443
  await ready.body?.cancel();
@@ -1,6 +1,46 @@
1
- import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionFactory,
3
+ SessionManager,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ bridgeHistoryHidesMemory,
7
+ memoryProjection,
8
+ privateCompaction,
9
+ recordMemoryTurn,
10
+ } from "pi-roundtable/kit";
2
11
  import type { PiTurnContext } from "../src/pi-protocol.ts";
3
12
 
13
+ /** Bridge's retained transcript bypasses projection, including exchanges Pi already compacted. */
14
+ export function sandboxBridgeRefusal(
15
+ session: {
16
+ model: { provider: string } | undefined;
17
+ sessionManager: Pick<SessionManager, "getBranch">;
18
+ },
19
+ turn: PiTurnContext,
20
+ ): string | undefined {
21
+ if (session.model?.provider !== "claude-bridge") return undefined;
22
+ if (
23
+ !bridgeHistoryHidesMemory(
24
+ session.sessionManager.getBranch(),
25
+ turn.authorPrincipalId ?? turn.authorId,
26
+ )
27
+ )
28
+ return undefined;
29
+ return "claude-bridge cannot replay another reader's private-memory turns or private exchanges hidden from this sandbox reader; use another provider or a fresh conversation";
30
+ }
31
+
32
+ /** Record the declared privacy of the host's prompt block before any worker provider call. */
33
+ export function recordSandboxMemoryTurn(
34
+ manager: Pick<SessionManager, "appendCustomEntry">,
35
+ turn: PiTurnContext,
36
+ ): () => void {
37
+ return recordMemoryTurn(
38
+ manager,
39
+ turn.authorPrincipalId ?? turn.authorId,
40
+ turn.memoryVisibility !== "shared" && turn.memory.length > 0,
41
+ );
42
+ }
43
+
4
44
  /**
5
45
  * Adds what the channel keeps about the current speaker to this run's system prompt. The
6
46
  * prompt is rebuilt every run, so the session history never piles up stale copies.
@@ -9,6 +49,24 @@ export function speakerMemoryExtension(
9
49
  turn: () => PiTurnContext,
10
50
  ): ExtensionFactory {
11
51
  return (pi) => {
52
+ // Sandbox sessions bypass the core SessionFactory; apply its shared-history projection here.
53
+ pi.on("context_with_system", (event) => {
54
+ const current = turn();
55
+ const messages = memoryProjection(event.messages, {
56
+ shared: true,
57
+ reader: current.authorPrincipalId ?? current.authorId,
58
+ });
59
+ return messages ? { messages } : undefined;
60
+ });
61
+ // Registered before custom compactors, also protecting Pi's built-in fallback.
62
+ pi.on("session_before_compact", (event) =>
63
+ privateCompaction(
64
+ event.preparation,
65
+ event.branchEntries.flatMap((entry) =>
66
+ entry.type === "message" ? [entry.message] : [],
67
+ ),
68
+ ),
69
+ );
12
70
  pi.on("before_agent_start", (event) => {
13
71
  const { memory } = turn();
14
72
  if (!memory) return;
@@ -133,7 +133,13 @@ export function brokerToolsExtension(
133
133
  const body = JSON.parse(
134
134
  await boundedText(response.body, 32 * 1024 * 1024),
135
135
  ) as PiToolResponse;
136
- if (!body.ok) throw new Error(body.error ?? "Host tool failed");
136
+ if (
137
+ body.privateTo !== undefined &&
138
+ (typeof body.privateTo !== "string" ||
139
+ body.privateTo.length === 0 ||
140
+ body.privateTo.length > 256)
141
+ )
142
+ throw new Error("Invalid private tool owner");
137
143
  if (body.image) {
138
144
  validateImages([body.image]);
139
145
  saveToOutbox(
@@ -142,7 +148,16 @@ export function brokerToolsExtension(
142
148
  Buffer.from(body.image.data, "base64"),
143
149
  );
144
150
  }
145
- return toolText(body.text ?? "");
151
+ return {
152
+ ...toolText(
153
+ body.ok ? (body.text ?? "") : (body.error ?? "Host tool failed"),
154
+ ),
155
+ isError: !body.ok,
156
+ details:
157
+ body.privateTo === undefined
158
+ ? undefined
159
+ : { privateTo: body.privateTo },
160
+ };
146
161
  },
147
162
  });
148
163
  };