privateer-agent 0.12.14 → 0.12.16

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.
@@ -29,7 +29,9 @@
29
29
  // buildMoat(), exactly as it does every other Pi-touching import. Extensions load late
30
30
  // and import it statically.
31
31
 
32
+ import { loadConfig, makePiPrivacyExtension } from "pi-privacy";
32
33
  import { noQuarterActive } from "../permissions/noQuarter.ts";
34
+ import { addPiiAllow, piiAllowEntries, removePiiAllow } from "./piiAllow.ts";
33
35
  import { cliPalette, detectScheme } from "../ui/palette.ts";
34
36
  import { accountPosture, privateerChannel } from "../providers/account.ts";
35
37
  import { hasCredentials } from "../auth/privateer.ts";
@@ -38,15 +40,32 @@ import { hasCredentials } from "../auth/privateer.ts";
38
40
  // no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
39
41
  // extension's status line), body in the accent color — distinct from a yellow warning and
40
42
  // from a red error, because this is neither: it's the answer we gave for you.
43
+ // The tail is the point as much as the color is: unattended, nobody was asked, so the
44
+ // only way to disagree with the answer is to know where to say so. `/privacy allow …`
45
+ // is that place, and a notice about a masked filename is exactly when you want to know
46
+ // it exists.
41
47
  function renderPiiAutoRedact(notice: string): string {
42
48
  const p = cliPalette(detectScheme());
43
49
  const body = notice.startsWith("⚑ ") ? notice.slice(2) : notice;
44
- return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET}`;
50
+ return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET} ${p.DIM}· /privacy allow <value> if it shouldn't be masked${p.RESET}`;
45
51
  }
46
52
 
47
53
  /** The pi-privacy options every Privateer session gets, whichever route built it. */
