privateer-agent 0.8.0 → 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.
@@ -20,7 +20,12 @@ import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
21
  import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
22
  import { makePiPrivacyExtension } from "pi-privacy";
23
- import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
23
+ import {
24
+ makeAccountProvider,
25
+ privateerChannel,
26
+ rememberAccountCredential,
27
+ dropPersistedAccountCredential,
28
+ } from "../providers/account.ts";
24
29
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
25
30
  import { RemoteBridge } from "./remoteBridge.ts";
26
31
  import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
@@ -43,6 +48,10 @@ export interface LiveTaskDeps {
43
48
  // abandoned spawn can't run the account meter or hold resources forever).
44
49
  const ATTACH_GRACE_MS = 180_000; // 3 min to attach after spawn
45
50
  const MAX_LIFETIME_MS = 30 * 60_000; // 30 min absolute cap
51
+ // How long to wait for the spawned terminal to actually register on the relay before we give
52
+ // up and report the spawn as failed. `start()` resolves before the socket opens, so without
53
+ // this confirmation the harbor would announce a terminal the app can never attach to.
54
+ const REGISTER_TIMEOUT_MS = 20_000;
46
55
 
47
56
  export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps): Promise<LiveTaskHandle> {
48
57
  const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
@@ -58,7 +67,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
58
67
  let initialPromptSent = false;
59
68
  let stopped = false;
60
69
  let spawnedAccount = false;
61
- let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
70
+ let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
62
71
 
63
72
  let attachTimer: ReturnType<typeof setTimeout> | undefined;
64
73
  let lifeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -73,7 +82,9 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
73
82
  // app's Linked Devices; the harbor's own child session stays alive. Best-effort.
74
83
  if (spawnedAccount) {
75
84
  try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
76
- try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
85
+ // Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
86
+ // must not delete an interactive terminal's entry (see providers/account.ts).
87
+ try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
77
88
  }
78
89
  deps.onClosed(termId);
79
90
  deps.log(`live task ${termId} closed`);
@@ -156,6 +167,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
156
167
  try {
157
168
  const creds = await spawnAccountCredentials();
158
169
  (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
170
+ rememberAccountCredential(creds); // claim it, so stop() drops OUR entry only
159
171
  spawnedAccount = true;
160
172
  } catch (e) {
161
173
  deps.log(`live task ${termId} account channel unavailable: ${(e as Error).message}`);
@@ -213,6 +225,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
213
225
  relay = new RelayClient(bridge.callbacks, { termId, label });
214
226
  bridge.attachRelay(relay);
215
227
  await relay.start();
228
+ // start() resolves before the socket registers; confirm the terminal is actually live on
229
+ // the relay BEFORE returning (→ the harbor announces task_spawned). Rejects on a hard
230
+ // failure (e.g. the concurrency cap) or timeout → the catch below tears down and propagates,
231
+ // so the harbor reports task_spawn_error instead of pointing the app at a dead terminal.
232
+ await relay.awaitRegistered(REGISTER_TIMEOUT_MS);
216
233
 
217
234
  // Reap if nobody ever attaches, and cap the absolute lifetime regardless.
218
235
  attachTimer = setTimeout(() => { if (!attached) void stop(); }, ATTACH_GRACE_MS);
@@ -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,16 +291,26 @@ 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;
306
+ // First-registration signal (opt-in via awaitRegistered): `start()` resolves before the
307
+ // socket actually opens, so a caller that must not act until the terminal is truly live on
308
+ // the relay — e.g. a live-task spawn that announces `task_spawned` — waits on this instead.
309
+ // Settled once: resolved on the first ws `open`, rejected on a hard (4xx) ticket-mint
310
+ // failure. Reconnect blips after a successful first open do NOT re-settle it.
311
+ private firstConnectSettled = false;
312
+ private firstConnectError: Error | null = null;
313
+ private firstConnectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [];
298
314
  // In-progress file transfers from the app, keyed by the controller's attachment
299
315
  // id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
300
316
  private readonly incoming = new Map<
@@ -322,8 +338,43 @@ export class RelayClient {
322
338
  await this.connect();
323
339
  }
324
340
 
341
+ // Resolve once this terminal has actually registered on the relay (ws `open`), reject on a
342
+ // hard registration failure (e.g. a 403 concurrency-cap denial) or after `timeoutMs`.
343
+ // `start()` resolves before the socket opens, so a live-task spawn calls this to confirm the
344
+ // app can attach BEFORE it announces the terminal — otherwise the app is told to drive a
345
+ // terminal that never came up and hangs. Idempotent; settling stores its result so a caller
346
+ // that awaits after the fact still gets it (no dangling/unhandled rejection).
347
+ awaitRegistered(timeoutMs: number): Promise<void> {
348
+ if (this.firstConnectSettled) {
349
+ return this.firstConnectError ? Promise.reject(this.firstConnectError) : Promise.resolve();
350
+ }
351
+ return new Promise<void>((resolve, reject) => {
352
+ const waiter = {
353
+ resolve: () => { clearTimeout(timer); resolve(); },
354
+ reject: (e: Error) => { clearTimeout(timer); reject(e); },
355
+ };
356
+ const timer = setTimeout(() => {
357
+ this.firstConnectWaiters = this.firstConnectWaiters.filter((w) => w !== waiter);
358
+ reject(new Error(`relay registration timed out after ${timeoutMs}ms`));
359
+ }, timeoutMs);
360
+ this.firstConnectWaiters.push(waiter);
361
+ });
362
+ }
363
+
364
+ private settleFirstConnect(err?: Error): void {
365
+ if (this.firstConnectSettled) return;
366
+ this.firstConnectSettled = true;
367
+ this.firstConnectError = err ?? null;
368
+ const waiters = this.firstConnectWaiters;
369
+ this.firstConnectWaiters = [];
370
+ for (const w of waiters) err ? w.reject(err) : w.resolve();
371
+ }
372
+
325
373
  stop(): void {
326
374
  this.closed = true;
375
+ // Fail any awaitRegistered() waiter promptly instead of leaving it to time out — a
376
+ // terminal stopped before it ever registered is never coming up.
377
+ this.settleFirstConnect(new Error("relay stopped before registering"));
327
378
  if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
328
379
  if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
329
380
  this.bufKind = null;
@@ -344,7 +395,23 @@ export class RelayClient {
344
395
  headers: { "Content-Type": "application/json" },
345
396
  body: JSON.stringify({ role: "agent", termId: this.termId, label: this.label }),
346
397
  });
347
- if (!res.ok) throw new Error(`relay ticket HTTP ${res.status}`);
398
+ if (!res.ok) {
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
+ );
412
+ e.status = res.status;
413
+ throw e;
414
+ }
348
415
  const { ticket } = (await res.json()) as { ticket: string };
349
416
 
350
417
  const wsUrl =
@@ -357,6 +424,8 @@ export class RelayClient {
357
424
 
358
425
  ws.on("open", () => {
359
426
  opened = true;
427
+ this.refusal = null; // a later refusal is news again
428
+ this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
360
429
  this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
361
430
  });
362
431
  ws.on("message", (data) => this.handle(data));
@@ -381,6 +450,29 @@ export class RelayClient {
381
450
  // Ticket mint failed (auth/network/route) — surface it; a silent failure
382
451
  // looks identical to "connected but ignoring me".
383
452
  const msg = err instanceof Error ? err.message : String(err);
453
+ // A 4xx (e.g. 403 concurrency cap) won't self-heal by retrying the same request —
454
+ // fail-fast any awaitRegistered() caller (a live-task spawn) so it stops hanging.
455
+ const status = (err as { status?: number })?.status;
456
+ const refused = typeof status === "number" && status >= 400 && status < 500;
457
+ if (refused) {
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;
473
+ }
474
+ // Transient (network/route/5xx): stay on the fast retry.
475
+ this.refusal = null;
384
476
  this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
385
477
  this.scheduleReconnect();
386
478
  } finally {
@@ -392,12 +484,12 @@ export class RelayClient {
392
484
  if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
393
485
  }
394
486
 
395
- private scheduleReconnect(): void {
487
+ private scheduleReconnect(delayMs: number = RECONNECT_MS): void {
396
488
  if (this.closed || this.reconnectTimer) return;
397
489
  this.reconnectTimer = setTimeout(() => {
398
490
  this.reconnectTimer = undefined;
399
491
  void this.connect();
400
- }, RECONNECT_MS);
492
+ }, delayMs);
401
493
  }
402
494
 
403
495
  private handle(data: WebSocket.RawData): void {
@@ -691,6 +783,13 @@ export class RelayClient {
691
783
  this.rawSend({ type: "task_spawned", termId, label });
692
784
  }
693
785
 
786
+ // Tell the app a live task spawn FAILED (reply to task_spawn), with a short reason, so the
787
+ // spawn screen can stop waiting and show it — a live spawn otherwise has no failure signal
788
+ // and the app would spin until its own timeout. Fire-and-forget over the management relay.
789
+ sendTaskSpawnError(reason: string): void {
790
+ this.rawSend({ type: "task_spawn_error", reason: safe(reason, 300) });
791
+ }
792
+
694
793
  sendEvent(ev: EngineEvent): void {
695
794
  if (ev.type === "text") return this.bufferDelta("text", ev.text);
696
795
  if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
@@ -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}`).