privateer-agent 0.11.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.
package/src/acp/run.ts CHANGED
@@ -43,12 +43,17 @@
43
43
 
44
44
  import "../boot.ts"; // env + attestation dispatcher, before any Pi import
45
45
  import { WEB_TOOL_NAMES } from "../tools/web.ts";
46
+ import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
46
47
 
47
48
  // Read-only by default, exactly as the channels runtime and the routines harbor do:
48
49
  // a turn nobody is watching must not be able to mutate the filesystem or shell out
49
50
  // until a human widens it in config.
50
51
  const SAFE_TOOLS = ["read", "grep", "find", "ls"];
51
52
  const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
53
+ // Media generation stays out of the default set (it spends the account's credit —
54
+ // see the harbor's note); an ACP host that wants it names it in `acp.tools`. Listed
55
+ // here so it can be stripped back out when generation is switched off.
56
+ const MEDIA_GEN_TOOLS: string[] = [...MEDIA_TOOL_NAMES];
52
57
 
53
58
  type Posture = "readonly" | "approve" | "auto";
54
59
  const POSTURES: Posture[] = ["readonly", "approve", "auto"];
@@ -80,14 +85,12 @@ export async function runAcp(): Promise<void> {
80
85
  "@earendil-works/pi-coding-agent"
81
86
  );
82
87
  const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
83
- const { makePermissionGate } = await import("../ext/permissionGate.ts");
84
88
  type GateController = import("../ext/permissionGate.ts").GateController;
85
- const { makePiPrivacyExtension } = await import("pi-privacy");
86
- const { makeAccountProvider, privateerChannel, rememberAccountCredential, dropPersistedAccountCredential } =
89
+ const { moatResourceOptions } = await import("../config/moat.ts");
90
+ const { privateerChannel, rememberAccountCredential, dropPersistedAccountCredential } =
87
91
  await import("../providers/account.ts");
88
92
  const { hasCredentials, acquireAccountCredential, revokeAccountSession } = await import("../auth/privateer.ts");
89
- const { makeWebTools } = await import("../tools/web.ts");
90
- const { webEnabled } = await import("../config/hosted.ts");
93
+ const { webEnabled, mediaEnabled } = await import("../config/hosted.ts");
91
94
  const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
92
95
  const { agentDir, configPath } = await import("../config/paths.ts");
93
96
  const { PrivateerAcpAgent, askOverAcp } = await import("./server.ts");
@@ -103,8 +106,9 @@ export async function runAcp(): Promise<void> {
103
106
  const block = cfg.acp ?? {};
104
107
  const defaultModel: string = resolveDefaultModel({ explicit: block.model ?? cfg.defaultModel });
105
108
  const web = webEnabled();
109
+ const media = mediaEnabled();
106
110
  const tools: string[] = Array.isArray(block.tools) && block.tools.length
107
- ? (web ? block.tools : block.tools.filter((t: string) => !WEB_TOOLS.includes(t)))
111
+ ? block.tools.filter((t: string) => (web || !WEB_TOOLS.includes(t)) && (media || !MEDIA_GEN_TOOLS.includes(t)))
108
112
  : (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
109
113
  const posture: Posture = normalizePosture(block.posture) ?? "approve";
110
114
  const baseCwd: string = block.cwd ?? process.cwd();
@@ -146,20 +150,13 @@ export async function runAcp(): Promise<void> {
146
150
  //
147
151
  // Verified live: without this, `bash` was denied and remoteAsk never fired.
148
152
  //
149
- // So: discover nothing, and load exactly the moat we want as in-code
150
- // factories below. Same shape as bin/privateer-subagent.mjs's
151
- // `--no-extensions -e …`, and for the same reason. This disables discovered
152
- // MCP too; an ACP host supplies its own MCP servers in session/new, which is
153
- // the more correct source anyway.
153
+ // The other entries now solve this with moatResourceOptions()'s extensionsOverride,
154
+ // which drops OUR shims and keeps the user's. ACP stays on the blunter setting on
155
+ // purpose: it also disables discovered MCP, and an ACP host supplies its own servers
156
+ // in session/new, which is the more correct source here. So this list is the only way
157
+ // a host (Zed, Buzz) gets the moat, the account provider, or media at all.
154
158
  noExtensions: true,
155
- extensionFactories: [
156
- makePermissionGate(gate),
157
- makePiPrivacyExtension({
158
- privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
159
- }),
160
- makeAccountProvider(),
161
- ...(webEnabled() ? [makeWebTools()] : []),
162
- ] as any,
159
+ ...((await moatResourceOptions({ kind: "acp", gate })) as any),
163
160
  },
164
161
  });