48
54
  export function sharedPrivacyOptions() {
55
+ // pi-privacy's OWN configuration — PI_PRIVACY_* env vars and a pi-privacy.config.json.
56
+ // We used to build the options object by hand and never call this, so every one of
57
+ // those settings was silently ignored in Privateer and nowhere else: a user who set
58
+ // PI_PRIVACY_PII_ALLOW watched it do nothing. Ambient settings go UNDER ours, so the
59
+ // knobs Privateer has a product reason to hold (the tier resolver, the unattended
60
+ // signal, the account badge) are still ours, while the ones that are a matter of taste
61
+ // (piiPolicy, badge sinks, the exfil/downgrade policies) become settable.
62
+ //
63
+ // Safe against a repo you just opened: a project-local pi-privacy.config.json is
64
+ // clamped by pi-privacy itself — it may not weaken a policy below the built-in floor,
65
+ // and may not add allowlist entries at all.
66
+ const ambient = loadConfig();
49
67
  return {
68
+ ...ambient,
50
69
  // The account channel's real posture. pi-privacy ships a `privateer` provider, but it
51
70
  // is the PUBLIC developer-key channel (sk-priv-…, server-proxied and unverifiable
52
71
  // end-to-end), so from the package alone every privateer/* model floors to
@@ -92,6 +111,64 @@ export function sharedPrivacyOptions() {
92
111
  // unconditionally and each masks its own patterns, so the surviving content is the
93
112
  // same either way. Under "warn" the order WOULD matter, since it decides whether the
94
113
  // prompt is raised on a raw key or one we already masked.
95
- toolResultPolicy: "redact" as const,
114
+ toolResultPolicy: ambient.toolResultPolicy ?? ("redact" as const),
115
+ // The operator's own "that is not personal data" list, read LIVE (see
116
+ // ./piiAllow.ts): `/privacy allow @acme.com` applies on the next turn, not the next
117
+ // launch. Ambient config entries come first — both lists are additive, since an
118
+ // allowlist can only ever remove detection and there is no sense in which one of
119
+ // them should silence the other.
120
+ piiAllow: () => [...(ambient.piiAllow ?? []), ...piiAllowEntries()],
121
+ };
122
+ }
123
+
124
+ // ── /privacy ─────────────────────────────────────────────────────────────────────────
125
+ // The gate's escape hatch, at the point of complaint. Everything else about pi-privacy is
126
+ // glanceable (the badge) or answerable in the moment (the prompt); the allowlist is the
127
+ // only part that needs a place to live, and "edit ~/.privateer/config.json and relaunch"
128
+ // is not a thing anyone does mid-task while the gate masks a filename in front of them.
129
+ //
130
+ // Registered from HERE rather than from the extension file, for the reason this module
131
+ // exists at all: the factory-built sessions and the discovered extension must not drift.
132
+ function registerPrivacyCommand(pi: any): void {
133
+ pi.registerCommand?.("privacy", {
134
+ description: "Values the PII gate must not treat as personal data: /privacy [allow <value> | unallow <value>]",
135
+ handler: (args: string, ctx: any) => {
136
+ const raw = String(args ?? "").trim();
137
+ const [verb, ...rest] = raw.split(/\s+/);
138
+ const value = rest.join(" ").trim();
139
+ const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
140
+
141
+ if (verb === "allow" || verb === "unallow") {
142
+ if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
143
+ const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
144
+ return notify(r.message, r.ok ? "info" : "warning");
145
+ }
146
+ if (verb) return notify(`unknown option "${verb}" — usage: /privacy [allow <value> | unallow <value>]`, "warning");
147
+
148
+ const mine = piiAllowEntries();
149
+ notify(
150
+ [
151
+ mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
152
+ "Reserved shapes (example.com, loopback, noreply@…) are allowed by default and not listed here.",
153
+ "Add one with /privacy allow <value> — an address (me@acme.com), a domain (@acme.com),",
154
+ "an IPv4 block (10.0.0.0/8), or any exact/globbed value.",
155
+ ].join("\n"),
156
+ "info",
157
+ );
158
+ },
159
+ });
160
+ }
161
+
162
+ /**
163
+ * The privacy half of the moat as ONE factory: pi-privacy configured the Privateer way,
164
+ * plus the `/privacy` command that maintains its allowlist. Both routes into pi-privacy
165
+ * (src/config/moat.ts and extensions/privateer-privacy.ts) use this, so neither can end
166
+ * up with the gate but not its escape hatch.
167
+ */
168
+ export function privacyExtension() {
169
+ const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
170
+ return function privateerPrivacyCore(pi: any): void {
171
+ privacy(pi);
172
+ registerPrivacyCommand(pi);
96
173
  };
97
174
  }
@@ -270,6 +270,44 @@ export function classifyToolCall(
270
270
  };
271
271
  }
272
272
 
273
+ // save_cargo (src/tools/cargo.ts) — hand an artifact to the connected app, which
274
+ // encrypts it and stores it in the user's Cargo.
275
+ //
276
+ // Left to the unknown-tool branch this is bash-kind: a "Run save_cargo" prompt over a
277
+ // JSON blob, and an outright DENY in plan/readonly. Denying it there is the wrong call
278
+ // twice over — it writes nothing on this machine, and "show me this on my phone" is a
279
+ // reasonable thing to ask for while planning.
280
+ //
281
+ // Classified as a WRITE even though no local file changes, because that is what it is
282
+ // from the user's side: a new, persistent thing in their account, against their storage
283
+ // quota. Not alwaysAsk — unlike the generate_* tools it spends no credit, and unlike an
284
+ // ordinary egress the destination is the user's OWN device, encrypted there before it
285
+ // is stored, so there is no third party to leak to and nothing irreversible to stop.
286
+ //
287
+ // `outside` is about the SOURCE. The content is read off disk and shipped off-machine,
288
+ // so `path: "~/Documents/notes.md"` discloses a file from outside the working directory
289
+ // — the same disclosure the media tools flag on their inputs, and the reason `outside`
290
+ // has to be set here: it forces a prompt even under acceptEdits, which would otherwise
291
+ // swallow the call as an ordinary in-scope write.
292
+ if (name === "save_cargo") {
293
+ const src = str(obj.path);
294
+ if (!src) return unknownTarget(toolName, "write");
295
+ const abs = resolveInCwd(scope.cwd, src);
296
+ const outside = isOutsideScope(scope, abs);
297
+ const protectedSrc = isProtectedPath(abs);
298
+ const kindNote = str(obj.kind) ? ` as ${str(obj.kind)}` : "";
299
+ const titleNote = str(obj.title) ? ` "${str(obj.title)}"` : "";
300
+ return {
301
+ tool: toolName,
302
+ kind: "write",
303
+ title: protectedSrc ? "Save a protected file to Cargo in the app" : "Save to Cargo in the Privateer app",
304
+ detail: `${outside || protectedSrc ? abs : src}${kindNote}${titleNote} → the app encrypts it and stores it in Cargo`,
305
+ protected: protectedSrc,
306
+ outside,
307
+ path: abs,
308
+ };
309
+ }
310
+
273
311
  // Media generation (src/tools/media.ts) and local composition (videoCompose.ts).
