privateer-agent 0.12.15 → 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.
- package/README.md +14 -0
- package/extensions/privateer-gate.ts +6 -0
- package/extensions/privateer-privacy.ts +5 -5
- package/package.json +2 -2
- package/src/cli/chat.ts +20 -3
- package/src/config/moat.ts +4 -4
- package/src/config/piiAllow.ts +117 -0
- package/src/config/privacyPolicy.ts +79 -2
- package/src/permissions/classify.ts +38 -0
- package/src/permissions/noQuarter.ts +22 -14
- package/src/providers/account.ts +6 -0
- package/src/remote/cargoSave.ts +105 -0
- package/src/remote/relayClient.ts +59 -0
- package/src/remote/remoteBridge.ts +75 -0
- package/src/tools/cargo.ts +181 -0
- package/src/tools/relayFileTools.ts +8 -1
package/README.md
CHANGED
|
@@ -296,6 +296,20 @@ skipped entirely on an attested TEE or on-device channel, which provably can't r
|
|
|
296
296
|
prompt anyway. It's best-effort structured-PII detection, labeled as such — a safety net, not
|
|
297
297
|
a guarantee.
|
|
298
298
|
|
|
299
|
+
Pattern detection fires on anything email-*shaped*, so some of what it finds isn't personal
|
|
300
|
+
data at all. **`/privacy allow <value>`** is where you say so — an address
|
|
301
|
+
(`me@acme.com`), a domain (`@acme.com`), an IPv4 block (`10.0.0.0/8`), or any exact or
|
|
302
|
+
globbed value. Entries live in `privacy.piiAllow` in `~/.privateer/config.json`, apply from
|
|
303
|
+
the next turn (no relaunch), and persist across sessions; `/privacy` on its own lists them
|
|
304
|
+
and `/privacy unallow <value>` puts one back under the gate. Reserved shapes —
|
|
305
|
+
`example.com`, loopback, `noreply@…`, `@users.noreply.github.com` — are allowed out of the
|
|
306
|
+
box. `PI_PRIVACY_*` env vars and a `pi-privacy.config.json` are honoured too (a
|
|
307
|
+
project-local file can only ever make the gate *stricter*).
|
|
308
|
+
|
|
309
|
+
Under **no quarter** the gate doesn't ask — there's nobody to ask — so it redacts and sends,
|
|
310
|
+
and prints what it masked. That's the one case where a false positive changes what the model
|
|
311
|
+
sees without you seeing it first, which is why the notice tells you `/privacy allow` exists.
|
|
312
|
+
|
|
299
313
|
## Privateer account (billed inference)
|
|
300
314
|
|
|
301
315
|
Instead of bringing your own key, run **`/signin`** to sign into a Privateer account. Your
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../src/remote/subagentRelay.ts";
|
|
22
22
|
import { RelayClient } from "../src/remote/relayClient.ts";
|
|
23
23
|
import { makeSendFileTool } from "../src/tools/sendFile.ts";
|
|
24
|
+
import { makeSaveCargoTool } from "../src/tools/cargo.ts";
|
|
24
25
|
import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
|
|
25
26
|
import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
|
|
26
27
|
import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
|
|
@@ -474,6 +475,11 @@ export default function privateerControl(pi: any): void {
|
|
|
474
475
|
// driving. The daemon no longer loads this file, so there is nothing to shadow.
|
|
475
476
|
pi.registerTool?.(makeSendFileTool(bridge));
|
|
476
477
|
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
478
|
+
// save_cargo belongs with them: it is the same bridge, the same "needs a connected
|
|
479
|
+
// app" precondition, and the same failure mode if it were registered anywhere the
|
|
480
|
+
// relay isn't this file's. Unlike the pair it hands the app PLAINTEXT to encrypt —
|
|
481
|
+
// the terminal has no master key, so the round trip is the feature (cargoSave.ts).
|
|
482
|
+
pi.registerTool?.(makeSaveCargoTool(bridge));
|
|
477
483
|
|
|
478
484
|
// Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
|
|
479
485
|
// no one can approve, so a "default" gate would fail-closed on every tool and the
|
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
// win regardless of the order pi discovers extensions in. This is purely a
|
|
24
24
|
// display/resolution + routing list — posture and attestation are dispatcher-bound and
|
|
25
25
|
// unaffected by the model set.
|
|
26
|
-
import { makePiPrivacyExtension } from "pi-privacy";
|
|
27
26
|
import { registerAccountModels } from "../src/providers/account.ts";
|
|
28
|
-
import {
|
|
27
|
+
import { privacyExtension } from "../src/config/privacyPolicy.ts";
|
|
29
28
|
|
|
30
29
|
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models), kimi-k2-6 first — the
|
|
31
30
|
// launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
|
|
@@ -55,9 +54,10 @@ function tinfoilModel(id: string) {
|
|
|
55
54
|
|
|
56
55
|
// One configuration, shared with the factory-built copy in src/config/moat.ts — the tier
|
|
57
56
|
// resolver for the private ACCOUNT channel, the unattended/no-quarter handling, the ingest
|
|
58
|
-
// policy
|
|
59
|
-
//
|
|
60
|
-
|
|
57
|
+
// policy, the operator's PII allowlist and the `/privacy` command that maintains it.
|
|
58
|
+
// Adding an option HERE rather than there is how this file and the moat drifted twice;
|
|
59
|
+
// src/config/privacyPolicy.ts records what that cost.
|
|
60
|
+
const privacy = privacyExtension();
|
|
61
61
|
|
|
62
62
|
export default function privateerPrivacy(pi: any): void {
|
|
63
63
|
privacy(pi);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.16",
|
|
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",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"@zed-industries/agent-client-protocol": "0.4.5",
|
|
82
82
|
"patch-package": "8.0.1",
|
|
83
83
|
"pi-mcp-adapter": "2.11.0",
|
|
84
|
-
"pi-privacy": "0.
|
|
84
|
+
"pi-privacy": "0.13.0",
|
|
85
85
|
"pi-subagents": "0.34.0",
|
|
86
86
|
"picomatch": "4.0.5",
|
|
87
87
|
"privateer-speak": "0.2.2",
|
package/src/cli/chat.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
|
|
|
14
14
|
import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
|
|
15
15
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
16
16
|
import { createUIContext } from "../ext/headlessUi.ts"; // no Pi deps → safe pre-boot
|
|
17
|
+
import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts"; // node:child_process only → safe pre-boot
|
|
17
18
|
|
|
18
19
|
// This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
|
|
19
20
|
// itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
|
|
@@ -47,6 +48,7 @@ async function main() {
|
|
|
47
48
|
persistAccountCredential,
|
|
48
49
|
dropPersistedAccountCredential,
|
|
49
50
|
ensureAccountArmed,
|
|
51
|
+
verificationLink,
|
|
50
52
|
} = await import("../providers/account.ts");
|
|
51
53
|
const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
|
|
52
54
|
const { agentVersion } = await import("../config/version.ts");
|
|
@@ -637,9 +639,24 @@ async function main() {
|
|
|
637
639
|
try {
|
|
638
640
|
const user = await priv.runDeviceLogin({
|
|
639
641
|
onCode: (code: any) => {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
642
|
+
// Browser-first, the same deal the TUI's /login widget makes: the URL carries
|
|
643
|
+
// the code, so the page lands straight on Authorize and the user clicks
|
|
644
|
+
// rather than types. verificationLink makes the server's scheme-less value
|
|
645
|
+
// absolute; canOpenBrowser decides the wording SYNCHRONOUSLY (SSH or a
|
|
646
|
+
// headless box keeps the old app-approve copy, because a launcher there
|
|
647
|
+
// would open on the wrong machine); and the link is printed either way, so
|
|
648
|
+
// an open that silently fails costs nothing.
|
|
649
|
+
const uri = verificationLink(code.verification_uri_complete ?? code.verification_uri);
|
|
650
|
+
const opening = Boolean(uri) && canOpenBrowser();
|
|
651
|
+
if (opening) void openInBrowser(uri);
|
|
652
|
+
console.log(
|
|
653
|
+
opening
|
|
654
|
+
? `\n${CYAN}Authorize this terminal in the browser window that just opened:${RESET}`
|
|
655
|
+
: `\n${CYAN}Approve this terminal in the Privateer app:${RESET}`,
|
|
656
|
+
);
|
|
657
|
+
const match = opening ? `${DIM} — check it matches the one in your browser${RESET}` : "";
|
|
658
|
+
console.log(` code: ${YELLOW}${code.user_code}${RESET}${match}`);
|
|
659
|
+
if (uri) console.log(` ${opening ? "no browser? open" : "or open"}: ${DIM}${uri}${RESET}`);
|
|
643
660
|
console.log(`${DIM} waiting for approval…${RESET}`);
|
|
644
661
|
},
|
|
645
662
|
});
|
package/src/config/moat.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { basename } from "node:path";
|
|
|
34
34
|
import { managedNames } from "./moatManifest.ts";
|
|
35
35
|
import type { GateController } from "../ext/permissionGate.ts";
|
|
36
36
|
import type { SendFileBridge } from "../tools/sendFile.ts";
|
|
37
|
+
import type { CargoSaveBridge } from "../tools/cargo.ts";
|
|
37
38
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
38
39
|
|
|
39
40
|
/** A Pi extension factory, as DefaultResourceLoader takes them. */
|
|
@@ -61,7 +62,7 @@ export interface MoatOptions {
|
|
|
61
62
|
* its module-level bridge and stands them down inside the daemon, so a live spawn's own
|
|
62
63
|
* pair is what the model gets (see tools/relayFileTools.ts).
|
|
63
64
|
*/
|
|
64
|
-
relayFiles?: { bridge: SendFileBridge; attachments: AttachmentStore };
|
|
65
|
+
relayFiles?: { bridge: SendFileBridge & CargoSaveBridge; attachments: AttachmentStore };
|
|
65
66
|
/**
|
|
66
67
|
* THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
|
|
67
68
|
* a path whose result reaches the app's Inbox — a scheduled routine, a submitted
|
|
@@ -176,17 +177,16 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
176
177
|
if (!caps) throw new Error(`buildMoat: unknown kind "${opts.kind}"`);
|
|
177
178
|
|
|
178
179
|
const { makePermissionGate } = await import("../ext/permissionGate.ts");
|
|
179
|
-
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
180
180
|
const { makeAccountProvider } = await import("../providers/account.ts");
|
|
181
181
|
const { webEnabled, mediaEnabled } = await import("./hosted.ts");
|
|
182
|
-
const {
|
|
182
|
+
const { privacyExtension } = await import("./privacyPolicy.ts");
|
|
183
183
|
|
|
184
184
|
const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
|
|
185
185
|
|
|
186
186
|
// pi-privacy is configured in exactly ONE place, shared with the DISCOVERED copy of this
|
|
187
187
|
// extension (the TUI's, and every subagent child's) — see ./privacyPolicy.ts for the two
|
|
188
188
|
// bugs that came of configuring it in two.
|
|
189
|
-
factories.push(
|
|
189
|
+
factories.push(privacyExtension());
|
|
190
190
|
factories.push(makeAccountProvider()); // must follow pi-privacy — see header
|
|
191
191
|
|
|
192
192
|
if (opts.relayFiles) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The PII allowlist the OPERATOR controls — "that string is not personal data, stop
|
|
2
|
+
// masking it."
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. pi-privacy's detection is pattern-based and honest about it: it fires
|
|
5
|
+
// on anything email-SHAPED or address-SHAPED. It refuses the shapes that are impossible
|
|
6
|
+
// for a type (see its precision guards), but plenty of real-world strings are genuinely
|
|
7
|
+
// ambiguous — a bare `8.0.0.0` is a version quad AND a valid address, an internal
|
|
8
|
+
// hostname list is a list of addresses, a fixture mailbox is an address nobody reads. For
|
|
9
|
+
// those the only correct answer comes from the person, and until now Privateer gave them
|
|
10
|
+
// nowhere to put it: sharedPrivacyOptions() hand-built pi-privacy's options and never
|
|
11
|
+
// called its config loader, so `PI_PRIVACY_PII_ALLOW` and pi-privacy.config.json were
|
|
12
|
+
// silently ignored in every Privateer session. The only lever was "Send + remember for
|
|
13
|
+
// session", which is all-or-nothing and forgotten at exit.
|
|
14
|
+
//
|
|
15
|
+
// This matters more since the gate learned to answer itself: under no quarter the
|
|
16
|
+
// question is swallowed and the payload is auto-redacted, so a false positive is no
|
|
17
|
+
// longer a prompt you dismiss — it is a silent rewrite the model then reads.
|
|
18
|
+
//
|
|
19
|
+
// WHERE IT LIVES. `privacy.piiAllow` in ~/.privateer/config.json (the same file the
|
|
20
|
+
// harbor, channels and webhooks read). Entry forms are pi-privacy's — `me@acme.com`
|
|
21
|
+
// (exact, `*` globs), `@acme.com` (that domain and its subdomains), `10.0.0.0/8` (an
|
|
22
|
+
// IPv4 block), or any exact/globbed value.
|
|
23
|
+
//
|
|
24
|
+
// LIVE, NOT LATCHED. entries() is handed to pi-privacy as a function, so `/privacy allow
|
|
25
|
+
// …` applies on the next turn instead of the next launch. It is called once per matched
|
|
26
|
+
// value during a scan, so it must stay cheap: the JSON is re-parsed only when the file
|
|
27
|
+
// changes (stamp = mtime + size), which leaves a stat per call and nothing else. An edit
|
|
28
|
+
// made in an editor is picked up on the next scan, exactly like one made through the
|
|
29
|
+
// command — there is no "restart to apply" step to explain.
|
|
30
|
+
|
|
31
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { configPath } from "./paths.ts";
|
|
33
|
+
|
|
34
|
+
let cached: string[] = [];
|
|
35
|
+
let cachedStamp = "";
|
|
36
|
+
|
|
37
|
+
function parse(raw: unknown): string[] {
|
|
38
|
+
const list = (raw as any)?.privacy?.piiAllow;
|
|
39
|
+
if (!Array.isArray(list)) return [];
|
|
40
|
+
return list.filter((e): e is string => typeof e === "string" && e.trim() !== "").map((e) => e.trim());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The operator's allowlist, live. Missing file, unreadable file and malformed JSON all
|
|
45
|
+
* mean "no entries" — a config typo must not take the PII gate down with it, and the
|
|
46
|
+
* gate erring toward MORE detection is the safe direction.
|
|
47
|
+
*/
|
|
48
|
+
export function piiAllowEntries(): readonly string[] {
|
|
49
|
+
try {
|
|
50
|
+
const st = statSync(configPath());
|
|
51
|
+
const stamp = `${st.mtimeMs}:${st.size}`;
|
|
52
|
+
if (stamp === cachedStamp) return cached;
|
|
53
|
+
cachedStamp = stamp;
|
|
54
|
+
cached = parse(JSON.parse(readFileSync(configPath(), "utf8")));
|
|
55
|
+
} catch {
|
|
56
|
+
cachedStamp = "";
|
|
57
|
+
cached = [];
|
|
58
|
+
}
|
|
59
|
+
return cached;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Drop the cache — after our own write, so the stamp is never mistaken for unchanged. */
|
|
63
|
+
function invalidate(): void {
|
|
64
|
+
cachedStamp = "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A bare `*` allows every value of every type: that is `piiPolicy: off` wearing a
|
|
68
|
+
// different hat, and pi-privacy refuses it at compile time anyway. Refusing it HERE means
|
|
69
|
+
// the person who typed it is told, rather than watching an entry land in their config and
|
|
70
|
+
// do nothing.
|
|
71
|
+
function invalidReason(entry: string): string | undefined {
|
|
72
|
+
if (!entry) return "an empty entry matches nothing";
|
|
73
|
+
if (/^\*+$/.test(entry)) return "a bare `*` would allowlist everything — turn the gate off explicitly if that's what you mean";
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AllowEdit {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
/** Why it was refused, or what already stood. Always safe to show the user. */
|
|
80
|
+
message: string;
|
|
81
|
+
entries: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function write(entries: string[]): void {
|
|
85
|
+
let cfg: Record<string, unknown> = {};
|
|
86
|
+
try {
|
|
87
|
+
cfg = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
88
|
+
} catch {
|
|
89
|
+
/* new or unreadable — a fresh object, so one bad file doesn't lose the edit */
|
|
90
|
+
}
|
|
91
|
+
const privacy = { ...((cfg.privacy as Record<string, unknown>) ?? {}), piiAllow: entries };
|
|
92
|
+
writeFileSync(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
|
|
93
|
+
invalidate();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Add one entry. Idempotent — adding what's already there is reported, not duplicated. */
|
|
97
|
+
export function addPiiAllow(raw: string): AllowEdit {
|
|
98
|
+
const entry = raw.trim();
|
|
99
|
+
const bad = invalidReason(entry);
|
|
100
|
+
if (bad) return { ok: false, message: bad, entries: piiAllowEntries() };
|
|
101
|
+
const entries = [...piiAllowEntries()];
|
|
102
|
+
if (entries.includes(entry)) return { ok: true, message: `${entry} is already allowlisted`, entries };
|
|
103
|
+
entries.push(entry);
|
|
104
|
+
write(entries);
|
|
105
|
+
return { ok: true, message: `${entry} is no longer treated as PII in this and future sessions`, entries };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove one entry, exactly as written. */
|
|
109
|
+
export function removePiiAllow(raw: string): AllowEdit {
|
|
110
|
+
const entry = raw.trim();
|
|
111
|
+
const entries = [...piiAllowEntries()];
|
|
112
|
+
const i = entries.indexOf(entry);
|
|
113
|
+
if (i < 0) return { ok: false, message: `${entry || "(empty)"} is not in the allowlist`, entries };
|
|
114
|
+
entries.splice(i, 1);
|
|
115
|
+
write(entries);
|
|
116
|
+
return { ok: true, message: `${entry} is gated again`, entries };
|
|
117
|
+
}
|
|
@@ -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
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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
|
|
37
|
+
return process.env[ENV] === "1";
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
/** Set the 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
|
|
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(!
|
|
49
|
+
return setNoQuarter(!noQuarterActive());
|
|
42
50
|
}
|
package/src/providers/account.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The `save_cargo` tool — save a file from disk into the user's Privateer app as
|
|
2
|
+
// Cargo: a titled, runnable artifact they can open, edit, download and share
|
|
3
|
+
// from any of their devices, not a path that only exists on this machine.
|
|
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:
|
|
9
|
+
//
|
|
10
|
+
// TAKES A PATH, NOT CONTENT. An artifact runs to half a megabyte. Inlining that
|
|
11
|
+
// into a tool call would spend the whole thing in tokens, twice — once when the
|
|
12
|
+
// model writes it, once when the call is echoed back into context — to move
|
|
13
|
+
// bytes that are already sitting on disk. So the model WRITES the file with its
|
|
14
|
+
// ordinary file tools, looks at it, and passes the path. The same reason the
|
|
15
|
+
// media tools all name an output path instead of returning bytes, and it makes
|
|
16
|
+
// the two compose: generate_model writes a .glb, the model writes a viewer .html
|
|
17
|
+
// that loads it, save_cargo puts the viewer in the user's pocket.
|
|
18
|
+
//
|
|
19
|
+
// REFUSES RATHER THAN GUESSES. A kind that doesn't match the content is an
|
|
20
|
+
// artifact that opens broken, and the user finds out later, on their phone,
|
|
21
|
+
// with no way to tell what went wrong. So an unmappable extension is an error
|
|
22
|
+
// with the mappable ones named, and a kind that contradicts the extension
|
|
23
|
+
// (`kind: 'sheet'` on a .html) is refused rather than quietly honoured.
|
|
24
|
+
//
|
|
25
|
+
// SAYS WHAT DID AND DIDN'T TRAVEL. Saving is the one media-adjacent thing here
|
|
26
|
+
// that is genuinely end-to-end encrypted — the app encrypts before the POST and
|
|
27
|
+
// the server stores ciphertext it cannot read — which is the exact opposite of
|
|
28
|
+
// generation's posture (media.ts's header is blunt about that). A model that
|
|
29
|
+
// can't tell the two apart will describe one with the other's guarantees, so the
|
|
30
|
+
// description states this one plainly and the success line repeats it.
|
|
31
|
+
|
|
32
|
+
import { Type } from "typebox";
|
|
33
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
34
|
+
import { basename, extname, isAbsolute, resolve } from "node:path";
|
|
35
|
+
import {
|
|
36
|
+
CARGO_KINDS,
|
|
37
|
+
MAX_CARGO_BYTES,
|
|
38
|
+
isCargoKind,
|
|
39
|
+
isHtmlKind,
|
|
40
|
+
kindForExtension,
|
|
41
|
+
type CargoKind,
|
|
42
|
+
type CargoSaveRequest,
|
|
43
|
+
type CargoSaveResult,
|
|
44
|
+
} from "../remote/cargoSave.ts";
|
|
45
|
+
|
|
46
|
+
function text(t: string) {
|
|
47
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The bridge surface this tool needs; RemoteBridge implements it. */
|
|
51
|
+
export interface CargoSaveBridge {
|
|
52
|
+
saveCargoRemote(req: CargoSaveRequest, signal?: AbortSignal): Promise<CargoSaveResult>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const CARGO_TOOL_NAMES = ["save_cargo"] as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Is `kind` a sane claim about a file called `ext`? An HTML kind needs an HTML
|
|
59
|
+
* file and vice versa; the source kinds have to match their own family. Checked
|
|
60
|
+
* because the failure is silent and late — the app stores whatever it is told
|
|
61
|
+
* and only shows the mismatch when the user opens the artifact.
|
|
62
|
+
*/
|
|
63
|
+
function kindMatchesExtension(kind: CargoKind, ext: string): boolean {
|
|
64
|
+
const implied = kindForExtension(ext);
|
|
65
|
+
if (!implied) return false;
|
|
66
|
+
// .html implies 'webpage', but slides and games are HTML documents too — the
|
|
67
|
+
// extension can't tell them apart and the model can, so any HTML kind is fine
|
|
68
|
+
// over an HTML file. The source kinds are exact.
|
|
69
|
+
if (isHtmlKind(kind)) return isHtmlKind(implied);
|
|
70
|
+
if (implied === "sheet") return kind === "sheet";
|
|
71
|
+
return implied === "md" && (kind === "md" || kind === "pdf" || kind === "docx");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function makeSaveCargoTool(bridge: CargoSaveBridge) {
|
|
75
|
+
return {
|
|
76
|
+
name: "save_cargo",
|
|
77
|
+
label: "Save to Cargo",
|
|
78
|
+
description:
|
|
79
|
+
"Save a file from disk into the user's Privateer app as Cargo — a titled artifact they can open, " +
|
|
80
|
+
"preview, edit, download and share from any of their signed-in devices. Use when the user asks for " +
|
|
81
|
+
"something they want to KEEP or open away from this machine (a page, a slide deck, a playable game, " +
|
|
82
|
+
"a report, a spreadsheet), rather than a file that only exists in this working directory. " +
|
|
83
|
+
"Write the file first with your normal file tools and pass its path.\n" +
|
|
84
|
+
"Accepts .html/.htm (kind webpage, slides or game — one self-contained document, all CSS and JS " +
|
|
85
|
+
"inlined, since it opens with no network and no sibling files), .md (kind md, pdf or docx — the " +
|
|
86
|
+
"kind picks what Download produces) and .csv (kind sheet). Max 512 KB; other file types cannot be " +
|
|
87
|
+
"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.",
|
|
91
|
+
parameters: Type.Object({
|
|
92
|
+
path: Type.String({
|
|
93
|
+
description: "Path of the file to save, relative to cwd or absolute (e.g. 'build/tower-defence.html').",
|
|
94
|
+
}),
|
|
95
|
+
kind: Type.Optional(
|
|
96
|
+
Type.String({
|
|
97
|
+
description:
|
|
98
|
+
"What the artifact IS, which decides its label and what Download produces. " +
|
|
99
|
+
"'webpage' | 'slides' | 'game' for an HTML document — pick the one that matches what you " +
|
|
100
|
+
"built, they are not interchangeable to the user. 'pdf' | 'docx' | 'md' for markdown source. " +
|
|
101
|
+
"'sheet' for CSV. Defaults from the file extension ('webpage' for .html), so set it whenever " +
|
|
102
|
+
"you built a deck or a game.",
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
title: Type.Optional(
|
|
106
|
+
Type.String({
|
|
107
|
+
description:
|
|
108
|
+
"Title shown in the app's Cargo list. Say what the thing is, the way the user would name it " +
|
|
109
|
+
"('Tower Defence', 'Q3 Expenses') — not the filename. Omitted → the app derives one from the content.",
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
}),
|
|
113
|
+
async execute(
|
|
114
|
+
_toolCallId: string,
|
|
115
|
+
params: { path: string; kind?: string; title?: string },
|
|
116
|
+
signal?: AbortSignal,
|
|
117
|
+
_onUpdate?: unknown,
|
|
118
|
+
ctx?: { cwd?: string },
|
|
119
|
+
) {
|
|
120
|
+
if (!params.path) return text("Error: path is required — say which file to save.");
|
|
121
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
122
|
+
const target = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
123
|
+
|
|
124
|
+
if (!existsSync(target)) return text(`File not found: ${params.path}`);
|
|
125
|
+
const stat = statSync(target);
|
|
126
|
+
if (stat.isDirectory()) return text(`${params.path} is a directory — save a single file.`);
|
|
127
|
+
if (stat.size === 0) return text(`${params.path} is empty — nothing to save.`);
|
|
128
|
+
if (stat.size > MAX_CARGO_BYTES) {
|
|
129
|
+
return text(
|
|
130
|
+
`${params.path} is ${(stat.size / 1024).toFixed(0)} KB; a Cargo artifact caps at ${MAX_CARGO_BYTES / 1024} KB. ` +
|
|
131
|
+
`Trim it, or send it as a file with send_file_to_client instead.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ext = extname(target);
|
|
136
|
+
const implied = kindForExtension(ext);
|
|
137
|
+
if (!implied) {
|
|
138
|
+
return text(
|
|
139
|
+
`${params.path} can't be Cargo: ${ext || "a file with no extension"} isn't an artifact format. ` +
|
|
140
|
+
`Cargo holds .html/.htm (a self-contained page, deck or game), .md, or .csv. ` +
|
|
141
|
+
`To put any other file on the user's device, use send_file_to_client.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const kind = params.kind ?? implied;
|
|
145
|
+
if (!isCargoKind(kind)) {
|
|
146
|
+
return text(`Unknown kind '${String(params.kind)}'. Valid kinds: ${CARGO_KINDS.join(", ")}.`);
|
|
147
|
+
}
|
|
148
|
+
if (!kindMatchesExtension(kind, ext)) {
|
|
149
|
+
return text(
|
|
150
|
+
`kind '${kind}' doesn't match ${ext} — the artifact would open broken in the app. ` +
|
|
151
|
+
`${ext} holds ${implied === "md" ? "markdown (kind md, pdf or docx)" : implied === "sheet" ? "CSV (kind sheet)" : "an HTML document (kind webpage, slides or game)"}. ` +
|
|
152
|
+
`Either drop the kind and let the extension decide, or write the content the kind actually needs.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Read as UTF-8: every Cargo kind is text. A binary file that happened to be
|
|
157
|
+
// named .html would arrive as replacement characters rather than as an error,
|
|
158
|
+
// but it would also not be an artifact anyone could open, and the size/extension
|
|
159
|
+
// checks above are what actually keep that case out.
|
|
160
|
+
let content: string;
|
|
161
|
+
try {
|
|
162
|
+
content = readFileSync(target, "utf8");
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return text(`Couldn't read ${params.path}: ${(e as Error).message}`);
|
|
165
|
+
}
|
|
166
|
+
if (!content.trim()) return text(`${params.path} has no content to save.`);
|
|
167
|
+
|
|
168
|
+
const res = await bridge.saveCargoRemote(
|
|
169
|
+
{ content, kind, title: params.title?.trim() || undefined },
|
|
170
|
+
signal,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (!res.ok) return text(`Couldn't save ${basename(target)} to Cargo: ${res.reason}`);
|
|
174
|
+
return text(
|
|
175
|
+
`Saved "${res.title}" to Cargo (${kind}, ${(stat.size / 1024).toFixed(0)} KB, ${res.storageType} storage, id ${res.cargoId}). ` +
|
|
176
|
+
`The user can open, edit, download and share it from Cargo in the Privateer app. ` +
|
|
177
|
+
`It was encrypted on their device before it was stored — the server holds only ciphertext.`,
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -13,11 +13,18 @@
|
|
|
13
13
|
// moat is no longer discoverable (src/config/moat.ts), so there is only ever one pair.
|
|
14
14
|
import { makeSendFileTool, type SendFileBridge } from "./sendFile.ts";
|
|
15
15
|
import { makeSaveAttachmentTool } from "./saveAttachment.ts";
|
|
16
|
+
import { makeSaveCargoTool, type CargoSaveBridge } from "./cargo.ts";
|
|
16
17
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// save_cargo rides with the file pair rather than with the media tools, because it
|
|
20
|
+
// shares their precondition and not media's: it needs a CONNECTED APP, not a signed-in
|
|
21
|
+
// account. Registering it in the moat's media block would put it in every harbor and
|
|
22
|
+
// channels session, where there is no controller and every call would fail — see
|
|
23
|
+
// remote/cargoSave.ts on why unattended runs deliver an artifact a different way.
|
|
24
|
+
export function makeRelayFileTools(bridge: SendFileBridge & CargoSaveBridge, attachments: AttachmentStore) {
|
|
19
25
|
return function relayFileTools(pi: any): void {
|
|
20
26
|
pi.registerTool?.(makeSendFileTool(bridge));
|
|
21
27
|
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
28
|
+
pi.registerTool?.(makeSaveCargoTool(bridge));
|
|
22
29
|
};
|
|
23
30
|
}
|