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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The moat's extension inventory, read from moatManifest.json — one list, four consumers.
|
|
2
|
+
//
|
|
3
|
+
// WHY A MANIFEST. Shipping an extension used to mean four edits in three files that
|
|
4
|
+
// nothing connects: bin/privateer-launch.mjs's MANAGED array (removes the stale shim),
|
|
5
|
+
// its shim() call (installs the new one), extensionsControl's RESERVED set (stops the
|
|
6
|
+
// app managing it as a user package), and the profile's extensionFactories list. Miss
|
|
7
|
+
// one and the failure is silent in a different way each time — a lingering shim pointing
|
|
8
|
+
// at a deleted file, an extension that never loads, or a moat package the app offers to
|
|
9
|
+
// uninstall. The launcher-vs-MANAGED pair had a test (tests/extensionLoad.test.ts) purely
|
|
10
|
+
// to catch the omission; the RESERVED half had none, and had already drifted —
|
|
11
|
+
// privateer-models and privateer-connect were shimmed but never reserved. Deriving all
|
|
12
|
+
// four from one file makes the drift unrepresentable rather than tested-for.
|
|
13
|
+
//
|
|
14
|
+
// WHY JSON. bin/privateer-launch.mjs reads this too, and it runs BEFORE the pi patches
|
|
15
|
+
// are applied, with no bundler and no dependencies — it can parse JSON and nothing else.
|
|
16
|
+
// A .ts manifest would need the launcher to load tsx, which is exactly the ordering the
|
|
17
|
+
// launcher exists to avoid.
|
|
18
|
+
//
|
|
19
|
+
// IMPORT-SAFETY: no Pi imports, no side effects — safe to load from anywhere, including
|
|
20
|
+
// pre-boot and from a jiti-loaded extension (the readFileSync + import.meta.url pattern
|
|
21
|
+
// is the one extensions/privateer-brand.ts already proves under Pi's loader).
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One shipped extension. `entry` is a repo-relative path to a first-party extension;
|
|
27
|
+
* `dep` is a node_modules specifier as [packageName, ...pathSegments], resolved through
|
|
28
|
+
* the node_modules chain at launch (npm hoists, so a fixed path would miss). Exactly one
|
|
29
|
+
* of the two is set.
|
|
30
|
+
*/
|
|
31
|
+
export interface MoatShim {
|
|
32
|
+
name: string;
|
|
33
|
+
entry?: string;
|
|
34
|
+
dep?: string[];
|
|
35
|
+
note?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Manifest {
|
|
39
|
+
shims: MoatShim[];
|
|
40
|
+
// Names we no longer install but still SWEEP from the agent dir on every launch, so a
|
|
41
|
+
// shim written by an older version can't linger and load a package we've since dropped.
|
|
42
|
+
retired: string[];
|
|
43
|
+
// Extra npm names the app must not manage: the scoped published names of packages we
|
|
44
|
+
// shim under a bare alias. A user hand-adding "@juicesharp/rpiv-web-tools" to settings
|
|
45
|
+
// "packages" would otherwise load a second copy alongside the launcher's shim.
|
|
46
|
+
reservedAliases: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const manifest: Manifest = JSON.parse(
|
|
50
|
+
readFileSync(new URL("./moatManifest.json", import.meta.url), "utf8"),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Every extension the launcher installs a shim for, in load order. */
|
|
54
|
+
export const MOAT_SHIMS: readonly MoatShim[] = Object.freeze(manifest.shims);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Names the launcher clears from the agent dir's extensions/ before installing: everything
|
|
58
|
+
* we ship plus everything we used to ship. The launcher reads the JSON directly (it cannot
|
|
59
|
+
* import TS), so this is the TS-side mirror — tests/extensionLoad.test.ts asserts they agree.
|
|
60
|
+
*/
|
|
61
|
+
export function managedNames(): string[] {
|
|
62
|
+
return [...manifest.shims.map((s) => s.name), ...manifest.retired];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Names the app's extension manager must refuse to add or remove. A superset of the
|
|
67
|
+
* managed names: also the scoped npm names behind our bare aliases.
|
|
68
|
+
*/
|
|
69
|
+
export function reservedNames(): string[] {
|
|
70
|
+
return [...managedNames(), ...manifest.reservedAliases];
|
|
71
|
+
}
|
package/src/harbor/index.ts
CHANGED
|
@@ -13,11 +13,9 @@ import {
|
|
|
13
13
|
import { agentDir, configPath } from "../config/paths.ts";
|
|
14
14
|
import { agentVersion } from "../config/version.ts";
|
|
15
15
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { type GateController } from "../ext/permissionGate.ts";
|
|
17
|
+
import { moatResourceOptions } from "../config/moat.ts";
|
|
18
18
|
import {
|
|
19
|
-
makeAccountProvider,
|
|
20
|
-
privateerChannel,
|
|
21
19
|
rememberAccountCredential,
|
|
22
20
|
dropPersistedAccountCredential,
|
|
23
21
|
} from "../providers/account.ts";
|
|
@@ -33,10 +31,10 @@ import type { Workflow, Step } from "../workflows/schema.ts";
|
|
|
33
31
|
import { readRunningPlatforms } from "../channels/status.ts";
|
|
34
32
|
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
35
33
|
import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
|
|
36
|
-
import { verifyChannelSave
|
|
34
|
+
import { verifyChannelSave } from "../crypto/accountVerify.ts";
|
|
37
35
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
38
36
|
import { authorizeControl } from "../remote/controlAuth.ts";
|
|
39
|
-
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke
|
|
37
|
+
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
|
|
40
38
|
import {
|
|
41
39
|
loadRoutines,
|
|
42
40
|
upsertRoutine,
|
|
@@ -56,14 +54,14 @@ import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigg
|
|
|
56
54
|
import { splitRoutineTools } from "../routines/toolSelect.ts";
|
|
57
55
|
import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "../mcp/toolNames.ts";
|
|
58
56
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
59
|
-
import {
|
|
57
|
+
import { postOutbox as sealToOutbox } from "../outbox/cloudOutbox.ts";
|
|
60
58
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
61
59
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
62
60
|
import { serializeBuild } from "./buildLock.ts";
|
|
63
|
-
import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
|
|
64
|
-
import {
|
|
65
|
-
import {
|
|
66
|
-
import {
|
|
61
|
+
import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
|
|
62
|
+
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
63
|
+
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
64
|
+
import { COMPOSE_TOOL_NAMES } from "../tools/videoCompose.ts";
|
|
67
65
|
|
|
68
66
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
69
67
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
@@ -79,23 +77,43 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
|
79
77
|
// the user hand-write an allow-list for it was the whole friction.
|
|
80
78
|
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
|
|
81
79
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
80
|
+
// Media generation (src/tools/media.ts) plus local composition (videoCompose.ts).
|
|
81
|
+
//
|
|
82
|
+
// Unlike the web tools these are deliberately NOT in the default allow-list, even with
|
|
83
|
+
// the switch on. Generation writes files and spends real credit — cents an image, up to
|
|
84
|
+
// a dollar a video clip — so an unattended routine gets them only by NAMING them, which
|
|
85
|
+
// makes "this routine can bill me for video" a decision someone made rather than a
|
|
86
|
+
// default they inherited. `media_capabilities` is free and read-only but stays with its
|
|
87
|
+
// siblings: on its own it would be a tool that only ever reports what the run can't do.
|
|
88
|
+
// Composition (video_compose) is deliberately NOT in this list: it is local ffmpeg work
|
|
89
|
+
// with no account, no network and no spend, so it stays grantable even with generation
|
|
90
|
+
// switched off — finishing the clips a previous run produced is exactly when that
|
|
91
|
+
// matters. See MEDIA_ALL for what actually gets registered.
|
|
92
|
+
const MEDIA_GEN_TOOLS: string[] = [...MEDIA_TOOL_NAMES];
|
|
93
|
+
const MEDIA_ALL: string[] = [...MEDIA_TOOL_NAMES, ...COMPOSE_TOOL_NAMES];
|
|
94
|
+
|
|
95
|
+
// Resolve a run's builtin allow-list. An explicit list wins, minus any web or media
|
|
96
|
+
// tools whose switch is off — a routine saved while one was on must not silently
|
|
97
|
+
// reference a tool that no longer registers.
|
|
85
98
|
function builtinToolsFor(explicit: string[]): string[] {
|
|
86
99
|
const web = webEnabled();
|
|
100
|
+
const media = mediaEnabled();
|
|
87
101
|
if (explicit.length > 0) {
|
|
88
|
-
return
|
|
102
|
+
return explicit.filter((t) => (web || !WEB_TOOLS.includes(t)) && (media || !MEDIA_GEN_TOOLS.includes(t)));
|
|
89
103
|
}
|
|
90
104
|
return web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS];
|
|
91
105
|
}
|
|
92
106
|
|
|
107
|
+
/** Every media tool name a routine may name, for the app's tool picker / docs. */
|
|
108
|
+
export function mediaToolNames(): string[] {
|
|
109
|
+
return mediaEnabled() ? [...MEDIA_ALL] : [...COMPOSE_TOOL_NAMES];
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
const TICK_MS = 60_000; // scan for due routines once a minute
|
|
94
113
|
// Harbor hosted mode (isHosted): suspend after this much idle time with no work,
|
|
95
114
|
// and stay up if a routine is due within the lead window (avoids suspend→wake churn).
|
|
96
115
|
const HOSTED_IDLE_MS = Number(process.env.HARBOR_IDLE_MS) || 5 * 60_000;
|
|
97
116
|
const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS) || 2 * 60_000;
|
|
98
|
-
const MAX_CLOUD_PLAINTEXT = 45_000;
|
|
99
117
|
// How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
|
|
100
118
|
// answer before it fail-closes to "no response" (the runner then defers the run). Bounds
|
|
101
119
|
// a stuck graph from pinning a `running` slot forever when the controller wanders off.
|
|
@@ -266,8 +284,6 @@ export class Harbor {
|
|
|
266
284
|
return "queued";
|
|
267
285
|
};
|
|
268
286
|
|
|
269
|
-
private outboxPub?: Uint8Array;
|
|
270
|
-
|
|
271
287
|
private readonly pushCloud: CloudPusher = async (routine, content, status) => {
|
|
272
288
|
const at = new Date().toISOString();
|
|
273
289
|
if (await this.postOutbox(routine.name, at, status, content)) return "sent";
|
|
@@ -276,16 +292,6 @@ export class Harbor {
|
|
|
276
292
|
};
|
|
277
293
|
|
|
278
294
|
async start(): Promise<void> {
|
|
279
|
-
// Mark the process before anything can create a session: the shipped TUI extensions
|
|
280
|
-
// are auto-discovered from the shared agent dir into every session we run, and the
|
|
281
|
-
// gate one has to stand its relay file tools down here so a live task's own pair
|
|
282
|
-
// (bound to that task's live relay) isn't shadowed. See config/harborDaemon.ts.
|
|
283
|
-
markHarborDaemon();
|
|
284
|
-
// Same reason, one step further: every session this process builds (routines,
|
|
285
|
-
// workflows, submitted tasks, live spawns) wires its own makePermissionGate, so
|
|
286
|
-
// the discovered gate must not install a second one on top of it. See
|
|
287
|
-
// config/inlineMoat.ts for what that collision costs.
|
|
288
|
-
markInlineMoat();
|
|
289
295
|
// Single-instance lock FIRST, before any other side effect: binding the IPC
|
|
290
296
|
// socket is the machine's mutex. If a live harbor already holds it this throws
|
|
291
297
|
// HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
|
|
@@ -566,55 +572,13 @@ export class Harbor {
|
|
|
566
572
|
}
|
|
567
573
|
|
|
568
574
|
// ── Cloud outbox (sealed store-and-forward) ────────────────────────────────
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const res = await apiRequest("/api/outbox/pubkey");
|
|
574
|
-
if (!res.ok) return undefined;
|
|
575
|
-
const data = (await res.json()) as { outboxPublicKey?: string | null; outboxPublicKeySig?: string | null };
|
|
576
|
-
if (!data.outboxPublicKey || !data.outboxPublicKeySig) return undefined;
|
|
577
|
-
// The key comes from the UNTRUSTED server. Verify the account's signature over it
|
|
578
|
-
// against the account signing key we pinned at link — otherwise a malicious server
|
|
579
|
-
// could substitute a key it controls and read every result we seal. Fail closed
|
|
580
|
-
// (no pin, missing sig, or bad sig ⇒ don't seal): the `cloud` channel then falls
|
|
581
|
-
// back to a local notice, so the result is deferred/kept, never leaked.
|
|
582
|
-
const accountPub = loadAccountSignKey();
|
|
583
|
-
if (!accountPub) return undefined;
|
|
584
|
-
if (!verifyOutboxKey(accountPub, data.outboxPublicKey, data.outboxPublicKeySig)) return undefined;
|
|
585
|
-
this.outboxPub = decodeAccountPublicKey(data.outboxPublicKey);
|
|
586
|
-
return this.outboxPub;
|
|
587
|
-
} catch {
|
|
588
|
-
return undefined;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// This machine's origin tag, embedded (E2EE) in every sealed result so the app can
|
|
593
|
-
// show WHICH box/environment produced it — the outbox record itself is account-only,
|
|
594
|
-
// so attribution can only live inside the sealed blob (where hostnames are allowed;
|
|
595
|
-
// the server never sees it). `id` is this install's stable relay id; `label` is the
|
|
596
|
-
// hostname-based device name. Cached — it never changes for the process lifetime.
|
|
597
|
-
private originCache?: { id: string; label: string };
|
|
598
|
-
private machineOrigin(): { id: string; label: string } {
|
|
599
|
-
if (!this.originCache) this.originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
|
|
600
|
-
return this.originCache;
|
|
601
|
-
}
|
|
575
|
+
// The sealing itself now lives in ../outbox/cloudOutbox.ts — an interactive
|
|
576
|
+
// remote-drive session needs the same path when the app isn't there to receive a
|
|
577
|
+
// finished turn, and two copies of an E2EE wire format is one too many. Same
|
|
578
|
+
// caches, same fail-closed key verification; this is just the harbor's caller.
|
|
602
579
|
|
|
603
580
|
private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: OutboxKind = "routine"): Promise<boolean> {
|
|
604
|
-
|
|
605
|
-
if (!pub) return false;
|
|
606
|
-
const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
607
|
-
const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: this.machineOrigin() });
|
|
608
|
-
try {
|
|
609
|
-
const res = await apiRequest("/api/outbox", {
|
|
610
|
-
method: "POST",
|
|
611
|
-
headers: { "content-type": "application/json" },
|
|
612
|
-
body: JSON.stringify({ sealed }),
|
|
613
|
-
});
|
|
614
|
-
return res.ok;
|
|
615
|
-
} catch {
|
|
616
|
-
return false;
|
|
617
|
-
}
|
|
581
|
+
return sealToOutbox(name, at, status, content, kind);
|
|
618
582
|
}
|
|
619
583
|
|
|
620
584
|
private async flushPendingCloud(): Promise<void> {
|
|
@@ -790,50 +754,22 @@ export class Harbor {
|
|
|
790
754
|
return "deny";
|
|
791
755
|
},
|
|
792
756
|
};
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
757
|
+
// Every extension this session gets, in the one canonical order — including the MCP
|
|
758
|
+
// adapter (Phase 5), the web/media capability shaping, and the filter that ignores
|
|
759
|
+
// any moat shim an older release left in the shared agent dir (releases up to 0.11
|
|
760
|
+
// installed one per extension; the launcher sweeps them, but a daemon can outlive
|
|
761
|
+
// the launch that would have). See config/moat.ts.
|
|
762
|
+
const moat = await moatResourceOptions({ kind: "harbor-session", gate });
|
|
763
|
+
// MCP_DIRECT_TOOLS is read by the adapter when its factory RUNS, inside
|
|
764
|
+
// createAgentSessionServices — so it has to wrap the session creation, not the
|
|
765
|
+
// moatResourceOptions() call that imported it.
|
|
801
766
|
const prevDirect = process.env.MCP_DIRECT_TOOLS;
|
|
802
767
|
process.env.MCP_DIRECT_TOOLS = directTools.length > 0 ? directTools.join(",") : "__none__";
|
|
803
768
|
try {
|
|
804
769
|
return await createAgentSessionServices({
|
|
805
770
|
cwd,
|
|
806
771
|
agentDir: agentDir(),
|
|
807
|
-
resourceLoaderOptions:
|
|
808
|
-
extensionFactories: [
|
|
809
|
-
makePermissionGate(gate),
|
|
810
|
-
// Per-model verified-TEE capability for pi-privacy's /models picker: show
|
|
811
|
-
// Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
|
|
812
|
-
// when logged in; ZDR-channel models stay at their honest floor. The live
|
|
813
|
-
// verdict still comes from accountPosture on select — this only lifts the label.
|
|
814
|
-
//
|
|
815
|
-
// ORDER MATTERS: pi-privacy's own catalog registers a `privateer`
|
|
816
|
-
// provider (its PUBLIC developer-key channel, one seed model), and Pi's
|
|
817
|
-
// registerProvider REPLACES a provider's models and request config. It
|
|
818
|
-
// must stay ABOVE makeAccountProvider() so the ACCOUNT channel lands last
|
|
819
|
-
// — otherwise the default model stops resolving and requests go to
|
|
820
|
-
// api.privateer.pro/v1 instead of /api/agent/v1. (The TUI hits this
|
|
821
|
-
// through extension discovery, where the order isn't ours to choose;
|
|
822
|
-
// extensions/privateer-privacy.ts re-asserts the account registration
|
|
823
|
-
// there. See registerAccountModels.)
|
|
824
|
-
makePiPrivacyExtension({
|
|
825
|
-
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
826
|
-
}),
|
|
827
|
-
makeAccountProvider(),
|
|
828
|
-
// Web access (src/tools/web.ts), when the agent is allowed it. Registered
|
|
829
|
-
// here rather than picked up from extensions/ because the harbor never
|
|
830
|
-
// installs the launcher's shims. Omitting the factory — not just dropping
|
|
831
|
-
// the names from the allow-list — is what makes "web off" mean the tools
|
|
832
|
-
// don't exist for this run at all.
|
|
833
|
-
...(webEnabled() ? [makeWebTools()] : []),
|
|
834
|
-
mcpAdapter,
|
|
835
|
-
] as any,
|
|
836
|
-
},
|
|
772
|
+
resourceLoaderOptions: moat as any,
|
|
837
773
|
});
|
|
838
774
|
} finally {
|
|
839
775
|
if (prevDirect === undefined) delete process.env.MCP_DIRECT_TOOLS;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The cloud outbox sender — sealed E2EE store-and-forward from this machine to
|
|
2
|
+
// the account's app.
|
|
3
|
+
//
|
|
4
|
+
// Lifted out of the harbor (which owned it privately) because it is no longer only
|
|
5
|
+
// the harbor's: an INTERACTIVE remote-drive session needs it too. When the app is
|
|
6
|
+
// closed — or its socket is simply gone — a driven turn still finishes, and until
|
|
7
|
+
// now its answer went nowhere: the relay drops frames with no controller attached,
|
|
8
|
+
// so the reply was written to a socket nobody was reading. Sealing it here puts it
|
|
9
|
+
// in the account's Inbox instead (see src/cli/chat.ts → deliverUnwatchedTurn).
|
|
10
|
+
//
|
|
11
|
+
// The terminal holds NO account key material: it seals TO the account's published
|
|
12
|
+
// X25519 public key and can never open what it (or any other terminal) wrote. That
|
|
13
|
+
// key comes from the UNTRUSTED server, so it is only used once the account's Ed25519
|
|
14
|
+
// signature over it verifies against the key pinned at link time. Fail closed: no
|
|
15
|
+
// pin, no signature, or a bad signature ⇒ we don't seal at all, and the caller falls
|
|
16
|
+
// back to its own durable channel (a queue, a file, a notice).
|
|
17
|
+
//
|
|
18
|
+
// Module-level caches (pubkey, machine origin) — one per process, exactly like the
|
|
19
|
+
// per-instance caches this replaced.
|
|
20
|
+
|
|
21
|
+
import { apiRequest, defaultDeviceLabel } from "../auth/privateer.ts";
|
|
22
|
+
import { loadAccountSignKey } from "../crypto/accountTrust.ts";
|
|
23
|
+
import { verifyOutboxKey } from "../crypto/accountVerify.ts";
|
|
24
|
+
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
25
|
+
import { routineRelayId, type OutboxKind } from "../routines/store.ts";
|
|
26
|
+
|
|
27
|
+
export type { OutboxKind };
|
|
28
|
+
|
|
29
|
+
// Plaintext cap per sealed item. The server rejects anything over ~128 KB of
|
|
30
|
+
// base64; this keeps us well inside that with room for the envelope, and matches
|
|
31
|
+
// the mailbox's purpose — summaries and answers, not transcripts.
|
|
32
|
+
export const MAX_CLOUD_PLAINTEXT = 45_000;
|
|
33
|
+
|
|
34
|
+
let outboxPub: Uint8Array | undefined;
|
|
35
|
+
let originCache: { id: string; label: string } | undefined;
|
|
36
|
+
|
|
37
|
+
/** Fetch + verify the account's outbox public key (cached for the process). */
|
|
38
|
+
export async function ensureOutboxPub(): Promise<Uint8Array | undefined> {
|
|
39
|
+
if (outboxPub) return outboxPub;
|
|
40
|
+
try {
|
|
41
|
+
const res = await apiRequest("/api/outbox/pubkey");
|
|
42
|
+
if (!res.ok) return undefined;
|
|
43
|
+
const data = (await res.json()) as { outboxPublicKey?: string | null; outboxPublicKeySig?: string | null };
|
|
44
|
+
if (!data.outboxPublicKey || !data.outboxPublicKeySig) return undefined;
|
|
45
|
+
// Verify the account's signature over the key against the signing key pinned at
|
|
46
|
+
// link — otherwise a malicious server could substitute a key it controls and read
|
|
47
|
+
// every result we seal.
|
|
48
|
+
const accountPub = loadAccountSignKey();
|
|
49
|
+
if (!accountPub) return undefined;
|
|
50
|
+
if (!verifyOutboxKey(accountPub, data.outboxPublicKey, data.outboxPublicKeySig)) return undefined;
|
|
51
|
+
outboxPub = decodeAccountPublicKey(data.outboxPublicKey);
|
|
52
|
+
return outboxPub;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* This machine's origin tag, embedded (E2EE) in every sealed result so the app can
|
|
60
|
+
* show WHICH box produced it. The outbox record itself is account-only, so attribution
|
|
61
|
+
* can only live inside the sealed blob — where a hostname is allowed, since the server
|
|
62
|
+
* never sees it. `id` is this install's stable relay id; `label` the device name.
|
|
63
|
+
*/
|
|
64
|
+
export function machineOrigin(): { id: string; label: string } {
|
|
65
|
+
if (!originCache) originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
|
|
66
|
+
return originCache;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Seal one result to the account outbox and POST the ciphertext. Returns whether the
|
|
71
|
+
* server accepted it; false covers every failure (no verified key, offline, rejected)
|
|
72
|
+
* and means the caller must fall back to its own durable channel.
|
|
73
|
+
*/
|
|
74
|
+
export async function postOutbox(
|
|
75
|
+
name: string,
|
|
76
|
+
at: string,
|
|
77
|
+
status: "ok" | "error",
|
|
78
|
+
content: string,
|
|
79
|
+
kind: OutboxKind = "routine",
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
const pub = await ensureOutboxPub();
|
|
82
|
+
if (!pub) return false;
|
|
83
|
+
const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
84
|
+
const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: machineOrigin() });
|
|
85
|
+
try {
|
|
86
|
+
const res = await apiRequest("/api/outbox", {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ sealed }),
|
|
90
|
+
});
|
|
91
|
+
return res.ok;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -160,6 +160,42 @@ const EDIT_TOOLS = new Set(["edit", "edit_file", "str_replace", "str_replace_edi
|
|
|
160
160
|
const WRITE_TOOLS = new Set(["write", "write_file", "create_file", "create", "save_attachment"]);
|
|
161
161
|
const BASH_TOOLS = new Set(["bash", "shell", "run", "exec", "sh"]);
|
|
162
162
|
|
|
163
|
+
// Media tools (src/tools/media.ts, src/tools/videoCompose.ts). See the block in
|
|
164
|
+
// classifyToolCall — they are writes against a named output file, and the generation
|
|
165
|
+
// ones cost real money, which is what the title says out loud.
|
|
166
|
+
const MEDIA_TOOLS = new Set([
|
|
167
|
+
"generate_image",
|
|
168
|
+
"generate_video",
|
|
169
|
+
"generate_speech",
|
|
170
|
+
"generate_music",
|
|
171
|
+
"media_capabilities",
|
|
172
|
+
"video_compose",
|
|
173
|
+
]);
|
|
174
|
+
// The generation tools egress model-chosen text (and any input file bytes) to
|
|
175
|
+
// Privateer's servers and onward to a provider — generate_music to one with no
|
|
176
|
+
// zero-retention endpoint — and each spends the account's credit. That egress +
|
|
177
|
+
// irreversible spend must never be auto-approved: classified as a plain `write`
|
|
178
|
+
// they were swallowed by acceptEdits (mode.ts:45) and by bypass/no-quarter, so a
|
|
179
|
+
// single injected call could leak context and bill the account with no dialog.
|
|
180
|
+
// `alwaysAsk` sits ABOVE bypass/acceptEdits/allowlist (mode.ts:37) and is never
|
|
181
|
+
// remembered, so every generation is a fresh human decision. video_compose is
|
|
182
|
+
// excluded on purpose — it is local ffmpeg, no egress and no spend — and
|
|
183
|
+
// media_capabilities is a read.
|
|
184
|
+
const MEDIA_GEN_TOOLS = new Set([
|
|
185
|
+
"generate_image",
|
|
186
|
+
"generate_video",
|
|
187
|
+
"generate_speech",
|
|
188
|
+
"generate_music",
|
|
189
|
+
]);
|
|
190
|
+
const MEDIA_TITLES: Record<string, string> = {
|
|
191
|
+
generate_image: "Generate an image (billed to your Privateer account)",
|
|
192
|
+
generate_video: "Generate a video (billed to your Privateer account)",
|
|
193
|
+
generate_speech: "Generate speech (billed to your Privateer account)",
|
|
194
|
+
generate_music: "Generate music (billed; music prompts have no zero-retention option)",
|
|
195
|
+
video_compose: "Compose video/audio locally",
|
|
196
|
+
media_capabilities: "Read media capabilities",
|
|
197
|
+
};
|
|
198
|
+
|
|
163
199
|
export function classifyToolCall(
|
|
164
200
|
toolName: string,
|
|
165
201
|
input: unknown,
|
|
@@ -189,6 +225,83 @@ export function classifyToolCall(
|
|
|
189
225
|
};
|
|
190
226
|
}
|
|
191
227
|
|
|
228
|
+
// Media generation (src/tools/media.ts) and local composition (videoCompose.ts).
|
|
229
|
+
//
|
|
230
|
+
// Left to the unknown-tool branch at the bottom these classify as bash-kind, which
|
|
231
|
+
// prompts with a JSON blob nobody can read and — worse — DENIES outright in plan and
|
|
232
|
+
// readonly mode, where a media call is exactly as legitimate as any other write.
|
|
233
|
+
// They are writes: each produces one named file, and the generation ones also spend
|
|
234
|
+
// the account's credit, so the prompt should name the file and the cost.
|
|
235
|
+
//
|
|
236
|
+
// `outside` covers BOTH directions. The output is the obvious one. The inputs matter
|
|
237
|
+
// just as much for the generation tools: `images: ["~/.ssh/id_rsa.png"]` would upload
|
|
238
|
+
// a file from outside scope to our servers, so an out-of-scope INPUT has to prompt
|
|
239
|
+
// even when the output lands neatly in cwd.
|
|
240
|
+
if (MEDIA_TOOLS.has(name)) {
|
|
241
|
+
const compose = name === "video_compose";
|
|
242
|
+
const inputs = [
|
|
243
|
+
...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
|
|
244
|
+
str(obj.input),
|
|
245
|
+
str(obj.audio),
|
|
246
|
+
...(Array.isArray(obj.images) ? (obj.images as unknown[]).map(str) : []),
|
|
247
|
+
str(obj.firstFrame),
|
|
248
|
+
str(obj.lastFrame),
|
|
249
|
+
].filter(Boolean);
|
|
250
|
+
// Resolve each input once, then flag the two ways an input is sensitive: it leaves
|
|
251
|
+
// the working directory, or it is a guarded file (.env, keys, credentials, …). The
|
|
252
|
+
// generation tools base64 every input up to our servers, so a PROTECTED input is a
|
|
253
|
+
// credential-exfil risk even when the output lands neatly in cwd — and the human
|
|
254
|
+
// approving what looks like a thumbnail has to see which file is being read. Both
|
|
255
|
+
// therefore feed `protected`/`outside` and are named in the prompt detail below.
|
|
256
|
+
const resolvedInputs = inputs.map((p) => resolveInCwd(scope.cwd, p));
|
|
257
|
+
const outsideInputs = resolvedInputs.filter((a) => isOutsideScope(scope, a));
|
|
258
|
+
const protectedInputs = resolvedInputs.filter((a) => isProtectedPath(a));
|
|
259
|
+
|
|
260
|
+
const outPath = str(obj.path ?? obj.output);
|
|
261
|
+
// `probe` reads and writes nothing; so does any composition call with no output
|
|
262
|
+
// (which the tool itself rejects). Gate those only when they touch a sensitive input.
|
|
263
|
+
if (!outPath) {
|
|
264
|
+
if (compose || name === "media_capabilities") {
|
|
265
|
+
const flagged = protectedInputs[0] ?? outsideInputs[0];
|
|
266
|
+
if (!flagged) return null;
|
|
267
|
+
return {
|
|
268
|
+
tool: toolName,
|
|
269
|
+
kind: "read",
|
|
270
|
+
title: protectedInputs.length > 0 ? "Read a protected file" : "Read outside working directory",
|
|
271
|
+
detail: flagged,
|
|
272
|
+
protected: protectedInputs.length > 0,
|
|
273
|
+
outside: outsideInputs.length > 0,
|
|
274
|
+
path: flagged,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return unknownTarget(toolName, "write"); // a generation call with no destination
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const absOut = resolveInCwd(scope.cwd, outPath);
|
|
281
|
+
const outputOutside = isOutsideScope(scope, absOut);
|
|
282
|
+
const outside = outputOutside || outsideInputs.length > 0;
|
|
283
|
+
// Name the sensitive input in the prompt — protected first (the more dangerous
|
|
284
|
+
// disclosure), then outside-scope. Base target is the output, absolute when it
|
|
285
|
+
// itself leaves cwd, else the path the caller wrote.
|
|
286
|
+
const inputNote = protectedInputs.length > 0
|
|
287
|
+
? ` (reads protected file ${protectedInputs[0]}${protectedInputs.length > 1 ? ` +${protectedInputs.length - 1} more` : ""})`
|
|
288
|
+
: outsideInputs.length > 0
|
|
289
|
+
? ` (reads ${outsideInputs[0]}, outside the working directory)`
|
|
290
|
+
: "";
|
|
291
|
+
return {
|
|
292
|
+
tool: toolName,
|
|
293
|
+
kind: "write",
|
|
294
|
+
title: outside
|
|
295
|
+
? `${MEDIA_TITLES[name]} outside working directory`
|
|
296
|
+
: MEDIA_TITLES[name],
|
|
297
|
+
detail: `${outputOutside ? absOut : outPath}${inputNote}`,
|
|
298
|
+
protected: isProtectedPath(absOut) || protectedInputs.length > 0,
|
|
299
|
+
outside,
|
|
300
|
+
alwaysAsk: MEDIA_GEN_TOOLS.has(name),
|
|
301
|
+
path: absOut,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
192
305
|
// Shell — the whole command is the detail (danger scanning runs on it).
|
|
193
306
|
if (BASH_TOOLS.has(name)) {
|
|
194
307
|
const command = str(obj.command ?? obj.cmd ?? obj.script);
|