274
312
  //
275
313
  // Left to the unknown-tool branch at the bottom these classify as bash-kind, which
@@ -5,38 +5,46 @@
5
5
  //
6
6
  // Two ways in, one state:
7
7
  // 1. `privateer --no-quarter` at launch → PRIVATEER_NO_QUARTER=1 (see
8
- // bin/privateer-launch.mjs), which seeds `active` below.
8
+ // bin/privateer-launch.mjs), which is what the reader below sees.
9
9
  // 2. shift+tab in a live session → toggleNoQuarter(). This is the "step away from
10
10
  // the keyboard" switch: flip it on and the agent runs to completion instead of
11
11
  // stopping on the next approval prompt.
12
12
  //
13
- // Toggling MIRRORS the env var, because that's how the state reaches subagents: a
14
- // pi-subagents child is a `pi` subprocess that inherits this process's env and reads
15
- // PRIVATEER_NO_QUARTER in its own gate. Children spawned after a toggle therefore
16
- // match the parent; ones already running keep the posture they started with.
13
+ // THE ENV VAR IS THE STORE, not a mirror of a module-level flag — and that is
14
+ // load-bearing for two separate reasons:
15
+ //
16
+ // • ACROSS PROCESSES: a pi-subagents child is a `pi` subprocess that inherits this
17
+ // process's env and reads PRIVATEER_NO_QUARTER in its own gate. Children spawned
18
+ // after a toggle therefore match the parent; ones already running keep the posture
19
+ // they started with.
20
+ // • ACROSS EXTENSIONS IN THIS PROCESS: Pi loads each extension with its OWN jiti
21
+ // instance and `moduleCache: false` (pi-coding-agent dist/core/extensions/loader.ts,
22
+ // loadExtensionModule), so every extension that imports this file gets a SEPARATE
23
+ // copy of it. A module-level `let` would be per-extension state: shift+tab in
24
+ // extensions/privateer-gate.ts flipped the gate's copy while the copy inside
25
+ // extensions/privateer-privacy.ts stayed false, so a no-quarter session still
26
+ // stopped the turn with pi-privacy's "PII detected — send as-is or redact?" prompt.
27
+ // process.env is the one thing all those copies share, so the state lives there and
28
+ // nowhere else: every reader, in every extension, sees every toggle.
17
29
  //
18
30
  // IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere,
19
31
  // including boot-ordered entrypoints (see boot.ts's ORDERING CONTRACT).
20
32
 
21
33
  const ENV = "PRIVATEER_NO_QUARTER";
22
34
 
23
- // Seeded from the launch flag so `--no-quarter` and the toggle share one state.
24
- let active = process.env[ENV] === "1";
25
-
26
- /** True while the gate is fully lowered for this session. */
35
+ /** True while the gate is fully lowered for this session. Read live, never cached. */
27
36
  export function noQuarterActive(): boolean {
28
- return active;
37
+ return process.env[ENV] === "1";
29
38
  }
