privateer-agent 0.6.6 → 0.6.8
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/README.md +163 -22
- package/SECURITY.md +5 -1
- package/bin/privateer-launch.mjs +31 -23
- package/extensions/privateer-brand.ts +147 -60
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +110 -3
- package/src/auth/accountSessions.ts +157 -0
- package/src/auth/privateer.ts +225 -41
- package/src/cli/chat.ts +3 -3
- package/src/config/paths.ts +9 -0
- package/src/daemon/index.ts +4 -4
- package/src/providers/account.ts +167 -17
- package/src/providers/defaultModel.ts +42 -26
|
@@ -5,25 +5,30 @@
|
|
|
5
5
|
// 1. A branded startup header — the anchor mark + "✻ PRIVATEER" wordmark + the
|
|
6
6
|
// "Chart your own course privately." tagline (ported from tree-cli's Banner).
|
|
7
7
|
// 2. A live status-bar badge (⚓ account) showing the sign-in state at a glance.
|
|
8
|
-
// 3.
|
|
8
|
+
// 3. Auth UX — /login, /logout, and a /privateer hub — which drive the
|
|
9
9
|
// device-code flow the account channel needs.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// ONE vocabulary, deliberately: log in / log out. This file used to avoid shadowing
|
|
12
|
+
// Pi's built-in /login and /logout and shipped /signin and /signout alongside them,
|
|
13
|
+
// which left the user with two auth vocabularies and — worse — a /logout that did
|
|
14
|
+
// not log you out. Pi's /logout only clears Pi's own authStorage; the Privateer
|
|
15
|
+
// machine login lives in ~/.privateer/credentials.json and survived it, so /logout
|
|
16
|
+
// on a signed-in machine reported "No stored credentials to remove" and changed
|
|
17
|
+
// nothing. We now own both verbs: /logout here is canonical and Pi's built-in is
|
|
18
|
+
// redirected to it (patches/, same mechanism as the /model → /models redirect).
|
|
19
|
+
// /signin and /signout stay as undocumented aliases for muscle memory.
|
|
17
20
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
+
// The account provider also registers unconditionally (see makeAccountProvider), so
|
|
22
|
+
// Privateer appears under Pi's "Use a subscription" list and a first-time user can
|
|
23
|
+
// log in that way too. On a successful login we hot-register the account provider so
|
|
24
|
+
// privateer/* models appear immediately (the account catalog refreshes to the live
|
|
25
|
+
// listing without a restart).
|
|
21
26
|
|
|
22
27
|
import { readFileSync, appendFileSync } from "node:fs";
|
|
23
28
|
import { homedir } from "node:os";
|
|
24
29
|
import { join } from "node:path";
|
|
25
30
|
import * as priv from "../src/auth/privateer.ts";
|
|
26
|
-
import { makeAccountProvider } from "../src/providers/account.ts";
|
|
31
|
+
import { armAccountCredential, makeAccountProvider } from "../src/providers/account.ts";
|
|
27
32
|
import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
|
|
28
33
|
import { discoverContextFiles, onContextChanged } from "../src/context.ts";
|
|
29
34
|
import { type Palette, paletteFor } from "../src/ui/palette.ts";
|
|
@@ -345,11 +350,18 @@ export default function privateerBrand(pi: any): void {
|
|
|
345
350
|
}
|
|
346
351
|
}
|
|
347
352
|
|
|
353
|
+
// /login. Signing in must leave the terminal ABLE TO PROMPT, not merely "connected" —
|
|
354
|
+
// so this runs the device flow, hot-registers the account provider, then hands off to
|
|
355
|
+
// activateSignedInModel to arm the channel and select the model. Every step reports.
|
|
348
356
|
async function doSignIn(ctx: any): Promise<void> {
|
|
349
357
|
if (priv.hasCredentials()) {
|
|
350
358
|
const u = priv.currentUser();
|
|
359
|
+
// Already linked: still make sure THIS session can use the account (a terminal
|
|
360
|
+
// launched before the login, or one whose channel never armed, otherwise sits
|
|
361
|
+
// "signed in" and unusable), then point at the two things they might have meant.
|
|
362
|
+
await activateSignedInModel(ctx, { switchModel: false });
|
|
351
363
|
return ctx?.ui?.notify?.(
|
|
352
|
-
`
|
|
364
|
+
`Signed in as ${u?.email ?? u?.id}. Run /logout to switch accounts, or /login keys to add a provider API key.`,
|
|
353
365
|
"info",
|
|
354
366
|
);
|
|
355
367
|
}
|
|
@@ -367,7 +379,7 @@ export default function privateerBrand(pi: any): void {
|
|
|
367
379
|
`${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
|
|
368
380
|
` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}`,
|
|
369
381
|
uri ? `${p.DIM} or open ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
|
|
370
|
-
`${p.DIM} waiting for approval
|
|
382
|
+
`${p.DIM} waiting for approval… ${p.RESET}${p.DIM}(esc to cancel · ${p.RESET}${p.INK}/login keys${p.DIM} to use your own API key instead)${p.RESET}`,
|
|
371
383
|
].filter(Boolean),
|
|
372
384
|
{ placement: "aboveEditor" },
|
|
373
385
|
);
|
|
@@ -381,20 +393,31 @@ export default function privateerBrand(pi: any): void {
|
|
|
381
393
|
/* provider list fetch failed — models appear on next launch */
|
|
382
394
|
}
|
|
383
395
|
refresh(ctx);
|
|
384
|
-
ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}
|
|
396
|
+
ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}.`, "info");
|
|
397
|
+
// Arm the account channel and select the model, IN THIS SESSION. runDeviceLogin
|
|
398
|
+
// fires onSignedIn too, which does the same work — this await is what makes the
|
|
399
|
+
// outcome (and any failure) land before we hand the prompt back to the user.
|
|
400
|
+
await activateSignedInModel(ctx);
|
|
385
401
|
} catch (e) {
|
|
386
402
|
ctx?.ui?.setWidget?.("privateer-signin", undefined);
|
|
387
403
|
ctx?.ui?.notify?.((e as Error).message || "Sign-in failed.", "error");
|
|
388
404
|
}
|
|
389
405
|
}
|
|
390
406
|
|
|
407
|
+
// Log out of this MACHINE — the login and every terminal spawned from it (see
|
|
408
|
+
// priv.logout). Unlike the old terminal-scoped sign-out this makes a blocking
|
|
409
|
+
// network call, so say what's happening before the round trip rather than after.
|
|
391
410
|
async function doSignOut(ctx: any): Promise<void> {
|
|
392
411
|
if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
|
|
393
412
|
const u = priv.currentUser();
|
|
394
|
-
|
|
413
|
+
ctx?.ui?.notify?.("Signing out of Privateer…", "info");
|
|
414
|
+
await priv.logout(); // never throws: local state is wiped whatever the network did
|
|
395
415
|
dropPersistedAccount(ctx);
|
|
396
416
|
refresh(ctx);
|
|
397
|
-
ctx?.ui?.notify?.(
|
|
417
|
+
ctx?.ui?.notify?.(
|
|
418
|
+
`Signed out${u?.email ? ` (${u.email})` : ""} — this machine and its terminals. Drop anchor for now.`,
|
|
419
|
+
"info",
|
|
420
|
+
);
|
|
398
421
|
}
|
|
399
422
|
|
|
400
423
|
function showStatus(ctx: any): void {
|
|
@@ -402,50 +425,103 @@ export default function privateerBrand(pi: any): void {
|
|
|
402
425
|
ctx?.ui?.notify?.(
|
|
403
426
|
u
|
|
404
427
|
? `Signed in to Privateer as ${u.email ?? u.id}.`
|
|
405
|
-
: "Not
|
|
428
|
+
: "Not logged in. Run /login to connect your Privateer account.",
|
|
406
429
|
"info",
|
|
407
430
|
);
|
|
408
431
|
}
|
|
409
432
|
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
433
|
+
// Make the terminal USABLE the instant the user signs in — the whole point of
|
|
434
|
+
// logging in, and the step that used to be missing. Two halves, in this order:
|
|
435
|
+
//
|
|
436
|
+
// 1. ARM the account channel. Pi stores an OAuth credential only for a login it
|
|
437
|
+
// drove itself, so after our own device-code /login the `privateer` provider has
|
|
438
|
+
// no key at all. Arming first also matters for the now-common case where the
|
|
439
|
+
// launch model ALREADY is the account model (a signed-out terminal boots on it,
|
|
440
|
+
// see providers/defaultModel.ts): there's no switch to make, only a key to fetch,
|
|
441
|
+
// and the old early-return skipped it and left the next prompt to fail.
|
|
442
|
+
// 2. SWITCH the live model, if we aren't already on it. A terminal that launched on
|
|
443
|
+
// a BYO key stays on that key otherwise, so signing in appears to do nothing.
|
|
444
|
+
//
|
|
445
|
+
// resolveSignedInModel picks Tinfoil GLM 5.2 — direct (client-attested) when a Tinfoil
|
|
446
|
+
// key is present, over the subscription otherwise. A deliberate PRIVATEER_MODEL is
|
|
447
|
+
// never overridden. Idempotent: sign-in fires this twice by design (once when
|
|
448
|
+
// credentials land, once when the channel is ready), and a second run is a no-op.
|
|
449
|
+
// Best-effort — but a failure is now REPORTED, because silently leaving the user on an
|
|
450
|
+
// unusable model is exactly the bug this replaces.
|
|
451
|
+
let activating = false;
|
|
452
|
+
async function activateSignedInModel(ctx: any, opts: { switchModel?: boolean } = {}): Promise<void> {
|
|
453
|
+
if (activating || process.env.PRIVATEER_MODEL?.trim()) return; // in-flight, or a deliberate override
|
|
454
|
+
activating = true;
|
|
455
|
+
try {
|
|
456
|
+
const spec = resolveSignedInModel();
|
|
457
|
+
const slash = spec.indexOf("/");
|
|
458
|
+
if (slash <= 0) return;
|
|
459
|
+
const provider = spec.slice(0, slash), id = spec.slice(slash + 1);
|
|
460
|
+
|
|
461
|
+
// The account channel needs a live session token before any privateer/* model can
|
|
462
|
+
// run. Retry briefly: this can be racing the credential the login itself minted.
|
|
463
|
+
if (provider === "privateer") {
|
|
464
|
+
let armed = false;
|
|
465
|
+
for (let attempt = 0; attempt < 3 && !armed; attempt++) {
|
|
466
|
+
armed = await armAccountCredential(ctx, { notify: false });
|
|
467
|
+
if (!armed) await new Promise((r) => setTimeout(r, 500));
|
|
468
|
+
}
|
|
469
|
+
if (!armed) {
|
|
470
|
+
dbg("activateSignedInModel: account channel not armed");
|
|
471
|
+
ctx?.ui?.notify?.(
|
|
472
|
+
"Signed in, but this terminal couldn't open an account session. Check your connection and run /login again, or sign a terminal out in the app if you're at the device limit.",
|
|
473
|
+
"error",
|
|
474
|
+
);
|
|
441
475
|
return;
|
|
442
476
|
}
|
|
443
|
-
} catch (e) {
|
|
444
|
-
dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
|
|
445
477
|
}
|
|
446
|
-
|
|
478
|
+
|
|
479
|
+
// Re-running /login while already signed in arms the channel but must NOT move the
|
|
480
|
+
// user off a model they picked with /models. Only a genuine sign-in switches.
|
|
481
|
+
if (opts.switchModel === false) return;
|
|
482
|
+
|
|
483
|
+
// Already on the target — the common case now that a signed-out terminal launches
|
|
484
|
+
// on the account model. Nothing to switch, but SAY so: the channel just went live
|
|
485
|
+
// under them, and silence after a login is what made the old flow feel broken.
|
|
486
|
+
const currentSpec = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
|
|
487
|
+
if (currentSpec === spec) {
|
|
488
|
+
dbg(`activateSignedInModel: already on ${spec}`);
|
|
489
|
+
refresh(ctx);
|
|
490
|
+
ctx?.ui?.notify?.(`${spec} is ready — private inference on your Privateer account.`, "info");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const reg = ctx?.modelRegistry;
|
|
495
|
+
if (!reg?.find || typeof pi.setModel !== "function") return;
|
|
496
|
+
const model = reg.find(provider, id);
|
|
497
|
+
if (!model) {
|
|
498
|
+
// The provider's live catalog may still be loading (or this is the first sign-in
|
|
499
|
+
// of the run, before the account provider was registered). Say something useful
|
|
500
|
+
// rather than stranding them on a model their account can't bill.
|
|
501
|
+
dbg(`activateSignedInModel: ${spec} not in registry`);
|
|
502
|
+
ctx?.ui?.notify?.(`Signed in. Run /models to pick a model — ${spec} isn't loaded yet.`, "warning");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
506
|
+
try {
|
|
507
|
+
const ok = await pi.setModel(model);
|
|
508
|
+
if (ok !== false) {
|
|
509
|
+
currentModelProvider = provider;
|
|
510
|
+
refresh(ctx);
|
|
511
|
+
ctx?.ui?.notify?.(`Now using ${spec} — private inference on your Privateer account.`, "info");
|
|
512
|
+
dbg(`activateSignedInModel: switched to ${spec}`);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {
|
|
516
|
+
dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
|
|
517
|
+
}
|
|
518
|
+
await new Promise((r) => setTimeout(r, 400)); // registry still settling — retry
|
|
519
|
+
}
|
|
520
|
+
dbg(`activateSignedInModel: gave up switching to ${spec}`);
|
|
521
|
+
ctx?.ui?.notify?.(`Signed in, but couldn't switch to ${spec}. Run /models to pick one.`, "warning");
|
|
522
|
+
} finally {
|
|
523
|
+
activating = false;
|
|
447
524
|
}
|
|
448
|
-
dbg(`activateSignedInModel: gave up switching to ${spec}`);
|
|
449
525
|
}
|
|
450
526
|
|
|
451
527
|
dbg("extension loaded, onSignedIn listener registering");
|
|
@@ -529,27 +605,38 @@ export default function privateerBrand(pi: any): void {
|
|
|
529
605
|
// that's now dead server-side (see dropPersistedAccount).
|
|
530
606
|
dropPersistedAccount(ctxRef);
|
|
531
607
|
refresh(ctxRef);
|
|
532
|
-
ctxRef?.ui?.notify?.("Your Privateer session expired. Run /
|
|
608
|
+
ctxRef?.ui?.notify?.("Your Privateer session expired. Run /login to sign back in.", "warning");
|
|
533
609
|
});
|
|
534
610
|
|
|
535
611
|
pi.registerCommand?.("update", {
|
|
536
612
|
description: "Update Privateer to the latest release (npm i -g privateer-agent@latest)",
|
|
537
613
|
handler: (_args: string, ctx: any) => doUpdate(ctx),
|
|
538
614
|
});
|
|
539
|
-
|
|
540
|
-
|
|
615
|
+
// ONE vocabulary: log in / log out. Pi's own built-ins are /login and /logout, so
|
|
616
|
+
// matching them is what makes the pair feel like a single concept instead of two
|
|
617
|
+
// half-overlapping ones (Pi's /logout used to clear only Pi's authStorage while the
|
|
618
|
+
// machine login lived on, which read as "logout doesn't work"). /logout here is the
|
|
619
|
+
// canonical command; the Pi built-in is redirected to it by patches/, the same
|
|
620
|
+
// mechanism as the /model → /models redirect.
|
|
621
|
+
//
|
|
622
|
+
// signin/signout stay registered as undocumented aliases — they were the shipped
|
|
623
|
+
// names, they're in muscle memory and in older docs, and an alias costs nothing.
|
|
624
|
+
pi.registerCommand?.("login", {
|
|
625
|
+
description: "Log in to your Privateer account · /login keys for a provider API key",
|
|
541
626
|
handler: (_args: string, ctx: any) => doSignIn(ctx),
|
|
542
627
|
});
|
|
543
|
-
pi.registerCommand?.("
|
|
544
|
-
description: "
|
|
628
|
+
pi.registerCommand?.("logout", {
|
|
629
|
+
description: "Log out of Privateer on this machine (revokes all its terminals)",
|
|
545
630
|
handler: (_args: string, ctx: any) => doSignOut(ctx),
|
|
546
631
|
});
|
|
632
|
+
pi.registerCommand?.("signin", { description: "", handler: (_a: string, ctx: any) => doSignIn(ctx) });
|
|
633
|
+
pi.registerCommand?.("signout", { description: "", handler: (_a: string, ctx: any) => doSignOut(ctx) });
|
|
547
634
|
pi.registerCommand?.("privateer", {
|
|
548
|
-
description: "Privateer account: /privateer [status |
|
|
635
|
+
description: "Privateer account: /privateer [status | login | logout]",
|
|
549
636
|
handler: (args: string, ctx: any) => {
|
|
550
637
|
const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
|
|
551
|
-
if (sub === "
|
|
552
|
-
if (sub === "
|
|
638
|
+
if (sub === "login" || sub === "signin") return doSignIn(ctx);
|
|
639
|
+
if (sub === "logout" || sub === "signout") return doSignOut(ctx);
|
|
553
640
|
return showStatus(ctx);
|
|
554
641
|
},
|
|
555
642
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
2
|
-
index e223ce1..
|
|
2
|
+
index e223ce1..3615d74 100644
|
|
3
3
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
4
4
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -163,6 +163,14 @@ export class AgentSession {
|
|
6
|
+
}
|
|
7
|
+
const isOAuth = this._modelRegistry.isUsingOAuth(model);
|
|
8
|
+
if (isOAuth) {
|
|
9
|
+
+ // Privateer patch: the account channel has no API key that could expire —
|
|
10
|
+
+ // the terminal is simply not signed in yet (a fresh install now boots on
|
|
11
|
+
+ // `privateer/*`, which is the model it will run once logged in). Stock Pi's
|
|
12
|
+
+ // wording describes a state that user was never in, and its "/login
|
|
13
|
+
+ // privateer" bypasses the branded sign-in. auth-guidance owns the words.
|
|
14
|
+
+ if (model.provider === "privateer") {
|
|
15
|
+
+ throw new Error(formatNoApiKeyFoundMessage(model.provider));
|
|
16
|
+
+ }
|
|
17
|
+
throw new Error(`Authentication failed for "${model.provider}". ` +
|
|
18
|
+
`Credentials may have expired or network is unavailable. ` +
|
|
19
|
+
`Run '/login ${model.provider}' to re-authenticate.`);
|
|
20
|
+
@@ -711,6 +719,15 @@ export class AgentSession {
|
|
6
21
|
finalError: msg.errorMessage,
|
|
7
22
|
});
|
|
8
23
|
this._retryAttempt = 0;
|
|
@@ -18,8 +33,52 @@ index e223ce1..2bdab10 100644
|
|
|
18
33
|
}
|
|
19
34
|
if (await this._checkCompaction(msg)) {
|
|
20
35
|
return true;
|
|
36
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
37
|
+
index 197bccc..27f6429 100644
|
|
38
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
39
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
40
|
+
@@ -1,21 +1,33 @@
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import { getDocsPath } from "../config.js";
|
|
43
|
+
const UNKNOWN_PROVIDER = "unknown";
|
|
44
|
+
+// Privateer patch: speak Privateer, and stop printing absolute node_modules doc paths.
|
|
45
|
+
+//
|
|
46
|
+
+// Stock Pi answered every auth failure with four lines, two of them full paths into
|
|
47
|
+
+// node_modules/@earendil-works/pi-coding-agent/docs/. On a terminal that isn't signed
|
|
48
|
+
+// in, that wall repeats on every prompt and buries the one sentence that matters. It
|
|
49
|
+
+// also told a Privateer user to go find a provider API key when their subscription
|
|
50
|
+
+// already covers the model. Same information, one actionable line.
|
|
51
|
+
export function getProviderLoginHelp() {
|
|
52
|
+
- return [
|
|
53
|
+
- "Use /login to log into a provider via OAuth or API key. See:",
|
|
54
|
+
- ` ${join(getDocsPath(), "providers.md")}`,
|
|
55
|
+
- ` ${join(getDocsPath(), "models.md")}`,
|
|
56
|
+
- ].join("\n");
|
|
57
|
+
+ return "Run /login to connect your Privateer account, or /login keys to use your own provider API key.";
|
|
58
|
+
}
|
|
59
|
+
export function formatNoModelsAvailableMessage() {
|
|
60
|
+
return `No models available. ${getProviderLoginHelp()}`;
|
|
61
|
+
}
|
|
62
|
+
export function formatNoModelSelectedMessage() {
|
|
63
|
+
- return `No model selected.\n\n${getProviderLoginHelp()}\n\nThen use /model to select a model.`;
|
|
64
|
+
+ return `No model selected. ${getProviderLoginHelp()}\n\nThen use /models to select a model.`;
|
|
65
|
+
}
|
|
66
|
+
export function formatNoApiKeyFoundMessage(provider) {
|
|
67
|
+
+ // The account channel: there is no API key to find — you're just not signed in (or
|
|
68
|
+
+ // the session didn't arm). Naming the real problem is the whole fix here.
|
|
69
|
+
+ if (provider === "privateer") {
|
|
70
|
+
+ return "This terminal isn't signed in to Privateer, so it can't run your subscription models.\n\nRun /login — it takes one approval in the Privateer app, and no API key.";
|
|
71
|
+
+ }
|
|
72
|
+
const providerDisplay = provider === UNKNOWN_PROVIDER ? "the selected model" : provider;
|
|
73
|
+
return `No API key found for ${providerDisplay}.\n\n${getProviderLoginHelp()}`;
|
|
74
|
+
}
|
|
75
|
+
+// Kept so the module's imports stay meaningful for anything that still wants the docs.
|
|
76
|
+
+export function getProviderDocsPaths() {
|
|
77
|
+
+ return [join(getDocsPath(), "providers.md"), join(getDocsPath(), "models.md")];
|
|
78
|
+
+}
|
|
79
|
+
//# sourceMappingURL=auth-guidance.js.map
|
|
21
80
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
22
|
-
index 5d65200..
|
|
81
|
+
index 5d65200..fef6005 100644
|
|
23
82
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
24
83
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
25
84
|
@@ -2042,7 +2042,17 @@ export class InteractiveMode {
|
|
@@ -41,3 +100,51 @@ index 5d65200..a997ad7 100644
|
|
|
41
100
|
return;
|
|
42
101
|
}
|
|
43
102
|
if (text === "/export" || text.startsWith("/export ")) {
|
|
103
|
+
@@ -2105,14 +2115,44 @@ export class InteractiveMode {
|
|
104
|
+
this.editor.setText("");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
- if (text === "/login") {
|
|
108
|
+
- this.showOAuthSelector("login");
|
|
109
|
+
+ if (text === "/login" || text.startsWith("/login ")) {
|
|
110
|
+
this.editor.setText("");
|
|
111
|
+
+ // Privateer redirect: a bare /login IS the account sign-in. Pi's built-in
|
|
112
|
+
+ // opens a two-step menu ("Use a subscription" → a list of 20+ providers)
|
|
113
|
+
+ // that buries the one option a Privateer user wants, and — because it
|
|
114
|
+
+ // only auto-selects a model when the current one is UNKNOWN — a
|
|
115
|
+
+ // successful login through it left the terminal on its launch model and
|
|
116
|
+
+ // the next prompt died on "No API key found". The extension's own flow
|
|
117
|
+
+ // signs in, arms the account channel, and selects the model.
|
|
118
|
+
+ // `/login <anything>` (documented as `/login keys`) still opens Pi's own
|
|
119
|
+
+ // selector, so BYO provider keys stay reachable; and if the extension
|
|
120
|
+
+ // isn't loaded we fall back to Pi's selector for both.
|
|
121
|
+
+ if (text === "/login" && this.isExtensionCommand("/privateer")) {
|
|
122
|
+
+ await this.session.prompt("/privateer login");
|
|
123
|
+
+ }
|
|
124
|
+
+ else {
|
|
125
|
+
+ this.showOAuthSelector("login");
|
|
126
|
+
+ }
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (text === "/logout") {
|
|
130
|
+
- this.showOAuthSelector("logout");
|
|
131
|
+
this.editor.setText("");
|
|
132
|
+
+ // Privateer redirect: /logout must actually log you out. Pi's built-in
|
|
133
|
+
+ // only clears Pi's authStorage, which leaves the Privateer machine
|
|
134
|
+
+ // login in ~/.privateer/credentials.json untouched — so on a signed-in
|
|
135
|
+
+ // machine it reported "No stored credentials to remove" and changed
|
|
136
|
+
+ // nothing. Route to the account logout instead, which revokes this
|
|
137
|
+
+ // machine's whole token family (login + every terminal spawned from it)
|
|
138
|
+
+ // and wipes local state. Target is `/privateer logout` rather than the
|
|
139
|
+
+ // extension's own `/logout` so this can never re-enter the branch it
|
|
140
|
+
+ // was dispatched from. Falls back to Pi's selector when the extension
|
|
141
|
+
+ // isn't loaded.
|
|
142
|
+
+ if (this.isExtensionCommand("/privateer")) {
|
|
143
|
+
+ await this.session.prompt("/privateer logout");
|
|
144
|
+
+ }
|
|
145
|
+
+ else {
|
|
146
|
+
+ this.showOAuthSelector("logout");
|
|
147
|
+
+ }
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (text === "/new") {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Which account-provider inference sessions this machine has spawned, and which
|
|
2
|
+
// terminal owns each one.
|
|
3
|
+
//
|
|
4
|
+
// The problem this solves: every launch used to spawn a NEW server-side session, and
|
|
5
|
+
// only a CLEAN exit revoked it (session_shutdown → revokeLocalSessions). A terminal
|
|
6
|
+
// that dies without running its shutdown hook — SIGKILL, a closed window, a crash,
|
|
7
|
+
// `kill` — leaves its session row alive server-side for the rest of its ~24h TTL. Do
|
|
8
|
+
// that a few times and the next spawn is refused with
|
|
9
|
+
// `429 CHILD_SESSION_CAP: Too many active terminals for this device`, which takes the
|
|
10
|
+
// whole account channel down until the rows age out.
|
|
11
|
+
//
|
|
12
|
+
// The fix is to reclaim an orphan instead of stacking another row on top of it. That
|
|
13
|
+
// needs one bit the credential itself can't tell us: is the terminal that owns it
|
|
14
|
+
// still RUNNING? A live terminal's session must never be touched — adopting it rotates
|
|
15
|
+
// its refresh token out from under it and kills a working session (they rotate in
|
|
16
|
+
// isolation, one per terminal, by design). So each entry records the owning pid, and
|
|
17
|
+
// a session counts as orphaned only once that pid is gone.
|
|
18
|
+
//
|
|
19
|
+
// pid liveness is signal-0. The failure mode is asymmetric and we lean on that: a
|
|
20
|
+
// RECYCLED pid makes a dead owner look alive, so we skip a reclaimable session and
|
|
21
|
+
// spawn a fresh one — the old behaviour, no harm. The dangerous direction (a live
|
|
22
|
+
// process reported dead) can't happen: a running pid never reports ESRCH.
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
25
|
+
import { dirname } from "node:path";
|
|
26
|
+
import { accountSessionsPath, globalDir } from "../config/paths.ts";
|
|
27
|
+
|
|
28
|
+
// One spawned session, keyed in the file by the pid of its owning terminal. `refresh`
|
|
29
|
+
// is what lets a later launch adopt or revoke it; `expires` is its access token's exp
|
|
30
|
+
// (see jwtExpMs), used only to prune entries that are dead server-side anyway.
|
|
31
|
+
export interface OwnedSession {
|
|
32
|
+
pid: number;
|
|
33
|
+
refresh: string;
|
|
34
|
+
expires: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Registry = Record<string, { refresh?: unknown; expires?: unknown }>;
|
|
38
|
+
|
|
39
|
+
function tryChmod(path: string, mode: number): void {
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(path, mode);
|
|
42
|
+
} catch {
|
|
43
|
+
/* best effort — a restrictive umask or an odd filesystem */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readRegistry(): Registry {
|
|
48
|
+
const path = accountSessionsPath();
|
|
49
|
+
if (!existsSync(path)) return {};
|
|
50
|
+
try {
|
|
51
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Registry) : {};
|
|
53
|
+
} catch {
|
|
54
|
+
return {}; // corrupt/truncated — start clean rather than wedging every launch
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Write via temp + rename so a concurrent reader never sees a half-written file. Two
|
|
59
|
+
// terminals racing can still lose one entry (last writer wins); the cost is one
|
|
60
|
+
// unreclaimable orphan, not a broken launch, so a lock file isn't worth it here.
|
|
61
|
+
function writeRegistry(reg: Registry): void {
|
|
62
|
+
const path = accountSessionsPath();
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
65
|
+
tryChmod(globalDir(), 0o700);
|
|
66
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
67
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
68
|
+
tryChmod(tmp, 0o600);
|
|
69
|
+
renameSync(tmp, path);
|
|
70
|
+
} catch {
|
|
71
|
+
/* best effort — losing the registry costs reclamation, never correctness */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Is a pid still running? EPERM means it exists but belongs to another user, which is
|
|
76
|
+
// still "alive" — and alive is the safe answer (we skip reclamation rather than risk
|
|
77
|
+
// hijacking a live terminal's session).
|
|
78
|
+
function isAlive(pid: number): boolean {
|
|
79
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseEntry(pid: string, raw: { refresh?: unknown; expires?: unknown }): OwnedSession | null {
|
|
89
|
+
const n = Number(pid);
|
|
90
|
+
if (!Number.isInteger(n) || typeof raw?.refresh !== "string" || !raw.refresh) return null;
|
|
91
|
+
return { pid: n, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Claim (or re-claim) a session for THIS process. Called wherever the account
|
|
95
|
+
// credential is minted or rotated — spawnAccountCredentials and
|
|
96
|
+
// refreshAccountCredentials — so the registry always holds the token that would
|
|
97
|
+
// actually work, including the rotations Pi drives on its own.
|
|
98
|
+
export function recordOwnedSession(cred: { refresh: string; expires: number }): void {
|
|
99
|
+
const reg = readRegistry();
|
|
100
|
+
reg[String(process.pid)] = { refresh: cred.refresh, expires: cred.expires };
|
|
101
|
+
writeRegistry(reg);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Drop this process's entry — the session is being revoked (clean exit, /signout), so
|
|
105
|
+
// it is about to stop existing server-side. Leaving it behind would advertise a dead
|
|
106
|
+
// session as a reclaimable orphan to the next launch.
|
|
107
|
+
export function forgetOwnedSession(): void {
|
|
108
|
+
const reg = readRegistry();
|
|
109
|
+
if (!(String(process.pid) in reg)) return;
|
|
110
|
+
delete reg[String(process.pid)];
|
|
111
|
+
writeRegistry(reg);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sessions whose owning terminal is gone: candidates to adopt or revoke. Prunes
|
|
115
|
+
// entries that are unusable anyway (malformed, or past their expiry) as a side
|
|
116
|
+
// effect, so the file can't grow without bound. Our own pid is never a candidate.
|
|
117
|
+
export function orphanedSessions(now = Date.now()): OwnedSession[] {
|
|
118
|
+
const reg = readRegistry();
|
|
119
|
+
const orphans: OwnedSession[] = [];
|
|
120
|
+
let pruned = false;
|
|
121
|
+
|
|
122
|
+
for (const [pid, raw] of Object.entries(reg)) {
|
|
123
|
+
const entry = parseEntry(pid, raw);
|
|
124
|
+
if (!entry || (entry.expires > 0 && entry.expires <= now)) {
|
|
125
|
+
delete reg[pid]; // malformed, or dead server-side — nothing to reclaim
|
|
126
|
+
pruned = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (entry.pid === process.pid || isAlive(entry.pid)) continue; // ours, or a live terminal's
|
|
130
|
+
orphans.push(entry);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (pruned) writeRegistry(reg);
|
|
134
|
+
return orphans;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Forget one orphan, once it has been definitively handled (adopted, or confirmed dead
|
|
138
|
+
// server-side). A entry whose refresh merely FAILED TO REACH the server is deliberately
|
|
139
|
+
// kept: dropping it on a network blip would leak that row until its TTL.
|
|
140
|
+
export function dropOwnedSession(pid: number): void {
|
|
141
|
+
const reg = readRegistry();
|
|
142
|
+
if (!(String(pid) in reg)) return;
|
|
143
|
+
delete reg[String(pid)];
|
|
144
|
+
writeRegistry(reg);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Wipe the registry. Used by logout(), where the server has just revoked this
|
|
148
|
+
// machine's whole token family: every entry now names a dead session, and leaving
|
|
149
|
+
// them behind would offer the next login a list of orphans to "reclaim" that can
|
|
150
|
+
// only fail. Also a test seam.
|
|
151
|
+
export function clearOwnedSessions(): void {
|
|
152
|
+
try {
|
|
153
|
+
rmSync(accountSessionsPath(), { force: true });
|
|
154
|
+
} catch {
|
|
155
|
+
/* nothing to remove */
|
|
156
|
+
}
|
|
157
|
+
}
|