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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Translating a routine's MCP selectors into the tool names Pi will actually honour.
3
+ *
4
+ * There are TWO vocabularies here and conflating them is why per-routine connector
5
+ * allow-lists silently granted nothing:
6
+ *
7
+ * • The SELECTOR vocabulary — "<server>__<tool>" exact, or "<server>__*" for a whole
8
+ * server. This is what a routine stores, what the app writes, and what the AI
9
+ * drafts. The double underscore is the point: no Pi builtin contains "__", so
10
+ * splitRoutineTools can tell a connector selector from a builtin name without a
11
+ * lookup table. This vocabulary is STABLE — routines on disk depend on it.
12
+ *
13
+ * • The REGISTERED vocabulary — what pi-mcp-adapter actually names a tool when it
14
+ * registers it with Pi: `formatToolName()` → "<serverPrefix>_<tool>", ONE
15
+ * underscore, with dashes in the server name folded to underscores. And Pi's
16
+ * `tools:` option is an exact-match Set (`allowedToolNames`), so a literal
17
+ * "github__*" or even "github__create_issue" handed to it matches nothing at all.
18
+ *
19
+ * This module is the translation layer, mirroring pi-mcp-adapter's `getServerPrefix`
20
+ * / `formatToolName` rather than importing them — the adapter ships as .ts in
21
+ * node_modules and pulling it into our typecheck is the thing `harbor/index.ts`
22
+ * already dodges with a variable import specifier. The mirror is four lines and is
23
+ * pinned by tests/toolSelect.test.ts.
24
+ *
25
+ * The other half of the problem: per-tool names only EXIST when direct tools are
26
+ * enabled. Otherwise the adapter exposes MCP through a single proxy tool named "mcp"
27
+ * — all servers, all tools, one grant, which is precisely what a per-routine
28
+ * allow-list is meant to avoid. So callers pair `names` with `directToolsEnv`, which
29
+ * scopes MCP_DIRECT_TOOLS to exactly the selected server/tool pairs for that one run.
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { agentDir } from "../config/paths.ts";
34
+
35
+ export type PrefixMode = "server" | "none" | "short";
36
+
37
+ /** Mirrors pi-mcp-adapter's getServerPrefix. */
38
+ export function serverPrefix(serverName: string, mode: PrefixMode): string {
39
+ if (mode === "none") return "";
40
+ if (mode === "short") {
41
+ const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
42
+ return short || "mcp";
43
+ }
44
+ return serverName.replace(/-/g, "_");
45
+ }
46
+
47
+ /** Mirrors pi-mcp-adapter's formatToolName. */
48
+ export function formatToolName(toolName: string, serverName: string, mode: PrefixMode): string {
49
+ const p = serverPrefix(serverName, mode);
50
+ return p ? `${p}_${toolName}` : toolName;
51
+ }
52
+
53
+ /** The cached tool inventory the adapter builds after connecting: server → tool names. */
54
+ export type McpInventory = Record<string, string[]>;
55
+
56
+ export interface ResolveInput {
57
+ /** "<server>__<tool>" / "<server>__*" selectors, as stored on the routine. */
58
+ selectors: string[];
59
+ /** Server → the tool names it exposes, from the adapter's metadata cache. */
60
+ inventory: McpInventory;
61
+ /** The adapter's tool-prefix mode (mcp.json → settings.toolPrefix). */
62
+ prefix: PrefixMode;
63
+ }
64
+
65
+ export interface ResolvedMcpTools {
66
+ /** Exact registered tool names to hand to Pi's `tools:` allow-list. */
67
+ names: string[];
68
+ /** Servers touched by the selectors — the set that must be reachable this run. */
69
+ servers: string[];
70
+ /** MCP_DIRECT_TOOLS entries ("server/tool", or bare "server" for a wildcard). */
71
+ directToolsEnv: string[];
72
+ /**
73
+ * Servers a selector named that the metadata cache knows nothing about. A wildcard
74
+ * over one of these expands to NOTHING, so the caller must warm the cache before
75
+ * building the session — see harbor/index.ts. Never silently ignore this.
76
+ */
77
+ coldServers: string[];
78
+ /**
79
+ * Exact selectors whose server DID report an inventory that doesn't contain that
80
+ * tool — a typo, or a tool the connector dropped. The name is still granted (it
81
+ * simply never registers), but the caller should say so rather than let the run
82
+ * quietly come back thinner than asked for.
83
+ */
84
+ unknownTools: string[];
85
+ }
86
+
87
+ /**
88
+ * Expand selectors against a known inventory. Pure — the file reads live in
89
+ * `resolveMcpSelection` below so this stays trivially testable.
90
+ *
91
+ * An EXACT selector resolves without the inventory (we can compute the registered
92
+ * name from the server + tool alone), so a connector whose cache entry is stale still
93
+ * works. A WILDCARD needs the inventory to enumerate, which is why `coldServers`
94
+ * exists.
95
+ */
96
+ export function resolveMcpTools({ selectors, inventory, prefix }: ResolveInput): ResolvedMcpTools {
97
+ const names: string[] = [];
98
+ const servers: string[] = [];
99
+ const directToolsEnv: string[] = [];
100
+ const coldServers: string[] = [];
101
+ const unknownTools: string[] = [];
102
+
103
+ const push = <T>(arr: T[], v: T) => {
104
+ if (!arr.includes(v)) arr.push(v);
105
+ };
106
+
107
+ for (const selector of selectors) {
108
+ const sep = selector.indexOf("__");
109
+ if (sep <= 0) continue; // not a selector; splitRoutineTools already routed it
110
+ const server = selector.slice(0, sep);
111
+ const tool = selector.slice(sep + 2);
112
+ if (!tool) continue;
113
+ push(servers, server);
114
+
115
+ if (tool === "*") {
116
+ const known = inventory[server];
117
+ if (!known || known.length === 0) {
118
+ push(coldServers, server);
119
+ continue;
120
+ }
121
+ // Bare server name = "every tool on this server" to MCP_DIRECT_TOOLS.
122
+ push(directToolsEnv, server);
123
+ for (const t of known) push(names, formatToolName(t, server, prefix));
124
+ continue;
125
+ }
126
+
127
+ push(names, formatToolName(tool, server, prefix));
128
+ // MCP_DIRECT_TOOLS matches the ORIGINAL (unprefixed) tool name.
129
+ push(directToolsEnv, `${server}/${tool}`);
130
+ const known = inventory[server];
131
+ if (!known || known.length === 0) push(coldServers, server);
132
+ else if (!known.includes(tool)) push(unknownTools, selector);
133
+ }
134
+
135
+ return { names, servers, directToolsEnv, coldServers, unknownTools };
136
+ }
137
+
138
+ /** The adapter's metadata cache, as it lands on disk (agent/mcp-cache.json). */
139
+ export function readMcpInventory(dir: string = agentDir()): McpInventory {
140
+ const out: McpInventory = {};
141
+ try {
142
+ const raw = JSON.parse(readFileSync(join(dir, "mcp-cache.json"), "utf8"));
143
+ for (const [server, entry] of Object.entries<any>(raw?.servers ?? {})) {
144
+ const tools = Array.isArray(entry?.tools)
145
+ ? entry.tools.map((t: any) => String(t?.name ?? "")).filter(Boolean)
146
+ : [];
147
+ out[server] = tools;
148
+ }
149
+ } catch {
150
+ /* no cache yet — every selected server is cold */
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * The adapter's prefix mode. `mcpControl.project()` pins "server" into the file it
157
+ * writes, so this is really a guard for a hand-written mcp.json.
158
+ */
159
+ export function readPrefixMode(dir: string = agentDir()): PrefixMode {
160
+ try {
161
+ const raw = JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8"));
162
+ const mode = raw?.settings?.toolPrefix;
163
+ if (mode === "server" || mode === "none" || mode === "short") return mode;
164
+ } catch {
165
+ /* no config — the adapter's own default */
166
+ }
167
+ return "server";
168
+ }
169
+
170
+ /** Read-from-disk wrapper around resolveMcpTools. */
171
+ export function resolveMcpSelection(selectors: string[], dir: string = agentDir()): ResolvedMcpTools {
172
+ return resolveMcpTools({
173
+ selectors,
174
+ inventory: readMcpInventory(dir),
175
+ prefix: readPrefixMode(dir),
176
+ });
177
+ }
@@ -14,12 +14,17 @@ import {
14
14
  type AccountCredential,
15
15
  serverBaseUrl,
16
16
  hasCredentials,
17
+ currentUser,
18
+ logout,
17
19
  runDeviceLogin,
18
20
  authedFetch,
19
21
  acquireAccountCredential,
20
22
  refreshAccountCredentials,
21
23
  notifySignedIn,
22
24
  } from "../auth/privateer.ts";
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { globalDir } from "../config/paths.ts";
23
28
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
24
29
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
25
30
  import {
@@ -36,7 +41,8 @@ import {
36
41
  // found" warning, which matters more than ever now that a signed-OUT terminal also
37
42
  // launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
38
43
  // rest are the familiar names. Also the fallback list if the live listing is
39
- // unreachable.
44
+ // unreachable. It is the FLOOR of the synchronous seed, not the whole of it — a launch
45
+ // that has seen the live catalog before seeds from the cache too (see seedCatalogIds).
40
46
  const DEFAULT_MODELS = [
41
47
  ACCOUNT_DEFAULT_MODEL_ID,
42
48
  ACCOUNT_NEAR_MODEL_ID,
@@ -58,6 +64,72 @@ function seedModel(id: string) {
58
64
  };
59
65
  }
60
66
 
67
+ // ── Catalog cache ────────────────────────────────────────────────────────────
68
+ //
69
+ // The live catalog (241 models and counting) can only be registered once the network
70
+ // fetch resolves, and a registration made after extension load does NOT reach the model
71
+ // registry immediately: pi queues it (extensions/loader.js pendingProviderRegistrations)
72
+ // and flushes it when the session BINDS. Everything that resolves a model at LAUNCH runs
73
+ // before that — Pi's findInitialModel (saved settings default) and its session-model
74
+ // restore both call modelRegistry.find() while only the synchronous seed exists. So a
75
+ // model outside DEFAULT_MODELS was un-resolvable at launch and Pi fell through to "first
76
+ // model with configured auth", i.e. a BYO provider — measurably `openrouter/*` on a
77
+ // machine with an OpenRouter key. That is the dead end defaultModel.ts exists to prevent,
78
+ // re-entered through the back door.
79
+ //
80
+ // So: remember the ids the live catalog last returned, and seed from them SYNCHRONOUSLY
81
+ // on the next launch. The live fetch still re-registers the authoritative list moments
82
+ // later, so a model the server drops disappears on the next launch rather than lingering.
83
+ //
84
+ // Deliberately ids ONLY. Privacy tiers are never cached: the tier drives the shield in
85
+ // /models, and a stale privacy claim is exactly the thing not to render from disk. The
86
+ // picker keeps fetching them live (accountCatalogLoaded stays false until it does).
87
+ const CATALOG_CACHE_MAX = 2000; // bound what a corrupted/hostile file can register
88
+
89
+ function catalogCachePath(): string {
90
+ return join(globalDir(), "account-models.json");
91
+ }
92
+
93
+ // Best effort in both directions: this cache is an optimization, and a launch must never
94
+ // fail because it couldn't be read or written.
95
+ function saveCachedCatalogIds(ids: string[]): void {
96
+ try {
97
+ mkdirSync(globalDir(), { recursive: true });
98
+ const payload = { v: 1, fetchedAt: new Date().toISOString(), ids: ids.slice(0, CATALOG_CACHE_MAX) };
99
+ writeFileSync(catalogCachePath(), JSON.stringify(payload) + "\n", "utf8");
100
+ } catch {
101
+ /* unwritable home — we just seed from DEFAULT_MODELS next launch */
102
+ }
103
+ }
104
+
105
+ export function loadCachedCatalogIds(): string[] {
106
+ try {
107
+ const path = catalogCachePath();
108
+ if (!existsSync(path)) return [];
109
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { ids?: unknown };
110
+ if (!Array.isArray(parsed.ids)) return [];
111
+ return parsed.ids.filter((id): id is string => typeof id === "string" && id.length > 0).slice(0, CATALOG_CACHE_MAX);
112
+ } catch {
113
+ return []; // absent, unreadable, or garbage — the seed list still works
114
+ }
115
+ }
116
+
117
+ // The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
118
+ // default has to be index 0 both because it must always resolve and because Pi clones the
119
+ // provider's first/default model when it synthesizes a custom model id
120
+ // (model-resolver.js buildFallbackModel).
121
+ export function seedCatalogIds(): string[] {
122
+ const ids = [...DEFAULT_MODELS];
123
+ const seen = new Set(ids);
124
+ for (const id of loadCachedCatalogIds()) {
125
+ if (!seen.has(id)) {
126
+ seen.add(id);
127
+ ids.push(id);
128
+ }
129
+ }
130
+ return ids;
131
+ }
132
+
61
133
  // One catalog entry with its server-asserted baseline privacy tier.
62
134
  export interface AccountModelInfo {
63
135
  id: string;
@@ -129,6 +201,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
129
201
  const parsed = (data.models ?? [])
130
202
  .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
131
203
  .filter((x): x is AccountModelInfo => !!x);
204
+ // Cache only a real LIVE listing — never the fallback, which would freeze the six
205
+ // seed ids on disk and read back as though it were the catalog.
206
+ if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
132
207
  infos = parsed.length ? parsed : fallback();
133
208
  }
134
209
  } catch {
@@ -144,6 +219,23 @@ export async function fetchAccountModels(): Promise<string[]> {
144
219
  return (await fetchAccountCatalog()).map((m) => m.id);
145
220
  }
146
221
 
222
+ // The device-code verification link, as an ABSOLUTE url.
223
+ //
224
+ // The server sends it scheme-less ("www.privateer.pro/settings/link-terminal?code=…"),
225
+ // and both surfaces that show it treat it as a URL: Pi's login dialog wraps it in an
226
+ // OSC-8 terminal hyperlink, and our own /login widget prints it for the user to open.
227
+ // Without a scheme an OSC-8 target isn't a valid URI, so terminals decline to linkify
228
+ // it — the one link in the sign-in flow becomes unclickable text. Prefix https:// when
229
+ // the value has no scheme of its own, and leave a well-formed (or empty) value alone.
230
+ // Only http/https are honoured: a scheme-looking prefix we don't expect is treated as a
231
+ // hostname rather than passed through to a terminal as a clickable link.
232
+ export function verificationLink(raw: string | undefined): string {
233
+ const uri = (raw ?? "").trim();
234
+ if (!uri) return "";
235
+ if (/^https?:\/\//i.test(uri)) return uri;
236
+ return `https://${uri.replace(/^\/+/, "")}`;
237
+ }
238
+
147
239
  // The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
148
240
  // the provider name). login/refreshToken/getApiKey are the whole contract.
149
241
  export const privateerOAuthProvider = {
@@ -155,12 +247,40 @@ export const privateerOAuthProvider = {
155
247
  // promise never settles, and Pi never restores the editor: the "Waiting for
156
248
  // authentication…" screen hangs with no way out. See auth/privateer.ts
157
249
  // pollForToken, which checks the signal and rejects with "Login cancelled.".
158
- async login(cb: { onDeviceCode?: (info: unknown) => void; signal?: AbortSignal }) {
250
+ async login(cb: {
251
+ onDeviceCode?: (info: unknown) => void;
252
+ onSelect?: (prompt: { message: string; options: { id: string; label: string }[] }) => Promise<string | undefined>;
253
+ signal?: AbortSignal;
254
+ }) {
159
255
  // Fresh machine? The device-code flow below fires notifySignedIn itself (via
160
256
  // pollForToken). Already linked? No device code runs — so we announce the
161
257
  // completed subscription login ourselves at the end, or the header/badge would
162
258
  // keep showing "not signed in" until the next launch.
163
- const wasLinked = hasCredentials();
259
+ //
260
+ // Already linked AND the user wants a DIFFERENT account is the third case, and it
261
+ // used to be unreachable: login() short-circuited on hasCredentials(), so choosing
262
+ // "Privateer account" while linked silently re-armed the account already signed in
263
+ // and reported success — there was no way to switch accounts from here at all. Pi
264
+ // hands us an `onSelect` callback for exactly this, so ask. Switching means signing
265
+ // this machine out first (logout revokes the machine's whole token family), which
266
+ // the prompt says plainly; the device flow then runs as if fresh.
267
+ let wasLinked = hasCredentials();
268
+ if (wasLinked && cb.onSelect) {
269
+ const user = currentUser();
270
+ const who = user?.email ?? user?.id ?? "this account";
271
+ const choice = await cb.onSelect({
272
+ message: `Already signed in as ${who}.`,
273
+ options: [
274
+ { id: "keep", label: `Stay signed in as ${who}` },
275
+ { id: "switch", label: "Sign in as a different account (signs this machine out first)" },
276
+ ],
277
+ });
278
+ if (choice === undefined) throw new Error("Login cancelled"); // selector dismissed
279
+ if (choice === "switch") {
280
+ await logout(); // revokes this machine's sessions and wipes local auth state
281
+ wasLinked = false; // fall through to the device flow as a fresh machine
282
+ }
283
+ }
164
284
  if (!wasLinked) {
165
285
  try {
166
286
  await runDeviceLogin({
@@ -168,7 +288,9 @@ export const privateerOAuthProvider = {
168
288
  onCode: (code) =>
169
289
  cb.onDeviceCode?.({
170
290
  userCode: code.user_code,
171
- verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
291
+ // Absolute url the server's value is scheme-less and Pi renders this as
292
+ // a terminal hyperlink. See verificationLink.
293
+ verificationUri: verificationLink(code.verification_uri_complete ?? code.verification_uri),
172
294
  intervalSeconds: code.interval,
173
295
  expiresInSeconds: code.expires_in,
174
296
  }),
@@ -207,9 +329,19 @@ export const privateerOAuthProvider = {
207
329
  return creds;
208
330
  },
209
331
  async refreshToken(creds: { refresh: string }) {
332
+ // Rotate THIS process's own session, never another terminal's. Pi keeps one
333
+ // credential per provider in auth.json and that file is machine-global, so the
334
+ // credential handed to us here can belong to a different, still-running terminal
335
+ // (see the ownership note above rememberAccountCredential). Rotating that one
336
+ // would take over its session and invalidate the copy it still holds — the exact
337
+ // reuse hazard the child-session split exists to avoid (auth/privateer.ts). When
338
+ // the incoming token isn't ours, rotate ours instead: Pi gets a valid credential
339
+ // either way, and the other terminal keeps its own.
340
+ const mine = armSlot().cred?.refresh;
341
+ const refresh = mine && creds.refresh !== mine ? mine : creds.refresh;
210
342
  let next: AccountCredential;
211
343
  try {
212
- next = await refreshAccountCredentials(creds.refresh);
344
+ next = await refreshAccountCredentials(refresh);
213
345
  } catch {
214
346
  // Child token expired/reused → get another. acquire (not spawn) so a terminal
215
347
  // that already holds the device's last session slot can reclaim an orphan
@@ -334,7 +466,10 @@ export function makeAccountProvider() {
334
466
  const shim = sealedShimBase();
335
467
  return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
336
468
  };
337
- let lastIds: string[] = DEFAULT_MODELS;
469
+ // Seed with the last live catalog when we have one (see seedCatalogIds): this is the
470
+ // list Pi resolves a saved default / a restored session model against at launch,
471
+ // before the live re-registration can reach the registry.
472
+ let lastIds: string[] = seedCatalogIds();
338
473
  const register = (ids: string[]): void => {
339
474
  lastIds = ids;
340
475
  pi.registerProvider!("privateer", {
@@ -345,7 +480,7 @@ export function makeAccountProvider() {
345
480
  models: ids.map(modelEntry),
346
481
  });
347
482
  };
348
- register(DEFAULT_MODELS); // immediate: provider exists this tick
483
+ register(lastIds); // immediate: provider exists this tick, with a resolvable catalog
349
484
  // Bring up the sealed shim, then re-register so sealed models pick up their shim
350
485
  // baseUrl. Registration re-runs anyway after the catalog fetch; this just makes
351
486
  // sure the switch lands even if the fetch is slow or fails.
@@ -373,6 +508,32 @@ export function makeAccountProvider() {
373
508
  // though the banner says "connected". The REPL (cli/chat.ts) and the harbor
374
509
  // already spawn one at startup; this gives the TUI the same seed.
375
510
  pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
511
+
512
+ // Two safety nets around the account channel, both OUTSIDE the request path —
513
+ // they read Pi's in-memory auth map and its finished messages, and never touch the
514
+ // inference request itself, so a healthy prompt behaves exactly as before.
515
+ //
516
+ // before_agent_start: re-arm if Pi's persisted credential vanished mid-session.
517
+ // Awaited (Pi awaits extension handlers), so the turn starts with a key rather than
518
+ // failing and asking the user to resend. See ensureAccountArmed.
519
+ pi.on?.("before_agent_start", async (_e, ctx) => {
520
+ try {
521
+ await ensureAccountArmed(ctx);
522
+ } catch {
523
+ /* the turn's own error path reports better than a diagnostic here */
524
+ }
525
+ });
526
+
527
+ // message_end: an assistant turn that ended in an auth error on the account channel
528
+ // means our session token is dead server-side. Pi has no reactive-401 refresh, so
529
+ // replace the session now instead of failing every prompt until `expires`.
530
+ // See recoverAccountSession.
531
+ pi.on?.("message_end", (e, ctx) => {
532
+ const msg = (e as { message?: { role?: string; stopReason?: string; errorMessage?: string } })?.message;
533
+ if (msg?.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;
534
+ if ((ctx as SeedContext)?.model?.provider !== "privateer") return;
535
+ void recoverAccountSession(ctx, msg.errorMessage);
536
+ });
376
537
  };
377
538
  }
378
539
 
@@ -394,7 +555,12 @@ export function makeAccountProvider() {
394
555
  // which Pi would happily reuse and 401 on.
395
556
  const ARMED = Symbol.for("privateer.accountCredential");
396
557
  type ArmedSlot = {
397
- [ARMED]?: { cred?: AccountCredential; inFlight?: Promise<AccountCredential> };
558
+ [ARMED]?: {
559
+ cred?: AccountCredential;
560
+ inFlight?: Promise<AccountCredential>;
561
+ // When we last replaced a failed session (recoverAccountSession's cooldown).
562
+ recoveredAt?: number;
563
+ };
398
564
  };
399
565
 
400
566
  function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
@@ -405,10 +571,29 @@ function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
405
571
  // Record a credential this process minted so nothing mints a second one. Exported for
406
572
  // the OAuth login path, which acquires its credential for Pi to own and would
407
573
  // otherwise leave the next arm() with nothing to reuse.
574
+ //
575
+ // This memo is also the OWNERSHIP record for Pi's persisted credential, which matters
576
+ // because auth.json holds exactly one `privateer` entry and is shared by every
577
+ // Privateer terminal on the machine. Each terminal mints its own session at launch, so
578
+ // the last one to start wins on disk and the entry any terminal reads back may belong
579
+ // to a different, still-running terminal. Two rules follow, both keyed off this memo:
580
+ //
581
+ // 1. Only DROP the persisted entry when it's the one we minted
582
+ // (dropPersistedAccountCredential). The exit hooks used to remove it
583
+ // unconditionally, so quitting one terminal deleted a live terminal's credential.
584
+ // That terminal kept working from Pi's in-memory copy until `expires`, then Pi
585
+ // reloaded auth.json, found nothing, and every prompt dead-ended on "No API key
586
+ // found for privateer" — with nothing to re-arm it before the next session_start.
587
+ // 2. Only ROTATE our own refresh token (privateerOAuthProvider.refreshToken).
408
588
  export function rememberAccountCredential(cred: AccountCredential): void {
409
589
  armSlot().cred = cred;
410
590
  }
411
591
 
592
+ // The credential this process minted, if any. Ownership probe for the two rules above.
593
+ export function ownedAccountCredential(): AccountCredential | undefined {
594
+ return armSlot().cred;
595
+ }
596
+
412
597
  // The remembered credential, if it's still usable. A minute of headroom: handing back
413
598
  // one that expires mid-request just trades a spawn for a 401.
414
599
  function liveAccountCredential(): AccountCredential | undefined {
@@ -437,7 +622,14 @@ async function accountCredential(): Promise<AccountCredential> {
437
622
  // `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
438
623
  // path privateer-brand uses to DROP the credential on sign-out).
439
624
  type SeedContext = {
440
- modelRegistry?: { authStorage?: { set?: (provider: string, cred: unknown) => void } };
625
+ modelRegistry?: {
626
+ authStorage?: {
627
+ set?: (provider: string, cred: unknown) => void;
628
+ get?: (provider: string) => unknown;
629
+ remove?: (provider: string) => void;
630
+ };
631
+ };
632
+ model?: { provider?: string; id?: string };
441
633
  hasUI?: boolean;
442
634
  ui?: { notify?: (message: string, level: string) => void };
443
635
  };
@@ -474,3 +666,120 @@ export async function armAccountCredential(
474
666
  return false;
475
667
  }
476
668
  }
669
+
670
+ // Drop Pi's PERSISTED account credential (the `privateer` entry in auth.json) — but
671
+ // ONLY when it's the one this process minted. Every exit path pairs a session revoke
672
+ // with this drop (the contract in auth/privateer.ts): Pi reuses the persisted
673
+ // credential on the next launch and refreshes it only on expiry, never reactively on a
674
+ // 401, so leaving a revoked token behind dead-ends the next run.
675
+ //
676
+ // The ownership check is what keeps that teardown from harming a CONCURRENT terminal.
677
+ // auth.json is machine-global with one entry per provider, so the entry on disk is
678
+ // whichever terminal armed last; an unconditional remove here deleted a live
679
+ // terminal's key and stranded it (see the note above rememberAccountCredential).
680
+ // Returns true only if the entry was actually removed.
681
+ //
682
+ // `force` is for the teardowns where ownership is irrelevant because NOTHING on this
683
+ // machine can use the entry any more: an explicit sign-out and a detected
684
+ // expiry/revocation both go through logout()/clearCredentials(), which revoke the
685
+ // machine's whole token family — every terminal's session included. Those callers have
686
+ // also already wiped the local credentials, so the ownership memo is gone by then and an
687
+ // ownership-checked drop would refuse and strand a dead entry on disk.
688
+ export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boolean } = {}): boolean {
689
+ const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
690
+ const mine = ownedAccountCredential()?.access;
691
+ // Every caller revokes this process's session immediately before calling us, so drop
692
+ // the memo whatever happens to the entry on disk — otherwise a later arm() in this
693
+ // process (a harbor run's session_start, say) could re-persist the revoked token.
694
+ armSlot().cred = undefined;
695
+ if (typeof store?.remove !== "function") return false;
696
+ try {
697
+ if (opts.force) {
698
+ /* whole family revoked — the entry is dead for every terminal, drop it */
699
+ } else if (typeof store.get === "function") {
700
+ const persisted = store.get("privateer") as { access?: unknown } | undefined;
701
+ if (!persisted) return false; // nothing persisted — nothing to drop
702
+ // Some other terminal's session. Leave it: it's the key that terminal is using.
703
+ if (typeof persisted.access === "string" && persisted.access !== mine) return false;
704
+ } else if (!mine) {
705
+ // Older Pi with no readable auth store AND we never minted anything — we have no
706
+ // basis to claim the entry, so leave it alone rather than break another terminal.
707
+ return false;
708
+ }
709
+ store.remove("privateer");
710
+ return true;
711
+ } catch {
712
+ return false; // older Pi shape / unwritable store — the server TTL is the fallback
713
+ }
714
+ }
715
+
716
+ // Make sure Pi still HAS an account credential before a turn goes out. Normally
717
+ // session_start's arm is enough and this is a free in-memory lookup. It exists for the
718
+ // case session_start can't cover: the persisted entry disappearing mid-session, which
719
+ // another terminal's exit could do (and still can, for a terminal running an older
720
+ // build without the ownership check above). Once the entry is gone Pi has no path back
721
+ // — getApiKey returns undefined and nothing re-arms until the next session_start — so
722
+ // every prompt fails on "No API key found for privateer".
723
+ //
724
+ // An entry that exists but has EXPIRED is deliberately left alone: that's Pi's own
725
+ // refresh path (refreshToken), and pre-empting it would mint a second session row.
726
+ export async function ensureAccountArmed(ctx: unknown): Promise<void> {
727
+ if (!hasCredentials()) return;
728
+ const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
729
+ if (typeof store?.get !== "function") return; // can't tell — leave it to session_start
730
+ try {
731
+ if (store.get("privateer")) return;
732
+ } catch {
733
+ return;
734
+ }
735
+ await armAccountCredential(ctx, { notify: false });
736
+ }
737
+
738
+ // Errors that mean "the token we're presenting is no longer accepted", as opposed to a
739
+ // cap, a network blip, or a model error. Matched against the provider error text Pi
740
+ // surfaces (the OpenAI SDK prefixes the status, and our backend sends `code` +
741
+ // `message`), so a revoked or dead session is recognizable without parsing internals.
742
+ // "No API key ... privateer" covers both wordings that mean the channel wasn't armed:
743
+ // pi-ai's "No API key for provider: privateer" (thrown when getApiKey resolves to
744
+ // undefined, which is also what a swallowed refresh failure looks like — pi-ai flattens
745
+ // our reason into "Failed to refresh OAuth token") and Pi's own "No API key found for
746
+ // privateer." guidance text.
747
+ const ACCOUNT_AUTH_FAILURE =
748
+ /\b401\b|SESSION_REVOKED|Authentication required|Invalid token|Failed to refresh OAuth token|No API key\b[^\n]*privateer/i;
749
+
750
+ // Don't spin: if the account itself is gone, one replacement attempt per window is
751
+ // plenty, and the user gets a clear message instead of a retry loop.
752
+ const RECOVERY_COOLDOWN_MS = 30_000;
753
+
754
+ // Replace a dead account session after an auth failure, so the NEXT prompt works.
755
+ //
756
+ // This is the client-side answer to a gap in Pi's OAuth contract: inference goes out
757
+ // over Pi's own HTTP path (provider baseUrl + the bearer from getApiKey), which has no
758
+ // reactive-401 hook — unlike authedFetch, which refreshes and retries in place. Pi
759
+ // refreshes on `expires` alone, so a session that dies server-side early (revoked from
760
+ // the app's Linked Devices, or cascaded by another device's logout) stays dead for the
761
+ // remaining lifetime of the access token — up to ~24h — with every prompt failing.
762
+ // Detecting the failure after the fact and re-arming turns that into a single failed
763
+ // turn the user can resend.
764
+ export async function recoverAccountSession(ctx: unknown, errorMessage: string): Promise<boolean> {
765
+ if (!hasCredentials()) return false; // signed out: onSessionExpired owns that message
766
+ if (!ACCOUNT_AUTH_FAILURE.test(errorMessage)) return false;
767
+ const slot = armSlot();
768
+ const now = Date.now();
769
+ if (slot.recoveredAt !== undefined && now - slot.recoveredAt < RECOVERY_COOLDOWN_MS) return false;
770
+ slot.recoveredAt = now;
771
+ // The credential we hold is the one that just failed — forget it so the arm below
772
+ // reclaims or spawns a live session instead of handing back the dead token.
773
+ slot.cred = undefined;
774
+ const armed = await armAccountCredential(ctx, { notify: false });
775
+ const c = ctx as SeedContext;
776
+ if (c?.hasUI) {
777
+ c.ui?.notify?.(
778
+ armed
779
+ ? "Your Privateer account session had expired — opened a new one. Send that message again."
780
+ : "Your Privateer account session isn't valid any more and couldn't be renewed. Run /login to sign back in.",
781
+ armed ? "warning" : "error",
782
+ );
783
+ }
784
+ return armed;
785
+ }
@@ -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";
@@ -62,7 +67,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
62
67
  let initialPromptSent = false;
63
68
  let stopped = false;
64
69
  let spawnedAccount = false;
65
- let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
70
+ let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
66
71
 
67
72
  let attachTimer: ReturnType<typeof setTimeout> | undefined;
68
73
  let lifeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -77,7 +82,9 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
77
82
  // app's Linked Devices; the harbor's own child session stays alive. Best-effort.
78
83
  if (spawnedAccount) {
79
84
  try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
80
- 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 */ }
81
88
  }
82
89
  deps.onClosed(termId);
83
90
  deps.log(`live task ${termId} closed`);
@@ -160,6 +167,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
160
167
  try {
161
168
  const creds = await spawnAccountCredentials();
162
169
  (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
170
+ rememberAccountCredential(creds); // claim it, so stop() drops OUR entry only
163
171
  spawnedAccount = true;
164
172
  } catch (e) {
165
173
  deps.log(`live task ${termId} account channel unavailable: ${(e as Error).message}`);