30
39
 
31
- /** Set the state (and mirror it to the env for future subagent children). Returns the new state. */
40
+ /** Set the state — in the env, so every copy of this module and every child agrees. Returns the new state. */
32
41
  export function setNoQuarter(on: boolean): boolean {
33
- active = on;
34
42
  if (on) process.env[ENV] = "1";
35
43
  else delete process.env[ENV];
36
- return active;
44
+ return on;
37
45
  }
38
46
 
39
47
  /** Flip the state. Returns the new state. */
40
48
  export function toggleNoQuarter(): boolean {
41
- return setNoQuarter(!active);
49
+ return setNoQuarter(!noQuarterActive());
42
50
  }
@@ -63,6 +63,12 @@ const DEFAULT_MODELS = [
63
63
  // account catalog automatically; these seeds just make them resolve at launch).
64
64
  "moonshotai/kimi-k3",
65
65
  "z-ai/glm-5.2",
66
+ // Both verified 2026-08-19 against /endpoints/zdr and GET /api/models: ZDR-covered
67
+ // and servable. Same reason as the line above — the live catalog already carries
68
+ // them, this only closes the first-launch window where a saved default that isn't
69
+ // yet in the cache would fall through to a BYO provider.
70
+ "x-ai/grok-4.6",
71
+ "openai/gpt-5.6-luna",
66
72
  ];
67
73
 
