privateer-agent 0.6.7 → 0.6.9

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,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 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,37 +172,62 @@ export const privateerOAuthProvider = {
169
172
  }
170
173
  }
171
174
  if (cb.signal?.aborted) throw new Error("Login cancelled");
172
- const creds = await acquireAccountCredential();
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
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
- return acquireAccountCredential();
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
- // Which privacy channel an account model routes through: NEAR confidential-compute
199
- // (TEE, attestable) for `near/`-prefixed ids, else a server-side ZDR channel.
200
- // 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.
201
229
  export function privateerChannel(modelId: string): "tee" | "zdr" {
202
- return modelId.startsWith("near/") ? "tee" : "zdr";
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 ensureAccountCredential(ctx));
327
+ pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
290
328
  };
291
329
  }
292
330
 
293
- // One spawn per PROCESS. session_start also fires for new/resume/fork/reload — all of
294
- // which keep this process (and its account session) alive — so re-spawning there would
295
- // leak a device row per event. A fresh process always spawns: a run that crashed
296
- // without its shutdown hook can leave a REVOKED credential persisted in auth.json with
297
- // a still-valid-looking `expires`, which Pi would happily reuse and 401 on.
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
- // The flag lives on globalThis, not in module scope, because jiti gives each extension
300
- // that imports this file its OWN module instance (see the note in auth/privateer.ts):
301
- // privateer-account and privateer-brand — which hot-registers the provider on /signin —
302
- // would otherwise hold separate flags and each spawn a session.
303
- const SEEDED = Symbol.for("privateer.accountCredentialSeeded");
304
- type SeedFlag = { [SEEDED]?: boolean };
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
- async function ensureAccountCredential(ctx: unknown): Promise<void> {
315
- const flag = globalThis as SeedFlag;
316
- if (flag[SEEDED] || !hasCredentials()) return;
317
- flag[SEEDED] = true;
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
- const creds = await acquireAccountCredential();
322
- store.set("privateer", { type: "oauth", ...creds });
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. Cleared so a later attempt can retry.
329
- flag[SEEDED] = false;
421
+ // privateer" on their first prompt.
330
422
  const c = ctx as SeedContext;
331
- if (c?.hasUI) c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
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
- // 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 });