privateer-agent 0.10.0 → 0.12.0
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 +13 -0
- package/bin/privateer-launch.mjs +144 -66
- package/bin/privateer-subagent.mjs +35 -22
- package/extensions/privateer-brand.ts +14 -1
- package/extensions/privateer-gate.ts +20 -28
- package/extensions/privateer-media.ts +20 -0
- package/extensions/privateer-privacy.ts +3 -3
- package/package.json +17 -7
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +876 -5
- package/src/acp/run.ts +16 -19
- package/src/channels/run.ts +18 -27
- package/src/cli/chat.ts +70 -25
- package/src/config/hosted.ts +34 -0
- package/src/config/moat.ts +236 -0
- package/src/config/moatManifest.json +26 -0
- package/src/config/moatManifest.ts +71 -0
- package/src/harbor/index.ts +49 -113
- package/src/outbox/cloudOutbox.ts +95 -0
- package/src/permissions/classify.ts +122 -0
- package/src/providers/account.ts +141 -19
- package/src/providers/defaultModel.ts +66 -11
- package/src/providers/phala/aci-verifier/VENDORED.md +16 -0
- package/src/providers/phala/reportBinding.ts +193 -0
- package/src/providers/phalaSeal.ts +7 -3
- package/src/providers/sealedShim.ts +35 -6
- package/src/remote/extensionsControl.ts +12 -22
- package/src/remote/liveTaskSession.ts +15 -24
- package/src/remote/relayClient.ts +47 -7
- package/src/remote/remoteBridge.ts +65 -0
- package/src/tools/media.ts +554 -0
- package/src/tools/relayFileTools.ts +10 -11
- package/src/tools/videoCompose.ts +579 -0
- package/src/tools/web.ts +12 -8
- package/src/config/harborDaemon.ts +0 -31
- package/src/config/inlineMoat.ts +0 -67
package/src/providers/account.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
|
|
38
38
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
39
39
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
40
|
-
// particular
|
|
40
|
+
// particular ACCOUNT_DEFAULT_MODEL_ID resolves at startup without a "model not
|
|
41
41
|
// found" warning, which matters more than ever now that a signed-OUT terminal also
|
|
42
42
|
// launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
|
|
43
43
|
// rest are the familiar names. Also the fallback list if the live listing is
|
|
@@ -46,6 +46,11 @@ import {
|
|
|
46
46
|
const DEFAULT_MODELS = [
|
|
47
47
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
48
48
|
ACCOUNT_NEAR_MODEL_ID,
|
|
49
|
+
// The default until 2026-08-01 (see TINFOIL_MODEL_ID). It stays in the floor so a
|
|
50
|
+
// user who saved it as their own default still resolves it synchronously at launch,
|
|
51
|
+
// rather than falling through to "first model with configured auth" — the BYO dead
|
|
52
|
+
// end this seed list exists to prevent.
|
|
53
|
+
"tinfoil/glm-5-2",
|
|
49
54
|
"anthropic/claude-opus-5",
|
|
50
55
|
"anthropic/claude-sonnet-5",
|
|
51
56
|
"openai/gpt-5.6-sol",
|
|
@@ -56,7 +61,9 @@ function seedModel(id: string) {
|
|
|
56
61
|
return {
|
|
57
62
|
id,
|
|
58
63
|
name: id,
|
|
59
|
-
reasoning
|
|
64
|
+
// reasoning + how to steer it, for the enclave models where we verified the
|
|
65
|
+
// control shape live; `reasoning: false` (Pi's "not a thinking model") for the rest.
|
|
66
|
+
...(thinkingProfile(id) ?? { reasoning: false as const }),
|
|
60
67
|
input: ["text"] as ("text" | "image")[],
|
|
61
68
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
62
69
|
contextWindow: 128000,
|
|
@@ -64,6 +71,83 @@ function seedModel(id: string) {
|
|
|
64
71
|
};
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
// ── Thinking control ─────────────────────────────────────────────────────────
|
|
75
|
+
//
|
|
76
|
+
// Every account model used to register with `reasoning: false`, and that one field
|
|
77
|
+
// silently pinned the whole catalog to maximum thinking. Pi gates EVERY
|
|
78
|
+
// thinking-control branch on `model.reasoning` (pi-ai api/openai-completions.js
|
|
79
|
+
// buildParams) and AgentSession.cycleThinkingLevel() returns undefined without it. So
|
|
80
|
+
// Privateer sent no thinking parameter at all — a thinking model ran at whatever its
|
|
81
|
+
// server-side default was, forever — and the user's thinking toggle was inert.
|
|
82
|
+
//
|
|
83
|
+
// What that cost, measured live against the account channel on 2026-08-01 with
|
|
84
|
+
// "Write a haiku about the sea": the default model emitted 77 reasoning deltas and
|
|
85
|
+
// ZERO content, spending all 300 tokens thinking. The same prompt with thinking off
|
|
86
|
+
// answered in 18 tokens / 1.8s.
|
|
87
|
+
//
|
|
88
|
+
// Annotating a model is a promise that the dial actually moves, so ONLY shapes
|
|
89
|
+
// verified against the live enclave appear below. Pi's default level is "medium", so
|
|
90
|
+
// nothing here turns thinking off behind the user's back — it makes the toggle real.
|
|
91
|
+
interface ThinkingProfile {
|
|
92
|
+
reasoning: true;
|
|
93
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
94
|
+
compat?: { thinkingFormat: string };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The vLLM chat-template family (GLM, Qwen). Honours
|
|
98
|
+
// `chat_template_kwargs.enable_thinking`, which is exactly what Pi's
|
|
99
|
+
// "qwen-chat-template" format emits. Verified — enable_thinking=false → 0 reasoning
|
|
100
|
+
// deltas and a direct answer, true → thinking restored, neither errors — on
|
|
101
|
+
// tinfoil/glm-5-2, near/zai-org/GLM-5.1-FP8, near/Qwen/Qwen3.6-35B-A3B-FP8 and
|
|
102
|
+
// phala/z-ai/glm-5.2.
|
|
103
|
+
//
|
|
104
|
+
// The switch is binary (there is no effort dial), so publish exactly two levels
|
|
105
|
+
// instead of five that all mean "on": a null in thinkingLevelMap marks a level
|
|
106
|
+
// unsupported and pi-ai's getSupportedThinkingLevels drops it.
|
|
107
|
+
const CHAT_TEMPLATE_THINKING: ThinkingProfile = {
|
|
108
|
+
reasoning: true,
|
|
109
|
+
thinkingLevelMap: { minimal: null, low: null, high: null, xhigh: null },
|
|
110
|
+
compat: { thinkingFormat: "qwen-chat-template" },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// gpt-oss (harmony) is the other way round: it IGNORES chat_template_kwargs and
|
|
114
|
+
// honours `reasoning_effort` — which is Pi's default format for our baseUrl, so this
|
|
115
|
+
// profile deliberately carries no compat override. Verified on tinfoil/gpt-oss-120b:
|
|
116
|
+
// low → 9 reasoning deltas, high → 61.
|
|
117
|
+
//
|
|
118
|
+
// Harmony has no "none", so "off" is pinned to the floor rather than left unset —
|
|
119
|
+
// unset would send nothing and let the model fall back to its own default (medium),
|
|
120
|
+
// i.e. an "off" that thinks harder than "low". This is the toggle's lowest setting,
|
|
121
|
+
// not silence.
|
|
122
|
+
const REASONING_EFFORT_THINKING: ThinkingProfile = {
|
|
123
|
+
reasoning: true,
|
|
124
|
+
thinkingLevelMap: { off: "low", minimal: "low", xhigh: null },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Only the TEE prefixes are annotated. Those are enclaves we drive directly and can
|
|
128
|
+
// probe. The rest of the catalog is proxied to a third-party gateway whose thinking
|
|
129
|
+
// shape we have NOT verified from here, and an unsupported parameter fails the whole
|
|
130
|
+
// turn — decisively worse than a turn that thinks too much. They keep the old
|
|
131
|
+
// behaviour exactly.
|
|
132
|
+
//
|
|
133
|
+
// Two deliberate omissions inside the TEE set: `*-instruct` ids are the
|
|
134
|
+
// non-thinking variants, and tinfoil/kimi-k2-6 reasons but ignored BOTH levers when
|
|
135
|
+
// probed, so annotating it would hand the user a dial connected to nothing.
|
|
136
|
+
const TEE_MODEL = /^(tinfoil|phala|near)\//;
|
|
137
|
+
|
|
138
|
+
export function thinkingProfile(id: string): ThinkingProfile | null {
|
|
139
|
+
if (!TEE_MODEL.test(id)) return null;
|
|
140
|
+
if (/instruct/i.test(id)) return null;
|
|
141
|
+
const profile = /gpt-oss/i.test(id)
|
|
142
|
+
? REASONING_EFFORT_THINKING
|
|
143
|
+
: /glm|qwen/i.test(id)
|
|
144
|
+
? CHAT_TEMPLATE_THINKING
|
|
145
|
+
: null;
|
|
146
|
+
// Hand out a COPY. These entries end up on hundreds of registered models, and a
|
|
147
|
+
// shared nested object is one careless mutation away from retuning the whole catalog.
|
|
148
|
+
return profile && { ...profile, thinkingLevelMap: { ...profile.thinkingLevelMap }, ...(profile.compat ? { compat: { ...profile.compat } } : {}) };
|
|
149
|
+
}
|
|
150
|
+
|
|
67
151
|
// ── Catalog cache ────────────────────────────────────────────────────────────
|
|
68
152
|
//
|
|
69
153
|
// The live catalog (241 models and counting) can only be registered once the network
|
|
@@ -114,10 +198,37 @@ export function loadCachedCatalogIds(): string[] {
|
|
|
114
198
|
}
|
|
115
199
|
}
|
|
116
200
|
|
|
201
|
+
// Whether the account channel can actually SERVE a catalog model right now.
|
|
202
|
+
//
|
|
203
|
+
// `phala/*` is sealed-only: it runs through the sealed blind relay and nowhere else.
|
|
204
|
+
// The server's cleartext `/api/agent/v1` has no Phala route and rejects the id
|
|
205
|
+
// outright (verified live 2026-07-31: 400 "phala/… is not a valid model ID"), because
|
|
206
|
+
// Phala models are the Sealed tier by design — the server is not meant to be able to
|
|
207
|
+
// read them. But `/api/models` advertises them to every client regardless of whether
|
|
208
|
+
// that client can reach the sealed path, so they were pickable and then failed on the
|
|
209
|
+
// first prompt. Offering a model we know cannot answer is worse than a shorter list.
|
|
210
|
+
//
|
|
211
|
+
// The condition is the SHIM, not the flag. Sealed mode being enabled only means we
|
|
212
|
+
// intend to seal; `phala/*` is unservable until the loopback shim is actually
|
|
213
|
+
// listening, because that is what its per-model baseUrl points at (see modelEntry).
|
|
214
|
+
// With the flag now defaulting on, "enabled but the shim failed to bind" is a state a
|
|
215
|
+
// user can really land in, and it must not re-offer models that would 400.
|
|
216
|
+
//
|
|
217
|
+
// `tinfoil/*` is deliberately NOT filtered: the cleartext path serves it fine (sealed
|
|
218
|
+
// mode only upgrades the badge from unconfirmed to verified), so it stays either way.
|
|
219
|
+
export function isServableAccountModel(id: string): boolean {
|
|
220
|
+
if (!id.startsWith("phala/")) return true;
|
|
221
|
+
return sealedEnabled() && sealedShimBase() !== null;
|
|
222
|
+
}
|
|
223
|
+
|
|
117
224
|
// The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
|
|
118
225
|
// default has to be index 0 both because it must always resolve and because Pi clones the
|
|
119
226
|
// provider's first/default model when it synthesizes a custom model id
|
|
120
227
|
// (model-resolver.js buildFallbackModel).
|
|
228
|
+
//
|
|
229
|
+
// Returns the server's list as cached, unfiltered — accountProviderConfig decides what
|
|
230
|
+
// is servable at each registration, so a model dropped now (shim not up yet) can be
|
|
231
|
+
// re-offered by a later re-registration without the cache being rewritten.
|
|
121
232
|
export function seedCatalogIds(): string[] {
|
|
122
233
|
const ids = [...DEFAULT_MODELS];
|
|
123
234
|
const seen = new Set(ids);
|
|
@@ -202,7 +313,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
|
|
|
202
313
|
.map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
|
|
203
314
|
.filter((x): x is AccountModelInfo => !!x);
|
|
204
315
|
// Cache only a real LIVE listing — never the fallback, which would freeze the six
|
|
205
|
-
// seed ids on disk and read back as though it were the catalog.
|
|
316
|
+
// seed ids on disk and read back as though it were the catalog. Both the cache and
|
|
317
|
+
// the returned list are the server's UNFILTERED offer; servability is decided at
|
|
318
|
+
// registration (accountProviderConfig), which re-evaluates it every time.
|
|
206
319
|
if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
|
|
207
320
|
infos = parsed.length ? parsed : fallback();
|
|
208
321
|
}
|
|
@@ -367,7 +480,7 @@ const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
|
|
|
367
480
|
// Which privacy channel an account model routes through: confidential compute (TEE)
|
|
368
481
|
// for the prefixes above, else a server-side ZDR channel. Ported from tree-cli
|
|
369
482
|
// resolve.ts, then widened — it used to say `near/` only, which quietly labelled the
|
|
370
|
-
// default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
|
|
483
|
+
// then-default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
|
|
371
484
|
export function privateerChannel(modelId: string): "tee" | "zdr" {
|
|
372
485
|
return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
|
|
373
486
|
}
|
|
@@ -402,14 +515,15 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
402
515
|
const att = await attestSealed(sealedProvider);
|
|
403
516
|
return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
|
|
404
517
|
}
|
|
405
|
-
// Honest labelling for the non-NEAR enclaves
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// client-side over the
|
|
518
|
+
// Honest labelling for the non-NEAR enclaves when we are NOT sealing — sealed mode
|
|
519
|
+
// explicitly disabled (PRIVATEER_SEALED=0), or on but the shim never came up. Tinfoil
|
|
520
|
+
// and Phala publish real attestations, but the server proxies the inference in
|
|
521
|
+
// cleartext, so from here we cannot bind a quote to the connection actually carrying
|
|
522
|
+
// our tokens — only the account's word that it did. That's `tee-unverified` (yellow
|
|
523
|
+
// "confidential compute, unconfirmed"), never the green tee-verified we reserve for a
|
|
524
|
+
// quote we checked ourselves. Re-enable sealed mode for the verified shield, or set
|
|
525
|
+
// TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests client-side over the
|
|
526
|
+
// TLS binding).
|
|
413
527
|
if (!modelId.startsWith("near/")) {
|
|
414
528
|
return { tier: "tee-unverified" };
|
|
415
529
|
}
|
|
@@ -429,11 +543,13 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
429
543
|
}
|
|
430
544
|
}
|
|
431
545
|
|
|
432
|
-
// A model entry, with a per-model baseUrl override once the
|
|
433
|
-
// `tinfoil/*` then route through the loopback shim (which seals to the
|
|
434
|
-
// instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the
|
|
435
|
-
// baseUrl. Until the shim is up (or
|
|
436
|
-
// the cleartext path
|
|
546
|
+
// A model entry, with a per-model baseUrl override once the sealed shim is listening:
|
|
547
|
+
// `tinfoil/*` and `phala/*` then route through the loopback shim (which seals to the
|
|
548
|
+
// blind relay) instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the
|
|
549
|
+
// provider baseUrl. Until the shim is up (or with sealed mode disabled) `tinfoil/*` falls
|
|
550
|
+
// back to the cleartext path and the badge stays honestly `tee-unverified` (see
|
|
551
|
+
// accountPosture); `phala/*` has no cleartext path at all and is not registered in that
|
|
552
|
+
// state (see isServableAccountModel).
|
|
437
553
|
function modelEntry(id: string) {
|
|
438
554
|
const base = seedModel(id);
|
|
439
555
|
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
@@ -448,7 +564,13 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
|
|
|
448
564
|
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
449
565
|
api: "openai-completions",
|
|
450
566
|
oauth: privateerOAuthProvider,
|
|
451
|
-
|
|
567
|
+
// Filter HERE rather than at the catalog, so callers keep passing the server's
|
|
568
|
+
// full list and every registration re-evaluates servability against the CURRENT
|
|
569
|
+
// shim state. That is what lets the post-shim re-registration in makeAccountProvider
|
|
570
|
+
// put `phala/*` back: had the ids been filtered upstream, the sealed-only models
|
|
571
|
+
// would have been dropped from `lastIds` before the shim ever finished starting and
|
|
572
|
+
// nothing would have brought them back.
|
|
573
|
+
models: ids.filter(isServableAccountModel).map(modelEntry),
|
|
452
574
|
};
|
|
453
575
|
}
|
|
454
576
|
|
|
@@ -460,7 +582,7 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
|
|
|
460
582
|
// REPLACES a provider's model list and its request config, so whichever registration
|
|
461
583
|
// lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
|
|
462
584
|
// on a typical box puts privateer-privacy after privateer-account. The account channel's
|
|
463
|
-
// whole catalog was then replaced by that one model, so the default
|
|
585
|
+
// whole catalog was then replaced by that one model, so the account default no
|
|
464
586
|
// longer resolved ("not found for provider privateer. Using custom model id") and the
|
|
465
587
|
// synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
|
|
466
588
|
//
|
|
@@ -15,13 +15,29 @@ 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
|
-
//
|
|
18
|
+
// A capable Tinfoil chat model, and Privateer's default everywhere. Tinfoil runs it
|
|
19
|
+
// inside an attestable TEE (the serving enclave's quote is published and the live TLS
|
|
20
|
+
// key is bound to it), which is the strongest privacy tier we offer — so a capable
|
|
21
|
+
// model on that tier is what a privacy-first agent should boot on.
|
|
22
22
|
// One definition, three consumers: this resolver, providers/account.ts's seed catalog,
|
|
23
23
|
// and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
|
|
24
|
-
|
|
24
|
+
//
|
|
25
|
+
// It was `tinfoil/glm-5-2` until 2026-08-01, and the swap is a LATENCY decision, not a
|
|
26
|
+
// capability one. Measured over 22 requests spaced 20s apart on the account channel,
|
|
27
|
+
// glm-5-2 stalled before its first token on 9 of them — 33s to 98s each, with the
|
|
28
|
+
// model demonstrably warm 20 seconds earlier, so it is contention in that deployment
|
|
29
|
+
// rather than a cold start anything here can warm up. kimi-k2-6 and gpt-oss-120b, same
|
|
30
|
+
// enclave provider, same tier, same transport, stalled 0 times in 20 (medians 1.2s and
|
|
31
|
+
// 1.0s). The same run reproduced glm-5-2's stalls on BOTH the sealed and the cleartext
|
|
32
|
+
// path, which is what rules out the shim, the relay and the proxy as the cause.
|
|
33
|
+
//
|
|
34
|
+
// Two consequences worth knowing when revisiting this: glm-5-2 remains in the live
|
|
35
|
+
// catalog and is one pick away for anyone who wants it, and kimi-k2-6 reasons on every
|
|
36
|
+
// turn with no working off switch (see thinkingProfile in providers/account.ts — its
|
|
37
|
+
// levers were probed and none of them moved the reasoning volume). Its reasoning is
|
|
38
|
+
// short and it always reaches an answer, which is why that is acceptable here and was
|
|
39
|
+
// not for glm-5-2.
|
|
40
|
+
export const TINFOIL_MODEL_ID = "tinfoil/kimi-k2-6";
|
|
25
41
|
|
|
26
42
|
// Same model, reached two ways:
|
|
27
43
|
// - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
|
|
@@ -63,6 +79,31 @@ export interface ResolveDefaultModelOptions {
|
|
|
63
79
|
env?: NodeJS.ProcessEnv;
|
|
64
80
|
// Override the signed-in check (testing). Defaults to hasCredentials().
|
|
65
81
|
signedIn?: boolean;
|
|
82
|
+
// The user's saved Pi default ("provider/id"). undefined (the default) reads it
|
|
83
|
+
// from agentDir()/settings.json; null skips it entirely — resolveSignedInModel
|
|
84
|
+
// uses null because the sign-in TARGET must stay the confidential model (the
|
|
85
|
+
// decision to stay on a saved pick is made explicitly at its call sites, where
|
|
86
|
+
// the account channel still gets armed either way).
|
|
87
|
+
saved?: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The user's own persisted model pick: Pi writes defaultProvider + defaultModel to
|
|
91
|
+
// agentDir()/settings.json on EVERY interactive switch (AgentSession.setModel →
|
|
92
|
+
// setDefaultModelAndProvider — both the built-in selector and pi-privacy's /models
|
|
93
|
+
// picker land there). That makes it the strongest non-env signal of deliberate
|
|
94
|
+
// intent we have, so resolveDefaultModel ranks it right after PRIVATEER_MODEL.
|
|
95
|
+
// Returns "provider/id", or null when either half is missing.
|
|
96
|
+
export function savedPiDefaultSpec(): string | null {
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(join(agentDir(), "settings.json"), "utf8").trim();
|
|
99
|
+
if (!raw) return null;
|
|
100
|
+
const s = JSON.parse(raw) as Record<string, unknown>;
|
|
101
|
+
const provider = typeof s.defaultProvider === "string" ? s.defaultProvider.trim() : "";
|
|
102
|
+
const modelId = typeof s.defaultModel === "string" ? s.defaultModel.trim() : "";
|
|
103
|
+
return provider && modelId ? `${provider}/${modelId}` : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
66
107
|
}
|
|
67
108
|
|
|
68
109
|
// Resolve the model spec ("provider/id") to use when no model is named. Pure and
|
|
@@ -71,10 +112,13 @@ export interface ResolveDefaultModelOptions {
|
|
|
71
112
|
// so the launcher, the REPL, and the next-launch seed all agree):
|
|
72
113
|
// 1. explicit user choice (config/channel) — deliberate, always wins
|
|
73
114
|
// 2. PRIVATEER_MODEL env — dev/global override
|
|
74
|
-
// 3.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
115
|
+
// 3. the SAVED Pi default (settings.json) — the model the user last picked
|
|
116
|
+
// interactively; Pi persists every switch, so honoring it here is what makes a
|
|
117
|
+
// /models pick actually stick across launches on every entry point
|
|
118
|
+
// 4. Tinfoil key present → the Tinfoil default — strongest (client-attested) privacy
|
|
119
|
+
// 5. signed into Privateer → the same model over the subscription
|
|
120
|
+
// 6. a BYO provider whose key is present — anthropic, openai, openrouter
|
|
121
|
+
// 7. nothing at all → the account default anyway — so the failure names Privateer
|
|
78
122
|
// and /login is the visible fix, instead of a keyless OpenRouter dead end
|
|
79
123
|
export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
|
|
80
124
|
const env = opts.env ?? process.env;
|
|
@@ -85,6 +129,9 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
|
|
|
85
129
|
const fromEnv = env.PRIVATEER_MODEL?.trim();
|
|
86
130
|
if (fromEnv) return fromEnv;
|
|
87
131
|
|
|
132
|
+
const saved = opts.saved === undefined ? savedPiDefaultSpec() : opts.saved;
|
|
133
|
+
if (saved) return saved;
|
|
134
|
+
|
|
88
135
|
// Privacy-first: a Tinfoil key means we can run verifiable TEE inference right now,
|
|
89
136
|
// which we prefer even over the account's NEAR channel — same order the launcher uses.
|
|
90
137
|
if (env.TINFOIL_API_KEY?.trim()) return TINFOIL_DEFAULT_SPEC;
|
|
@@ -110,8 +157,11 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
|
|
|
110
157
|
// model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
|
|
111
158
|
// is present and over the subscription otherwise — no BYO key needed.
|
|
112
159
|
// PRIVATEER_MODEL still wins — a deliberate override is never stomped.
|
|
160
|
+
// `saved: null` on purpose: this is the sign-in TARGET, and the target is always the
|
|
161
|
+
// confidential model. Whether to actually move a session that sits on a deliberate
|
|
162
|
+
// saved pick is decided at the call sites (which arm the account channel either way).
|
|
113
163
|
export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
|
|
114
|
-
return resolveDefaultModel({ env, signedIn: true });
|
|
164
|
+
return resolveDefaultModel({ env, signedIn: true, saved: null });
|
|
115
165
|
}
|
|
116
166
|
|
|
117
167
|
// Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
|
|
@@ -135,7 +185,12 @@ function splitSpec(spec: string): { provider: string; modelId: string } | null {
|
|
|
135
185
|
// `defaultModel` key), so a deliberate /model choice is never stomped. Best-effort —
|
|
136
186
|
// any read/parse/write failure is swallowed; a missing seed just means the user picks
|
|
137
187
|
// a model once via /model. Returns the spec written, or null if we left it alone.
|
|
138
|
-
|
|
188
|
+
//
|
|
189
|
+
// The default seed is resolveSignedInModel(), not the static account spec: now that
|
|
190
|
+
// the launcher HONORS a saved default (omits --model when one exists), seeding
|
|
191
|
+
// `privateer/…` for a Tinfoil-keyed user would demote them from direct
|
|
192
|
+
// client-attested inference to the subscription proxy on every later launch.
|
|
193
|
+
export function ensurePiDefaultModel(spec: string = resolveSignedInModel()): string | null {
|
|
139
194
|
const parts = splitSpec(spec);
|
|
140
195
|
if (!parts) return null;
|
|
141
196
|
const settingsPath = join(agentDir(), "settings.json");
|
|
@@ -21,3 +21,19 @@ Everything else is byte-for-byte upstream. The crypto runs on `globalThis.crypto
|
|
|
21
21
|
(Node ≥ 22) these are all native — **no polyfills needed** (unlike the treeview RN app,
|
|
22
22
|
which bridges them via `react-native-quick-crypto`). Re-pull from upstream to update;
|
|
23
23
|
re-apply only the `.js`-extension strip.
|
|
24
|
+
|
|
25
|
+
## What lives OUTSIDE this directory (and why)
|
|
26
|
+
`../reportBinding.ts` — `verifyAciReportBinding`, the algorithm dispatch for §10.1
|
|
27
|
+
checks 2–6. Callers use it instead of importing `verifyReportBinding` from here.
|
|
28
|
+
|
|
29
|
+
Upstream's verifier is Web-Crypto-only, so it throws `UnsupportedAlgorithmError` on
|
|
30
|
+
an `ecdsa-secp256k1` keyset endorsement — which §4.3 explicitly permits alongside
|
|
31
|
+
ed25519, and which the deployed `inference.phala.com` gateway actually uses. Rather
|
|
32
|
+
than patch this tree (and re-patch it on every re-pull), the dispatch sits outside:
|
|
33
|
+
ed25519 delegates here verbatim, secp256k1 takes a parallel path over `@noble/curves`,
|
|
34
|
+
and any other algorithm still throws. Nothing here changed, so the re-pull recipe
|
|
35
|
+
above stays exactly the `.js`-extension strip.
|
|
36
|
+
|
|
37
|
+
If upstream ever adds secp256k1 (or a check 7) to `report.ts`, collapse
|
|
38
|
+
`reportBinding.ts` back to a straight re-export — `tests/phalaReportBinding.test.ts`
|
|
39
|
+
pins the behaviour either way.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Algorithm dispatch for the ACI report-binding checks (§10.1 checks 2–6).
|
|
2
|
+
//
|
|
3
|
+
// The ACI spec (§4.3) allows the keyset endorsement to be signed with EITHER
|
|
4
|
+
// `ed25519` OR `ecdsa-secp256k1` — the former because "every primitive in it is
|
|
5
|
+
// available in the Web Crypto API", the latter for "clients in the EVM/dstack
|
|
6
|
+
// ecosystem". Upstream's reference TS verifier implements only the Web Crypto half:
|
|
7
|
+
// `verifySignature` throws `UnsupportedAlgorithmError` on secp256k1
|
|
8
|
+
// (aci-verifier/crypto.ts), and `verifyReportBinding` propagates that.
|
|
9
|
+
//
|
|
10
|
+
// The deployed gateway (inference.phala.com) signs with `ecdsa-secp256k1`, so the
|
|
11
|
+
// vendored verifier can never attest it — a limit of upstream's CLIENT, not of the
|
|
12
|
+
// spec or the gateway. Verified live 2026-07-31: attestation fetched, endorsement
|
|
13
|
+
// rejected with UnsupportedAlgorithmError.
|
|
14
|
+
//
|
|
15
|
+
// This module owns the dispatch so `aci-verifier/` stays byte-for-byte upstream
|
|
16
|
+
// (see its VENDORED.md — only the `.js`-extension strip diverges, and re-pulls stay
|
|
17
|
+
// mechanical):
|
|
18
|
+
// ed25519 → delegate to the vendored verifyReportBinding, verbatim
|
|
19
|
+
// ecdsa-secp256k1 → the same checks 2–6, with check 5 done over @noble/curves
|
|
20
|
+
// anything else → still throws (never a silent pass)
|
|
21
|
+
//
|
|
22
|
+
// The secp256k1 path deliberately mirrors report.ts check-for-check, in the same
|
|
23
|
+
// order and with the same check names, so a caller cannot tell which path ran.
|
|
24
|
+
|
|
25
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
26
|
+
import {
|
|
27
|
+
verifyReportBinding,
|
|
28
|
+
computeWorkloadId,
|
|
29
|
+
computeKeysetDigest,
|
|
30
|
+
computeReportData,
|
|
31
|
+
keysetEndorsementPayload,
|
|
32
|
+
sha256,
|
|
33
|
+
fromHex,
|
|
34
|
+
UnsupportedAlgorithmError,
|
|
35
|
+
type AttestationReport,
|
|
36
|
+
type Check,
|
|
37
|
+
type ReportVerification,
|
|
38
|
+
type ReportBindingOptions,
|
|
39
|
+
} from "./aci-verifier/index.ts";
|
|
40
|
+
|
|
41
|
+
const ED25519 = "ed25519";
|
|
42
|
+
const SECP256K1 = "ecdsa-secp256k1";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Verify a report's cryptographic bindings for `nonce` (§10.1 checks 2–6),
|
|
46
|
+
* dispatching on the algorithm the attested identity key declares. Drop-in
|
|
47
|
+
* replacement for the vendored `verifyReportBinding`: same arguments, same result
|
|
48
|
+
* shape, same "a failed check is `ok: false`, never thrown" contract.
|
|
49
|
+
*
|
|
50
|
+
* Like upstream, this is the crypto-binding half only — compose it with a hardware
|
|
51
|
+
* quote verifier (phalaSeal.ts `verifyHardwareQuote`) for Level 2.
|
|
52
|
+
*/
|
|
53
|
+
export async function verifyAciReportBinding(
|
|
54
|
+
report: AttestationReport,
|
|
55
|
+
nonce: string | null | undefined,
|
|
56
|
+
options: ReportBindingOptions = {},
|
|
57
|
+
): Promise<ReportVerification> {
|
|
58
|
+
const algo = report.attestation.workload_keyset.workload_identity.public_key.algo;
|
|
59
|
+
if (algo === ED25519) return verifyReportBinding(report, nonce, options);
|
|
60
|
+
if (algo !== SECP256K1) {
|
|
61
|
+
// Same fail-closed posture as upstream: an algorithm we cannot check is a
|
|
62
|
+
// refusal, not a pass.
|
|
63
|
+
throw new UnsupportedAlgorithmError(algo, "keyset endorsement (§4.3)");
|
|
64
|
+
}
|
|
65
|
+
return verifySecp256k1ReportBinding(report, nonce, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function verifySecp256k1ReportBinding(
|
|
69
|
+
report: AttestationReport,
|
|
70
|
+
nonce: string | null | undefined,
|
|
71
|
+
options: ReportBindingOptions,
|
|
72
|
+
): Promise<ReportVerification> {
|
|
73
|
+
const now = options.now ?? Math.floor(Date.now() / 1000);
|
|
74
|
+
const checks: Check[] = [];
|
|
75
|
+
|
|
76
|
+
const keyset = report.attestation.workload_keyset;
|
|
77
|
+
const identityKey = keyset.workload_identity.public_key;
|
|
78
|
+
|
|
79
|
+
// Check 2: workload_id == digest of the identity public key in the report's keyset.
|
|
80
|
+
const workloadId = await computeWorkloadId(identityKey);
|
|
81
|
+
pushEqual(checks, "workload_id", report.workload_id, workloadId);
|
|
82
|
+
|
|
83
|
+
// Check 3: workload_keyset_digest == digest of the report's keyset.
|
|
84
|
+
const workloadKeysetDigest = await computeKeysetDigest(keyset);
|
|
85
|
+
pushEqual(checks, "workload_keyset_digest", report.workload_keyset_digest, workloadKeysetDigest);
|
|
86
|
+
|
|
87
|
+
// Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
|
|
88
|
+
// The hardware-evidence-binds-report_data half is verifyHardwareQuote's job.
|
|
89
|
+
const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
|
|
90
|
+
pushEqual(checks, "report_data", report.attestation.report_data, expectedReportData);
|
|
91
|
+
|
|
92
|
+
// Check 5: keyset endorsement verifies under the identity key, algo matching.
|
|
93
|
+
const endorsement = report.attestation.keyset_endorsement;
|
|
94
|
+
if (endorsement.algo !== identityKey.algo) {
|
|
95
|
+
checks.push({
|
|
96
|
+
name: "keyset_endorsement",
|
|
97
|
+
ok: false,
|
|
98
|
+
detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
const ok = await verifySecp256k1(
|
|
102
|
+
identityKey.public_key,
|
|
103
|
+
endorsement.value,
|
|
104
|
+
keysetEndorsementPayload(workloadKeysetDigest),
|
|
105
|
+
);
|
|
106
|
+
checks.push({
|
|
107
|
+
name: "keyset_endorsement",
|
|
108
|
+
ok,
|
|
109
|
+
...(ok ? {} : { detail: "endorsement signature failed under identity key" }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check 6: freshness. Nonce binding is check 4; here bound the epoch and, when
|
|
114
|
+
// the profile trusts it, the declared validity window.
|
|
115
|
+
const notAfter = keyset.keyset_epoch.not_after;
|
|
116
|
+
const epochOk = now < notAfter;
|
|
117
|
+
checks.push({
|
|
118
|
+
name: "keyset_epoch.not_after",
|
|
119
|
+
ok: epochOk,
|
|
120
|
+
...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
|
|
121
|
+
});
|
|
122
|
+
if (options.trustPlatformClock) {
|
|
123
|
+
const freshness = report.attestation.freshness;
|
|
124
|
+
const fetchedAt = freshness?.fetched_at;
|
|
125
|
+
const staleAfter = freshness?.stale_after;
|
|
126
|
+
const windowOk =
|
|
127
|
+
typeof fetchedAt === "number" &&
|
|
128
|
+
typeof staleAfter === "number" &&
|
|
129
|
+
fetchedAt <= now &&
|
|
130
|
+
now < staleAfter;
|
|
131
|
+
checks.push({
|
|
132
|
+
name: "freshness_window",
|
|
133
|
+
ok: windowOk,
|
|
134
|
+
...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* §4.3 secp256k1 endorsement: a 64-byte `r || s` signature over
|
|
143
|
+
* `sha256(payload bytes)`. Returns false on malformed input rather than throwing —
|
|
144
|
+
* a bad signature is a failed check, not an exception.
|
|
145
|
+
*
|
|
146
|
+
* NOT the §8.5 *receipt* shape, which is a 65-byte recoverable `r || s || v` and
|
|
147
|
+
* where the spec says 64-byte signatures MUST be rejected. Different shapes; easy
|
|
148
|
+
* to conflate if this ever grows a receipt path.
|
|
149
|
+
*/
|
|
150
|
+
async function verifySecp256k1(
|
|
151
|
+
publicKeyHex: string,
|
|
152
|
+
signatureHex: string,
|
|
153
|
+
payload: Uint8Array,
|
|
154
|
+
): Promise<boolean> {
|
|
155
|
+
try {
|
|
156
|
+
const sig = fromHex(signatureHex);
|
|
157
|
+
if (sig.length !== 64) return false; // r||s only; DER / recoverable forms are not §4.3
|
|
158
|
+
const msgHash = await sha256(payload);
|
|
159
|
+
return secp256k1.verify(sig, msgHash, publicKey(publicKeyHex), {
|
|
160
|
+
prehash: false, // we hand it the sha256 digest, per §4.3
|
|
161
|
+
// Accept high-s as well as low-s. ECDSA malleability is meaningless for a
|
|
162
|
+
// signature over a FIXED payload — an attacker who can flip s already has a
|
|
163
|
+
// valid endorsement and still cannot sign a different keyset digest. Leaving
|
|
164
|
+
// the default on would reject ~half of otherwise-valid endorsements from any
|
|
165
|
+
// signer that doesn't normalize, as an intermittent attestation failure.
|
|
166
|
+
lowS: false,
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Identity key bytes. §7.1 pins secp256k1 public keys as 65-byte uncompressed SEC1
|
|
175
|
+
* and requires that "the 64-byte uncompressed form without the `0x04` prefix MUST be
|
|
176
|
+
* accepted and treated as the same key" — so restore the prefix when it's absent.
|
|
177
|
+
* The live gateway sends the 65-byte form; do NOT prefix that one again.
|
|
178
|
+
*/
|
|
179
|
+
function publicKey(hex: string): Uint8Array {
|
|
180
|
+
const raw = fromHex(hex);
|
|
181
|
+
if (raw.length === 64) {
|
|
182
|
+
const sec1 = new Uint8Array(65);
|
|
183
|
+
sec1[0] = 0x04;
|
|
184
|
+
sec1.set(raw, 1);
|
|
185
|
+
return sec1;
|
|
186
|
+
}
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
|
|
191
|
+
const ok = actual === expected;
|
|
192
|
+
checks.push({ name, ok, ...(ok ? {} : { detail: `report ${actual} != recomputed ${expected}` }) });
|
|
193
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// no polyfills, unlike the RN app.
|
|
14
14
|
//
|
|
15
15
|
// Two-layer attestation, fail-secure:
|
|
16
|
-
// (1)
|
|
16
|
+
// (1) verifyAciReportBinding — the report's crypto binding (keyset digest,
|
|
17
17
|
// report_data == statement(nonce), endorsement sig). Self-attesting alone.
|
|
18
18
|
// (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
|
|
19
19
|
// against Intel collateral and binds the quote's report_data to (1)'s statement
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
import type { Report } from "@phala/dcap-qvl";
|
|
24
24
|
import {
|
|
25
|
-
verifyReportBinding,
|
|
26
25
|
openE2eeChannel,
|
|
27
26
|
toHex,
|
|
28
27
|
fromHex,
|
|
@@ -30,6 +29,11 @@ import {
|
|
|
30
29
|
type ReportVerification,
|
|
31
30
|
type E2eeChannel,
|
|
32
31
|
} from "./phala/aci-verifier/index.ts";
|
|
32
|
+
// Not the vendored verifyReportBinding directly: the deployed gateway signs its
|
|
33
|
+
// keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
|
|
34
|
+
// refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
|
|
35
|
+
// the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
|
|
36
|
+
import { verifyAciReportBinding } from "./phala/reportBinding.ts";
|
|
33
37
|
import { serverBaseUrl } from "../auth/privateer.ts";
|
|
34
38
|
|
|
35
39
|
const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
|
|
@@ -94,7 +98,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
|
|
|
94
98
|
if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
|
|
95
99
|
const report = (await res.json()) as AttestationReport;
|
|
96
100
|
|
|
97
|
-
const verification = await
|
|
101
|
+
const verification = await verifyAciReportBinding(report, nonce);
|
|
98
102
|
if (!verification.ok) {
|
|
99
103
|
const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
|
|
100
104
|
throw new Error(`phala attestation binding failed: ${failed}`);
|