165
162
 
@@ -49,6 +49,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
49
49
  // Names only — the factory itself is imported lazily in main() like every other
50
50
  // module here. Safe statically: this evaluates after boot.ts and pulls in no Pi.
51
51
  import { WEB_TOOL_NAMES } from "../tools/web.ts";
52
+ import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
52
53
 
53
54
  // Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
54
55
  // a turn nobody is watching can't mutate the filesystem or shell out. Now that the
@@ -62,6 +63,14 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
62
63
  // capability that still sends a query off the machine.
63
64
  const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
64
65
 
66
+ // Media GENERATION never joins the default set, even with the switch on: a chat
67
+ // message is an untrusted prompt, and these spend the account's credit. A channel that
68
+ // should be able to make pictures says so in `channels.tools`. Listed here only so
69
+ // they can be stripped back out when generation is switched off, the same way web
70
+ // tools are — a config naming a tool that no longer registers is a confusing failure.
71
+ // (video_compose is local ffmpeg work and needs no such stripping.)
72
+ const MEDIA_GEN_TOOLS: string[] = [...MEDIA_TOOL_NAMES];
73
+
65
74
  // A channel's posture governs how an ADMIN's risky actions are handled (members are
66
75
  // always capped to read-only — see effectivePosture). Config + restart only; there
67
76
  // is deliberately no in-chat toggle.
@@ -106,13 +115,6 @@ function normalizePosture(v: unknown): Posture | undefined {
106
115
  }
107
116
 
