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.
@@ -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,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 Routines" terminal across restarts instead of a fresh
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
- const e: Error & { status?: number } = new Error(`relay ticket HTTP ${res.status}`);
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
- ws.on("message", (data) => this.handle(data));
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
- if (typeof status === "number" && status >= 400 && status < 500) {
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
- private scheduleReconnect(): void {
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
- }, RECONNECT_MS);
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 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
- }