privateer-agent 0.10.0 → 0.12.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.
@@ -40,13 +40,22 @@ import { iterateSSE } from "./phala/sse.ts";
40
40
  // (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
41
41
  export type SealedProvider = "tinfoil" | "phala";
42
42
 
43
- // Sealed mode is OFF until verified end-to-end against a live relay (a real EHBP
44
- // round-trip needs the deployed relay + TINFOIL_API_KEY; see the live checklist in
45
- // docs/tee-privateer-tinfoil-ehbp.md). Off = the current plaintext path + honest
46
- // yellow badge, untouched. Flip with PRIVATEER_SEALED=1.
43
+ // Sealed mode is ON by default as of 2026-07-31, when the live checklist in
44
+ // docs/tee-verified-tinfoil-ehbp.md passed end to end against the deployed relay:
45
+ // both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
46
+ // TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
47
+ // refused rather than silently greened, and the server bills the turn.
48
+ //
49
+ // What it buys: the prompt is sealed to the enclave the client itself attested, so the
50
+ // badge is a quote WE checked rather than the account's word — green instead of
51
+ // "Trusted Execution (unconfirmed)".
52
+ //
53
+ // PRIVATEER_SEALED=0 (or =false) drops back to the cleartext `/api/agent/v1` path and
54
+ // the honest yellow badge. Note that is a real downgrade for `phala/*`, which is
55
+ // sealed-only and simply disappears from the catalog (see isServableAccountModel).
47
56
  export function sealedEnabled(): boolean {
48
57
  const v = process.env.PRIVATEER_SEALED;
49
- return v === "1" || v === "true";
58
+ return !(v === "0" || v === "false");
50
59
  }
51
60
 
52
61
  // The sealed provider a model id routes through, or null if it isn't a sealed
@@ -151,7 +160,12 @@ export function buildForward(
151
160
  // Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
152
161
  }
153
162
  const headers: Record<string, string> = {
154
- "Content-Type": "application/json",
163
+ // MUST carry the charset. EHBP seals the body but headers travel in cleartext
164
+ // (tinfoil/dist/encrypted-body-fetch.js: "EHBP only seals the body"), so this
165
+ // Content-Type is what the enclave's router actually validates — and it rejects a
166
+ // bare `application/json` with "Unsupported Media Type: Only 'application/json' is
167
+ // allowed" (verified live 2026-07-31: bare → 400, any `charset=` variant → 200).
168
+ "Content-Type": "application/json; charset=utf-8",
155
169
  "X-Sealed-Model": sealedModel,
156
170
  };
157
171
  if (authHeader) headers.Authorization = authHeader;
@@ -165,6 +179,7 @@ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
165
179
 
166
180
  let shimBase: string | null = null;
167
181
  let shimStarting: Promise<string> | null = null;
182
+ let shimServer: http.Server | null = null;
168
183
 
169
184
  // The shim's base URL once listening, else null. account.ts reads this to decide
170
185
  // whether a sealed model can point its baseUrl at the shim yet.
@@ -179,6 +194,19 @@ export function ensureSealedShim(): Promise<string> {
179
194
  return shimStarting;
180
195
  }
181
196
 
