pi-roundtable-sandbox 0.8.0 → 0.9.0
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 +7 -0
- package/README.md +65 -5
- package/package.json +3 -3
- package/src/broker.ts +22 -4
- package/src/claim.ts +9 -1
- package/src/index.ts +1 -0
- package/src/pi-broker.ts +97 -5
- package/src/pi-protocol.ts +22 -1
- package/src/pi-runtime.ts +10 -2
- package/src/precheck-runner.ts +1 -1
- package/src/runtime-options.ts +1 -1
- package/src/runtime.ts +17 -3
- package/src/sandbox-turn.ts +8 -3
- package/src/scoped-delegator.ts +10 -3
- package/worker/pi-compaction.ts +23 -5
- package/worker/pi-main.ts +25 -1
- package/worker/pi-memory.ts +59 -1
- package/worker/pi-tools.ts +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.9.0
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- `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.
|
|
9
|
+
- `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.
|
|
10
|
+
- `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`.
|
|
11
|
+
|
|
5
12
|
## 0.8.0
|
|
6
13
|
|
|
7
14
|
- `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
|
|
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)` |
|
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
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.
|
|
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.
|
|
55
|
+
"pi-roundtable": "0.9.0",
|
|
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:
|
|
31
|
+
speaker: SandboxSpeaker;
|
|
22
32
|
signal: AbortSignal;
|
|
23
33
|
}
|
|
24
|
-
/**
|
|
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: {
|
|
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
|
-
{
|
|
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
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
|
-
| {
|
|
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 (
|
|
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);
|
package/src/pi-protocol.ts
CHANGED
|
@@ -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
|
};
|
package/src/precheck-runner.ts
CHANGED
|
@@ -339,7 +339,7 @@ export function precheckScriptRunner(
|
|
|
339
339
|
await grant({
|
|
340
340
|
channel: schedule.channel,
|
|
341
341
|
target: schedule.target,
|
|
342
|
-
tier:
|
|
342
|
+
tier: context.tier,
|
|
343
343
|
})
|
|
344
344
|
).flatMap((server) => {
|
|
345
345
|
const tools = server.tools.filter((tool) =>
|
package/src/runtime-options.ts
CHANGED
|
@@ -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:
|
|
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: {
|
|
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,
|
package/src/sandbox-turn.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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 ??
|
package/src/scoped-delegator.ts
CHANGED
|
@@ -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: {
|
|
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();
|
package/worker/pi-compaction.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
109
|
-
|
|
110
|
-
|
|
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 {
|
|
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();
|
package/worker/pi-memory.ts
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
|
-
import type {
|
|
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;
|
package/worker/pi-tools.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
};
|