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/README.md +9 -0
- package/bin/privateer-launch.mjs +144 -68
- package/bin/privateer-subagent.mjs +35 -22
- package/extensions/privateer-brand.ts +14 -1
- package/extensions/privateer-gate.ts +20 -28
- package/extensions/privateer-media.ts +20 -0
- package/extensions/privateer-privacy.ts +3 -3
- package/package.json +16 -7
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +876 -5
- package/src/acp/run.ts +16 -19
- package/src/channels/run.ts +18 -27
- package/src/cli/chat.ts +61 -18
- package/src/config/hosted.ts +34 -0
- package/src/config/moat.ts +236 -0
- package/src/config/moatManifest.json +26 -0
- package/src/config/moatManifest.ts +71 -0
- package/src/harbor/index.ts +49 -113
- package/src/outbox/cloudOutbox.ts +95 -0
- package/src/permissions/classify.ts +113 -0
- package/src/providers/account.ts +88 -4
- package/src/providers/defaultModel.ts +66 -11
- package/src/remote/extensionsControl.ts +12 -24
- package/src/remote/liveTaskSession.ts +15 -24
- package/src/remote/relayClient.ts +39 -0
- package/src/remote/remoteBridge.ts +65 -0
- package/src/tools/media.ts +554 -0
- package/src/tools/relayFileTools.ts +10 -11
- package/src/tools/videoCompose.ts +579 -0
- package/src/tools/web.ts +12 -8
- package/src/config/harborDaemon.ts +0 -31
- package/src/config/inlineMoat.ts +0 -67
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 {
|
|
86
|
-
const {
|
|
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 {
|
|
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
|
-
?
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// the
|
|
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
|
-
|
|
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
|
|
package/src/channels/run.ts
CHANGED
|
@@ -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 {
|
|
127
|
-
const {
|
|
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
|
-
?
|
|
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
|
-
|
|
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 {
|
|
33
|
-
const {
|
|
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
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
561
|
-
//
|
|
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
|
-
|
|
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}`);
|
package/src/config/hosted.ts
CHANGED
|
@@ -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
|
+
}
|