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.
- package/extensions/privateer-brand.ts +24 -11
- package/extensions/privateer-connect.ts +135 -10
- package/extensions/privateer-privacy.ts +20 -8
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -1
- package/src/auth/privateer.ts +24 -5
- package/src/channels/run.ts +18 -1
- package/src/cli/chat.ts +11 -2
- package/src/config/hosted.ts +21 -0
- package/src/harbor/index.ts +317 -77
- package/src/harbor/ipc.ts +50 -0
- package/src/harbor/service.ts +56 -8
- package/src/mcp/catalog.ts +32 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/providers/account.ts +364 -28
- package/src/remote/liveTaskSession.ts +11 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +124 -8
- package/src/remote/routinesControl.ts +1 -1
- package/src/routines/schema.ts +2 -0
- package/src/routines/store.ts +1 -1
- package/src/routines/toolSelect.ts +13 -19
- package/src/tools/web.ts +236 -0
package/src/providers/account.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
@@ -297,6 +429,51 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
297
429
|
}
|
|
298
430
|
}
|
|
299
431
|
|
|
432
|
+
// A model entry, with a per-model baseUrl override once the EHBP shim is listening:
|
|
433
|
+
// `tinfoil/*` then route through the loopback shim (which seals to the blind relay)
|
|
434
|
+
// instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the provider
|
|
435
|
+
// baseUrl. Until the shim is up (or when sealed mode is off) sealed models fall back to
|
|
436
|
+
// the cleartext path — and the badge stays honestly `tee-unverified` (see accountPosture).
|
|
437
|
+
function modelEntry(id: string) {
|
|
438
|
+
const base = seedModel(id);
|
|
439
|
+
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
440
|
+
const shim = sealedShimBase();
|
|
441
|
+
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// The Pi provider config for the account channel, over a given set of model ids.
|
|
445
|
+
export function accountProviderConfig(ids: string[]): Record<string, unknown> {
|
|
446
|
+
return {
|
|
447
|
+
name: "Privateer account",
|
|
448
|
+
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
449
|
+
api: "openai-completions",
|
|
450
|
+
oauth: privateerOAuthProvider,
|
|
451
|
+
models: ids.map(modelEntry),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Re-assert the account channel's registration from ANOTHER extension.
|
|
456
|
+
//
|
|
457
|
+
// pi-privacy also ships a `privateer` provider (its PRIVACY_PROVIDERS catalog) — the
|
|
458
|
+
// PUBLIC developer-key channel: baseUrl api.privateer.pro/v1, `${PRIVATEER_API_KEY}`,
|
|
459
|
+
// and a single seed model (near/zai-org/GLM-5.1-FP8). Pi's registerProvider FULLY
|
|
460
|
+
// REPLACES a provider's model list and its request config, so whichever registration
|
|
461
|
+
// lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
|
|
462
|
+
// on a typical box puts privateer-privacy after privateer-account. The account channel's
|
|
463
|
+
// whole catalog was then replaced by that one model, so the default `tinfoil/glm-5-2` no
|
|
464
|
+
// longer resolved ("not found for provider privateer. Using custom model id") and the
|
|
465
|
+
// synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
|
|
466
|
+
//
|
|
467
|
+
// So privateer-privacy.ts calls this right after pi-privacy runs, exactly as it re-widens
|
|
468
|
+
// `tinfoil`. Idempotent and order-independent: if privacy happens to load first, the
|
|
469
|
+
// account extension's own registration lands afterwards with the same config, and the
|
|
470
|
+
// live-catalog fetch re-registers over both moments later either way.
|
|
471
|
+
export function registerAccountModels(pi: {
|
|
472
|
+
registerProvider?: (name: string, config: unknown) => void;
|
|
473
|
+
}): void {
|
|
474
|
+
pi.registerProvider?.("privateer", accountProviderConfig(seedCatalogIds()));
|
|
475
|
+
}
|
|
476
|
+
|
|
300
477
|
// Extension factory: registers the account provider so `/login` can offer it.
|
|
301
478
|
//
|
|
302
479
|
// We register UNCONDITIONALLY (not only when a machine login already exists). Pi's
|
|
@@ -322,30 +499,15 @@ export function makeAccountProvider() {
|
|
|
322
499
|
on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
|
|
323
500
|
}): void => {
|
|
324
501
|
if (typeof pi.registerProvider !== "function") return;
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
// sealed mode is off) sealed models fall back to the cleartext path — and the
|
|
330
|
-
// badge stays honestly `tee-unverified` (see accountPosture).
|
|
331
|
-
const modelEntry = (id: string) => {
|
|
332
|
-
const base = seedModel(id);
|
|
333
|
-
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
334
|
-
const shim = sealedShimBase();
|
|
335
|
-
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
336
|
-
};
|
|
337
|
-
let lastIds: string[] = DEFAULT_MODELS;
|
|
502
|
+
// Seed with the last live catalog when we have one (see seedCatalogIds): this is the
|
|
503
|
+
// list Pi resolves a saved default / a restored session model against at launch,
|
|
504
|
+
// before the live re-registration can reach the registry.
|
|
505
|
+
let lastIds: string[] = seedCatalogIds();
|
|
338
506
|
const register = (ids: string[]): void => {
|
|
339
507
|
lastIds = ids;
|
|
340
|
-
pi.registerProvider!("privateer",
|
|
341
|
-
name: "Privateer account",
|
|
342
|
-
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
343
|
-
api: "openai-completions",
|
|
344
|
-
oauth: privateerOAuthProvider,
|
|
345
|
-
models: ids.map(modelEntry),
|
|
346
|
-
});
|
|
508
|
+
pi.registerProvider!("privateer", accountProviderConfig(ids));
|
|
347
509
|
};
|
|
348
|
-
register(
|
|
510
|
+
register(lastIds); // immediate: provider exists this tick, with a resolvable catalog
|
|
349
511
|
// Bring up the sealed shim, then re-register so sealed models pick up their shim
|
|
350
512
|
// baseUrl. Registration re-runs anyway after the catalog fetch; this just makes
|
|
351
513
|
// sure the switch lands even if the fetch is slow or fails.
|
|
@@ -373,6 +535,32 @@ export function makeAccountProvider() {
|
|
|
373
535
|
// though the banner says "connected". The REPL (cli/chat.ts) and the harbor
|
|
374
536
|
// already spawn one at startup; this gives the TUI the same seed.
|
|
375
537
|
pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
|
|
538
|
+
|
|
539
|
+
// Two safety nets around the account channel, both OUTSIDE the request path —
|
|
540
|
+
// they read Pi's in-memory auth map and its finished messages, and never touch the
|
|
541
|
+
// inference request itself, so a healthy prompt behaves exactly as before.
|
|
542
|
+
//
|
|
543
|
+
// before_agent_start: re-arm if Pi's persisted credential vanished mid-session.
|
|
544
|
+
// Awaited (Pi awaits extension handlers), so the turn starts with a key rather than
|
|
545
|
+
// failing and asking the user to resend. See ensureAccountArmed.
|
|
546
|
+
pi.on?.("before_agent_start", async (_e, ctx) => {
|
|
547
|
+
try {
|
|
548
|
+
await ensureAccountArmed(ctx);
|
|
549
|
+
} catch {
|
|
550
|
+
/* the turn's own error path reports better than a diagnostic here */
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// message_end: an assistant turn that ended in an auth error on the account channel
|
|
555
|
+
// means our session token is dead server-side. Pi has no reactive-401 refresh, so
|
|
556
|
+
// replace the session now instead of failing every prompt until `expires`.
|
|
557
|
+
// See recoverAccountSession.
|
|
558
|
+
pi.on?.("message_end", (e, ctx) => {
|
|
559
|
+
const msg = (e as { message?: { role?: string; stopReason?: string; errorMessage?: string } })?.message;
|
|
560
|
+
if (msg?.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;
|
|
561
|
+
if ((ctx as SeedContext)?.model?.provider !== "privateer") return;
|
|
562
|
+
void recoverAccountSession(ctx, msg.errorMessage);
|
|
563
|
+
});
|
|
376
564
|
};
|
|
377
565
|
}
|
|
378
566
|
|
|
@@ -394,7 +582,12 @@ export function makeAccountProvider() {
|
|
|
394
582
|
// which Pi would happily reuse and 401 on.
|
|
395
583
|
const ARMED = Symbol.for("privateer.accountCredential");
|
|
396
584
|
type ArmedSlot = {
|
|
397
|
-
[ARMED]?: {
|
|
585
|
+
[ARMED]?: {
|
|
586
|
+
cred?: AccountCredential;
|
|
587
|
+
inFlight?: Promise<AccountCredential>;
|
|
588
|
+
// When we last replaced a failed session (recoverAccountSession's cooldown).
|
|
589
|
+
recoveredAt?: number;
|
|
590
|
+
};
|
|
398
591
|
};
|
|
399
592
|
|
|
400
593
|
function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
|
|
@@ -405,10 +598,29 @@ function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
|
|
|
405
598
|
// Record a credential this process minted so nothing mints a second one. Exported for
|
|
406
599
|
// the OAuth login path, which acquires its credential for Pi to own and would
|
|
407
600
|
// otherwise leave the next arm() with nothing to reuse.
|
|
601
|
+
//
|
|
602
|
+
// This memo is also the OWNERSHIP record for Pi's persisted credential, which matters
|
|
603
|
+
// because auth.json holds exactly one `privateer` entry and is shared by every
|
|
604
|
+
// Privateer terminal on the machine. Each terminal mints its own session at launch, so
|
|
605
|
+
// the last one to start wins on disk and the entry any terminal reads back may belong
|
|
606
|
+
// to a different, still-running terminal. Two rules follow, both keyed off this memo:
|
|
607
|
+
//
|
|
608
|
+
// 1. Only DROP the persisted entry when it's the one we minted
|
|
609
|
+
// (dropPersistedAccountCredential). The exit hooks used to remove it
|
|
610
|
+
// unconditionally, so quitting one terminal deleted a live terminal's credential.
|
|
611
|
+
// That terminal kept working from Pi's in-memory copy until `expires`, then Pi
|
|
612
|
+
// reloaded auth.json, found nothing, and every prompt dead-ended on "No API key
|
|
613
|
+
// found for privateer" — with nothing to re-arm it before the next session_start.
|
|
614
|
+
// 2. Only ROTATE our own refresh token (privateerOAuthProvider.refreshToken).
|
|
408
615
|
export function rememberAccountCredential(cred: AccountCredential): void {
|
|
409
616
|
armSlot().cred = cred;
|
|
410
617
|
}
|
|
411
618
|
|
|
619
|
+
// The credential this process minted, if any. Ownership probe for the two rules above.
|
|
620
|
+
export function ownedAccountCredential(): AccountCredential | undefined {
|
|
621
|
+
return armSlot().cred;
|
|
622
|
+
}
|
|
623
|
+
|
|
412
624
|
// The remembered credential, if it's still usable. A minute of headroom: handing back
|
|
413
625
|
// one that expires mid-request just trades a spawn for a 401.
|
|
414
626
|
function liveAccountCredential(): AccountCredential | undefined {
|
|
@@ -437,7 +649,14 @@ async function accountCredential(): Promise<AccountCredential> {
|
|
|
437
649
|
// `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
|
|
438
650
|
// path privateer-brand uses to DROP the credential on sign-out).
|
|
439
651
|
type SeedContext = {
|
|
440
|
-
modelRegistry?: {
|
|
652
|
+
modelRegistry?: {
|
|
653
|
+
authStorage?: {
|
|
654
|
+
set?: (provider: string, cred: unknown) => void;
|
|
655
|
+
get?: (provider: string) => unknown;
|
|
656
|
+
remove?: (provider: string) => void;
|
|
657
|
+
};
|
|
658
|
+
};
|
|
659
|
+
model?: { provider?: string; id?: string };
|
|
441
660
|
hasUI?: boolean;
|
|
442
661
|
ui?: { notify?: (message: string, level: string) => void };
|
|
443
662
|
};
|
|
@@ -474,3 +693,120 @@ export async function armAccountCredential(
|
|
|
474
693
|
return false;
|
|
475
694
|
}
|
|
476
695
|
}
|
|
696
|
+
|
|
697
|
+
// Drop Pi's PERSISTED account credential (the `privateer` entry in auth.json) — but
|
|
698
|
+
// ONLY when it's the one this process minted. Every exit path pairs a session revoke
|
|
699
|
+
// with this drop (the contract in auth/privateer.ts): Pi reuses the persisted
|
|
700
|
+
// credential on the next launch and refreshes it only on expiry, never reactively on a
|
|
701
|
+
// 401, so leaving a revoked token behind dead-ends the next run.
|
|
702
|
+
//
|
|
703
|
+
// The ownership check is what keeps that teardown from harming a CONCURRENT terminal.
|
|
704
|
+
// auth.json is machine-global with one entry per provider, so the entry on disk is
|
|
705
|
+
// whichever terminal armed last; an unconditional remove here deleted a live
|
|
706
|
+
// terminal's key and stranded it (see the note above rememberAccountCredential).
|
|
707
|
+
// Returns true only if the entry was actually removed.
|
|
708
|
+
//
|
|
709
|
+
// `force` is for the teardowns where ownership is irrelevant because NOTHING on this
|
|
710
|
+
// machine can use the entry any more: an explicit sign-out and a detected
|
|
711
|
+
// expiry/revocation both go through logout()/clearCredentials(), which revoke the
|
|
712
|
+
// machine's whole token family — every terminal's session included. Those callers have
|
|
713
|
+
// also already wiped the local credentials, so the ownership memo is gone by then and an
|
|
714
|
+
// ownership-checked drop would refuse and strand a dead entry on disk.
|
|
715
|
+
export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boolean } = {}): boolean {
|
|
716
|
+
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
717
|
+
const mine = ownedAccountCredential()?.access;
|
|
718
|
+
// Every caller revokes this process's session immediately before calling us, so drop
|
|
719
|
+
// the memo whatever happens to the entry on disk — otherwise a later arm() in this
|
|
720
|
+
// process (a harbor run's session_start, say) could re-persist the revoked token.
|
|
721
|
+
armSlot().cred = undefined;
|
|
722
|
+
if (typeof store?.remove !== "function") return false;
|
|
723
|
+
try {
|
|
724
|
+
if (opts.force) {
|
|
725
|
+
/* whole family revoked — the entry is dead for every terminal, drop it */
|
|
726
|
+
} else if (typeof store.get === "function") {
|
|
727
|
+
const persisted = store.get("privateer") as { access?: unknown } | undefined;
|
|
728
|
+
if (!persisted) return false; // nothing persisted — nothing to drop
|
|
729
|
+
// Some other terminal's session. Leave it: it's the key that terminal is using.
|
|
730
|
+
if (typeof persisted.access === "string" && persisted.access !== mine) return false;
|
|
731
|
+
} else if (!mine) {
|
|
732
|
+
// Older Pi with no readable auth store AND we never minted anything — we have no
|
|
733
|
+
// basis to claim the entry, so leave it alone rather than break another terminal.
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
store.remove("privateer");
|
|
737
|
+
return true;
|
|
738
|
+
} catch {
|
|
739
|
+
return false; // older Pi shape / unwritable store — the server TTL is the fallback
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Make sure Pi still HAS an account credential before a turn goes out. Normally
|
|
744
|
+
// session_start's arm is enough and this is a free in-memory lookup. It exists for the
|
|
745
|
+
// case session_start can't cover: the persisted entry disappearing mid-session, which
|
|
746
|
+
// another terminal's exit could do (and still can, for a terminal running an older
|
|
747
|
+
// build without the ownership check above). Once the entry is gone Pi has no path back
|
|
748
|
+
// — getApiKey returns undefined and nothing re-arms until the next session_start — so
|
|
749
|
+
// every prompt fails on "No API key found for privateer".
|
|
750
|
+
//
|
|
751
|
+
// An entry that exists but has EXPIRED is deliberately left alone: that's Pi's own
|
|
752
|
+
// refresh path (refreshToken), and pre-empting it would mint a second session row.
|
|
753
|
+
export async function ensureAccountArmed(ctx: unknown): Promise<void> {
|
|
754
|
+
if (!hasCredentials()) return;
|
|
755
|
+
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
756
|
+
if (typeof store?.get !== "function") return; // can't tell — leave it to session_start
|
|
757
|
+
try {
|
|
758
|
+
if (store.get("privateer")) return;
|
|
759
|
+
} catch {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
await armAccountCredential(ctx, { notify: false });
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Errors that mean "the token we're presenting is no longer accepted", as opposed to a
|
|
766
|
+
// cap, a network blip, or a model error. Matched against the provider error text Pi
|
|
767
|
+
// surfaces (the OpenAI SDK prefixes the status, and our backend sends `code` +
|
|
768
|
+
// `message`), so a revoked or dead session is recognizable without parsing internals.
|
|
769
|
+
// "No API key ... privateer" covers both wordings that mean the channel wasn't armed:
|
|
770
|
+
// pi-ai's "No API key for provider: privateer" (thrown when getApiKey resolves to
|
|
771
|
+
// undefined, which is also what a swallowed refresh failure looks like — pi-ai flattens
|
|
772
|
+
// our reason into "Failed to refresh OAuth token") and Pi's own "No API key found for
|
|
773
|
+
// privateer." guidance text.
|
|
774
|
+
const ACCOUNT_AUTH_FAILURE =
|
|
775
|
+
/\b401\b|SESSION_REVOKED|Authentication required|Invalid token|Failed to refresh OAuth token|No API key\b[^\n]*privateer/i;
|
|
776
|
+
|
|
777
|
+
// Don't spin: if the account itself is gone, one replacement attempt per window is
|
|
778
|
+
// plenty, and the user gets a clear message instead of a retry loop.
|
|
779
|
+
const RECOVERY_COOLDOWN_MS = 30_000;
|
|
780
|
+
|
|
781
|
+
// Replace a dead account session after an auth failure, so the NEXT prompt works.
|
|
782
|
+
//
|
|
783
|
+
// This is the client-side answer to a gap in Pi's OAuth contract: inference goes out
|
|
784
|
+
// over Pi's own HTTP path (provider baseUrl + the bearer from getApiKey), which has no
|
|
785
|
+
// reactive-401 hook — unlike authedFetch, which refreshes and retries in place. Pi
|
|
786
|
+
// refreshes on `expires` alone, so a session that dies server-side early (revoked from
|
|
787
|
+
// the app's Linked Devices, or cascaded by another device's logout) stays dead for the
|
|
788
|
+
// remaining lifetime of the access token — up to ~24h — with every prompt failing.
|
|
789
|
+
// Detecting the failure after the fact and re-arming turns that into a single failed
|
|
790
|
+
// turn the user can resend.
|
|
791
|
+
export async function recoverAccountSession(ctx: unknown, errorMessage: string): Promise<boolean> {
|
|
792
|
+
if (!hasCredentials()) return false; // signed out: onSessionExpired owns that message
|
|
793
|
+
if (!ACCOUNT_AUTH_FAILURE.test(errorMessage)) return false;
|
|
794
|
+
const slot = armSlot();
|
|
795
|
+
const now = Date.now();
|
|
796
|
+
if (slot.recoveredAt !== undefined && now - slot.recoveredAt < RECOVERY_COOLDOWN_MS) return false;
|
|
797
|
+
slot.recoveredAt = now;
|
|
798
|
+
// The credential we hold is the one that just failed — forget it so the arm below
|
|
799
|
+
// reclaims or spawns a live session instead of handing back the dead token.
|
|
800
|
+
slot.cred = undefined;
|
|
801
|
+
const armed = await armAccountCredential(ctx, { notify: false });
|
|
802
|
+
const c = ctx as SeedContext;
|
|
803
|
+
if (c?.hasUI) {
|
|
804
|
+
c.ui?.notify?.(
|
|
805
|
+
armed
|
|
806
|
+
? "Your Privateer account session had expired — opened a new one. Send that message again."
|
|
807
|
+
: "Your Privateer account session isn't valid any more and couldn't be renewed. Run /login to sign back in.",
|
|
808
|
+
armed ? "warning" : "error",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
return armed;
|
|
812
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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}`);
|