privateer-agent 0.6.7 → 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/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 +4 -1
- package/src/auth/privateer.ts +94 -22
- package/src/cli/chat.ts +2 -2
- package/src/daemon/index.ts +2 -2
- package/src/providers/account.ts +132 -37
- package/src/providers/defaultModel.ts +42 -26
package/SECURITY.md
CHANGED
|
@@ -30,7 +30,11 @@ npm audit signatures # verifies registry signatures + p
|
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
If a version lacks provenance, it did not come from this workflow. Treat that as
|
|
33
|
-
suspicious and report it.
|
|
33
|
+
suspicious and report it — with one documented exception: **0.6.7 is the first release
|
|
34
|
+
published this way.** Trusted publishing was misconfigured until then, so every earlier
|
|
35
|
+
version (through 0.6.6) was published by hand from a maintainer's machine and carries no
|
|
36
|
+
attestation. Those are not forgeries, but they are not independently verifiable either.
|
|
37
|
+
If that distinction matters to you, use 0.6.7 or later.
|
|
34
38
|
|
|
35
39
|
## The permission gate
|
|
36
40
|
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -214,28 +214,39 @@ else {
|
|
|
214
214
|
// auto-install. Fire-and-forget: the event loop stays alive while the TUI child runs.
|
|
215
215
|
refreshUpdateCache();
|
|
216
216
|
|
|
217
|
-
// Default model.
|
|
218
|
-
//
|
|
219
|
-
//
|
|
217
|
+
// Default model. Mirrors src/providers/defaultModel.ts resolveDefaultModel() — keep
|
|
218
|
+
// the two in step. Tinfoil's GLM 5.2 is the default either way: direct when the user
|
|
219
|
+
// has a Tinfoil key (pi-privacy can client-attest the enclave), over the Privateer
|
|
220
|
+
// subscription otherwise.
|
|
221
|
+
//
|
|
222
|
+
// The last branch is the important one. A signed-out, keyless terminal used to launch
|
|
223
|
+
// on `openrouter/openai/gpt-4o-mini`, which it had no key for — so the first prompt
|
|
224
|
+
// died on "No API key found for openrouter", /login couldn't fix it (nothing switched
|
|
225
|
+
// the live model), and the error named a provider the user had never heard of. It now
|
|
226
|
+
// launches on the SAME account model it will use once signed in: nothing to switch,
|
|
227
|
+
// the status bar shows what they're about to get, and the error until then names
|
|
228
|
+
// Privateer and points at /login.
|
|
220
229
|
const CRED = path.join(PRIVATEER_HOME, "credentials.json");
|
|
221
230
|
const signedIn = fs.existsSync(CRED);
|
|
231
|
+
const ACCOUNT_MODEL = "privateer/tinfoil/glm-5-2";
|
|
222
232
|
const MODEL = process.env.PRIVATEER_MODEL
|
|
223
233
|
? process.env.PRIVATEER_MODEL
|
|
224
234
|
: haveTinfoilKey()
|
|
225
235
|
? "tinfoil/glm-5-2"
|
|
226
236
|
: signedIn
|
|
227
|
-
?
|
|
228
|
-
: "
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
237
|
+
? ACCOUNT_MODEL
|
|
238
|
+
: haveKey("ANTHROPIC_API_KEY")
|
|
239
|
+
? "anthropic/claude-opus-4-8"
|
|
240
|
+
: haveKey("OPENAI_API_KEY")
|
|
241
|
+
? "openai/gpt-5.5"
|
|
242
|
+
: haveKey("OPENROUTER_API_KEY")
|
|
243
|
+
? "openrouter/openai/gpt-4o-mini"
|
|
244
|
+
: ACCOUNT_MODEL;
|
|
245
|
+
|
|
246
|
+
// Nothing to run with: no model named, no BYO key, not signed in. The TUI still boots
|
|
247
|
+
// (that's where /login lives), but say why up front — a returning user whose login
|
|
248
|
+
// file vanished otherwise has no way to tell a cleared session from a first run.
|
|
249
|
+
if (!signedIn && !process.env.PRIVATEER_MODEL && !haveByoKey()) {
|
|
239
250
|
warnKeylessLaunch();
|
|
240
251
|
}
|
|
241
252
|
|
|
@@ -367,20 +378,17 @@ function warnKeylessLaunch() {
|
|
|
367
378
|
? [
|
|
368
379
|
"",
|
|
369
380
|
" ⚓ Your Privateer login is missing — this terminal isn't signed in.",
|
|
370
|
-
` (no ${path.join(PRIVATEER_HOME, "credentials.json")})`,
|
|
371
381
|
"",
|
|
372
|
-
"
|
|
373
|
-
"
|
|
374
|
-
" prompting fails with \"No API key found\" because no model key is set.",
|
|
382
|
+
" Run /login and approve the code in the Privateer app. You'll be back on your",
|
|
383
|
+
" subscription models straight away — no API key needed.",
|
|
375
384
|
"",
|
|
376
385
|
]
|
|
377
386
|
: [
|
|
378
387
|
"",
|
|
379
|
-
" ⚓
|
|
388
|
+
" ⚓ Welcome aboard. Run /login to connect your Privateer account.",
|
|
380
389
|
"",
|
|
381
|
-
"
|
|
382
|
-
"
|
|
383
|
-
" prompting fails with \"No API key found\".",
|
|
390
|
+
" One approval in the Privateer app and you're running Tinfoil GLM 5.2 in a",
|
|
391
|
+
" trusted enclave — no API key needed. Prefer your own key? /login keys.",
|
|
384
392
|
"",
|
|
385
393
|
];
|
|
386
394
|
process.stderr.write(lines.join("\n") + "\n");
|
|
@@ -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") {
|
|
@@ -144,7 +144,10 @@ export function dropOwnedSession(pid: number): void {
|
|
|
144
144
|
writeRegistry(reg);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
//
|
|
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.
|
|
148
151
|
export function clearOwnedSessions(): void {
|
|
149
152
|
try {
|
|
150
153
|
rmSync(accountSessionsPath(), { force: true });
|
package/src/auth/privateer.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
forgetOwnedSession,
|
|
22
22
|
orphanedSessions,
|
|
23
23
|
dropOwnedSession,
|
|
24
|
+
clearOwnedSessions,
|
|
24
25
|
} from "./accountSessions.ts";
|
|
25
26
|
import { isAccountCapCode } from "../engine/errors.ts";
|
|
26
27
|
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
@@ -94,9 +95,37 @@ export function defaultDeviceLabel(): string {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
// ──
|
|
98
|
+
// ── Cross-instance state ─────────────────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// Pi loads every extension with a FRESH jiti instance (`moduleCache: false`, see
|
|
101
|
+
// core/extensions/loader.js), so privateer-brand and privateer-account each get their
|
|
102
|
+
// OWN copy of this module — separate credential cache, separate listener sets.
|
|
103
|
+
//
|
|
104
|
+
// That silently broke /login. The account provider's OAuth login() ran inside the
|
|
105
|
+
// privateer-account copy and called notifySignedIn() there, while the UI's listener
|
|
106
|
+
// (the one that switches the live session onto a model the account can actually
|
|
107
|
+
// serve) was registered on the privateer-brand copy. The signal never crossed, so a
|
|
108
|
+
// successful sign-in left the terminal pinned to its keyless launch model and the
|
|
109
|
+
// very next prompt died with "No API key found for openrouter" — exactly the state
|
|
110
|
+
// the login was supposed to fix. The same split let a /logout in one copy leave a
|
|
111
|
+
// stale `user` memoized in another.
|
|
112
|
+
//
|
|
113
|
+
// So anything that must be observed ACROSS extensions lives on globalThis, keyed by
|
|
114
|
+
// a registered Symbol — one bus, however many module instances jiti creates.
|
|
115
|
+
const SHARED = Symbol.for("privateer.auth.shared");
|
|
116
|
+
|
|
117
|
+
interface SharedAuthState {
|
|
118
|
+
cache: Credentials | null;
|
|
119
|
+
signedIn: Set<SignedInListener>;
|
|
120
|
+
expired: Set<SessionExpiredListener>;
|
|
121
|
+
}
|
|
98
122
|
|
|
99
|
-
|
|
123
|
+
function shared(): SharedAuthState {
|
|
124
|
+
const g = globalThis as { [SHARED]?: SharedAuthState };
|
|
125
|
+
return (g[SHARED] ??= { cache: null, signedIn: new Set(), expired: new Set() });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
|
|
100
129
|
|
|
101
130
|
// Per-terminal child session (see spawnChildSession). Held in memory ONLY — it
|
|
102
131
|
// is never written to the shared credentials file, so each running terminal
|
|
@@ -144,15 +173,16 @@ export function loadCredentials(): Credentials | null {
|
|
|
144
173
|
// memoized the pre-login "absent" as null, that instance would report "not signed
|
|
145
174
|
// in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
|
|
146
175
|
// disk on each miss lets a later call see what a sign-in just wrote.
|
|
147
|
-
|
|
176
|
+
const state = shared();
|
|
177
|
+
if (state.cache) return state.cache;
|
|
148
178
|
const path = credentialsPath();
|
|
149
179
|
if (!existsSync(path)) return null;
|
|
150
180
|
try {
|
|
151
|
-
|
|
181
|
+
state.cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
|
152
182
|
} catch {
|
|
153
183
|
return null;
|
|
154
184
|
}
|
|
155
|
-
return
|
|
185
|
+
return state.cache;
|
|
156
186
|
}
|
|
157
187
|
|
|
158
188
|
export function saveCredentials(creds: Credentials): void {
|
|
@@ -162,7 +192,7 @@ export function saveCredentials(creds: Credentials): void {
|
|
|
162
192
|
const path = credentialsPath();
|
|
163
193
|
writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
164
194
|
tryChmod(path, 0o600);
|
|
165
|
-
|
|
195
|
+
shared().cache = creds;
|
|
166
196
|
}
|
|
167
197
|
|
|
168
198
|
export function clearCredentials(): void {
|
|
@@ -174,7 +204,7 @@ export function clearCredentials(): void {
|
|
|
174
204
|
// Drop the pinned account signing key too — it belongs to the account that just
|
|
175
205
|
// signed out; a different account must re-pin its own at link.
|
|
176
206
|
clearAccountSignKey();
|
|
177
|
-
|
|
207
|
+
shared().cache = null;
|
|
178
208
|
_child = null;
|
|
179
209
|
_account = null;
|
|
180
210
|
}
|
|
@@ -204,15 +234,15 @@ function tryChmod(path: string, mode: number): void {
|
|
|
204
234
|
// stops working; the UI subscribes to announce the sign-out prominently.
|
|
205
235
|
|
|
206
236
|
type SessionExpiredListener = () => void;
|
|
207
|
-
const _expiredListeners = new Set<SessionExpiredListener>();
|
|
208
237
|
|
|
209
238
|
export function onSessionExpired(listener: SessionExpiredListener): () => void {
|
|
210
|
-
|
|
211
|
-
|
|
239
|
+
const listeners = shared().expired;
|
|
240
|
+
listeners.add(listener);
|
|
241
|
+
return () => listeners.delete(listener);
|
|
212
242
|
}
|
|
213
243
|
|
|
214
244
|
function notifySessionExpired(): void {
|
|
215
|
-
for (const listener of
|
|
245
|
+
for (const listener of shared().expired) {
|
|
216
246
|
try {
|
|
217
247
|
listener();
|
|
218
248
|
} catch {
|
|
@@ -248,17 +278,19 @@ export function handleServerRevoke(): void {
|
|
|
248
278
|
// A listener here refreshes the UI regardless of which path the user took.
|
|
249
279
|
|
|
250
280
|
type SignedInListener = () => void;
|
|
251
|
-
const _signedInListeners = new Set<SignedInListener>();
|
|
252
281
|
|
|
253
282
|
export function onSignedIn(listener: SignedInListener): () => void {
|
|
254
|
-
|
|
255
|
-
|
|
283
|
+
const listeners = shared().signedIn;
|
|
284
|
+
listeners.add(listener);
|
|
285
|
+
return () => listeners.delete(listener);
|
|
256
286
|
}
|
|
257
287
|
|
|
258
288
|
// Emit the sign-in signal. Exported so the account OAuth provider can announce a
|
|
259
289
|
// completed subscription login on the already-linked path (see the note above).
|
|
290
|
+
// Listeners MUST be idempotent: a device-code login fires this once the credentials
|
|
291
|
+
// land and again once the account channel is armed (see privateerOAuthProvider.login).
|
|
260
292
|
export function notifySignedIn(): void {
|
|
261
|
-
for (const listener of
|
|
293
|
+
for (const listener of shared().signedIn) {
|
|
262
294
|
try {
|
|
263
295
|
listener();
|
|
264
296
|
} catch {
|
|
@@ -602,15 +634,55 @@ export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
|
|
|
602
634
|
// ── Logout ───────────────────────────────────────────────────────────────────
|
|
603
635
|
|
|
604
636
|
/**
|
|
605
|
-
* Log out this
|
|
606
|
-
* local
|
|
637
|
+
* Log out this MACHINE: revoke the machine login and every terminal session
|
|
638
|
+
* spawned from it, then wipe all local auth state. Other devices (the phone app,
|
|
639
|
+
* another laptop) keep their own logins — each has its own token family.
|
|
640
|
+
*
|
|
641
|
+
* Two things this deliberately does NOT do, both of which it used to:
|
|
642
|
+
*
|
|
643
|
+
* 1. It does not POST /auth/logout. That endpoint calls revokeAllUserSessions —
|
|
644
|
+
* the entire ACCOUNT, every device including the app — while this function's
|
|
645
|
+
* contract (and its old doc comment) promised the opposite. Signing out of one
|
|
646
|
+
* terminal must not sign you out of your phone.
|
|
647
|
+
*
|
|
648
|
+
* 2. It does not go through apiRequest/authedFetch. Those authenticate with a CHILD
|
|
649
|
+
* session and spawn one if absent — so at the per-machine child cap the spawn
|
|
650
|
+
* throws 429 and the logout never reaches the server AT ALL. That was a deadlock:
|
|
651
|
+
* the cap blocked the one call that clears the cap. We authenticate with the
|
|
652
|
+
* PARENT instead, which is never subject to the cap.
|
|
653
|
+
*
|
|
654
|
+
* The parent's stored access token is usually expired (it is minted once at /login
|
|
655
|
+
* and never rotated — the refresh token is the liveness proof), so we rotate for a
|
|
656
|
+
* fresh one first. Rotation is free here precisely because we are destroying the
|
|
657
|
+
* credential either way: nothing downstream needs the token we burn. rotateSession
|
|
658
|
+
* is the no-ownership-side-effects variant, so this cannot clobber the registry
|
|
659
|
+
* entry of a session we are about to revoke wholesale anyway.
|
|
660
|
+
*
|
|
661
|
+
* DELETE /auth/session/current then revokes the parent's family, which the server
|
|
662
|
+
* cascades to every row with `parentFamilyId === familyId` — i.e. all this machine's
|
|
663
|
+
* terminals, including the orphans left by terminals that died without their
|
|
664
|
+
* shutdown hook. That cascade is what makes an accumulated cap self-clearing:
|
|
665
|
+
* logout, log back in, and the machine starts from zero live children.
|
|
666
|
+
*
|
|
667
|
+
* Local state is wiped unconditionally at the end, whatever the network did. A
|
|
668
|
+
* logout that can't reach the server must still leave you logged out locally —
|
|
669
|
+
* the rows it failed to revoke age out on their TTL.
|
|
607
670
|
*/
|
|
608
671
|
export async function logout(): Promise<void> {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
672
|
+
const parent = loadCredentials();
|
|
673
|
+
if (parent) {
|
|
674
|
+
try {
|
|
675
|
+
const fresh = await rotateSession(parent.refreshToken);
|
|
676
|
+
await deleteSession(fresh.access, 5000);
|
|
677
|
+
} catch {
|
|
678
|
+
/* offline, or the login was already dead server-side — wipe locally anyway */
|
|
679
|
+
}
|
|
613
680
|
}
|
|
681
|
+
// In-memory sessions are gone with the family above; drop the handles so nothing
|
|
682
|
+
// tries to revoke them individually on the way out.
|
|
683
|
+
_child = null;
|
|
684
|
+
_account = null;
|
|
685
|
+
clearOwnedSessions(); // every entry named a session the cascade just killed
|
|
614
686
|
clearCredentials();
|
|
615
687
|
}
|
|
616
688
|
|
|
@@ -622,7 +694,7 @@ export async function logout(): Promise<void> {
|
|
|
622
694
|
// = the JWT's exp (so Pi refreshes just before the server would reject it). These are
|
|
623
695
|
// independent of authedFetch's in-memory _child (Pi owns this credential's lifecycle).
|
|
624
696
|
|
|
625
|
-
interface AccountCredential {
|
|
697
|
+
export interface AccountCredential {
|
|
626
698
|
access: string;
|
|
627
699
|
refresh: string;
|
|
628
700
|
expires: number; // ms epoch
|
package/src/cli/chat.ts
CHANGED
|
@@ -478,8 +478,8 @@ async function main() {
|
|
|
478
478
|
});
|
|
479
479
|
console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
|
|
480
480
|
// Move the live session onto a confidential model right away, so the next prompt
|
|
481
|
-
// doesn't dead-end on the
|
|
482
|
-
//
|
|
481
|
+
// doesn't dead-end on the launch model's missing key. resolveSignedInModel picks
|
|
482
|
+
// Tinfoil GLM 5.2 — direct with a Tinfoil key, over the subscription otherwise;
|
|
483
483
|
// PRIVATEER_MODEL (a deliberate override) is respected and left alone.
|
|
484
484
|
if (!process.env.PRIVATEER_MODEL?.trim()) {
|
|
485
485
|
const target = resolveSignedInModel();
|
package/src/daemon/index.ts
CHANGED
|
@@ -326,7 +326,7 @@ export class Daemon {
|
|
|
326
326
|
// The account signed this daemon out server-side (revoked from the app's Linked
|
|
327
327
|
// Devices). Beyond ending remote access (onTerminate), this wipes the machine
|
|
328
328
|
// login: drop the relay and clear credentials, so routines/tasks stop cleanly
|
|
329
|
-
// instead of dead-ending on a 401 each run. Stays idle until you /
|
|
329
|
+
// instead of dead-ending on a 401 each run. Stays idle until you /login on this
|
|
330
330
|
// machine and restart the daemon (the relayTerminated guard, as with onTerminate).
|
|
331
331
|
onRevoked: () => {
|
|
332
332
|
this.relayTerminated = true;
|
|
@@ -334,7 +334,7 @@ export class Daemon {
|
|
|
334
334
|
this.relay?.stop();
|
|
335
335
|
this.relay = undefined;
|
|
336
336
|
handleServerRevoke();
|
|
337
|
-
log("account signed out from the app (session revoked) — cleared credentials; idle until you run /
|
|
337
|
+
log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the daemon");
|
|
338
338
|
},
|
|
339
339
|
onStatus: (text) => log(`relay: ${text}`),
|
|
340
340
|
onDisconnected: () => {
|
package/src/providers/account.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// only a first-ever machine login runs the device-code flow.
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
type AccountCredential,
|
|
14
15
|
serverBaseUrl,
|
|
15
16
|
hasCredentials,
|
|
16
17
|
runDeviceLogin,
|
|
@@ -20,16 +21,18 @@ import {
|
|
|
20
21
|
notifySignedIn,
|
|
21
22
|
} from "../auth/privateer.ts";
|
|
22
23
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
23
|
-
import { ACCOUNT_DEFAULT_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
24
|
+
import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
24
25
|
|
|
25
26
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
26
27
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
27
|
-
// particular the
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// fallback list if the live listing
|
|
28
|
+
// particular the default, tinfoil/glm-5-2, resolves at startup without a "model not
|
|
29
|
+
// found" warning, which matters more than ever now that a signed-OUT terminal also
|
|
30
|
+
// launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
|
|
31
|
+
// rest are the familiar names. Also the fallback list if the live listing is
|
|
32
|
+
// unreachable.
|
|
31
33
|
const DEFAULT_MODELS = [
|
|
32
34
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
35
|
+
ACCOUNT_NEAR_MODEL_ID,
|
|
33
36
|
"anthropic/claude-sonnet-4.6",
|
|
34
37
|
"openai/gpt-5.5",
|
|
35
38
|
"deepseek/deepseek-v4-flash",
|
|
@@ -169,37 +172,62 @@ export const privateerOAuthProvider = {
|
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
172
|
-
|
|
175
|
+
// Go through the process-wide, single-flighted accessor rather than acquiring
|
|
176
|
+
// directly. The device flow above already fired notifySignedIn, whose listeners arm
|
|
177
|
+
// the account channel — so a bare acquire here would race that one and mint a SECOND
|
|
178
|
+
// server-side session (a duplicate row in Linked Devices, and a step closer to
|
|
179
|
+
// 429 CHILD_SESSION_CAP). Sharing the in-flight promise makes it exactly one.
|
|
180
|
+
const creds = await accountCredential();
|
|
173
181
|
// Seed Pi's saved model default to the account channel, so the next launch resolves
|
|
174
182
|
// to a billable subscription model instead of falling through to a keyless built-in
|
|
175
183
|
// (the "No API key found for openrouter" trap). No-op if the user already has a
|
|
176
184
|
// chosen default. See providers/defaultModel.ts.
|
|
177
185
|
ensurePiDefaultModel();
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
186
|
+
// Announce the completed login — ALWAYS, on both paths, and only now that the
|
|
187
|
+
// account channel actually holds a credential.
|
|
188
|
+
//
|
|
189
|
+
// The fresh path fires this once already, from pollForToken, the instant
|
|
190
|
+
// credentials.json is written. That's the right moment for the header, and the
|
|
191
|
+
// wrong one for the model: the listener that moves the live session onto an
|
|
192
|
+
// account model would run while this spawn was still in flight and find no key.
|
|
193
|
+
// Firing again here is what makes the switch land. Listeners are documented as
|
|
194
|
+
// idempotent (see notifySignedIn), and the model switch no-ops when it's already
|
|
195
|
+
// on target, so the double signal costs nothing.
|
|
196
|
+
notifySignedIn();
|
|
181
197
|
return creds;
|
|
182
198
|
},
|
|
183
199
|
async refreshToken(creds: { refresh: string }) {
|
|
200
|
+
let next: AccountCredential;
|
|
184
201
|
try {
|
|
185
|
-
|
|
202
|
+
next = await refreshAccountCredentials(creds.refresh);
|
|
186
203
|
} catch {
|
|
187
204
|
// Child token expired/reused → get another. acquire (not spawn) so a terminal
|
|
188
205
|
// that already holds the device's last session slot can reclaim an orphan
|
|
189
206
|
// instead of being refused a fresh one mid-session.
|
|
190
|
-
|
|
207
|
+
next = await acquireAccountCredential();
|
|
191
208
|
}
|
|
209
|
+
// Keep the process memo on the CURRENT token: the one it replaced is dead, and
|
|
210
|
+
// handing a dead token to a later arm() would 401 on the first prompt.
|
|
211
|
+
rememberAccountCredential(next);
|
|
212
|
+
return next;
|
|
192
213
|
},
|
|
193
214
|
getApiKey(creds: { access: string }): string {
|
|
194
215
|
return creds.access;
|
|
195
216
|
},
|
|
196
217
|
};
|
|
197
218
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
219
|
+
// Confidential-compute prefixes in the account catalog: every model the server serves
|
|
220
|
+
// out of a TEE. `near/` is the one we can attest end to end from here (the server
|
|
221
|
+
// proxies a nonce'd quote); `tinfoil/` and `phala/` are equally real enclaves whose
|
|
222
|
+
// attestation we cannot bind to THIS connection through the proxy — see accountPosture.
|
|
223
|
+
const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
|
|
224
|
+
|
|
225
|
+
// Which privacy channel an account model routes through: confidential compute (TEE)
|
|
226
|
+
// for the prefixes above, else a server-side ZDR channel. Ported from tree-cli
|
|
227
|
+
// resolve.ts, then widened — it used to say `near/` only, which quietly labelled the
|
|
228
|
+
// default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
|
|
201
229
|
export function privateerChannel(modelId: string): "tee" | "zdr" {
|
|
202
|
-
return modelId.startsWith(
|
|
230
|
+
return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
|
|
203
231
|
}
|
|
204
232
|
|
|
205
233
|
export interface AccountPosture {
|
|
@@ -220,6 +248,16 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
220
248
|
if (privateerChannel(modelId) === "zdr") {
|
|
221
249
|
return { tier: "zdr-policy" };
|
|
222
250
|
}
|
|
251
|
+
// Honest labelling for the non-NEAR enclaves. Tinfoil and Phala publish real
|
|
252
|
+
// attestations, but the server proxies the inference, so from here we cannot bind a
|
|
253
|
+
// quote to the connection actually carrying our tokens — only the account's word
|
|
254
|
+
// that it did. That's `tee-unverified` (yellow "confidential compute, unconfirmed"),
|
|
255
|
+
// never the green tee-verified we reserve for a quote we checked ourselves. A user
|
|
256
|
+
// who wants the verified shield sets TINFOIL_API_KEY and runs `tinfoil/*` direct,
|
|
257
|
+
// where pi-privacy attests the enclave client-side.
|
|
258
|
+
if (!modelId.startsWith("near/")) {
|
|
259
|
+
return { tier: "tee-unverified" };
|
|
260
|
+
}
|
|
223
261
|
try {
|
|
224
262
|
const res = await authedFetch(
|
|
225
263
|
`${serverBaseUrl()}/api/models/near/attestation?model=${encodeURIComponent(modelId)}`,
|
|
@@ -286,22 +324,67 @@ export function makeAccountProvider() {
|
|
|
286
324
|
// all, and the first prompt dead-ends on "No API key found for privateer." — even
|
|
287
325
|
// though the banner says "connected". The REPL (cli/chat.ts) and the daemon
|
|
288
326
|
// already spawn one at startup; this gives the TUI the same seed.
|
|
289
|
-
pi.on?.("session_start", (_e, ctx) => void
|
|
327
|
+
pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
|
|
290
328
|
};
|
|
291
329
|
}
|
|
292
330
|
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
331
|
+
// ── Arming the account channel ───────────────────────────────────────────────
|
|
332
|
+
//
|
|
333
|
+
// ONE account session per PROCESS. Every mint is expensive and visible: it's a row in
|
|
334
|
+
// the app's Linked Devices list, and the server caps how many a device may hold
|
|
335
|
+
// (429 CHILD_SESSION_CAP). session_start alone fires for new/resume/fork/reload, and a
|
|
336
|
+
// mid-session /login wants the channel armed too — so the credential is minted once
|
|
337
|
+
// and then remembered, and later callers reuse it instead of stacking another row.
|
|
338
|
+
//
|
|
339
|
+
// Both the memo and its in-flight promise live on globalThis rather than in module
|
|
340
|
+
// scope, because jiti gives each extension its OWN instance of this file (see the note
|
|
341
|
+
// in auth/privateer.ts). privateer-account seeds at launch and privateer-brand arms
|
|
342
|
+
// after a sign-in; module-scoped state would let each mint its own session.
|
|
298
343
|
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
344
|
+
// A fresh PROCESS always mints: a run that crashed without its shutdown hook can leave
|
|
345
|
+
// a REVOKED credential persisted in auth.json with a still-valid-looking `expires`,
|
|
346
|
+
// which Pi would happily reuse and 401 on.
|
|
347
|
+
const ARMED = Symbol.for("privateer.accountCredential");
|
|
348
|
+
type ArmedSlot = {
|
|
349
|
+
[ARMED]?: { cred?: AccountCredential; inFlight?: Promise<AccountCredential> };
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
|
|
353
|
+
const g = globalThis as ArmedSlot;
|
|
354
|
+
return (g[ARMED] ??= {});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Record a credential this process minted so nothing mints a second one. Exported for
|
|
358
|
+
// the OAuth login path, which acquires its credential for Pi to own and would
|
|
359
|
+
// otherwise leave the next arm() with nothing to reuse.
|
|
360
|
+
export function rememberAccountCredential(cred: AccountCredential): void {
|
|
361
|
+
armSlot().cred = cred;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The remembered credential, if it's still usable. A minute of headroom: handing back
|
|
365
|
+
// one that expires mid-request just trades a spawn for a 401.
|
|
366
|
+
function liveAccountCredential(): AccountCredential | undefined {
|
|
367
|
+
const cred = armSlot().cred;
|
|
368
|
+
return cred && cred.expires > Date.now() + 60_000 ? cred : undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Get this process's account credential, minting one only if we don't already hold a
|
|
372
|
+
// live one. Single-flighted, so two callers racing (session_start and a sign-in) share
|
|
373
|
+
// one spawn rather than opening two sessions.
|
|
374
|
+
async function accountCredential(): Promise<AccountCredential> {
|
|
375
|
+
const live = liveAccountCredential();
|
|
376
|
+
if (live) return live;
|
|
377
|
+
const slot = armSlot();
|
|
378
|
+
slot.inFlight ??= acquireAccountCredential()
|
|
379
|
+
.then((cred) => {
|
|
380
|
+
slot.cred = cred;
|
|
381
|
+
return cred;
|
|
382
|
+
})
|
|
383
|
+
.finally(() => {
|
|
384
|
+
slot.inFlight = undefined;
|
|
385
|
+
});
|
|
386
|
+
return slot.inFlight;
|
|
387
|
+
}
|
|
305
388
|
|
|
306
389
|
// `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
|
|
307
390
|
// path privateer-brand uses to DROP the credential on sign-out).
|
|
@@ -311,23 +394,35 @@ type SeedContext = {
|
|
|
311
394
|
ui?: { notify?: (message: string, level: string) => void };
|
|
312
395
|
};
|
|
313
396
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
397
|
+
// Put a working account credential into Pi's auth store, so `privateer/*` models can
|
|
398
|
+
// actually run. Called at session_start (the launch seed) and again right after a
|
|
399
|
+
// sign-in (see the brand extension) — a mid-session /login has to arm the channel
|
|
400
|
+
// itself, because Pi writes an OAuth credential only for a login IT drove, never for
|
|
401
|
+
// our own /login device-code command.
|
|
402
|
+
//
|
|
403
|
+
// Returns true when the channel is armed. `notify` controls whether a failure is
|
|
404
|
+
// announced: the launch seed says so out loud, while a caller that reports the outcome
|
|
405
|
+
// itself (the sign-in path) passes false so the user doesn't read it twice.
|
|
406
|
+
export async function armAccountCredential(
|
|
407
|
+
ctx: unknown,
|
|
408
|
+
opts: { notify?: boolean } = {},
|
|
409
|
+
): Promise<boolean> {
|
|
410
|
+
if (!hasCredentials()) return false;
|
|
318
411
|
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
319
|
-
if (typeof store?.set !== "function") return;
|
|
412
|
+
if (typeof store?.set !== "function") return false;
|
|
320
413
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
414
|
+
store.set("privateer", { type: "oauth", ...(await accountCredential()) });
|
|
415
|
+
return true;
|
|
323
416
|
} catch (e) {
|
|
324
417
|
// The account channel is NOT armed: a dead machine login (401 → credentials cleared
|
|
325
418
|
// + onSessionExpired), the terminal cap (429), or a network blip. Say so now — the
|
|
326
419
|
// banner still reads "connected" (it only knows about the local credentials file),
|
|
327
420
|
// so staying silent leaves the user to discover it as a bare "No API key found for
|
|
328
|
-
// privateer" on their first prompt.
|
|
329
|
-
flag[SEEDED] = false;
|
|
421
|
+
// privateer" on their first prompt.
|
|
330
422
|
const c = ctx as SeedContext;
|
|
331
|
-
if (
|
|
423
|
+
if (opts.notify !== false && c?.hasUI) {
|
|
424
|
+
c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
332
427
|
}
|
|
333
428
|
}
|
|
@@ -15,24 +15,35 @@ import { join } from "node:path";
|
|
|
15
15
|
import { hasCredentials } from "../auth/privateer.ts";
|
|
16
16
|
import { agentDir } from "../config/paths.ts";
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
18
|
+
// Tinfoil's most capable chat model, and Privateer's default everywhere. Tinfoil runs
|
|
19
|
+
// GLM 5.2 inside an attestable TEE (the serving enclave's quote is published and the
|
|
20
|
+
// live TLS key is bound to it), which is the strongest privacy tier we offer — so the
|
|
21
|
+
// most capable model on that tier is what a privacy-first agent should boot on.
|
|
22
|
+
// One definition, three consumers: this resolver, providers/account.ts's seed catalog,
|
|
23
|
+
// and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
|
|
24
|
+
export const TINFOIL_MODEL_ID = "tinfoil/glm-5-2";
|
|
25
|
+
|
|
26
|
+
// Same model, reached two ways:
|
|
27
|
+
// - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
|
|
28
|
+
// TINFOIL_API_KEY, where pi-privacy can CLIENT-attest the enclave live.
|
|
29
|
+
// - ACCOUNT_DEFAULT_SPEC — through the Privateer subscription (the `privateer`
|
|
30
|
+
// provider proxies it), so a signed-in user needs no BYO key at all.
|
|
31
|
+
// The direct route wins when a key is present; otherwise being signed in is enough.
|
|
32
|
+
export const TINFOIL_DEFAULT_SPEC = TINFOIL_MODEL_ID;
|
|
33
|
+
export const ACCOUNT_DEFAULT_MODEL_ID = TINFOIL_MODEL_ID;
|
|
23
34
|
export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
|
|
24
35
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
+
// The account channel's NEAR confidential-compute model — no longer the default, but
|
|
37
|
+
// still the one account model we can attest end-to-end through the server proxy, so
|
|
38
|
+
// it stays first in the seed catalog after the default. See providers/account.ts.
|
|
39
|
+
export const ACCOUNT_NEAR_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
|
|
40
|
+
|
|
41
|
+
// The legacy BYO default, kept ONLY for a user who set an OpenRouter key and isn't
|
|
42
|
+
// signed in — it's what their key actually pays for. It is deliberately no longer the
|
|
43
|
+
// keyless fallback: landing a signed-out, keyless terminal on OpenRouter is what
|
|
44
|
+
// produced the "No API key found for openrouter" dead end that /login couldn't
|
|
45
|
+
// explain. With no key and no login we now point at the account channel instead, so
|
|
46
|
+
// the error names Privateer and /login is visibly the fix.
|
|
36
47
|
export const LEGACY_BYO_FALLBACK = "openrouter/openai/gpt-4o-mini";
|
|
37
48
|
|
|
38
49
|
// BYO providers we can positively detect from the environment, in preference order.
|
|
@@ -56,14 +67,15 @@ export interface ResolveDefaultModelOptions {
|
|
|
56
67
|
|
|
57
68
|
// Resolve the model spec ("provider/id") to use when no model is named. Pure and
|
|
58
69
|
// synchronous (only reads env + the credentials file), so it's safe to call from any
|
|
59
|
-
// entry point at startup. Precedence (mirrors bin/privateer-
|
|
60
|
-
// launcher, the REPL, and the next-launch seed all agree):
|
|
70
|
+
// entry point at startup. Precedence (mirrors bin/privateer-launch.mjs's launch logic,
|
|
71
|
+
// so the launcher, the REPL, and the next-launch seed all agree):
|
|
61
72
|
// 1. explicit user choice (config/channel) — deliberate, always wins
|
|
62
73
|
// 2. PRIVATEER_MODEL env — dev/global override
|
|
63
74
|
// 3. Tinfoil key present → Tinfoil GLM 5.2 — strongest (client-attested) privacy
|
|
64
|
-
// 4. signed into Privateer → the
|
|
75
|
+
// 4. signed into Privateer → the same model over the subscription
|
|
65
76
|
// 5. a BYO provider whose key is present — anthropic, openai, openrouter
|
|
66
|
-
// 6.
|
|
77
|
+
// 6. nothing at all → the account default anyway — so the failure names Privateer
|
|
78
|
+
// and /login is the visible fix, instead of a keyless OpenRouter dead end
|
|
67
79
|
export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
|
|
68
80
|
const env = opts.env ?? process.env;
|
|
69
81
|
|
|
@@ -84,15 +96,19 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
|
|
|
84
96
|
if (env[keyName]?.trim()) return spec;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
|
|
99
|
+
// No key, no login. Point at the account channel regardless: it's the model this
|
|
100
|
+
// terminal will run the moment they /login, so signing in needs no model switch at
|
|
101
|
+
// all, and until then the error reads "No API key found for privateer" — which our
|
|
102
|
+
// guidance turns into "you're not signed in · run /login".
|
|
103
|
+
return ACCOUNT_DEFAULT_SPEC;
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
// The confidential model to switch the LIVE session onto the moment a user signs in.
|
|
91
|
-
// A terminal launched with
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2 when a key
|
|
95
|
-
//
|
|
107
|
+
// A terminal launched with a BYO key (or an explicit --model) is pinned to whatever it
|
|
108
|
+
// resolved at launch; without an in-session switch a mid-session /login changes nothing
|
|
109
|
+
// visible and the user is left wondering what signing in bought them. This resolves the
|
|
110
|
+
// model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
|
|
111
|
+
// is present and over the subscription otherwise — no BYO key needed.
|
|
96
112
|
// PRIVATEER_MODEL still wins — a deliberate override is never stomped.
|
|
97
113
|
export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
|
|
98
114
|
return resolveDefaultModel({ env, signedIn: true });
|