privateer-agent 0.8.2 → 0.9.1
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/extensions/privateer-brand.ts +24 -11
- package/extensions/privateer-connect.ts +135 -10
- package/extensions/privateer-privacy.ts +20 -8
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -1
- package/src/auth/privateer.ts +24 -5
- package/src/channels/run.ts +18 -1
- package/src/cli/chat.ts +11 -2
- package/src/config/hosted.ts +21 -0
- package/src/harbor/index.ts +317 -77
- package/src/harbor/ipc.ts +50 -0
- package/src/harbor/service.ts +56 -8
- package/src/mcp/catalog.ts +32 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/providers/account.ts +364 -28
- package/src/remote/liveTaskSession.ts +11 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +124 -8
- package/src/remote/routinesControl.ts +1 -1
- package/src/routines/schema.ts +2 -0
- package/src/routines/store.ts +1 -1
- package/src/routines/toolSelect.ts +13 -19
- package/src/tools/web.ts +236 -0
package/src/remote/mcpControl.ts
CHANGED
|
@@ -15,13 +15,22 @@
|
|
|
15
15
|
* means a machine has ONE coherent MCP config whether it was edited from the desktop
|
|
16
16
|
* over IPC or from the phone over the relay.
|
|
17
17
|
*
|
|
18
|
-
* SECRETS: MCP env values are credentials (GITHUB_PERSONAL_ACCESS_TOKEN, …)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
* SECRETS: MCP env values are credentials (GITHUB_PERSONAL_ACCESS_TOKEN, …), and so
|
|
19
|
+
* are a `bearerToken` and every HTTP header VALUE (an `Authorization:` header is a
|
|
20
|
+
* credential by construction). Over the untrusted relay all three are WRITE-ONLY,
|
|
21
|
+
* exactly like channel bot tokens: list() NEVER returns one — only which keys exist
|
|
22
|
+
* (`envKeys` / `headerKeys`) and which are non-empty (`secretsSet` / `headersSet`),
|
|
23
|
+
* by name, plus a `bearerTokenSet` boolean. `bearerTokenEnv` IS returned: it is a
|
|
24
|
+
* variable NAME, not a value. save() persists whatever values it is handed; the
|
|
25
|
+
* seal/open of those values in transit is the caller's job (the harbor opens a
|
|
26
|
+
* sealed-box addressed to its terminal, mirroring applyChannelSave, and REFUSES a
|
|
27
|
+
* secret that arrived unsealed), so this module only ever deals in the plaintext files
|
|
28
|
+
* it already owns.
|
|
29
|
+
*
|
|
30
|
+
* SCOPE — bearer tokens are a LOCAL/DESKTOP capability. A hosted (Harbor) agent gets
|
|
31
|
+
* OAuth connectors only, because its home is tmpfs and a durable secret would have to
|
|
32
|
+
* rest somewhere we can read; see treeview/docs/HARBOR_CONNECTORS_PLAN.md §2, decided
|
|
33
|
+
* Option B. Nothing here may become the mechanism for storing a hosted credential.
|
|
25
34
|
*
|
|
26
35
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
27
36
|
* frame plumbing and the sealed-secret open.
|
|
@@ -32,6 +41,13 @@ import { agentDir } from "../config/paths.ts";
|
|
|
32
41
|
|
|
33
42
|
export type McpTransport = "stdio" | "http";
|
|
34
43
|
|
|
44
|
+
/**
|
|
45
|
+
* How an HTTP connector authenticates — our vocabulary, projected onto the adapter's
|
|
46
|
+
* `auth` field. "none" is a server that needs no credential at all (or carries one
|
|
47
|
+
* entirely in custom headers); stdio connectors are always "none".
|
|
48
|
+
*/
|
|
49
|
+
export type McpAuth = "oauth" | "bearer" | "none";
|
|
50
|
+
|
|
35
51
|
// One server as stored in the source file (mcp-desktop.json). Mirrors the desktop's
|
|
36
52
|
// SourceEntry: the standard fields the adapter needs plus our `enabled` flag.
|
|
37
53
|
interface SourceEntry {
|
|
@@ -40,6 +56,13 @@ interface SourceEntry {
|
|
|
40
56
|
args?: string[];
|
|
41
57
|
env?: Record<string, string>;
|
|
42
58
|
url?: string;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
auth?: McpAuth;
|
|
61
|
+
bearerToken?: string;
|
|
62
|
+
bearerTokenEnv?: string;
|
|
63
|
+
// LEGACY, read-only: entries written before `auth` existed carry a boolean here.
|
|
64
|
+
// authOf() folds it in; nothing new ever writes it, and project() never emits it
|
|
65
|
+
// (the adapter's own `oauth` field is an OAuthConfig object, not a boolean).
|
|
43
66
|
oauth?: boolean;
|
|
44
67
|
enabled?: boolean;
|
|
45
68
|
}
|
|
@@ -47,6 +70,30 @@ interface SourceFile {
|
|
|
47
70
|
servers: Record<string, SourceEntry>;
|
|
48
71
|
}
|
|
49
72
|
|
|
73
|
+
const AUTHS: readonly McpAuth[] = ["oauth", "bearer", "none"];
|
|
74
|
+
function isAuth(v: unknown): v is McpAuth {
|
|
75
|
+
return typeof v === "string" && AUTHS.includes(v as McpAuth);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// What this entry will actually do when it connects. Explicit `auth` wins; a bearer
|
|
79
|
+
// token implies bearer; a legacy `oauth: false` means no auth; otherwise HTTP servers
|
|
80
|
+
// auto-detect OAuth, which is the adapter's own default.
|
|
81
|
+
//
|
|
82
|
+
// The headers rule mirrors pi-mcp-adapter's supportsOAuth(): "configured custom headers
|
|
83
|
+
// take precedence over implicit OAuth auto-detection." Without this we'd project an
|
|
84
|
+
// EXPLICIT auth:"oauth" for a headers-carrying entry, and the adapter checks
|
|
85
|
+
// auth === "oauth" BEFORE its headers check — so we'd force OAuth on exactly the
|
|
86
|
+
// connectors it means to skip it for. Only implicit auth defers to headers; an explicit
|
|
87
|
+
// `auth` from the user still wins.
|
|
88
|
+
function authOf(e: SourceEntry, transport: McpTransport): McpAuth {
|
|
89
|
+
if (transport !== "http") return "none";
|
|
90
|
+
if (isAuth(e.auth)) return e.auth;
|
|
91
|
+
if (e.bearerToken || e.bearerTokenEnv) return "bearer";
|
|
92
|
+
if (e.oauth === false) return "none";
|
|
93
|
+
if (e.headers && Object.keys(e.headers).length > 0) return "none";
|
|
94
|
+
return "oauth";
|
|
95
|
+
}
|
|
96
|
+
|
|
50
97
|
// Non-secret projection of one server, sent to the app. No env VALUES, ever — only
|
|
51
98
|
// which env keys exist and which are set (`secretsSet`). `host` is surfaced for the
|
|
52
99
|
// app's privacy badge ("Sends data to <host>" for http; stdio runs locally).
|
|
@@ -58,23 +105,36 @@ export interface RemoteMcpServer {
|
|
|
58
105
|
argsPreview?: string; // stdio: args joined, for a one-line summary
|
|
59
106
|
url?: string; // http: the endpoint (not a secret; the vendor host)
|
|
60
107
|
host?: string; // http: parsed host for the privacy badge
|
|
61
|
-
|
|
108
|
+
auth: McpAuth; // what this connector will actually do to authenticate
|
|
109
|
+
// Kept for app builds that predate `auth`. True only for a real OAuth connector —
|
|
110
|
+
// it used to mean "is an http server", which claimed OAuth for bearer/no-auth ones.
|
|
111
|
+
oauth: boolean;
|
|
62
112
|
envKeys: string[]; // env var NAMES only (e.g. ["GITHUB_PERSONAL_ACCESS_TOKEN"])
|
|
63
113
|
secretsSet: string[]; // subset of envKeys whose value is non-empty — names only
|
|
114
|
+
headerKeys: string[]; // http: header NAMES only — values are credentials
|
|
115
|
+
headersSet: string[]; // subset of headerKeys whose value is non-empty — names only
|
|
116
|
+
bearerTokenSet: boolean; // http: a static bearer token is stored (never its value)
|
|
117
|
+
bearerTokenEnv?: string; // http: the env var the token is read from — a NAME, safe
|
|
64
118
|
}
|
|
65
119
|
|
|
66
|
-
// An app-submitted edit. Non-secret fields REPLACE when present; `env`
|
|
67
|
-
//
|
|
68
|
-
// an omitted key keeps the existing value (so a re-save without re-typing
|
|
69
|
-
// preserves it, matching the channels-manager rule)
|
|
120
|
+
// An app-submitted edit. Non-secret fields REPLACE when present; `env` and `headers`
|
|
121
|
+
// map a key → its (already-opened) value, and only present, non-empty values
|
|
122
|
+
// overwrite — an omitted key keeps the existing value (so a re-save without re-typing
|
|
123
|
+
// the token preserves it, matching the channels-manager rule), while an explicit
|
|
124
|
+
// empty string clears it. `bearerToken` follows the same rule as a single value.
|
|
70
125
|
export interface McpDraft {
|
|
71
126
|
name: string;
|
|
72
127
|
transport?: McpTransport;
|
|
73
128
|
command?: string;
|
|
74
129
|
args?: string[];
|
|
75
130
|
url?: string;
|
|
131
|
+
auth?: McpAuth;
|
|
132
|
+
// Legacy alias for `auth` — true → "oauth", false → "none". `auth` wins if both.
|
|
76
133
|
oauth?: boolean;
|
|
77
134
|
env?: Record<string, string>;
|
|
135
|
+
headers?: Record<string, string>;
|
|
136
|
+
bearerToken?: string;
|
|
137
|
+
bearerTokenEnv?: string;
|
|
78
138
|
}
|
|
79
139
|
|
|
80
140
|
export interface McpControl {
|
|
@@ -147,21 +207,65 @@ export function makeMcpControl(opts?: {
|
|
|
147
207
|
// Project the enabled servers into the standard mcp.json the adapter reads. An
|
|
148
208
|
// entry with no explicit transport is treated as stdio if it has a command, http
|
|
149
209
|
// if it has a url — matching the adapter's own inference.
|
|
210
|
+
//
|
|
211
|
+
// `toolPrefix` is pinned rather than left to the adapter's default because a
|
|
212
|
+
// routine's "<server>__<tool>" selector is translated into a REGISTERED tool name
|
|
213
|
+
// before it can grant anything (src/mcp/toolNames.ts), and that translation depends
|
|
214
|
+
// on the mode. "server" is the adapter's own default, so pinning it changes nothing
|
|
215
|
+
// today and stops a future default flip from silently voiding every allow-list.
|
|
150
216
|
function project(src: SourceFile): void {
|
|
151
217
|
const mcpServers: Record<string, unknown> = {};
|
|
152
218
|
for (const [name, e] of Object.entries(src.servers)) {
|
|
153
219
|
if (e.enabled === false) continue;
|
|
154
|
-
|
|
155
|
-
mcpServers[name] = std;
|
|
220
|
+
mcpServers[name] = toStandard(e);
|
|
156
221
|
}
|
|
157
222
|
mkdirSync(dirname(projectionPath()), { recursive: true });
|
|
158
|
-
writeFileSync(
|
|
223
|
+
writeFileSync(
|
|
224
|
+
projectionPath(),
|
|
225
|
+
JSON.stringify({ mcpServers, settings: { toolPrefix: "server" } }, null, 2) + "\n",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// One managed entry as pi-mcp-adapter's ServerEntry. Built field by field rather
|
|
230
|
+
// than spread, because our vocabulary and the adapter's differ in two places that
|
|
231
|
+
// matter: our `auth: "none"` is its `auth: false`, and our legacy boolean `oauth`
|
|
232
|
+
// collides with its `oauth` (an OAuthConfig OBJECT) and must never reach the file.
|
|
233
|
+
//
|
|
234
|
+
// Getting `auth` right is load-bearing, not cosmetic. The adapter only attaches an
|
|
235
|
+
// Authorization header when `auth === "bearer"` (server-manager.ts), and its
|
|
236
|
+
// supportsOAuth() refuses OAuth outright once custom headers are configured — so an
|
|
237
|
+
// omitted `auth` silently means "no bearer token was ever sent".
|
|
238
|
+
function toStandard(e: SourceEntry): Record<string, unknown> {
|
|
239
|
+
const transport: McpTransport = e.transport ?? (e.url ? "http" : "stdio");
|
|
240
|
+
const std: Record<string, unknown> = {};
|
|
241
|
+
if (transport === "stdio") {
|
|
242
|
+
if (e.command) std.command = e.command;
|
|
243
|
+
if (e.args?.length) std.args = e.args;
|
|
244
|
+
} else {
|
|
245
|
+
if (e.url) std.url = e.url;
|
|
246
|
+
if (e.headers && Object.keys(e.headers).length > 0) std.headers = e.headers;
|
|
247
|
+
const auth = authOf(e, transport);
|
|
248
|
+
if (auth === "bearer") {
|
|
249
|
+
std.auth = "bearer";
|
|
250
|
+
if (e.bearerToken) std.bearerToken = e.bearerToken;
|
|
251
|
+
if (e.bearerTokenEnv) std.bearerTokenEnv = e.bearerTokenEnv;
|
|
252
|
+
} else if (auth === "oauth") {
|
|
253
|
+
std.auth = "oauth";
|
|
254
|
+
} else {
|
|
255
|
+
std.auth = false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (e.env && Object.keys(e.env).length > 0) std.env = e.env;
|
|
259
|
+
return std;
|
|
159
260
|
}
|
|
160
261
|
|
|
161
262
|
function toRemote(name: string, e: SourceEntry): RemoteMcpServer {
|
|
162
263
|
const transport: McpTransport = e.transport ?? (e.url ? "http" : "stdio");
|
|
163
264
|
const env = e.env ?? {};
|
|
164
265
|
const envKeys = Object.keys(env);
|
|
266
|
+
const headers = transport === "http" ? e.headers ?? {} : {};
|
|
267
|
+
const headerKeys = Object.keys(headers);
|
|
268
|
+
const auth = authOf(e, transport);
|
|
165
269
|
return {
|
|
166
270
|
name,
|
|
167
271
|
transport,
|
|
@@ -170,13 +274,36 @@ export function makeMcpControl(opts?: {
|
|
|
170
274
|
argsPreview: transport === "stdio" && e.args?.length ? e.args.join(" ") : undefined,
|
|
171
275
|
url: transport === "http" ? e.url : undefined,
|
|
172
276
|
host: transport === "http" && e.url ? hostOf(e.url) : undefined,
|
|
173
|
-
|
|
174
|
-
oauth:
|
|
277
|
+
auth,
|
|
278
|
+
oauth: auth === "oauth",
|
|
175
279
|
envKeys,
|
|
176
280
|
secretsSet: envKeys.filter((k) => String(env[k] ?? "").length > 0),
|
|
281
|
+
headerKeys,
|
|
282
|
+
headersSet: headerKeys.filter((k) => String(headers[k] ?? "").length > 0),
|
|
283
|
+
bearerTokenSet: transport === "http" && String(e.bearerToken ?? "").length > 0,
|
|
284
|
+
bearerTokenEnv: transport === "http" ? e.bearerTokenEnv : undefined,
|
|
177
285
|
};
|
|
178
286
|
}
|
|
179
287
|
|
|
288
|
+
// Merge a submitted key/value map into the stored one: a present non-empty value
|
|
289
|
+
// overwrites, an explicit empty string clears that key, an omitted key is left
|
|
290
|
+
// alone. Shared by `env` and `headers` so the "re-save without re-typing the token"
|
|
291
|
+
// rule can't drift between them. Returns undefined when nothing is left.
|
|
292
|
+
function mergeSecrets(
|
|
293
|
+
prev: Record<string, string> | undefined,
|
|
294
|
+
submitted: Record<string, string>,
|
|
295
|
+
): Record<string, string> | undefined {
|
|
296
|
+
const merged: Record<string, string> = { ...(prev ?? {}) };
|
|
297
|
+
for (const [k, v] of Object.entries(submitted)) {
|
|
298
|
+
const key = String(k).trim();
|
|
299
|
+
if (!key) continue;
|
|
300
|
+
const val = String(v ?? "");
|
|
301
|
+
if (val.length > 0) merged[key] = val;
|
|
302
|
+
else delete merged[key];
|
|
303
|
+
}
|
|
304
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
180
307
|
return {
|
|
181
308
|
list(): RemoteMcpServer[] {
|
|
182
309
|
const src = readSource();
|
|
@@ -188,6 +315,8 @@ export function makeMcpControl(opts?: {
|
|
|
188
315
|
if (!name) return { ok: false, message: "A connector needs a name." };
|
|
189
316
|
if (draft.transport !== undefined && !isTransport(draft.transport))
|
|
190
317
|
return { ok: false, message: "Unknown transport." };
|
|
318
|
+
if (draft.auth !== undefined && !isAuth(draft.auth))
|
|
319
|
+
return { ok: false, message: "Unknown authentication type." };
|
|
191
320
|
|
|
192
321
|
const src = readSource();
|
|
193
322
|
const prev: SourceEntry = src.servers[name] ?? {};
|
|
@@ -201,31 +330,57 @@ export function makeMcpControl(opts?: {
|
|
|
201
330
|
if (draft.command !== undefined) entry.command = String(draft.command).trim();
|
|
202
331
|
const args = cleanArgs(draft.args);
|
|
203
332
|
if (args !== undefined) entry.args = args;
|
|
204
|
-
// A stdio server can't reach a url and
|
|
333
|
+
// A stdio server can't reach a url and has nothing to authenticate to — clear
|
|
334
|
+
// every http-only field so a transport flip can't leave a live token behind.
|
|
205
335
|
delete entry.url;
|
|
336
|
+
delete entry.headers;
|
|
337
|
+
delete entry.auth;
|
|
338
|
+
delete entry.bearerToken;
|
|
339
|
+
delete entry.bearerTokenEnv;
|
|
206
340
|
delete entry.oauth;
|
|
207
341
|
if (!entry.command) return { ok: false, message: "A local (stdio) connector needs a command." };
|
|
208
342
|
} else {
|
|
209
343
|
if (draft.url !== undefined) entry.url = String(draft.url).trim();
|
|
210
|
-
|
|
344
|
+
// `auth` is authoritative; the legacy boolean is honoured only when it isn't
|
|
345
|
+
// sent, so an old app build keeps working without being able to override.
|
|
346
|
+
if (draft.auth !== undefined) entry.auth = draft.auth;
|
|
347
|
+
else if (draft.oauth !== undefined) entry.auth = draft.oauth ? "oauth" : "none";
|
|
348
|
+
if (draft.bearerTokenEnv !== undefined) {
|
|
349
|
+
const v = String(draft.bearerTokenEnv).trim();
|
|
350
|
+
if (v) entry.bearerTokenEnv = v;
|
|
351
|
+
else delete entry.bearerTokenEnv;
|
|
352
|
+
}
|
|
353
|
+
if (draft.bearerToken !== undefined) {
|
|
354
|
+
const v = String(draft.bearerToken);
|
|
355
|
+
if (v.length > 0) entry.bearerToken = v;
|
|
356
|
+
else delete entry.bearerToken;
|
|
357
|
+
}
|
|
358
|
+
if (draft.headers !== undefined) {
|
|
359
|
+
const merged = mergeSecrets(prev.headers, draft.headers);
|
|
360
|
+
if (merged) entry.headers = merged;
|
|
361
|
+
else delete entry.headers;
|
|
362
|
+
}
|
|
363
|
+
// A token that arrived without an explicit `auth` means bearer — otherwise the
|
|
364
|
+
// adapter stores the token and never sends it (it only sets the Authorization
|
|
365
|
+
// header when auth === "bearer"), which reads to the user as "my token is
|
|
366
|
+
// saved and the connector still 401s".
|
|
367
|
+
if (draft.auth === undefined && (entry.bearerToken || entry.bearerTokenEnv)) entry.auth = "bearer";
|
|
368
|
+
// Once `auth` is set, the legacy boolean is noise that authOf would have to
|
|
369
|
+
// keep tie-breaking. Drop it.
|
|
370
|
+
if (entry.auth !== undefined) delete entry.oauth;
|
|
211
371
|
delete entry.command;
|
|
212
372
|
delete entry.args;
|
|
213
373
|
if (!entry.url) return { ok: false, message: "A remote (http) connector needs a URL." };
|
|
374
|
+
if (authOf(entry, "http") === "bearer" && !entry.bearerToken && !entry.bearerTokenEnv)
|
|
375
|
+
return { ok: false, message: "A bearer connector needs a token, or the name of an env var holding one." };
|
|
214
376
|
}
|
|
215
377
|
|
|
216
378
|
// Env/secrets: a present, non-empty value overwrites; an omitted key keeps the
|
|
217
379
|
// existing value (re-save without re-typing the token preserves it). An explicit
|
|
218
380
|
// empty string clears that key.
|
|
219
381
|
if (draft.env !== undefined) {
|
|
220
|
-
const merged
|
|
221
|
-
|
|
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;
|
|
382
|
+
const merged = mergeSecrets(prev.env, draft.env);
|
|
383
|
+
if (merged) entry.env = merged;
|
|
229
384
|
else delete entry.env;
|
|
230
385
|
}
|
|
231
386
|
|
|
@@ -266,3 +421,44 @@ export function makeMcpControl(opts?: {
|
|
|
266
421
|
},
|
|
267
422
|
};
|
|
268
423
|
}
|
|
424
|
+
|
|
425
|
+
// MCP draft fields whose VALUES are credentials. They ride a sealed box addressed to
|
|
426
|
+
// the terminal and are refused anywhere else. `bearerTokenEnv` is deliberately absent:
|
|
427
|
+
// it is a variable NAME, not a value, and travels in the clear.
|
|
428
|
+
const MCP_SEALED_FIELDS = ["env", "headers", "bearerToken"] as const;
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Apply the sealed half of an MCP connector save to the plain draft.
|
|
432
|
+
*
|
|
433
|
+
* Credential-bearing fields may ONLY arrive in the sealed box. A signed frame proves
|
|
434
|
+
* the account authored it; it does not stop the relay from READING it, and a bearer
|
|
435
|
+
* token or an `Authorization` header in the clear on the wire is exactly what sealing
|
|
436
|
+
* exists to prevent. We refuse rather than strip: silently dropping a token looks, to
|
|
437
|
+
* the user, like a save that worked.
|
|
438
|
+
*
|
|
439
|
+
* An absent field in the box means "leave what is stored alone" (mcpControl's
|
|
440
|
+
* re-save-without-re-typing rule) — which is not the same as an empty object.
|
|
441
|
+
*
|
|
442
|
+
* Lives here rather than in the harbor so it stays Pi-free and testable: importing the
|
|
443
|
+
* harbor pulls in the whole Pi session stack, which must only load after boot.ts.
|
|
444
|
+
* The signature check runs BEFORE this (harbor applyMcpSave).
|
|
445
|
+
*/
|
|
446
|
+
export function mergeSealedMcpSecrets(
|
|
447
|
+
draft: Record<string, unknown>,
|
|
448
|
+
opened?: { env?: Record<string, string>; headers?: Record<string, string>; bearerToken?: string },
|
|
449
|
+
): { ok: true; draft: Record<string, unknown> } | { ok: false; message: string } {
|
|
450
|
+
for (const field of MCP_SEALED_FIELDS) {
|
|
451
|
+
if (draft[field] !== undefined) {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
message: `Connector credentials (${field}) must be sealed to this terminal, not sent in the clear.`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!opened) return { ok: true, draft };
|
|
459
|
+
const merged = { ...draft };
|
|
460
|
+
if (opened.env !== undefined) merged.env = opened.env;
|
|
461
|
+
if (opened.headers !== undefined) merged.headers = opened.headers;
|
|
462
|
+
if (opened.bearerToken !== undefined) merged.bearerToken = opened.bearerToken;
|
|
463
|
+
return { ok: true, draft: merged };
|
|
464
|
+
}
|
|
@@ -227,6 +227,30 @@ export interface RelayCallbacks {
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
const RECONNECT_MS = 3000;
|
|
230
|
+
// Retry cadence after the relay REFUSES us (4xx — in practice the plan's live-agent
|
|
231
|
+
// cap). Slow, because only an account change can clear it, but not never: the harbor
|
|
232
|
+
// should come up on its own once a slot frees. Kept well under the server's denial
|
|
233
|
+
// record TTL so the app's "blocked" row stays warm between attempts rather than
|
|
234
|
+
// flickering in and out of the plan-limit state.
|
|
235
|
+
const REFUSED_RECONNECT_MS = 60_000;
|
|
236
|
+
// ── Liveness ────────────────────────────────────────────────────────────────────
|
|
237
|
+
// A TCP socket can die without either side being told: a server instance restarts,
|
|
238
|
+
// a NAT/idle timer drops the flow, a laptop sleeps. The kernel keeps reporting
|
|
239
|
+
// ESTABLISHED, `ws` never fires 'close', and the reconnect path above — which only
|
|
240
|
+
// runs on close/error — never runs. That failure mode is invisible AND permanent:
|
|
241
|
+
// the server prunes the terminal from its presence registry after ~60s, so the app
|
|
242
|
+
// shows the harbor as offline while the harbor's own log says "connected", forever.
|
|
243
|
+
//
|
|
244
|
+
// So don't wait to be told. The server pings every 25s, so an alive socket sees
|
|
245
|
+
// inbound traffic at least that often; we ping on our own timer too (the peer's pong
|
|
246
|
+
// counts as inbound). If nothing arrives for LIVENESS_TIMEOUT_MS — three missed
|
|
247
|
+
// server pings — the socket is dead: terminate it and take the normal reconnect path.
|
|
248
|
+
const HEARTBEAT_MS = 20_000;
|
|
249
|
+
const LIVENESS_TIMEOUT_MS = 75_000;
|
|
250
|
+
// Cap the opening handshake too. Without this a black-holed connect leaves `this.ws`
|
|
251
|
+
// set with no open/close/error ever firing, and connect()'s `if (this.ws) return`
|
|
252
|
+
// guard then blocks every future attempt — the same permanent silence by another route.
|
|
253
|
+
const HANDSHAKE_TIMEOUT_MS = 15_000;
|
|
230
254
|
// File-transfer ceilings for app→CLI attachments. The app enforces its own caps
|
|
231
255
|
// before sending; these are a defensive backstop so a controller can't exhaust
|
|
232
256
|
// memory with a lying `size` or a flood of concurrent transfers.
|
|
@@ -285,13 +309,20 @@ export class RelayClient {
|
|
|
285
309
|
private closed = false;
|
|
286
310
|
private connecting = false;
|
|
287
311
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
312
|
+
// Liveness watchdog for the open socket (see HEARTBEAT_MS): our own ping timer plus
|
|
313
|
+
// the epoch of the last thing we heard from the server — any frame, ping or pong.
|
|
314
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
315
|
+
private lastInboundAt = 0;
|
|
316
|
+
private connectedAt = 0;
|
|
317
|
+
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
318
|
+
private refusal: string | null = null;
|
|
288
319
|
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
289
320
|
private bufKind: "text" | "reasoning" | null = null;
|
|
290
321
|
private buf = "";
|
|
291
322
|
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
292
323
|
// Stable for this process so reconnects keep the same terminal identity. Callers
|
|
293
324
|
// may pass a persisted id/label (e.g. the routines harbor, so it shows up as one
|
|
294
|
-
// recognizable "Privateer
|
|
325
|
+
// recognizable "Privateer Local Harbor" terminal across restarts instead of a fresh
|
|
295
326
|
// random one each time).
|
|
296
327
|
private readonly termId: string;
|
|
297
328
|
private readonly label: string;
|
|
@@ -369,6 +400,8 @@ export class RelayClient {
|
|
|
369
400
|
this.settleFirstConnect(new Error("relay stopped before registering"));
|
|
370
401
|
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
|
|
371
402
|
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
403
|
+
this.stopHeartbeat();
|
|
404
|
+
this.connectedAt = 0;
|
|
372
405
|
this.bufKind = null;
|
|
373
406
|
this.buf = "";
|
|
374
407
|
this.incoming.clear();
|
|
@@ -388,7 +421,19 @@ export class RelayClient {
|
|
|
388
421
|
body: JSON.stringify({ role: "agent", termId: this.termId, label: this.label }),
|
|
389
422
|
});
|
|
390
423
|
if (!res.ok) {
|
|
391
|
-
|
|
424
|
+
// Carry the server's reason, not just the status. A 403 here is almost always
|
|
425
|
+
// the plan's live-agent cap, and a bare "relay ticket HTTP 403" in a harbor log
|
|
426
|
+
// tells the operator nothing about which knob to turn.
|
|
427
|
+
let detail = "";
|
|
428
|
+
try {
|
|
429
|
+
const body = (await res.json()) as { message?: string; code?: string };
|
|
430
|
+
detail = body?.message || body?.code || "";
|
|
431
|
+
} catch {
|
|
432
|
+
/* non-JSON body — the status stands on its own */
|
|
433
|
+
}
|
|
434
|
+
const e: Error & { status?: number } = new Error(
|
|
435
|
+
detail ? `relay ticket HTTP ${res.status}: ${detail}` : `relay ticket HTTP ${res.status}`,
|
|
436
|
+
);
|
|
392
437
|
e.status = res.status;
|
|
393
438
|
throw e;
|
|
394
439
|
}
|
|
@@ -397,19 +442,28 @@ export class RelayClient {
|
|
|
397
442
|
const wsUrl =
|
|
398
443
|
serverBaseUrl().replace(/^http/, "ws") + `/relay?ticket=${encodeURIComponent(ticket)}`;
|
|
399
444
|
this.debug(`connecting → ${wsUrl}`);
|
|
400
|
-
const ws = new WebSocket(wsUrl);
|
|
445
|
+
const ws = new WebSocket(wsUrl, { handshakeTimeout: HANDSHAKE_TIMEOUT_MS });
|
|
401
446
|
this.ws = ws;
|
|
402
447
|
let opened = false;
|
|
403
448
|
let lastErr = "";
|
|
404
449
|
|
|
405
450
|
ws.on("open", () => {
|
|
406
451
|
opened = true;
|
|
452
|
+
this.refusal = null; // a later refusal is news again
|
|
453
|
+
this.connectedAt = Date.now();
|
|
454
|
+
this.startHeartbeat(ws);
|
|
407
455
|
this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
|
|
408
456
|
this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
|
|
409
457
|
});
|
|
410
|
-
|
|
458
|
+
// Anything the server sends counts as proof of life for the watchdog. `ws`
|
|
459
|
+
// answers server pings with a pong for us, and answers our pings with 'pong'.
|
|
460
|
+
ws.on("message", (data) => { this.lastInboundAt = Date.now(); this.handle(data); });
|
|
461
|
+
ws.on("ping", () => { this.lastInboundAt = Date.now(); });
|
|
462
|
+
ws.on("pong", () => { this.lastInboundAt = Date.now(); });
|
|
411
463
|
ws.on("close", () => {
|
|
464
|
+
this.stopHeartbeat();
|
|
412
465
|
if (this.ws === ws) this.ws = null;
|
|
466
|
+
this.connectedAt = 0;
|
|
413
467
|
this.cb.onDisconnected?.();
|
|
414
468
|
if (!this.closed) {
|
|
415
469
|
this.cb.onStatus?.(
|
|
@@ -431,11 +485,27 @@ export class RelayClient {
|
|
|
431
485
|
const msg = err instanceof Error ? err.message : String(err);
|
|
432
486
|
// A 4xx (e.g. 403 concurrency cap) won't self-heal by retrying the same request —
|
|
433
487
|
// fail-fast any awaitRegistered() caller (a live-task spawn) so it stops hanging.
|
|
434
|
-
// The management terminal ignores this signal, so its reconnect behavior is unchanged.
|
|
435
488
|
const status = (err as { status?: number })?.status;
|
|
436
|
-
|
|
489
|
+
const refused = typeof status === "number" && status >= 400 && status < 500;
|
|
490
|
+
if (refused) {
|
|
437
491
|
this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
|
|
492
|
+
// A refusal is a decision, not a hiccup: hammering the same request every few
|
|
493
|
+
// seconds can't change it, and for a harbor — whose onStatus goes to a log file,
|
|
494
|
+
// not to a person — that is thousands of identical lines a day. Say it once, in
|
|
495
|
+
// full, then retry on a slow timer so the terminal still comes up by itself the
|
|
496
|
+
// moment the account frees a slot or changes plan.
|
|
497
|
+
if (this.refusal !== msg) {
|
|
498
|
+
this.refusal = msg;
|
|
499
|
+
this.cb.onStatus?.(
|
|
500
|
+
`Remote access refused: ${msg} — this terminal will not be drivable from the app until that is resolved. ` +
|
|
501
|
+
`Retrying every ${Math.round(REFUSED_RECONNECT_MS / 1000)}s.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
this.scheduleReconnect(REFUSED_RECONNECT_MS);
|
|
505
|
+
return;
|
|
438
506
|
}
|
|
507
|
+
// Transient (network/route/5xx): stay on the fast retry.
|
|
508
|
+
this.refusal = null;
|
|
439
509
|
this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
|
|
440
510
|
this.scheduleReconnect();
|
|
441
511
|
} finally {
|
|
@@ -447,12 +517,43 @@ export class RelayClient {
|
|
|
447
517
|
if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
|
|
448
518
|
}
|
|
449
519
|
|
|
450
|
-
|
|
520
|
+
// Watch one open socket: ping on a timer, and terminate it if the server has gone
|
|
521
|
+
// quiet for longer than any healthy connection ever is (see LIVENESS_TIMEOUT_MS).
|
|
522
|
+
// `terminate()` (not close()) because the point is that the peer may be gone — a
|
|
523
|
+
// close handshake would wait for a reply that never comes. The 'close' it fires
|
|
524
|
+
// takes the ordinary reconnect path, so recovery needs no separate machinery.
|
|
525
|
+
private startHeartbeat(ws: WebSocket): void {
|
|
526
|
+
this.stopHeartbeat();
|
|
527
|
+
this.lastInboundAt = Date.now();
|
|
528
|
+
this.heartbeatTimer = setInterval(() => {
|
|
529
|
+
// A socket we've since replaced or dropped isn't ours to police anymore.
|
|
530
|
+
if (this.ws !== ws) { this.stopHeartbeat(); return; }
|
|
531
|
+
if (ws.readyState !== WebSocket.OPEN) return; // closing — 'close' will clean up
|
|
532
|
+
const quietMs = Date.now() - this.lastInboundAt;
|
|
533
|
+
if (quietMs > LIVENESS_TIMEOUT_MS) {
|
|
534
|
+
this.cb.onStatus?.(
|
|
535
|
+
`Remote access went silent for ${Math.round(quietMs / 1000)}s (the connection died without closing) — dropping it and reconnecting…`,
|
|
536
|
+
);
|
|
537
|
+
this.stopHeartbeat();
|
|
538
|
+
try { ws.terminate(); } catch (_) { /* already gone — 'close' still fires */ }
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
try { ws.ping(); } catch (_) { /* socket dying — the next tick or 'close' handles it */ }
|
|
542
|
+
}, HEARTBEAT_MS);
|
|
543
|
+
// Never hold the process open for a heartbeat alone.
|
|
544
|
+
this.heartbeatTimer.unref?.();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private stopHeartbeat(): void {
|
|
548
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private scheduleReconnect(delayMs: number = RECONNECT_MS): void {
|
|
451
552
|
if (this.closed || this.reconnectTimer) return;
|
|
452
553
|
this.reconnectTimer = setTimeout(() => {
|
|
453
554
|
this.reconnectTimer = undefined;
|
|
454
555
|
void this.connect();
|
|
455
|
-
},
|
|
556
|
+
}, delayMs);
|
|
456
557
|
}
|
|
457
558
|
|
|
458
559
|
private handle(data: WebSocket.RawData): void {
|
|
@@ -709,6 +810,21 @@ export class RelayClient {
|
|
|
709
810
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
710
811
|
}
|
|
711
812
|
|
|
813
|
+
// Connection health, for `privateer harbor status` / the IPC status reply. `quietSec`
|
|
814
|
+
// is how long since the server last said anything: a connected socket that has been
|
|
815
|
+
// quiet for longer than the server's 25s ping cadence is the shape of the half-open
|
|
816
|
+
// failure the watchdog exists to catch, so it is worth showing rather than a bare
|
|
817
|
+
// "connected".
|
|
818
|
+
connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
|
|
819
|
+
if (!this.isConnected()) return { connected: false };
|
|
820
|
+
const now = Date.now();
|
|
821
|
+
return {
|
|
822
|
+
connected: true,
|
|
823
|
+
upSec: this.connectedAt ? Math.round((now - this.connectedAt) / 1000) : undefined,
|
|
824
|
+
quietSec: this.lastInboundAt ? Math.round((now - this.lastInboundAt) / 1000) : undefined,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
712
828
|
// Push a finished routine result to any attached controller as a text event, so
|
|
713
829
|
// it renders in the app's live feed. Returns whether the socket was open to send
|
|
714
830
|
// on; a durable channel (file/notice) still backs this up, since we can't know
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Unlike those two, routines are owned by the HARBOR (not an interactive Pi
|
|
10
10
|
* session): they live in routines.json (see routines/store.ts) and fire from the
|
|
11
11
|
* resident scheduler. So this control is wired into the harbor's own relay
|
|
12
|
-
* connection (the "Privateer
|
|
12
|
+
* connection (the "Privateer Local Harbor" terminal), not the REPL/TUI. Running a
|
|
13
13
|
* routine now is the one action that needs the harbor itself, so it's injected as
|
|
14
14
|
* `runNow` rather than reaching back into the store.
|
|
15
15
|
*
|
package/src/routines/schema.ts
CHANGED
|
@@ -52,6 +52,8 @@ export const Routine = z
|
|
|
52
52
|
// may be builtin names ("read") or MCP selectors — "<server>__<tool>" exact or
|
|
53
53
|
// "<server>__*" for a whole server (see routines/toolSelect.ts). Selected MCP
|
|
54
54
|
// tools run unattended under the auto-approve gate, so grant the minimum needed.
|
|
55
|
+
// The selector is NOT the tool name Pi sees; the harbor translates it at run time
|
|
56
|
+
// (mcp/toolNames.ts). This stored vocabulary is stable — don't "fix" it to match.
|
|
55
57
|
tools: z.array(z.string()).optional(),
|
|
56
58
|
// Paused routines stay in the file but never fire.
|
|
57
59
|
enabled: z.boolean().default(true),
|
package/src/routines/store.ts
CHANGED
|
@@ -21,7 +21,7 @@ function slug(name: string): string {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// A stable relay terminal id for the harbor, persisted so it reappears as the same
|
|
24
|
-
// "Privateer
|
|
24
|
+
// "Privateer Local Harbor" terminal in the app across restarts (rather than a fresh
|
|
25
25
|
// random terminal each boot). Random on first use so it stays unique per install —
|
|
26
26
|
// the relay routes on this id with no user namespacing, so a shared constant could
|
|
27
27
|
// collide across accounts. Matches the server's isValidTermId (`[A-Za-z0-9_-]{8,64}`).
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// A record of tool-name → tool definition. Was `ai`'s ToolSet in 0.2; MCP tool
|
|
2
|
-
// wiring is a Phase-5 concern, so a structural type suffices for the filter below.
|
|
3
|
-
type ToolSet = Record<string, unknown>;
|
|
4
|
-
|
|
5
1
|
// A routine's `tools` field mixes builtin tool names with MCP selectors. MCP tools
|
|
6
|
-
// are
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
2
|
+
// are selected as "<server>__<tool>", and no Pi builtin name contains "__", so the
|
|
3
|
+
// separator is unambiguous: entries with "__" are MCP selectors — an exact tool name
|
|
4
|
+
// or a per-server wildcard "<server>__*" — everything else is a builtin allow-list
|
|
5
|
+
// entry.
|
|
6
|
+
//
|
|
7
|
+
// NOTE the selector is NOT the name Pi registers. pi-mcp-adapter names a tool
|
|
8
|
+
// "<serverPrefix>_<tool>" (one underscore), and Pi's `tools:` option is an exact-match
|
|
9
|
+
// Set — so a selector must be TRANSLATED before it can grant anything. That
|
|
10
|
+
// translation lives in ../mcp/toolNames.ts; this module only splits and matches.
|
|
10
11
|
|
|
11
12
|
export interface RoutineToolSplit {
|
|
12
13
|
// Builtin tool names (read, glob, ...). Empty → caller falls back to the safe set.
|
|
@@ -33,18 +34,11 @@ export function splitRoutineTools(tools?: string[]): RoutineToolSplit {
|
|
|
33
34
|
return { builtin, mcp, servers: [...servers] };
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
// Does a
|
|
37
|
-
// matching any tool on that server.
|
|
37
|
+
// Does a SELECTOR-vocabulary tool name match a selector? Exact match, or "<server>__*"
|
|
38
|
+
// matching any tool on that server. Used to answer "does this routine already grant
|
|
39
|
+
// X?" against stored selectors — never against registered Pi tool names, which use a
|
|
40
|
+
// different separator (see ../mcp/toolNames.ts).
|
|
38
41
|
export function matchesSelector(name: string, selector: string): boolean {
|
|
39
42
|
if (selector.endsWith("__*")) return name.startsWith(selector.slice(0, -1));
|
|
40
43
|
return name === selector;
|
|
41
44
|
}
|
|
42
|
-
|
|
43
|
-
// Narrow a connected MCP toolset to the selected tools. Least privilege matters here:
|
|
44
|
-
// routine runs use the auto-approve gate, so anything left in this set fires without
|
|
45
|
-
// a human in the loop.
|
|
46
|
-
export function filterMcpTools(tools: ToolSet, selectors: string[]): ToolSet {
|
|
47
|
-
return Object.fromEntries(
|
|
48
|
-
Object.entries(tools).filter(([name]) => selectors.some((s) => matchesSelector(name, s))),
|
|
49
|
-
);
|
|
50
|
-
}
|