privateer-agent 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/bin/privateer-launch.mjs +178 -9
- package/extensions/privateer-gate.ts +4 -0
- package/package.json +3 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -0
- package/skills/resolve-dependencies/SKILL.md +103 -0
- package/src/cli/chat.ts +2 -0
- package/src/ext/permissionGate.ts +13 -3
- package/src/permissions/modeGate.ts +14 -0
package/README.md
CHANGED
|
@@ -248,6 +248,21 @@ redactor before it leaves.
|
|
|
248
248
|
Switch with **`/mode`**. Even in `bypass`, a danger filter blocks destructive shell commands,
|
|
249
249
|
and protected files (`.env`, shell rc files…) are guarded — the gate is never fully off.
|
|
250
250
|
|
|
251
|
+
### `--no-quarter` — lower the moat entirely
|
|
252
|
+
|
|
253
|
+
For an unattended run in a directory and on a task you fully trust, launch with:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
privateer --no-quarter
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
This is the one exception to "the gate is never fully off." It disables the permission
|
|
260
|
+
gate for the **whole session** — every action auto-approves with no prompt, including
|
|
261
|
+
destructive shell commands, out-of-cwd access, and protected files. Subagents spawned
|
|
262
|
+
by the session inherit it. There is no `/mode` equivalent; it's a deliberate launch-time
|
|
263
|
+
opt-out (env `PRIVATEER_NO_QUARTER=1`) and prints a red warning banner so it's never a
|
|
264
|
+
surprise. Use it sparingly.
|
|
265
|
+
|
|
251
266
|
## Extend it
|
|
252
267
|
|
|
253
268
|
Everything below is a **Pi extension** loaded by discovery (see [Built on Pi](#built-on-pi)) —
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
//
|
|
13
13
|
// Ported from the original bash launcher; behaviour is intended to match exactly.
|
|
14
14
|
|
|
15
|
-
import { spawn } from "node:child_process";
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import path from "node:path";
|
|
@@ -41,6 +41,29 @@ const NODE_BIN = BUNDLED ? bundledNode : process.execPath;
|
|
|
41
41
|
process.env.PATH = path.dirname(NODE_BIN) + path.delimiter + (process.env.PATH || "");
|
|
42
42
|
|
|
43
43
|
const args = process.argv.slice(2);
|
|
44
|
+
|
|
45
|
+
// `--no-quarter` — total permission bypass ("take no prisoners"). Strip it from the
|
|
46
|
+
// args BEFORE anything else so it never reaches Pi's cli.js (which doesn't know it)
|
|
47
|
+
// and so `sub`/`args.slice(1)` see only real subcommands. When present we export
|
|
48
|
+
// PRIVATEER_NO_QUARTER=1; the permission gate (extensions/privateer-gate.ts, and any
|
|
49
|
+
// subagent child that inherits this env) then auto-approves EVERY action with no
|
|
50
|
+
// prompt — dangerous shell, destructive tools, out-of-cwd, protected files, all of
|
|
51
|
+
// it. This is the moat fully lowered; only pass it when you trust the whole session.
|
|
52
|
+
const NO_QUARTER = args.some((a) => a === "--no-quarter");
|
|
53
|
+
if (NO_QUARTER) {
|
|
54
|
+
for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-quarter") args.splice(i, 1);
|
|
55
|
+
process.env.PRIVATEER_NO_QUARTER = "1";
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
[
|
|
58
|
+
"",
|
|
59
|
+
" ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate DISABLED for this session.",
|
|
60
|
+
" Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt.",
|
|
61
|
+
" Only use this in a directory and with a task you fully trust.",
|
|
62
|
+
"",
|
|
63
|
+
].join("\n") + "\n",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
const sub = args[0];
|
|
45
68
|
|
|
46
69
|
// `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
|
|
@@ -80,7 +103,8 @@ if (sub === "update") {
|
|
|
80
103
|
}
|
|
81
104
|
} else {
|
|
82
105
|
console.log("Updating privateer-agent to the latest release…");
|
|
83
|
-
|
|
106
|
+
// npm is npm.cmd on Windows; Node >=18.20 needs a shell to spawn a .cmd (EINVAL otherwise).
|
|
107
|
+
runToCompletion(isWin ? "npm.cmd" : "npm", ["install", "-g", "privateer-agent@latest"], { shell: isWin });
|
|
84
108
|
}
|
|
85
109
|
// runToCompletion exits via the child's exit handler.
|
|
86
110
|
}
|
|
@@ -96,6 +120,12 @@ else if (sub === "daemon") {
|
|
|
96
120
|
|
|
97
121
|
// --- normal launch: install the moat, then exec Pi's TUI -------------------
|
|
98
122
|
else {
|
|
123
|
+
// Windows has no bash out of the box, but Privateer's command tool needs one. If a
|
|
124
|
+
// real bash isn't reachable, stop here with a clear, actionable message — otherwise
|
|
125
|
+
// the user boots fine and only hits a cryptic `'bash' is not recognized` the first
|
|
126
|
+
// time the agent tries to run a command. Unix always has a shell, so this is a no-op.
|
|
127
|
+
ensureShellOrExit();
|
|
128
|
+
|
|
99
129
|
const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
|
|
100
130
|
const EXT_DIR = path.join(AGENT_DIR, "extensions");
|
|
101
131
|
fs.mkdirSync(EXT_DIR, { recursive: true });
|
|
@@ -162,26 +192,157 @@ else {
|
|
|
162
192
|
// TEE, strongest tier) when a Tinfoil key is present; else the signed-in account's
|
|
163
193
|
// NEAR channel; else a cheap OpenRouter fallback.
|
|
164
194
|
const CRED = path.join(PRIVATEER_HOME, "credentials.json");
|
|
195
|
+
const signedIn = fs.existsSync(CRED);
|
|
165
196
|
const MODEL = process.env.PRIVATEER_MODEL
|
|
166
197
|
? process.env.PRIVATEER_MODEL
|
|
167
198
|
: haveTinfoilKey()
|
|
168
199
|
? "tinfoil/glm-5-2"
|
|
169
|
-
:
|
|
200
|
+
: signedIn
|
|
170
201
|
? "privateer/near/zai-org/GLM-5.1-FP8"
|
|
171
202
|
: "openrouter/openai/gpt-4o-mini";
|
|
172
203
|
|
|
204
|
+
// Guard the keyless dead-end. We land on the OpenRouter fallback ONLY when the user
|
|
205
|
+
// named no model, has no Tinfoil key, AND isn't signed in (no credentials.json). If
|
|
206
|
+
// they also have no OpenRouter/other BYO key, the very first prompt errors with a bare
|
|
207
|
+
// "No API key found for openrouter" and nothing explains why. Worse, if this machine
|
|
208
|
+
// was signed in before (other ~/.privateer state exists but the login file is gone),
|
|
209
|
+
// that bare error hides a vanished session. Surface a clear, branded notice BEFORE the
|
|
210
|
+
// TUI loads — but still boot it, so `/login` inside works (and activateSignedInModel
|
|
211
|
+
// switches the live session onto the account channel the moment they sign back in).
|
|
212
|
+
if (MODEL === "openrouter/openai/gpt-4o-mini" && !haveByoKey()) {
|
|
213
|
+
warnKeylessLaunch();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Privateer's own bundled skills. Loaded by explicit path (Pi's `--skill`, which
|
|
217
|
+
// takes a file or directory) rather than seeded into the agent dir, so they load
|
|
218
|
+
// read-only from the shipped release — always matching this version, never
|
|
219
|
+
// clobbering or resurrecting anything in the user's own editable skills dir. Each
|
|
220
|
+
// is a directory holding a SKILL.md. Skip any that aren't present (e.g. a partial
|
|
221
|
+
// dev checkout) so a missing dir can't wedge launch.
|
|
222
|
+
const SKILL_DIRS = ["resolve-dependencies"]
|
|
223
|
+
.map((name) => path.join(REPO, "skills", name))
|
|
224
|
+
.filter((dir) => fs.existsSync(dir));
|
|
225
|
+
const skillArgs = SKILL_DIRS.flatMap((dir) => ["--skill", dir]);
|
|
226
|
+
|
|
173
227
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
174
228
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
175
|
-
runToCompletion(NODE_BIN, [...nodeArgs, CLI, "--model", MODEL, ...args]);
|
|
229
|
+
runToCompletion(NODE_BIN, [...nodeArgs, CLI, "--model", MODEL, ...skillArgs, ...args]);
|
|
176
230
|
}
|
|
177
231
|
|
|
178
232
|
// --- helpers ---------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
// Preflight: on Windows, make sure a bash the command tool can use actually exists.
|
|
235
|
+
// Mirrors pi-coding-agent's resolver (utils/shell.js getShellConfig): an explicit
|
|
236
|
+
// shellPath override wins, then Git Bash in Program Files, then any bash.exe on PATH.
|
|
237
|
+
// If none resolve, print branded install guidance and exit before the TUI loads.
|
|
238
|
+
function ensureShellOrExit() {
|
|
239
|
+
if (!isWin) return; // macOS/Linux always ship /bin/sh (bash); nothing to check.
|
|
240
|
+
|
|
241
|
+
// Respect an explicit shellPath in the agent settings — if the user set one, defer
|
|
242
|
+
// to Pi's own resolver (it validates the path and reports its own error).
|
|
243
|
+
try {
|
|
244
|
+
const s = JSON.parse(fs.readFileSync(path.join(PRIVATEER_HOME, "agent", "settings.json"), "utf8"));
|
|
245
|
+
if (s && typeof s.shellPath === "string" && s.shellPath.trim()) return;
|
|
246
|
+
} catch { /* no settings file / no override — fall through to detection */ }
|
|
247
|
+
|
|
248
|
+
if (findWindowsBash()) return; // a usable bash is reachable — carry on.
|
|
249
|
+
|
|
250
|
+
const msg = [
|
|
251
|
+
"",
|
|
252
|
+
" ⚓ Privateer needs a bash shell to run commands, and Windows doesn't ship one.",
|
|
253
|
+
"",
|
|
254
|
+
" Fix it with any ONE of these, then run `privateer` again:",
|
|
255
|
+
"",
|
|
256
|
+
" 1. Install Git for Windows (recommended) — bundles Git Bash where Privateer",
|
|
257
|
+
" looks first, no config needed: https://git-scm.com/download/win",
|
|
258
|
+
"",
|
|
259
|
+
" 2. Use WSL — run Privateer inside a WSL shell, or install it with",
|
|
260
|
+
" `wsl --install` from an admin PowerShell.",
|
|
261
|
+
"",
|
|
262
|
+
" 3. Already have Cygwin/MSYS2? Add its bash.exe to PATH, or set \"shellPath\"",
|
|
263
|
+
" to your bash.exe in your Privateer settings.json.",
|
|
264
|
+
"",
|
|
265
|
+
" After installing, open a NEW terminal (PATH changes don't reach open windows).",
|
|
266
|
+
"",
|
|
267
|
+
].join("\n");
|
|
268
|
+
process.stderr.write(msg + "\n");
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Return the path to a usable bash.exe on Windows, or null. Same search order as
|
|
273
|
+
// pi-coding-agent: Git Bash under %ProgramFiles%[(x86)], then `where bash.exe`.
|
|
274
|
+
function findWindowsBash() {
|
|
275
|
+
const candidates = [];
|
|
276
|
+
if (process.env.ProgramFiles) candidates.push(path.join(process.env.ProgramFiles, "Git", "bin", "bash.exe"));
|
|
277
|
+
if (process.env["ProgramFiles(x86)"]) candidates.push(path.join(process.env["ProgramFiles(x86)"], "Git", "bin", "bash.exe"));
|
|
278
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
279
|
+
try {
|
|
280
|
+
const r = spawnSync("where", ["bash.exe"], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
281
|
+
if (r.status === 0 && r.stdout) {
|
|
282
|
+
const first = r.stdout.trim().split(/\r?\n/)[0];
|
|
283
|
+
if (first && fs.existsSync(first)) return first;
|
|
284
|
+
}
|
|
285
|
+
} catch { /* `where` unavailable — treat as not found */ }
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
179
289
|
function haveTinfoilKey() {
|
|
180
|
-
|
|
181
|
-
|
|
290
|
+
return haveKey("TINFOIL_API_KEY");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// True if `name` is set in the environment or (dev convenience) present and non-empty in
|
|
294
|
+
// the repo .env — the same two sources the child inherits, so this matches what Pi will
|
|
295
|
+
// actually see for the provider key at request time.
|
|
296
|
+
function haveKey(name) {
|
|
297
|
+
if (process.env[name]) return true;
|
|
298
|
+
try { return new RegExp(`^${name}=.+`, "m").test(fs.readFileSync(ENV_FILE, "utf8")); }
|
|
182
299
|
catch { return false; }
|
|
183
300
|
}
|
|
184
301
|
|
|
302
|
+
// Any BYO provider key that would make the keyless OpenRouter launch model usable (or at
|
|
303
|
+
// least give the runtime SOME working provider). Mirrors defaultModel.ts's BYO_BY_KEY.
|
|
304
|
+
function haveByoKey() {
|
|
305
|
+
return ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "TINFOIL_API_KEY"]
|
|
306
|
+
.some(haveKey);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Explain the keyless launch instead of letting the first prompt dead-end on a bare
|
|
310
|
+
// "No API key found for openrouter". Distinguishes a returning user whose login file is
|
|
311
|
+
// missing (other ~/.privateer state exists → likely signed out unexpectedly) from a
|
|
312
|
+
// genuine first run. Non-fatal: we print and carry on so `/login` inside still works.
|
|
313
|
+
function warnKeylessLaunch() {
|
|
314
|
+
// Heuristic "was signed in before": the agent dir or our own config.json exists even
|
|
315
|
+
// though credentials.json doesn't. A true first run has neither yet.
|
|
316
|
+
let returning = false;
|
|
317
|
+
try {
|
|
318
|
+
returning =
|
|
319
|
+
fs.existsSync(path.join(PRIVATEER_HOME, "agent")) ||
|
|
320
|
+
fs.existsSync(path.join(PRIVATEER_HOME, "config.json"));
|
|
321
|
+
} catch { /* best-effort — default to the first-run wording */ }
|
|
322
|
+
|
|
323
|
+
const lines = returning
|
|
324
|
+
? [
|
|
325
|
+
"",
|
|
326
|
+
" ⚓ Your Privateer login is missing — this terminal isn't signed in.",
|
|
327
|
+
` (no ${path.join(PRIVATEER_HOME, "credentials.json")})`,
|
|
328
|
+
"",
|
|
329
|
+
" If you were signed in before, your session was cleared. Run /login to sign",
|
|
330
|
+
" back in — you'll return to your subscription models right away. Until then,",
|
|
331
|
+
" prompting fails with \"No API key found\" because no model key is set.",
|
|
332
|
+
"",
|
|
333
|
+
]
|
|
334
|
+
: [
|
|
335
|
+
"",
|
|
336
|
+
" ⚓ You're not signed in to Privateer and no provider API key is set.",
|
|
337
|
+
"",
|
|
338
|
+
" Run /login to use your subscription, or set a provider key (e.g.",
|
|
339
|
+
" ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY). Until then,",
|
|
340
|
+
" prompting fails with \"No API key found\".",
|
|
341
|
+
"",
|
|
342
|
+
];
|
|
343
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
344
|
+
}
|
|
345
|
+
|
|
185
346
|
function refreshUpdateCache() {
|
|
186
347
|
const cache = path.join(PRIVATEER_HOME, "update-check.json");
|
|
187
348
|
try {
|
|
@@ -206,9 +367,17 @@ function refreshUpdateCache() {
|
|
|
206
367
|
.then((j) => write(String(j?.tag_name || "").replace(/^v/, "")))
|
|
207
368
|
.catch(() => { /* offline — keep stale cache */ });
|
|
208
369
|
} else {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
370
|
+
// On Windows `npm` is npm.cmd; Node >=18.20 throws EINVAL synchronously when
|
|
371
|
+
// spawning a .cmd without a shell, so run through a shell there and guard the
|
|
372
|
+
// call — this is a fire-and-forget cache refresh and must never break launch.
|
|
373
|
+
let p;
|
|
374
|
+
try {
|
|
375
|
+
p = spawn(isWin ? "npm.cmd" : "npm", ["view", "privateer-agent", "version"], {
|
|
376
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
377
|
+
shell: isWin,
|
|
378
|
+
windowsHide: true,
|
|
379
|
+
});
|
|
380
|
+
} catch { return; /* no npm / spawn refused — keep stale cache */ }
|
|
212
381
|
let out = "";
|
|
213
382
|
p.stdout.on("data", (d) => { out += d; });
|
|
214
383
|
p.on("close", () => write(out.trim()));
|
|
@@ -353,6 +353,10 @@ const gate = makePermissionGate({
|
|
|
353
353
|
localAsk,
|
|
354
354
|
getRemote: bridge.getRemote,
|
|
355
355
|
getNoQuarter: bridge.getNoQuarter,
|
|
356
|
+
// `--no-quarter` at launch (see bin/privateer-launch.mjs) sets PRIVATEER_NO_QUARTER
|
|
357
|
+
// and opts this whole session — TUI and any subagent children that inherit the env —
|
|
358
|
+
// out of the gate entirely: every action auto-approves, no prompt.
|
|
359
|
+
getSkipAllPermissions: () => process.env.PRIVATEER_NO_QUARTER === "1",
|
|
356
360
|
remoteAsk: bridge.remoteAsk,
|
|
357
361
|
});
|
|
358
362
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"venice"
|
|
29
29
|
],
|
|
30
30
|
"bin": {
|
|
31
|
-
"privateer": "./bin/privateer-
|
|
31
|
+
"privateer": "./bin/privateer-launch.mjs"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"bin",
|
|
38
38
|
"src",
|
|
39
39
|
"extensions",
|
|
40
|
+
"skills",
|
|
40
41
|
"patches",
|
|
41
42
|
"README.md",
|
|
42
43
|
"LICENSE"
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
2
|
+
index e223ce1..2bdab10 100644
|
|
3
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
4
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
5
|
+
@@ -711,6 +711,15 @@ export class AgentSession {
|
|
6
|
+
finalError: msg.errorMessage,
|
|
7
|
+
});
|
|
8
|
+
this._retryAttempt = 0;
|
|
9
|
+
+ // Privateer patch: a transient error that survived the full retry budget
|
|
10
|
+
+ // is terminal. Ending the turn here (instead of falling through to
|
|
11
|
+
+ // compaction / queued-message continuation) prevents the agent loop from
|
|
12
|
+
+ // re-entering agent.continue(), hitting the identical error, and — because
|
|
13
|
+
+ // the retry counter was just reset to 0 — starting a fresh burst of 3.
|
|
14
|
+
+ // That re-entry is what produced the endless "retrying 1/3…2/3…3/3" loop
|
|
15
|
+
+ // on a persistently-failing provider/tool. Context-overflow errors never
|
|
16
|
+
+ // reach this branch (_isRetryableError → false), so compaction is untouched.
|
|
17
|
+
+ return false;
|
|
18
|
+
}
|
|
19
|
+
if (await this._checkCompaction(msg)) {
|
|
20
|
+
return true;
|
|
1
21
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
2
22
|
index 5d65200..a997ad7 100644
|
|
3
23
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "resolve-dependencies"
|
|
3
|
+
description: "Resolve a missing system binary/CLI tool or language package (npm/pip/cargo/go/gem) in the current environment. Use when a command fails with 'command not found', a build/test fails on a missing tool, or code fails on an unresolved import/module. Detects the right package manager, prefers a user-writable install, and runs the concrete install command through the permission gate."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resolving missing dependencies
|
|
7
|
+
|
|
8
|
+
When work is blocked because a **system tool** (e.g. `ripgrep`, `jq`, `cmake`) or a
|
|
9
|
+
**language package** (e.g. `zod`, `requests`, `serde`) is not installed, resolve it
|
|
10
|
+
deliberately. You are running on the user's own machine — installs are real and
|
|
11
|
+
persist — so pick the smallest, least-privileged command that unblocks the task and
|
|
12
|
+
let the user approve it.
|
|
13
|
+
|
|
14
|
+
Never install silently to "just make it work." Confirm what's missing, choose the
|
|
15
|
+
narrowest install, and run it through the normal `bash` tool so the permission gate
|
|
16
|
+
shows the user the exact command. Do not pipe remote scripts into a shell
|
|
17
|
+
(`curl … | sh`) — the gate flags that as dangerous and it is rarely the right way to
|
|
18
|
+
get a dependency.
|
|
19
|
+
|
|
20
|
+
## 1. Confirm it's actually missing
|
|
21
|
+
|
|
22
|
+
Before installing anything, verify the gap:
|
|
23
|
+
|
|
24
|
+
- System tool: `command -v <tool>` (or `which <tool>`). Empty output → missing.
|
|
25
|
+
- Language package: check the project manifest first (`package.json`,
|
|
26
|
+
`requirements.txt` / `pyproject.toml`, `Cargo.toml`, `go.mod`, `Gemfile`). If the
|
|
27
|
+
package is already declared, the fix is usually **install project deps**
|
|
28
|
+
(`npm install`, `pip install -r requirements.txt`), not adding a new one.
|
|
29
|
+
|
|
30
|
+
If a tool is missing only for this one command, consider whether an already-present
|
|
31
|
+
alternative works (e.g. `rg` → `grep -r`, `jq` → a small `node`/`python` one-liner)
|
|
32
|
+
before installing.
|
|
33
|
+
|
|
34
|
+
## 2. Detect the right package manager
|
|
35
|
+
|
|
36
|
+
**Language packages** — pick from the manifest that exists in the project (do not
|
|
37
|
+
guess the ecosystem):
|
|
38
|
+
|
|
39
|
+
| Manifest present | Add a package | Install declared deps |
|
|
40
|
+
| --------------------------- | ------------------------------------------- | -------------------------------- |
|
|
41
|
+
| `package.json` | `npm install <pkg>` (or `pnpm add`/`yarn add` if that lockfile is present) | `npm install` |
|
|
42
|
+
| `requirements.txt` | `pip install <pkg>` | `pip install -r requirements.txt`|
|
|
43
|
+
| `pyproject.toml` (Poetry) | `poetry add <pkg>` | `poetry install` |
|
|
44
|
+
| `pyproject.toml` (uv) | `uv add <pkg>` | `uv sync` |
|
|
45
|
+
| `Cargo.toml` | `cargo add <pkg>` | `cargo build` |
|
|
46
|
+
| `go.mod` | `go get <pkg>` | `go mod download` |
|
|
47
|
+
| `Gemfile` | `bundle add <pkg>` | `bundle install` |
|
|
48
|
+
|
|
49
|
+
Match the lockfile, not just the manifest: `pnpm-lock.yaml` → use `pnpm`,
|
|
50
|
+
`yarn.lock` → `yarn`, `bun.lockb` → `bun`. Adding a package edits the manifest —
|
|
51
|
+
that's usually wanted, but say so if the user only asked to run something.
|
|
52
|
+
|
|
53
|
+
**System binaries** — detect the OS package manager by probing, in order, and use the
|
|
54
|
+
first one available:
|
|
55
|
+
|
|
56
|
+
| `command -v` hit | Install command | Notes |
|
|
57
|
+
| ---------------- | ---------------------------------- | ---------------------------------- |
|
|
58
|
+
| `brew` | `brew install <pkg>` | macOS/Linuxbrew — no sudo, preferred |
|
|
59
|
+
| `apt-get` | `sudo apt-get install -y <pkg>` | Debian/Ubuntu; run `apt-get update` first if the install 404s |
|
|
60
|
+
| `dnf` / `yum` | `sudo dnf install -y <pkg>` | Fedora/RHEL |
|
|
61
|
+
| `apk` | `apk add <pkg>` (`sudo` if needed) | Alpine |
|
|
62
|
+
| `pacman` | `sudo pacman -S --noconfirm <pkg>` | Arch |
|
|
63
|
+
| `nix-env` | `nix-env -iA nixpkgs.<pkg>` | Nix — no sudo |
|
|
64
|
+
| `zypper` | `sudo zypper install -y <pkg>` | openSUSE |
|
|
65
|
+
|
|
66
|
+
The package name is not always the command name (e.g. the `fd` command ships as
|
|
67
|
+
`fd-find` on apt, `fd` on brew). If unsure of the exact package name, use the
|
|
68
|
+
`web_search` / `web_fetch` tools to confirm it for the detected package manager
|
|
69
|
+
before running the install.
|
|
70
|
+
|
|
71
|
+
## 3. Prefer the least-privileged install
|
|
72
|
+
|
|
73
|
+
- Reach for a **no-sudo** manager first (`brew`, `nix`, language package managers)
|
|
74
|
+
before a system one that needs `sudo`.
|
|
75
|
+
- For language packages outside a project, prefer a **user or isolated install**
|
|
76
|
+
over a global one:
|
|
77
|
+
- Python: a virtualenv (`python -m venv .venv && . .venv/bin/activate`) or
|
|
78
|
+
`pip install --user <pkg>` rather than a system-wide `sudo pip`.
|
|
79
|
+
- Node CLIs you only need to run once: `npx <pkg>` instead of `npm install -g`.
|
|
80
|
+
- Only escalate to `sudo` / system-wide when there is no user-writable option and the
|
|
81
|
+
task genuinely needs it. Explain why in the same message so the user's approval is
|
|
82
|
+
informed.
|
|
83
|
+
|
|
84
|
+
## 4. Run it and verify
|
|
85
|
+
|
|
86
|
+
Run the chosen command via the `bash` tool (never fabricate success). The permission
|
|
87
|
+
gate will surface the exact command to the user — that is intended, so keep the
|
|
88
|
+
command to the single thing you need.
|
|
89
|
+
|
|
90
|
+
After it completes:
|
|
91
|
+
|
|
92
|
+
1. Re-check availability (`command -v <tool>`, or re-run the failing import/build).
|
|
93
|
+
2. Retry the original task.
|
|
94
|
+
3. If the install was denied or failed, **stop and tell the user** what's missing and
|
|
95
|
+
the command you would run — do not loop on variants of the same install.
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- If the environment looks locked-down (read-only filesystem, no package manager
|
|
100
|
+
found, no network), say so plainly and ask how the user wants to proceed rather than
|
|
101
|
+
hunting for a workaround.
|
|
102
|
+
- One dependency at a time when debugging a broken environment — install, verify,
|
|
103
|
+
then move on — so a failure is easy to attribute.
|
package/src/cli/chat.ts
CHANGED
|
@@ -281,6 +281,8 @@ async function main() {
|
|
|
281
281
|
},
|
|
282
282
|
getRemote: bridge.getRemote,
|
|
283
283
|
getNoQuarter: bridge.getNoQuarter,
|
|
284
|
+
// `--no-quarter` at launch (env PRIVATEER_NO_QUARTER) → total gate bypass, no prompts.
|
|
285
|
+
getSkipAllPermissions: () => process.env.PRIVATEER_NO_QUARTER === "1",
|
|
284
286
|
remoteAsk: bridge.remoteAsk,
|
|
285
287
|
// Subagents (and their child-only intercom tools) can't be driven from the app
|
|
286
288
|
// yet — pi-subagents runs each in a child session whose gate/UI bypass the relay,
|
|
@@ -44,6 +44,10 @@ export interface GateController {
|
|
|
44
44
|
confineToCwd?: boolean;
|
|
45
45
|
getRemote?(): boolean;
|
|
46
46
|
getNoQuarter?(): boolean;
|
|
47
|
+
// Total bypass — see ModeGate.getSkipAllPermissions. Set by the `--no-quarter`
|
|
48
|
+
// launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
|
|
49
|
+
// action with no prompt.
|
|
50
|
+
getSkipAllPermissions?(): boolean;
|
|
47
51
|
// Block a tool outright while the turn is remote-driven (only consulted when
|
|
48
52
|
// getRemote() is true). For tools whose own prompts render on the host terminal
|
|
49
53
|
// rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
|
|
@@ -132,20 +136,26 @@ export async function decideToolCall(
|
|
|
132
136
|
ask,
|
|
133
137
|
getRemote: ctrl.getRemote,
|
|
134
138
|
getNoQuarter: ctrl.getNoQuarter,
|
|
139
|
+
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
135
140
|
});
|
|
136
141
|
|
|
137
142
|
let decision: "allow" | "deny";
|
|
138
143
|
try {
|
|
139
144
|
decision = await withTimeout(gate.request(req), ctrl.approvalTimeoutMs, ctx.signal);
|
|
140
145
|
} catch (err) {
|
|
141
|
-
// Fail closed: a thrown/aborted/timed-out approval blocks the tool.
|
|
146
|
+
// Fail closed: a thrown/aborted/timed-out approval blocks the tool. Phrase it
|
|
147
|
+
// as terminal — re-issuing the identical call will hit the same closed gate, so
|
|
148
|
+
// tell the model to stop retrying and take a different path (or ask the user).
|
|
142
149
|
return {
|
|
143
150
|
block: true,
|
|
144
|
-
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default
|
|
151
|
+
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default. Do not retry the same command; it will be blocked again. Try a different approach or ask the user to run it.`,
|
|
145
152
|
};
|
|
146
153
|
}
|
|
147
154
|
if (decision === "deny") {
|
|
148
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
block: true,
|
|
157
|
+
reason: `${req.title} was denied by the permission gate. Do not retry the same command; it will be denied again. Take a different approach or ask the user to run it themselves.`,
|
|
158
|
+
};
|
|
149
159
|
}
|
|
150
160
|
return undefined;
|
|
151
161
|
}
|
|
@@ -34,6 +34,13 @@ export interface ModeGateDeps {
|
|
|
34
34
|
// pinging the phone. Dangerous shell and alwaysAsk-destructive actions rank
|
|
35
35
|
// above bypass in decideAuto, so those still relay for an explicit Allow/Deny.
|
|
36
36
|
getNoQuarter?: () => boolean;
|
|
37
|
+
// True when the operator launched with `--no-quarter` (env PRIVATEER_NO_QUARTER):
|
|
38
|
+
// a session-wide TOTAL bypass of the gate. Every request auto-approves — including
|
|
39
|
+
// dangerous shell, destructive tools, out-of-cwd and protected-file access — with
|
|
40
|
+
// no prompt, local or remote. This sits ABOVE everything else (mode, allowlist,
|
|
41
|
+
// the remote branch, even the dangerous-command denylist): the operator has
|
|
42
|
+
// explicitly opted the whole session out of the moat. Off unless the flag is set.
|
|
43
|
+
getSkipAllPermissions?: () => boolean;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
// The permission gate used by the live TUI. It first applies the mode/allowlist
|
|
@@ -43,6 +50,13 @@ export class ModeGate implements PermissionGate {
|
|
|
43
50
|
constructor(private readonly deps: ModeGateDeps) {}
|
|
44
51
|
|
|
45
52
|
async request(req: PermissionRequest): Promise<PermissionDecision> {
|
|
53
|
+
// No-quarter: the operator launched with `--no-quarter`, opting the whole
|
|
54
|
+
// session out of the gate. Total bypass — auto-allow EVERY request (dangerous
|
|
55
|
+
// shell, destructive tools, outside-cwd, protected files) with no prompt, before
|
|
56
|
+
// any mode/allowlist/remote/denylist policy is consulted. Deliberately the very
|
|
57
|
+
// first check so nothing below can force an "ask" back on.
|
|
58
|
+
if (this.deps.getSkipAllPermissions?.()) return "allow";
|
|
59
|
+
|
|
46
60
|
const denylist = this.deps.denylist ?? [];
|
|
47
61
|
const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
|
|
48
62
|
|