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.
- package/SECURITY.md +5 -1
- package/bin/privateer-launch.mjs +33 -23
- package/extensions/privateer-brand.ts +152 -60
- package/extensions/privateer-connect.ts +754 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +128 -4
- package/src/auth/accountSessions.ts +4 -1
- package/src/auth/privateer.ts +94 -22
- package/src/cli/chat.ts +2 -2
- package/src/daemon/index.ts +2 -2
- package/src/mcp/catalog.ts +178 -0
- package/src/providers/account.ts +132 -37
- package/src/providers/defaultModel.ts +42 -26
package/src/providers/account.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// only a first-ever machine login runs the device-code flow.
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
type AccountCredential,
|
|
14
15
|
serverBaseUrl,
|
|
15
16
|
hasCredentials,
|
|
16
17
|
runDeviceLogin,
|
|
@@ -20,16 +21,18 @@ import {
|
|
|
20
21
|
notifySignedIn,
|
|
21
22
|
} from "../auth/privateer.ts";
|
|
22
23
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
23
|
-
import { ACCOUNT_DEFAULT_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
24
|
+
import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
24
25
|
|
|
25
26
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
26
27
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
27
|
-
// particular the
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// fallback list if the live listing
|
|
28
|
+
// particular the default, tinfoil/glm-5-2, resolves at startup without a "model not
|
|
29
|
+
// found" warning, which matters more than ever now that a signed-OUT terminal also
|
|
30
|
+
// launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
|
|
31
|
+
// rest are the familiar names. Also the fallback list if the live listing is
|
|
32
|
+
// unreachable.
|
|
31
33
|
const DEFAULT_MODELS = [
|
|
32
34
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
35
|
+
ACCOUNT_NEAR_MODEL_ID,
|
|
33
36
|
"anthropic/claude-sonnet-4.6",
|
|
34
37
|
"openai/gpt-5.5",
|
|
35
38
|
"deepseek/deepseek-v4-flash",
|
|
@@ -169,37 +172,62 @@ export const privateerOAuthProvider = {
|
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
172
|
-
|
|
175
|
+
// Go through the process-wide, single-flighted accessor rather than acquiring
|
|
176
|
+
// directly. The device flow above already fired notifySignedIn, whose listeners arm
|
|
177
|
+
// the account channel — so a bare acquire here would race that one and mint a SECOND
|
|
178
|
+
// server-side session (a duplicate row in Linked Devices, and a step closer to
|
|
179
|
+
// 429 CHILD_SESSION_CAP). Sharing the in-flight promise makes it exactly one.
|
|
180
|
+
const creds = await accountCredential();
|
|
173
181
|
// Seed Pi's saved model default to the account channel, so the next launch resolves
|
|
174
182
|
// to a billable subscription model instead of falling through to a keyless built-in
|
|
175
183
|
// (the "No API key found for openrouter" trap). No-op if the user already has a
|
|
176
184
|
// chosen default. See providers/defaultModel.ts.
|
|
177
185
|
ensurePiDefaultModel();
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
186
|
+
// Announce the completed login — ALWAYS, on both paths, and only now that the
|
|
187
|
+
// account channel actually holds a credential.
|
|
188
|
+
//
|
|
189
|
+
// The fresh path fires this once already, from pollForToken, the instant
|
|
190
|
+
// credentials.json is written. That's the right moment for the header, and the
|
|
191
|
+
// wrong one for the model: the listener that moves the live session onto an
|
|
192
|
+
// account model would run while this spawn was still in flight and find no key.
|
|
193
|
+
// Firing again here is what makes the switch land. Listeners are documented as
|
|
194
|
+
// idempotent (see notifySignedIn), and the model switch no-ops when it's already
|
|
195
|
+
// on target, so the double signal costs nothing.
|
|
196
|
+
notifySignedIn();
|
|
181
197
|
return creds;
|
|
182
198
|
},
|
|
183
199
|
async refreshToken(creds: { refresh: string }) {
|
|
200
|
+
let next: AccountCredential;
|
|
184
201
|
try {
|
|
185
|
-
|
|
202
|
+
next = await refreshAccountCredentials(creds.refresh);
|
|
186
203
|
} catch {
|
|
187
204
|
// Child token expired/reused → get another. acquire (not spawn) so a terminal
|
|
188
205
|
// that already holds the device's last session slot can reclaim an orphan
|
|
189
206
|
// instead of being refused a fresh one mid-session.
|
|
190
|
-
|
|
207
|
+
next = await acquireAccountCredential();
|
|
191
208
|
}
|
|
209
|
+
// Keep the process memo on the CURRENT token: the one it replaced is dead, and
|
|
210
|
+
// handing a dead token to a later arm() would 401 on the first prompt.
|
|
211
|
+
rememberAccountCredential(next);
|
|
212
|
+
return next;
|
|
192
213
|
},
|
|
193
214
|
getApiKey(creds: { access: string }): string {
|
|
194
215
|
return creds.access;
|
|
195
216
|
},
|
|
196
217
|
};
|
|
197
218
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
219
|
+
// Confidential-compute prefixes in the account catalog: every model the server serves
|
|
220
|
+
// out of a TEE. `near/` is the one we can attest end to end from here (the server
|
|
221
|
+
// proxies a nonce'd quote); `tinfoil/` and `phala/` are equally real enclaves whose
|
|
222
|
+
// attestation we cannot bind to THIS connection through the proxy — see accountPosture.
|
|
223
|
+
const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
|
|
224
|
+
|
|
225
|
+
// Which privacy channel an account model routes through: confidential compute (TEE)
|
|
226
|
+
// for the prefixes above, else a server-side ZDR channel. Ported from tree-cli
|
|
227
|
+
// resolve.ts, then widened — it used to say `near/` only, which quietly labelled the
|
|
228
|
+
// default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
|
|
201
229
|
export function privateerChannel(modelId: string): "tee" | "zdr" {
|
|
202
|
-
return modelId.startsWith(
|
|
230
|
+
return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
|
|
203
231
|
}
|
|
204
232
|
|
|
205
233
|
export interface AccountPosture {
|
|
@@ -220,6 +248,16 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
220
248
|
if (privateerChannel(modelId) === "zdr") {
|
|
221
249
|
return { tier: "zdr-policy" };
|
|
222
250
|
}
|
|
251
|
+
// Honest labelling for the non-NEAR enclaves. Tinfoil and Phala publish real
|
|
252
|
+
// attestations, but the server proxies the inference, so from here we cannot bind a
|
|
253
|
+
// quote to the connection actually carrying our tokens — only the account's word
|
|
254
|
+
// that it did. That's `tee-unverified` (yellow "confidential compute, unconfirmed"),
|
|
255
|
+
// never the green tee-verified we reserve for a quote we checked ourselves. A user
|
|
256
|
+
// who wants the verified shield sets TINFOIL_API_KEY and runs `tinfoil/*` direct,
|
|
257
|
+
// where pi-privacy attests the enclave client-side.
|
|
258
|
+
if (!modelId.startsWith("near/")) {
|
|
259
|
+
return { tier: "tee-unverified" };
|
|
260
|
+
}
|
|
223
261
|
try {
|
|
224
262
|
const res = await authedFetch(
|
|
225
263
|
`${serverBaseUrl()}/api/models/near/attestation?model=${encodeURIComponent(modelId)}`,
|
|
@@ -286,22 +324,67 @@ export function makeAccountProvider() {
|
|
|
286
324
|
// all, and the first prompt dead-ends on "No API key found for privateer." — even
|
|
287
325
|
// though the banner says "connected". The REPL (cli/chat.ts) and the daemon
|
|
288
326
|
// already spawn one at startup; this gives the TUI the same seed.
|
|
289
|
-
pi.on?.("session_start", (_e, ctx) => void
|
|
327
|
+
pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
|
|
290
328
|
};
|
|
291
329
|
}
|
|
292
330
|
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
331
|
+
// ── Arming the account channel ───────────────────────────────────────────────
|
|
332
|
+
//
|
|
333
|
+
// ONE account session per PROCESS. Every mint is expensive and visible: it's a row in
|
|
334
|
+
// the app's Linked Devices list, and the server caps how many a device may hold
|
|
335
|
+
// (429 CHILD_SESSION_CAP). session_start alone fires for new/resume/fork/reload, and a
|
|
336
|
+
// mid-session /login wants the channel armed too — so the credential is minted once
|
|
337
|
+
// and then remembered, and later callers reuse it instead of stacking another row.
|
|
338
|
+
//
|
|
339
|
+
// Both the memo and its in-flight promise live on globalThis rather than in module
|
|
340
|
+
// scope, because jiti gives each extension its OWN instance of this file (see the note
|
|
341
|
+
// in auth/privateer.ts). privateer-account seeds at launch and privateer-brand arms
|
|
342
|
+
// after a sign-in; module-scoped state would let each mint its own session.
|
|
298
343
|
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
344
|
+
// A fresh PROCESS always mints: a run that crashed without its shutdown hook can leave
|
|
345
|
+
// a REVOKED credential persisted in auth.json with a still-valid-looking `expires`,
|
|
346
|
+
// which Pi would happily reuse and 401 on.
|
|
347
|
+
const ARMED = Symbol.for("privateer.accountCredential");
|
|
348
|
+
type ArmedSlot = {
|
|
349
|
+
[ARMED]?: { cred?: AccountCredential; inFlight?: Promise<AccountCredential> };
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
|
|
353
|
+
const g = globalThis as ArmedSlot;
|
|
354
|
+
return (g[ARMED] ??= {});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Record a credential this process minted so nothing mints a second one. Exported for
|
|
358
|
+
// the OAuth login path, which acquires its credential for Pi to own and would
|
|
359
|
+
// otherwise leave the next arm() with nothing to reuse.
|
|
360
|
+
export function rememberAccountCredential(cred: AccountCredential): void {
|
|
361
|
+
armSlot().cred = cred;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The remembered credential, if it's still usable. A minute of headroom: handing back
|
|
365
|
+
// one that expires mid-request just trades a spawn for a 401.
|
|
366
|
+
function liveAccountCredential(): AccountCredential | undefined {
|
|
367
|
+
const cred = armSlot().cred;
|
|
368
|
+
return cred && cred.expires > Date.now() + 60_000 ? cred : undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Get this process's account credential, minting one only if we don't already hold a
|
|
372
|
+
// live one. Single-flighted, so two callers racing (session_start and a sign-in) share
|
|
373
|
+
// one spawn rather than opening two sessions.
|
|
374
|
+
async function accountCredential(): Promise<AccountCredential> {
|
|
375
|
+
const live = liveAccountCredential();
|
|
376
|
+
if (live) return live;
|
|
377
|
+
const slot = armSlot();
|
|
378
|
+
slot.inFlight ??= acquireAccountCredential()
|
|
379
|
+
.then((cred) => {
|
|
380
|
+
slot.cred = cred;
|
|
381
|
+
return cred;
|
|
382
|
+
})
|
|
383
|
+
.finally(() => {
|
|
384
|
+
slot.inFlight = undefined;
|
|
385
|
+
});
|
|
386
|
+
return slot.inFlight;
|
|
387
|
+
}
|
|
305
388
|
|
|
306
389
|
// `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
|
|
307
390
|
// path privateer-brand uses to DROP the credential on sign-out).
|
|
@@ -311,23 +394,35 @@ type SeedContext = {
|
|
|
311
394
|
ui?: { notify?: (message: string, level: string) => void };
|
|
312
395
|
};
|
|
313
396
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
397
|
+
// Put a working account credential into Pi's auth store, so `privateer/*` models can
|
|
398
|
+
// actually run. Called at session_start (the launch seed) and again right after a
|
|
399
|
+
// sign-in (see the brand extension) — a mid-session /login has to arm the channel
|
|
400
|
+
// itself, because Pi writes an OAuth credential only for a login IT drove, never for
|
|
401
|
+
// our own /login device-code command.
|
|
402
|
+
//
|
|
403
|
+
// Returns true when the channel is armed. `notify` controls whether a failure is
|
|
404
|
+
// announced: the launch seed says so out loud, while a caller that reports the outcome
|
|
405
|
+
// itself (the sign-in path) passes false so the user doesn't read it twice.
|
|
406
|
+
export async function armAccountCredential(
|
|
407
|
+
ctx: unknown,
|
|
408
|
+
opts: { notify?: boolean } = {},
|
|
409
|
+
): Promise<boolean> {
|
|
410
|
+
if (!hasCredentials()) return false;
|
|
318
411
|
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
319
|
-
if (typeof store?.set !== "function") return;
|
|
412
|
+
if (typeof store?.set !== "function") return false;
|
|
320
413
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
414
|
+
store.set("privateer", { type: "oauth", ...(await accountCredential()) });
|
|
415
|
+
return true;
|
|
323
416
|
} catch (e) {
|
|
324
417
|
// The account channel is NOT armed: a dead machine login (401 → credentials cleared
|
|
325
418
|
// + onSessionExpired), the terminal cap (429), or a network blip. Say so now — the
|
|
326
419
|
// banner still reads "connected" (it only knows about the local credentials file),
|
|
327
420
|
// so staying silent leaves the user to discover it as a bare "No API key found for
|
|
328
|
-
// privateer" on their first prompt.
|
|
329
|
-
flag[SEEDED] = false;
|
|
421
|
+
// privateer" on their first prompt.
|
|
330
422
|
const c = ctx as SeedContext;
|
|
331
|
-
if (
|
|
423
|
+
if (opts.notify !== false && c?.hasUI) {
|
|
424
|
+
c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
332
427
|
}
|
|
333
428
|
}
|
|
@@ -15,24 +15,35 @@ import { join } from "node:path";
|
|
|
15
15
|
import { hasCredentials } from "../auth/privateer.ts";
|
|
16
16
|
import { agentDir } from "../config/paths.ts";
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
18
|
+
// Tinfoil's most capable chat model, and Privateer's default everywhere. Tinfoil runs
|
|
19
|
+
// GLM 5.2 inside an attestable TEE (the serving enclave's quote is published and the
|
|
20
|
+
// live TLS key is bound to it), which is the strongest privacy tier we offer — so the
|
|
21
|
+
// most capable model on that tier is what a privacy-first agent should boot on.
|
|
22
|
+
// One definition, three consumers: this resolver, providers/account.ts's seed catalog,
|
|
23
|
+
// and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
|
|
24
|
+
export const TINFOIL_MODEL_ID = "tinfoil/glm-5-2";
|
|
25
|
+
|
|
26
|
+
// Same model, reached two ways:
|
|
27
|
+
// - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
|
|
28
|
+
// TINFOIL_API_KEY, where pi-privacy can CLIENT-attest the enclave live.
|
|
29
|
+
// - ACCOUNT_DEFAULT_SPEC — through the Privateer subscription (the `privateer`
|
|
30
|
+
// provider proxies it), so a signed-in user needs no BYO key at all.
|
|
31
|
+
// The direct route wins when a key is present; otherwise being signed in is enough.
|
|
32
|
+
export const TINFOIL_DEFAULT_SPEC = TINFOIL_MODEL_ID;
|
|
33
|
+
export const ACCOUNT_DEFAULT_MODEL_ID = TINFOIL_MODEL_ID;
|
|
23
34
|
export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
|
|
24
35
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
+
// The account channel's NEAR confidential-compute model — no longer the default, but
|
|
37
|
+
// still the one account model we can attest end-to-end through the server proxy, so
|
|
38
|
+
// it stays first in the seed catalog after the default. See providers/account.ts.
|
|
39
|
+
export const ACCOUNT_NEAR_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
|
|
40
|
+
|
|
41
|
+
// The legacy BYO default, kept ONLY for a user who set an OpenRouter key and isn't
|
|
42
|
+
// signed in — it's what their key actually pays for. It is deliberately no longer the
|
|
43
|
+
// keyless fallback: landing a signed-out, keyless terminal on OpenRouter is what
|
|
44
|
+
// produced the "No API key found for openrouter" dead end that /login couldn't
|
|
45
|
+
// explain. With no key and no login we now point at the account channel instead, so
|
|
46
|
+
// the error names Privateer and /login is visibly the fix.
|
|
36
47
|
export const LEGACY_BYO_FALLBACK = "openrouter/openai/gpt-4o-mini";
|
|
37
48
|
|
|
38
49
|
// BYO providers we can positively detect from the environment, in preference order.
|
|
@@ -56,14 +67,15 @@ export interface ResolveDefaultModelOptions {
|
|
|
56
67
|
|
|
57
68
|
// Resolve the model spec ("provider/id") to use when no model is named. Pure and
|
|
58
69
|
// synchronous (only reads env + the credentials file), so it's safe to call from any
|
|
59
|
-
// entry point at startup. Precedence (mirrors bin/privateer-
|
|
60
|
-
// launcher, the REPL, and the next-launch seed all agree):
|
|
70
|
+
// entry point at startup. Precedence (mirrors bin/privateer-launch.mjs's launch logic,
|
|
71
|
+
// so the launcher, the REPL, and the next-launch seed all agree):
|
|
61
72
|
// 1. explicit user choice (config/channel) — deliberate, always wins
|
|
62
73
|
// 2. PRIVATEER_MODEL env — dev/global override
|
|
63
74
|
// 3. Tinfoil key present → Tinfoil GLM 5.2 — strongest (client-attested) privacy
|
|
64
|
-
// 4. signed into Privateer → the
|
|
75
|
+
// 4. signed into Privateer → the same model over the subscription
|
|
65
76
|
// 5. a BYO provider whose key is present — anthropic, openai, openrouter
|
|
66
|
-
// 6.
|
|
77
|
+
// 6. nothing at all → the account default anyway — so the failure names Privateer
|
|
78
|
+
// and /login is the visible fix, instead of a keyless OpenRouter dead end
|
|
67
79
|
export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
|
|
68
80
|
const env = opts.env ?? process.env;
|
|
69
81
|
|
|
@@ -84,15 +96,19 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
|
|
|
84
96
|
if (env[keyName]?.trim()) return spec;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
|
|
99
|
+
// No key, no login. Point at the account channel regardless: it's the model this
|
|
100
|
+
// terminal will run the moment they /login, so signing in needs no model switch at
|
|
101
|
+
// all, and until then the error reads "No API key found for privateer" — which our
|
|
102
|
+
// guidance turns into "you're not signed in · run /login".
|
|
103
|
+
return ACCOUNT_DEFAULT_SPEC;
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
// The confidential model to switch the LIVE session onto the moment a user signs in.
|
|
91
|
-
// A terminal launched with
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2 when a key
|
|
95
|
-
//
|
|
107
|
+
// A terminal launched with a BYO key (or an explicit --model) is pinned to whatever it
|
|
108
|
+
// resolved at launch; without an in-session switch a mid-session /login changes nothing
|
|
109
|
+
// visible and the user is left wondering what signing in bought them. This resolves the
|
|
110
|
+
// model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
|
|
111
|
+
// is present and over the subscription otherwise — no BYO key needed.
|
|
96
112
|
// PRIVATEER_MODEL still wins — a deliberate override is never stomped.
|
|
97
113
|
export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
|
|
98
114
|
return resolveDefaultModel({ env, signedIn: true });
|