privateer-agent 0.14.3 → 0.14.4

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.
@@ -640,10 +640,10 @@ export default function privateerControl(pi: any): void {
640
640
  // driving. The daemon no longer loads this file, so there is nothing to shadow.
641
641
  pi.registerTool?.(makeSendFileTool(bridge));
642
642
  pi.registerTool?.(makeSaveAttachmentTool(attachments));
643
- // save_cargo belongs with them: it is the same bridge, the same "needs a connected
644
- // app" precondition, and the same failure mode if it were registered anywhere the
645
- // relay isn't this file's. Unlike the pair it hands the app PLAINTEXT to encrypt —
646
- // the terminal has no master key, so the round trip is the feature (cargoSave.ts).
643
+ // save_cargo belongs with them: it tries this bridge's attached app first, which hands
644
+ // the app PLAINTEXT to encrypt — the terminal has no master key (cargoSave.ts). With no
645
+ // app attached (remote access off, or nobody driving) it queues the artifact sealed to
646
+ // the account instead, and the next app to open files it.
647
647
  pi.registerTool?.(makeSaveCargoTool(bridge));
648
648
  // The chart tools, for the same reasons and one more. Same: this bridge, the same
649
649
  // "needs a connected app" precondition, the same plaintext-out/app-encrypts round trip
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -306,6 +306,18 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
306
306
  if (opts.relayFiles) {
307
307
  const { makeRelayFileTools } = await import("../tools/relayFileTools.ts");
308
308
  factories.push(makeRelayFileTools(opts.relayFiles.bridge, opts.relayFiles.attachments));
309
+ } else {
310
+ // No relay of its own — a harbor run, a channels or ACP session — so no app can ever
311
+ // be attached. save_cargo still exists here, QUEUE-ONLY: it seals the artifact to the
312
+ // account and the next app to open files it (remote/cargoSave.ts). Signed-in only,
313
+ // because the queue is the account's; a signed-out session gets no tool rather than
314
+ // one that refuses every call. The rest of the relay set stays out: send_file needs
315
+ // a live app to receive bytes, and the chart tools READ decrypted content back.
316
+ const { hasCredentials } = await import("../auth/privateer.ts");
317
+ if (hasCredentials()) {
318
+ const { makeSaveCargoTool } = await import("../tools/cargo.ts");
319
+ factories.push((pi: any) => { pi.registerTool?.(makeSaveCargoTool()); });
320
+ }
309
321
  }
310
322
 
311
323
  if (opts.resultMedia) {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
- "reviewedFor": "0.14.3",
3
+ "reviewedFor": "0.14.4",
4
4
  "items": [
5
+ { "text": "Save to Cargo with no app attached — it lands on your next app open", "since": "0.14.4" },
5
6
  { "text": "Several privateer -p runs at once no longer sign each other out", "since": "0.14.3" },
6
7
  { "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" },
7
- { "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" },
8
- { "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" }
8
+ { "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" }
9
9
  ]
10
10
  }
@@ -26,12 +26,13 @@
26
26
  // per-instance caches this replaced.
27
27
 
28
28
  import { readFileSync, existsSync, statSync } from "node:fs";
29
- import { apiRequest, defaultDeviceLabel } from "../auth/privateer.ts";
29
+ import { apiRequest, defaultDeviceLabel, hasCredentials } from "../auth/privateer.ts";
30
30
  import { loadAccountSignKey } from "../crypto/accountTrust.ts";
31
31
  import { verifyOutboxKey } from "../crypto/accountVerify.ts";
32
32
  import { sealJson, seal, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
33
33
  import { routineRelayId, type OutboxKind } from "../routines/store.ts";
34
34
  import { MAX_ATTACHMENT_BYTES, type MediaClass, type StagedMedia } from "../routines/resultMedia.ts";
35
+ import type { CargoSaveRequest } from "../remote/cargoSave.ts";
35
36
 
36
37
  export type { OutboxKind };
37
38
 
@@ -337,3 +338,77 @@ export async function postOutbox(
337
338
  return false;
338
339
  }
339
340
  }
341
+
342
+ // ── Queued Cargo saves ───────────────────────────────────────────────────────
343
+ //
344
+ // `save_cargo` with no app attached (remote/cargoSave.ts has the why). Its own mailbox,
345
+ // /api/outbox/cargo, not an outbox item: every shipped app acks every item it fetches,
346
+ // so an artifact sent as one would be eaten — acked and never filed — by any device
347
+ // still on an older build, and filing has to happen once per account, which needs the
348
+ // server-side claim that mailbox has and this one doesn't.
349
+
350
+ /** Ceiling on the sealed artifact, matching the server's MAX_CARGO_SEALED_BYTES. */
351
+ export const MAX_QUEUED_CARGO_SEALED_BYTES = 2 * 1024 * 1024;
352
+
353
+ /**
354
+ * The plaintext the app opens — treeview client/utils/queuedCargoParse.ts reads exactly
355
+ * this. `type` (not `kind`) names the envelope: `kind` is the artifact's, and the app's
356
+ * reader refuses anything that isn't a cargo envelope rather than guessing.
357
+ */
358
+ export function queuedCargoEnvelope(req: CargoSaveRequest, at: Date, origin: { id: string; label: string }) {
359
+ return {
360
+ v: 1,
361
+ type: "cargo",
362
+ kind: req.kind,
363
+ ...(req.title ? { title: req.title } : {}),
364
+ content: req.content,
365
+ at: at.toISOString(),
366
+ origin,
367
+ };
368
+ }
369
+
370
+ export type QueuedCargoResult = { ok: true; id: string } | { ok: false; reason: string };
371
+
372
+ /**
373
+ * Seal one artifact to the account and park it for the app to file. Never throws;
374
+ * every failure comes back as a reason written for the model to repeat.
375
+ */
376
+ export async function postQueuedCargo(req: CargoSaveRequest): Promise<QueuedCargoResult> {
377
+ if (!hasCredentials()) {
378
+ return { ok: false, reason: "this terminal is not signed in to a Privateer account — run /signin, then save again" };
379
+ }
380
+ const pub = await ensureOutboxPub();
381
+ if (!pub) {
382
+ // No pinned account key, or one the published key doesn't verify against. Either way
383
+ // sealing would be to a key nobody has shown belongs to this account — fail closed.
384
+ return {
385
+ ok: false,
386
+ reason:
387
+ "the account's encryption key could not be verified from this terminal — open the Privateer app once (it publishes the key), " +
388
+ "or re-link this terminal from the app, then save again",
389
+ };
390
+ }
391
+ const sealed = sealJson(pub, queuedCargoEnvelope(req, new Date(), machineOrigin()));
392
+ if (Math.floor((sealed.length * 3) / 4) > MAX_QUEUED_CARGO_SEALED_BYTES) {
393
+ return { ok: false, reason: "the artifact is too large to queue — trim it below 512 KB" };
394
+ }
395
+ try {
396
+ const res = await apiRequest("/api/outbox/cargo", {
397
+ method: "POST",
398
+ headers: { "content-type": "application/json" },
399
+ body: JSON.stringify({ sealed }),
400
+ });
401
+ if (res.ok) {
402
+ const data = (await res.json().catch(() => ({}))) as { id?: unknown };
403
+ return { ok: true, id: typeof data.id === "string" ? data.id : "" };
404
+ }
405
+ if (res.status === 404) {
406
+ return { ok: false, reason: "the Privateer server does not accept queued artifacts yet — attach the app to this terminal and save again" };
407
+ }
408
+ const err = (await res.json().catch(() => ({}))) as { message?: unknown };
409
+ const message = typeof err.message === "string" && err.message ? err.message : `the server answered ${res.status}`;
410
+ return { ok: false, reason: `the server refused to queue it: ${message}` };
411
+ } catch (e) {
412
+ return { ok: false, reason: `the server could not be reached (${(e as Error).message})` };
413
+ }
414
+ }
@@ -374,7 +374,7 @@ export function classifyToolCall(
374
374
  tool: toolName,
375
375
  kind: "write",
376
376
  title: protectedSrc ? "Save a protected file to Cargo in the app" : "Save to Cargo in the Privateer app",
377
- detail: `${outside || protectedSrc ? abs : src}${kindNote}${titleNote} → the app encrypts it and stores it in Cargo`,
377
+ detail: `${outside || protectedSrc ? abs : src}${kindNote}${titleNote} → the app encrypts it and stores it in Cargo (queued, sealed, if no app is attached)`,
378
378
  protected: protectedSrc,
379
379
  outside,
380
380
  path: abs,
@@ -18,10 +18,21 @@
18
18
  // model-authored one does. Nothing new reaches the server, and no new endpoint
19
19
  // exists.
20
20
  //
21
- // WHAT THAT COSTS. The app has to be attached. A harbor is headless by design and
22
- // there is no controller to ask, so save_cargo is not registered there — an
23
- // unattended run still delivers an artifact the way it always has, as a fence in
24
- // its Inbox result (routines/resultBrief.ts). Don't "fix" that by widening this.
21
+ // WHEN NO APP IS ATTACHED. The live round trip needs an app on the other end, and
22
+ // requiring one made save_cargo useless exactly where it was wanted most: a terminal
23
+ // nobody is driving, and every harbor. So with no app attached the artifact is QUEUED
24
+ // instead (outbox/cloudOutbox.ts postQueuedCargo): sealed to the account's X25519
25
+ // outbox public key — the same write-only sealed box Inbox results use, verified
26
+ // against the account signing key pinned at link — and parked at /api/outbox/cargo.
27
+ // The next app to sync opens it and files it through the very same saveCargo()
28
+ // (treeview client/services/queuedCargoService.ts). The master key still never
29
+ // leaves the app, the server still holds only ciphertext, and the stored row is the
30
+ // one the live path would have made. What the queue cannot do is confirm: there is no
31
+ // cargoId until an app files it, and the tool says exactly that.
32
+ //
33
+ // Only an UNATTACHED failure falls back (`unattached: true` below). An app that
34
+ // refused, timed out, or dropped mid-transfer may already have stored the artifact,
35
+ // and queueing it as well is how a user ends up with two copies.
25
36
 
26
37
  /** Cargo artifact kinds — mirrors client/utils/cargoKinds.ts CargoKind. */
27
38
  export const CARGO_KINDS = ["webpage", "slides", "game", "pdf", "docx", "md", "sheet"] as const;
@@ -102,4 +113,7 @@ export interface CargoSaveRequest {
102
113
  */
103
114
  export type CargoSaveResult =
104
115
  | { ok: true; cargoId: string; title: string; storageType: string }
105
- | { ok: false; reason: string };
116
+ | { ok: false; reason: string; unattached?: true };
117
+
118
+ /** Is this failure "there was no app to ask" — the one a queued save may follow? */
119
+ export const isUnattached = (r: CargoSaveResult): boolean => !r.ok && r.unattached === true;
@@ -522,12 +522,15 @@ export class RemoteBridge {
522
522
  // way the gate wraps remoteAsk, so a silent or too-old app would wedge the turn
523
523
  // forever without one.
524
524
  saveCargoRemote = (req: CargoSaveRequest, signal?: AbortSignal): Promise<CargoSaveResult> => {
525
- if (!this.relay) return Promise.resolve({ ok: false, reason: "remote access is not enabled — run /remote-access on and drive this terminal from the Privateer app" });
526
- if (!this.relay.isConnected()) return Promise.resolve({ ok: false, reason: "the relay is not connected" });
525
+ // The three "nobody to ask" answers carry `unattached`, which is what lets the tool
526
+ // queue the artifact for the app instead (cargoSave.ts). Nothing was sent, so there is
527
+ // no chance a queued copy duplicates one the app stored.
528
+ if (!this.relay) return Promise.resolve({ ok: false, unattached: true, reason: "remote access is not enabled — run /remote-access on and drive this terminal from the Privateer app" });
529
+ if (!this.relay.isConnected()) return Promise.resolve({ ok: false, unattached: true, reason: "the relay is not connected" });
527
530
  // hasController is optional on RelayLike; a transport that can't tell is treated as
528
531
  // attached, matching how the rest of the bridge reads it.
529
532
  if (this.relay.hasController && !this.relay.hasController()) {
530
- return Promise.resolve({ ok: false, reason: "the Privateer app is not attached to this terminal — only the app holds the key that encrypts an artifact, so open it and attach before saving" });
533
+ return Promise.resolve({ ok: false, unattached: true, reason: "the Privateer app is not attached to this terminal" });
531
534
  }
532
535
  const id = randomUUID();
533
536
  return new Promise<CargoSaveResult>((resolve) => {
@@ -2,10 +2,12 @@
2
2
  // Cargo: a titled, runnable artifact they can open, edit, download and share
3
3
  // from any of their devices, not a path that only exists on this machine.
4
4
  //
5
- // The interesting part is not this file, it's why the save is a round trip
6
- // through the app at all — src/remote/cargoSave.ts has that (short version: the
7
- // terminal holds no master key, and a Cargo row is ciphertext). What matters
8
- // here is the shape that follows from it:
5
+ // The interesting part is not this file, it's why the save goes through the app
6
+ // at all — src/remote/cargoSave.ts has that (short version: the terminal holds no
7
+ // master key, and a Cargo row is ciphertext). Two routes follow from it: with an
8
+ // app attached, a live round trip that comes back with the stored row's id; with
9
+ // none, the artifact is sealed to the account and QUEUED for the next app to file.
10
+ // What matters here is the shape that follows:
9
11
  //
10
12
  // TAKES A PATH, NOT CONTENT. An artifact runs to half a megabyte. Inlining that
11
13
  // into a tool call would spend the whole thing in tokens, twice — once when the
@@ -28,6 +30,11 @@
28
30
  // generation's posture (media.ts's header is blunt about that). A model that
29
31
  // can't tell the two apart will describe one with the other's guarantees, so the
30
32
  // description states this one plainly and the success line repeats it.
33
+ //
34
+ // AND WHAT HASN'T HAPPENED YET. A queued save is not a saved one: it is in no
35
+ // Cargo list until an app opens and files it. The queued line says so in as many
36
+ // words, because "saved" would send the user to look for something that isn't
37
+ // there yet.
31
38
 
32
39
  import { Type } from "typebox";
33
40
  import { existsSync, readFileSync, statSync } from "node:fs";
@@ -39,9 +46,11 @@ import {
39
46
  isHtmlKind,
40
47
  kindForExtension,
41
48
  type CargoKind,
49
+ isUnattached,
42
50
  type CargoSaveRequest,
43
51
  type CargoSaveResult,
44
52
  } from "../remote/cargoSave.ts";
53
+ import { postQueuedCargo, type QueuedCargoResult } from "../outbox/cloudOutbox.ts";
45
54
 
46
55
  function text(t: string) {
47
56
  return { content: [{ type: "text", text: t }], details: {} };
@@ -52,6 +61,9 @@ export interface CargoSaveBridge {
52
61
  saveCargoRemote(req: CargoSaveRequest, signal?: AbortSignal): Promise<CargoSaveResult>;
53
62
  }
54
63
 
64
+ /** Where an artifact goes when no app is attached. Injectable for tests. */
65
+ export type CargoQueue = (req: CargoSaveRequest) => Promise<QueuedCargoResult>;
66
+
55
67
  export const CARGO_TOOL_NAMES = ["save_cargo"] as const;
56
68
 
57
69
  /**
@@ -71,7 +83,11 @@ function kindMatchesExtension(kind: CargoKind, ext: string): boolean {
71
83
  return implied === "md" && (kind === "md" || kind === "pdf" || kind === "docx");
72
84
  }
73
85
 
74
- export function makeSaveCargoTool(bridge: CargoSaveBridge) {
86
+ /**
87
+ * `bridge` is the live route — absent in a session with no relay of its own (a harbor
88
+ * run, a channels session), which then only ever queues. `queue` is the other route.
89
+ */
90
+ export function makeSaveCargoTool(bridge?: CargoSaveBridge, queue: CargoQueue = postQueuedCargo) {
75
91
  return {
76
92
  name: "save_cargo",
77
93
  label: "Save to Cargo",
@@ -85,9 +101,11 @@ export function makeSaveCargoTool(bridge: CargoSaveBridge) {
85
101
  "inlined, since it opens with no network and no sibling files), .md (kind md, pdf or docx — the " +
86
102
  "kind picks what Download produces) and .csv (kind sheet). Max 512 KB; other file types cannot be " +
87
103
  "Cargo — use send_file_to_client to hand the user an image, video or PDF instead.\n" +
88
- "Needs the Privateer app attached to this terminal: the app holds the key, and it encrypts the " +
89
- "artifact on the device before storing it, so the content is never readable by the server. That is " +
90
- "a stronger guarantee than the generate_* tools have — do not describe those the same way.",
104
+ "Works whether or not the Privateer app is attached. Attached: the app encrypts and stores it now. " +
105
+ "Not attached: the artifact is sealed to the user's account key and queued, and the app files it the " +
106
+ "next time it opens — it is NOT in Cargo until then, so say that rather than that it is saved. Either " +
107
+ "way only the user's app can read it; the server holds ciphertext. That is a stronger guarantee than " +
108
+ "the generate_* tools have — do not describe those the same way.",
91
109
  parameters: Type.Object({
92
110
  path: Type.String({
93
111
  description: "Path of the file to save, relative to cwd or absolute (e.g. 'build/tower-defence.html').",
@@ -165,11 +183,27 @@ export function makeSaveCargoTool(bridge: CargoSaveBridge) {
165
183
  }
166
184
  if (!content.trim()) return text(`${params.path} has no content to save.`);
167
185
 
168
- const res = await bridge.saveCargoRemote(
169
- { content, kind, title: params.title?.trim() || undefined },
170
- signal,
171
- );
186
+ const req: CargoSaveRequest = { content, kind, title: params.title?.trim() || undefined };
187
+ const res: CargoSaveResult = bridge
188
+ ? await bridge.saveCargoRemote(req, signal)
189
+ : { ok: false, unattached: true, reason: "no app is attached to this session" };
172
190
 
191
+ if (!res.ok && isUnattached(res)) {
192
+ const queued = await queue(req);
193
+ if (!queued.ok) {
194
+ return text(
195
+ `Couldn't save ${basename(target)} to Cargo. No Privateer app is attached (${res.reason}), ` +
196
+ `and queueing it for the app failed: ${queued.reason}.`,
197
+ );
198
+ }
199
+ const label = req.title ? `"${req.title}"` : basename(target);
200
+ return text(
201
+ `Queued ${label} for Cargo (${kind}, ${(stat.size / 1024).toFixed(0)} KB). No Privateer app is attached right now, ` +
202
+ `so it was sealed to the user's account key and parked on the server as ciphertext only their app can open. ` +
203
+ `It will appear in Cargo the next time the Privateer app opens on any of their devices — it is not there yet, ` +
204
+ `so tell the user that rather than that it is saved.`,
205
+ );
206
+ }
173
207
  if (!res.ok) return text(`Couldn't save ${basename(target)} to Cargo: ${res.reason}`);
174
208
  return text(
175
209
  `Saved "${res.title}" to Cargo (${kind}, ${(stat.size / 1024).toFixed(0)} KB, ${res.storageType} storage, id ${res.cargoId}). ` +
@@ -18,18 +18,18 @@ import { makeChartTools, type ChartOpBridge } from "./charts.ts";
18
18
  import { makeSaveToLibraryTool, type LibrarySaveBridge } from "./saveToLibrary.ts";
19
19
  import type { AttachmentStore } from "../util/attachmentStore.ts";
20
20
 
21
- // save_cargo rides with the file pair rather than with the media tools, because it
22
- // shares their precondition and not media's: it needs a CONNECTED APP, not a signed-in
23
- // account. Registering it in the moat's media block would put it in every harbor and
24
- // channels session, where there is no controller and every call would fail — see
25
- // remote/cargoSave.ts on why unattended runs deliver an artifact a different way.
26
- // save_to_library rides here for save_cargo's reason exactly — it needs a CONNECTED
21
+ // save_cargo rides with the file pair because, in a session that HAS a relay, it should
22
+ // try the attached app first: that path comes back with the stored row. With no app
23
+ // attached it queues the artifact for the next app to file instead (remote/cargoSave.ts),
24
+ // which is also how a session with no relay at all gets it — config/moat.ts registers a
25
+ // queue-only copy there.
26
+ // save_to_library rides here because it needs a CONNECTED
27
27
  // APP rather than a signed-in account, because only the app holds the master key that
28
28
  // a Library row's ciphertext is under. Putting it in the moat's media block would give
29
29
  // it to every harbor and channels session, where there is no controller and every call
30
30
  // would fail; an unattended run already delivers a file a different way, as a sealed
31
31
  // attachment on its Inbox result (routines/resultMedia.ts).
32
- // The chart tools ride here too, for the same reason save_cargo does and one more of
32
+ // The chart tools ride here too, for save_to_library's reason and one more of
33
33
  // their own. Same reason: they need a CONNECTED APP, not a signed-in account, so the
34
34
  // moat's media block would put them in every harbor and channels session where there is
35
35
  // no controller and every call would fail. Their own reason: unlike cargo they also READ