108
117
  async function main() {
109
- // Mark the process before anything can create a session. This runner builds its
110
- // session with its own makePermissionGate (the in-chat approver below), but the
111
- // shipped TUI gate extension is still auto-discovered from the shared agent dir —
112
- // and with no UI bound here its local asker fails CLOSED, denying every gated tool
113
- // before our approver is ever consulted. See config/inlineMoat.ts.
114
- const { markInlineMoat } = await import("../config/inlineMoat.ts");
115
- markInlineMoat();
116
118
  const { readFileSync, appendFileSync } = await import("node:fs");
117
119
  const { join } = await import("node:path");
118
120
  const {
@@ -121,13 +123,9 @@ async function main() {
121
123
  SessionManager,
122
124
  } = await import("@earendil-works/pi-coding-agent");
123
125
  const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
124
- const { makePermissionGate } = await import("../ext/permissionGate.ts");
125
126
  type GateController = import("../ext/permissionGate.ts").GateController;
126
- const { makePiPrivacyExtension } = await import("pi-privacy");
127
- const { makeAccountProvider, privateerChannel } = await import("../providers/account.ts");
128
- const { hasCredentials } = await import("../auth/privateer.ts");
129
- const { makeWebTools } = await import("../tools/web.ts");
130
- const { webEnabled } = await import("../config/hosted.ts");
127
+ const { moatResourceOptions } = await import("../config/moat.ts");
128
+ const { webEnabled, mediaEnabled } = await import("../config/hosted.ts");
131
129
  const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
132
130
  const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
133
131
  const { redactText, collectSecrets } = await import("../util/redact.ts");
@@ -153,8 +151,9 @@ async function main() {
153
151
  const ch = cfg.channels ?? {};
154
152
  const defaultModel: string = resolveDefaultModel({ explicit: ch.model ?? cfg.defaultModel });
155
153
  const web = webEnabled();
154
+ const media = mediaEnabled();
156
155
  const defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length
157
- ? (web ? ch.tools : ch.tools.filter((t: string) => !WEB_TOOLS.includes(t)))
156
+ ? ch.tools.filter((t: string) => (web || !WEB_TOOLS.includes(t)) && (media || !MEDIA_GEN_TOOLS.includes(t)))
158
157
  : (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
159
158
  const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
160
159
  const cwd: string = ch.cwd ?? process.cwd();
@@ -203,23 +202,15 @@ async function main() {
203
202
  return store.bridge.requestApproval(store.chatId, req, signal);
204
203
  },
205
204
  };
205
+ // Web and media are shaped by config/moat.ts: generation only when the agent is allowed
206
+ // it, composition always (local ffmpeg work). Neither joins the default tool list — a
207
+ // channel has to name them in its `tools`, because a message from a chat app is exactly
208
+ // the kind of untrusted prompt that shouldn't be able to bill for video.
206
209
  const services = await createAgentSessionServices({
207
210
  cwd,
208
211
  agentDir: agentDir(),
209
212
  resourceLoaderOptions: {
210
- extensionFactories: [
211
- makePermissionGate(gate),
212
- // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
213
- // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
214
- makePiPrivacyExtension({
215
- privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
216
- }),
217
- makeAccountProvider(),
218
- // Web access (src/tools/web.ts) — same wiring as the harbor: registered here
219
- // because this path builds its session explicitly, and omitted entirely when
220
- // the agent isn't allowed the web.
221
- ...(webEnabled() ? [makeWebTools()] : []),
222
- ] as any,
213
+ ...((await moatResourceOptions({ kind: "channels", gate })) as any),
223
214
  },
224
215
  });
225
216
 
package/src/cli/chat.ts CHANGED
@@ -29,8 +29,9 @@ async function main() {
29
29
  SessionManager,
30
30
  } = await import("@earendil-works/pi-coding-agent");
31
31
  const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
32
- const { makePermissionGate, isRemoteUnsafeTool } = await import("../ext/permissionGate.ts");
33
- const { makePiPrivacyExtension, verifyModelPosture, TIERS } = await import("pi-privacy");
32
+ const { isRemoteUnsafeTool } = await import("../ext/permissionGate.ts");
33
+ const { moatResourceOptions } = await import("../config/moat.ts");
34
+ const { verifyModelPosture, TIERS } = await import("pi-privacy");
34
35
  const { agentDir } = await import("../config/paths.ts");
35
36
  const { RemoteBridge } = await import("../remote/remoteBridge.ts");
36
37
  const { startParentApprovalRelay } = await import("../remote/subagentRelay.ts");
@@ -41,14 +42,14 @@ async function main() {
41
42
  const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
42
43
  const priv = await import("../auth/privateer.ts");
43
44
  const {
44
- makeAccountProvider,
45
45
  accountPosture,
46
- privateerChannel,
47
46
  rememberAccountCredential,
48
47
  dropPersistedAccountCredential,
49
48
  } = await import("../providers/account.ts");
50
49
  const { agentVersion } = await import("../config/version.ts");
51
- const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
50
+ const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
51
+ const { postOutbox } = await import("../outbox/cloudOutbox.ts");
52
+ const { addPendingCloud } = await import("../routines/store.ts");
52
53
 
53
54
  // resolveDefaultModel() already honours PRIVATEER_MODEL first, then the account
54
55
  // default when signed in, then a BYO key — one source of truth (defaultModel.ts).
@@ -170,6 +171,12 @@ async function main() {
170
171
  relay?.sendCommands(availableCommands());
171
172
  },
172
173
  onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
174
+ // The driver asked for something, then closed the app (or lost the network) before
175
+ // the answer came back. The relay dropped every frame of it, so seal the reply to
176
+ // the account outbox instead: it lands in the app's Inbox on next open, sealed to
177
+ // a key this terminal can't read back. Same durability contract as a routine's
178
+ // `cloud` delivery — including the on-disk queue when the post can't go out now.
179
+ onUnwatchedResult: ({ prompt, content }) => void deliverUnwatchedTurn(prompt, content),
173
180
  // The app composer is autocompleting an `@file` mention — list the cwd entries
174
181
  // matching the query and reply. Read-only + cwd-constrained (searchFiles never
175
182
  // escapes the subtree); resolution of the picked path happens on the prompt turn.
@@ -275,6 +282,43 @@ async function main() {
275
282
  // Serialize turns so a remote prompt and a locally-typed one can't overlap.
276
283
  // `echo` prints the "⟿ [app] …" line; the caller suppresses it when it already
277
284
  // echoed (a fall-through command from onCommand).
285
+ // Deliver a finished but UNWATCHED driven turn to the account's Inbox.
286
+ //
287
+ // The app drove this turn and then went away — closed, killed, backgrounded past the
288
+ // relay's heartbeat. Everything the agent streamed up was dropped by the server (it
289
+ // forwards only to an attached controller), so without this the work is simply gone:
290
+ // the person comes back to a feed that ends at their own question. Sealing it to the
291
+ // outbox is the same store-and-forward path a routine's `cloud` delivery uses — the
292
+ // server holds ciphertext it cannot open, the app decrypts on next open, and the item
293
+ // is deleted server-side once acked.
294
+ //
295
+ // `task` rather than `routine`: this was a one-shot the user asked for, which is what
296
+ // that kind means in the app's Inbox filter.
297
+ //
298
+ // Never throws — a failed delivery falls back to the same on-disk queue the harbor
299
+ // flushes, and a failure to do even that must not take down the REPL.
300
+ async function deliverUnwatchedTurn(prompt: string, content: string): Promise<void> {
301
+ // Title the item by what was ASKED, not by what came back: it's what the person
302
+ // will recognise in a list, and the first line of an answer is often "Sure —".
303
+ const asked = prompt.replace(/\s+/g, " ").trim();
304
+ const title = asked ? (asked.length > 60 ? asked.slice(0, 57) + "…" : asked) : "Remote session";
305
+ const at = new Date().toISOString();
306
+ const body = `**${asked || "(no prompt)"}**\n\n${content}`;
307
+ try {
308
+ if (await postOutbox(title, at, "ok", body, "task")) {
309
+ console.log(`\n${DIM}⟿ app closed mid-turn — result sent to your Inbox.${RESET}`);
310
+ return;
311
+ }
312
+ // Couldn't seal/post right now (offline, no verified outbox key yet, server down).
313
+ // Queue it: the harbor flushes this file on its next connect, so the result is
314
+ // still headed for the Inbox rather than lost.
315
+ addPendingCloud({ routine: title, at, status: "ok", content: body, kind: "task" });
316
+ console.log(`\n${DIM}⟿ app closed mid-turn — result queued for your Inbox.${RESET}`);
317
+ } catch {
318
+ /* best effort: an undeliverable result must never break the session */
319
+ }
320
+ }
321
+
278
322
  async function runTurn(text: string, remote: boolean, echo = true): Promise<void> {
279
323
  if (turnActive) {
280
324
  console.log(`\n${DIM}(busy — a turn is already running)${RESET}`);
@@ -369,16 +413,11 @@ async function main() {
369
413
  cwd,
370
414
  agentDir: agentDir(),
371
415
  resourceLoaderOptions: {
372
- // Structural ext types are intentionally narrow; cast to Pi's ExtensionFactory.
373
- extensionFactories: [
374
- makePermissionGate(gate),
375
- // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
376
- // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
377
- makePiPrivacyExtension({
378
- privateerVerifiedTee: (m) => priv.hasCredentials() && privateerChannel(m.id ?? "") === "tee",
379
- }),
380
- makeAccountProvider(),
381
- ] as any,
416
+ // The REPL builds its session from an explicit factory list rather than the
417
+ // launcher's shim directory, so config/moat.ts is the only way it gets the gate,
418
+ // the account provider, or media at all. Structural ext types are intentionally
419
+ // narrow; cast to Pi's ExtensionFactory.
420
+ ...((await moatResourceOptions({ kind: "repl", gate })) as any),
382
421
  },
383
422
  });
384
423
  for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
@@ -557,11 +596,15 @@ async function main() {
557
596
  console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
558
597
  // Move the live session onto a confidential model right away, so the next prompt
559
598
  // doesn't dead-end on the launch model's missing key. resolveSignedInModel picks
560
- // Tinfoil GLM 5.2 — direct with a Tinfoil key, over the subscription otherwise;
561
- // PRIVATEER_MODEL (a deliberate override) is respected and left alone.
599
+ // the Tinfoil default — direct with a Tinfoil key, over the subscription
600
+ // otherwise. Two deliberate choices are respected and left alone: a
601
+ // PRIVATEER_MODEL override, and a saved default the user picked themselves
602
+ // (Pi persists every interactive switch) that points anywhere other than the
603
+ // target — sign-in arms the account channel, it doesn't move a chosen model.
562
604
  if (!process.env.PRIVATEER_MODEL?.trim()) {
563
605
  const target = resolveSignedInModel();
564
- if (target !== currentSpec) await switchModel(target, false);
606
+ const saved = savedPiDefaultSpec();
607
+ if (target !== currentSpec && (!saved || saved === target)) await switchModel(target, false);
565
608
  }
566
609
  } catch (e) {
567
610
  console.log(`${RED}${(e as Error).message}${RESET}`);
@@ -38,6 +38,40 @@ export function webEnabled(): boolean {
38
38
  return hasCredentials();
39
39
  }
40
40
 
41
+ /**
42
+ * Is this agent allowed to GENERATE media (images, video, speech, music)?
43
+ *
44
+ * Every media call is served by the account API, so credentials are a hard prerequisite.
45
+ * `HARBOR_MEDIA` is authoritative when set. When it is UNSET the default splits by where
46
+ * we run: a hosted tenant fails CLOSED, a daemon on the user's own machine defaults on
47
+ * once signed in. Hosted has to be off-by-default because generation spends real credit
48
+ * and sends the prompt (plus any input image) out of the enclave to a model provider —
49
+ * that is a grant the user makes per agent (tenantEnv sets HARBOR_MEDIA=1), never one a
50
+ * missing env var hands out. (Contrast webEnabled, whose unset-default is on everywhere
51
+ * only because the orchestrator ALWAYS sets HARBOR_WEB in hosted mode; HARBOR_MEDIA is
52
+ * not guaranteed to be set, so the default here must be the safe one.)
53
+ *
54
+ * It is a separate switch from web access because it buys a different thing and costs a
55
+ * different thing. Generation spends real credit — cents for an image, up to a dollar
56
+ * for a video clip — where a search costs a fraction of one, and the prompt (plus any
57
+ * input image) leaves the enclave for a model provider rather than for a search index.
58
+ * A hosted agent that should read the news is not automatically one that should be able
59
+ * to bill the account for a minute of video.
60
+ *
61
+ * Note this gates GENERATION only. `video_compose` is local ffmpeg work on files
62
+ * already on disk — no account, no network, no spend — and stays available regardless,
63
+ * so an agent can still finish assembling media a previous run produced.
64
+ */
65
+ export function mediaEnabled(): boolean {
66
+ const flag = process.env.HARBOR_MEDIA;
67
+ if (flag === "1") return hasCredentials();
68
+ if (flag === "0") return false;
69
+ // Unset: hosted tenants fail closed (explicit per-agent grant only); a daemon on
70
+ // the user's own machine defaults on once signed in.
71
+ if (isHosted()) return false;
72
+ return hasCredentials();
73
+ }
74
+
41
75
  /**
42
76
  * Publish this harbor's relay identity key so the Harbor host can attest it.
43
77
  *
@@ -0,0 +1,236 @@
1
+ /**
2
+ * buildMoat() — the one place that decides which extensions a session gets, in what order.
3
+ *
4
+ * WHAT THIS REPLACES. Five entry points each hand-assembled their own `extensionFactories`
5
+ * array: the harbor's sessions (routines, workflows, tasks), its live task spawns, the
6
+ * channels runner, ACP, and the dev REPL. The five lists were the same list — same gate,
7
+ * same privacy extension, same account provider, the same `webEnabled()`/`mediaEnabled()`
8
+ * conditionals — copied with small deliberate differences and a large amount of duplicated
9
+ * comment explaining them. Nothing held them together, so an extension added to one path
10
+ * silently didn't exist on the other four (privateer-media had reached three of five), and
11
+ * the ordering rule below lived as prose repeated in each copy.
12
+ *
13
+ * ORDER MATTERS, ONCE. pi-privacy's own catalog registers a `privateer` provider (its
14
+ * PUBLIC developer-key channel, one seed model), and Pi's registerProvider REPLACES a
15
+ * provider's models and request config. So the privacy extension must be built ABOVE
16
+ * makeAccountProvider(), or the ACCOUNT channel doesn't land last, the default model stops
17
+ * resolving, and requests go to api.privateer.pro/v1 instead of /api/agent/v1. Expressing
18
+ * that as one ordered array is the point: it used to be a comment that each copy had to
19
+ * keep. (The interactive TUI still gets its extensions by discovery, where the order isn't
20
+ * ours to choose — which is why extensions/privateer-privacy.ts has to re-assert the
21
+ * account registration there. Folding the TUI into this function is the next step.)
22
+ *
23
+ * CAPABILITY SHAPING. web and media are omitted as FACTORIES, not merely dropped from the
24
+ * allow-list, when their switch is off: "web off" should mean the tool doesn't exist for
25
+ * that run, not that it exists and is denied. The per-kind table below records which paths
26
+ * get what — it is the behaviour the five copies already had, now readable in one place.
27
+ *
28
+ * IMPORT-SAFETY: every Pi-touching import is dynamic, inside the async function. So this
29
+ * module is safe to import statically from anywhere, including a pre-boot entry (the REPL
30
+ * loads everything Pi-touching after ../boot.ts, and this must not break that).
31
+ */
32
+
33
+ import { basename } from "node:path";
34
+ import { managedNames } from "./moatManifest.ts";
35
+ import type { GateController } from "../ext/permissionGate.ts";
36
+ import type { SendFileBridge } from "../tools/sendFile.ts";
37
+ import type { AttachmentStore } from "../util/attachmentStore.ts";
38
+
39
+ /** A Pi extension factory, as DefaultResourceLoader takes them. */
40
+ export type ExtensionFactory = (pi: any) => void;
41
+
42
+ /**
43
+ * Which session is being built. Not cosmetic: it selects the capability row below, so a
44
+ * new entry point has to say what it is rather than inherit whatever the copy it was
45
+ * pasted from happened to have.
46
+ */
47
+ export type MoatKind =
48
+ | "harbor-session" // routines, workflows, submitted tasks (headless, unattended)
49
+ | "live-task" // a drivable session the harbor spawns for the app
50
+ | "channels" // Telegram / Slack / Discord / WhatsApp bridge sessions
51
+ | "acp" // `privateer acp` — an ACP host (Zed, Buzz) drives
52
+ | "repl"; // the lean dev REPL (npm run chat)
53
+
54
+ export interface MoatOptions {
55
+ kind: MoatKind;
56
+ /** This session's gate — its cwd, its approver. Never shared between sessions. */
57
+ gate: GateController;
58
+ /**
59
+ * send_file_to_client / save_attachment bound to THIS session's bridge. Only a path that
60
+ * owns a relay passes these; the shipped gate extension registers its own pair against
61
+ * its module-level bridge and stands them down inside the daemon, so a live spawn's own
62
+ * pair is what the model gets (see tools/relayFileTools.ts).
63
+ */
64
+ relayFiles?: { bridge: SendFileBridge; attachments: AttachmentStore };
65
+ }
66
+
67
+ /**
68
+ * Per-kind capabilities. `web` and `media` are the CEILING — each is still ANDed with its
69
+ * runtime switch (webEnabled/mediaEnabled), so a false here means "never", not "by default".
70
+ *
71
+ * web is deliberately off for live-task and repl: makeWebTools() routes through the account
72
+ * API precisely because an unattended run must not hold a search provider key, and both of
73
+ * those paths have a human at the other end who can use their own provider (src/tools/web.ts).
74
+ * compose is unconditional everywhere: local ffmpeg work, no account, no network, no spend —
75
+ * so a run with generation off can still assemble media that already exists on disk.
76
+ */
77
+ const CAPABILITIES: Record<MoatKind, { web: boolean; media: boolean; mcp: boolean }> = {
78
+ "harbor-session": { web: true, media: true, mcp: true },
79
+ "live-task": { web: false, media: true, mcp: false },
80
+ channels: { web: true, media: true, mcp: false },
81
+ acp: { web: true, media: true, mcp: false },
82
+ repl: { web: false, media: true, mcp: false },
83
+ };
84
+
85
+ /**
86
+ * Drop the launcher's shims from a session's DISCOVERED extension set, leaving everything
87
+ * else — the user's own `packages`, their own files in <agentDir>/extensions — untouched.
88
+ *
89
+ * THE PROBLEM. bin/privateer-launch.mjs installs the moat as shim files in the shared
90
+ * ~/.privateer/agent/extensions, and Pi discovers those into EVERY session built against
91
+ * that agent dir — including the ones the harbor daemon, the channels runner and the REPL
92
+ * stand up with their own in-code moat. So each of those processes got two copies of the
93
+ * moat: one wired to the session it belongs to, and one wired to module-level state from a
94
+ * process that isn't running (a RemoteBridge only `/remote-access` ever attaches a relay
95
+ * to, an allowlist shared across concurrent sessions, and process.cwd() rather than the
96
+ * session's). Pi runs every tool_call handler and resolves duplicate tool names
97
+ * first-registration-wins with discovered extensions loading BEFORE inline factories, so
98
+ * the stray copy either raised a second approval dialog for the same call (where a UI was
99
+ * bound) or fail-closed denied it before the session's real approver was consulted (where
100
+ * one wasn't) — and its tools shadowed the session's own.
101
+ *
102
+ * WHAT WE DID BEFORE. Each affected extension asked, from inside itself, whether its
103
+ * consumer wanted it: `discoveredGateApplies()`, `inHarborDaemon()`, `isSubagentChild()`,
104
+ * coordinated through process.env because jiti loads discovered extensions with the module
105
+ * cache off, so two copies of a module don't share memory. That inverted the dependency
106
+ * (the moat knew about the harbor), covered only the extensions someone had remembered to
107
+ * defend — the media, MCP, web and brand shims never stood down at all — and had to be
108
+ * repeated in each new entry point. The REPL was simply missed, and ran two gates.
109
+ *
110
+ * WHAT WE DO NOW. The host decides. Pi's own `extensionsOverride` hook runs over the
111
+ * discovered set before inline factories are applied, so a process that builds its own moat
112
+ * filters ours out by name and keeps the user's. Positive, one place, and it cannot be
113
+ * forgotten by a new entry point because it ships with the factories it complements.
114
+ *
115
+ * Matching is by file basename against the shipping manifest: the launcher owns those
116
+ * names (extensionsControl refuses to install a user package under any of them), and the
117
+ * basename is stable where an absolute path is not — Pi reports the shim's own path, but
118
+ * tmpdir symlinks and Windows separators make full-path equality a trap.
119
+ *
120
+ * WHAT THIS DOES NOT DO. Pi runs the override AFTER loading, so a dropped shim's module
121
+ * has still been evaluated and its factory still ran — we stop it being dispatched to and
122
+ * owning tool names, not being executed. Two consequences. Its tool registrations collide
123
+ * with ours during load, producing conflict diagnostics that name an extension the session
124
+ * is about to discard; those are noise, so we drop them alongside it (an entry point that
125
+ * printed them would tell the user a tool conflicts with something not in the session).
126
+ * And any side effect in the extension body still happens, which is why the shipped gate
127
+ * keeps its own stand-down checks for now. Stopping the load itself means not putting the
128
+ * shims in the discovered directory at all — passing them to the TUI as explicit `-e` args
129
+ * the way bin/privateer-subagent.mjs already does for children — which also has to move
130
+ * TUI-spawned subagents onto that wrapper. Separate change.
131
+ */
132
+ export function excludeDiscoveredMoat(): (base: any) => any {
133
+ const managed = new Set(managedNames());
134
+ const name = (p: unknown): string => basename(String(p ?? "")).replace(/\.(ts|js|mjs|cjs)$/, "");
135
+ return (base: any) => {
136
+ const dropped: string[] = [];
137
+ const extensions = (base?.extensions ?? []).filter((e: any) => {
138
+ if (!managed.has(name(e?.path))) return true;
139
+ dropped.push(String(e?.path ?? ""));
140
+ return false;
141
+ });
142
+ // A diagnostic is ours to drop when it belongs to a shim we removed, or when it points
143
+ // at one — "Tool X conflicts with <shim path>" is reported against the OTHER extension.
144
+ const errors = (base?.errors ?? []).filter(
145
+ (e: any) =>
146
+ !managed.has(name(e?.path)) &&
147
+ !dropped.some((p) => p && String(e?.error ?? "").includes(p)),
148
+ );
149
+ return { ...base, extensions, errors };
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Everything a session's resourceLoaderOptions needs: our factories, plus the filter that
155
+ * stops the discovered copies of those same extensions loading alongside them. Callers
156
+ * should prefer this over buildMoat() — passing the factories without the filter is the
157
+ * bug described above.
158
+ */
159
+ export async function moatResourceOptions(
160
+ opts: MoatOptions,
161
+ ): Promise<{ extensionFactories: ExtensionFactory[]; extensionsOverride: (base: any) => any }> {
162
+ return { extensionFactories: await buildMoat(opts), extensionsOverride: excludeDiscoveredMoat() };
163
+ }
164
+
165
+ /** Build the ordered extension factory list for one session. */
166
+ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]> {
167
+ const caps = CAPABILITIES[opts.kind];
168
+ if (!caps) throw new Error(`buildMoat: unknown kind "${opts.kind}"`);
169
+
170
+ const { makePermissionGate } = await import("../ext/permissionGate.ts");
171
+ const { makePiPrivacyExtension } = await import("pi-privacy");
172
+ const { makeAccountProvider, privateerChannel } = await import("../providers/account.ts");
173
+ const { hasCredentials } = await import("../auth/privateer.ts");
174
+ const { webEnabled, mediaEnabled } = await import("./hosted.ts");
175
+ const { noQuarterActive } = await import("../permissions/noQuarter.ts");
176
+ const { cliPalette, detectScheme } = await import("../ui/palette.ts");
177
+
178
+ const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
179
+
180
+ // Per-model verified-TEE capability for pi-privacy's /models picker: show Privateer's
181
+ // TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE" when logged in, while
182
+ // ZDR-channel models stay at their honest floor. The live verdict still comes from
183
+ // accountPosture on select — this only lifts the label.
184
+ factories.push(
185
+ makePiPrivacyExtension({
186
+ privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
187
+ // No quarter = unattended: the PII send-or-redact question would block a session
188
+ // the operator explicitly stepped away from, so it's swallowed the safe way —
189
+ // auto-redact + send — and what was masked surfaces as output instead. A live
190
+ // function, not a boolean: shift+tab / /no-quarter flips this mid-session.
191
+ piiUnattended: noQuarterActive,
192
+ // Color-coat that notice as the moat acting on your behalf: the red no-quarter
193
+ // flag (same glyph/color as the no-quarter banner in chat.ts), body in the
194
+ // accent color — distinct from yellow warnings and red errors.
195
+ renderPiiAutoRedact: (notice: string) => {
196
+ const p = cliPalette(detectScheme());
197
+ const body = notice.startsWith("⚑ ") ? notice.slice(2) : notice;
198
+ return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET}`;
199
+ },
200
+ }),
201
+ );
202
+ factories.push(makeAccountProvider()); // must follow pi-privacy — see header
203
+
204
+ if (opts.relayFiles) {
205
+ const { makeRelayFileTools } = await import("../tools/relayFileTools.ts");
206
+ factories.push(makeRelayFileTools(opts.relayFiles.bridge, opts.relayFiles.attachments));
207
+ }
208
+
209
+ if (caps.web && webEnabled()) {
210
+ const { makeWebTools } = await import("../tools/web.ts");
211
+ factories.push(makeWebTools());
212
+ }
213
+
214
+ if (caps.media && mediaEnabled()) {
215
+ const { makeMediaTools } = await import("../tools/media.ts");
216
+ factories.push(makeMediaTools());
217
+ }
218
+ const { makeComposeTools } = await import("../tools/videoCompose.ts");
219
+ factories.push(makeComposeTools());
220
+
221
+ if (caps.mcp) {
222
+ // Registers the tools from the shared agent/mcp.json — the same projection the app's
223
+ // MCP manager writes over the relay. No servers configured → a no-op. The specifier is
224
+ // a variable so tsc treats it as Promise<any> and doesn't pull the third-party
225
+ // adapter's own .ts into our typecheck.
226
+ //
227
+ // The caller still owns MCP_DIRECT_TOOLS: the adapter reads it when the factory RUNS
228
+ // (inside createAgentSessionServices), not when it is imported here, so the env has to
229
+ // be set around session creation rather than around this call.
230
+ const mcpAdapterSpec = "pi-mcp-adapter";
231
+ const { default: mcpAdapter } = await import(mcpAdapterSpec);
232
+ factories.push(mcpAdapter);
233
+ }
234
+
235
+ return factories;
236
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$comment": "The one list of extensions Privateer ships. Read by bin/privateer-launch.mjs (shim install, dependency-free, pre-patch) and src/config/moatManifest.ts (RESERVED + profile composition). JSON so both a plain .mjs and the TS side can read it without a build step. See moatManifest.ts for the field contract.",
3
+ "shims": [
4
+ { "name": "privateer-brand", "entry": "extensions/privateer-brand.ts", "note": "banner, ⚓ badge, /signin /signout" },
5
+ { "name": "privateer-context", "entry": "extensions/privateer-context.ts", "note": "PRIVATEER.md context + /init" },
6
+ { "name": "privateer-gate", "entry": "extensions/privateer-gate.ts", "note": "the permission gate (moat)" },
7
+ { "name": "privateer-account", "entry": "extensions/privateer-account.ts", "note": "account inference provider" },
8
+ { "name": "privateer-models", "entry": "extensions/privateer-models.ts", "note": "/models picker w/ privacy shields" },
9
+ { "name": "privateer-posture", "entry": "extensions/privateer-posture.ts", "note": "live attestation shield" },
10
+ { "name": "privateer-tools", "entry": "extensions/privateer-tools.ts", "note": "Privateer tool pack" },
11
+ { "name": "privateer-privacy", "entry": "extensions/privateer-privacy.ts", "note": "pi-privacy + account tier resolver" },
12
+ { "name": "privateer-connect", "entry": "extensions/privateer-connect.ts", "note": "/connect — MCP connector manager" },
13
+ { "name": "privateer-media", "entry": "extensions/privateer-media.ts", "note": "image/video/speech/music + ffmpeg compose" },
14
+ { "name": "rpiv-web-tools", "dep": ["@juicesharp/rpiv-web-tools", "index.ts"], "note": "private web tools (user's own provider key)" },
15
+ { "name": "rpiv-ask-user-question", "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "note": "ask_user_question" },
16
+ { "name": "pi-mcp-adapter", "dep": ["pi-mcp-adapter", "index.ts"], "note": "MCP servers as first-class tools" },
17
+ { "name": "pi-hypa", "dep": ["@hypabolic/pi-hypa", "extensions", "index.ts"], "note": "hypa pack" },
18
+ { "name": "pi-subagents", "dep": ["pi-subagents", "src", "extension", "index.ts"], "note": "bounded parallel sub-agents" }
19
+ ],
20
+ "retired": ["pi-privacy", "pi-web-access"],
21
+ "reservedAliases": [
22
+ "@juicesharp/rpiv-web-tools",
23
+ "@juicesharp/rpiv-ask-user-question",
24
+ "@hypabolic/pi-hypa"
25
+ ]
26
+ }