privateer-agent 0.14.2 → 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.
- package/extensions/privateer-gate.ts +4 -4
- package/package.json +1 -1
- package/src/auth/accountSessions.ts +116 -60
- package/src/auth/privateer.ts +10 -2
- package/src/config/moat.ts +12 -0
- package/src/config/paths.ts +12 -0
- package/src/config/whatsNew.json +4 -4
- package/src/engine/errors.ts +1 -1
- package/src/outbox/cloudOutbox.ts +76 -1
- package/src/permissions/classify.ts +1 -1
- package/src/remote/cargoSave.ts +19 -5
- package/src/remote/remoteBridge.ts +6 -3
- package/src/tools/cargo.ts +46 -12
- package/src/tools/media.ts +12 -2
- package/src/tools/relayFileTools.ts +7 -7
- package/src/tools/web.ts +17 -2
|
@@ -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
|
|
644
|
-
// app
|
|
645
|
-
//
|
|
646
|
-
// the
|
|
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
|
+
"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",
|
|
@@ -21,21 +21,50 @@
|
|
|
21
21
|
// RECYCLED pid makes a dead owner look alive, so we skip a reclaimable session and
|
|
22
22
|
// spawn a fresh one — the old behaviour, no harm. The dangerous direction (a live
|
|
23
23
|
// process reported dead) can't happen: a running pid never reports ESRCH.
|
|
24
|
+
//
|
|
25
|
+
// LAYOUT — one file per owning pid (`<pid>.json` in accountSessionsDir), and an orphan
|
|
26
|
+
// is CLAIMED by renaming its file before anyone rotates it. This used to be a single
|
|
27
|
+
// JSON file that every process read, modified and wrote back whole, and that was a real
|
|
28
|
+
// outage under concurrency (an agent firing several `privateer -p` one-shots at once):
|
|
29
|
+
//
|
|
30
|
+
// A reads {orphan: R} · B reads {orphan: R} · B adopts the orphan (R → R′) and
|
|
31
|
+
// writes it out · A writes ITS stale copy back, orphan entry and all.
|
|
32
|
+
//
|
|
33
|
+
// The resurrected entry names a refresh token that was already rotated. The next launch
|
|
34
|
+
// "reclaims" it, the server sees a spent token replayed past its 30s grace window, calls
|
|
35
|
+
// it theft, and revokes the whole lineage — which is the session B (or whoever adopted
|
|
36
|
+
// down the chain) is using right now, and possibly the one in the shared auth.json that
|
|
37
|
+
// other running terminals read. They all 401 mid-run, and a one-shot run has no next
|
|
38
|
+
// message to recover on, so the user is told to /login while signed in.
|
|
39
|
+
//
|
|
40
|
+
// Per-pid files mean no process ever writes another's entry, and rename is atomic on
|
|
41
|
+
// every platform we ship on, so exactly one claimer wins an orphan and the rest get
|
|
42
|
+
// ENOENT and move on. A claim whose claimant died is deleted rather than released: its
|
|
43
|
+
// token may already have been rotated, and offering it again is the same hazard.
|
|
24
44
|
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
26
|
-
import {
|
|
27
|
-
import { accountSessionsPath, globalDir } from "../config/paths.ts";
|
|
45
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
import { accountSessionsDir, accountSessionsPath, globalDir } from "../config/paths.ts";
|
|
28
48
|
|
|
29
|
-
// One spawned session, keyed
|
|
30
|
-
//
|
|
31
|
-
//
|
|
49
|
+
// One spawned session, keyed by the pid of its owning terminal. `refresh` is what lets
|
|
50
|
+
// a later launch adopt or revoke it; `expires` is its access token's exp (see
|
|
51
|
+
// jwtExpMs), used only to prune entries that are dead server-side anyway.
|
|
32
52
|
export interface OwnedSession {
|
|
33
53
|
pid: number;
|
|
34
54
|
refresh: string;
|
|
35
55
|
expires: number;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
const ENTRY = /^(\d+)\.json$/;
|
|
59
|
+
const CLAIM = /^(\d+)\.claim-(\d+)$/;
|
|
60
|
+
|
|
61
|
+
function entryPath(pid: number): string {
|
|
62
|
+
return join(accountSessionsDir(), `${pid}.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function claimPath(pid: number, claimant = process.pid): string {
|
|
66
|
+
return join(accountSessionsDir(), `${pid}.claim-${claimant}`);
|
|
67
|
+
}
|
|
39
68
|
|
|
40
69
|
function tryChmod(path: string, mode: number): void {
|
|
41
70
|
try {
|
|
@@ -45,31 +74,21 @@ function tryChmod(path: string, mode: number): void {
|
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
|
|
48
|
-
function
|
|
49
|
-
const path = accountSessionsPath();
|
|
50
|
-
if (!existsSync(path)) return {};
|
|
77
|
+
function tryRm(path: string): void {
|
|
51
78
|
try {
|
|
52
|
-
|
|
53
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Registry) : {};
|
|
79
|
+
rmSync(path, { force: true });
|
|
54
80
|
} catch {
|
|
55
|
-
|
|
81
|
+
/* already gone, or unremovable — nothing more to do */
|
|
56
82
|
}
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
|
|
60
|
-
// terminals racing can still lose one entry (last writer wins); the cost is one
|
|
61
|
-
// unreclaimable orphan, not a broken launch, so a lock file isn't worth it here.
|
|
62
|
-
function writeRegistry(reg: Registry): void {
|
|
63
|
-
const path = accountSessionsPath();
|
|
85
|
+
function readEntry(path: string, pid: number): OwnedSession | null {
|
|
64
86
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
69
|
-
tryChmod(tmp, 0o600);
|
|
70
|
-
renameSync(tmp, path);
|
|
87
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as { refresh?: unknown; expires?: unknown };
|
|
88
|
+
if (typeof raw?.refresh !== "string" || !raw.refresh) return null;
|
|
89
|
+
return { pid, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
|
|
71
90
|
} catch {
|
|
72
|
-
|
|
91
|
+
return null; // corrupt/truncated — treated as malformed and pruned
|
|
73
92
|
}
|
|
74
93
|
}
|
|
75
94
|
|
|
@@ -86,72 +105,109 @@ function isAlive(pid: number): boolean {
|
|
|
86
105
|
}
|
|
87
106
|
}
|
|
88
107
|
|
|
89
|
-
function parseEntry(pid: string, raw: { refresh?: unknown; expires?: unknown }): OwnedSession | null {
|
|
90
|
-
const n = Number(pid);
|
|
91
|
-
if (!Number.isInteger(n) || typeof raw?.refresh !== "string" || !raw.refresh) return null;
|
|
92
|
-
return { pid: n, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
108
|
// Claim (or re-claim) a session for THIS process. Called wherever the account
|
|
96
109
|
// credential is minted or rotated — spawnAccountCredentials and
|
|
97
110
|
// refreshAccountCredentials — so the registry always holds the token that would
|
|
98
|
-
// actually work, including the rotations Pi drives on its own.
|
|
111
|
+
// actually work, including the rotations Pi drives on its own. Written via temp +
|
|
112
|
+
// rename so a concurrent reader never sees a half-written file.
|
|
99
113
|
export function recordOwnedSession(cred: { refresh: string; expires: number }): void {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(accountSessionsDir(), { recursive: true });
|
|
116
|
+
tryChmod(globalDir(), 0o700);
|
|
117
|
+
tryChmod(accountSessionsDir(), 0o700);
|
|
118
|
+
const path = entryPath(process.pid);
|
|
119
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
120
|
+
writeFileSync(tmp, JSON.stringify({ refresh: cred.refresh, expires: cred.expires }) + "\n", {
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
mode: 0o600,
|
|
123
|
+
});
|
|
124
|
+
tryChmod(tmp, 0o600);
|
|
125
|
+
renameSync(tmp, path);
|
|
126
|
+
} catch {
|
|
127
|
+
/* best effort — losing the registry costs reclamation, never correctness */
|
|
128
|
+
}
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
// Drop this process's entry — the session is being revoked (clean exit, /signout), so
|
|
106
132
|
// it is about to stop existing server-side. Leaving it behind would advertise a dead
|
|
107
133
|
// session as a reclaimable orphan to the next launch.
|
|
108
134
|
export function forgetOwnedSession(): void {
|
|
109
|
-
|
|
110
|
-
if (!(String(process.pid) in reg)) return;
|
|
111
|
-
delete reg[String(process.pid)];
|
|
112
|
-
writeRegistry(reg);
|
|
135
|
+
tryRm(entryPath(process.pid));
|
|
113
136
|
}
|
|
114
137
|
|
|
115
|
-
// Sessions whose owning terminal is gone: candidates to adopt or revoke.
|
|
116
|
-
//
|
|
117
|
-
//
|
|
138
|
+
// Sessions whose owning terminal is gone: candidates to adopt or revoke. Listing is NOT
|
|
139
|
+
// claiming — call claimOrphan before rotating one. Prunes entries that are unusable
|
|
140
|
+
// anyway (malformed, or past their expiry) and claims left by a dead claimant, so the
|
|
141
|
+
// directory can't grow without bound. Our own pid is never a candidate.
|
|
118
142
|
export function orphanedSessions(now = Date.now()): OwnedSession[] {
|
|
119
|
-
|
|
143
|
+
let names: string[];
|
|
144
|
+
try {
|
|
145
|
+
names = readdirSync(accountSessionsDir());
|
|
146
|
+
} catch {
|
|
147
|
+
return []; // no registry yet
|
|
148
|
+
}
|
|
120
149
|
const orphans: OwnedSession[] = [];
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
const path = join(accountSessionsDir(), name);
|
|
152
|
+
const claim = CLAIM.exec(name);
|
|
153
|
+
if (claim) {
|
|
154
|
+
// Mid-adoption by a live process: theirs. Claimant died: its token may already
|
|
155
|
+
// be rotated, so it is junk — never put back up for reclamation.
|
|
156
|
+
if (!isAlive(Number(claim[2]))) tryRm(path);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const m = ENTRY.exec(name);
|
|
160
|
+
if (!m) continue; // a temp file mid-write, or something that isn't ours
|
|
161
|
+
const pid = Number(m[1]);
|
|
162
|
+
if (pid === process.pid || isAlive(pid)) continue; // ours, or a live terminal's
|
|
163
|
+
const entry = readEntry(path, pid);
|
|
125
164
|
if (!entry || (entry.expires > 0 && entry.expires <= now)) {
|
|
126
|
-
|
|
127
|
-
pruned = true;
|
|
165
|
+
tryRm(path); // malformed, or dead server-side — nothing to reclaim
|
|
128
166
|
continue;
|
|
129
167
|
}
|
|
130
|
-
if (entry.pid === process.pid || isAlive(entry.pid)) continue; // ours, or a live terminal's
|
|
131
168
|
orphans.push(entry);
|
|
132
169
|
}
|
|
133
|
-
|
|
134
|
-
if (pruned) writeRegistry(reg);
|
|
135
170
|
return orphans;
|
|
136
171
|
}
|
|
137
172
|
|
|
173
|
+
// Take an orphan for THIS process, atomically. True only for the one caller whose rename
|
|
174
|
+
// landed; everyone else racing for the same orphan gets false and must leave it alone.
|
|
175
|
+
// Pair with dropOwnedSession (handled) or releaseOrphan (couldn't reach the server).
|
|
176
|
+
export function claimOrphan(pid: number): boolean {
|
|
177
|
+
try {
|
|
178
|
+
renameSync(entryPath(pid), claimPath(pid));
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Put back an orphan we claimed but couldn't settle: its refresh never reached the
|
|
186
|
+
// server, so the token is still the live one and the row is still worth reclaiming.
|
|
187
|
+
export function releaseOrphan(pid: number): void {
|
|
188
|
+
try {
|
|
189
|
+
renameSync(claimPath(pid), entryPath(pid));
|
|
190
|
+
} catch {
|
|
191
|
+
tryRm(claimPath(pid));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
138
195
|
// Forget one orphan, once it has been definitively handled (adopted, or confirmed dead
|
|
139
|
-
// server-side).
|
|
140
|
-
//
|
|
196
|
+
// server-side). An entry whose refresh merely FAILED TO REACH the server goes back via
|
|
197
|
+
// releaseOrphan instead: dropping it on a network blip would leak that row until its TTL.
|
|
141
198
|
export function dropOwnedSession(pid: number): void {
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
delete reg[String(pid)];
|
|
145
|
-
writeRegistry(reg);
|
|
199
|
+
tryRm(claimPath(pid));
|
|
200
|
+
if (pid !== process.pid) tryRm(entryPath(pid));
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
// Wipe the registry. Used by logout(), where the server has just revoked this
|
|
149
204
|
// machine's whole token family: every entry now names a dead session, and leaving
|
|
150
205
|
// them behind would offer the next login a list of orphans to "reclaim" that can
|
|
151
|
-
// only fail. Also a test seam.
|
|
206
|
+
// only fail. Also removes the legacy single-file registry. Also a test seam.
|
|
152
207
|
export function clearOwnedSessions(): void {
|
|
208
|
+
tryRm(accountSessionsPath());
|
|
153
209
|
try {
|
|
154
|
-
rmSync(
|
|
210
|
+
if (existsSync(accountSessionsDir())) rmSync(accountSessionsDir(), { recursive: true, force: true });
|
|
155
211
|
} catch {
|
|
156
212
|
/* nothing to remove */
|
|
157
213
|
}
|
package/src/auth/privateer.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
recordOwnedSession,
|
|
21
21
|
forgetOwnedSession,
|
|
22
22
|
orphanedSessions,
|
|
23
|
+
claimOrphan,
|
|
24
|
+
releaseOrphan,
|
|
23
25
|
dropOwnedSession,
|
|
24
26
|
clearOwnedSessions,
|
|
25
27
|
} from "./accountSessions.ts";
|
|
@@ -811,13 +813,17 @@ export async function acquireAccountCredential(): Promise<AccountCredential> {
|
|
|
811
813
|
|
|
812
814
|
while (attempted < orphans.length && !adopted) {
|
|
813
815
|
const orphan = orphans[attempted++];
|
|
816
|
+
// Another launch racing us may already have it. Rotating it anyway would replay a
|
|
817
|
+
// token that launch just spent — reuse detection, and its session revoked mid-run.
|
|
818
|
+
if (!claimOrphan(orphan.pid)) continue;
|
|
814
819
|
try {
|
|
815
820
|
adopted = await refreshAccountCredentials(orphan.refresh);
|
|
816
821
|
dropOwnedSession(orphan.pid); // the rotation above re-recorded it under OUR pid
|
|
817
822
|
} catch (e) {
|
|
818
|
-
// Refused → the session is gone; stop tracking it. Unreachable →
|
|
819
|
-
// network blip doesn't strand a live row we could have reclaimed next launch.
|
|
823
|
+
// Refused → the session is gone; stop tracking it. Unreachable → put it back, so
|
|
824
|
+
// a network blip doesn't strand a live row we could have reclaimed next launch.
|
|
820
825
|
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
826
|
+
else releaseOrphan(orphan.pid);
|
|
821
827
|
}
|
|
822
828
|
}
|
|
823
829
|
|
|
@@ -836,12 +842,14 @@ export async function acquireAccountCredential(): Promise<AccountCredential> {
|
|
|
836
842
|
// overwrite the entry for the credential this terminal is actually using.
|
|
837
843
|
async function revokeOrphanedSessions(orphans: OwnedSession[], timeoutMs = 1500): Promise<void> {
|
|
838
844
|
for (const orphan of orphans) {
|
|
845
|
+
if (!claimOrphan(orphan.pid)) continue; // another launch got there first
|
|
839
846
|
try {
|
|
840
847
|
const cred = await rotateSession(orphan.refresh);
|
|
841
848
|
await deleteSession(cred.access, timeoutMs);
|
|
842
849
|
dropOwnedSession(orphan.pid);
|
|
843
850
|
} catch (e) {
|
|
844
851
|
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
852
|
+
else releaseOrphan(orphan.pid);
|
|
845
853
|
}
|
|
846
854
|
}
|
|
847
855
|
}
|
package/src/config/moat.ts
CHANGED
|
@@ -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) {
|
package/src/config/paths.ts
CHANGED
|
@@ -45,6 +45,18 @@ export function configPath(): string {
|
|
|
45
45
|
// session belonging to a STILL-RUNNING terminal from one orphaned by a crash, so it
|
|
46
46
|
// can reclaim the orphan instead of spawning another and walking into the server's
|
|
47
47
|
// per-device terminal cap. Holds refresh tokens — written 0600, like credentials.json.
|
|
48
|
+
//
|
|
49
|
+
// One file per owning pid in a directory, not one shared JSON file: concurrent launches
|
|
50
|
+
// (an agent firing several `privateer -p` runs at once) each read-modify-wrote the whole
|
|
51
|
+
// file, so one could write back an orphan another had already adopted (see
|
|
52
|
+
// auth/accountSessions.ts).
|
|
53
|
+
export function accountSessionsDir(): string {
|
|
54
|
+
return join(globalDir(), "account-sessions");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The pre-directory single-file registry. Only ever deleted now (clearOwnedSessions);
|
|
58
|
+
// its entries are never offered for reclamation, because a stale one is exactly the
|
|
59
|
+
// token that trips the server's reuse detection.
|
|
48
60
|
export function accountSessionsPath(): string {
|
|
49
61
|
return join(globalDir(), "account-sessions.json");
|
|
50
62
|
}
|
package/src/config/whatsNew.json
CHANGED
|
@@ -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
|
+
"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" },
|
|
6
|
+
{ "text": "Several privateer -p runs at once no longer sign each other out", "since": "0.14.3" },
|
|
5
7
|
{ "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" },
|
|
6
|
-
{ "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" }
|
|
7
|
-
{ "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
|
|
8
|
-
{ "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" }
|
|
8
|
+
{ "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" }
|
|
9
9
|
]
|
|
10
10
|
}
|
package/src/engine/errors.ts
CHANGED
|
@@ -597,7 +597,7 @@ export function describeErrorText(
|
|
|
597
597
|
bare ? `${who} ${refused} this request was sent with (${status}).` : compactProviderError(s),
|
|
598
598
|
),
|
|
599
599
|
hint: account
|
|
600
|
-
? "This
|
|
600
|
+
? "This run's Privateer session was refused. A fresh one is opened automatically — send the message again, or re-run a one-shot (`-p`) command. Only if it keeps failing, check `privateer auth status`."
|
|
601
601
|
: `Check the API key for ${who} — run /login, or set the provider's API key env var.`,
|
|
602
602
|
};
|
|
603
603
|
}
|
|
@@ -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,
|
package/src/remote/cargoSave.ts
CHANGED
|
@@ -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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
|
|
526
|
-
|
|
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
|
|
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) => {
|
package/src/tools/cargo.ts
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
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
|
|
169
|
-
|
|
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}). ` +
|
package/src/tools/media.ts
CHANGED
|
@@ -227,12 +227,22 @@ async function callAccount<T>(
|
|
|
227
227
|
if (res.status === 402 || /INSUFFICIENT|QUOTA|TOP_?UP/i.test(code)) {
|
|
228
228
|
return { ok: false, status: res.status, message: serverMessage || "the account is out of credit for media generation — top up or upgrade to continue" };
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
// Same split as callRag (tools/web.ts): a 401 survived authedFetch's retry, a 403 is
|
|
231
|
+
// a refusal that signing in again won't change.
|
|
232
|
+
if (res.status === 401) {
|
|
231
233
|
return {
|
|
232
234
|
ok: false, status: res.status,
|
|
233
235
|
message:
|
|
234
236
|
serverMessage ||
|
|
235
|
-
"this agent
|
|
237
|
+
"Privateer refused this agent's session (401) — running the command again opens a fresh one; if it keeps failing, check `privateer auth status`",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (res.status === 403) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false, status: res.status,
|
|
243
|
+
message:
|
|
244
|
+
serverMessage ||
|
|
245
|
+
"Privateer refused this media request (403) — the plan may not include it; this is not a sign-in problem",
|
|
236
246
|
};
|
|
237
247
|
}
|
|
238
248
|
if (res.status === 400 || res.status === 413) {
|
|
@@ -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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// save_to_library rides here
|
|
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
|
|
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
|
package/src/tools/web.ts
CHANGED
|
@@ -129,8 +129,23 @@ export async function callRag<T>(path: string, body: unknown): Promise<({ ok: tr
|
|
|
129
129
|
if (res.status === 402 || /CAP|QUOTA|INSUFFICIENT|TOP_?UP/i.test(code)) {
|
|
130
130
|
return { ok: false, message: serverMessage || "the account is out of credit for web access — top up or upgrade to continue" };
|
|
131
131
|
}
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
// A 401 here has already survived authedFetch's refresh-and-retry, so the session
|
|
133
|
+
// really couldn't be renewed. A 403 is NOT a sign-in problem — a plan or account
|
|
134
|
+
// refusal, or the edge firewall in front of the API — and calling it "not signed in"
|
|
135
|
+
// had models telling signed-in users to log in. Pass the server's reason through.
|
|
136
|
+
if (res.status === 401) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
message:
|
|
140
|
+
serverMessage ||
|
|
141
|
+
"Privateer refused this agent's session for web access (401) — running the command again opens a fresh one; if it keeps failing, check `privateer auth status`",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 403) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
message: serverMessage || "Privateer refused this web request (403) — not a sign-in problem, so logging in again won't change it",
|
|
148
|
+
};
|
|
134
149
|
}
|
|
135
150
|
if (res.status === 400) {
|
|
136
151
|
return { ok: false, message: serverMessage || `Privateer rejected the request${code ? ` (${code})` : ""}` };
|