privateer-agent 0.12.8 → 0.12.11
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 +29 -10
- package/SECURITY.md +41 -5
- package/bin/privateer-launch.mjs +10 -0
- package/bin/privateer-verify.d.mts +10 -0
- package/bin/privateer-verify.mjs +289 -0
- package/extensions/privateer-brand.ts +8 -2
- package/extensions/privateer-hints.ts +8 -2
- package/extensions/privateer-speak.ts +1 -1
- package/package.json +25 -25
- package/patches/{@earendil-works+pi-coding-agent+0.80.3.patch → @earendil-works+pi-coding-agent+0.84.1.patch} +138 -70
- package/src/acp/run.ts +5 -4
- package/src/channels/run.ts +3 -1
- package/src/cli/chat.ts +7 -5
- package/src/config/moat.ts +13 -0
- package/src/harbor/index.ts +72 -21
- package/src/main.ts +1 -1
- package/src/nostr/event.ts +1 -1
- package/src/outbox/cloudOutbox.ts +179 -3
- package/src/providers/account.ts +38 -30
- package/src/providers/defaultModel.ts +1 -1
- package/src/providers/piAuthStore.ts +129 -0
- package/src/providers/sealedShim.ts +1 -1
- package/src/remote/liveTaskSession.ts +6 -6
- package/src/routines/delivery.ts +15 -2
- package/src/routines/resultBrief.ts +60 -0
- package/src/routines/resultMedia.ts +131 -0
- package/src/routines/schema.ts +1 -1
- package/src/routines/store.ts +7 -0
- package/src/session.ts +26 -9
- package/src/tools/attachResult.ts +66 -0
- package/src/tools/media.ts +2 -2
- package/src/tools/sendFile.ts +4 -1
- package/src/tools/videoCompose.ts +1 -1
- package/src/tools/web.ts +1 -1
package/src/cli/chat.ts
CHANGED
|
@@ -44,8 +44,10 @@ async function main() {
|
|
|
44
44
|
const {
|
|
45
45
|
accountPosture,
|
|
46
46
|
rememberAccountCredential,
|
|
47
|
+
persistAccountCredential,
|
|
47
48
|
dropPersistedAccountCredential,
|
|
48
49
|
} = await import("../providers/account.ts");
|
|
50
|
+
const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
|
|
49
51
|
const { agentVersion } = await import("../config/version.ts");
|
|
50
52
|
const { pickerCatalog, hiddenAccountNotice, hiddenAccountTitleSuffix } = await import("../providers/modelCatalog.ts");
|
|
51
53
|
const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
|
|
@@ -448,7 +450,7 @@ async function main() {
|
|
|
448
450
|
try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
|
|
449
451
|
// Ownership-checked: auth.json is machine-global, so removing an entry another
|
|
450
452
|
// running terminal minted would strand it (see providers/account.ts).
|
|
451
|
-
try { dropPersistedAccountCredential(
|
|
453
|
+
try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
|
|
452
454
|
}
|
|
453
455
|
const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
|
|
454
456
|
process.once("SIGINT", onSignal);
|
|
@@ -459,14 +461,14 @@ async function main() {
|
|
|
459
461
|
if (provider === "privateer") {
|
|
460
462
|
try {
|
|
461
463
|
const creds = await priv.acquireAccountCredential();
|
|
462
|
-
|
|
464
|
+
await persistAccountCredential(creds);
|
|
463
465
|
rememberAccountCredential(creds); // claim it, so cleanup drops OUR entry and only ours
|
|
464
466
|
} catch (e) {
|
|
465
467
|
console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
|
|
466
468
|
}
|
|
467
469
|
}
|
|
468
470
|
|
|
469
|
-
const model = (services
|
|
471
|
+
const model = (modelRegistryOf(services) as any).find(provider, modelId);
|
|
470
472
|
if (!model) {
|
|
471
473
|
console.log(`${RED}Model ${provider}/${modelId} not found.${RESET} Set PRIVATEER_MODEL=provider/id and check the key is in .env.`);
|
|
472
474
|
rl.close();
|
|
@@ -633,7 +635,7 @@ async function main() {
|
|
|
633
635
|
// is filtered out until the credential is armed).
|
|
634
636
|
const SIGN_IN_HINT = "Run /login.";
|
|
635
637
|
async function modelCatalog() {
|
|
636
|
-
return pickerCatalog(services
|
|
638
|
+
return pickerCatalog(modelRegistryOf(services) as any);
|
|
637
639
|
}
|
|
638
640
|
|
|
639
641
|
// Switch the live session's model in place (history preserved — see
|
|
@@ -644,7 +646,7 @@ async function main() {
|
|
|
644
646
|
const at = sp.indexOf("/");
|
|
645
647
|
if (at < 0) { const m = "Usage: /model provider/id"; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
|
|
646
648
|
const p = sp.slice(0, at), id = sp.slice(at + 1);
|
|
647
|
-
const model = (session.modelRegistry as any).find?.(p, id) ?? (services
|
|
649
|
+
const model = (session.modelRegistry as any).find?.(p, id) ?? (modelRegistryOf(services) as any).find?.(p, id);
|
|
648
650
|
if (!model) { const m = `Model ${sp} not found — try /models.`; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
|
|
649
651
|
try {
|
|
650
652
|
await session.setModel(model);
|
package/src/config/moat.ts
CHANGED
|
@@ -62,6 +62,14 @@ export interface MoatOptions {
|
|
|
62
62
|
* pair is what the model gets (see tools/relayFileTools.ts).
|
|
63
63
|
*/
|
|
64
64
|
relayFiles?: { bridge: SendFileBridge; attachments: AttachmentStore };
|
|
65
|
+
/**
|
|
66
|
+
* THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
|
|
67
|
+
* a path whose result reaches the app's Inbox — a scheduled routine, a submitted
|
|
68
|
+
* task — and never shared between runs, so yesterday's staged file can't ride along
|
|
69
|
+
* with today's. Absent ⇒ attach_to_result doesn't exist for the session, which is
|
|
70
|
+
* the point: a run whose result goes to a webhook or a file has nothing to attach to.
|
|
71
|
+
*/
|
|
72
|
+
resultMedia?: import("../routines/resultMedia.ts").ResultMedia;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
/**
|
|
@@ -206,6 +214,11 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
206
214
|
factories.push(makeRelayFileTools(opts.relayFiles.bridge, opts.relayFiles.attachments));
|
|
207
215
|
}
|
|
208
216
|
|
|
217
|
+
if (opts.resultMedia) {
|
|
218
|
+
const { makeAttachResultTools } = await import("../tools/attachResult.ts");
|
|
219
|
+
factories.push(makeAttachResultTools(opts.resultMedia));
|
|
220
|
+
}
|
|
221
|
+
|
|
209
222
|
if (caps.web && webEnabled()) {
|
|
210
223
|
const { makeWebTools } = await import("../tools/web.ts");
|
|
211
224
|
factories.push(makeWebTools());
|
package/src/harbor/index.ts
CHANGED
|
@@ -17,8 +17,11 @@ import { type GateController } from "../ext/permissionGate.ts";
|
|
|
17
17
|
import { moatResourceOptions } from "../config/moat.ts";
|
|
18
18
|
import {
|
|
19
19
|
rememberAccountCredential,
|
|
20
|
+
persistAccountCredential,
|
|
20
21
|
dropPersistedAccountCredential,
|
|
21
22
|
} from "../providers/account.ts";
|
|
23
|
+
// Pi-free static graph by design — see the header of piAuthStore.ts.
|
|
24
|
+
import { modelRegistryOf } from "../providers/piAuthStore.ts";
|
|
22
25
|
import { resolveDefaultModel } from "../providers/defaultModel.ts";
|
|
23
26
|
import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
24
27
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
@@ -54,6 +57,9 @@ import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigg
|
|
|
54
57
|
import { splitRoutineTools } from "../routines/toolSelect.ts";
|
|
55
58
|
import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "../mcp/toolNames.ts";
|
|
56
59
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
60
|
+
import { ResultMedia, type StagedMedia } from "../routines/resultMedia.ts";
|
|
61
|
+
import { withBrief } from "../routines/resultBrief.ts";
|
|
62
|
+
import { ATTACH_RESULT_TOOL } from "../tools/attachResult.ts";
|
|
57
63
|
import { postOutbox as sealToOutbox } from "../outbox/cloudOutbox.ts";
|
|
58
64
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
59
65
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
@@ -284,10 +290,14 @@ export class Harbor {
|
|
|
284
290
|
return "queued";
|
|
285
291
|
};
|
|
286
292
|
|
|
287
|
-
private readonly pushCloud: CloudPusher = async (routine, content, status) => {
|
|
293
|
+
private readonly pushCloud: CloudPusher = async (routine, content, status, media = []) => {
|
|
288
294
|
const at = new Date().toISOString();
|
|
289
|
-
if (await this.postOutbox(routine.name, at, status, content)) return "sent";
|
|
290
|
-
|
|
295
|
+
if (await this.postOutbox(routine.name, at, status, content, "routine", media)) return "sent";
|
|
296
|
+
// The queued copy keeps the attachment RECORDS, not the bytes: the files are
|
|
297
|
+
// still on this disk, so a flush hours later re-reads them (and says so in the
|
|
298
|
+
// body for any that have since gone). Buffering megabytes into a JSON queue
|
|
299
|
+
// would be the same content twice, on a box that already has it.
|
|
300
|
+
addPendingCloud({ routine: routine.name, at, status, content, ...(media.length ? { media } : {}) });
|
|
291
301
|
return "queued";
|
|
292
302
|
};
|
|
293
303
|
|
|
@@ -577,8 +587,15 @@ export class Harbor {
|
|
|
577
587
|
// finished turn, and two copies of an E2EE wire format is one too many. Same
|
|
578
588
|
// caches, same fail-closed key verification; this is just the harbor's caller.
|
|
579
589
|
|
|
580
|
-
private async postOutbox(
|
|
581
|
-
|
|
590
|
+
private async postOutbox(
|
|
591
|
+
name: string,
|
|
592
|
+
at: string,
|
|
593
|
+
status: "ok" | "error",
|
|
594
|
+
content: string,
|
|
595
|
+
kind: OutboxKind = "routine",
|
|
596
|
+
media: StagedMedia[] = [],
|
|
597
|
+
): Promise<boolean> {
|
|
598
|
+
return sealToOutbox(name, at, status, content, kind, media);
|
|
582
599
|
}
|
|
583
600
|
|
|
584
601
|
private async flushPendingCloud(): Promise<void> {
|
|
@@ -587,7 +604,10 @@ export class Harbor {
|
|
|
587
604
|
if (queue.length === 0) return;
|
|
588
605
|
const remaining: PendingCloud[] = [];
|
|
589
606
|
for (const p of queue) {
|
|
590
|
-
if (
|
|
607
|
+
if (
|
|
608
|
+
remaining.length === 0 &&
|
|
609
|
+
(await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? []))
|
|
610
|
+
) continue;
|
|
591
611
|
remaining.push(p);
|
|
592
612
|
}
|
|
593
613
|
if (remaining.length !== queue.length) {
|
|
@@ -705,15 +725,25 @@ export class Harbor {
|
|
|
705
725
|
log(" note: email delivery is not wired yet (Phase 5) — skipping it");
|
|
706
726
|
}
|
|
707
727
|
|
|
728
|
+
// A routine delivering to the app's Inbox can answer with more than prose: an
|
|
729
|
+
// artifact fence the app turns into a Cargo card, and files staged through
|
|
730
|
+
// attach_to_result. Both are wired here rather than in the session builder,
|
|
731
|
+
// because both only make sense when there is an Inbox at the other end — a
|
|
732
|
+
// file- or webhook-only routine gets neither the tool nor the brief.
|
|
733
|
+
const toInbox = routine.delivery.includes("cloud");
|
|
734
|
+
const media = toInbox ? new ResultMedia() : undefined;
|
|
735
|
+
|
|
708
736
|
const { out, status, error, notes } = await this.runSession({
|
|
709
|
-
prompt: routine.prompt,
|
|
737
|
+
prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
|
|
710
738
|
cwd: routine.cwd,
|
|
711
739
|
model: modelSpec,
|
|
712
740
|
tools: routine.tools ?? [],
|
|
741
|
+
media,
|
|
713
742
|
});
|
|
714
743
|
|
|
715
744
|
const content = formatResult(routine, out, status, error, notes);
|
|
716
745
|
const report = await deliver(routine, content, status, {
|
|
746
|
+
media: media?.list() ?? [],
|
|
717
747
|
pushRelay: this.pushRelay,
|
|
718
748
|
pushCloud: this.pushCloud,
|
|
719
749
|
webhooks: config.webhooks,
|
|
@@ -741,7 +771,7 @@ export class Harbor {
|
|
|
741
771
|
// activation, so the build is serialized (serializeBuild) and the previous value is
|
|
742
772
|
// restored. "__none__" is its sentinel for "register none": an unattended run with
|
|
743
773
|
// no connector selectors gets no direct tools, whatever a shared mcp.json says.
|
|
744
|
-
private buildSessionServices(cwd: string, directTools: string[]): Promise<any> {
|
|
774
|
+
private buildSessionServices(cwd: string, directTools: string[], media?: ResultMedia): Promise<any> {
|
|
745
775
|
return serializeBuild(async () => {
|
|
746
776
|
const gate: GateController = {
|
|
747
777
|
getMode: () => "bypass",
|
|
@@ -759,7 +789,7 @@ export class Harbor {
|
|
|
759
789
|
// any moat shim an older release left in the shared agent dir (releases up to 0.11
|
|
760
790
|
// installed one per extension; the launcher sweeps them, but a daemon can outlive
|
|
761
791
|
// the launch that would have). See config/moat.ts.
|
|
762
|
-
const moat = await moatResourceOptions({ kind: "harbor-session", gate });
|
|
792
|
+
const moat = await moatResourceOptions({ kind: "harbor-session", gate, resultMedia: media });
|
|
763
793
|
// MCP_DIRECT_TOOLS is read by the adapter when its factory RUNS, inside
|
|
764
794
|
// createAgentSessionServices — so it has to wrap the session creation, not the
|
|
765
795
|
// moatResourceOptions() call that imported it.
|
|
@@ -849,7 +879,7 @@ export class Harbor {
|
|
|
849
879
|
rmSync(join(agentDir(), "mcp-cache.json"), { force: true });
|
|
850
880
|
const services = await this.buildSessionServices(cwd, []);
|
|
851
881
|
const { provider, modelId } = parseSpec(modelSpec);
|
|
852
|
-
const model = (services
|
|
882
|
+
const model = (modelRegistryOf(services) as any).find(provider, modelId);
|
|
853
883
|
if (!model) return;
|
|
854
884
|
const { session } = await createAgentSessionFromServices({
|
|
855
885
|
services,
|
|
@@ -878,22 +908,33 @@ export class Harbor {
|
|
|
878
908
|
// shell command still fail-closes headlessly (localAsk denies) — plus per-run account
|
|
879
909
|
// credentials that are revoked in the finally so they never linger as an orphaned
|
|
880
910
|
// "device" in the app's Linked Devices.
|
|
881
|
-
private async runSession(spec: {
|
|
911
|
+
private async runSession(spec: {
|
|
912
|
+
prompt: string;
|
|
913
|
+
cwd: string;
|
|
914
|
+
model: string;
|
|
915
|
+
tools: string[];
|
|
916
|
+
media?: ResultMedia;
|
|
917
|
+
}): Promise<{ out: string; status: "ok" | "error"; error?: string; notes: string[] }> {
|
|
882
918
|
let out = "";
|
|
883
919
|
let status: "ok" | "error" = "ok";
|
|
884
920
|
let error: string | undefined;
|
|
885
|
-
let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
|
|
886
921
|
let spawnedAccount = false;
|
|
887
|
-
const
|
|
922
|
+
const resolved = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
|
|
923
|
+
// attach_to_result rides with the staging area rather than the allow-list: it is
|
|
924
|
+
// how the answer is DELIVERED, not a capability the run gains. A routine that
|
|
925
|
+
// named an explicit tool list still gets it, because the alternative is a run
|
|
926
|
+
// that can generate a still and then has no way to hand it over — and the file
|
|
927
|
+
// is going to the user's own mailbox, which `delivery: cloud` already chose.
|
|
928
|
+
const allowedTools = spec.media ? [...resolved.tools, ATTACH_RESULT_TOOL] : resolved.tools;
|
|
929
|
+
const { directToolsEnv, notes } = resolved;
|
|
888
930
|
try {
|
|
889
|
-
const services = await this.buildSessionServices(spec.cwd, directToolsEnv);
|
|
890
|
-
servicesRef = services as any;
|
|
931
|
+
const services = await this.buildSessionServices(spec.cwd, directToolsEnv, spec.media);
|
|
891
932
|
|
|
892
933
|
const { provider, modelId } = parseSpec(spec.model);
|
|
893
934
|
if (provider === "privateer") {
|
|
894
935
|
try {
|
|
895
936
|
const creds = await acquireAccountCredential();
|
|
896
|
-
|
|
937
|
+
await persistAccountCredential(creds);
|
|
897
938
|
rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
|
|
898
939
|
spawnedAccount = true;
|
|
899
940
|
} catch (e) {
|
|
@@ -901,7 +942,7 @@ export class Harbor {
|
|
|
901
942
|
}
|
|
902
943
|
}
|
|
903
944
|
|
|
904
|
-
const model = (services
|
|
945
|
+
const model = (modelRegistryOf(services) as any).find(provider, modelId);
|
|
905
946
|
if (!model) {
|
|
906
947
|
status = "error";
|
|
907
948
|
error = `model ${provider}/${modelId} not found`;
|
|
@@ -935,7 +976,7 @@ export class Harbor {
|
|
|
935
976
|
try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
|
|
936
977
|
// Ownership-checked: an interactive terminal on this machine shares auth.json,
|
|
937
978
|
// and its entry must survive a harbor run's teardown (see providers/account.ts).
|
|
938
|
-
try { dropPersistedAccountCredential(
|
|
979
|
+
try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
|
|
939
980
|
}
|
|
940
981
|
}
|
|
941
982
|
return { out, status, error, notes };
|
|
@@ -959,14 +1000,24 @@ export class Harbor {
|
|
|
959
1000
|
}
|
|
960
1001
|
this.running.add(key);
|
|
961
1002
|
log(`running task "${title}"`);
|
|
1003
|
+
// A submitted task ALWAYS answers into the Inbox, so it always gets the brief and
|
|
1004
|
+
// the attachment tool — the app is the only place its result is ever read.
|
|
1005
|
+
const media = new ResultMedia();
|
|
962
1006
|
try {
|
|
963
|
-
const { out, status, error, notes } = await this.runSession({
|
|
1007
|
+
const { out, status, error, notes } = await this.runSession({
|
|
1008
|
+
prompt: withBrief(spec.prompt, { inbox: true, canAttach: true }),
|
|
1009
|
+
cwd,
|
|
1010
|
+
model: modelSpec,
|
|
1011
|
+
tools: spec.tools ?? [],
|
|
1012
|
+
media,
|
|
1013
|
+
});
|
|
964
1014
|
const content = redactText(formatTaskResult(title, out, status, error, modelSpec, notes), collectSecrets(config.providers));
|
|
965
1015
|
const at = new Date().toISOString();
|
|
1016
|
+
const staged = media.list();
|
|
966
1017
|
// Durable delivery: seal to the outbox. If we can't seal yet (no verified pubkey /
|
|
967
1018
|
// offline), queue it with kind:"task" so the flush re-seals it correctly later.
|
|
968
|
-
const sealed = await this.postOutbox(title, at, status, content, "task");
|
|
969
|
-
if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task" });
|
|
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 } : {}) });
|
|
970
1021
|
// Live mirror if a controller is attached (the outbox copy is the source of truth).
|
|
971
1022
|
if (this.controllerAttached) this.relay?.sendTaskResult(title, content);
|
|
972
1023
|
log(` task "${title}" ${status}; ${sealed ? "sealed to outbox" : "queued for outbox"}`);
|
package/src/main.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// boot's side effects are guaranteed to have run before any Pi module loads.
|
|
4
4
|
//
|
|
5
5
|
// Phase 1 skeleton: this prints the resolved boot state so `npm start` proves
|
|
6
|
-
// the boot chain
|
|
6
|
+
// the boot chain in full (env pinned, dispatcher installed) without a TUI.
|
|
7
7
|
// Phases 4/6 replace the body with the harbor/relay wiring and the pi-tui app.
|
|
8
8
|
|
|
9
9
|
import "./boot.ts";
|
package/src/nostr/event.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function signEvent(u: UnsignedEvent, secretKeyHex: string, auxRand?: Uint
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Verify an event
|
|
66
|
+
* Verify an event in full, fail-closed on anything malformed.
|
|
67
67
|
*
|
|
68
68
|
* BOTH checks matter and the order is deliberate: recompute the id first, so a
|
|
69
69
|
* tampered `content` or `tags` is caught even when the signature over the ORIGINAL
|
|
@@ -15,14 +15,23 @@
|
|
|
15
15
|
// pin, no signature, or a bad signature ⇒ we don't seal at all, and the caller falls
|
|
16
16
|
// back to its own durable channel (a queue, a file, a notice).
|
|
17
17
|
//
|
|
18
|
+
// ATTACHMENTS. A result may also carry media (routines/resultMedia.ts). The message
|
|
19
|
+
// envelope stays small — it is capped by the server at 128KB of base64 — so only a
|
|
20
|
+
// thumbnail-sized file rides inside it; anything larger is sealed on its own and
|
|
21
|
+
// PUT to /api/outbox/blob, with the envelope carrying just its id and metadata. Both
|
|
22
|
+
// halves are sealed to the same account key, so the server holds ciphertext either
|
|
23
|
+
// way, and the app collects the blobs on the same sync that opens the message.
|
|
24
|
+
//
|
|
18
25
|
// Module-level caches (pubkey, machine origin) — one per process, exactly like the
|
|
19
26
|
// per-instance caches this replaced.
|
|
20
27
|
|
|
28
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
21
29
|
import { apiRequest, defaultDeviceLabel } from "../auth/privateer.ts";
|
|
22
30
|
import { loadAccountSignKey } from "../crypto/accountTrust.ts";
|
|
23
31
|
import { verifyOutboxKey } from "../crypto/accountVerify.ts";
|
|
24
|
-
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
32
|
+
import { sealJson, seal, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
25
33
|
import { routineRelayId, type OutboxKind } from "../routines/store.ts";
|
|
34
|
+
import { MAX_ATTACHMENT_BYTES, type MediaClass, type StagedMedia } from "../routines/resultMedia.ts";
|
|
26
35
|
|
|
27
36
|
export type { OutboxKind };
|
|
28
37
|
|
|
@@ -31,6 +40,34 @@ export type { OutboxKind };
|
|
|
31
40
|
// the mailbox's purpose — summaries and answers, not transcripts.
|
|
32
41
|
export const MAX_CLOUD_PLAINTEXT = 45_000;
|
|
33
42
|
|
|
43
|
+
// Total plaintext an envelope may reach once attachment metadata (and any inline
|
|
44
|
+
// bytes) are folded in. 128KB of base64 is 98,304 bytes of plaintext; this leaves
|
|
45
|
+
// headroom for the sealed-box overhead and JSON escaping.
|
|
46
|
+
const MAX_ENVELOPE_PLAINTEXT = 90_000;
|
|
47
|
+
|
|
48
|
+
/** Largest file carried INSIDE the envelope rather than as its own blob. */
|
|
49
|
+
export const MAX_INLINE_MEDIA_BYTES = 24 * 1024;
|
|
50
|
+
/** Total base64 an envelope will spend on inline media, whatever the file count. */
|
|
51
|
+
const INLINE_TOTAL_B64_BUDGET = 32 * 1024;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One attachment as the app sees it: metadata always, then EITHER `b64` (small
|
|
55
|
+
* enough to inline) or `blobId` (fetch + open separately). Never both, never
|
|
56
|
+
* neither — an attachment we couldn't deliver is dropped from the list and named
|
|
57
|
+
* in the body instead, because a card that can never load is worse than a sentence
|
|
58
|
+
* saying the file stayed on the machine.
|
|
59
|
+
*/
|
|
60
|
+
export interface OutboxMedia {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
mediaType: string;
|
|
64
|
+
cls: MediaClass;
|
|
65
|
+
size: number;
|
|
66
|
+
caption?: string;
|
|
67
|
+
b64?: string;
|
|
68
|
+
blobId?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
let outboxPub: Uint8Array | undefined;
|
|
35
72
|
let originCache: { id: string; label: string } | undefined;
|
|
36
73
|
|
|
@@ -66,10 +103,119 @@ export function machineOrigin(): { id: string; label: string } {
|
|
|
66
103
|
return originCache;
|
|
67
104
|
}
|
|
68
105
|
|
|
106
|
+
/** Seal one attachment's bytes and store them. Returns the blob id, or undefined. */
|
|
107
|
+
async function uploadBlob(pub: Uint8Array, bytes: Uint8Array): Promise<string | undefined> {
|
|
108
|
+
try {
|
|
109
|
+
const res = await apiRequest("/api/outbox/blob", {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "content-type": "application/json" },
|
|
112
|
+
body: JSON.stringify({ sealed: seal(pub, bytes) }),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) return undefined;
|
|
115
|
+
const data = (await res.json()) as { id?: string };
|
|
116
|
+
return typeof data.id === "string" ? data.id : undefined;
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Delete blobs we uploaded for a message that then failed to post. Best-effort: the
|
|
124
|
+
* server's TTL would collect them anyway, but a queued result re-uploads on its next
|
|
125
|
+
* attempt, and leaving the first copy behind spends the account's blob quota on bytes
|
|
126
|
+
* nothing will ever reference.
|
|
127
|
+
*/
|
|
128
|
+
async function dropBlobs(ids: string[]): Promise<void> {
|
|
129
|
+
if (ids.length === 0) return;
|
|
130
|
+
try {
|
|
131
|
+
await apiRequest("/api/outbox/blob/ack", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ ids }),
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
/* TTL is the backstop */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Turn staged files into envelope entries, uploading what doesn't fit inline.
|
|
143
|
+
*
|
|
144
|
+
* `budget` is the base64 room left in the envelope after the body. Files are taken
|
|
145
|
+
* in order — the model attached them in the order it wanted them shown — so a small
|
|
146
|
+
* still after a large one still gets its inline slot.
|
|
147
|
+
*/
|
|
148
|
+
async function packMedia(
|
|
149
|
+
pub: Uint8Array,
|
|
150
|
+
staged: StagedMedia[],
|
|
151
|
+
budget: number,
|
|
152
|
+
): Promise<{ media: OutboxMedia[]; blobIds: string[]; undelivered: string[] }> {
|
|
153
|
+
const media: OutboxMedia[] = [];
|
|
154
|
+
const blobIds: string[] = [];
|
|
155
|
+
const undelivered: string[] = [];
|
|
156
|
+
let inlineLeft = Math.max(0, Math.min(budget, INLINE_TOTAL_B64_BUDGET));
|
|
157
|
+
|
|
158
|
+
for (const item of staged) {
|
|
159
|
+
// Re-check at delivery: the file was measured when the model attached it, and a
|
|
160
|
+
// run can be long. A file that has since been deleted, replaced with a directory
|
|
161
|
+
// or grown past the cap is named in the body rather than silently dropped.
|
|
162
|
+
let bytes: Uint8Array;
|
|
163
|
+
try {
|
|
164
|
+
if (!existsSync(item.path)) throw new Error("missing");
|
|
165
|
+
const stat = statSync(item.path);
|
|
166
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_ATTACHMENT_BYTES) throw new Error("unusable");
|
|
167
|
+
bytes = new Uint8Array(readFileSync(item.path));
|
|
168
|
+
} catch {
|
|
169
|
+
undelivered.push(item.name);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const meta: OutboxMedia = {
|
|
174
|
+
id: item.id,
|
|
175
|
+
name: item.name,
|
|
176
|
+
mediaType: item.mediaType,
|
|
177
|
+
cls: item.cls,
|
|
178
|
+
size: bytes.length,
|
|
179
|
+
...(item.caption ? { caption: item.caption } : {}),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (bytes.length <= MAX_INLINE_MEDIA_BYTES) {
|
|
183
|
+
const b64 = Buffer.from(bytes).toString("base64");
|
|
184
|
+
if (b64.length <= inlineLeft) {
|
|
185
|
+
inlineLeft -= b64.length;
|
|
186
|
+
media.push({ ...meta, b64 });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const blobId = await uploadBlob(pub, bytes);
|
|
192
|
+
if (!blobId) {
|
|
193
|
+
undelivered.push(item.name);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
blobIds.push(blobId);
|
|
197
|
+
media.push({ ...meta, blobId });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { media, blobIds, undelivered };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A line in the result body for each file that couldn't travel. */
|
|
204
|
+
function undeliveredNote(names: string[]): string {
|
|
205
|
+
if (names.length === 0) return "";
|
|
206
|
+
const label = machineOrigin().label;
|
|
207
|
+
return `\n\n---\n\n> ⚠︎ Couldn't deliver ${names.length === 1 ? "one attachment" : `${names.length} attachments`} (${names.join(", ")}) — ${names.length === 1 ? "it is" : "they are"} still on ${label}.\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
69
210
|
/**
|
|
70
211
|
* Seal one result to the account outbox and POST the ciphertext. Returns whether the
|
|
71
212
|
* server accepted it; false covers every failure (no verified key, offline, rejected)
|
|
72
213
|
* and means the caller must fall back to its own durable channel.
|
|
214
|
+
*
|
|
215
|
+
* `staged` is this run's attachments (routines/resultMedia.ts). They are uploaded
|
|
216
|
+
* BEFORE the message, so a message that lands always references bytes that exist;
|
|
217
|
+
* if the message then fails, the blobs are dropped again and the caller's queued
|
|
218
|
+
* retry re-uploads from the same paths on disk.
|
|
73
219
|
*/
|
|
74
220
|
export async function postOutbox(
|
|
75
221
|
name: string,
|
|
@@ -77,19 +223,49 @@ export async function postOutbox(
|
|
|
77
223
|
status: "ok" | "error",
|
|
78
224
|
content: string,
|
|
79
225
|
kind: OutboxKind = "routine",
|
|
226
|
+
staged: StagedMedia[] = [],
|
|
80
227
|
): Promise<boolean> {
|
|
81
228
|
const pub = await ensureOutboxPub();
|
|
82
229
|
if (!pub) return false;
|
|
83
|
-
|
|
84
|
-
|
|
230
|
+
let body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
231
|
+
|
|
232
|
+
const { media, blobIds, undelivered } = staged.length
|
|
233
|
+
? await packMedia(pub, staged, MAX_ENVELOPE_PLAINTEXT - body.length - 2_000)
|
|
234
|
+
: { media: [] as OutboxMedia[], blobIds: [] as string[], undelivered: [] as string[] };
|
|
235
|
+
body += undeliveredNote(undelivered);
|
|
236
|
+
|
|
237
|
+
// v2 adds `media`. Older apps ignore the field and still render the body, which is
|
|
238
|
+
// why the note above names undelivered files in prose rather than only in metadata.
|
|
239
|
+
const envelope: Record<string, unknown> = {
|
|
240
|
+
v: media.length > 0 ? 2 : 1,
|
|
241
|
+
kind,
|
|
242
|
+
name,
|
|
243
|
+
status,
|
|
244
|
+
at,
|
|
245
|
+
content: body,
|
|
246
|
+
origin: machineOrigin(),
|
|
247
|
+
...(media.length > 0 ? { media } : {}),
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
let sealed = sealJson(pub, envelope);
|
|
251
|
+
if (sealed.length > 128 * 1024) {
|
|
252
|
+
// The budget arithmetic above should make this unreachable; if it is ever wrong,
|
|
253
|
+
// the ANSWER is what gets clipped, never the attachments — those are the part the
|
|
254
|
+
// user cannot reconstruct from anywhere else.
|
|
255
|
+
envelope.content = body.slice(0, Math.max(0, MAX_CLOUD_PLAINTEXT / 2)) + "\n…truncated";
|
|
256
|
+
sealed = sealJson(pub, envelope);
|
|
257
|
+
}
|
|
258
|
+
|
|
85
259
|
try {
|
|
86
260
|
const res = await apiRequest("/api/outbox", {
|
|
87
261
|
method: "POST",
|
|
88
262
|
headers: { "content-type": "application/json" },
|
|
89
263
|
body: JSON.stringify({ sealed }),
|
|
90
264
|
});
|
|
265
|
+
if (!res.ok) await dropBlobs(blobIds);
|
|
91
266
|
return res.ok;
|
|
92
267
|
} catch {
|
|
268
|
+
await dropBlobs(blobIds);
|
|
93
269
|
return false;
|
|
94
270
|
}
|
|
95
271
|
}
|