privateer-agent 0.6.4 → 0.6.7
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 +183 -22
- 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/auth/accountSessions.ts +154 -0
- package/src/auth/privateer.ts +131 -19
- package/src/cli/chat.ts +1 -1
- package/src/config/hosted.ts +36 -0
- package/src/config/paths.ts +9 -0
- package/src/daemon/index.ts +77 -9
- package/src/providers/account.ts +59 -4
- 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/src/providers/account.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
hasCredentials,
|
|
16
16
|
runDeviceLogin,
|
|
17
17
|
authedFetch,
|
|
18
|
-
|
|
18
|
+
acquireAccountCredential,
|
|
19
19
|
refreshAccountCredentials,
|
|
20
20
|
notifySignedIn,
|
|
21
21
|
} from "../auth/privateer.ts";
|
|
@@ -169,7 +169,7 @@ export const privateerOAuthProvider = {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
172
|
-
const creds = await
|
|
172
|
+
const creds = await acquireAccountCredential();
|
|
173
173
|
// Seed Pi's saved model default to the account channel, so the next launch resolves
|
|
174
174
|
// to a billable subscription model instead of falling through to a keyless built-in
|
|
175
175
|
// (the "No API key found for openrouter" trap). No-op if the user already has a
|
|
@@ -184,8 +184,10 @@ export const privateerOAuthProvider = {
|
|
|
184
184
|
try {
|
|
185
185
|
return await refreshAccountCredentials(creds.refresh);
|
|
186
186
|
} catch {
|
|
187
|
-
//
|
|
188
|
-
|
|
187
|
+
// Child token expired/reused → get another. acquire (not spawn) so a terminal
|
|
188
|
+
// that already holds the device's last session slot can reclaim an orphan
|
|
189
|
+
// instead of being refused a fresh one mid-session.
|
|
190
|
+
return acquireAccountCredential();
|
|
189
191
|
}
|
|
190
192
|
},
|
|
191
193
|
getApiKey(creds: { access: string }): string {
|
|
@@ -256,6 +258,7 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
256
258
|
export function makeAccountProvider() {
|
|
257
259
|
return (pi: {
|
|
258
260
|
registerProvider?: (name: string, config: unknown) => void;
|
|
261
|
+
on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
|
|
259
262
|
}): void => {
|
|
260
263
|
if (typeof pi.registerProvider !== "function") return;
|
|
261
264
|
const register = (ids: string[]): void =>
|
|
@@ -274,5 +277,57 @@ export function makeAccountProvider() {
|
|
|
274
277
|
.catch(() => {
|
|
275
278
|
/* keep the fallback model */
|
|
276
279
|
});
|
|
280
|
+
|
|
281
|
+
// Seed the account channel's credential at launch. Nothing else does this in the
|
|
282
|
+
// TUI: Pi only obtains an OAuth credential by running /login, and our shutdown
|
|
283
|
+
// hook deliberately REVOKES the account session and deletes its persisted
|
|
284
|
+
// auth.json entry (see the LIFECYCLE HAZARD note in src/auth/privateer.ts). So a
|
|
285
|
+
// signed-in user who quits and relaunches lands on privateer/* with no key at
|
|
286
|
+
// all, and the first prompt dead-ends on "No API key found for privateer." — even
|
|
287
|
+
// though the banner says "connected". The REPL (cli/chat.ts) and the daemon
|
|
288
|
+
// already spawn one at startup; this gives the TUI the same seed.
|
|
289
|
+
pi.on?.("session_start", (_e, ctx) => void ensureAccountCredential(ctx));
|
|
277
290
|
};
|
|
278
291
|
}
|
|
292
|
+
|
|
293
|
+
// One spawn per PROCESS. session_start also fires for new/resume/fork/reload — all of
|
|
294
|
+
// which keep this process (and its account session) alive — so re-spawning there would
|
|
295
|
+
// leak a device row per event. A fresh process always spawns: a run that crashed
|
|
296
|
+
// without its shutdown hook can leave a REVOKED credential persisted in auth.json with
|
|
297
|
+
// a still-valid-looking `expires`, which Pi would happily reuse and 401 on.
|
|
298
|
+
//
|
|
299
|
+
// The flag lives on globalThis, not in module scope, because jiti gives each extension
|
|
300
|
+
// that imports this file its OWN module instance (see the note in auth/privateer.ts):
|
|
301
|
+
// privateer-account and privateer-brand — which hot-registers the provider on /signin —
|
|
302
|
+
// would otherwise hold separate flags and each spawn a session.
|
|
303
|
+
const SEEDED = Symbol.for("privateer.accountCredentialSeeded");
|
|
304
|
+
type SeedFlag = { [SEEDED]?: boolean };
|
|
305
|
+
|
|
306
|
+
// `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
|
|
307
|
+
// path privateer-brand uses to DROP the credential on sign-out).
|
|
308
|
+
type SeedContext = {
|
|
309
|
+
modelRegistry?: { authStorage?: { set?: (provider: string, cred: unknown) => void } };
|
|
310
|
+
hasUI?: boolean;
|
|
311
|
+
ui?: { notify?: (message: string, level: string) => void };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
async function ensureAccountCredential(ctx: unknown): Promise<void> {
|
|
315
|
+
const flag = globalThis as SeedFlag;
|
|
316
|
+
if (flag[SEEDED] || !hasCredentials()) return;
|
|
317
|
+
flag[SEEDED] = true;
|
|
318
|
+
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
319
|
+
if (typeof store?.set !== "function") return;
|
|
320
|
+
try {
|
|
321
|
+
const creds = await acquireAccountCredential();
|
|
322
|
+
store.set("privateer", { type: "oauth", ...creds });
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// The account channel is NOT armed: a dead machine login (401 → credentials cleared
|
|
325
|
+
// + onSessionExpired), the terminal cap (429), or a network blip. Say so now — the
|
|
326
|
+
// banner still reads "connected" (it only knows about the local credentials file),
|
|
327
|
+
// so staying silent leaves the user to discover it as a bare "No API key found for
|
|
328
|
+
// privateer" on their first prompt. Cleared so a later attempt can retry.
|
|
329
|
+
flag[SEEDED] = false;
|
|
330
|
+
const c = ctx as SeedContext;
|
|
331
|
+
if (c?.hasUI) c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -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
|
}
|