privateer-agent 0.6.4 → 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 +20 -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 +49 -6
- package/package.json +7 -3
- package/src/config/hosted.ts +36 -0
- package/src/daemon/index.ts +75 -7
- 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
|
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, "..");
|
|
@@ -72,7 +73,8 @@ const sub = args[0];
|
|
|
72
73
|
if (sub === "--version" || sub === "-V") {
|
|
73
74
|
const ver = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")).version; } catch { return null; } };
|
|
74
75
|
const pv = ver(path.join(REPO, "package.json")) || "unknown";
|
|
75
|
-
const
|
|
76
|
+
const piPkg = resolveDep(REPO, "@earendil-works/pi-coding-agent", "package.json");
|
|
77
|
+
const pi = piPkg ? ver(piPkg) : null;
|
|
76
78
|
console.log(`privateer ${pv}${pi ? ` (pi ${pi})` : ""}`);
|
|
77
79
|
process.exit(0);
|
|
78
80
|
}
|
|
@@ -126,6 +128,13 @@ else {
|
|
|
126
128
|
// time the agent tries to run a command. Unix always has a shell, so this is a no-op.
|
|
127
129
|
ensureShellOrExit();
|
|
128
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
|
+
|
|
129
138
|
const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
|
|
130
139
|
const EXT_DIR = path.join(AGENT_DIR, "extensions");
|
|
131
140
|
fs.mkdirSync(EXT_DIR, { recursive: true });
|
|
@@ -142,9 +151,17 @@ else {
|
|
|
142
151
|
for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
|
|
143
152
|
|
|
144
153
|
const ext = (...p) => path.join(REPO, "extensions", ...p);
|
|
145
|
-
|
|
146
|
-
|
|
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;
|
|
147
163
|
fs.writeFileSync(path.join(EXT_DIR, `${name}.ts`), `export { default } from ${JSON.stringify(pathToFileURL(target).href)};\n`);
|
|
164
|
+
};
|
|
148
165
|
|
|
149
166
|
shim("privateer-brand", ext("privateer-brand.ts")); // banner, ⚓ badge, /signin /signout
|
|
150
167
|
shim("privateer-context", ext("privateer-context.ts")); // PRIVATEER.md context + /init
|
|
@@ -154,12 +171,21 @@ else {
|
|
|
154
171
|
shim("privateer-posture", ext("privateer-posture.ts"));
|
|
155
172
|
shim("privateer-tools", ext("privateer-tools.ts"));
|
|
156
173
|
shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
|
|
157
|
-
shim("rpiv-web-tools", dep("@juicesharp
|
|
174
|
+
shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
|
|
158
175
|
shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
|
|
159
|
-
shim("pi-hypa", dep("@hypabolic
|
|
176
|
+
shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
|
|
160
177
|
shim("pi-subagents", dep("pi-subagents", "src", "extension", "index.ts"));
|
|
161
178
|
|
|
162
|
-
|
|
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
|
+
}
|
|
163
189
|
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
164
190
|
// The binary pi-subagents spawns for each child. Point it at OUR cli.js so the child
|
|
165
191
|
// reads this same PI_CODING_AGENT_DIR and DISCOVERS the moat shims (gated + private,
|
|
@@ -286,6 +312,23 @@ function findWindowsBash() {
|
|
|
286
312
|
return null;
|
|
287
313
|
}
|
|
288
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
|
+
|
|
289
332
|
function haveTinfoilKey() {
|
|
290
333
|
return haveKey("TINFOIL_API_KEY");
|
|
291
334
|
}
|
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",
|
|
@@ -39,6 +43,7 @@
|
|
|
39
43
|
"extensions",
|
|
40
44
|
"skills",
|
|
41
45
|
"patches",
|
|
46
|
+
"SECURITY.md",
|
|
42
47
|
"README.md",
|
|
43
48
|
"LICENSE"
|
|
44
49
|
],
|
|
@@ -48,8 +53,7 @@
|
|
|
48
53
|
"channels": "node --env-file=.env --import tsx src/channels/run.ts",
|
|
49
54
|
"dev": "tsx watch src/main.ts",
|
|
50
55
|
"typecheck": "tsc --noEmit",
|
|
51
|
-
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
52
|
-
"postinstall": "patch-package --error-on-fail"
|
|
56
|
+
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
53
57
|
},
|
|
54
58
|
"engines": {
|
|
55
59
|
"node": ">=22.19.0"
|
package/src/config/hosted.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { globalDir } from "./paths.ts";
|
|
4
|
+
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
5
|
+
|
|
1
6
|
// Harbor hosted mode.
|
|
2
7
|
//
|
|
3
8
|
// When true, this daemon is running inside Privateer's confidential-VM fleet
|
|
@@ -11,3 +16,34 @@
|
|
|
11
16
|
export function isHosted(): boolean {
|
|
12
17
|
return process.env.HARBOR_HOSTED === "1";
|
|
13
18
|
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Publish this daemon's relay identity key so the Harbor host can attest it.
|
|
22
|
+
*
|
|
23
|
+
* ATTESTATION CONTRACT (host side: treeview `server/services/harborOrchestrator/`):
|
|
24
|
+
* the orchestrator mints the SEV-SNP report on the CVM host — configfs-tsm is a
|
|
25
|
+
* privileged kernel interface a rootless tenant deliberately cannot reach — and binds
|
|
26
|
+
* `report_data[0:32] = sha256(DER-SPKI(terminalPub))`. To do that it needs OUR public
|
|
27
|
+
* key, so we drop it in `$PRIVATEER_HOME` (bind-mounted from host tmpfs) as the mirror
|
|
28
|
+
* of the `routines/relay-id` file the host seeds for us.
|
|
29
|
+
*
|
|
30
|
+
* It must be the key the app ACTUALLY drives over the relay — the same value we send
|
|
31
|
+
* in sendContext({ terminalPub }) — otherwise the app's fail-closed check reports a
|
|
32
|
+
* key mismatch. Base64 of the raw 32 X25519 bytes; the host wraps it in the SPKI DER
|
|
33
|
+
* prefix itself. Minting happens on first call, which is fine: this runs at boot,
|
|
34
|
+
* before the relay registers.
|
|
35
|
+
*
|
|
36
|
+
* Hosted-only and best-effort: on a user's own machine this is a no-op, and a write
|
|
37
|
+
* failure must never take the daemon down — attestation simply fail-closes host-side
|
|
38
|
+
* with HARBOR_ATTEST_NO_KEY rather than reporting a false "attested".
|
|
39
|
+
*/
|
|
40
|
+
export function publishRelayPub(): void {
|
|
41
|
+
if (!isHosted()) return;
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(join(globalDir(), "relay-pub"), terminalPublicKeyBase64(), { mode: 0o600 });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(
|
|
46
|
+
`[harbor] could not publish relay-pub — enclave attestation will fail closed: ${String(err)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
|
20
20
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
21
21
|
import { makeRoutinesControl } from "../remote/routinesControl.ts";
|
|
22
22
|
import { makeChannelsControl } from "../remote/channelsControl.ts";
|
|
23
|
+
import { makeMcpControl } from "../remote/mcpControl.ts";
|
|
23
24
|
import { makeWorkflowsControl } from "../remote/workflowsControl.ts";
|
|
24
25
|
import { runWorkflow as executeWorkflow, type RunnerDeps, type AgentRunSpec, type AgentRunResult, type ScriptRunResult } from "../workflows/runner.ts";
|
|
25
26
|
import type { Workflow, Step } from "../workflows/schema.ts";
|
|
@@ -50,7 +51,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
|
|
|
50
51
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
51
52
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
52
53
|
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
53
|
-
import { isHosted } from "../config/hosted.ts";
|
|
54
|
+
import { isHosted, publishRelayPub } from "../config/hosted.ts";
|
|
54
55
|
|
|
55
56
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
56
57
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
@@ -190,6 +191,13 @@ export class Daemon {
|
|
|
190
191
|
runningPlatforms: () => readRunningPlatforms(),
|
|
191
192
|
});
|
|
192
193
|
|
|
194
|
+
// App-facing MCP connector management (list/save/set_enabled/remove) over the
|
|
195
|
+
// daemon's relay — the daemon is the Node HOST that actually runs the adapter (a
|
|
196
|
+
// phone/web client can't). Edits the SHARED agent/mcp-desktop.json + mcp.json, so a
|
|
197
|
+
// machine has one MCP config whether it was set from the desktop (IPC) or the phone
|
|
198
|
+
// (relay). Tokens ride in a SEALED box (applyMcpSave) — the relay never sees them.
|
|
199
|
+
private readonly mcp = makeMcpControl();
|
|
200
|
+
|
|
193
201
|
// App-facing workflow management (list/get/save/remove/run) over the daemon's relay.
|
|
194
202
|
// Run-now is injected here since only the daemon owns the runner + its seams. A
|
|
195
203
|
// workflow can carry a `script` step (RCE if forged), so every mutation is
|
|
@@ -219,6 +227,9 @@ export class Daemon {
|
|
|
219
227
|
};
|
|
220
228
|
|
|
221
229
|
start(): void {
|
|
230
|
+
// Hosted only: publish our relay pubkey for the host to bind into the SEV-SNP
|
|
231
|
+
// report. Before syncRelay() so the key exists by the time we're reachable.
|
|
232
|
+
publishRelayPub();
|
|
222
233
|
this.primeSchedule();
|
|
223
234
|
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
224
235
|
this.server = startIpcServer((req) => this.handleIpc(req));
|
|
@@ -287,6 +298,14 @@ export class Daemon {
|
|
|
287
298
|
onChannelsList: () => this.pushChannels(),
|
|
288
299
|
onChannelsSave: (draft, sealedSecrets, sig, ts) => this.pushChannels(this.applyChannelSave(draft, sealedSecrets, sig, ts)),
|
|
289
300
|
onChannelsRemove: (platform, sig, ts) => this.pushChannels(this.guardControl("channels_remove", { platform }, sig, ts, () => this.channels.remove(platform as any).message)),
|
|
301
|
+
// MCP connector management from the app. `save` has its own signed verify (it
|
|
302
|
+
// carries a sealed env box — applyMcpSave); `set_enabled`/`remove` are
|
|
303
|
+
// account-signed here (H2 — a forged toggle arms/disarms a tool surface; a
|
|
304
|
+
// forged removal is a DoS). Then mcpControl writes the shared config + re-pushes.
|
|
305
|
+
onMcpList: () => this.pushMcp(),
|
|
306
|
+
onMcpSave: (draft, sealedSecrets, sig, ts) => this.pushMcp(this.applyMcpSave(draft, sealedSecrets, sig, ts)),
|
|
307
|
+
onMcpSetEnabled: (name, enabled, sig, ts) => this.pushMcp(this.guardControl("mcp_set_enabled", { name, enabled }, sig, ts, () => this.mcp.setEnabled(name, enabled).message)),
|
|
308
|
+
onMcpRemove: (name, sig, ts) => this.pushMcp(this.guardControl("mcp_remove", { name }, sig, ts, () => this.mcp.remove(name).message)),
|
|
290
309
|
// Workflow management from the app. Each MUTATION is account-signed (H2) — a forged
|
|
291
310
|
// workflows_save plants a `script` step that bypasses the permission gate (RCE),
|
|
292
311
|
// and workflows_run executes the graph — so all three are verified (guardControl,
|
|
@@ -340,6 +359,42 @@ export class Daemon {
|
|
|
340
359
|
this.relay?.sendChannels({ items: this.channels.list(), message });
|
|
341
360
|
}
|
|
342
361
|
|
|
362
|
+
private pushMcp(message?: string): void {
|
|
363
|
+
this.relay?.sendMcp({ items: this.mcp.list(), message });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Verify an account-signed MCP save (H2) that also carries a SEALED env box, then
|
|
367
|
+
// apply it. Same shape as applyChannelSave but routed through the generic signed
|
|
368
|
+
// envelope (action "mcp_save", args {draft, sealedSecrets}) — the action tag stops a
|
|
369
|
+
// signature made for any other frame from being replayed as an MCP save. Fail-closed:
|
|
370
|
+
// an unsigned/forged/stale frame returns the refusal message and NOTHING is written.
|
|
371
|
+
// The sealed box opens to { termId, env } — a token the relay never sees in the clear.
|
|
372
|
+
private applyMcpSave(draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number): string | undefined {
|
|
373
|
+
const auth = authorizeControl(
|
|
374
|
+
routineRelayId(),
|
|
375
|
+
"mcp_save",
|
|
376
|
+
{ draft, sealedSecrets: sealedSecrets ?? null },
|
|
377
|
+
sig,
|
|
378
|
+
ts,
|
|
379
|
+
);
|
|
380
|
+
if (!auth.ok) return auth.message;
|
|
381
|
+
|
|
382
|
+
let withEnv = draft;
|
|
383
|
+
if (sealedSecrets) {
|
|
384
|
+
let opened: { termId?: string; env?: Record<string, string> };
|
|
385
|
+
try {
|
|
386
|
+
opened = openJsonFromApp(sealedSecrets);
|
|
387
|
+
} catch {
|
|
388
|
+
return "Couldn't decrypt the connector credentials — they may have been sealed to a different terminal.";
|
|
389
|
+
}
|
|
390
|
+
if (opened.termId !== routineRelayId()) {
|
|
391
|
+
return "These credentials were addressed to a different terminal.";
|
|
392
|
+
}
|
|
393
|
+
withEnv = { ...draft, env: opened.env ?? {} };
|
|
394
|
+
}
|
|
395
|
+
return this.mcp.save(withEnv as any).message;
|
|
396
|
+
}
|
|
397
|
+
|
|
343
398
|
// Push the current workflow summaries to an attached controller (its workflows
|
|
344
399
|
// manager). `message` is a one-line result from the last mutation, if any.
|
|
345
400
|
private pushWorkflows(message?: string): void {
|
|
@@ -600,9 +655,14 @@ export class Daemon {
|
|
|
600
655
|
const config = loadDaemonConfig();
|
|
601
656
|
const modelSpec = routine.model ?? config.defaultModel;
|
|
602
657
|
const split = splitRoutineTools(routine.tools);
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
658
|
+
// MCP tools (server__tool) join the allow-list: the mcpAdapter loaded in runSession
|
|
659
|
+
// registers them from the shared mcp.json, and the routine's SIGNED tool list is the
|
|
660
|
+
// authorization boundary under the bypass gate (same as builtin tools). An http/OAuth
|
|
661
|
+
// connector that never completed its browser flow simply errors at call time.
|
|
662
|
+
const builtinAllow = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
663
|
+
const allowedTools = [...builtinAllow, ...split.mcp];
|
|
664
|
+
if (routine.delivery.includes("email")) {
|
|
665
|
+
log(" note: email delivery is not wired yet (Phase 5) — skipping it");
|
|
606
666
|
}
|
|
607
667
|
|
|
608
668
|
const { out, status, error } = await this.runSession({
|
|
@@ -655,11 +715,19 @@ export class Daemon {
|
|
|
655
715
|
return "deny";
|
|
656
716
|
},
|
|
657
717
|
};
|
|
718
|
+
// MCP adapter (Phase 5): registers the tools from the shared agent/mcp.json — the
|
|
719
|
+
// same projection the app's MCP manager (mcpControl) writes over the relay. No
|
|
720
|
+
// servers configured → a no-op. Dynamically imported so it loads only when a
|
|
721
|
+
// session actually runs (Pi is already booted by here). The specifier is a
|
|
722
|
+
// variable so tsc treats it as Promise<any> and doesn't pull the third-party
|
|
723
|
+
// adapter's own .ts into our typecheck — same intent as the desktop's agentImport.
|
|
724
|
+
const mcpAdapterSpec = "pi-mcp-adapter";
|
|
725
|
+
const { default: mcpAdapter } = await import(mcpAdapterSpec);
|
|
658
726
|
const services = await createAgentSessionServices({
|
|
659
727
|
cwd: spec.cwd,
|
|
660
728
|
agentDir: agentDir(),
|
|
661
729
|
resourceLoaderOptions: {
|
|
662
|
-
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
|
|
730
|
+
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider(), mcpAdapter] as any,
|
|
663
731
|
},
|
|
664
732
|
});
|
|
665
733
|
servicesRef = services as any;
|
|
@@ -724,7 +792,7 @@ export class Daemon {
|
|
|
724
792
|
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
725
793
|
const modelSpec = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
726
794
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
727
|
-
const allowedTools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
795
|
+
const allowedTools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
728
796
|
const title = deriveTaskTitle(spec);
|
|
729
797
|
const key = `task:${title}`;
|
|
730
798
|
if (this.running.has(key)) {
|
|
@@ -904,7 +972,7 @@ export class Daemon {
|
|
|
904
972
|
const config = loadDaemonConfig();
|
|
905
973
|
const model = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
906
974
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
907
|
-
const tools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
975
|
+
const tools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
908
976
|
const { out, status, error } = await this.runSession({ prompt: spec.prompt, cwd: spec.cwd, model, tools });
|
|
909
977
|
let output: Record<string, unknown> = {};
|
|
910
978
|
try { const p = JSON.parse(out.trim()); if (p && typeof p === "object" && !Array.isArray(p)) output = p as Record<string, unknown>; } catch { /* non-JSON → raw text only */ }
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP connector management for the app — the sibling of channelsControl.ts, but for
|
|
3
|
+
* MCP server config rather than messaging channels. It is what lets the phone/web
|
|
4
|
+
* client add, toggle, and remove MCP connectors on a Node HOST it drives over the
|
|
5
|
+
* relay (the daemon today; an interactive terminal by the same shape).
|
|
6
|
+
*
|
|
7
|
+
* The client itself can NEVER run MCP — a browser tab / RN runtime can't spawn a
|
|
8
|
+
* stdio child or hold the adapter. So "serving MCP to phone/web" means MANAGING the
|
|
9
|
+
* config here, on a host that executes it. This control owns that config.
|
|
10
|
+
*
|
|
11
|
+
* SAME FILE MODEL AS THE DESKTOP (treeview/desktop/src/main/mcpService.ts): the
|
|
12
|
+
* source of truth is `${agentDir}/mcp-desktop.json` — every server with an `enabled`
|
|
13
|
+
* flag — and from it we PROJECT the standard `${agentDir}/mcp.json` (enabled servers
|
|
14
|
+
* only, `{mcpServers:{}}` shape) that pi-mcp-adapter reads. Sharing those two files
|
|
15
|
+
* means a machine has ONE coherent MCP config whether it was edited from the desktop
|
|
16
|
+
* over IPC or from the phone over the relay.
|
|
17
|
+
*
|
|
18
|
+
* SECRETS: MCP env values are credentials (GITHUB_PERSONAL_ACCESS_TOKEN, …). Over the
|
|
19
|
+
* untrusted relay they are WRITE-ONLY, exactly like channel bot tokens: list() NEVER
|
|
20
|
+
* returns an env VALUE — only which env keys exist (`envKeys`) and which are non-empty
|
|
21
|
+
* (`secretsSet`), by name. save() persists whatever env VALUES it is handed in
|
|
22
|
+
* `draft.env`; the seal/open of those values in transit is the caller's job (the
|
|
23
|
+
* daemon opens a sealed-box addressed to its terminal, mirroring applyChannelSave), so
|
|
24
|
+
* this module only ever deals in the plaintext files it already owns.
|
|
25
|
+
*
|
|
26
|
+
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
27
|
+
* frame plumbing and the sealed-secret open.
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
30
|
+
import { join, dirname } from "node:path";
|
|
31
|
+
import { agentDir } from "../config/paths.ts";
|
|
32
|
+
|
|
33
|
+
export type McpTransport = "stdio" | "http";
|
|
34
|
+
|
|
35
|
+
// One server as stored in the source file (mcp-desktop.json). Mirrors the desktop's
|
|
36
|
+
// SourceEntry: the standard fields the adapter needs plus our `enabled` flag.
|
|
37
|
+
interface SourceEntry {
|
|
38
|
+
transport?: McpTransport;
|
|
39
|
+
command?: string;
|
|
40
|
+
args?: string[];
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
url?: string;
|
|
43
|
+
oauth?: boolean;
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
}
|
|
46
|
+
interface SourceFile {
|
|
47
|
+
servers: Record<string, SourceEntry>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Non-secret projection of one server, sent to the app. No env VALUES, ever — only
|
|
51
|
+
// which env keys exist and which are set (`secretsSet`). `host` is surfaced for the
|
|
52
|
+
// app's privacy badge ("Sends data to <host>" for http; stdio runs locally).
|
|
53
|
+
export interface RemoteMcpServer {
|
|
54
|
+
name: string;
|
|
55
|
+
transport: McpTransport;
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
command?: string; // stdio: the launch binary (not a secret — e.g. "npx")
|
|
58
|
+
argsPreview?: string; // stdio: args joined, for a one-line summary
|
|
59
|
+
url?: string; // http: the endpoint (not a secret; the vendor host)
|
|
60
|
+
host?: string; // http: parsed host for the privacy badge
|
|
61
|
+
oauth: boolean; // http servers negotiate OAuth; stdio never does
|
|
62
|
+
envKeys: string[]; // env var NAMES only (e.g. ["GITHUB_PERSONAL_ACCESS_TOKEN"])
|
|
63
|
+
secretsSet: string[]; // subset of envKeys whose value is non-empty — names only
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// An app-submitted edit. Non-secret fields REPLACE when present; `env` maps a var
|
|
67
|
+
// name → its (already-opened) value, and only present, non-empty values overwrite —
|
|
68
|
+
// an omitted key keeps the existing value (so a re-save without re-typing the token
|
|
69
|
+
// preserves it, matching the channels-manager rule).
|
|
70
|
+
export interface McpDraft {
|
|
71
|
+
name: string;
|
|
72
|
+
transport?: McpTransport;
|
|
73
|
+
command?: string;
|
|
74
|
+
args?: string[];
|
|
75
|
+
url?: string;
|
|
76
|
+
oauth?: boolean;
|
|
77
|
+
env?: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface McpControl {
|
|
81
|
+
// Every managed server, non-secret projection. Enabled or not — the app shows
|
|
82
|
+
// disabled connectors so they can be toggled back on.
|
|
83
|
+
list(): RemoteMcpServer[];
|
|
84
|
+
// Create or edit a server. Validates transport ⟷ required field (stdio→command,
|
|
85
|
+
// http→url). Returns a one-line result. Re-projects mcp.json on success.
|
|
86
|
+
save(draft: McpDraft): { ok: boolean; message?: string };
|
|
87
|
+
// Enable/disable a server (re-projects). ok:false when the name is unknown.
|
|
88
|
+
setEnabled(name: string, enabled: boolean): { ok: boolean; message?: string };
|
|
89
|
+
// Delete a server entirely (re-projects). ok:false when nothing was configured.
|
|
90
|
+
remove(name: string): { ok: boolean; message?: string };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const TRANSPORTS: readonly McpTransport[] = ["stdio", "http"];
|
|
94
|
+
function isTransport(v: unknown): v is McpTransport {
|
|
95
|
+
return typeof v === "string" && TRANSPORTS.includes(v as McpTransport);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hostOf(url: string): string | undefined {
|
|
99
|
+
try {
|
|
100
|
+
return new URL(url).host || undefined;
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cleanArgs(v: unknown): string[] | undefined {
|
|
107
|
+
if (!Array.isArray(v)) return undefined;
|
|
108
|
+
return v.map((x) => String(x ?? "")).filter((s) => s.length > 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function makeMcpControl(opts?: {
|
|
112
|
+
// Override the source/projection dir (tests). Defaults to the shared agent dir, so
|
|
113
|
+
// this control and the desktop's mcpService edit the SAME two files.
|
|
114
|
+
dir?: () => string;
|
|
115
|
+
}): McpControl {
|
|
116
|
+
const dir = opts?.dir ?? agentDir;
|
|
117
|
+
const sourcePath = () => join(dir(), "mcp-desktop.json");
|
|
118
|
+
const projectionPath = () => join(dir(), "mcp.json");
|
|
119
|
+
|
|
120
|
+
function readSource(): SourceFile {
|
|
121
|
+
// Seed from an existing standard mcp.json on first run (a machine that already
|
|
122
|
+
// had connectors before this control existed), so nothing is silently dropped.
|
|
123
|
+
try {
|
|
124
|
+
const raw = JSON.parse(readFileSync(sourcePath(), "utf8"));
|
|
125
|
+
if (raw && typeof raw === "object" && raw.servers) return { servers: raw.servers };
|
|
126
|
+
} catch {
|
|
127
|
+
/* fall through to seed */
|
|
128
|
+
}
|
|
129
|
+
const servers: Record<string, SourceEntry> = {};
|
|
130
|
+
try {
|
|
131
|
+
const proj = JSON.parse(readFileSync(projectionPath(), "utf8"));
|
|
132
|
+
for (const [name, entry] of Object.entries(proj?.mcpServers ?? {})) {
|
|
133
|
+
servers[name] = { ...(entry as SourceEntry), enabled: true };
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
/* no prior config */
|
|
137
|
+
}
|
|
138
|
+
return { servers };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeSource(src: SourceFile): void {
|
|
142
|
+
mkdirSync(dirname(sourcePath()), { recursive: true });
|
|
143
|
+
writeFileSync(sourcePath(), JSON.stringify(src, null, 2) + "\n");
|
|
144
|
+
project(src);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Project the enabled servers into the standard mcp.json the adapter reads. An
|
|
148
|
+
// entry with no explicit transport is treated as stdio if it has a command, http
|
|
149
|
+
// if it has a url — matching the adapter's own inference.
|
|
150
|
+
function project(src: SourceFile): void {
|
|
151
|
+
const mcpServers: Record<string, unknown> = {};
|
|
152
|
+
for (const [name, e] of Object.entries(src.servers)) {
|
|
153
|
+
if (e.enabled === false) continue;
|
|
154
|
+
const { enabled, ...std } = e;
|
|
155
|
+
mcpServers[name] = std;
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(dirname(projectionPath()), { recursive: true });
|
|
158
|
+
writeFileSync(projectionPath(), JSON.stringify({ mcpServers }, null, 2) + "\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toRemote(name: string, e: SourceEntry): RemoteMcpServer {
|
|
162
|
+
const transport: McpTransport = e.transport ?? (e.url ? "http" : "stdio");
|
|
163
|
+
const env = e.env ?? {};
|
|
164
|
+
const envKeys = Object.keys(env);
|
|
165
|
+
return {
|
|
166
|
+
name,
|
|
167
|
+
transport,
|
|
168
|
+
enabled: e.enabled !== false,
|
|
169
|
+
command: transport === "stdio" ? e.command : undefined,
|
|
170
|
+
argsPreview: transport === "stdio" && e.args?.length ? e.args.join(" ") : undefined,
|
|
171
|
+
url: transport === "http" ? e.url : undefined,
|
|
172
|
+
host: transport === "http" && e.url ? hostOf(e.url) : undefined,
|
|
173
|
+
// http servers negotiate OAuth; stdio never does (matches mcpService.list()).
|
|
174
|
+
oauth: transport === "http",
|
|
175
|
+
envKeys,
|
|
176
|
+
secretsSet: envKeys.filter((k) => String(env[k] ?? "").length > 0),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
list(): RemoteMcpServer[] {
|
|
182
|
+
const src = readSource();
|
|
183
|
+
return Object.entries(src.servers).map(([name, e]) => toRemote(name, e));
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
save(draft: McpDraft): { ok: boolean; message?: string } {
|
|
187
|
+
const name = String(draft?.name ?? "").trim();
|
|
188
|
+
if (!name) return { ok: false, message: "A connector needs a name." };
|
|
189
|
+
if (draft.transport !== undefined && !isTransport(draft.transport))
|
|
190
|
+
return { ok: false, message: "Unknown transport." };
|
|
191
|
+
|
|
192
|
+
const src = readSource();
|
|
193
|
+
const prev: SourceEntry = src.servers[name] ?? {};
|
|
194
|
+
const entry: SourceEntry = { ...prev };
|
|
195
|
+
|
|
196
|
+
const transport: McpTransport =
|
|
197
|
+
(draft.transport as McpTransport) ?? prev.transport ?? (draft.url || prev.url ? "http" : "stdio");
|
|
198
|
+
entry.transport = transport;
|
|
199
|
+
|
|
200
|
+
if (transport === "stdio") {
|
|
201
|
+
if (draft.command !== undefined) entry.command = String(draft.command).trim();
|
|
202
|
+
const args = cleanArgs(draft.args);
|
|
203
|
+
if (args !== undefined) entry.args = args;
|
|
204
|
+
// A stdio server can't reach a url and never does OAuth — clear stale fields.
|
|
205
|
+
delete entry.url;
|
|
206
|
+
delete entry.oauth;
|
|
207
|
+
if (!entry.command) return { ok: false, message: "A local (stdio) connector needs a command." };
|
|
208
|
+
} else {
|
|
209
|
+
if (draft.url !== undefined) entry.url = String(draft.url).trim();
|
|
210
|
+
if (draft.oauth !== undefined) entry.oauth = !!draft.oauth;
|
|
211
|
+
delete entry.command;
|
|
212
|
+
delete entry.args;
|
|
213
|
+
if (!entry.url) return { ok: false, message: "A remote (http) connector needs a URL." };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Env/secrets: a present, non-empty value overwrites; an omitted key keeps the
|
|
217
|
+
// existing value (re-save without re-typing the token preserves it). An explicit
|
|
218
|
+
// empty string clears that key.
|
|
219
|
+
if (draft.env !== undefined) {
|
|
220
|
+
const merged: Record<string, string> = { ...(prev.env ?? {}) };
|
|
221
|
+
for (const [k, v] of Object.entries(draft.env)) {
|
|
222
|
+
const key = String(k).trim();
|
|
223
|
+
if (!key) continue;
|
|
224
|
+
const val = String(v ?? "");
|
|
225
|
+
if (val.length > 0) merged[key] = val;
|
|
226
|
+
else delete merged[key];
|
|
227
|
+
}
|
|
228
|
+
if (Object.keys(merged).length > 0) entry.env = merged;
|
|
229
|
+
else delete entry.env;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A brand-new server comes up enabled; an edit preserves the prior flag.
|
|
233
|
+
entry.enabled = prev.enabled ?? true;
|
|
234
|
+
|
|
235
|
+
src.servers[name] = entry;
|
|
236
|
+
try {
|
|
237
|
+
writeSource(src);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
|
|
240
|
+
}
|
|
241
|
+
return { ok: true, message: `Saved "${name}".` };
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
setEnabled(name: string, enabled: boolean): { ok: boolean; message?: string } {
|
|
245
|
+
const src = readSource();
|
|
246
|
+
if (!src.servers[name]) return { ok: false, message: "No such connector." };
|
|
247
|
+
src.servers[name].enabled = !!enabled;
|
|
248
|
+
try {
|
|
249
|
+
writeSource(src);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, message: `${enabled ? "Enabled" : "Disabled"} "${name}".` };
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
remove(name: string): { ok: boolean; message?: string } {
|
|
257
|
+
const src = readSource();
|
|
258
|
+
if (!src.servers[name]) return { ok: false, message: "Not configured." };
|
|
259
|
+
delete src.servers[name];
|
|
260
|
+
try {
|
|
261
|
+
writeSource(src);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, message: `Removed "${name}".` };
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -182,6 +182,22 @@ export interface RelayCallbacks {
|
|
|
182
182
|
// The app asked to delete a platform's channel config, by platform name. Signed
|
|
183
183
|
// (H2) — a forged removal is a DoS (the bot stops until re-added).
|
|
184
184
|
onChannelsRemove?: (platform: string, sig?: string, ts?: number) => void;
|
|
185
|
+
// The app opened the MCP connectors manager — reply with the current MCP config
|
|
186
|
+
// (a sendMcp frame). Owned by the daemon (the host that runs the adapter), so these
|
|
187
|
+
// only fire on its relay. Read-only, so unsigned.
|
|
188
|
+
onMcpList?: () => void;
|
|
189
|
+
// The app asked to create/edit an MCP connector. `draft` carries only NON-secret
|
|
190
|
+
// fields (name/transport/command/args/url/oauth). `sealedSecrets`, when present, is
|
|
191
|
+
// a base64 sealed-box the app sealed to THIS terminal's pinned pubkey, opening to
|
|
192
|
+
// `{ termId, env: {NAME: value} }` — the connector's credential env. `sig`+`ts`
|
|
193
|
+
// authenticate the WHOLE save with the pinned account key (same shape as
|
|
194
|
+
// channels_save), so a hostile relay can neither forge a token nor inject a command.
|
|
195
|
+
onMcpSave?: (draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number) => void;
|
|
196
|
+
// The app asked to enable/disable a connector by name. Signed (H2) — a forged toggle
|
|
197
|
+
// silently arms/disarms a tool surface. Idempotent (non-strict ts).
|
|
198
|
+
onMcpSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
|
|
199
|
+
// The app asked to delete a connector by name. Signed (H2) — a forged removal is a DoS.
|
|
200
|
+
onMcpRemove?: (name: string, sig?: string, ts?: number) => void;
|
|
185
201
|
// The app opened the workflows manager — reply with the current workflow summaries
|
|
186
202
|
// (a sendWorkflows frame). Owned by the daemon, so these only fire on its relay.
|
|
187
203
|
onWorkflowsList?: () => void;
|
|
@@ -519,6 +535,27 @@ export class RelayClient {
|
|
|
519
535
|
case "channels_remove":
|
|
520
536
|
if (typeof frame.platform === "string") this.cb.onChannelsRemove?.(frame.platform, sig(frame), tsOf(frame));
|
|
521
537
|
break;
|
|
538
|
+
case "mcp_list":
|
|
539
|
+
this.cb.onMcpList?.();
|
|
540
|
+
break;
|
|
541
|
+
case "mcp_save":
|
|
542
|
+
// The connector rides in `draft` (same slot as channels_save), untyped — the
|
|
543
|
+
// daemon strict-validates it via mcpControl.save after the signature check.
|
|
544
|
+
if (frame.draft && typeof frame.draft === "object") {
|
|
545
|
+
this.cb.onMcpSave?.(
|
|
546
|
+
frame.draft,
|
|
547
|
+
typeof frame.sealedSecrets === "string" ? frame.sealedSecrets : undefined,
|
|
548
|
+
sig(frame),
|
|
549
|
+
tsOf(frame),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
break;
|
|
553
|
+
case "mcp_set_enabled":
|
|
554
|
+
if (typeof frame.name === "string") this.cb.onMcpSetEnabled?.(frame.name, frame.enabled === true, sig(frame), tsOf(frame));
|
|
555
|
+
break;
|
|
556
|
+
case "mcp_remove":
|
|
557
|
+
if (typeof frame.name === "string") this.cb.onMcpRemove?.(frame.name, sig(frame), tsOf(frame));
|
|
558
|
+
break;
|
|
522
559
|
case "workflows_list":
|
|
523
560
|
this.cb.onWorkflowsList?.();
|
|
524
561
|
break;
|
|
@@ -875,6 +912,47 @@ export class RelayClient {
|
|
|
875
912
|
});
|
|
876
913
|
}
|
|
877
914
|
|
|
915
|
+
// Push the host's MCP connectors to the app's MCP manager. Sent on request and after
|
|
916
|
+
// each save/set_enabled/remove. Like sendChannels this is the user's OWN config echoed
|
|
917
|
+
// to their OWN app — but an env VALUE (a token) NEVER crosses this wire: only `envKeys`
|
|
918
|
+
// (names) and `secretsSet` (which of those are non-empty, by name) are sent, so a relay
|
|
919
|
+
// / server compromise can't lift a credential from this frame. List bounded like the
|
|
920
|
+
// other managers.
|
|
921
|
+
sendMcp(payload: {
|
|
922
|
+
items: {
|
|
923
|
+
name: string;
|
|
924
|
+
transport: string;
|
|
925
|
+
enabled: boolean;
|
|
926
|
+
command?: string;
|
|
927
|
+
argsPreview?: string;
|
|
928
|
+
url?: string;
|
|
929
|
+
host?: string;
|
|
930
|
+
oauth: boolean;
|
|
931
|
+
envKeys: string[];
|
|
932
|
+
secretsSet: string[];
|
|
933
|
+
}[];
|
|
934
|
+
busy?: boolean;
|
|
935
|
+
message?: string;
|
|
936
|
+
}): void {
|
|
937
|
+
this.rawSend({
|
|
938
|
+
type: "mcp",
|
|
939
|
+
items: payload.items.slice(0, 50).map((m) => ({
|
|
940
|
+
name: clip(String(m.name), 128),
|
|
941
|
+
transport: m.transport === "http" ? "http" : "stdio",
|
|
942
|
+
enabled: !!m.enabled,
|
|
943
|
+
command: m.command ? clip(m.command, 200) : undefined,
|
|
944
|
+
argsPreview: m.argsPreview ? clip(m.argsPreview, 500) : undefined,
|
|
945
|
+
url: m.url ? clip(m.url, 500) : undefined,
|
|
946
|
+
host: m.host ? clip(m.host, 200) : undefined,
|
|
947
|
+
oauth: !!m.oauth,
|
|
948
|
+
envKeys: (m.envKeys ?? []).slice(0, 30).map((k) => clip(String(k), 128)),
|
|
949
|
+
secretsSet: (m.secretsSet ?? []).slice(0, 30).map((k) => clip(String(k), 128)),
|
|
950
|
+
})),
|
|
951
|
+
busy: !!payload.busy,
|
|
952
|
+
message: payload.message ? clip(payload.message, 500) : undefined,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
878
956
|
// Push the daemon's saved workflows to the app's workflows manager as SUMMARIES (not
|
|
879
957
|
// the full graphs — the editor fetches one at a time via sendWorkflow). Sent on request
|
|
880
958
|
// and after each save/remove/run. The user's OWN config echoed to their OWN app, so
|
|
@@ -158,6 +158,13 @@ export class RemoteBridge {
|
|
|
158
158
|
onChannelsList: () => {},
|
|
159
159
|
onChannelsSave: () => {},
|
|
160
160
|
onChannelsRemove: () => {},
|
|
161
|
+
// MCP connectors, like channels, are managed on the daemon (the host that runs the
|
|
162
|
+
// adapter) — the daemon's own relay handles mcp_*. These no-ops just satisfy Required;
|
|
163
|
+
// an interactive terminal manages MCP over IPC (desktop), never over this relay.
|
|
164
|
+
onMcpList: () => {},
|
|
165
|
+
onMcpSave: () => {},
|
|
166
|
+
onMcpSetEnabled: () => {},
|
|
167
|
+
onMcpRemove: () => {},
|
|
161
168
|
// Workflows, like routines/channels, are daemon-owned — the daemon's own relay handles
|
|
162
169
|
// workflows_*. These no-ops just satisfy Required; an interactive terminal never
|
|
163
170
|
// surfaces workflows.
|
package/src/routines/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
|
|
3
4
|
// Where a routine's result is delivered after it runs. `file`/`relay`/`notice` stay
|
|
@@ -79,6 +80,13 @@ export const RoutineFile = z.object({
|
|
|
79
80
|
export type RoutineFile = z.infer<typeof RoutineFile>;
|
|
80
81
|
|
|
81
82
|
// A time-ordered routine id minted once at creation.
|
|
83
|
+
//
|
|
84
|
+
// The random suffix is load-bearing, not decoration. This was `r-${Date.now()}` alone,
|
|
85
|
+
// so two routines created in the SAME MILLISECOND got the same id — and upsertRoutine
|
|
86
|
+
// keys on id, so the second silently overwrote the first. Creating routines in quick
|
|
87
|
+
// succession (an import, a scripted setup, a fast tap-tap in the app) could therefore
|
|
88
|
+
// lose one with no error anywhere. The timestamp prefix still sorts by creation order;
|
|
89
|
+
// the suffix just makes collisions vanishingly unlikely.
|
|
82
90
|
export function newRoutineId(): string {
|
|
83
|
-
return `r-${Date.now()}`;
|
|
91
|
+
return `r-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
84
92
|
}
|