197
+ // Close the shim and forget it, so a later ensureSealedShim() starts a fresh one.
198
+ // The listener is `unref`'d and never blocks exit, so this is not needed for
199
+ // shutdown — it exists so a caller that stops sealing (or a test) can drop the
200
+ // socket deterministically rather than leaving a port open for the process lifetime.
201
+ export function stopSealedShim(): Promise<void> {
202
+ const server = shimServer;
203
+ shimServer = null;
204
+ shimBase = null;
205
+ shimStarting = null;
206
+ if (!server) return Promise.resolve();
207
+ return new Promise((resolve) => server.close(() => resolve()));
208
+ }
209
+
182
210
  function startShim(): Promise<string> {
183
211
  return new Promise((resolve, reject) => {
184
212
  const server = http.createServer((req, res) => {
@@ -192,6 +220,7 @@ function startShim(): Promise<string> {
192
220
  server.listen(0, "127.0.0.1", () => {
193
221
  const addr = server.address();
194
222
  if (addr && typeof addr === "object") {
223
+ shimServer = server;
195
224
  shimBase = `http://127.0.0.1:${addr.port}`;
196
225
  resolve(shimBase);
197
226
  } else {
@@ -8,11 +8,11 @@
8
8
  * of these and route the extensions_* relay frames through it.
9
9
  *
10
10
  * Only the user's OWN packages surface here. The Privateer moat (privateer-*,
11
- * pi-privacy, rpiv-web-tools, …) is installed by bin/privateer-tui as shim .ts
12
- * files in the agent dir's extensions/ folder — auto-discovered, NOT recorded in
13
- * settings.json "packages". listConfiguredPackages() reads only "packages", so the
14
- * moat is naturally excluded. We keep a RESERVED name guard as defence in depth in
15
- * case a user ever hand-adds one of our package names.
11
+ * rpiv-web-tools, …) never appears in settings.json "packages": the launcher passes it to
12
+ * Pi as `-e` arguments, and the in-process entries build it from factories. So
13
+ * listConfiguredPackages(), which reads only "packages", excludes it naturally. The
14
+ * RESERVED guard is defence in depth for a user who hand-adds one of our names — which
15
+ * would load a second copy of an extension the moat already supplies.
16
16
  *
17
17
  * Framework-agnostic: nothing here imports React or the relay. The caller owns the
18
18
  * frame plumbing and hands us a SettingsManager (the REPL reuses the session's;
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
22
22
  import type { ProgressEvent, SettingsManager } from "@earendil-works/pi-coding-agent";
23
+ import { reservedNames } from "../config/moatManifest.ts";
23
24
 
24
25
  // One installed extension as surfaced to the app. NON-PII: a package source
25
26
  // (npm:/git: spec) plus its scope — no cwd, no absolute paths beyond what Pi
@@ -49,23 +50,12 @@ export interface ExtensionsControl {
49
50
  // Package names we never manage from the app: the Privateer moat + adopted packs
50
51
  // installed as shims by the launcher. A guard only — listConfiguredPackages()
51
52
  // already omits them since they aren't settings "packages".
52
- const RESERVED = new Set([
53
- "privateer-brand",
54
- "privateer-context",
55
- "privateer-gate",
56
- "privateer-account",
57
- "privateer-posture",
58
- "privateer-tools",
59
- "privateer-privacy",
60
- "pi-privacy",
61
- "pi-web-access",
62
- "rpiv-web-tools",
63
- "@juicesharp/rpiv-web-tools",
64
- "pi-mcp-adapter",
65
- "pi-hypa",
66
- "@hypabolic/pi-hypa",
67
- "pi-subagents",
68
- ]);
53
+ //
54
+ // Derived from the shipping manifest rather than hand-listed. The hand-written version
55
+ // had drifted: privateer-models and privateer-connect were shimmed by the launcher but
56
+ // missing here, so the app would have offered to install a user package under a name the
57
+ // launcher overwrites on every launch. See src/config/moatManifest.ts.
58
+ const RESERVED = new Set(reservedNames());
69
59
 
70
60
  // The bare package name inside a source spec, for the RESERVED check. Strips the
71
61
  // npm:/git: scheme and any @version / #ref suffix; leaves scoped names intact.
@@ -18,19 +18,13 @@ import {
18
18
  import { agentDir } from "../config/paths.ts";
19
19
  import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
- import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
- import { makePiPrivacyExtension } from "pi-privacy";
23
- import {
24
- makeAccountProvider,
25
- privateerChannel,
26
- rememberAccountCredential,
27
- dropPersistedAccountCredential,
28
- } from "../providers/account.ts";
21
+ import { isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
+ import { moatResourceOptions } from "../config/moat.ts";
23
+ import { rememberAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
29
24
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
30
25
  import { RemoteBridge } from "./remoteBridge.ts";
31
- import { makeRelayFileTools } from "../tools/relayFileTools.ts";
32
26
  import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
33
- import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
27
+ import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
34
28
  import { createUIContext } from "../ext/headlessUi.ts";
35
29
  import { noQuarterActive } from "../permissions/noQuarter.ts";
36
30
 
@@ -207,24 +201,21 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
207
201
  onRemoteBlocked: (toolName) => bridge.sendNotice(`${toolName} is disabled while driving remotely — its prompts can't reach the app.`),
208
202
  };
209
203
 
204
+ // relayFiles binds send_file_to_client / save_attachment to THIS session's bridge — the
205
+ // one whose relay the app is attached to. The shipped gate extension is discovered into
206
+ // this session too but stands its own pair down inside the daemon, so these are the ones
207
+ // the model gets (see tools/relayFileTools.ts). Media generation is on for a live spawn:
208
+ // it is driven from the app, so "make me a video of X" is one of the things it is FOR —
209
+ // and the gate still routes each call to the phone for approval like any other write.
210
210
  const services = await createAgentSessionServices({
211
211
  cwd,
212
212
  agentDir: agentDir(),
213
213
  resourceLoaderOptions: {
214
- extensionFactories: [
215
- makePermissionGate(gate),
216
- // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
217
- // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
218
- makePiPrivacyExtension({
219
- privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
220
- }),
221
- makeAccountProvider(),
222
- // send_file_to_client / save_attachment bound to THIS session's bridge — the one
223
- // whose relay the app is attached to. The shipped gate extension is discovered
224
- // into this session too but stands its own pair down inside the daemon, so these
225
- // are the ones the model gets (see tools/relayFileTools.ts).
226
- makeRelayFileTools(bridge, attachments),
227
- ] as any,
214
+ ...((await moatResourceOptions({
215
+ kind: "live-task",
216
+ gate,
217
+ relayFiles: { bridge, attachments },
218
+ })) as any),
228
219
  },
229
220
  });
230
221
  servicesRef = services as any;
@@ -118,6 +118,11 @@ export interface RelayCallbacks {
118
118
  onNoQuarter?: (on: boolean) => void;
119
119
  // A controller attached — push a transcript snapshot so it can catch up.
120
120
  onControllerAttached: () => void;
121
+ // The last controller went away (app closed / socket reaped). The terminal keeps
122
+ // running; the owner should stop assuming anything it sends up is being read, and
123
+ // deliver finished work durably instead (the cloud outbox → the app's Inbox).
124
+ // Optional so callbacks that predate the frame keep compiling.
125
+ onControllerDetached?: () => void;
121
126
  // The app ran a slash command from its composer (e.g. "/model provider/id").
122
127
  // Routed to the same command dispatcher the local REPL uses. Optional so
123
128
  // callbacks that predate the app command UI keep compiling.
@@ -353,6 +358,11 @@ export class RelayClient {
353
358
  private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
354
359
  private lastInboundAt = 0;
355
360
  private connectedAt = 0;
361
+ // Is somebody actually on the other end? An open socket is NOT the same thing —
362
+ // the server holds our frames' route open and simply drops what it forwards when
363
+ // no controller is attached, so without this an unwatched turn writes its answer
364
+ // into a socket nobody is reading. See hasController() and the `handle` note.
365
+ private controllerHere = false;
356
366
  // Current backoff delay for the next unqualified scheduleReconnect(); reset on 'open'.
357
367
  private reconnectDelay = RECONNECT_MS;
358
368
  // Last refusal reason reported, so a 4xx is logged once instead of on every retry.
@@ -507,6 +517,9 @@ export class RelayClient {
507
517
  this.stopHeartbeat();
508
518
  if (this.ws === ws) this.ws = null;
509
519
  this.connectedAt = 0;
520
+ // A dead socket means no controller is reachable, whatever the last
521
+ // attach/detach frame said. Re-learned on the next attach or inbound frame.
522
+ this.controllerHere = false;
510
523
  this.cb.onDisconnected?.();
511
524
  if (!this.closed) {
512
525
  this.cb.onStatus?.(
@@ -660,6 +673,12 @@ export class RelayClient {
660
673
  return;
661
674
  }
662
675
  this.debug(`recv ${frame.type}`);
676
+ // Presence, learned from the traffic itself. The server forwards a frame to us
677
+ // only when a controller sent it (or when it announces one attaching), so ANY
678
+ // inbound frame is proof somebody is on the other end — which matters because
679
+ // `controller_attached` is missed by a terminal whose own socket reconnected
680
+ // mid-session. Cleared by `controller_detached` and by a dead socket.
681
+ if (frame.type !== "controller_detached") this.controllerHere = true;
663
682
  switch (frame.type) {
664
683
  case "prompt":
665
684
  // Forward even an empty/whitespace prompt: a file-only send carries no text,
@@ -685,6 +704,16 @@ export class RelayClient {
685
704
  case "controller_attached":
686
705
  this.cb.onControllerAttached();
687
706
  break;
707
+ // The app's socket went away (closed the app, lost the network, backgrounded
708
+ // long enough for the server's heartbeat to reap it). The relay stays up — the
709
+ // terminal is still reachable — but anything we send now lands nowhere, so the
710
+ // owner delivers finished work through the outbox instead. Published only when
711
+ // the LAST controller left (CAS-guarded server-side), so a take-over doesn't
712
+ // masquerade as "nobody is watching".
713
+ case "controller_detached":
714
+ this.controllerHere = false;
715
+ this.cb.onControllerDetached?.();
716
+ break;
688
717
  case "command":
689
718
  if (typeof frame.text === "string") this.cb.onCommand?.(frame.text);
690
719
  break;
@@ -876,6 +905,16 @@ export class RelayClient {
876
905
  return this.ws?.readyState === WebSocket.OPEN;
877
906
  }
878
907
 
908
+ // Is the app actually on the other end right now? Requires an open socket AND a
909
+ // controller known to be attached — learned from `controller_attached`, from any
910
+ // frame a controller sent us, and un-learned by `controller_detached` or a dropped
911
+ // socket. Conservative in the useful direction: it only reads true when we have
912
+ // positive evidence someone is there, so "deliver it durably instead" is the
913
+ // default for a turn whose audience we can't account for.
914
+ hasController(): boolean {
915
+ return this.isConnected() && this.controllerHere;
916
+ }
917
+
879
918
  // Connection health, for `privateer harbor status` / the IPC status reply. `quietSec`
880
919
  // is how long since the server last said anything: a connected socket that has been
881
920
  // quiet for longer than the server's 25s ping cadence is the shape of the half-open
@@ -996,13 +1035,14 @@ export class RelayClient {
996
1035
  // stance (the server/controller learns as little as possible about the machine).
997
1036
  // Empty/absent fields are omitted so the app renders less rather than blank.
998
1037
  //
999
- // `cwd` is the one scoped exception, and ONLY a harbor-spawned live session passes
1000
- // it (see liveTaskSession): the driver chose that directory in the spawn form — or,
1001
- // having left it blank, needs to see which one the harbor picked — because it is
1002
- // where everything that session reads, writes and `@`-mentions lives, and unlike an
1003
- // interactive terminal there is no human sitting in it to already know. It is
1004
- // home-collapsed (`~/…`) on the way out, so the banner reads like the CLI's own and
1005
- // the OS username still never crosses the relay.
1038
+ // `cwd` is the one scoped exception, sent by BOTH session kinds: it is where
1039
+ // everything the agent reads, writes and `@`-mentions lives, so a driver who can't
1040
+ // see it is guessing at the blast radius of every prompt they send. (It used to be
1041
+ // harbor-spawned sessions only — on the theory that a human sits in an interactive
1042
+ // terminal and already knows the folder. They don't when they're driving it from a
1043
+ // phone, which is the entire point of this transport.) It is home-collapsed (`~/…`)
1044
+ // on the way out, so the banner reads like the CLI's own and the OS username still
1045
+ // never crosses the relay.
1006
1046
  sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
1007
1047
  const frame: Record<string, unknown> = { type: "context" };
1008
1048
  if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
@@ -16,11 +16,20 @@ import type { PermissionRequest } from "../permissions/gate.ts";
16
16
  import type { AskOutcome } from "../permissions/modeGate.ts";
17
17
  import type { RelayCallbacks } from "./relayClient.ts";
18
18
 
19
+ // How much of a driven turn's reply we hold for possible outbox delivery. The
20
+ // sealed item is capped at 45k plaintext anyway; this just stops a pathological
21
+ // turn from growing the buffer without bound.
22
+ const MAX_TURN_CAPTURE = 60_000;
23
+
19
24
  // The outbound surface the bridge needs; RelayClient implements all of it.
20
25
  export interface RelayLike {
21
26
  requestApproval(id: string, req: PermissionRequest): void;
22
27
  sendEvent(ev: EngineEvent): void;
23
28
  isConnected(): boolean;
29
+ // Is a controller actually attached (not merely "our socket is up")? Optional:
30
+ // a relay that can't tell is treated as attached, so an unknown audience never
31
+ // turns into a duplicate of something the app already displayed.
32
+ hasController?(): boolean;
24
33
  sendNoQuarter(on: boolean): void;
25
34
  sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
26
35
  sendNotice(text: string): void;
@@ -99,6 +108,15 @@ export interface RemoteBridgeConfig {
99
108
  onFilesSearch?: (id: string, query: string) => void;
100
109
  // A controller (re)attached — the owner should push a transcript snapshot.
101
110
  onControllerAttached?: () => void;
111
+ // The last controller went away. Informational for the owner (the bridge already
112
+ // fails pending approvals closed); a driven turn in flight keeps running.
113
+ onControllerDetached?: () => void;
114
+ // A driven turn finished with NOBODY on the other end — the app was closed, killed,
115
+ // or its socket was reaped while the agent worked. Everything the turn streamed up
116
+ // was dropped by the relay, so the owner should deliver this durably instead (seal
117
+ // it to the account outbox → the app's Inbox). Called once per unwatched turn, after
118
+ // the turn settles; `prompt` is what was asked, `content` the reply as streamed.
119
+ onUnwatchedResult?: (result: { prompt: string; content: string }) => void;
102
120
  onStatus?: (text: string) => void;
103
121
  // A file finished transferring down from the app. The owner registers it (e.g. into
104
122
  // an AttachmentStore) so the save_attachment tool can persist it.
@@ -113,6 +131,17 @@ export class RemoteBridge {
113
131
  private readonly pendingSelects = new Map<string, (v: string | null) => void>();
114
132
  private readonly pendingInputs = new Map<string, (v: string | null) => void>();
115
133
  private pendingAttachments: RemoteAttachment[] = [];
134
+ // The driven turn in flight, kept only so it can be delivered to the outbox if it
135
+ // turns out nobody was watching (see settleTurn). Bounded: the outbox truncates at
136
+ // 45k anyway, and this must not grow with a runaway turn.
137
+ //
138
+ // `turnDriven` is deliberately NOT `remote`: a mid-turn disconnect clears `remote`
139
+ // (so the gate stops waiting on a controller that's gone) and that is exactly the
140
+ // case this feature exists for — the turn was still driven by the app, and its
141
+ // answer still has to reach the account. Only settleTurn clears it.
142
+ private turnDriven = false;
143
+ private turnPrompt = "";
144
+ private turnText = "";
116
145
 
117
146
  constructor(private readonly cfg: RemoteBridgeConfig) {}
118
147
 
@@ -128,6 +157,9 @@ export class RemoteBridge {
128
157
  readonly callbacks: Required<RelayCallbacks> = {
129
158
  onPrompt: (text) => {
130
159
  this.remote = true; // a remote turn is now in flight → gate relays each action
160
+ this.turnDriven = true;
161
+ this.turnPrompt = text;
162
+ this.turnText = "";
131
163
  const attachments = this.pendingAttachments;
132
164
  this.pendingAttachments = [];
133
165
  this.cfg.onPrompt(text, attachments);
@@ -195,6 +227,16 @@ export class RemoteBridge {
195
227
  this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
196
228
  },
197
229
  onControllerAttached: () => this.cfg.onControllerAttached?.(),
230
+ // The app left while we're still running. Same posture as a dropped socket: stop
231
+ // treating the turn as remote (the gate must not wait on a controller that isn't
232
+ // there) and fail every pending approval closed. The turn itself keeps going —
233
+ // and settleTurn will deliver its answer to the outbox, since `turnDriven` (unlike
234
+ // `remote`) survives the departure.
235
+ onControllerDetached: () => {
236
+ this.remote = false;
237
+ this.rejectAllPending();
238
+ this.cfg.onControllerDetached?.();
239
+ },
198
240
  onAttachment: (file) => {
199
241
  this.pendingAttachments.push(file);
200
242
  this.cfg.onAttachment?.(file);
@@ -307,13 +349,36 @@ export class RemoteBridge {
307
349
 
308
350
  // Mark the end of a turn so the next (possibly local) turn isn't treated as
309
351
  // remote. Call after each driven turn completes.
352
+ //
353
+ // Also the one moment we can tell whether the turn had an audience. If the app
354
+ // drove it and is now gone, everything the turn streamed up was dropped by the
355
+ // relay — so hand the answer to the owner for durable delivery rather than letting
356
+ // a completed piece of work evaporate because someone closed their phone.
310
357
  settleTurn(): void {
358
+ const driven = this.turnDriven;
359
+ const prompt = this.turnPrompt;
360
+ const content = this.turnText.trim();
311
361
  this.remote = false;
362
+ this.turnDriven = false;
363
+ this.turnPrompt = "";
364
+ this.turnText = "";
365
+ if (!driven || !content) return;
366
+ // Unknown (a relay that can't report presence) counts as watched: better to skip
367
+ // delivery than to duplicate something the app already showed in its feed.
368
+ const watched = this.relay?.hasController ? this.relay.hasController() : !!this.relay?.isConnected();
369
+ if (watched) return;
370
+ this.cfg.onUnwatchedResult?.({ prompt, content });
312
371
  }
313
372
 
314
373
  // Forward an EngineEvent up to the app. Safe to call for every event of every
315
374
  // turn (local included) — the relay only sends when a socket is open.
316
375
  forwardEvent(ev: EngineEvent): void {
376
+ // Keep the driven turn's reply as it streams, in case settleTurn finds nobody
377
+ // was there to read it. Text only: the outbox item is the answer, not a
378
+ // transcript of every tool call.
379
+ if (this.turnDriven && ev.type === "text" && this.turnText.length < MAX_TURN_CAPTURE) {
380
+ this.turnText += String(ev.text ?? "");
381
+ }
317
382
  this.relay?.sendEvent(ev);
318
383
  }
319
384