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.
@@ -11,25 +11,28 @@
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,
17
18
  authedFetch,
18
- spawnAccountCredentials,
19
+ acquireAccountCredential,
19
20
  refreshAccountCredentials,
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 signed-in default, near/zai-org/GLM-5.1-FP8, resolves at startup
28
- // without a "model not found" warning. The first entry is that default: a NEAR
29
- // confidential-compute (TEE, attestable) model — the strongest privacy tier. Also the
30
- // fallback list if the live listing can't be reached.
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,35 +172,62 @@ export const privateerOAuthProvider = {
169
172
  }
170
173
  }
171
174
  if (cb.signal?.aborted) throw new Error("Login cancelled");
172
- const creds = await spawnAccountCredentials();
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
- // The fresh path already fired notifySignedIn (pollForToken); fire here for the
179
- // already-linked path so the header re-renders to "connected" on this terminal too.
180
- if (wasLinked) notifySignedIn();
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
- return await refreshAccountCredentials(creds.refresh);
202
+ next = await refreshAccountCredentials(creds.refresh);
186
203
  } catch {
187
- // child token expired/reused → spawn a fresh one from the parent login.
188
- return spawnAccountCredentials();
204
+ // Child token expired/reused → get another. acquire (not spawn) so a terminal
205
+ // that already holds the device's last session slot can reclaim an orphan
206
+ // instead of being refused a fresh one mid-session.
207
+ next = await acquireAccountCredential();
189
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;
190
213
  },
191
214
  getApiKey(creds: { access: string }): string {
192
215
  return creds.access;
193
216
  },
194
217
  };
195
218
 
196
- // Which privacy channel an account model routes through: NEAR confidential-compute
197
- // (TEE, attestable) for `near/`-prefixed ids, else a server-side ZDR channel.
198
- // Ported from tree-cli resolve.ts.
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.
199
229
  export function privateerChannel(modelId: string): "tee" | "zdr" {
200
- return modelId.startsWith("near/") ? "tee" : "zdr";
230
+ return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
201
231
  }
202
232
 
203
233
  export interface AccountPosture {
@@ -218,6 +248,16 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
218
248
  if (privateerChannel(modelId) === "zdr") {
219
249
  return { tier: "zdr-policy" };
220
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
+ }
221
261
  try {
222
262
  const res = await authedFetch(
223
263
  `${serverBaseUrl()}/api/models/near/attestation?model=${encodeURIComponent(modelId)}`,
@@ -256,6 +296,7 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
256
296
  export function makeAccountProvider() {
257
297
  return (pi: {
258
298
  registerProvider?: (name: string, config: unknown) => void;
299
+ on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
259
300
  }): void => {
260
301
  if (typeof pi.registerProvider !== "function") return;
261
302
  const register = (ids: string[]): void =>
@@ -274,5 +315,114 @@ export function makeAccountProvider() {
274
315
  .catch(() => {
275
316
  /* keep the fallback model */
276
317
  });
318
+
319
+ // Seed the account channel's credential at launch. Nothing else does this in the
320
+ // TUI: Pi only obtains an OAuth credential by running /login, and our shutdown
321
+ // hook deliberately REVOKES the account session and deletes its persisted
322
+ // auth.json entry (see the LIFECYCLE HAZARD note in src/auth/privateer.ts). So a
323
+ // signed-in user who quits and relaunches lands on privateer/* with no key at
324
+ // all, and the first prompt dead-ends on "No API key found for privateer." — even
325
+ // though the banner says "connected". The REPL (cli/chat.ts) and the daemon
326
+ // already spawn one at startup; this gives the TUI the same seed.
327
+ pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
277
328
  };
278
329
  }
330
+
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.
343
+ //
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
+ }
388
+
389
+ // `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
390
+ // path privateer-brand uses to DROP the credential on sign-out).
391
+ type SeedContext = {
392
+ modelRegistry?: { authStorage?: { set?: (provider: string, cred: unknown) => void } };
393
+ hasUI?: boolean;
394
+ ui?: { notify?: (message: string, level: string) => void };
395
+ };
396
+
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;
411
+ const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
412
+ if (typeof store?.set !== "function") return false;
413
+ try {
414
+ store.set("privateer", { type: "oauth", ...(await accountCredential()) });
415
+ return true;
416
+ } catch (e) {
417
+ // The account channel is NOT armed: a dead machine login (401 → credentials cleared
418
+ // + onSessionExpired), the terminal cap (429), or a network blip. Say so now — the
419
+ // banner still reads "connected" (it only knows about the local credentials file),
420
+ // so staying silent leaves the user to discover it as a bare "No API key found for
421
+ // privateer" on their first prompt.
422
+ const c = ctx as SeedContext;
423
+ if (opts.notify !== false && c?.hasUI) {
424
+ c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
425
+ }
426
+ return false;
427
+ }
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
- // The signed-in default: a NEAR confidential-compute (TEE, attestable) model — the
19
- // strongest privacy tier the account channel offers, and the same id the app shows
20
- // first. Kept here as the one definition; providers/account.ts imports it so its seed
21
- // catalog can't drift.
22
- export const ACCOUNT_DEFAULT_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
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
- // Tinfoil's GLM 5.2 — CLIENT-side-attested TEE inference (the live TLS key is bound to
26
- // the enclave's quote), the strongest privacy tier we offer, stronger than the account's
27
- // server-proxied NEAR channel. Preferred whenever a Tinfoil key is present. Kept as the
28
- // one definition so bin/privateer-tui and this resolver agree. See extensions/privateer-
29
- // privacy.ts, which registers `tinfoil/glm-5-2` (and friends) on the tinfoil provider.
30
- export const TINFOIL_DEFAULT_SPEC = "tinfoil/glm-5-2";
31
-
32
- // Last-resort BYO default, preserved from the pre-resolver code so a user who set an
33
- // OpenRouter key (and isn't signed in) keeps the old behaviour. If they have no key
34
- // either, this still surfaces the familiar "No API key found for openrouter" — a clear
35
- // signal to run /login or set a key, which is better than an empty/undefined model.
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-tui's launch logic, so the
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 account default — subscription users, no BYO key
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. LEGACY_BYO_FALLBACK — familiar "add a key" signal
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
- return LEGACY_BYO_FALLBACK;
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 no credentials is pinned by `--model` to the keyless
92
- // OpenRouter fallback; without an in-session switch it stays there and the first prompt
93
- // after /login dead-ends on "No API key found for openrouter". This resolves the model
94
- // sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2 when a key is present, otherwise
95
- // the account's NEAR confidential channel (billable to the subscription, no BYO key).
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 });