privateer-agent 0.12.13 → 0.12.15
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 +7 -0
- package/bin/privateer-subagent.mjs +10 -2
- package/extensions/privateer-desktop.ts +54 -0
- package/extensions/privateer-gate.ts +7 -0
- package/extensions/privateer-hints.ts +33 -2
- package/extensions/privateer-media.ts +17 -4
- package/extensions/privateer-privacy.ts +13 -33
- package/extensions/privateer-tools.ts +8 -3
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +102 -5
- package/src/acp/run.ts +29 -2
- package/src/channels/run.ts +9 -0
- package/src/cli/chat.ts +47 -0
- package/src/config/desktopApp.ts +138 -0
- package/src/config/moat.ts +6 -26
- package/src/config/moatManifest.json +1 -0
- package/src/config/moatManifest.ts +5 -0
- package/src/config/privacyPolicy.ts +97 -0
- package/src/engine/errors.ts +97 -1
- package/src/ext/permissionGate.ts +6 -0
- package/src/harbor/index.ts +65 -11
- package/src/outbox/cloudOutbox.ts +72 -4
- package/src/permissions/childSpend.ts +105 -0
- package/src/permissions/classify.ts +61 -3
- package/src/permissions/modeGate.ts +39 -0
- package/src/providers/account.ts +8 -1
- package/src/providers/phala/measurements.ts +170 -0
- package/src/providers/phala/pin.ts +98 -0
- package/src/providers/phalaSeal.ts +131 -9
- package/src/providers/sealedShim.ts +6 -1
- package/src/remote/liveTaskSession.ts +8 -1
- package/src/remote/relayClient.ts +21 -0
- package/src/remote/remoteBridge.ts +9 -0
- package/src/routines/store.ts +11 -0
- package/src/tools/media.ts +516 -11
- package/src/tools/routineResult.ts +146 -0
- package/src/tools/videoCompose.ts +825 -7
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Where the Privateer DESKTOP app lives on this machine, and how to bring it up.
|
|
2
|
+
//
|
|
3
|
+
// The desktop app and this terminal are two front ends over one home: both read
|
|
4
|
+
// ~/.privateer, so they share the account login, the model config, the MCP catalog
|
|
5
|
+
// and the per-folder spawn defaults (config/spawns.ts). That makes "open the app"
|
|
6
|
+
// a genuinely useful thing for a terminal to offer — nothing is handed over, the
|
|
7
|
+
// state is already common — which is what /desktop (extensions/privateer-desktop.ts)
|
|
8
|
+
// does, and what the working-line tip in privateer-hints.ts points at.
|
|
9
|
+
//
|
|
10
|
+
// WHY DETECTION AND NOT JUST A DOWNLOAD LINK. A tip that advertises software the
|
|
11
|
+
// user hasn't got is an ad; one that names a command for software they HAVE is
|
|
12
|
+
// discoverability. So both consumers ask this module first, and the hint stays
|
|
13
|
+
// silent on a machine with no app installed.
|
|
14
|
+
//
|
|
15
|
+
// NO FOLDER HANDOFF. The app takes no path argument today — its second-instance
|
|
16
|
+
// handler just focuses the running window (desktop/src/main/main.mjs) — so /desktop
|
|
17
|
+
// opens the app, and the user picks this folder from File ▸ Spawn Privateer at…,
|
|
18
|
+
// where the spawn record this terminal already shares makes it start on the same
|
|
19
|
+
// model and connectors. If the app ever learns a folder argv, this is the one place
|
|
20
|
+
// that has to change.
|
|
21
|
+
//
|
|
22
|
+
// IMPORT-SAFETY: node builtins only, no Pi imports, no side effects — safe from an
|
|
23
|
+
// extension under jiti and from a pre-boot entry alike.
|
|
24
|
+
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { basename, join } from "node:path";
|
|
29
|
+
|
|
30
|
+
/** Per-platform download pages (README ▸ Desktop app). No Linux build exists. */
|
|
31
|
+
const DOWNLOAD_MAC = "https://privateer.pro/download/mac";
|
|
32
|
+
const DOWNLOAD_MAC_INTEL = "https://privateer.pro/download/mac-intel";
|
|
33
|
+
const DOWNLOAD_WINDOWS = "https://privateer.pro/download/windows";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The download page to lead with on THIS machine, or null where we ship no desktop
|
|
37
|
+
* build. macOS is split by CPU family — the arm64 dmg won't run on an Intel Mac —
|
|
38
|
+
* and process.arch is the interpreter's answer, not the machine's: an Apple-silicon
|
|
39
|
+
* Mac running a Rosetta node reports x64 and would be led to the Intel build. That
|
|
40
|
+
* is survivable rather than solved (detecting the translation means shelling out to
|
|
41
|
+
* `sysctl sysctl.proc_translated` for one link) because the OTHER build is offered
|
|
42
|
+
* alongside it — see desktopDownloadAltUrl.
|
|
43
|
+
*/
|
|
44
|
+
export function desktopDownloadUrl(
|
|
45
|
+
platform: NodeJS.Platform = process.platform,
|
|
46
|
+
arch: string = process.arch,
|
|
47
|
+
): string | null {
|
|
48
|
+
if (platform === "darwin") return arch === "x64" ? DOWNLOAD_MAC_INTEL : DOWNLOAD_MAC;
|
|
49
|
+
if (platform === "win32") return DOWNLOAD_WINDOWS;
|
|
50
|
+
return null; // electron-builder.yml targets mac + win only
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The macOS build for the OTHER CPU family, so a Mac user is never one wrong arch
|
|
55
|
+
* away from a download that won't launch. Null everywhere else: Windows ships one
|
|
56
|
+
* x64 installer and there is no Linux build at all.
|
|
57
|
+
*/
|
|
58
|
+
export function desktopDownloadAltUrl(
|
|
59
|
+
platform: NodeJS.Platform = process.platform,
|
|
60
|
+
arch: string = process.arch,
|
|
61
|
+
): string | null {
|
|
62
|
+
if (platform !== "darwin") return null;
|
|
63
|
+
return arch === "x64" ? DOWNLOAD_MAC : DOWNLOAD_MAC_INTEL;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The installed app, or null. Ordered by confidence:
|
|
68
|
+
*
|
|
69
|
+
* 1. OUR OWN interpreter, when the terminal is running on the app's bundled Node.
|
|
70
|
+
* The desktop's CLI shim runs `privateer` through the app binary itself with
|
|
71
|
+
* ELECTRON_RUN_AS_NODE=1 (desktop/src/main/cliShim.mjs), so process.execPath IS
|
|
72
|
+
* the app — and it's the copy the user actually installed, wherever they put it.
|
|
73
|
+
* 2. The standard install locations. macOS drag-install goes to /Applications or
|
|
74
|
+
* ~/Applications; the Windows installer is per-user NSIS (perMachine: false) so
|
|
75
|
+
* %LOCALAPPDATA%\Programs\Privateer is the default, with Program Files covered
|
|
76
|
+
* for an install that chose it (allowToChangeInstallationDirectory: true).
|
|
77
|
+
*
|
|
78
|
+
* A user who installed somewhere else entirely reads as "not installed" — which
|
|
79
|
+
* costs them a download link they don't need, and never a wrong app launched.
|
|
80
|
+
*/
|
|
81
|
+
export function desktopAppPath(
|
|
82
|
+
platform: NodeJS.Platform = process.platform,
|
|
83
|
+
env: Record<string, string | undefined> = process.env,
|
|
84
|
+
execPath: string = process.execPath,
|
|
85
|
+
): string | null {
|
|
86
|
+
const hit = (p: string): string | null => (p && existsSync(p) ? p : null);
|
|
87
|
+
|
|
88
|
+
if (platform === "darwin") {
|
|
89
|
+
// …/Privateer.app/Contents/MacOS/Privateer → …/Privateer.app
|
|
90
|
+
const marker = "/Privateer.app/";
|
|
91
|
+
const at = execPath.indexOf(marker);
|
|
92
|
+
if (at >= 0) {
|
|
93
|
+
const bundle = hit(execPath.slice(0, at + marker.length - 1));
|
|
94
|
+
if (bundle) return bundle;
|
|
95
|
+
}
|
|
96
|
+
return hit("/Applications/Privateer.app") ?? hit(join(homedir(), "Applications", "Privateer.app"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (platform === "win32") {
|
|
100
|
+
if (/^privateer\.exe$/i.test(basename(execPath))) {
|
|
101
|
+
const self = hit(execPath);
|
|
102
|
+
if (self) return self;
|
|
103
|
+
}
|
|
104
|
+
const local = env.LOCALAPPDATA;
|
|
105
|
+
const files = env.ProgramFiles;
|
|
106
|
+
return (
|
|
107
|
+
(local ? hit(join(local, "Programs", "Privateer", "Privateer.exe")) : null) ??
|
|
108
|
+
(files ? hit(join(files, "Privateer", "Privateer.exe")) : null)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Launch (or focus) the desktop app. Best-effort and never throws — the caller
|
|
117
|
+
* reports the failure as text, the way openBrowser.ts does.
|
|
118
|
+
*
|
|
119
|
+
* ELECTRON_RUN_AS_NODE IS STRIPPED, and that is load-bearing rather than tidy: when
|
|
120
|
+
* this terminal was itself started by the desktop's CLI shim, that variable is set
|
|
121
|
+
* in our environment, and a child inheriting it starts the app binary as a bare Node
|
|
122
|
+
* that exits without ever drawing a window. `open` goes through LaunchServices and
|
|
123
|
+
* wouldn't pass it on anyway; the Windows path spawns the exe directly and would.
|
|
124
|
+
*/
|
|
125
|
+
export function openDesktopApp(appPath: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
126
|
+
const { ELECTRON_RUN_AS_NODE: _drop, ...env } = process.env;
|
|
127
|
+
try {
|
|
128
|
+
const child =
|
|
129
|
+
platform === "darwin"
|
|
130
|
+
? spawn("open", ["-a", appPath], { detached: true, stdio: "ignore", env })
|
|
131
|
+
: spawn(appPath, [], { detached: true, stdio: "ignore", env });
|
|
132
|
+
child.on("error", () => {}); // a spawn failure must not raise on the event loop
|
|
133
|
+
child.unref(); // never hold the CLI's exit open
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/config/moat.ts
CHANGED
|
@@ -177,36 +177,16 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
177
177
|
|
|
178
178
|
const { makePermissionGate } = await import("../ext/permissionGate.ts");
|
|
179
179
|
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
180
|
-
const { makeAccountProvider
|
|
181
|
-
const { hasCredentials } = await import("../auth/privateer.ts");
|
|
180
|
+
const { makeAccountProvider } = await import("../providers/account.ts");
|
|
182
181
|
const { webEnabled, mediaEnabled } = await import("./hosted.ts");
|
|
183
|
-
const {
|
|
184
|
-
const { cliPalette, detectScheme } = await import("../ui/palette.ts");
|
|
182
|
+
const { sharedPrivacyOptions } = await import("./privacyPolicy.ts");
|
|
185
183
|
|
|
186
184
|
const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
|
|
187
185
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
factories.push(
|
|
193
|
-
makePiPrivacyExtension({
|
|
194
|
-
privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
195
|
-
// No quarter = unattended: the PII send-or-redact question would block a session
|
|
196
|
-
// the operator explicitly stepped away from, so it's swallowed the safe way —
|
|
197
|
-
// auto-redact + send — and what was masked surfaces as output instead. A live
|
|
198
|
-
// function, not a boolean: shift+tab / /no-quarter flips this mid-session.
|
|
199
|
-
piiUnattended: noQuarterActive,
|
|
200
|
-
// Color-coat that notice as the moat acting on your behalf: the red no-quarter
|
|
201
|
-
// flag (same glyph/color as the no-quarter banner in chat.ts), body in the
|
|
202
|
-
// accent color — distinct from yellow warnings and red errors.
|
|
203
|
-
renderPiiAutoRedact: (notice: string) => {
|
|
204
|
-
const p = cliPalette(detectScheme());
|
|
205
|
-
const body = notice.startsWith("⚑ ") ? notice.slice(2) : notice;
|
|
206
|
-
return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET}`;
|
|
207
|
-
},
|
|
208
|
-
}),
|
|
209
|
-
);
|
|
186
|
+
// pi-privacy is configured in exactly ONE place, shared with the DISCOVERED copy of this
|
|
187
|
+
// extension (the TUI's, and every subagent child's) — see ./privacyPolicy.ts for the two
|
|
188
|
+
// bugs that came of configuring it in two.
|
|
189
|
+
factories.push(makePiPrivacyExtension(sharedPrivacyOptions()));
|
|
210
190
|
factories.push(makeAccountProvider()); // must follow pi-privacy — see header
|
|
211
191
|
|
|
212
192
|
if (opts.relayFiles) {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
{ "name": "privateer-privacy", "entry": "extensions/privateer-privacy.ts", "note": "pi-privacy + account tier resolver" },
|
|
12
12
|
{ "name": "privateer-connect", "entry": "extensions/privateer-connect.ts", "note": "/connect — MCP connector manager" },
|
|
13
13
|
{ "name": "privateer-media", "entry": "extensions/privateer-media.ts", "note": "image/video/speech/music + ffmpeg compose" },
|
|
14
|
+
{ "name": "privateer-desktop", "entry": "extensions/privateer-desktop.ts", "note": "/desktop — open the Privateer desktop app" },
|
|
14
15
|
{ "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
|
|
15
16
|
{ "name": "privateer-update", "entry": "extensions/privateer-update.ts", "note": "tool pack updates in place — banner flag + /update" },
|
|
16
17
|
{ "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
@@ -27,6 +27,11 @@ import { readFileSync } from "node:fs";
|
|
|
27
27
|
* `dep` is a node_modules specifier as [packageName, ...pathSegments], resolved through
|
|
28
28
|
* the node_modules chain at launch (npm hoists, so a fixed path would miss). Exactly one
|
|
29
29
|
* of the two is set.
|
|
30
|
+
*
|
|
31
|
+
* `note` is USER-VISIBLE: relayClient.sendExtensions ships it to the app, which lists it
|
|
32
|
+
* under each built-in in the Extensions manager. Write it as a one-line description of
|
|
33
|
+
* what the extension gives the user (English only — the app renders it verbatim, as it
|
|
34
|
+
* already does for the agent's status messages), not as a code comment.
|
|
30
35
|
*/
|
|
31
36
|
export interface MoatShim {
|
|
32
37
|
name: string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The pi-privacy options that must be the SAME however a session reaches pi-privacy.
|
|
2
|
+
//
|
|
3
|
+
// There are two call sites, and they are genuinely separate routes — not one path with a
|
|
4
|
+
// fallback:
|
|
5
|
+
//
|
|
6
|
+
// - src/config/moat.ts builds the extension from FACTORIES: the harbor's sessions,
|
|
7
|
+
// live tasks, the channels runner, ACP, the lean REPL.
|
|
8
|
+
// - extensions/privateer-privacy.ts is what Pi DISCOVERS for the interactive TUI, and
|
|
9
|
+
// what bin/privateer-subagent.mjs injects (`-e`) into every subagent child.
|
|
10
|
+
//
|
|
11
|
+
// They drifted, and the drift was the bug this module exists to make impossible: the
|
|
12
|
+
// factory side wired the unattended signal and the discovered side didn't, so in the
|
|
13
|
+
// terminal `--no-quarter` (and shift+tab) lowered the permission moat while pi-privacy
|
|
14
|
+
// still stopped the turn with "PII detected — send as-is or redact?". No quarter means no
|
|
15
|
+
// prompts, from every gate in the session, not just ours.
|
|
16
|
+
//
|
|
17
|
+
// EVERYTHING pi-privacy is configured with lives here — there is no "and each side adds
|
|
18
|
+
// its own bit" left, because that arrangement is what produced both bugs. The other one:
|
|
19
|
+
// c2ee0fa added `resolveTier` to the discovered extension to stop the PII gate
|
|
20
|
+
// over-warning on the account channel (pi-privacy's own catalog only knows Privateer's
|
|
21
|
+
// PUBLIC developer key, which floors to zdr-policy, so a session on an ATTESTED account
|
|
22
|
+
// TEE model was judged unverified and asked about PII on every turn). The factory-built
|
|
23
|
+
// sessions never got it, and they never got the badge right either. Symmetrically, they
|
|
24
|
+
// had `privateerVerifiedTee` and the discovered copy didn't.
|
|
25
|
+
//
|
|
26
|
+
// IMPORT-SAFETY: this module reaches the account channel, so it pulls Pi-touching code
|
|
27
|
+
// and node builtins — it is NOT safe to import from a boot-ordered entrypoint (see
|
|
28
|
+
// boot.ts's ORDERING CONTRACT). moat.ts therefore imports it DYNAMICALLY, inside
|
|
29
|
+
// buildMoat(), exactly as it does every other Pi-touching import. Extensions load late
|
|
30
|
+
// and import it statically.
|
|
31
|
+
|
|
32
|
+
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
33
|
+
import { cliPalette, detectScheme } from "../ui/palette.ts";
|
|
34
|
+
import { accountPosture, privateerChannel } from "../providers/account.ts";
|
|
35
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
36
|
+
|
|
37
|
+
// Color-coat pi-privacy's auto-redact notice as the moat acting on your behalf: the red
|
|
38
|
+
// no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
|
|
39
|
+
// extension's status line), body in the accent color — distinct from a yellow warning and
|
|
40
|
+
// from a red error, because this is neither: it's the answer we gave for you.
|
|
41
|
+
function renderPiiAutoRedact(notice: string): string {
|
|
42
|
+
const p = cliPalette(detectScheme());
|
|
43
|
+
const body = notice.startsWith("⚑ ") ? notice.slice(2) : notice;
|
|
44
|
+
return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pi-privacy options every Privateer session gets, whichever route built it. */
|
|
48
|
+
export function sharedPrivacyOptions() {
|
|
49
|
+
return {
|
|
50
|
+
// The account channel's real posture. pi-privacy ships a `privateer` provider, but it
|
|
51
|
+
// is the PUBLIC developer-key channel (sk-priv-…, server-proxied and unverifiable
|
|
52
|
+
// end-to-end), so from the package alone every privateer/* model floors to
|
|
53
|
+
// zdr-policy. The in-app ACCOUNT channel is a different thing — its own OAuth
|
|
54
|
+
// session, account server and sealed relay — and only the host can say what it
|
|
55
|
+
// resolves to: tee-verified for a quote WE checked over the sealed path,
|
|
56
|
+
// tee-unverified for a proxied enclave we can't bind to this connection, zdr-policy
|
|
57
|
+
// for the ZDR-channel models. Without this hook a session running an ATTESTED TEE
|
|
58
|
+
// model is judged unverified, so the badge lies and the PII gate asks about every
|
|
59
|
+
// turn — the over-warning c2ee0fa fixed for the terminal and nowhere else.
|
|
60
|
+
resolveTier: async (provider: string, modelId: string) => {
|
|
61
|
+
if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
|
|
62
|
+
return (await accountPosture(modelId)).tier;
|
|
63
|
+
},
|
|
64
|
+
// Per-model verified-TEE capability for pi-privacy's /models picker: show Privateer's
|
|
65
|
+
// TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE" when logged in, while
|
|
66
|
+
// ZDR-channel models stay at their honest floor. The live verdict still comes from
|
|
67
|
+
// resolveTier above on select — this only lifts the label.
|
|
68
|
+
privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
69
|
+
// No quarter = unattended. The PII send-or-redact question would stall a session the
|
|
70
|
+
// operator explicitly stepped away from, so pi-privacy swallows it the SAFE way —
|
|
71
|
+
// redact, then send — and reports what it masked as output instead of asking. A live
|
|
72
|
+
// function, not a boolean: shift+tab / `/no-quarter` flips this mid-session and the
|
|
73
|
+
// gate re-reads it on every request.
|
|
74
|
+
piiUnattended: noQuarterActive,
|
|
75
|
+
renderPiiAutoRedact,
|
|
76
|
+
// pi-privacy's INGEST gate: credentials arriving in a tool result are masked before
|
|
77
|
+
// they enter context (otherwise they're re-sent every turn and written to the session
|
|
78
|
+
// file on disk). We already redact tool output in src/ext/permissionGate.ts, so its
|
|
79
|
+
// default "warn" would put an interactive prompt in front of something this app has
|
|
80
|
+
// always handled silently — "redact" keeps our UX and still takes the added coverage.
|
|
81
|
+
//
|
|
82
|
+
// The two redactors are COMPLEMENTARY, not duplicative, which is why we run both:
|
|
83
|
+
// ours masks the configured provider keys by exact value (from env/config) plus the
|
|
84
|
+
// provider-specific shapes (sk-/AIza/xai-/gsk_/csk-/vapi_/fw_/Z.ai, auth headers);
|
|
85
|
+
// pi-privacy's catches what shows up in USER code and shell output — AWS AKIA/ASIA,
|
|
86
|
+
// GitHub gh[pousr]_, JWTs, PEM private-key blocks, Slack, Stripe — none of which our
|
|
87
|
+
// patterns match.
|
|
88
|
+
//
|
|
89
|
+
// Order between the two is NOT guaranteed: pi discovers extensions with a bare
|
|
90
|
+
// readdirSync and never sorts, so it's filesystem-dependent (alphabetical on this box
|
|
91
|
+
// today, not by contract). "redact" makes that moot — both handlers run
|
|
92
|
+
// unconditionally and each masks its own patterns, so the surviving content is the
|
|
93
|
+
// same either way. Under "warn" the order WOULD matter, since it decides whether the
|
|
94
|
+
// prompt is raised on a raw key or one we already masked.
|
|
95
|
+
toolResultPolicy: "redact" as const,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/engine/errors.ts
CHANGED
|
@@ -117,6 +117,99 @@ export function isAccountCapCode(code: string | null | undefined): boolean {
|
|
|
117
117
|
return typeof code === "string" && CAP_CODE.test(code);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// ── Oversized / non-API error bodies ─────────────────────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// An inference endpoint does not always answer as an API. Put a WAF, a proxy or a
|
|
123
|
+
// captive portal in front of one and a rejected request comes back as an HTML page,
|
|
124
|
+
// which the provider SDK folds whole into `error.message` — status first, body after.
|
|
125
|
+
//
|
|
126
|
+
// The incident this exists for: the account channel's edge WAF answered a turn with a
|
|
127
|
+
// 403 block page carrying three inline base64 web fonts, so `errorMessage` was 221 KB.
|
|
128
|
+
// Pi printed it into the terminal in full, appended it to the session file on every
|
|
129
|
+
// attempt (a 1.8 MB session), and ran its transient-error regex over it — and a
|
|
130
|
+
// megabyte of base64 reliably contains "429", "500", "502", so a permanent 403 looked
|
|
131
|
+
// retryable and burned the whole retry budget before the user saw anything.
|
|
132
|
+
//
|
|
133
|
+
// Both halves are fixed where Pi reads the message (see the patch in
|
|
134
|
+
// patches/@earendil-works+pi-coding-agent+*.patch, which mirrors these two helpers):
|
|
135
|
+
// squeeze the page down to the line a person can act on, and let the STATUS decide
|
|
136
|
+
// retryability rather than a substring of the body.
|
|
137
|
+
|
|
138
|
+
/** Hard cap on an error message we display, persist, or classify. */
|
|
139
|
+
export const MAX_ERROR_CHARS = 2_000;
|
|
140
|
+
|
|
141
|
+
/** How much of an HTML page's visible text is worth keeping. */
|
|
142
|
+
const MAX_PAGE_TEXT_CHARS = 600;
|
|
143
|
+
|
|
144
|
+
const HTML_DOC = /<!doctype html|<html[\s>]/i;
|
|
145
|
+
|
|
146
|
+
/** Tags whose contents are never prose: markup, styling, or a logo. */
|
|
147
|
+
const NON_PROSE = /<(script|style|svg|head|noscript)\b[\s\S]*?<\/\1\s*>/gi;
|
|
148
|
+
|
|
149
|
+
const ENTITIES: Record<string, string> = {
|
|
150
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'", "#x27": "'",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function plainText(html: string): string {
|
|
154
|
+
return html
|
|
155
|
+
.replace(/<[^>]*>/g, " ")
|
|
156
|
+
.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (m, code: string) => {
|
|
157
|
+
const key = code.toLowerCase();
|
|
158
|
+
if (ENTITIES[key] !== undefined) return ENTITIES[key];
|
|
159
|
+
if (key.startsWith("#x")) return String.fromCodePoint(parseInt(key.slice(2), 16) || 0) || m;
|
|
160
|
+
if (key.startsWith("#")) return String.fromCodePoint(parseInt(key.slice(1), 10) || 0) || m;
|
|
161
|
+
return m;
|
|
162
|
+
})
|
|
163
|
+
.replace(/\s+/g, " ")
|
|
164
|
+
.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reduce a provider error message to something worth showing, storing and classifying.
|
|
169
|
+
*
|
|
170
|
+
* An HTML page collapses to its status, its <title> and its visible text — which is
|
|
171
|
+
* where a WAF puts the one detail the user needs to report ("Request ID: …"). Anything
|
|
172
|
+
* else over the cap is truncated. Text already short and non-HTML is returned unchanged,
|
|
173
|
+
* so ordinary provider errors pass through untouched.
|
|
174
|
+
*/
|
|
175
|
+
export function compactProviderError(raw: string): string {
|
|
176
|
+
const text = typeof raw === "string" ? raw : String(raw ?? "");
|
|
177
|
+
if (!HTML_DOC.test(text)) {
|
|
178
|
+
if (text.length <= MAX_ERROR_CHARS) return text;
|
|
179
|
+
return `${text.slice(0, MAX_ERROR_CHARS)}… [dropped ${text.length - MAX_ERROR_CHARS} chars]`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const status = /^\s*(\d{3})\b/.exec(text)?.[1];
|
|
183
|
+
const title = plainText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(text)?.[1] ?? "");
|
|
184
|
+
const body = plainText(text.replace(NON_PROSE, " "));
|
|
185
|
+
const visible = [title, body].filter(Boolean).join(" — ").slice(0, MAX_PAGE_TEXT_CHARS);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
`${status ?? "HTTP error"} — an HTML page, not an API response (something in front of ` +
|
|
189
|
+
`the provider answered: a WAF, a proxy, or a captive portal): ` +
|
|
190
|
+
`${visible || "(no readable text)"} [dropped ${text.length} chars of HTML]`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Client-error statuses that CAN clear on their own: a timeout, a lock conflict, an
|
|
195
|
+
// early-data replay, a throttle. Every other 4xx is the request itself being wrong.
|
|
196
|
+
const TRANSIENT_CLIENT_STATUS = new Set([408, 409, 425, 429]);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True when the message opens with an HTTP status that retrying cannot clear.
|
|
200
|
+
*
|
|
201
|
+
* Both provider paths put the status first — the OpenAI-shaped SDK builds
|
|
202
|
+
* `"403 <body>"`, pi-messages builds `"403 Forbidden: <body>"` — so the status is a
|
|
203
|
+
* fact we can read, where a substring of the body is only a guess. 5xx and messages
|
|
204
|
+
* with no leading status are left to the caller's own classifier.
|
|
205
|
+
*/
|
|
206
|
+
export function isHardHttpFailure(text: string | null | undefined): boolean {
|
|
207
|
+
const m = /^\s*(\d{3})\b/.exec(typeof text === "string" ? text : "");
|
|
208
|
+
if (!m) return false;
|
|
209
|
+
const status = Number(m[1]);
|
|
210
|
+
return status >= 400 && status < 500 && !TRANSIENT_CLIENT_STATUS.has(status);
|
|
211
|
+
}
|
|
212
|
+
|
|
120
213
|
function rawMessage(err: unknown): string {
|
|
121
214
|
if (err instanceof Error) return err.message;
|
|
122
215
|
if (typeof err === "string") return err;
|
|
@@ -243,5 +336,8 @@ export function describeError(err: unknown): DescribedError {
|
|
|
243
336
|
});
|
|
244
337
|
}
|
|
245
338
|
|
|
246
|
-
|
|
339
|
+
// Unrecognized: show the provider's own words rather than swallow them — but an
|
|
340
|
+
// unrecognized error is exactly where a WAF block page or a megabyte of markup
|
|
341
|
+
// arrives, so it goes through the compactor first.
|
|
342
|
+
return out({ message: compactProviderError(text) });
|
|
247
343
|
}
|
|
@@ -53,6 +53,11 @@ export interface GateController {
|
|
|
53
53
|
// launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
|
|
54
54
|
// action with no prompt.
|
|
55
55
|
getSkipAllPermissions?(): boolean;
|
|
56
|
+
// Billing tools the operator authorized before this run started, so an unattended
|
|
57
|
+
// session can spend what it was told it may spend instead of denying every media
|
|
58
|
+
// call for want of a human. Lifts `alwaysAsk` and nothing else — see
|
|
59
|
+
// ModeGate.isSpendPreauthorized for the guards.
|
|
60
|
+
isSpendPreauthorized?(req: PermissionRequest): boolean;
|
|
56
61
|
// Block a tool outright while the turn is remote-driven (only consulted when
|
|
57
62
|
// getRemote() is true). For tools whose own prompts render on the host terminal
|
|
58
63
|
// rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
|
|
@@ -144,6 +149,7 @@ export async function decideToolCall(
|
|
|
144
149
|
getNoQuarter: ctrl.getNoQuarter,
|
|
145
150
|
getAutoApprove: ctrl.getAutoApprove,
|
|
146
151
|
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
152
|
+
isSpendPreauthorized: ctrl.isSpendPreauthorized,
|
|
147
153
|
});
|
|
148
154
|
|
|
149
155
|
let decision: "allow" | "deny";
|
package/src/harbor/index.ts
CHANGED
|
@@ -60,7 +60,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
|
|
|
60
60
|
import { ResultMedia, type StagedMedia } from "../routines/resultMedia.ts";
|
|
61
61
|
import { withBrief } from "../routines/resultBrief.ts";
|
|
62
62
|
import { ATTACH_RESULT_TOOL } from "../tools/attachResult.ts";
|
|
63
|
-
import { postOutbox as sealToOutbox } from "../outbox/cloudOutbox.ts";
|
|
63
|
+
import { postOutbox as sealToOutbox, type OutboxSource } from "../outbox/cloudOutbox.ts";
|
|
64
64
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
65
65
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
66
66
|
import { serializeBuild } from "./buildLock.ts";
|
|
@@ -68,6 +68,8 @@ import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/h
|
|
|
68
68
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
69
69
|
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
70
70
|
import { COMPOSE_TOOL_NAMES } from "../tools/videoCompose.ts";
|
|
71
|
+
import { BILLED_MEDIA_TOOLS } from "../permissions/classify.ts";
|
|
72
|
+
import { grantChildSpend } from "../permissions/childSpend.ts";
|
|
71
73
|
|
|
72
74
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
73
75
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
@@ -130,6 +132,11 @@ const GATE_TIMEOUT_MS = 5 * 60_000;
|
|
|
130
132
|
const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
|
|
131
133
|
const WARM_POLL_MS = 250;
|
|
132
134
|
|
|
135
|
+
// Distinguishes concurrent runs in the child-spend registry. A counter rather than a
|
|
136
|
+
// timestamp: two runs starting in the same millisecond would share a key, and the second
|
|
137
|
+
// to finish would release a grant the first still holds.
|
|
138
|
+
let runSeq = 0;
|
|
139
|
+
|
|
133
140
|
interface HarborConfig {
|
|
134
141
|
defaultModel: string;
|
|
135
142
|
webhooks?: Record<string, { url: string; secret?: string; headers?: Record<string, string> }>;
|
|
@@ -212,6 +219,22 @@ function formatWorkflowResult(name: string, result: { status: string; output: Re
|
|
|
212
219
|
return `${head}${body}${reason}`.trimEnd() + "\n";
|
|
213
220
|
}
|
|
214
221
|
|
|
222
|
+
// What a routine ASKED for, sealed into the outbox envelope beside what it answered.
|
|
223
|
+
//
|
|
224
|
+
// The app can then act on a finished run — "now do X with this" — with the routine's
|
|
225
|
+
// own standing instruction and working directory in hand, instead of guessing from a
|
|
226
|
+
// summary. The routine never leaves this machine except inside the sealed blob (the
|
|
227
|
+
// server holds ciphertext), and cloudOutbox clips every field.
|
|
228
|
+
function routineSource(routine: Routine): OutboxSource {
|
|
229
|
+
return {
|
|
230
|
+
routineId: routine.id,
|
|
231
|
+
prompt: routine.prompt,
|
|
232
|
+
cwd: routine.cwd,
|
|
233
|
+
model: routine.model,
|
|
234
|
+
schedule: routine.cron ?? routine.at,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
215
238
|
// Canonical control-envelope args for a task_submit / task_spawn signature. MUST match
|
|
216
239
|
// the app's signer (client/services/accountSign.ts) byte-for-byte: the SAME key set with
|
|
217
240
|
// undefined → null, so the recursive-key-sorted JSON both sides sign is identical. A
|
|
@@ -292,12 +315,13 @@ export class Harbor {
|
|
|
292
315
|
|
|
293
316
|
private readonly pushCloud: CloudPusher = async (routine, content, status, media = []) => {
|
|
294
317
|
const at = new Date().toISOString();
|
|
295
|
-
|
|
318
|
+
const source = routineSource(routine);
|
|
319
|
+
if (await this.postOutbox(routine.name, at, status, content, "routine", media, source)) return "sent";
|
|
296
320
|
// The queued copy keeps the attachment RECORDS, not the bytes: the files are
|
|
297
321
|
// still on this disk, so a flush hours later re-reads them (and says so in the
|
|
298
322
|
// body for any that have since gone). Buffering megabytes into a JSON queue
|
|
299
323
|
// would be the same content twice, on a box that already has it.
|
|
300
|
-
addPendingCloud({ routine: routine.name, at, status, content, ...(media.length ? { media } : {}) });
|
|
324
|
+
addPendingCloud({ routine: routine.name, at, status, content, source, ...(media.length ? { media } : {}) });
|
|
301
325
|
return "queued";
|
|
302
326
|
};
|
|
303
327
|
|
|
@@ -594,8 +618,9 @@ export class Harbor {
|
|
|
594
618
|
content: string,
|
|
595
619
|
kind: OutboxKind = "routine",
|
|
596
620
|
media: StagedMedia[] = [],
|
|
621
|
+
source?: OutboxSource,
|
|
597
622
|
): Promise<boolean> {
|
|
598
|
-
return sealToOutbox(name, at, status, content, kind, media);
|
|
623
|
+
return sealToOutbox(name, at, status, content, kind, media, source);
|
|
599
624
|
}
|
|
600
625
|
|
|
601
626
|
private async flushPendingCloud(): Promise<void> {
|
|
@@ -606,7 +631,7 @@ export class Harbor {
|
|
|
606
631
|
for (const p of queue) {
|
|
607
632
|
if (
|
|
608
633
|
remaining.length === 0 &&
|
|
609
|
-
(await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? []))
|
|
634
|
+
(await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))
|
|
610
635
|
) continue;
|
|
611
636
|
remaining.push(p);
|
|
612
637
|
}
|
|
@@ -771,8 +796,14 @@ export class Harbor {
|
|
|
771
796
|
// activation, so the build is serialized (serializeBuild) and the previous value is
|
|
772
797
|
// restored. "__none__" is its sentinel for "register none": an unattended run with
|
|
773
798
|
// no connector selectors gets no direct tools, whatever a shared mcp.json says.
|
|
774
|
-
private buildSessionServices(
|
|
799
|
+
private buildSessionServices(
|
|
800
|
+
cwd: string,
|
|
801
|
+
directTools: string[],
|
|
802
|
+
media?: ResultMedia,
|
|
803
|
+
spendGrant: readonly string[] = [],
|
|
804
|
+
): Promise<any> {
|
|
775
805
|
return serializeBuild(async () => {
|
|
806
|
+
const granted = new Set(spendGrant);
|
|
776
807
|
const gate: GateController = {
|
|
777
808
|
getMode: () => "bypass",
|
|
778
809
|
setMode: () => {},
|
|
@@ -783,6 +814,14 @@ export class Harbor {
|
|
|
783
814
|
async localAsk() {
|
|
784
815
|
return "deny";
|
|
785
816
|
},
|
|
817
|
+
// The billing media tools THIS run was granted. Without this the run below is a
|
|
818
|
+
// contradiction: a routine may name generate_video (naming it is the operator's
|
|
819
|
+
// decision — see MEDIA_GEN_TOOLS above), the tool registers, and then every call
|
|
820
|
+
// is denied, because `alwaysAsk` outranks bypass and localAsk has nobody to ask.
|
|
821
|
+
// Scoped to this session's gate rather than a process-wide switch, so two
|
|
822
|
+
// concurrent runs can hold different grants. The gate still refuses to let a
|
|
823
|
+
// grant cover a call that leaves cwd or reads a protected file.
|
|
824
|
+
isSpendPreauthorized: (req) => granted.has(req.tool),
|
|
786
825
|
};
|
|
787
826
|
// Every extension this session gets, in the one canonical order — including the MCP
|
|
788
827
|
// adapter (Phase 5), the web/media capability shaping, and the filter that ignores
|
|
@@ -927,8 +966,14 @@ export class Harbor {
|
|
|
927
966
|
// is going to the user's own mailbox, which `delivery: cloud` already chose.
|
|
928
967
|
const allowedTools = spec.media ? [...resolved.tools, ATTACH_RESULT_TOOL] : resolved.tools;
|
|
929
968
|
const { directToolsEnv, notes } = resolved;
|
|
969
|
+
// The billing tools this run may use: the ones its own allow-list names, and no
|
|
970
|
+
// others. Authorizes the run's own calls (the gate, below) and its subagents' —
|
|
971
|
+
// children are a separate process and read the grant from the environment, released
|
|
972
|
+
// in the finally so it can never outlive the run that holds it.
|
|
973
|
+
const spendGrant = allowedTools.filter((t) => BILLED_MEDIA_TOOLS.has(t));
|
|
974
|
+
const releaseChildSpend = grantChildSpend(`run:${++runSeq}`, spendGrant);
|
|
930
975
|
try {
|
|
931
|
-
const services = await this.buildSessionServices(spec.cwd, directToolsEnv, spec.media);
|
|
976
|
+
const services = await this.buildSessionServices(spec.cwd, directToolsEnv, spec.media, spendGrant);
|
|
932
977
|
|
|
933
978
|
const { provider, modelId } = parseSpec(spec.model);
|
|
934
979
|
if (provider === "privateer") {
|
|
@@ -978,6 +1023,9 @@ export class Harbor {
|
|
|
978
1023
|
// and its entry must survive a harbor run's teardown (see providers/account.ts).
|
|
979
1024
|
try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
|
|
980
1025
|
}
|
|
1026
|
+
// Drop this run's child grant. Unconditional and last: a grant left behind would
|
|
1027
|
+
// authorize the NEXT run's subagents for tools that run never named.
|
|
1028
|
+
releaseChildSpend();
|
|
981
1029
|
}
|
|
982
1030
|
return { out, status, error, notes };
|
|
983
1031
|
}
|
|
@@ -1014,10 +1062,13 @@ export class Harbor {
|
|
|
1014
1062
|
const content = redactText(formatTaskResult(title, out, status, error, modelSpec, notes), collectSecrets(config.providers));
|
|
1015
1063
|
const at = new Date().toISOString();
|
|
1016
1064
|
const staged = media.list();
|
|
1065
|
+
// What was asked, carried with what came back — a submitted task is followed up
|
|
1066
|
+
// on exactly like a routine run (the app's Inbox is the only place either is read).
|
|
1067
|
+
const source: OutboxSource = { prompt: spec.prompt, cwd, model: modelSpec };
|
|
1017
1068
|
// Durable delivery: seal to the outbox. If we can't seal yet (no verified pubkey /
|
|
1018
1069
|
// offline), queue it with kind:"task" so the flush re-seals it correctly later.
|
|
1019
|
-
const sealed = await this.postOutbox(title, at, status, content, "task", staged);
|
|
1020
|
-
if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task", ...(staged.length ? { media: staged } : {}) });
|
|
1070
|
+
const sealed = await this.postOutbox(title, at, status, content, "task", staged, source);
|
|
1071
|
+
if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task", source, ...(staged.length ? { media: staged } : {}) });
|
|
1021
1072
|
// Live mirror if a controller is attached (the outbox copy is the source of truth).
|
|
1022
1073
|
if (this.controllerAttached) this.relay?.sendTaskResult(title, content);
|
|
1023
1074
|
log(` task "${title}" ${status}; ${sealed ? "sealed to outbox" : "queued for outbox"}`);
|
|
@@ -1046,8 +1097,11 @@ export class Harbor {
|
|
|
1046
1097
|
void (async () => {
|
|
1047
1098
|
const at = new Date().toISOString();
|
|
1048
1099
|
const body = redactText(content, collectSecrets(loadHarborConfig().providers));
|
|
1049
|
-
|
|
1050
|
-
|
|
1100
|
+
// An empty-prompt spawn (a bare agent to drive) packs to no source at all
|
|
1101
|
+
// — cloudOutbox drops it rather than shipping an empty object.
|
|
1102
|
+
const source: OutboxSource = { prompt: spec.prompt, cwd: spec.cwd, model: spec.model };
|
|
1103
|
+
if (!(await this.postOutbox(title, at, status, body, "task", [], source))) {
|
|
1104
|
+
addPendingCloud({ routine: title, at, status, content: body, kind: "task", source });
|
|
1051
1105
|
}
|
|
1052
1106
|
})();
|
|
1053
1107
|
},
|