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.
@@ -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, …). 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
- * harbor opens a sealed-box addressed to its terminal, mirroring applyChannelSave), so
24
- * this module only ever deals in the plaintext files it already owns.
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
- oauth: boolean; // http servers negotiate OAuth; stdio never does
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` 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).
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
- const { enabled, ...std } = e;
155
- mcpServers[name] = std;
220
+ mcpServers[name] = toStandard(e);
156
221
  }
157
222
  mkdirSync(dirname(projectionPath()), { recursive: true });
158
- writeFileSync(projectionPath(), JSON.stringify({ mcpServers }, null, 2) + "\n");
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
- // http servers negotiate OAuth; stdio never does (matches mcpService.list()).
174
- oauth: transport === "http",
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 never does OAuth — clear stale fields.
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
- if (draft.oauth !== undefined) entry.oauth = !!draft.oauth;
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: 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;
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 Routines" terminal across restarts instead of a fresh
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
- const e: Error & { status?: number } = new Error(`relay ticket HTTP ${res.status}`);
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
- if (typeof status === "number" && status >= 400 && status < 500) {
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
- }, RECONNECT_MS);
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 Routines" terminal), not the REPL/TUI. Running a
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
  *
@@ -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),
@@ -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 Routines" terminal in the app across restarts (rather than a fresh
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 namespaced "<server>__<tool>" (see adaptMcpTools), and no builtin name contains
7
- // "__", so the separator is unambiguous: entries with "__" are MCP selectors — an
8
- // exact tool name or a per-server wildcard "<server>__*" — everything else is a
9
- // builtin allowlist entry.
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 namespaced MCP tool name match a selector? Exact match, or "<server>__*"
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
- }