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.
@@ -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
- // ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
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
- let _cache: Credentials | null | undefined;
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
- if (_cache) return _cache;
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
- _cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
181
+ state.cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
136
182
  } catch {
137
183
  return null;
138
184
  }
139
- return _cache;
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
- _cache = creds;
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
- _cache = null;
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
- _expiredListeners.add(listener);
195
- return () => _expiredListeners.delete(listener);
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 _expiredListeners) {
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
- _signedInListeners.add(listener);
239
- return () => _signedInListeners.delete(listener);
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 _signedInListeners) {
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 terminal: revoke its session server-side (best effort) and wipe
570
- * local credentials. Other devices/sessions are untouched.
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
- try {
574
- await apiRequest("/auth/logout", { method: "POST" });
575
- } catch {
576
- /* best effort — clear locally regardless */
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
- if (res.status === 401) {
620
- clearCredentials();
621
- notifySessionExpired();
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 res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
634
- if (!res.ok) throw new Error(`account refresh failed (${res.status})`);
635
- const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
636
- _account = { accessToken }; // the rotated session is the one an explicit sign-out revokes
637
- return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
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.spawnAccountCredentials();
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 keyless launch model ("No API key found for openrouter").
482
- // resolveSignedInModel prefers Tinfoil GLM 5.2, else the account's NEAR channel;
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();
@@ -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
+ }
@@ -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, spawnAccountCredentials, handleServerRevoke } from "../auth/privateer.ts";
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 /signin on this
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 /signin on this machine and restart the daemon");
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 spawnAccountCredentials();
738
+ const creds = await acquireAccountCredential();
739
739
  (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
740
740
  spawnedAccount = true;
741
741
  } catch (e) {