privateer-agent 0.8.2 → 0.9.0
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/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 +255 -74
- package/src/mcp/catalog.ts +32 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/providers/account.ts +318 -9
- package/src/remote/liveTaskSession.ts +11 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +43 -6
- 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,12 @@ 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;
|
|
230
236
|
// File-transfer ceilings for app→CLI attachments. The app enforces its own caps
|
|
231
237
|
// before sending; these are a defensive backstop so a controller can't exhaust
|
|
232
238
|
// memory with a lying `size` or a flood of concurrent transfers.
|
|
@@ -285,13 +291,15 @@ export class RelayClient {
|
|
|
285
291
|
private closed = false;
|
|
286
292
|
private connecting = false;
|
|
287
293
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
294
|
+
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
295
|
+
private refusal: string | null = null;
|
|
288
296
|
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
289
297
|
private bufKind: "text" | "reasoning" | null = null;
|
|
290
298
|
private buf = "";
|
|
291
299
|
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
292
300
|
// Stable for this process so reconnects keep the same terminal identity. Callers
|
|
293
301
|
// may pass a persisted id/label (e.g. the routines harbor, so it shows up as one
|
|
294
|
-
// recognizable "Privateer
|
|
302
|
+
// recognizable "Privateer Local Harbor" terminal across restarts instead of a fresh
|
|
295
303
|
// random one each time).
|
|
296
304
|
private readonly termId: string;
|
|
297
305
|
private readonly label: string;
|
|
@@ -388,7 +396,19 @@ export class RelayClient {
|
|
|
388
396
|
body: JSON.stringify({ role: "agent", termId: this.termId, label: this.label }),
|
|
389
397
|
});
|
|
390
398
|
if (!res.ok) {
|
|
391
|
-
|
|
399
|
+
// Carry the server's reason, not just the status. A 403 here is almost always
|
|
400
|
+
// the plan's live-agent cap, and a bare "relay ticket HTTP 403" in a harbor log
|
|
401
|
+
// tells the operator nothing about which knob to turn.
|
|
402
|
+
let detail = "";
|
|
403
|
+
try {
|
|
404
|
+
const body = (await res.json()) as { message?: string; code?: string };
|
|
405
|
+
detail = body?.message || body?.code || "";
|
|
406
|
+
} catch {
|
|
407
|
+
/* non-JSON body — the status stands on its own */
|
|
408
|
+
}
|
|
409
|
+
const e: Error & { status?: number } = new Error(
|
|
410
|
+
detail ? `relay ticket HTTP ${res.status}: ${detail}` : `relay ticket HTTP ${res.status}`,
|
|
411
|
+
);
|
|
392
412
|
e.status = res.status;
|
|
393
413
|
throw e;
|
|
394
414
|
}
|
|
@@ -404,6 +424,7 @@ export class RelayClient {
|
|
|
404
424
|
|
|
405
425
|
ws.on("open", () => {
|
|
406
426
|
opened = true;
|
|
427
|
+
this.refusal = null; // a later refusal is news again
|
|
407
428
|
this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
|
|
408
429
|
this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
|
|
409
430
|
});
|
|
@@ -431,11 +452,27 @@ export class RelayClient {
|
|
|
431
452
|
const msg = err instanceof Error ? err.message : String(err);
|
|
432
453
|
// A 4xx (e.g. 403 concurrency cap) won't self-heal by retrying the same request —
|
|
433
454
|
// 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
455
|
const status = (err as { status?: number })?.status;
|
|
436
|
-
|
|
456
|
+
const refused = typeof status === "number" && status >= 400 && status < 500;
|
|
457
|
+
if (refused) {
|
|
437
458
|
this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
|
|
459
|
+
// A refusal is a decision, not a hiccup: hammering the same request every few
|
|
460
|
+
// seconds can't change it, and for a harbor — whose onStatus goes to a log file,
|
|
461
|
+
// not to a person — that is thousands of identical lines a day. Say it once, in
|
|
462
|
+
// full, then retry on a slow timer so the terminal still comes up by itself the
|
|
463
|
+
// moment the account frees a slot or changes plan.
|
|
464
|
+
if (this.refusal !== msg) {
|
|
465
|
+
this.refusal = msg;
|
|
466
|
+
this.cb.onStatus?.(
|
|
467
|
+
`Remote access refused: ${msg} — this terminal will not be drivable from the app until that is resolved. ` +
|
|
468
|
+
`Retrying every ${Math.round(REFUSED_RECONNECT_MS / 1000)}s.`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
this.scheduleReconnect(REFUSED_RECONNECT_MS);
|
|
472
|
+
return;
|
|
438
473
|
}
|
|
474
|
+
// Transient (network/route/5xx): stay on the fast retry.
|
|
475
|
+
this.refusal = null;
|
|
439
476
|
this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
|
|
440
477
|
this.scheduleReconnect();
|
|
441
478
|
} finally {
|
|
@@ -447,12 +484,12 @@ export class RelayClient {
|
|
|
447
484
|
if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
|
|
448
485
|
}
|
|
449
486
|
|
|
450
|
-
private scheduleReconnect(): void {
|
|
487
|
+
private scheduleReconnect(delayMs: number = RECONNECT_MS): void {
|
|
451
488
|
if (this.closed || this.reconnectTimer) return;
|
|
452
489
|
this.reconnectTimer = setTimeout(() => {
|
|
453
490
|
this.reconnectTimer = undefined;
|
|
454
491
|
void this.connect();
|
|
455
|
-
},
|
|
492
|
+
}, delayMs);
|
|
456
493
|
}
|
|
457
494
|
|
|
458
495
|
private handle(data: WebSocket.RawData): void {
|
|
@@ -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
|
-
}
|