privateer-agent 0.12.47 → 0.12.49
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 +28 -1
- package/bin/headless-flags.d.mts +25 -0
- package/bin/headless-flags.mjs +166 -0
- package/bin/privateer-launch.mjs +93 -3
- package/extensions/privateer-gate.ts +192 -5
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.86.0.patch +24 -11
- package/src/acp/run.ts +3 -1
- package/src/bridge/engineAdapter.ts +1 -1
- package/src/cli/chat.ts +6 -2
- package/src/config/privacyDisabled.ts +6 -1
- package/src/config/privacyPolicy.ts +3 -1
- package/src/engine/errors.ts +57 -3
- package/src/ext/permissionGate.ts +23 -4
- package/src/permissions/cliSpend.ts +163 -0
- package/src/permissions/modeGate.ts +9 -3
- package/src/permissions/noQuarter.ts +21 -2
- package/src/providers/account.ts +89 -1
- package/src/remote/headlessApproval.ts +146 -0
- package/src/tools/media.ts +151 -12
package/README.md
CHANGED
|
@@ -507,6 +507,30 @@ Honest caveat for Buzz: **Buzz currently auto-approves permission prompts**, so
|
|
|
507
507
|
the tool ceiling *is* the control — which is exactly why the default is read-only. Full
|
|
508
508
|
setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
|
|
509
509
|
|
|
510
|
+
Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
|
|
511
|
+
every approval arrives as a `session/request_permission` call it answers. The wire format,
|
|
512
|
+
option ids and a minimal client are in [`docs/acp.md`](docs/acp.md#driving-privateer-from-another-agent).
|
|
513
|
+
|
|
514
|
+
### One-shot runs (`-p`) and spending
|
|
515
|
+
|
|
516
|
+
A `privateer -p "…"` run has no screen, so anything that needs approval is **denied**. That
|
|
517
|
+
includes every billed media tool, and the run says so when it starts. Two flags change it,
|
|
518
|
+
and both apply to that one invocation only. Nothing is saved, and an exported environment
|
|
519
|
+
variable can't turn either on.
|
|
520
|
+
|
|
521
|
+
```bash
|
|
522
|
+
# pre-approve named billed tools, capped by calls and/or estimated dollars
|
|
523
|
+
privateer -p --allow-spend generate_video --max-calls 1 --max-spend 1.00 "make a 6s intro clip"
|
|
524
|
+
|
|
525
|
+
# or ask on your phone: approvals go to the Privateer app, denied if unanswered in time
|
|
526
|
+
privateer -p --approve-in-app --approval-timeout 300 "make a 6s intro clip"
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
`--max-spend` is checked before each call against the server's own estimate for that exact
|
|
530
|
+
call. `media_capabilities` shows the same prices. A call that can't be priced is refused
|
|
531
|
+
under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
|
|
532
|
+
open or running in the background.
|
|
533
|
+
|
|
510
534
|
## Connectors — MCP
|
|
511
535
|
|
|
512
536
|
Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
@@ -672,7 +696,10 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
672
696
|
|
|
673
697
|
Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
|
|
674
698
|
`privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
|
|
675
|
-
[`docs/acp.md`](docs/acp.md)), `privateer
|
|
699
|
+
[`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
|
|
700
|
+
`privateer -p … [--allow-spend …] [--approve-in-app]` (see
|
|
701
|
+
[one-shot runs](#one-shot-runs--p-and-spending)), `privateer --no-quarter`, `privateer --version`.
|
|
702
|
+
An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
|
|
676
703
|
|
|
677
704
|
## Develop
|
|
678
705
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Types for bin/headless-flags.mjs. Same reason as update-route.d.mts: the
|
|
2
|
+
// implementation is plain .mjs because bin/ runs under a bare `node`, but the tests
|
|
3
|
+
// that pin the grammar are TypeScript.
|
|
4
|
+
|
|
5
|
+
export const BILLED_TOOLS: string[];
|
|
6
|
+
export const CLI_SPEND_ENV: string;
|
|
7
|
+
export const APPROVE_IN_APP_ENV: string;
|
|
8
|
+
export const PI_SUBCOMMANDS: string[];
|
|
9
|
+
|
|
10
|
+
export interface CliSpendGrant {
|
|
11
|
+
tools: string[];
|
|
12
|
+
maxCalls?: number;
|
|
13
|
+
maxSpendUsd?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isHeadlessRun(args: string[]): boolean;
|
|
17
|
+
|
|
18
|
+
/** Strips the headless flags out of `args` in place. */
|
|
19
|
+
export function extractHeadlessFlags(args: string[]): {
|
|
20
|
+
spend?: CliSpendGrant;
|
|
21
|
+
approveInAppMs?: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function authProblem(args: string[], cmd?: string): string | null;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The launcher's argv rules for HEADLESS runs and for Pi's own subcommands.
|
|
2
|
+
//
|
|
3
|
+
// Plain .mjs, like update-route.mjs, because bin/ runs under a bare `node` before any
|
|
4
|
+
// transpiler exists — and separate from the launcher because the launcher runs on
|
|
5
|
+
// import, so nothing inside it can be tested. See tests/headlessFlags.test.ts.
|
|
6
|
+
|
|
7
|
+
/** Every tool that bills the account — mirrors BILLED_MEDIA_TOOLS in src/permissions/classify.ts. */
|
|
8
|
+
export const BILLED_TOOLS = [
|
|
9
|
+
"generate_image",
|
|
10
|
+
"generate_video",
|
|
11
|
+
"generate_model",
|
|
12
|
+
"generate_sprite",
|
|
13
|
+
"generate_speech",
|
|
14
|
+
"generate_music",
|
|
15
|
+
"generate_sfx",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** The env var carrying a `--allow-spend` grant from the launcher to the gate. */
|
|
19
|
+
export const CLI_SPEND_ENV = "PRIVATEER_CLI_SPEND";
|
|
20
|
+
/** The env var carrying `--approve-in-app` (its value is the timeout in ms). */
|
|
21
|
+
export const APPROVE_IN_APP_ENV = "PRIVATEER_APPROVE_IN_APP";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_APPROVAL_TIMEOUT_SEC = 300;
|
|
24
|
+
|
|
25
|
+
/** True when these args ask Pi for a one-shot, no-screen run. */
|
|
26
|
+
export function isHeadlessRun(args) {
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
const a = args[i];
|
|
29
|
+
if (a === "--") break;
|
|
30
|
+
if (a === "-p" || a === "--print") return true;
|
|
31
|
+
if (a === "--mode" && args[i + 1] === "json") return true;
|
|
32
|
+
if (a === "--mode=json") return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toolName(raw) {
|
|
38
|
+
const t = raw.trim();
|
|
39
|
+
if (!t) return null;
|
|
40
|
+
// `video` is accepted for `generate_video`: it is what a person types, and it is
|
|
41
|
+
// unambiguous. Anything else must be one of the billed tools, spelled out.
|
|
42
|
+
const full = t.startsWith("generate_") ? t : `generate_${t}`;
|
|
43
|
+
return BILLED_TOOLS.includes(full) ? full : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pull the headless-run flags out of `args` (in place) and validate them together.
|
|
48
|
+
*
|
|
49
|
+
* --allow-spend <tool[,tool…]> pre-approve these billed tools for THIS run
|
|
50
|
+
* --max-calls <n> …at most n billed calls in total
|
|
51
|
+
* --max-spend <usd> …at most this much, estimated before each call
|
|
52
|
+
* --approve-in-app send approvals this run can't answer to the app
|
|
53
|
+
* --approval-timeout <seconds> how long to wait for the app (default 300)
|
|
54
|
+
*
|
|
55
|
+
* Returns `{ spend, approveInAppMs, error }`. `error` is a message for the user and
|
|
56
|
+
* means nothing else should be trusted. A spend grant must be capped (calls, dollars
|
|
57
|
+
* or both) and only exists for `-p` / `--mode json` runs — in the TUI a person
|
|
58
|
+
* approves each call, so there is nothing to pre-approve.
|
|
59
|
+
*/
|
|
60
|
+
export function extractHeadlessFlags(args) {
|
|
61
|
+
const tools = [];
|
|
62
|
+
let maxCalls;
|
|
63
|
+
let maxSpendUsd;
|
|
64
|
+
let approveInApp = false;
|
|
65
|
+
let timeoutSec;
|
|
66
|
+
let spendFlagSeen = false;
|
|
67
|
+
|
|
68
|
+
const take = (i, name) => {
|
|
69
|
+
const v = args[i + 1];
|
|
70
|
+
if (v === undefined || v.startsWith("--")) return { error: `${name} needs a value` };
|
|
71
|
+
return { value: v };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
const a = args[i];
|
|
76
|
+
if (a === "--") break;
|
|
77
|
+
const [flag, inline] = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, undefined];
|
|
78
|
+
if (!["--allow-spend", "--max-calls", "--max-spend", "--approve-in-app", "--approval-timeout"].includes(flag)) continue;
|
|
79
|
+
|
|
80
|
+
let value = inline;
|
|
81
|
+
let consumed = 1;
|
|
82
|
+
if (flag !== "--approve-in-app" && value === undefined) {
|
|
83
|
+
const t = take(i, flag);
|
|
84
|
+
if (t.error) return { error: t.error };
|
|
85
|
+
value = t.value;
|
|
86
|
+
consumed = 2;
|
|
87
|
+
}
|
|
88
|
+
args.splice(i, consumed);
|
|
89
|
+
i--;
|
|
90
|
+
|
|
91
|
+
if (flag === "--allow-spend") {
|
|
92
|
+
spendFlagSeen = true;
|
|
93
|
+
for (const raw of value.split(",")) {
|
|
94
|
+
const name = toolName(raw);
|
|
95
|
+
if (!name) {
|
|
96
|
+
return { error: `--allow-spend: "${raw.trim()}" is not a billed tool. Use one of: ${BILLED_TOOLS.join(", ")}` };
|
|
97
|
+
}
|
|
98
|
+
if (!tools.includes(name)) tools.push(name);
|
|
99
|
+
}
|
|
100
|
+
} else if (flag === "--max-calls") {
|
|
101
|
+
spendFlagSeen = true;
|
|
102
|
+
const n = Number(value);
|
|
103
|
+
if (!Number.isInteger(n) || n < 1) return { error: `--max-calls must be a whole number of calls, 1 or more (got "${value}")` };
|
|
104
|
+
maxCalls = n;
|
|
105
|
+
} else if (flag === "--max-spend") {
|
|
106
|
+
spendFlagSeen = true;
|
|
107
|
+
const n = Number(String(value).replace(/^\$/, ""));
|
|
108
|
+
if (!Number.isFinite(n) || n <= 0) return { error: `--max-spend must be a dollar amount above 0, e.g. 1.00 (got "${value}")` };
|
|
109
|
+
maxSpendUsd = n;
|
|
110
|
+
} else if (flag === "--approve-in-app") {
|
|
111
|
+
approveInApp = true;
|
|
112
|
+
} else if (flag === "--approval-timeout") {
|
|
113
|
+
const n = Number(value);
|
|
114
|
+
if (!Number.isFinite(n) || n < 10 || n > 3600) return { error: `--approval-timeout must be 10-3600 seconds (got "${value}")` };
|
|
115
|
+
timeoutSec = n;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const headless = isHeadlessRun(args);
|
|
120
|
+
if (spendFlagSeen) {
|
|
121
|
+
if (tools.length === 0) return { error: "--max-calls / --max-spend cap a spend grant — name the tools with --allow-spend <tool>" };
|
|
122
|
+
if (maxCalls === undefined && maxSpendUsd === undefined) {
|
|
123
|
+
return { error: "--allow-spend needs a cap: add --max-calls <n>, --max-spend <usd>, or both" };
|
|
124
|
+
}
|
|
125
|
+
if (!headless) {
|
|
126
|
+
return { error: "--allow-spend only applies to a headless run (-p / --print, or --mode json). In the terminal UI you approve each call yourself." };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (timeoutSec !== undefined && !approveInApp) return { error: "--approval-timeout only applies with --approve-in-app" };
|
|
130
|
+
if (approveInApp && !headless) {
|
|
131
|
+
return { error: "--approve-in-app only applies to a headless run (-p / --print, or --mode json). An interactive terminal can use /remote-access." };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
spend: tools.length ? { tools, ...(maxCalls !== undefined ? { maxCalls } : {}), ...(maxSpendUsd !== undefined ? { maxSpendUsd } : {}) } : undefined,
|
|
136
|
+
approveInAppMs: approveInApp ? Math.round((timeoutSec ?? DEFAULT_APPROVAL_TIMEOUT_SEC) * 1000) : undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pi's own subcommands. Pi recognizes each only as args[0], so the launcher must hand
|
|
141
|
+
// them over with NOTHING in front — the normal launch prepends --model, -e and --skill,
|
|
142
|
+
// which is exactly how `privateer auth check` used to become a chat message.
|
|
143
|
+
export const PI_SUBCOMMANDS = ["auth", "install", "remove", "uninstall", "list", "config"];
|
|
144
|
+
|
|
145
|
+
const AUTH_SUBCOMMANDS = ["check", "print-api-key", "print-bearer-token"];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Is `args` an `auth` invocation Pi won't understand? Returns the message to print, or
|
|
149
|
+
* null. `status` is ours (answered by the launcher), `help`/--help are Pi's.
|
|
150
|
+
*/
|
|
151
|
+
export function authProblem(args, cmd = "privateer") {
|
|
152
|
+
if (args[0] !== "auth") return null;
|
|
153
|
+
const sub = args[1];
|
|
154
|
+
if (sub === undefined || sub === "help" || sub === "status" || args.includes("--help") || args.includes("-h")) return null;
|
|
155
|
+
if (AUTH_SUBCOMMANDS.includes(sub)) return null;
|
|
156
|
+
return [
|
|
157
|
+
`${cmd} auth: unknown command "${sub}".`,
|
|
158
|
+
"",
|
|
159
|
+
` ${cmd} auth status is this machine signed in to Privateer?`,
|
|
160
|
+
` ${cmd} auth check --provider <name> are a provider's credentials ready?`,
|
|
161
|
+
` ${cmd} auth print-api-key --provider <name> print a provider's API key`,
|
|
162
|
+
` ${cmd} auth print-bearer-token --provider <name> print a provider's bearer token`,
|
|
163
|
+
"",
|
|
164
|
+
`Nothing was sent to a model. To sign in, run \`${cmd}\` and type /login.`,
|
|
165
|
+
].join("\n");
|
|
166
|
+
}
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import path from "node:path";
|
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
31
31
|
import { routeUpdate } from "./update-route.mjs";
|
|
32
|
+
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, authProblem, extractHeadlessFlags } from "./headless-flags.mjs";
|
|
32
33
|
import { runToCompletion } from "./run-to-completion.mjs";
|
|
33
34
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
34
35
|
|
|
@@ -158,11 +159,19 @@ const NO_QUARTER = args.some((a) => a === "--no-quarter");
|
|
|
158
159
|
if (NO_QUARTER) {
|
|
159
160
|
for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-quarter") args.splice(i, 1);
|
|
160
161
|
process.env.PRIVATEER_NO_QUARTER = "1";
|
|
162
|
+
// No quarter is also `/privacy off` (src/permissions/noQuarter.ts). Marked, unless the
|
|
163
|
+
// filter was already off, so shift+tab back to the moat restores it.
|
|
164
|
+
if (process.env.PRIVATEER_PRIVACY_OFF !== "1" && process.env.PI_PRIVACY_OFF !== "1") {
|
|
165
|
+
process.env.PRIVATEER_PRIVACY_OFF = "1";
|
|
166
|
+
process.env.PI_PRIVACY_OFF = "1";
|
|
167
|
+
process.env.PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER = "1";
|
|
168
|
+
}
|
|
161
169
|
process.stderr.write(
|
|
162
170
|
[
|
|
163
171
|
"",
|
|
164
|
-
" ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate DISABLED for this session.",
|
|
165
|
-
" Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt
|
|
172
|
+
" ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate AND privacy filter DISABLED for this session.",
|
|
173
|
+
" Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt,",
|
|
174
|
+
" and outbound requests are not scanned for PII.",
|
|
166
175
|
" Only use this in a directory and with a task you fully trust.",
|
|
167
176
|
" shift+tab (or /no-quarter off) raises the moat again.",
|
|
168
177
|
"",
|
|
@@ -178,6 +187,7 @@ if (NO_PRIVACY) {
|
|
|
178
187
|
for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-privacy" || args[i] === "--privacy-off") args.splice(i, 1);
|
|
179
188
|
process.env.PRIVATEER_PRIVACY_OFF = "1";
|
|
180
189
|
process.env.PI_PRIVACY_OFF = "1";
|
|
190
|
+
delete process.env.PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER; // asked for outright — raising the moat keeps it off
|
|
181
191
|
process.stderr.write(
|
|
182
192
|
[
|
|
183
193
|
"",
|
|
@@ -219,6 +229,25 @@ if (ALLOW_COMPUTER) {
|
|
|
219
229
|
);
|
|
220
230
|
}
|
|
221
231
|
|
|
232
|
+
// Headless-run flags — spend pre-approval and app approvals for `-p` runs. Stripped
|
|
233
|
+
// before Pi sees them, exactly like the flags above; grammar in bin/headless-flags.mjs.
|
|
234
|
+
//
|
|
235
|
+
// THE ENV IS OURS, NEVER THE SHELL'S. The grant reaches the gate as an env var (the only
|
|
236
|
+
// channel into Pi's process), so an inherited value is deleted FIRST: a
|
|
237
|
+
// PRIVATEER_CLI_SPEND left exported in someone's shell must never pre-approve a run
|
|
238
|
+
// they didn't type the flag for. Only this invocation's argv can set it.
|
|
239
|
+
delete process.env[CLI_SPEND_ENV];
|
|
240
|
+
delete process.env[APPROVE_IN_APP_ENV];
|
|
241
|
+
{
|
|
242
|
+
const headless = extractHeadlessFlags(args);
|
|
243
|
+
if (headless.error) {
|
|
244
|
+
process.stderr.write(`privateer: ${headless.error}\n`);
|
|
245
|
+
process.exit(2);
|
|
246
|
+
}
|
|
247
|
+
if (headless.spend) process.env[CLI_SPEND_ENV] = JSON.stringify(headless.spend);
|
|
248
|
+
if (headless.approveInAppMs) process.env[APPROVE_IN_APP_ENV] = String(headless.approveInAppMs);
|
|
249
|
+
}
|
|
250
|
+
|
|
222
251
|
const sub = args[0];
|
|
223
252
|
|
|
224
253
|
// `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
|
|
@@ -541,6 +570,38 @@ else if (sub === "acp") {
|
|
|
541
570
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
542
571
|
}
|
|
543
572
|
|
|
573
|
+
// --- Pi's own subcommands: `auth`, and the package commands -------------------
|
|
574
|
+
// Pi matches these only as args[0]. The normal launch below prepends --model, -e and
|
|
575
|
+
// --skill, so every one of them used to fall through as a CHAT MESSAGE — `privateer
|
|
576
|
+
// auth list` was answered by the model, which listed the project folders in the
|
|
577
|
+
// user's home directory. Hand them over bare instead, and refuse an `auth` Pi doesn't
|
|
578
|
+
// know rather than let it become a prompt.
|
|
579
|
+
else if (PI_SUBCOMMANDS.includes(sub)) {
|
|
580
|
+
const cmd = process.env.PRIVATEER_CMD || "privateer";
|
|
581
|
+
const problem = authProblem(args, cmd);
|
|
582
|
+
if (problem) {
|
|
583
|
+
process.stderr.write(problem + "\n");
|
|
584
|
+
process.exit(2);
|
|
585
|
+
}
|
|
586
|
+
if (sub === "auth" && args[1] === "status") {
|
|
587
|
+
printAuthStatus(cmd);
|
|
588
|
+
process.exit(0);
|
|
589
|
+
}
|
|
590
|
+
const CLI = resolveDep(REPO, "@earendil-works/pi-coding-agent", "dist", "cli.js");
|
|
591
|
+
if (!CLI || !fs.existsSync(CLI)) {
|
|
592
|
+
console.error(
|
|
593
|
+
"privateer: couldn't find pi-coding-agent — the install looks incomplete.\n" +
|
|
594
|
+
" Try reinstalling: npm install -g privateer-agent@latest",
|
|
595
|
+
);
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
configureCompileCache(PRIVATEER_HOME); // same Pi module graph as a launch — reuse its compiled code
|
|
599
|
+
ensurePatches(); // project `.privateer/` config dirs are a patch; -l scope needs them
|
|
600
|
+
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
601
|
+
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
602
|
+
runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...args]);
|
|
603
|
+
}
|
|
604
|
+
|
|
544
605
|
// --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
|
|
545
606
|
else {
|
|
546
607
|
// Node 22 can reuse compiled code across launches instead of reparsing Pi and every
|
|
@@ -872,6 +933,28 @@ function warnKeylessLaunch() {
|
|
|
872
933
|
process.stderr.write(lines.join("\n") + "\n");
|
|
873
934
|
}
|
|
874
935
|
|
|
936
|
+
// `privateer auth status` — the question people (and agents) actually ask, answered
|
|
937
|
+
// from local state only: no network, nothing sent anywhere, and no secret printed.
|
|
938
|
+
function printAuthStatus(cmd) {
|
|
939
|
+
let creds = null;
|
|
940
|
+
try {
|
|
941
|
+
creds = JSON.parse(fs.readFileSync(path.join(PRIVATEER_HOME, "credentials.json"), "utf8"));
|
|
942
|
+
} catch { /* absent or unreadable → signed out */ }
|
|
943
|
+
const lines = [];
|
|
944
|
+
if (creds && typeof creds.accessToken === "string") {
|
|
945
|
+
const who = creds.user?.email || "your account";
|
|
946
|
+
lines.push(`Privateer account: signed in as ${who}${creds.serverBaseUrl ? ` (${creds.serverBaseUrl})` : ""}`);
|
|
947
|
+
lines.push(" This checks the local login only; a session revoked from the app shows up on the next request.");
|
|
948
|
+
} else {
|
|
949
|
+
lines.push("Privateer account: not signed in");
|
|
950
|
+
lines.push(` Run \`${cmd}\` and type /login, then approve the code in the Privateer app.`);
|
|
951
|
+
}
|
|
952
|
+
const keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "TINFOIL_API_KEY", "PRIVATEER_API_KEY"].filter(haveKey);
|
|
953
|
+
lines.push(`Provider keys in the environment: ${keys.length ? keys.join(", ") : "none"}`);
|
|
954
|
+
lines.push(`To check one provider end to end: ${cmd} auth check --provider <name>`);
|
|
955
|
+
console.log(lines.join("\n"));
|
|
956
|
+
}
|
|
957
|
+
|
|
875
958
|
function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
|
|
876
959
|
console.log(
|
|
877
960
|
[
|
|
@@ -891,7 +974,8 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
|
|
|
891
974
|
` ${cmd} uninstall <source> Alias for remove`,
|
|
892
975
|
` ${cmd} list List installed extensions from settings`,
|
|
893
976
|
` ${cmd} config Open TUI to enable/disable package resources`,
|
|
894
|
-
` ${cmd} auth
|
|
977
|
+
` ${cmd} auth status Show whether this machine is signed in`,
|
|
978
|
+
` ${cmd} auth <command> Check provider readiness or print credentials (check, print-api-key, print-bearer-token)`,
|
|
895
979
|
` ${cmd} <command> --help Show help for a specific command`,
|
|
896
980
|
"",
|
|
897
981
|
"Options:",
|
|
@@ -902,6 +986,12 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
|
|
|
902
986
|
" --append-system-prompt <text> Append text or file contents to the system prompt",
|
|
903
987
|
" --mode <mode> Output mode: text (default), json, or rpc",
|
|
904
988
|
" --print, -p Non-interactive mode: process prompt and exit",
|
|
989
|
+
" --allow-spend <tools> -p only: pre-approve billed tools for this run (e.g. generate_video)",
|
|
990
|
+
" --max-calls <n> …at most n billed calls (a cap is required)",
|
|
991
|
+
" --max-spend <usd> …at most this much, estimated before each call",
|
|
992
|
+
" --approve-in-app -p only: send approvals to the Privateer app instead of denying",
|
|
993
|
+
" --approval-timeout <seconds> How long --approve-in-app waits (default 300)",
|
|
994
|
+
" --no-quarter Run with NO approval prompts at all (trusted tasks only)",
|
|
905
995
|
" --continue, -c Continue previous session",
|
|
906
996
|
" --resume, -r Select a session to resume",
|
|
907
997
|
" --session <path|id> Use specific session file or partial UUID",
|
|
@@ -35,7 +35,14 @@ import { matchesKey } from "@earendil-works/pi-tui";
|
|
|
35
35
|
import * as priv from "../src/auth/privateer.ts";
|
|
36
36
|
import { paletteFor } from "../src/ui/palette.ts";
|
|
37
37
|
import { noQuarterActive, setNoQuarter } from "../src/permissions/noQuarter.ts";
|
|
38
|
+
import { privacyDisabled } from "../src/config/privacyDisabled.ts";
|
|
39
|
+
import { updatePostureBadge } from "./privateer-posture.ts";
|
|
38
40
|
import { childSpendAllows } from "../src/permissions/childSpend.ts";
|
|
41
|
+
import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
|
|
42
|
+
import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
|
|
43
|
+
import type { PermissionRequest } from "../src/permissions/gate.ts";
|
|
44
|
+
import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
|
|
45
|
+
import { describeErrorText } from "../src/engine/errors.ts";
|
|
39
46
|
import type { PermissionMode } from "../src/config/permissionMode.ts";
|
|
40
47
|
|
|
41
48
|
const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
|
|
@@ -245,6 +252,10 @@ function advertiseCommands(): { name: string; description?: string }[] {
|
|
|
245
252
|
// own connect/disconnect callbacks can refresh the indicator, not just the command.
|
|
246
253
|
const REMOTE_STATUS_KEY = "privateer:remote-access";
|
|
247
254
|
let uiRef: any = null;
|
|
255
|
+
// The live model, for repainting the privacy badge when no quarter moves the filter —
|
|
256
|
+
// privateer-posture's own watcher sits on ITS copy of privacyDisabled.ts (see
|
|
257
|
+
// noQuarter.ts on per-extension module copies), so a flip from here never reaches it.
|
|
258
|
+
let modelRef: { provider?: string; id?: string } | undefined;
|
|
248
259
|
// "off" → no indicator; "connecting" → relay starting or reconnecting (yellow);
|
|
249
260
|
// "connected" → socket open, controller reachable (green).
|
|
250
261
|
let remoteState: "off" | "connecting" | "connected" = "off";
|
|
@@ -299,12 +310,17 @@ function refreshNoQuarterStatus(): void {
|
|
|
299
310
|
// Flip the state and tell the user, loudly on the way down. Takes effect from the
|
|
300
311
|
// next gated action — an approval already on screen still needs an answer.
|
|
301
312
|
function applyNoQuarter(on: boolean, ui: any): void {
|
|
302
|
-
|
|
313
|
+
const privacyWasOff = privacyDisabled();
|
|
314
|
+
setNoQuarter(on); // also `/privacy off` on the way down, and back on the way up if it did that
|
|
303
315
|
refreshNoQuarterStatus();
|
|
316
|
+
const privacyMoved = privacyDisabled() !== privacyWasOff;
|
|
317
|
+
if (privacyMoved) void updatePostureBadge({ ui, model: modelRef });
|
|
304
318
|
ui?.notify?.(
|
|
305
319
|
on
|
|
306
|
-
? "⚑ No quarter — the permission gate is OFF for this session. Every action (shell, edits, destructive tools, out-of-cwd, protected files) now runs without asking
|
|
307
|
-
|
|
320
|
+
? "⚑ No quarter — the permission gate is OFF for this session. Every action (shell, edits, destructive tools, out-of-cwd, protected files) now runs without asking" +
|
|
321
|
+
(privacyMoved ? ", and the privacy filter is off: outbound requests are not scanned for PII" : "") +
|
|
322
|
+
". shift+tab to raise the moat again."
|
|
323
|
+
: "⚓ Moat raised — the permission gate is back on" + (privacyMoved ? ", and so is the privacy filter." : "."),
|
|
308
324
|
on ? "warning" : "info",
|
|
309
325
|
);
|
|
310
326
|
}
|
|
@@ -455,7 +471,78 @@ const bridge = new RemoteBridge({
|
|
|
455
471
|
// otherwise keep the fail-closed defaultLocalAsk (headless deny). A top-level TUI keeps
|
|
456
472
|
// its own interactive/remote gate.
|
|
457
473
|
const childChannel = isSubagentChild() ? inheritedChannelDir() : undefined;
|
|
458
|
-
|
|
474
|
+
|
|
475
|
+
// ── headless (`-p` / `--mode json`) runs ─────────────────────────────────────────
|
|
476
|
+
//
|
|
477
|
+
// A top-level headless run has no screen, so the default asker denies. Two launch flags
|
|
478
|
+
// change that, both typed per invocation and neither ever persisted (bin/headless-flags.mjs):
|
|
479
|
+
// --allow-spend … --max-calls/--max-spend pre-approves named billing tools, capped
|
|
480
|
+
// (src/permissions/cliSpend.ts)
|
|
481
|
+
// --approve-in-app puts what's left to the Privateer app
|
|
482
|
+
// (src/remote/headlessApproval.ts)
|
|
483
|
+
// Neither applies in a subagent child (its grant comes only from childSpend.ts) nor in
|
|
484
|
+
// the TUI; `headlessRun` is set from Pi's own mode at session_start.
|
|
485
|
+
let headlessRun = false;
|
|
486
|
+
const cliGrant = isSubagentChild() ? null : readCliSpendGrant();
|
|
487
|
+
const approveInAppMs = isSubagentChild() ? 0 : Number(process.env.PRIVATEER_APPROVE_IN_APP) || 0;
|
|
488
|
+
|
|
489
|
+
// Price a billed call from the server's capability report — the same reservation
|
|
490
|
+
// figure the server holds (tools/media.ts). Loaded on first use: most runs never price
|
|
491
|
+
// anything, and media.ts has no business in the TUI's startup graph.
|
|
492
|
+
async function quoteMediaCall(tool: string, input: unknown, signal?: AbortSignal): Promise<number | null> {
|
|
493
|
+
const { readMediaCapabilities, quoteMediaCallUsd } = await import("../src/tools/media.ts");
|
|
494
|
+
const a = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
|
|
495
|
+
const model = typeof a.model === "string" && a.model ? a.model : undefined;
|
|
496
|
+
const query = tool === "generate_video" || tool === "generate_sprite" ? { videoModel: model } : tool === "generate_model" ? { model } : {};
|
|
497
|
+
const caps = await readMediaCapabilities(query, signal);
|
|
498
|
+
return caps.ok ? quoteMediaCallUsd(tool, input, caps.data) : null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const cliLedger = cliGrant ? new CliSpendLedger(cliGrant, quoteMediaCall) : null;
|
|
502
|
+
|
|
503
|
+
// Why a particular request was denied, for the model. Keyed by the request object the
|
|
504
|
+
// gate builds per call, so parallel calls never read each other's reason.
|
|
505
|
+
const denialNotes = new WeakMap<PermissionRequest, string>();
|
|
506
|
+
// The call's own arguments, seen by the spend check, so an app prompt can be priced
|
|
507
|
+
// for THIS call rather than the dearest one the model could make.
|
|
508
|
+
const requestInputs = new WeakMap<PermissionRequest, unknown>();
|
|
509
|
+
|
|
510
|
+
const appApprover = approveInAppMs
|
|
511
|
+
? new HeadlessAppApprover({
|
|
512
|
+
timeoutMs: approveInAppMs,
|
|
513
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
514
|
+
signedIn: () => priv.hasCredentials(),
|
|
515
|
+
label: "privateer -p",
|
|
516
|
+
makeRelay: (callbacks) => new RelayClient(callbacks as any, { label: "privateer -p" }) as any,
|
|
517
|
+
})
|
|
518
|
+
: null;
|
|
519
|
+
|
|
520
|
+
async function headlessAsk(req: PermissionRequest, ctx: any) {
|
|
521
|
+
if (appApprover) {
|
|
522
|
+
// Put the price on the question — "about $0.50" is what makes a phone approval an
|
|
523
|
+
// informed one. Best-effort and bounded: a slow quote must not eat the timeout.
|
|
524
|
+
let note: string | undefined;
|
|
525
|
+
if (isSpendRequest(req)) {
|
|
526
|
+
const usd = await Promise.race([
|
|
527
|
+
quoteMediaCall(req.tool, requestInputs.get(req), ctx?.signal).catch(() => null),
|
|
528
|
+
new Promise<null>((r) => setTimeout(() => r(null), 5_000)),
|
|
529
|
+
]);
|
|
530
|
+
if (typeof usd === "number") note = `about $${usd.toFixed(2)}`;
|
|
531
|
+
}
|
|
532
|
+
const out = await appApprover.ask(req, ctx?.signal, note);
|
|
533
|
+
if (out === "deny" && !denialNotes.has(req)) {
|
|
534
|
+
denialNotes.set(req, "It was not approved in the Privateer app. Stop and report this rather than retrying.");
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
const out = await defaultLocalAsk(req, ctx);
|
|
539
|
+
if (out === "deny" && isSpendRequest(req) && !denialNotes.has(req)) denialNotes.set(req, headlessSpendGuidance(req.tool));
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const localAsk = childChannel
|
|
544
|
+
? makeChildGateAsk(childChannel)
|
|
545
|
+
: (req: PermissionRequest, ctx: any) => (headlessRun ? headlessAsk(req, ctx) : defaultLocalAsk(req, ctx));
|
|
459
546
|
|
|
460
547
|
const gate = makePermissionGate({
|
|
461
548
|
getMode: () => mode,
|
|
@@ -477,7 +564,28 @@ const gate = makePermissionGate({
|
|
|
477
564
|
// when pi-subagents has marked us a child), so a terminal keeps asking its human. This
|
|
478
565
|
// is what lets an unattended run delegate a shot to a subagent: without it the child's
|
|
479
566
|
// gate denies every generate_* call, having no one to ask.
|
|
480
|
-
|
|
567
|
+
//
|
|
568
|
+
// Plus, in a top-level headless run only, the `--allow-spend` ledger: capped, per
|
|
569
|
+
// invocation, and priced before each call when there's a --max-spend.
|
|
570
|
+
isSpendPreauthorized: async (req, input, signal) => {
|
|
571
|
+
requestInputs.set(req, input);
|
|
572
|
+
if (childSpendAllows(req.tool)) return true;
|
|
573
|
+
if (!cliLedger || !headlessRun) return false;
|
|
574
|
+
const d = await cliLedger.authorize(req.tool, input, signal);
|
|
575
|
+
if (d.ok) {
|
|
576
|
+
process.stderr.write(
|
|
577
|
+
`⚓ --allow-spend: running ${req.tool}${d.usd !== null ? ` (about $${d.usd.toFixed(2)})` : ""} — ${cliLedger.summary()}\n`,
|
|
578
|
+
);
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
denialNotes.set(
|
|
582
|
+
req,
|
|
583
|
+
`Not covered by this run's spend pre-approval: ${d.reason}. ` +
|
|
584
|
+
(appApprover ? "" : "Stop and report this to the user rather than retrying."),
|
|
585
|
+
);
|
|
586
|
+
return false;
|
|
587
|
+
},
|
|
588
|
+
explainDenial: (req) => denialNotes.get(req),
|
|
481
589
|
});
|
|
482
590
|
|
|
483
591
|
export default function privateerControl(pi: any): void {
|
|
@@ -550,6 +658,7 @@ export default function privateerControl(pi: any): void {
|
|
|
550
658
|
// machine's real catalog and the banner shows the current spec from the start.
|
|
551
659
|
if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
|
|
552
660
|
if (!currentSpec && ctx?.model) currentSpec = modelSpec(ctx.model);
|
|
661
|
+
if (ctx?.model) modelRef = ctx.model;
|
|
553
662
|
refreshRemoteStatus();
|
|
554
663
|
// shift+tab → no quarter, and the red footer indicator when it's already on (a
|
|
555
664
|
// `--no-quarter` launch, or a session replacement mid-run). Interactive TUI only:
|
|
@@ -561,12 +670,90 @@ export default function privateerControl(pi: any): void {
|
|
|
561
670
|
if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
|
|
562
671
|
mode = "bypass";
|
|
563
672
|
}
|
|
673
|
+
headlessRun = !isSubagentChild() && (ctx?.mode === "print" || ctx?.mode === "json");
|
|
674
|
+
if (headlessRun) announceHeadlessSpend();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// Say it UP FRONT. An agent driving `privateer -p` used to plan a whole job and only
|
|
678
|
+
// hit the billing wall at the last step, because nothing said a -p run can't approve a
|
|
679
|
+
// billed call. Tell the person (stderr, before any work) and the model (its system
|
|
680
|
+
// prompt, before it plans). Quiet when nothing billable is enabled, or when no quarter
|
|
681
|
+
// lifts the gate anyway.
|
|
682
|
+
function uncoveredBilledTools(): string[] {
|
|
683
|
+
if (noQuarterActive()) return [];
|
|
684
|
+
const active: string[] = (() => {
|
|
685
|
+
try {
|
|
686
|
+
return pi.getActiveTools?.() ?? [];
|
|
687
|
+
} catch {
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
691
|
+
return active.filter((t) => BILLED_MEDIA_TOOLS.has(t) && !cliGrant?.tools.includes(t));
|
|
692
|
+
}
|
|
693
|
+
function announceHeadlessSpend(): void {
|
|
694
|
+
const cmd = process.env.PRIVATEER_CMD || "privateer";
|
|
695
|
+
const out: string[] = [];
|
|
696
|
+
if (cliGrant) {
|
|
697
|
+
const caps = [
|
|
698
|
+
cliGrant.maxCalls !== undefined ? `at most ${cliGrant.maxCalls} call(s)` : "",
|
|
699
|
+
cliGrant.maxSpendUsd !== undefined ? `$${cliGrant.maxSpendUsd.toFixed(2)} estimated` : "",
|
|
700
|
+
].filter(Boolean);
|
|
701
|
+
out.push(`⚓ Spend pre-approved for this run: ${cliGrant.tools.join(", ")} — ${caps.join(", ")}.`);
|
|
702
|
+
}
|
|
703
|
+
const uncovered = uncoveredBilledTools();
|
|
704
|
+
if (uncovered.length) {
|
|
705
|
+
out.push(
|
|
706
|
+
appApprover
|
|
707
|
+
? `⚓ Billed tools (${uncovered.join(", ")}) will ask for approval in the Privateer app — up to ${Math.round(approveInAppMs / 1000)}s each.`
|
|
708
|
+
: `⚓ Heads-up: billed tools (${uncovered.join(", ")}) are enabled, but a -p run can't approve them — any call will be denied.\n` +
|
|
709
|
+
` To allow them: --allow-spend <tool> --max-calls <n> [--max-spend <usd>], or --approve-in-app, or drive Privateer over ACP (\`${cmd} acp\`).`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
if (out.length) process.stderr.write(out.join("\n") + "\n");
|
|
713
|
+
}
|
|
714
|
+
pi.on("before_agent_start", (event: any) => {
|
|
715
|
+
if (!headlessRun) return;
|
|
716
|
+
const uncovered = uncoveredBilledTools();
|
|
717
|
+
const lines: string[] = [];
|
|
718
|
+
if (cliGrant) {
|
|
719
|
+
lines.push(
|
|
720
|
+
`This run may call ${cliGrant.tools.join(", ")} without further approval, within its caps` +
|
|
721
|
+
`${cliGrant.maxCalls !== undefined ? ` (at most ${cliGrant.maxCalls} call(s) in total)` : ""}` +
|
|
722
|
+
`${cliGrant.maxSpendUsd !== undefined ? ` (at most $${cliGrant.maxSpendUsd.toFixed(2)}, estimated before each call — check prices with media_capabilities)` : ""}.`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
if (uncovered.length) {
|
|
726
|
+
lines.push(
|
|
727
|
+
appApprover
|
|
728
|
+
? `Calls to ${uncovered.join(", ")} bill the user's account and must be approved by them in the Privateer app; each waits for that approval.`
|
|
729
|
+
: `This is a non-interactive run with nobody to approve spending: ${uncovered.join(", ")} will be DENIED if called. ` +
|
|
730
|
+
"Do not plan work that depends on them. If the task needs one, say so up front and tell the user to re-run with " +
|
|
731
|
+
"--allow-spend <tool> --max-calls <n>, or --approve-in-app.",
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
if (!lines.length) return;
|
|
735
|
+
return { systemPrompt: `${event.systemPrompt}\n\n# Billing in this run\n${lines.join("\n")}` };
|
|
736
|
+
});
|
|
737
|
+
pi.on("session_shutdown", () => appApprover?.close());
|
|
738
|
+
|
|
739
|
+
// Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
|
|
740
|
+
// "Connection error." — and exits. Follow it with what that means and what to do,
|
|
741
|
+
// on stderr so stdout stays the answer (or the JSON stream).
|
|
742
|
+
pi.on("agent_end", (ev: any) => {
|
|
743
|
+
if (!headlessRun) return;
|
|
744
|
+
const msgs: any[] = Array.isArray(ev?.messages) ? ev.messages : [];
|
|
745
|
+
const last = [...msgs].reverse().find((m) => m?.role === "assistant");
|
|
746
|
+
if (last?.stopReason !== "error" || typeof last.errorMessage !== "string") return;
|
|
747
|
+
const described = describeErrorText(last.errorMessage, { provider: last.provider });
|
|
748
|
+
if (!described) return;
|
|
749
|
+
process.stderr.write(`privateer: ${described.message}${described.hint ? `\n ${described.hint}` : ""}\n`);
|
|
564
750
|
});
|
|
565
751
|
|
|
566
752
|
// Follow local model switches too (the user picking a model in the TUI): keep
|
|
567
753
|
// currentSpec current and push context so a driving app's banner stays in sync.
|
|
568
754
|
pi.on("model_select", (ev: any) => {
|
|
569
755
|
if (ev?.model) {
|
|
756
|
+
modelRef = ev.model;
|
|
570
757
|
currentSpec = modelSpec(ev.model);
|
|
571
758
|
relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() });
|
|
572
759
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.49",
|
|
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",
|