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 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
+ }
@@ -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 pi = ver(path.join(REPO, "node_modules", "@earendil-works", "pi-coding-agent", "package.json"));
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
- const dep = (...p) => path.join(REPO, "node_modules", ...p);
146
- const shim = (name, target) =>
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", "rpiv-web-tools", "index.ts")); // private web tools
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", "pi-hypa", "extensions", "index.ts"));
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
- const CLI = dep("@earendil-works", "pi-coding-agent", "dist", "cli.js");
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.4",
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"
@@ -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
+ }
@@ -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
- const allowedTools = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
604
- if (split.mcp.length > 0 || routine.delivery.includes("email")) {
605
- log(" note: MCP tools + email delivery are not wired yet (Phase 5) skipping those");
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.
@@ -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
  }