privateer-agent 0.6.3 → 0.6.6
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 +35 -0
- package/SECURITY.md +54 -0
- package/bin/apply-patches.d.mts +18 -0
- package/bin/apply-patches.mjs +143 -0
- package/bin/privateer-launch.mjs +151 -10
- package/extensions/privateer-gate.ts +4 -0
- package/package.json +8 -3
- 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/config/hosted.ts +36 -0
- package/src/daemon/index.ts +75 -7
- package/src/ext/permissionGate.ts +13 -3
- package/src/permissions/modeGate.ts +14 -0
- package/src/remote/mcpControl.ts +268 -0
- package/src/remote/relayClient.ts +78 -0
- package/src/remote/remoteBridge.ts +7 -0
- package/src/routines/schema.ts +9 -1
package/README.md
CHANGED
|
@@ -133,6 +133,26 @@ curl -fsSL https://privateer.pro/install.sh | sh
|
|
|
133
133
|
|
|
134
134
|
**Requirements:** macOS or Linux, Node.js ≥ 22.19.0.
|
|
135
135
|
|
|
136
|
+
### Verifying what you're about to run
|
|
137
|
+
|
|
138
|
+
Privateer is a coding agent — it runs shell commands and edits files, so "should I trust
|
|
139
|
+
this package?" is the right question to ask before `npx`. Two things are checkable
|
|
140
|
+
without taking anyone's word for it:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm view privateer-agent dist.attestations # published from CI with npm provenance:
|
|
144
|
+
# a signed link from this tarball to the
|
|
145
|
+
# exact commit and build that produced it
|
|
146
|
+
npm audit signatures # verify registry signatures + provenance
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The package also declares **no install scripts** — no `postinstall`, nothing. Installing
|
|
150
|
+
it writes files and executes nothing; `npm install -g privateer-agent --ignore-scripts`
|
|
151
|
+
gives an identical result. Code runs only when you run `privateer`.
|
|
152
|
+
|
|
153
|
+
See [SECURITY.md](SECURITY.md) for the threat model, the permission gate, and how to
|
|
154
|
+
report a vulnerability.
|
|
155
|
+
|
|
136
156
|
**From source:**
|
|
137
157
|
|
|
138
158
|
```bash
|
|
@@ -248,6 +268,21 @@ redactor before it leaves.
|
|
|
248
268
|
Switch with **`/mode`**. Even in `bypass`, a danger filter blocks destructive shell commands,
|
|
249
269
|
and protected files (`.env`, shell rc files…) are guarded — the gate is never fully off.
|
|
250
270
|
|
|
271
|
+
### `--no-quarter` — lower the moat entirely
|
|
272
|
+
|
|
273
|
+
For an unattended run in a directory and on a task you fully trust, launch with:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
privateer --no-quarter
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
This is the one exception to "the gate is never fully off." It disables the permission
|
|
280
|
+
gate for the **whole session** — every action auto-approves with no prompt, including
|
|
281
|
+
destructive shell commands, out-of-cwd access, and protected files. Subagents spawned
|
|
282
|
+
by the session inherit it. There is no `/mode` equivalent; it's a deliberate launch-time
|
|
283
|
+
opt-out (env `PRIVATEER_NO_QUARTER=1`) and prints a red warning banner so it's never a
|
|
284
|
+
surprise. Use it sparingly.
|
|
285
|
+
|
|
251
286
|
## Extend it
|
|
252
287
|
|
|
253
288
|
Everything below is a **Pi extension** loaded by discovery (see [Built on Pi](#built-on-pi)) —
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Report privately via [GitHub Security Advisories](https://github.com/privateer-agent/privateer-agent/security/advisories/new),
|
|
6
|
+
or email **support@privateer.pro**. Please don't open a public issue for anything
|
|
7
|
+
exploitable. Expect an initial response within 72 hours.
|
|
8
|
+
|
|
9
|
+
## What you're running
|
|
10
|
+
|
|
11
|
+
Privateer is a terminal coding agent: it reads and writes files, runs shell commands and
|
|
12
|
+
talks to model providers on your behalf. That is the point of it, and it is also the
|
|
13
|
+
threat model. Two things are worth verifying rather than taking on faith.
|
|
14
|
+
|
|
15
|
+
**The package runs no install scripts.** There is no `preinstall`, `install`,
|
|
16
|
+
`postinstall` or `prepare` hook. `npm install -g privateer-agent` writes files and
|
|
17
|
+
executes nothing; installing with `--ignore-scripts` produces an identical result. Code
|
|
18
|
+
runs only when you run `privateer`. (Dependency patching happens at first launch — see
|
|
19
|
+
`bin/apply-patches.mjs` and `docs/shipping.md`.)
|
|
20
|
+
|
|
21
|
+
**Releases carry npm provenance.** Published from `.github/workflows/release.yml` with
|
|
22
|
+
`npm publish --provenance`, so npm holds a signed Sigstore attestation binding the
|
|
23
|
+
tarball to this repository, the exact commit and the workflow run that built it. The npm
|
|
24
|
+
package page shows a verified *"Built and signed on GitHub Actions"* badge linking to
|
|
25
|
+
the build. To check it yourself:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm view privateer-agent dist.attestations # attestation metadata exists
|
|
29
|
+
npm audit signatures # verifies registry signatures + provenance
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If a version lacks provenance, it did not come from this workflow. Treat that as
|
|
33
|
+
suspicious and report it.
|
|
34
|
+
|
|
35
|
+
## The permission gate
|
|
36
|
+
|
|
37
|
+
By default every shell command, file write outside the working directory, and
|
|
38
|
+
destructive tool call stops for explicit approval. This is the moat, and it is the main
|
|
39
|
+
thing standing between a prompt-injected model and your filesystem.
|
|
40
|
+
|
|
41
|
+
`--no-quarter` disables it entirely — every action runs unprompted. It exists for
|
|
42
|
+
trusted, disposable environments (throwaway containers, CI). Do not use it on a machine
|
|
43
|
+
whose contents you care about, and do not use it on a repository or task involving
|
|
44
|
+
untrusted content: a coding agent reading an attacker-controlled file is a realistic
|
|
45
|
+
injection path.
|
|
46
|
+
|
|
47
|
+
## Keys and credentials
|
|
48
|
+
|
|
49
|
+
Provider API keys and account credentials live under `~/.privateer/` on your machine and
|
|
50
|
+
are sent only to the provider you selected. Bot tokens for messaging channels are sealed
|
|
51
|
+
to a terminal keypair before they reach our relay, and channel configuration is verified
|
|
52
|
+
against a link-pinned account key — the relay can neither read those tokens nor forge
|
|
53
|
+
configuration. Architecture and residual risks are documented in
|
|
54
|
+
`docs/daemon-channels-and-app.md`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Types for the launcher's patch/resolve helpers. The implementation is plain .mjs
|
|
2
|
+
// because bin/ must run under a bare `node` with no transpiler (the launcher is the
|
|
3
|
+
// very first thing to execute, before tsx/jiti are in play).
|
|
4
|
+
|
|
5
|
+
/** Directory CONTAINING the node_modules that holds `name`, or null if not installed. */
|
|
6
|
+
export function findDepRoot(from: string, name: string): string | null;
|
|
7
|
+
|
|
8
|
+
/** Absolute path to a file inside an installed dependency, or null if absent. */
|
|
9
|
+
export function resolveDep(from: string, name: string, ...rest: string[]): string | null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Apply `patches/` into whichever node_modules the targets landed in. Idempotent and
|
|
13
|
+
* best-effort; see the implementation for why this runs at launch, not on install.
|
|
14
|
+
*/
|
|
15
|
+
export function applyPatchesIfNeeded(
|
|
16
|
+
repo: string,
|
|
17
|
+
nodeBin?: string,
|
|
18
|
+
): "current" | "applied" | "skipped" | "failed";
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Apply the patches/ directory into node_modules — at LAUNCH, not at install.
|
|
2
|
+
//
|
|
3
|
+
// Why not a `postinstall` script (the obvious place)? Because `npm install` /
|
|
4
|
+
// `npx privateer-agent` would then execute our code on the user's machine BEFORE
|
|
5
|
+
// they ever decided to run Privateer. That is exactly the install-time-execution
|
|
6
|
+
// risk a careful reviewer — human or agent — flags on an unfamiliar package, and
|
|
7
|
+
// it is the one npm-side signal we can remove outright. With no install scripts,
|
|
8
|
+
// `npm install -g privateer-agent --ignore-scripts` is completely inert: it writes
|
|
9
|
+
// files and runs nothing. Patching moves to the first actual launch, which the
|
|
10
|
+
// user explicitly asked for.
|
|
11
|
+
//
|
|
12
|
+
// Contract: idempotent, cheap on the hot path (a stamp file short-circuits every
|
|
13
|
+
// launch after the first), and BEST-EFFORT — every patch here is a UX/robustness
|
|
14
|
+
// improvement, never a correctness prerequisite, so a failure to apply degrades to
|
|
15
|
+
// stock Pi behaviour rather than blocking launch. That matters for the common
|
|
16
|
+
// `sudo npm install -g` case, where node_modules is root-owned and an unprivileged
|
|
17
|
+
// launch simply cannot write to it.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
// Bump when the applier's own semantics change, to force a re-apply on upgrade.
|
|
25
|
+
const STAMP_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where do our patch targets actually live?
|
|
29
|
+
*
|
|
30
|
+
* NOT necessarily `<repo>/node_modules`. When Privateer is installed as a dependency
|
|
31
|
+
* (`npm i privateer-agent`, `npx privateer-agent`), npm HOISTS pi-coding-agent to the
|
|
32
|
+
* parent project's node_modules and leaves us with no node_modules of our own — so
|
|
33
|
+
* assuming a local one means the patches silently never apply. Resolve the real target
|
|
34
|
+
* from our own package instead, and return the directory that CONTAINS the node_modules
|
|
35
|
+
* it landed in: that is the cwd patch-package needs, in every layout (hoisted, nested,
|
|
36
|
+
* global, bundled).
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Find the directory CONTAINING the node_modules that holds `name`, starting at
|
|
40
|
+
* `from` and walking up. Returns null if the dependency isn't installed anywhere.
|
|
41
|
+
*
|
|
42
|
+
* This is the one resolution primitive both the launcher and the patcher need,
|
|
43
|
+
* because `<repo>/node_modules` is NOT where dependencies reliably live:
|
|
44
|
+
* - `npm i -g privateer-agent` -> nested: <repo>/node_modules/<name>
|
|
45
|
+
* - `npx privateer-agent` -> hoisted: <repo>/../node_modules/<name>
|
|
46
|
+
* - `npm i privateer-agent` -> hoisted into the host project
|
|
47
|
+
* Hardcoding the nested case silently breaks every hoisted install.
|
|
48
|
+
*
|
|
49
|
+
* We walk directories rather than using require.resolve because modern packages
|
|
50
|
+
* (pi-coding-agent among them) declare an `exports` map with no "./package.json"
|
|
51
|
+
* entry, so require.resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED even when the
|
|
52
|
+
* package is sitting right there. Directory lookup sees through `exports`.
|
|
53
|
+
*/
|
|
54
|
+
export function findDepRoot(from, name) {
|
|
55
|
+
const segs = name.split("/");
|
|
56
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
57
|
+
if (fs.existsSync(path.join(dir, "node_modules", ...segs, "package.json"))) return dir;
|
|
58
|
+
const parent = path.dirname(dir);
|
|
59
|
+
if (parent === dir) return null; // hit the filesystem root
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Absolute path to an installed dependency's file, or null if the dep isn't present. */
|
|
64
|
+
export function resolveDep(from, name, ...rest) {
|
|
65
|
+
const root = findDepRoot(from, name);
|
|
66
|
+
return root ? path.join(root, "node_modules", ...name.split("/"), ...rest) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolvePatchRoots(repo, patchFiles) {
|
|
70
|
+
const roots = new Set();
|
|
71
|
+
for (const file of patchFiles) {
|
|
72
|
+
// "@earendil-works+pi-coding-agent+0.80.3.patch" -> "@earendil-works/pi-coding-agent"
|
|
73
|
+
const parts = path.basename(file, ".patch").split("+");
|
|
74
|
+
const name = parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
75
|
+
const root = findDepRoot(repo, name);
|
|
76
|
+
if (root) roots.add(root);
|
|
77
|
+
}
|
|
78
|
+
return [...roots];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** sha256 over every patch file's name + contents — the identity of "what should be applied". */
|
|
82
|
+
function patchSetHash(patchDir, files) {
|
|
83
|
+
const h = crypto.createHash("sha256").update(String(STAMP_VERSION));
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
h.update(f);
|
|
86
|
+
h.update(fs.readFileSync(path.join(patchDir, f)));
|
|
87
|
+
}
|
|
88
|
+
return h.digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ensure patches/ is applied to repo/node_modules. Returns one of:
|
|
93
|
+
* "current" — already applied (stamp matches); nothing done
|
|
94
|
+
* "applied" — patches were just applied successfully
|
|
95
|
+
* "skipped" — nothing to do (no patches / no node_modules / patch-package absent)
|
|
96
|
+
* "failed" — apply was attempted and did not succeed (caller may warn)
|
|
97
|
+
*/
|
|
98
|
+
export function applyPatchesIfNeeded(repo, nodeBin = process.execPath) {
|
|
99
|
+
try {
|
|
100
|
+
repo = path.resolve(repo); // roots come back absolute; a relative repo would break path.relative
|
|
101
|
+
const patchDir = path.join(repo, "patches");
|
|
102
|
+
if (!fs.existsSync(patchDir)) return "skipped";
|
|
103
|
+
const patchFiles = fs.readdirSync(patchDir).filter((f) => f.endsWith(".patch")).sort();
|
|
104
|
+
if (patchFiles.length === 0) return "skipped";
|
|
105
|
+
|
|
106
|
+
const want = patchSetHash(patchDir, patchFiles);
|
|
107
|
+
const roots = resolvePatchRoots(repo, patchFiles);
|
|
108
|
+
if (roots.length === 0) return "skipped"; // targets not installed
|
|
109
|
+
|
|
110
|
+
// patch-package is a runtime dependency precisely so this works post-install.
|
|
111
|
+
const pp = resolveDep(repo, "patch-package", "index.js");
|
|
112
|
+
if (!pp || !fs.existsSync(pp)) return "skipped";
|
|
113
|
+
|
|
114
|
+
let did = false;
|
|
115
|
+
for (const root of roots) {
|
|
116
|
+
const stampFile = path.join(root, "node_modules", ".privateer-patches.json");
|
|
117
|
+
try {
|
|
118
|
+
if (JSON.parse(fs.readFileSync(stampFile, "utf8")).hash === want) continue; // current
|
|
119
|
+
} catch { /* missing or unreadable stamp — (re)apply */ }
|
|
120
|
+
|
|
121
|
+
// --patch-dir points at OUR patches even though cwd is wherever the deps landed.
|
|
122
|
+
// It MUST be relative: patch-package resolves it against cwd, so an absolute path
|
|
123
|
+
// is silently mangled into a non-existent one and every patch "fails" to apply.
|
|
124
|
+
const relPatchDir = path.relative(root, patchDir);
|
|
125
|
+
const r = spawnSync(nodeBin, [pp, "--error-on-fail", "--patch-dir", relPatchDir], {
|
|
126
|
+
cwd: root,
|
|
127
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
128
|
+
timeout: 60_000,
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
});
|
|
131
|
+
if (r.status !== 0) return "failed";
|
|
132
|
+
did = true;
|
|
133
|
+
|
|
134
|
+
// Only stamp after a clean apply, so a partial/failed run retries next launch.
|
|
135
|
+
try {
|
|
136
|
+
fs.writeFileSync(stampFile, JSON.stringify({ hash: want, at: new Date().toISOString() }) + "\n");
|
|
137
|
+
} catch { /* unwritable node_modules — applied fine, we just re-check next launch */ }
|
|
138
|
+
}
|
|
139
|
+
return did ? "applied" : "current";
|
|
140
|
+
} catch {
|
|
141
|
+
return "failed";
|
|
142
|
+
}
|
|
143
|
+
}
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import fs from "node:fs";
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
20
21
|
|
|
21
22
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
|
|
22
23
|
const REPO = path.resolve(HERE, "..");
|
|
@@ -41,6 +42,29 @@ const NODE_BIN = BUNDLED ? bundledNode : process.execPath;
|
|
|
41
42
|
process.env.PATH = path.dirname(NODE_BIN) + path.delimiter + (process.env.PATH || "");
|
|
42
43
|
|
|
43
44
|
const args = process.argv.slice(2);
|
|
45
|
+
|
|
46
|
+
// `--no-quarter` — total permission bypass ("take no prisoners"). Strip it from the
|
|
47
|
+
// args BEFORE anything else so it never reaches Pi's cli.js (which doesn't know it)
|
|
48
|
+
// and so `sub`/`args.slice(1)` see only real subcommands. When present we export
|
|
49
|
+
// PRIVATEER_NO_QUARTER=1; the permission gate (extensions/privateer-gate.ts, and any
|
|
50
|
+
// subagent child that inherits this env) then auto-approves EVERY action with no
|
|
51
|
+
// prompt — dangerous shell, destructive tools, out-of-cwd, protected files, all of
|
|
52
|
+
// it. This is the moat fully lowered; only pass it when you trust the whole session.
|
|
53
|
+
const NO_QUARTER = args.some((a) => a === "--no-quarter");
|
|
54
|
+
if (NO_QUARTER) {
|
|
55
|
+
for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-quarter") args.splice(i, 1);
|
|
56
|
+
process.env.PRIVATEER_NO_QUARTER = "1";
|
|
57
|
+
process.stderr.write(
|
|
58
|
+
[
|
|
59
|
+
"",
|
|
60
|
+
" ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate DISABLED for this session.",
|
|
61
|
+
" Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt.",
|
|
62
|
+
" Only use this in a directory and with a task you fully trust.",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n") + "\n",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
const sub = args[0];
|
|
45
69
|
|
|
46
70
|
// `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
|
|
@@ -49,7 +73,8 @@ const sub = args[0];
|
|
|
49
73
|
if (sub === "--version" || sub === "-V") {
|
|
50
74
|
const ver = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")).version; } catch { return null; } };
|
|
51
75
|
const pv = ver(path.join(REPO, "package.json")) || "unknown";
|
|
52
|
-
const
|
|
76
|
+
const piPkg = resolveDep(REPO, "@earendil-works/pi-coding-agent", "package.json");
|
|
77
|
+
const pi = piPkg ? ver(piPkg) : null;
|
|
53
78
|
console.log(`privateer ${pv}${pi ? ` (pi ${pi})` : ""}`);
|
|
54
79
|
process.exit(0);
|
|
55
80
|
}
|
|
@@ -103,6 +128,13 @@ else {
|
|
|
103
128
|
// time the agent tries to run a command. Unix always has a shell, so this is a no-op.
|
|
104
129
|
ensureShellOrExit();
|
|
105
130
|
|
|
131
|
+
// Apply our pi-coding-agent patches. This happens HERE, on a launch the user asked
|
|
132
|
+
// for, rather than in a postinstall — so installing the package runs no code at all.
|
|
133
|
+
// Stamped, so it's a single file read on every launch after the first. Best-effort:
|
|
134
|
+
// both patches are UX fixes, so a root-owned node_modules (sudo npm i -g) just means
|
|
135
|
+
// stock Pi behaviour, not a broken boot. Bundles ship pre-patched and no-op here.
|
|
136
|
+
ensurePatches();
|
|
137
|
+
|
|
106
138
|
const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
|
|
107
139
|
const EXT_DIR = path.join(AGENT_DIR, "extensions");
|
|
108
140
|
fs.mkdirSync(EXT_DIR, { recursive: true });
|
|
@@ -119,9 +151,17 @@ else {
|
|
|
119
151
|
for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
|
|
120
152
|
|
|
121
153
|
const ext = (...p) => path.join(REPO, "extensions", ...p);
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
// Resolve dependencies by walking the node_modules chain, NOT as REPO/node_modules.
|
|
155
|
+
// npm only nests deps under us for a global install; `npx privateer-agent` and
|
|
156
|
+
// `npm i privateer-agent` HOIST them to a sibling/parent node_modules, where the
|
|
157
|
+
// hardcoded path resolves to nothing and every shim below points at a missing file.
|
|
158
|
+
const dep = (name, ...rest) => resolveDep(REPO, name, ...rest);
|
|
159
|
+
// A missing target means that optional tool pack isn't installed — skip its shim
|
|
160
|
+
// rather than writing one that points at nothing (which fails at extension load).
|
|
161
|
+
const shim = (name, target) => {
|
|
162
|
+
if (!target || !fs.existsSync(target)) return;
|
|
124
163
|
fs.writeFileSync(path.join(EXT_DIR, `${name}.ts`), `export { default } from ${JSON.stringify(pathToFileURL(target).href)};\n`);
|
|
164
|
+
};
|
|
125
165
|
|
|
126
166
|
shim("privateer-brand", ext("privateer-brand.ts")); // banner, ⚓ badge, /signin /signout
|
|
127
167
|
shim("privateer-context", ext("privateer-context.ts")); // PRIVATEER.md context + /init
|
|
@@ -131,12 +171,21 @@ else {
|
|
|
131
171
|
shim("privateer-posture", ext("privateer-posture.ts"));
|
|
132
172
|
shim("privateer-tools", ext("privateer-tools.ts"));
|
|
133
173
|
shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
|
|
134
|
-
shim("rpiv-web-tools", dep("@juicesharp
|
|
174
|
+
shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
|
|
135
175
|
shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
|
|
136
|
-
shim("pi-hypa", dep("@hypabolic
|
|
176
|
+
shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
|
|
137
177
|
shim("pi-subagents", dep("pi-subagents", "src", "extension", "index.ts"));
|
|
138
178
|
|
|
139
|
-
|
|
179
|
+
// Unlike the tool packs above, Pi's CLI is not optional — it IS the agent. If it
|
|
180
|
+
// didn't resolve, the install is broken; say so instead of spawning `undefined`.
|
|
181
|
+
const CLI = dep("@earendil-works/pi-coding-agent", "dist", "cli.js");
|
|
182
|
+
if (!CLI || !fs.existsSync(CLI)) {
|
|
183
|
+
console.error(
|
|
184
|
+
"privateer: couldn't find pi-coding-agent — the install looks incomplete.\n" +
|
|
185
|
+
" Try reinstalling: npm install -g privateer-agent@latest",
|
|
186
|
+
);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
140
189
|
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
141
190
|
// The binary pi-subagents spawns for each child. Point it at OUR cli.js so the child
|
|
142
191
|
// reads this same PI_CODING_AGENT_DIR and DISCOVERS the moat shims (gated + private,
|
|
@@ -169,17 +218,41 @@ else {
|
|
|
169
218
|
// TEE, strongest tier) when a Tinfoil key is present; else the signed-in account's
|
|
170
219
|
// NEAR channel; else a cheap OpenRouter fallback.
|
|
171
220
|
const CRED = path.join(PRIVATEER_HOME, "credentials.json");
|
|
221
|
+
const signedIn = fs.existsSync(CRED);
|
|
172
222
|
const MODEL = process.env.PRIVATEER_MODEL
|
|
173
223
|
? process.env.PRIVATEER_MODEL
|
|
174
224
|
: haveTinfoilKey()
|
|
175
225
|
? "tinfoil/glm-5-2"
|
|
176
|
-
:
|
|
226
|
+
: signedIn
|
|
177
227
|
? "privateer/near/zai-org/GLM-5.1-FP8"
|
|
178
228
|
: "openrouter/openai/gpt-4o-mini";
|
|
179
229
|
|
|
230
|
+
// Guard the keyless dead-end. We land on the OpenRouter fallback ONLY when the user
|
|
231
|
+
// named no model, has no Tinfoil key, AND isn't signed in (no credentials.json). If
|
|
232
|
+
// they also have no OpenRouter/other BYO key, the very first prompt errors with a bare
|
|
233
|
+
// "No API key found for openrouter" and nothing explains why. Worse, if this machine
|
|
234
|
+
// was signed in before (other ~/.privateer state exists but the login file is gone),
|
|
235
|
+
// that bare error hides a vanished session. Surface a clear, branded notice BEFORE the
|
|
236
|
+
// TUI loads — but still boot it, so `/login` inside works (and activateSignedInModel
|
|
237
|
+
// switches the live session onto the account channel the moment they sign back in).
|
|
238
|
+
if (MODEL === "openrouter/openai/gpt-4o-mini" && !haveByoKey()) {
|
|
239
|
+
warnKeylessLaunch();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Privateer's own bundled skills. Loaded by explicit path (Pi's `--skill`, which
|
|
243
|
+
// takes a file or directory) rather than seeded into the agent dir, so they load
|
|
244
|
+
// read-only from the shipped release — always matching this version, never
|
|
245
|
+
// clobbering or resurrecting anything in the user's own editable skills dir. Each
|
|
246
|
+
// is a directory holding a SKILL.md. Skip any that aren't present (e.g. a partial
|
|
247
|
+
// dev checkout) so a missing dir can't wedge launch.
|
|
248
|
+
const SKILL_DIRS = ["resolve-dependencies"]
|
|
249
|
+
.map((name) => path.join(REPO, "skills", name))
|
|
250
|
+
.filter((dir) => fs.existsSync(dir));
|
|
251
|
+
const skillArgs = SKILL_DIRS.flatMap((dir) => ["--skill", dir]);
|
|
252
|
+
|
|
180
253
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
181
254
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
182
|
-
runToCompletion(NODE_BIN, [...nodeArgs, CLI, "--model", MODEL, ...args]);
|
|
255
|
+
runToCompletion(NODE_BIN, [...nodeArgs, CLI, "--model", MODEL, ...skillArgs, ...args]);
|
|
183
256
|
}
|
|
184
257
|
|
|
185
258
|
// --- helpers ---------------------------------------------------------------
|
|
@@ -239,12 +312,80 @@ function findWindowsBash() {
|
|
|
239
312
|
return null;
|
|
240
313
|
}
|
|
241
314
|
|
|
315
|
+
// Run the patch applier and, on the one interesting outcome (we tried and couldn't),
|
|
316
|
+
// tell the user why in a way they can act on. "current"/"applied"/"skipped" are silent.
|
|
317
|
+
function ensurePatches() {
|
|
318
|
+
if (applyPatchesIfNeeded(REPO, NODE_BIN) !== "failed") return;
|
|
319
|
+
process.stderr.write(
|
|
320
|
+
[
|
|
321
|
+
"",
|
|
322
|
+
" ⚓ Couldn't apply Privateer's bundled patches to node_modules — continuing without them.",
|
|
323
|
+
" Two upstream fixes (retry-loop guard, /model → /models redirect) stay off.",
|
|
324
|
+
` Usually a permissions issue: ${path.join(REPO, "node_modules")} isn't writable`,
|
|
325
|
+
" by this user (a `sudo npm install -g` install). Re-run once with sudo, or",
|
|
326
|
+
" install without sudo (nvm, or an npm prefix you own) to fix it for good.",
|
|
327
|
+
"",
|
|
328
|
+
].join("\n") + "\n",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
242
332
|
function haveTinfoilKey() {
|
|
243
|
-
|
|
244
|
-
|
|
333
|
+
return haveKey("TINFOIL_API_KEY");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// True if `name` is set in the environment or (dev convenience) present and non-empty in
|
|
337
|
+
// the repo .env — the same two sources the child inherits, so this matches what Pi will
|
|
338
|
+
// actually see for the provider key at request time.
|
|
339
|
+
function haveKey(name) {
|
|
340
|
+
if (process.env[name]) return true;
|
|
341
|
+
try { return new RegExp(`^${name}=.+`, "m").test(fs.readFileSync(ENV_FILE, "utf8")); }
|
|
245
342
|
catch { return false; }
|
|
246
343
|
}
|
|
247
344
|
|
|
345
|
+
// Any BYO provider key that would make the keyless OpenRouter launch model usable (or at
|
|
346
|
+
// least give the runtime SOME working provider). Mirrors defaultModel.ts's BYO_BY_KEY.
|
|
347
|
+
function haveByoKey() {
|
|
348
|
+
return ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "TINFOIL_API_KEY"]
|
|
349
|
+
.some(haveKey);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Explain the keyless launch instead of letting the first prompt dead-end on a bare
|
|
353
|
+
// "No API key found for openrouter". Distinguishes a returning user whose login file is
|
|
354
|
+
// missing (other ~/.privateer state exists → likely signed out unexpectedly) from a
|
|
355
|
+
// genuine first run. Non-fatal: we print and carry on so `/login` inside still works.
|
|
356
|
+
function warnKeylessLaunch() {
|
|
357
|
+
// Heuristic "was signed in before": the agent dir or our own config.json exists even
|
|
358
|
+
// though credentials.json doesn't. A true first run has neither yet.
|
|
359
|
+
let returning = false;
|
|
360
|
+
try {
|
|
361
|
+
returning =
|
|
362
|
+
fs.existsSync(path.join(PRIVATEER_HOME, "agent")) ||
|
|
363
|
+
fs.existsSync(path.join(PRIVATEER_HOME, "config.json"));
|
|
364
|
+
} catch { /* best-effort — default to the first-run wording */ }
|
|
365
|
+
|
|
366
|
+
const lines = returning
|
|
367
|
+
? [
|
|
368
|
+
"",
|
|
369
|
+
" ⚓ Your Privateer login is missing — this terminal isn't signed in.",
|
|
370
|
+
` (no ${path.join(PRIVATEER_HOME, "credentials.json")})`,
|
|
371
|
+
"",
|
|
372
|
+
" If you were signed in before, your session was cleared. Run /login to sign",
|
|
373
|
+
" back in — you'll return to your subscription models right away. Until then,",
|
|
374
|
+
" prompting fails with \"No API key found\" because no model key is set.",
|
|
375
|
+
"",
|
|
376
|
+
]
|
|
377
|
+
: [
|
|
378
|
+
"",
|
|
379
|
+
" ⚓ You're not signed in to Privateer and no provider API key is set.",
|
|
380
|
+
"",
|
|
381
|
+
" Run /login to use your subscription, or set a provider key (e.g.",
|
|
382
|
+
" ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY). Until then,",
|
|
383
|
+
" prompting fails with \"No API key found\".",
|
|
384
|
+
"",
|
|
385
|
+
];
|
|
386
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
387
|
+
}
|
|
388
|
+
|
|
248
389
|
function refreshUpdateCache() {
|
|
249
390
|
const cache = path.join(PRIVATEER_HOME, "update-check.json");
|
|
250
391
|
try {
|
|
@@ -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.6",
|
|
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",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/privateer-agent/privateer-agent.git"
|
|
11
11
|
},
|
|
12
|
+
"homepage": "https://privateer.pro",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/privateer-agent/privateer-agent/issues"
|
|
15
|
+
},
|
|
12
16
|
"keywords": [
|
|
13
17
|
"ai",
|
|
14
18
|
"coding-agent",
|
|
@@ -37,7 +41,9 @@
|
|
|
37
41
|
"bin",
|
|
38
42
|
"src",
|
|
39
43
|
"extensions",
|
|
44
|
+
"skills",
|
|
40
45
|
"patches",
|
|
46
|
+
"SECURITY.md",
|
|
41
47
|
"README.md",
|
|
42
48
|
"LICENSE"
|
|
43
49
|
],
|
|
@@ -47,8 +53,7 @@
|
|
|
47
53
|
"channels": "node --env-file=.env --import tsx src/channels/run.ts",
|
|
48
54
|
"dev": "tsx watch src/main.ts",
|
|
49
55
|
"typecheck": "tsc --noEmit",
|
|
50
|
-
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
51
|
-
"postinstall": "patch-package --error-on-fail"
|
|
56
|
+
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
52
57
|
},
|
|
53
58
|
"engines": {
|
|
54
59
|
"node": ">=22.19.0"
|
|
@@ -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
|