68
74
  function seedModel(id: string) {
@@ -0,0 +1,105 @@
1
+ // The wire contract for CLI → app Cargo saves, shared by the three modules that
2
+ // have to agree on it: RelayClient (sends the frames), RemoteBridge (correlates
3
+ // the reply), and the save_cargo tool (validates before either runs).
4
+ //
5
+ // WHY THE ROUND TRIP EXISTS AT ALL. A Cargo row is `encryptedContent` +
6
+ // `encryptedMetadata` — AES-256-GCM, done on the device, under the account master
7
+ // key. The server stores ciphertext and never decrypts (treeview CLAUDE.md §5).
8
+ // The terminal deliberately holds no master key: the device-grant login mints a
9
+ // session token and nothing else, and crypto/accountVerify.ts says so out loud —
10
+ // "the terminal holds no master key and can't derive the real one itself". So a
11
+ // CLI that POSTed /api/cargo by itself could only write a row nothing can open.
12
+ //
13
+ // The app can. It is already signed in, already holds the key, and already has
14
+ // `saveCargo()` — the same function the chat's Save button and the file importer
15
+ // call. So the CLI hands it plaintext over the relay the user already trusts to
16
+ // carry their prompts and approvals, and the app does the encrypting. The master
17
+ // key stays exactly where it was; the artifact takes the same path to storage a
18
+ // model-authored one does. Nothing new reaches the server, and no new endpoint
19
+ // exists.
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.
25
+
26
+ /** Cargo artifact kinds — mirrors client/utils/cargoKinds.ts CargoKind. */
27
+ export const CARGO_KINDS = ["webpage", "slides", "game", "pdf", "docx", "md", "sheet"] as const;
28
+ export type CargoKind = (typeof CARGO_KINDS)[number];
29
+
30
+ export function isCargoKind(v: unknown): v is CargoKind {
31
+ return typeof v === "string" && (CARGO_KINDS as readonly string[]).includes(v);
32
+ }
33
+
34
+ /** Kinds whose stored content is a runnable HTML document. */
35
+ const HTML_KINDS: readonly CargoKind[] = ["webpage", "slides", "game"];
36
+
37
+ export const isHtmlKind = (k: CargoKind): boolean => HTML_KINDS.includes(k);
38
+
39
+ // The artifact's `langs` metadata is deliberately NOT computed here. The app stamps it
40
+ // at save time (RemoteDriveContext, the same expression fileImportService uses), because
41
+ // it has to match what extractRunnableCode stamps for a model-authored artifact exactly —
42
+ // and a second copy on this side of the wire is precisely the drift that would make a
43
+ // terminal-authored artifact render differently from an identical one built in chat.
44
+
45
+ /**
46
+ * The kind implied by a file extension, for when the caller didn't say. Only the
47
+ * unambiguous ones map: .html could be a page, a deck or a game and the model is
48
+ * the one that knows which, so it lands on 'webpage' and is told to say if it
49
+ * meant otherwise. Returns null when the extension isn't one Cargo can hold —
50
+ * the tool refuses rather than guessing, because a .png "saved as a webpage" is
51
+ * a broken artifact the user only discovers when they open it.
52
+ */
53
+ export function kindForExtension(ext: string): CargoKind | null {
54
+ switch (ext.toLowerCase()) {
55
+ case ".html":
56
+ case ".htm":
57
+ return "webpage";
58
+ case ".md":
59
+ case ".markdown":
60
+ return "md";
61
+ case ".csv":
62
+ return "sheet";
63
+ default:
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Ceiling on the artifact source, matching the app's own MAX_DOC_BYTES
70
+ * (client/utils/extractRunnableCode.ts:46). An artifact arriving from the
71
+ * terminal lands in the same store and the same preview surfaces as a
72
+ * model-authored one, so anything the app would refuse from the model has to be
73
+ * refused here too — and refused HERE, where the message can name the file and
74
+ * its size, rather than as a save failure three frames later.
75
+ */
76
+ export const MAX_CARGO_BYTES = 512 * 1024;
77
+
78
+ /**
79
+ * Characters of artifact text per `cargo_chunk` frame. The relay caps a frame at
80
+ * 256 KB and an artifact may be twice that, so the content is chunked the same
81
+ * way sendFile chunks a file. Text, not base64: a Cargo artifact is HTML,
82
+ * markdown or CSV by definition, so there is nothing binary to encode and the
83
+ * 4/3 inflation would buy nothing. Splitting mid-surrogate is safe — JSON.stringify
84
+ * escapes a lone surrogate as \udXXX and JSON.parse restores it, so the halves
85
+ * rejoin into the original pair on the app side.
86
+ */
87
+ export const CARGO_CHUNK_CHARS = 120_000;
88
+
89
+ /** A CLI-initiated Cargo save, relayed to the app to encrypt and store. */
90
+ export interface CargoSaveRequest {
91
+ /** Artifact source: an HTML document, markdown, or CSV per `kind`. */
92
+ content: string;
93
+ kind: CargoKind;
94
+ /** Title for the artifact. Omitted → the app derives one from the content. */
95
+ title?: string;
96
+ }
97
+
98
+ /**
99
+ * The app's answer. `ok: false` carries a reason written for a person — a locked
100
+ * vault, full cloud storage, a guest session — because the tool hands it
101
+ * straight to the model, and "save failed" is not something it can act on.
102
+ */
103
+ export type CargoSaveResult =
104
+ | { ok: true; cargoId: string; title: string; storageType: string }
105
+ | { ok: false; reason: string };
@@ -22,6 +22,7 @@ import { apiRequest, serverBaseUrl } from "../auth/privateer.ts";
22
22
  import { MOAT_SHIMS, reservedNames } from "../config/moatManifest.ts";
23
23
  import type { EngineEvent } from "../engine/events.ts";
24
24
  import type { PermissionRequest } from "../permissions/gate.ts";
25
+ import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
25
26
 
26
27
  // Display label for THIS running terminal. Deliberately NON-PII: we do NOT send
27
28
  // username@hostname or the working-directory name to the server/controller (the
@@ -134,6 +135,13 @@ export interface RelayCallbacks {
134
135
  // The app answered a CLI-initiated text-input prompt (the id from requestInput).
135
136
  // A null value means the app dismissed the prompt without submitting.
136
137
  onInputResponse?: (id: string, value: string | null) => void;
138
+ // The app finished a CLI-initiated Cargo save (the id from requestCargoSave):
139
+ // it encrypted the artifact under the account master key and stored it, or
140
+ // refused. Optional so callbacks that predate the frame keep compiling — and a
141
+ // controller too old to understand cargo_begin simply never answers, which the
142
+ // bridge's bounded wait turns into a clean "this app can't save artifacts yet"
143
+ // rather than a wedged tool call.
144
+ onCargoSaved?: (id: string, result: CargoSaveResult) => void;
137
145
  // The app's composer is autocompleting an `@file` mention — reply with the cwd
138
146
  // files/dirs matching `query` (a sendFileMatches frame, keyed by the same id).
139
147
  // Read-only; resolution of the picked path still happens on the prompt turn.
@@ -667,6 +675,11 @@ export class RelayClient {
667
675
  query?: string;
668
676
  sig?: string;
669
677
  ts?: number;
678
+ // cargo_saved (the app's verdict on a save_cargo round trip)
679
+ ok?: boolean;
680
+ cargoId?: string;
681
+ storageType?: string;
682
+ reason?: string;
670
683
  };
671
684
  try {
672
685
  frame = JSON.parse(data.toString());
@@ -724,6 +737,24 @@ export class RelayClient {
724
737
  case "input_response":
725
738
  if (frame.id) this.cb.onInputResponse?.(frame.id, typeof frame.value === "string" ? frame.value : null);
726
739
  break;
740
+ // The app's verdict on a Cargo save we sent up. Everything is re-typed off
741
+ // the wire rather than trusted: the id keys a pending tool call, and the
742
+ // rest is quoted back to the model, so a malformed frame must degrade to a
743
+ // refusal with a reason instead of an `undefined` the tool prints.
744
+ case "cargo_saved": {
745
+ if (typeof frame.id !== "string" || !frame.id) break;
746
+ const result: CargoSaveResult =
747
+ frame.ok === true && typeof frame.cargoId === "string" && frame.cargoId
748
+ ? {
749
+ ok: true,
750
+ cargoId: frame.cargoId,
751
+ title: typeof frame.title === "string" ? frame.title : "",
752
+ storageType: frame.storageType === "local" ? "local" : "cloud",
753
+ }
754
+ : { ok: false, reason: typeof frame.reason === "string" && frame.reason ? frame.reason : "the app refused the save without giving a reason" };
755
+ this.cb.onCargoSaved?.(frame.id, result);
756
+ break;
757
+ }
727
758
  case "files_search":
728
759
  if (typeof frame.id === "string") this.cb.onFilesSearch?.(frame.id, typeof frame.query === "string" ? frame.query : "");
729
760
  break;
@@ -1358,6 +1389,34 @@ export class RelayClient {
1358
1389
  });
1359
1390
  }
1360
1391
 
1392
+ // Ask the app to save an artifact as Cargo: it encrypts under the account
1393
+ // master key (which this process does not have — see cargoSave.ts) and stores
1394
+ // it, then answers with a cargo_saved keyed by `id`.
1395
+ //
1396
+ // Chunked like sendFile, and for the same reason: the relay caps a frame at
1397
+ // 256 KB and an artifact may be 512 KB. Ordering is the WS's, so the app can
1398
+ // reject a seq gap as a dropped transfer rather than silently storing a
1399
+ // half-artifact.
1400
+ //
1401
+ // NOT run through `safe()`. That redacts secrets, and redaction inside an
1402
+ // artifact is corruption — it would store a document with `[redacted]` where
1403
+ // the model wrote an API shape, and the user would find out on Preview. Same
1404
+ // call sendFile makes about its bytes; the tool decides what is safe to send.
1405
+ requestCargoSave(id: string, req: CargoSaveRequest): void {
1406
+ this.flushDeltas(); // land the save in order relative to buffered text
1407
+ this.rawSend({
1408
+ type: "cargo_begin",
1409
+ id,
1410
+ kind: req.kind,
1411
+ title: req.title ? clip(req.title, 200) : undefined,
1412
+ size: Buffer.byteLength(req.content, "utf8"),
1413
+ });
1414
+ for (let off = 0, seq = 0; off < req.content.length; off += CARGO_CHUNK_CHARS, seq++) {
1415
+ this.rawSend({ type: "cargo_chunk", id, seq, data: req.content.slice(off, off + CARGO_CHUNK_CHARS) });
1416
+ }
1417
+ this.rawSend({ type: "cargo_end", id });
1418
+ }
1419
+
1361
1420
  requestApproval(id: string, req: PermissionRequest): void {
1362
1421
  this.rawSend({
1363
1422
  type: "approval_request",
@@ -15,12 +15,20 @@ import type { EngineEvent } from "../engine/events.ts";
15
15
  import type { PermissionRequest } from "../permissions/gate.ts";
16
16
  import type { AskOutcome } from "../permissions/modeGate.ts";
17
17
  import type { RelayCallbacks } from "./relayClient.ts";
18
+ import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
18
19
 
19
20
  // How much of a driven turn's reply we hold for possible outbox delivery. The
20
21
  // sealed item is capped at 45k plaintext anyway; this just stops a pathological
21
22
  // turn from growing the buffer without bound.
22
23
  const MAX_TURN_CAPTURE = 60_000;
23
24
 
25
+ // How long the app gets to encrypt an artifact and store it before the save_cargo tool
26
+ // gives up. Generous because the app end is a real round trip — encrypt, POST /api/cargo,
27
+ // wait on the network — and a phone on a slow link is the normal case, not the edge one.
28
+ // Read per call rather than at module load so a test can set it without re-importing the
29
+ // module (and so the env is read in the process that actually runs the save).
30
+ const cargoSaveTimeoutMs = (): number => Number(process.env.PRIVATEER_CARGO_TIMEOUT_MS) || 60_000;
31
+
24
32
  // The outbound surface the bridge needs; RelayClient implements all of it.
25
33
  export interface RelayLike {
26
34
  requestApproval(id: string, req: PermissionRequest): void;
@@ -39,6 +47,7 @@ export interface RelayLike {
39
47
  sendFileMatches(id: string, matches: { path: string; isDir: boolean }[]): void;
40
48
  sendExtensions(payload: ExtensionsPayload): void;
41
49
  sendSkills(payload: SkillsPayload): void;
50
+ requestCargoSave(id: string, req: CargoSaveRequest): void;
42
51
  }
43
52
 
44
53
  // The installed-extensions snapshot relayed to the app's extensions manager.
@@ -139,6 +148,7 @@ export class RemoteBridge {
139
148
  private readonly pending = new Map<string, (d: AskOutcome) => void>();
140
149
  private readonly pendingSelects = new Map<string, (v: string | null) => void>();
141
150
  private readonly pendingInputs = new Map<string, (v: string | null) => void>();
151
+ private readonly pendingCargo = new Map<string, (r: CargoSaveResult) => void>();
142
152
  private pendingAttachments: RemoteAttachment[] = [];
143
153
  // The driven turn in flight, kept only so it can be delivered to the outbox if it
144
154
  // turns out nobody was watching (see settleTurn). Bounded: the outbox truncates at
@@ -230,6 +240,10 @@ export class RemoteBridge {
230
240
  const resolve = this.pendingInputs.get(id);
231
241
  if (resolve) resolve(value);
232
242
  },
243
+ onCargoSaved: (id, result) => {
244
+ const resolve = this.pendingCargo.get(id);
245
+ if (resolve) resolve(result);
246
+ },
233
247
  onFilesSearch: (id, query) => this.cfg.onFilesSearch?.(id, query),
234
248
  onNoQuarter: (on) => {
235
249
  this.noQuarter = on;
@@ -402,6 +416,57 @@ export class RemoteBridge {
402
416
  return this.relay.sendFile(file);
403
417
  }
404
418
 
419
+ // Hand an artifact to the app to encrypt and store as Cargo (the save_cargo tool),
420
+ // and wait for its verdict. The app owns the master key; this process never has one,
421
+ // so this round trip IS the feature rather than a convenience — see cargoSave.ts.
422
+ //
423
+ // Three ways this ends without a stored artifact, each with its own message, because
424
+ // the model gets the reason verbatim and they call for different next moves:
425
+ //
426
+ // - No controller. Unlike sendFile, "handed to an open socket" is not good enough
427
+ // here: the caller is promised an artifact id, and with nobody attached the server
428
+ // drops the frames and we would wait out the full timeout to learn it. hasController()
429
+ // is checked up front so the failure is immediate and says what to do about it.
430
+ // - The app answered a refusal (locked vault, storage full, guest session). Passed
431
+ // through as written — those messages already exist for a person to read.
432
+ // - Nothing came back inside the deadline. Nothing else wraps this in a timeout the
433
+ // way the gate wraps remoteAsk, so a silent or too-old app would wedge the turn
434
+ // forever without one.
435
+ saveCargoRemote = (req: CargoSaveRequest, signal?: AbortSignal): Promise<CargoSaveResult> => {
436
+ 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" });
437
+ if (!this.relay.isConnected()) return Promise.resolve({ ok: false, reason: "the relay is not connected" });
438
+ // hasController is optional on RelayLike; a transport that can't tell is treated as
439
+ // attached, matching how the rest of the bridge reads it.
440
+ if (this.relay.hasController && !this.relay.hasController()) {
441
+ 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" });
442
+ }
443
+ const id = randomUUID();
444
+ return new Promise<CargoSaveResult>((resolve) => {
445
+ const settle = (r: CargoSaveResult) => {
446
+ if (!this.pendingCargo.has(id)) return; // already settled (abort raced the reply)
447
+ this.pendingCargo.delete(id);
448
+ clearTimeout(timer);
449
+ signal?.removeEventListener("abort", onAbort);
450
+ resolve(r);
451
+ };
452
+ const onAbort = () => settle({ ok: false, reason: "the turn was interrupted before the app confirmed the save" });
453
+ const deadline = cargoSaveTimeoutMs();
454
+ const timer = setTimeout(
455
+ () => settle({ ok: false, reason: `the app did not answer within ${Math.round(deadline / 1000)}s — it may be an older version that cannot save artifacts from a terminal` }),
456
+ deadline,
457
+ );
458
+ // Don't hold the process open on this timer alone; an exiting CLI shouldn't
459
+ // linger for a save the app is never going to answer.
460
+ timer.unref?.();
461
+ this.pendingCargo.set(id, settle);
462
+ if (signal) {
463
+ if (signal.aborted) return onAbort();
464
+ signal.addEventListener("abort", onAbort, { once: true });
465
+ }
466
+ this.relay!.requestCargoSave(id, req);
467
+ });
468
+ };
469
+
405
470
  private rejectAllPending(): void {
406
471
  for (const resolve of this.pending.values()) resolve("deny");
407
472
  this.pending.clear();
@@ -411,5 +476,15 @@ export class RemoteBridge {
411
476
  // Same for a relayed text prompt: a gone controller resolves to "no input".
412
477
  for (const resolve of this.pendingInputs.values()) resolve(null);
413
478
  this.pendingInputs.clear();
479
+ // A save whose controller vanished mid-flight is NOT reported as failed-and-done:
480
+ // the app may have encrypted and stored the artifact before its socket dropped, and
481
+ // the frame carrying the id is what we lost. Saying "it didn't save" would send the
482
+ // model round again and leave the user with two copies of the same artifact, so the
483
+ // reason says plainly that the outcome is unknown and names Cargo as the place to
484
+ // look before retrying.
485
+ for (const resolve of this.pendingCargo.values()) {
486
+ resolve({ ok: false, reason: "the app disconnected before confirming the save — it may or may not have stored the artifact; check Cargo in the app before saving again" });
487
+ }
488
+ this.pendingCargo.clear();
414
489
  }
415
490
  }