privateer-agent 0.12.14 → 0.12.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/extensions/privateer-desktop.ts +54 -0
- package/extensions/privateer-gate.ts +6 -0
- package/extensions/privateer-hints.ts +33 -2
- package/extensions/privateer-privacy.ts +5 -5
- package/package.json +2 -2
- package/src/cli/chat.ts +20 -3
- package/src/config/desktopApp.ts +138 -0
- package/src/config/moat.ts +4 -4
- package/src/config/moatManifest.json +1 -0
- 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
|
|
@@ -396,6 +410,12 @@ Download for [macOS](https://privateer.pro/download/mac) (Apple silicon),
|
|
|
396
410
|
[macOS Intel](https://privateer.pro/download/mac-intel), or
|
|
397
411
|
[Windows](https://privateer.pro/download/windows).
|
|
398
412
|
|
|
413
|
+
Once it's installed, **`/desktop`** in the terminal brings it up — no Spotlight detour. It
|
|
414
|
+
opens the app, not a copy of this conversation: the desktop hosts its own session, so pick
|
|
415
|
+
the folder you were working in from **File ▸ Spawn Privateer at…** and it starts on the same
|
|
416
|
+
model and connectors this terminal uses (the per-folder defaults live in `~/.privateer`,
|
|
417
|
+
which both read).
|
|
418
|
+
|
|
399
419
|
It's an early release and **not yet code-signed or notarized** — macOS will warn on first
|
|
400
420
|
open. Routines and channels deliberately aren't hosted here: those belong to the always-on
|
|
401
421
|
harbor, so background work still wants `privateer harbor install`.
|
|
@@ -640,6 +660,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
640
660
|
| `/extensions` | list loaded Pi extensions |
|
|
641
661
|
| `/web-tools` | point `web_search`/`web_fetch` at a search provider of your own (signed in, they already work on your account) |
|
|
642
662
|
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
663
|
+
| `/desktop` | open the [desktop app](#desktop-app) — same login, same per-folder defaults |
|
|
643
664
|
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
|
644
665
|
|
|
645
666
|
Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// /desktop — bring up the Privateer desktop app from the terminal.
|
|
2
|
+
//
|
|
3
|
+
// The two front ends already share everything that matters: one ~/.privateer home,
|
|
4
|
+
// so one login, one model config, one MCP catalog, one set of per-folder spawn
|
|
5
|
+
// defaults. What was missing was a way across. Getting from a terminal to the app
|
|
6
|
+
// meant leaving the terminal — Spotlight, the Dock, a Start-menu hunt — which is
|
|
7
|
+
// exactly the kind of small friction that leaves a shipped app unopened.
|
|
8
|
+
//
|
|
9
|
+
// What it does NOT do is carry the conversation over: the desktop hosts its own
|
|
10
|
+
// in-process session and takes no folder argument (see src/config/desktopApp.ts),
|
|
11
|
+
// so this opens the app and says how to point a window at the folder you were just
|
|
12
|
+
// working in. When the app isn't installed we say so once, with the download page
|
|
13
|
+
// for this platform, and never again unasked — the working-line tip in
|
|
14
|
+
// privateer-hints.ts only fires on a machine that HAS it.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
desktopAppPath,
|
|
18
|
+
desktopDownloadAltUrl,
|
|
19
|
+
desktopDownloadUrl,
|
|
20
|
+
openDesktopApp,
|
|
21
|
+
} from "../src/config/desktopApp.ts";
|
|
22
|
+
|
|
23
|
+
export default function privateerDesktop(pi: any): void {
|
|
24
|
+
pi.registerCommand?.("desktop", {
|
|
25
|
+
description: "Open the Privateer desktop app — same login, same per-folder defaults",
|
|
26
|
+
handler: (_args: string, ctx: any) => {
|
|
27
|
+
const app = desktopAppPath();
|
|
28
|
+
|
|
29
|
+
if (app) {
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
if (openDesktopApp(app)) {
|
|
32
|
+
ctx?.ui?.notify?.(
|
|
33
|
+
`Opening the Privateer desktop app — same login and per-folder defaults as this terminal. ` +
|
|
34
|
+
`File ▸ Spawn Privateer at… points a window at ${cwd}.`,
|
|
35
|
+
"info",
|
|
36
|
+
);
|
|
37
|
+
} else {
|
|
38
|
+
ctx?.ui?.notify?.(`Could not launch ${app} — open it yourself and this terminal keeps working.`, "error");
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const url = desktopDownloadUrl();
|
|
44
|
+
const alt = desktopDownloadAltUrl(); // the other Mac build — see desktopApp.ts
|
|
45
|
+
ctx?.ui?.notify?.(
|
|
46
|
+
url
|
|
47
|
+
? `The Privateer desktop app isn't installed here. Download: ${url}${alt ? ` (other Macs: ${alt})` : ""} — ` +
|
|
48
|
+
`it reads the same ~/.privateer, so it starts already signed in with your models and connectors.`
|
|
49
|
+
: `The desktop app ships for macOS and Windows only — on this platform the terminal agent is the app.`,
|
|
50
|
+
"info",
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -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
|
|
@@ -25,6 +25,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { configPath, globalDir } from "../src/config/paths.ts";
|
|
28
|
+
import { desktopAppPath } from "../src/config/desktopApp.ts";
|
|
28
29
|
|
|
29
30
|
const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
|
|
30
31
|
const EVERY_MS = 12_000;
|
|
@@ -76,9 +77,28 @@ const HINTS: Array<() => string> = [
|
|
|
76
77
|
? `${k} is push-to-talk — /speak on reads the answer back`
|
|
77
78
|
: `/talk types what you say — /speak on reads the answer back`;
|
|
78
79
|
},
|
|
80
|
+
() => (haveDesktopApp() ? `/desktop opens the Privateer desktop app — same login, same folder defaults` : ""),
|
|
79
81
|
() => `these tips are /hints — /hints off silences them`,
|
|
80
82
|
];
|
|
81
83
|
|
|
84
|
+
// A hint returns "" when it doesn't apply to THIS machine, and the rotation skips
|
|
85
|
+
// it. The desktop tip is the case that needs it: naming a command for an app the
|
|
86
|
+
// user has is discoverability, advertising one they haven't is an ad. Memoised
|
|
87
|
+
// because the rotation asks every 12 s and the answer is a stat() on a path that
|
|
88
|
+
// doesn't change under a running terminal (installing the app mid-session is worth
|
|
89
|
+
// a restart, not a filesystem poll).
|
|
90
|
+
let desktopSeen: boolean | undefined;
|
|
91
|
+
function haveDesktopApp(): boolean {
|
|
92
|
+
if (desktopSeen === undefined) {
|
|
93
|
+
try {
|
|
94
|
+
desktopSeen = desktopAppPath() !== null;
|
|
95
|
+
} catch {
|
|
96
|
+
desktopSeen = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return desktopSeen;
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
// Default ON: absent file, absent block, or unreadable JSON all mean enabled.
|
|
83
103
|
// Only an explicit { hints: { enabled: false } } turns the rotation off.
|
|
84
104
|
function hintsEnabled(): boolean {
|
|
@@ -119,9 +139,20 @@ export default function privateerHints(pi: any): void {
|
|
|
119
139
|
if (restoreDefault) uiRef?.setWorkingMessage?.();
|
|
120
140
|
};
|
|
121
141
|
|
|
142
|
+
// The next hint that applies here, advancing the cursor past any that opted out.
|
|
143
|
+
// Bounded by the list length, so an all-empty list ends the rotation instead of
|
|
144
|
+
// spinning through it forever.
|
|
145
|
+
const nextHint = (): string => {
|
|
146
|
+
for (let i = 0; i < HINTS.length; i++) {
|
|
147
|
+
const text = HINTS[cursor++ % HINTS.length]();
|
|
148
|
+
if (text) return text;
|
|
149
|
+
}
|
|
150
|
+
return "";
|
|
151
|
+
};
|
|
152
|
+
|
|
122
153
|
const showNext = (): void => {
|
|
123
|
-
const hint =
|
|
124
|
-
|
|
154
|
+
const hint = nextHint();
|
|
155
|
+
if (!hint) return; // nothing applies on this machine — leave "Working..." alone
|
|
125
156
|
uiRef?.setWorkingMessage?.(`Working... · tip: ${hint}`);
|
|
126
157
|
timer = setTimeout(showNext, EVERY_MS);
|
|
127
158
|
};
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Where the Privateer DESKTOP app lives on this machine, and how to bring it up.
|
|
2
|
+
//
|
|
3
|
+
// The desktop app and this terminal are two front ends over one home: both read
|
|
4
|
+
// ~/.privateer, so they share the account login, the model config, the MCP catalog
|
|
5
|
+
// and the per-folder spawn defaults (config/spawns.ts). That makes "open the app"
|
|
6
|
+
// a genuinely useful thing for a terminal to offer — nothing is handed over, the
|
|
7
|
+
// state is already common — which is what /desktop (extensions/privateer-desktop.ts)
|
|
8
|
+
// does, and what the working-line tip in privateer-hints.ts points at.
|
|
9
|
+
//
|
|
10
|
+
// WHY DETECTION AND NOT JUST A DOWNLOAD LINK. A tip that advertises software the
|
|
11
|
+
// user hasn't got is an ad; one that names a command for software they HAVE is
|
|
12
|
+
// discoverability. So both consumers ask this module first, and the hint stays
|
|
13
|
+
// silent on a machine with no app installed.
|
|
14
|
+
//
|
|
15
|
+
// NO FOLDER HANDOFF. The app takes no path argument today — its second-instance
|
|
16
|
+
// handler just focuses the running window (desktop/src/main/main.mjs) — so /desktop
|
|
17
|
+
// opens the app, and the user picks this folder from File ▸ Spawn Privateer at…,
|
|
18
|
+
// where the spawn record this terminal already shares makes it start on the same
|
|
19
|
+
// model and connectors. If the app ever learns a folder argv, this is the one place
|
|
20
|
+
// that has to change.
|
|
21
|
+
//
|
|
22
|
+
// IMPORT-SAFETY: node builtins only, no Pi imports, no side effects — safe from an
|
|
23
|
+
// extension under jiti and from a pre-boot entry alike.
|
|
24
|
+
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { basename, join } from "node:path";
|
|
29
|
+
|
|
30
|
+
/** Per-platform download pages (README ▸ Desktop app). No Linux build exists. */
|
|
31
|
+
const DOWNLOAD_MAC = "https://privateer.pro/download/mac";
|
|
32
|
+
const DOWNLOAD_MAC_INTEL = "https://privateer.pro/download/mac-intel";
|
|
33
|
+
const DOWNLOAD_WINDOWS = "https://privateer.pro/download/windows";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The download page to lead with on THIS machine, or null where we ship no desktop
|
|
37
|
+
* build. macOS is split by CPU family — the arm64 dmg won't run on an Intel Mac —
|
|
38
|
+
* and process.arch is the interpreter's answer, not the machine's: an Apple-silicon
|
|
39
|
+
* Mac running a Rosetta node reports x64 and would be led to the Intel build. That
|
|
40
|
+
* is survivable rather than solved (detecting the translation means shelling out to
|
|
41
|
+
* `sysctl sysctl.proc_translated` for one link) because the OTHER build is offered
|
|
42
|
+
* alongside it — see desktopDownloadAltUrl.
|
|
43
|
+
*/
|
|
44
|
+
export function desktopDownloadUrl(
|
|
45
|
+
platform: NodeJS.Platform = process.platform,
|
|
46
|
+
arch: string = process.arch,
|
|
47
|
+
): string | null {
|
|
48
|
+
if (platform === "darwin") return arch === "x64" ? DOWNLOAD_MAC_INTEL : DOWNLOAD_MAC;
|
|
49
|
+
if (platform === "win32") return DOWNLOAD_WINDOWS;
|
|
50
|
+
return null; // electron-builder.yml targets mac + win only
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The macOS build for the OTHER CPU family, so a Mac user is never one wrong arch
|
|
55
|
+
* away from a download that won't launch. Null everywhere else: Windows ships one
|
|
56
|
+
* x64 installer and there is no Linux build at all.
|
|
57
|
+
*/
|
|
58
|
+
export function desktopDownloadAltUrl(
|
|
59
|
+
platform: NodeJS.Platform = process.platform,
|
|
60
|
+
arch: string = process.arch,
|
|
61
|
+
): string | null {
|
|
62
|
+
if (platform !== "darwin") return null;
|
|
63
|
+
return arch === "x64" ? DOWNLOAD_MAC : DOWNLOAD_MAC_INTEL;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The installed app, or null. Ordered by confidence:
|
|
68
|
+
*
|
|
69
|
+
* 1. OUR OWN interpreter, when the terminal is running on the app's bundled Node.
|
|
70
|
+
* The desktop's CLI shim runs `privateer` through the app binary itself with
|
|
71
|
+
* ELECTRON_RUN_AS_NODE=1 (desktop/src/main/cliShim.mjs), so process.execPath IS
|
|
72
|
+
* the app — and it's the copy the user actually installed, wherever they put it.
|
|
73
|
+
* 2. The standard install locations. macOS drag-install goes to /Applications or
|
|
74
|
+
* ~/Applications; the Windows installer is per-user NSIS (perMachine: false) so
|
|
75
|
+
* %LOCALAPPDATA%\Programs\Privateer is the default, with Program Files covered
|
|
76
|
+
* for an install that chose it (allowToChangeInstallationDirectory: true).
|
|
77
|
+
*
|
|
78
|
+
* A user who installed somewhere else entirely reads as "not installed" — which
|
|
79
|
+
* costs them a download link they don't need, and never a wrong app launched.
|
|
80
|
+
*/
|
|
81
|
+
export function desktopAppPath(
|
|
82
|
+
platform: NodeJS.Platform = process.platform,
|
|
83
|
+
env: Record<string, string | undefined> = process.env,
|
|
84
|
+
execPath: string = process.execPath,
|
|
85
|
+
): string | null {
|
|
86
|
+
const hit = (p: string): string | null => (p && existsSync(p) ? p : null);
|
|
87
|
+
|
|
88
|
+
if (platform === "darwin") {
|
|
89
|
+
// …/Privateer.app/Contents/MacOS/Privateer → …/Privateer.app
|
|
90
|
+
const marker = "/Privateer.app/";
|
|
91
|
+
const at = execPath.indexOf(marker);
|
|
92
|
+
if (at >= 0) {
|
|
93
|
+
const bundle = hit(execPath.slice(0, at + marker.length - 1));
|
|
94
|
+
if (bundle) return bundle;
|
|
95
|
+
}
|
|
96
|
+
return hit("/Applications/Privateer.app") ?? hit(join(homedir(), "Applications", "Privateer.app"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (platform === "win32") {
|
|
100
|
+
if (/^privateer\.exe$/i.test(basename(execPath))) {
|
|
101
|
+
const self = hit(execPath);
|
|
102
|
+
if (self) return self;
|
|
103
|
+
}
|
|
104
|
+
const local = env.LOCALAPPDATA;
|
|
105
|
+
const files = env.ProgramFiles;
|
|
106
|
+
return (
|
|
107
|
+
(local ? hit(join(local, "Programs", "Privateer", "Privateer.exe")) : null) ??
|
|
108
|
+
(files ? hit(join(files, "Privateer", "Privateer.exe")) : null)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Launch (or focus) the desktop app. Best-effort and never throws — the caller
|
|
117
|
+
* reports the failure as text, the way openBrowser.ts does.
|
|
118
|
+
*
|
|
119
|
+
* ELECTRON_RUN_AS_NODE IS STRIPPED, and that is load-bearing rather than tidy: when
|
|
120
|
+
* this terminal was itself started by the desktop's CLI shim, that variable is set
|
|
121
|
+
* in our environment, and a child inheriting it starts the app binary as a bare Node
|
|
122
|
+
* that exits without ever drawing a window. `open` goes through LaunchServices and
|
|
123
|
+
* wouldn't pass it on anyway; the Windows path spawns the exe directly and would.
|
|
124
|
+
*/
|
|
125
|
+
export function openDesktopApp(appPath: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
126
|
+
const { ELECTRON_RUN_AS_NODE: _drop, ...env } = process.env;
|
|
127
|
+
try {
|
|
128
|
+
const child =
|
|
129
|
+
platform === "darwin"
|
|
130
|
+
? spawn("open", ["-a", appPath], { detached: true, stdio: "ignore", env })
|
|
131
|
+
: spawn(appPath, [], { detached: true, stdio: "ignore", env });
|
|
132
|
+
child.on("error", () => {}); // a spawn failure must not raise on the event loop
|
|
133
|
+
child.unref(); // never hold the CLI's exit open
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
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) {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
{ "name": "privateer-privacy", "entry": "extensions/privateer-privacy.ts", "note": "pi-privacy + account tier resolver" },
|
|
12
12
|
{ "name": "privateer-connect", "entry": "extensions/privateer-connect.ts", "note": "/connect — MCP connector manager" },
|
|
13
13
|
{ "name": "privateer-media", "entry": "extensions/privateer-media.ts", "note": "image/video/speech/music + ffmpeg compose" },
|
|
14
|
+
{ "name": "privateer-desktop", "entry": "extensions/privateer-desktop.ts", "note": "/desktop — open the Privateer desktop app" },
|
|
14
15
|
{ "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
|
|
15
16
|
{ "name": "privateer-update", "entry": "extensions/privateer-update.ts", "note": "tool pack updates in place — banner flag + /update" },
|
|
16
17
|
{ "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
@@ -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
|
+
}
|