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
package/src/auth/privateer.ts
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
16
16
|
import { hostname, userInfo } from "node:os";
|
|
17
17
|
import { globalDir, credentialsPath } from "../config/paths.ts";
|
|
18
|
+
import {
|
|
19
|
+
type OwnedSession,
|
|
20
|
+
recordOwnedSession,
|
|
21
|
+
forgetOwnedSession,
|
|
22
|
+
orphanedSessions,
|
|
23
|
+
dropOwnedSession,
|
|
24
|
+
clearOwnedSessions,
|
|
25
|
+
} from "./accountSessions.ts";
|
|
18
26
|
import { isAccountCapCode } from "../engine/errors.ts";
|
|
19
27
|
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
20
28
|
import { pinAccountSignKey, clearAccountSignKey } from "../crypto/accountTrust.ts";
|
|
@@ -87,9 +95,37 @@ export function defaultDeviceLabel(): string {
|
|
|
87
95
|
}
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
// ──
|
|
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
|
+
}
|
|
122
|
+
|
|
123
|
+
function shared(): SharedAuthState {
|
|
124
|
+
const g = globalThis as { [SHARED]?: SharedAuthState };
|
|
125
|
+
return (g[SHARED] ??= { cache: null, signedIn: new Set(), expired: new Set() });
|
|
126
|
+
}
|
|
91
127
|
|
|
92
|
-
|
|
128
|
+
// ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
|
|
93
129
|
|
|
94
130
|
// Per-terminal child session (see spawnChildSession). Held in memory ONLY — it
|
|
95
131
|
// is never written to the shared credentials file, so each running terminal
|
|
@@ -117,6 +153,15 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
|
117
153
|
// right after revokeLocalSessions() so the next launch spawns a fresh session instead
|
|
118
154
|
// of reusing the revoked one. Doing both is safe; doing only one is not. See
|
|
119
155
|
// revokeLocalSessions and its callers (cli/chat.ts, daemon/index.ts).
|
|
156
|
+
//
|
|
157
|
+
// That pairing only covers a CLEAN exit, though. A terminal killed without running its
|
|
158
|
+
// shutdown hook leaves its row alive server-side for the full TTL, and the next launch
|
|
159
|
+
// used to spawn another on top of it — enough repeats and the spawn is refused with
|
|
160
|
+
// `429 CHILD_SESSION_CAP`. So every session is also recorded in a pid-keyed registry
|
|
161
|
+
// (auth/accountSessions.ts) and acquireAccountCredential reclaims one whose owning
|
|
162
|
+
// terminal is gone instead of spawning. Keep the registry in step with reality:
|
|
163
|
+
// recordOwnedSession wherever a credential is minted or rotated, forgetOwnedSession
|
|
164
|
+
// wherever one is revoked.
|
|
120
165
|
let _account: { accessToken: string } | null = null;
|
|
121
166
|
|
|
122
167
|
export function loadCredentials(): Credentials | null {
|
|
@@ -128,15 +173,16 @@ export function loadCredentials(): Credentials | null {
|
|
|
128
173
|
// memoized the pre-login "absent" as null, that instance would report "not signed
|
|
129
174
|
// in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
|
|
130
175
|
// disk on each miss lets a later call see what a sign-in just wrote.
|
|
131
|
-
|
|
176
|
+
const state = shared();
|
|
177
|
+
if (state.cache) return state.cache;
|
|
132
178
|
const path = credentialsPath();
|
|
133
179
|
if (!existsSync(path)) return null;
|
|
134
180
|
try {
|
|
135
|
-
|
|
181
|
+
state.cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
|
136
182
|
} catch {
|
|
137
183
|
return null;
|
|
138
184
|
}
|
|
139
|
-
return
|
|
185
|
+
return state.cache;
|
|
140
186
|
}
|
|
141
187
|
|
|
142
188
|
export function saveCredentials(creds: Credentials): void {
|
|
@@ -146,7 +192,7 @@ export function saveCredentials(creds: Credentials): void {
|
|
|
146
192
|
const path = credentialsPath();
|
|
147
193
|
writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
148
194
|
tryChmod(path, 0o600);
|
|
149
|
-
|
|
195
|
+
shared().cache = creds;
|
|
150
196
|
}
|
|
151
197
|
|
|
152
198
|
export function clearCredentials(): void {
|
|
@@ -158,7 +204,7 @@ export function clearCredentials(): void {
|
|
|
158
204
|
// Drop the pinned account signing key too — it belongs to the account that just
|
|
159
205
|
// signed out; a different account must re-pin its own at link.
|
|
160
206
|
clearAccountSignKey();
|
|
161
|
-
|
|
207
|
+
shared().cache = null;
|
|
162
208
|
_child = null;
|
|
163
209
|
_account = null;
|
|
164
210
|
}
|
|
@@ -188,15 +234,15 @@ function tryChmod(path: string, mode: number): void {
|
|
|
188
234
|
// stops working; the UI subscribes to announce the sign-out prominently.
|
|
189
235
|
|
|
190
236
|
type SessionExpiredListener = () => void;
|
|
191
|
-
const _expiredListeners = new Set<SessionExpiredListener>();
|
|
192
237
|
|
|
193
238
|
export function onSessionExpired(listener: SessionExpiredListener): () => void {
|
|
194
|
-
|
|
195
|
-
|
|
239
|
+
const listeners = shared().expired;
|
|
240
|
+
listeners.add(listener);
|
|
241
|
+
return () => listeners.delete(listener);
|
|
196
242
|
}
|
|
197
243
|
|
|
198
244
|
function notifySessionExpired(): void {
|
|
199
|
-
for (const listener of
|
|
245
|
+
for (const listener of shared().expired) {
|
|
200
246
|
try {
|
|
201
247
|
listener();
|
|
202
248
|
} catch {
|
|
@@ -232,17 +278,19 @@ export function handleServerRevoke(): void {
|
|
|
232
278
|
// A listener here refreshes the UI regardless of which path the user took.
|
|
233
279
|
|
|
234
280
|
type SignedInListener = () => void;
|
|
235
|
-
const _signedInListeners = new Set<SignedInListener>();
|
|
236
281
|
|
|
237
282
|
export function onSignedIn(listener: SignedInListener): () => void {
|
|
238
|
-
|
|
239
|
-
|
|
283
|
+
const listeners = shared().signedIn;
|
|
284
|
+
listeners.add(listener);
|
|
285
|
+
return () => listeners.delete(listener);
|
|
240
286
|
}
|
|
241
287
|
|
|
242
288
|
// Emit the sign-in signal. Exported so the account OAuth provider can announce a
|
|
243
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).
|
|
244
292
|
export function notifySignedIn(): void {
|
|
245
|
-
for (const listener of
|
|
293
|
+
for (const listener of shared().signedIn) {
|
|
246
294
|
try {
|
|
247
295
|
listener();
|
|
248
296
|
} catch {
|
|
@@ -375,6 +423,29 @@ export async function runDeviceLogin(opts: {
|
|
|
375
423
|
* isolation, so two terminals never fight over one rotating token (which would
|
|
376
424
|
* trip the server's reuse-detection and revoke every session).
|
|
377
425
|
*/
|
|
426
|
+
// Turn a failed /auth/session/spawn into an accurate error.
|
|
427
|
+
//
|
|
428
|
+
// A 401 means the parent refresh token is gone — the machine login itself is dead, so
|
|
429
|
+
// clear it and announce (the UI flips to signed-out). EVERY OTHER status used to be
|
|
430
|
+
// reported as an expiry too, which actively misled: the common one is 429
|
|
431
|
+
// `CHILD_SESSION_CAP` ("Too many active terminals for this device. Sign one out and
|
|
432
|
+
// try again"), where /login is not the fix and the credentials are perfectly valid.
|
|
433
|
+
// Pass the server's own message through so the user learns what to actually do.
|
|
434
|
+
async function spawnFailure(res: Response): Promise<Error> {
|
|
435
|
+
if (res.status === 401) {
|
|
436
|
+
clearCredentials();
|
|
437
|
+
notifySessionExpired();
|
|
438
|
+
return new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
439
|
+
}
|
|
440
|
+
let message: string | undefined;
|
|
441
|
+
try {
|
|
442
|
+
message = ((await res.json()) as { message?: string }).message;
|
|
443
|
+
} catch {
|
|
444
|
+
/* non-JSON body — fall back to the status line below */
|
|
445
|
+
}
|
|
446
|
+
return new Error(message?.trim() || `Couldn't start a Privateer session (HTTP ${res.status}).`);
|
|
447
|
+
}
|
|
448
|
+
|
|
378
449
|
async function spawnChildSession(): Promise<ChildSession> {
|
|
379
450
|
const parent = loadCredentials();
|
|
380
451
|
if (!parent) throw new Error("Not logged in to Privateer. Run /login.");
|
|
@@ -389,14 +460,7 @@ async function spawnChildSession(): Promise<ChildSession> {
|
|
|
389
460
|
}, {
|
|
390
461
|
headers: { Authorization: `Bearer ${parent.accessToken}` },
|
|
391
462
|
});
|
|
392
|
-
if (!res.ok)
|
|
393
|
-
// Parent refresh token invalid/expired → the machine login is gone.
|
|
394
|
-
if (res.status === 401) {
|
|
395
|
-
clearCredentials();
|
|
396
|
-
notifySessionExpired();
|
|
397
|
-
}
|
|
398
|
-
throw new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
399
|
-
}
|
|
463
|
+
if (!res.ok) throw await spawnFailure(res);
|
|
400
464
|
const { accessToken, refreshToken } = (await res.json()) as ChildSession;
|
|
401
465
|
_child = { accessToken, refreshToken };
|
|
402
466
|
return _child;
|
|
@@ -543,6 +607,10 @@ export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
|
|
|
543
607
|
const account = _account;
|
|
544
608
|
if (!account) return;
|
|
545
609
|
_account = null;
|
|
610
|
+
// Stop advertising this session as reclaimable BEFORE killing it: an entry left
|
|
611
|
+
// behind would offer the next launch a dead row to adopt (it would fail over to a
|
|
612
|
+
// spawn, but only after a wasted round trip).
|
|
613
|
+
forgetOwnedSession();
|
|
546
614
|
await deleteSession(account.accessToken, timeoutMs);
|
|
547
615
|
}
|
|
548
616
|
|
|
@@ -566,15 +634,55 @@ export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
|
|
|
566
634
|
// ── Logout ───────────────────────────────────────────────────────────────────
|
|
567
635
|
|
|
568
636
|
/**
|
|
569
|
-
* Log out this
|
|
570
|
-
* 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.
|
|
571
670
|
*/
|
|
572
671
|
export async function logout(): Promise<void> {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
}
|
|
577
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
|
|
578
686
|
clearCredentials();
|
|
579
687
|
}
|
|
580
688
|
|
|
@@ -586,7 +694,7 @@ export async function logout(): Promise<void> {
|
|
|
586
694
|
// = the JWT's exp (so Pi refreshes just before the server would reject it). These are
|
|
587
695
|
// independent of authedFetch's in-memory _child (Pi owns this credential's lifecycle).
|
|
588
696
|
|
|
589
|
-
interface AccountCredential {
|
|
697
|
+
export interface AccountCredential {
|
|
590
698
|
access: string;
|
|
591
699
|
refresh: string;
|
|
592
700
|
expires: number; // ms epoch
|
|
@@ -615,26 +723,102 @@ export async function spawnAccountCredentials(): Promise<AccountCredential> {
|
|
|
615
723
|
{ refreshToken: parent.refreshToken, deviceLabel: defaultDeviceLabel() },
|
|
616
724
|
{ headers: { Authorization: `Bearer ${parent.accessToken}` } },
|
|
617
725
|
);
|
|
726
|
+
if (!res.ok) throw await spawnFailure(res);
|
|
727
|
+
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
728
|
+
_account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
|
|
729
|
+
const cred = { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
730
|
+
recordOwnedSession(cred); // claim the row, so a crash leaves it reclaimable
|
|
731
|
+
return cred;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// An /auth/refresh the server actively REFUSED, as opposed to one that never got an
|
|
735
|
+
// answer. Only the former proves the session is gone; a network failure says nothing,
|
|
736
|
+
// and treating it as death would leak the row (see dropOwnedSession).
|
|
737
|
+
export interface RefreshRejection extends Error {
|
|
738
|
+
status: number;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export function isRefreshRejection(e: unknown): e is RefreshRejection {
|
|
742
|
+
return e instanceof Error && typeof (e as RefreshRejection).status === "number";
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Rotate a session's refresh token, with NO ownership side effects. Split out from
|
|
746
|
+
// refreshAccountCredentials so orphan cleanup can rotate a session purely to obtain a
|
|
747
|
+
// token it can revoke with, without claiming that session as this terminal's own.
|
|
748
|
+
async function rotateSession(refresh: string): Promise<AccountCredential> {
|
|
749
|
+
const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
|
|
618
750
|
if (!res.ok) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
623
|
-
throw new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
751
|
+
const err = new Error(`account refresh failed (${res.status})`) as RefreshRejection;
|
|
752
|
+
err.status = res.status;
|
|
753
|
+
throw err;
|
|
624
754
|
}
|
|
625
755
|
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
626
|
-
_account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
|
|
627
756
|
return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
628
757
|
}
|
|
629
758
|
|
|
630
759
|
// Rotate this account credential's own refresh token; caller falls back to a fresh
|
|
631
760
|
// spawn if this throws (expired/reused child token).
|
|
632
761
|
export async function refreshAccountCredentials(refresh: string): Promise<AccountCredential> {
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
762
|
+
const cred = await rotateSession(refresh);
|
|
763
|
+
_account = { accessToken: cred.access }; // the rotated session is the one an explicit sign-out revokes
|
|
764
|
+
// Re-claim on every rotation — including the ones Pi drives on expiry — so the
|
|
765
|
+
// registry always holds a token that would actually work if we crashed right now.
|
|
766
|
+
recordOwnedSession(cred);
|
|
767
|
+
return cred;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Get an account credential for THIS terminal, reusing a session orphaned by a
|
|
771
|
+
// terminal that died without revoking rather than stacking another row on top of it.
|
|
772
|
+
//
|
|
773
|
+
// Reclaiming is what keeps a crash from costing a permanent session slot: each orphan
|
|
774
|
+
// otherwise sits on the server for its full TTL, and enough of them earn a
|
|
775
|
+
// `429 CHILD_SESSION_CAP` on the next spawn. A successful /auth/refresh doubles as the
|
|
776
|
+
// liveness probe — it proves the row is real and hands back a usable access token —
|
|
777
|
+
// so an orphan that turns out to be dead just falls through to the next candidate.
|
|
778
|
+
//
|
|
779
|
+
// Orphans we don't adopt are revoked in the background: their terminal is gone, so the
|
|
780
|
+
// row is pure waste, and freeing it is what actually unwinds an account already at the
|
|
781
|
+
// cap. Never touches a session whose owner is still running (see accountSessions.ts).
|
|
782
|
+
export async function acquireAccountCredential(): Promise<AccountCredential> {
|
|
783
|
+
const orphans = orphanedSessions();
|
|
784
|
+
let adopted: AccountCredential | null = null;
|
|
785
|
+
let attempted = 0;
|
|
786
|
+
|
|
787
|
+
while (attempted < orphans.length && !adopted) {
|
|
788
|
+
const orphan = orphans[attempted++];
|
|
789
|
+
try {
|
|
790
|
+
adopted = await refreshAccountCredentials(orphan.refresh);
|
|
791
|
+
dropOwnedSession(orphan.pid); // the rotation above re-recorded it under OUR pid
|
|
792
|
+
} catch (e) {
|
|
793
|
+
// Refused → the session is gone; stop tracking it. Unreachable → keep it, so a
|
|
794
|
+
// network blip doesn't strand a live row we could have reclaimed next launch.
|
|
795
|
+
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Best-effort cleanup of the ones we didn't need. Detached: freeing slots must never
|
|
800
|
+
// delay startup, and a failure here costs nothing the next launch can't retry.
|
|
801
|
+
const leftovers = orphans.slice(attempted);
|
|
802
|
+
if (leftovers.length) void revokeOrphanedSessions(leftovers);
|
|
803
|
+
|
|
804
|
+
return adopted ?? (await spawnAccountCredentials());
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Revoke sessions whose terminal is gone. Revoking needs a LIVE access token
|
|
808
|
+
// (DELETE /auth/session/current is Bearer-authenticated) and an orphan's stored one is
|
|
809
|
+
// usually stale, so rotate first — via rotateSession, which deliberately does NOT claim
|
|
810
|
+
// ownership: these sessions are being destroyed, not adopted, and recording them would
|
|
811
|
+
// overwrite the entry for the credential this terminal is actually using.
|
|
812
|
+
async function revokeOrphanedSessions(orphans: OwnedSession[], timeoutMs = 1500): Promise<void> {
|
|
813
|
+
for (const orphan of orphans) {
|
|
814
|
+
try {
|
|
815
|
+
const cred = await rotateSession(orphan.refresh);
|
|
816
|
+
await deleteSession(cred.access, timeoutMs);
|
|
817
|
+
dropOwnedSession(orphan.pid);
|
|
818
|
+
} catch (e) {
|
|
819
|
+
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
638
822
|
}
|
|
639
823
|
|
|
640
824
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
package/src/cli/chat.ts
CHANGED
|
@@ -341,7 +341,7 @@ async function main() {
|
|
|
341
341
|
// resolves it; Pi then manages refresh on expiry via the registered oauth provider.
|
|
342
342
|
if (provider === "privateer") {
|
|
343
343
|
try {
|
|
344
|
-
const creds = await priv.
|
|
344
|
+
const creds = await priv.acquireAccountCredential();
|
|
345
345
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
346
346
|
} catch (e) {
|
|
347
347
|
console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
|
|
@@ -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/config/paths.ts
CHANGED
|
@@ -39,3 +39,12 @@ export function credentialsPath(): string {
|
|
|
39
39
|
export function configPath(): string {
|
|
40
40
|
return join(globalDir(), "config.json");
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// Account-provider inference sessions this MACHINE has spawned, keyed by the pid of
|
|
44
|
+
// the terminal that owns each one (see auth/accountSessions.ts). Lets a launch tell a
|
|
45
|
+
// session belonging to a STILL-RUNNING terminal from one orphaned by a crash, so it
|
|
46
|
+
// can reclaim the orphan instead of spawning another and walking into the server's
|
|
47
|
+
// per-device terminal cap. Holds refresh tokens — written 0600, like credentials.json.
|
|
48
|
+
export function accountSessionsPath(): string {
|
|
49
|
+
return join(globalDir(), "account-sessions.json");
|
|
50
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
|
|
|
30
30
|
import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
|
|
31
31
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
32
32
|
import { authorizeControl } from "../remote/controlAuth.ts";
|
|
33
|
-
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest,
|
|
33
|
+
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
|
|
34
34
|
import {
|
|
35
35
|
loadRoutines,
|
|
36
36
|
upsertRoutine,
|
|
@@ -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: () => {
|
|
@@ -735,7 +735,7 @@ export class Daemon {
|
|
|
735
735
|
const { provider, modelId } = parseSpec(spec.model);
|
|
736
736
|
if (provider === "privateer") {
|
|
737
737
|
try {
|
|
738
|
-
const creds = await
|
|
738
|
+
const creds = await acquireAccountCredential();
|
|
739
739
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
740
740
|
spawnedAccount = true;
|
|
741
741
|
} catch (e) {
|