privateer-agent 0.7.0 → 0.8.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.
@@ -0,0 +1,200 @@
1
+ // Phala (ACI E2EE) sealed transport for the account channel — the Node port of
2
+ // treeview's client PhalaProvider.
3
+ //
4
+ // Where Tinfoil seals the whole body at the transport layer (EHBP via SecureClient),
5
+ // Phala's Attested Confidential Inference encrypts the request's *content fields*
6
+ // (x25519-aes-256-gcm-hkdf-sha256) to the enclave's attested X25519 key, sends the
7
+ // `X-E2EE-*` headers alongside, and decrypts the response fields. The Privateer relay
8
+ // (`${server}/api/sealed/phala`, treeview/server/routes/sealed.js) injects PHALA_API_KEY
9
+ // and forwards ciphertext — it can't read prompts or responses.
10
+ //
11
+ // Crypto is the vendored @dstack/aci-verifier (./phala/aci-verifier), pure Web Crypto
12
+ // (X25519/HKDF/AES-GCM/Ed25519). Node ≥ 22 provides all of it on globalThis.crypto —
13
+ // no polyfills, unlike the RN app.
14
+ //
15
+ // Two-layer attestation, fail-secure:
16
+ // (1) verifyReportBinding — the report's crypto binding (keyset digest,
17
+ // report_data == statement(nonce), endorsement sig). Self-attesting alone.
18
+ // (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
19
+ // against Intel collateral and binds the quote's report_data to (1)'s statement
20
+ // digest. requireQuote defaults TRUE; PRIVATEER_PHALA_REQUIRE_QUOTE=0 drops it
21
+ // (local testing only — removes the hardware root of trust).
22
+
23
+ import type { Report } from "@phala/dcap-qvl";
24
+ import {
25
+ verifyReportBinding,
26
+ openE2eeChannel,
27
+ toHex,
28
+ fromHex,
29
+ type AttestationReport,
30
+ type ReportVerification,
31
+ type E2eeChannel,
32
+ } from "./phala/aci-verifier/index.ts";
33
+ import { serverBaseUrl } from "../auth/privateer.ts";
34
+
35
+ const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
36
+
37
+ function relayBase(): string {
38
+ return `${serverBaseUrl().replace(/\/+$/, "")}/api/sealed/phala`;
39
+ }
40
+
41
+ // Hardware quote check on by default (fail-secure). Only "0"/"false" disables it.
42
+ function requireQuote(): boolean {
43
+ const v = process.env.PRIVATEER_PHALA_REQUIRE_QUOTE;
44
+ return !(v === "0" || v === "false");
45
+ }
46
+ function pccsUrl(): string | undefined {
47
+ return process.env.PRIVATEER_PHALA_PCCS_URL || undefined;
48
+ }
49
+ function acceptableTcb(): Set<string> {
50
+ const v = process.env.PRIVATEER_PHALA_TCB;
51
+ const list = v ? v.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_ACCEPTABLE_TCB;
52
+ return new Set(list);
53
+ }
54
+
55
+ // The 64-byte report_data from a verified TDX quote report (TD1.0/1.5 layouts).
56
+ function extractQuoteReportData(report: Report): Uint8Array {
57
+ const td10 = report.asTd10?.();
58
+ if (td10?.reportData) return new Uint8Array(td10.reportData);
59
+ const td15 = report.asTd15?.();
60
+ if (td15?.base?.reportData) return new Uint8Array(td15.base.reportData);
61
+ const data = report.data as { reportData?: Uint8Array } | undefined;
62
+ if (data?.reportData) return new Uint8Array(data.reportData);
63
+ throw new Error("phala: verified quote report has no reportData");
64
+ }
65
+
66
+ interface VerifiedAttestation {
67
+ report: AttestationReport;
68
+ verification: ReportVerification;
69
+ }
70
+
71
+ // Attest once, cache the verified report; drop the memo on failure so a later call
72
+ // re-attests rather than caching the error.
73
+ let attestationPromise: Promise<VerifiedAttestation> | null = null;
74
+
75
+ function attest(): Promise<VerifiedAttestation> {
76
+ if (!attestationPromise) {
77
+ attestationPromise = establishAttestation().catch((err) => {
78
+ attestationPromise = null;
79
+ throw err as Error;
80
+ });
81
+ }
82
+ return attestationPromise;
83
+ }
84
+
85
+ export function resetPhala(): void {
86
+ attestationPromise = null;
87
+ }
88
+
89
+ async function establishAttestation(): Promise<VerifiedAttestation> {
90
+ const nonce = toHex(globalThis.crypto.getRandomValues(new Uint8Array(32)));
91
+ // The relay proxies GET /attestation?nonce=… → the gateway's
92
+ // GET /v1/aci/attestation?nonce=… (public; no user content).
93
+ const res = await fetch(`${relayBase()}/attestation?nonce=${nonce}`, { method: "GET" });
94
+ if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
95
+ const report = (await res.json()) as AttestationReport;
96
+
97
+ const verification = await verifyReportBinding(report, nonce);
98
+ if (!verification.ok) {
99
+ const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
100
+ throw new Error(`phala attestation binding failed: ${failed}`);
101
+ }
102
+ await verifyHardwareQuote(report);
103
+ return { report, verification };
104
+ }
105
+
106
+ async function verifyHardwareQuote(report: AttestationReport): Promise<void> {
107
+ if (!requireQuote()) return;
108
+
109
+ const attestation = report.attestation as unknown as {
110
+ tee_type?: string;
111
+ report_data?: string;
112
+ evidence?: { quote?: string; quote_report_data?: string };
113
+ };
114
+ const teeType = String(attestation?.tee_type || "");
115
+ if (teeType !== "tdx") throw new Error(`phala: unsupported/absent tee_type "${teeType}" (only tdx is wired)`);
116
+ const quoteHex = attestation.evidence?.quote;
117
+ if (typeof quoteHex !== "string" || !quoteHex) throw new Error("phala: attestation evidence has no TDX quote");
118
+ const reportDataHex = String(attestation.report_data || "").toLowerCase();
119
+ if (!reportDataHex) throw new Error("phala: report has no report_data");
120
+
121
+ // Verify the quote against fetched Intel/Phala collateral (pure-JS dcap-qvl).
122
+ const { getCollateralAndVerify } = await import("@phala/dcap-qvl");
123
+ const verified = await getCollateralAndVerify(fromHex(quoteHex), pccsUrl());
124
+
125
+ // 1) Genuine hardware + acceptable TCB status.
126
+ const status = String(verified.status);
127
+ if (!acceptableTcb().has(status)) throw new Error(`phala: TDX quote TCB status not accepted: "${status}"`);
128
+
129
+ // 2) The genuine quote committed to our attested statement digest.
130
+ const quoteReportData = extractQuoteReportData(verified.report);
131
+ if (toHex(quoteReportData.slice(0, 32)) !== reportDataHex) {
132
+ throw new Error("phala: TDX quote report_data does not bind the attested report_data");
133
+ }
134
+
135
+ // 3) Consistency: the report's declared quote_report_data matches the real quote.
136
+ const declared = attestation.evidence?.quote_report_data;
137
+ if (typeof declared === "string" && declared && toHex(quoteReportData) !== declared.toLowerCase()) {
138
+ throw new Error("phala: evidence.quote_report_data does not match the verified quote");
139
+ }
140
+ }
141
+
142
+ // Posture signal: does the attested keyset verify (crypto binding + hardware quote)?
143
+ // A green result is a quote WE checked, bound to the E2EE key we seal to.
144
+ export async function attestPhala(): Promise<{ ok: boolean; error?: string }> {
145
+ try {
146
+ await attest();
147
+ return { ok: true };
148
+ } catch (e) {
149
+ return { ok: false, error: (e as Error).message };
150
+ }
151
+ }
152
+
153
+ export interface PhalaExchange {
154
+ res: Response;
155
+ channel: E2eeChannel;
156
+ streaming: boolean;
157
+ }
158
+
159
+ // Run one sealed request for Pi: attest, open a fresh per-call E2EE channel (the
160
+ // channel's request state is single-shot → not safe to share across concurrent
161
+ // calls), seal the request fields, and POST to the relay with the X-E2EE-* headers +
162
+ // the cleartext X-Sealed-Model (relay billing) + Pi's account bearer. Returns the
163
+ // upstream response and the channel so the caller can decrypt it.
164
+ export async function phalaSealedFetch(
165
+ rawBody: string,
166
+ authHeader: string | undefined,
167
+ signal?: AbortSignal,
168
+ ): Promise<PhalaExchange> {
169
+ const { report, verification } = await attest();
170
+ const channel = await openE2eeChannel(report, verification);
171
+
172
+ let request: Record<string, unknown>;
173
+ try {
174
+ request = JSON.parse(rawBody) as Record<string, unknown>;
175
+ } catch {
176
+ throw new Error("phala: request body is not JSON");
177
+ }
178
+ const fullModel = typeof request.model === "string" ? request.model : "unknown";
179
+ const streaming = request.stream !== false;
180
+ // Bare model id for the enclave (the `phala/` prefix is app-side only); keep the
181
+ // full id on the cleartext X-Sealed-Model billing header.
182
+ request.model = fullModel.replace(/^phala\//, "");
183
+ request.stream = streaming;
184
+
185
+ const { body, headers: e2ee } = await channel.seal(request);
186
+ const headers: Record<string, string> = {
187
+ "Content-Type": "application/json",
188
+ "X-Sealed-Model": fullModel,
189
+ ...e2ee,
190
+ };
191
+ if (authHeader) headers.Authorization = authHeader;
192
+
193
+ const res = await fetch(`${relayBase()}/v1/chat/completions`, {
194
+ method: "POST",
195
+ headers,
196
+ body: JSON.stringify(body),
197
+ signal,
198
+ });
199
+ return { res, channel, streaming };
200
+ }
@@ -0,0 +1,295 @@
1
+ // Sealed-mode (EHBP) transport for the account channel.
2
+ //
3
+ // Background: the `privateer` provider runs inference through the server at
4
+ // `${server}/api/agent/v1`, which reads the prompt in cleartext (it assembles/
5
+ // forwards the body). For `tinfoil/*` models that means the badge honestly reads
6
+ // "Trusted Execution (unconfirmed)": the enclave is real, but a quote fetched
7
+ // through the proxy can't be bound to THIS connection (see account.ts and
8
+ // docs/tee-privateer-tinfoil-ehbp.md).
9
+ //
10
+ // Sealed mode closes that. Tinfoil's EHBP (Encrypted HTTP Body Protocol) is HPKE
11
+ // applied to the HTTP body only, independent of TLS, and the enclave's attestation
12
+ // binds the HPKE key. The Privateer server already exposes a blind relay for it
13
+ // (`${server}/api/sealed/:provider`, treeview/server/routes/sealed.js): it forwards
14
+ // the ciphertext body + the `Ehbp-*` headers, injects the provider key, and meters
15
+ // on a cleartext usage header — it never sees the prompt. The treeview app already
16
+ // speaks this (client/services/pipeline/transport/tinfoilProvider.ts).
17
+ //
18
+ // Pi's `openai-completions` adapter does the HTTP itself and exposes no custom-fetch
19
+ // hook, so we can't seal from inside the provider config. Instead we run an
20
+ // in-process loopback HTTP server (this module): Pi POSTs a plain OpenAI request to
21
+ // it, the shim seals the body to the attested enclave via Tinfoil's `SecureClient`,
22
+ // forwards Pi's account bearer + the cleartext `X-Sealed-Model` billing header, and
23
+ // streams the decrypted response back. The provider points the `tinfoil/*` models'
24
+ // per-model `baseUrl` at this shim (account.ts).
25
+ //
26
+ // The one SecureClient per provider is shared by the data plane (the shim) and the
27
+ // posture check (attestSealed), so the green shield reflects the exact client that
28
+ // carries the tokens — the invariant: attest the key we actually seal to.
29
+
30
+ import http from "node:http";
31
+ import { Readable } from "node:stream";
32
+ import { SecureClient } from "tinfoil";
33
+ import { serverBaseUrl } from "../auth/privateer.ts";
34
+ import { attestPhala, phalaSealedFetch } from "./phalaSeal.ts";
35
+ import { iterateSSE } from "./phala/sse.ts";
36
+
37
+ // Providers whose enclave supports application-layer body encryption + client-verified
38
+ // attestation, and for which we have a Node client: Tinfoil (EHBP) and Phala (ACI
39
+ // E2EE). NEAR does NOT — it's attested TLS only, so it stays the confidential
40
+ // (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
41
+ export type SealedProvider = "tinfoil" | "phala";
42
+
43
+ // Sealed mode is OFF until verified end-to-end against a live relay (a real EHBP
44
+ // round-trip needs the deployed relay + TINFOIL_API_KEY; see the live checklist in
45
+ // docs/tee-privateer-tinfoil-ehbp.md). Off = the current plaintext path + honest
46
+ // yellow badge, untouched. Flip with PRIVATEER_SEALED=1.
47
+ export function sealedEnabled(): boolean {
48
+ const v = process.env.PRIVATEER_SEALED;
49
+ return v === "1" || v === "true";
50
+ }
51
+
52
+ // The sealed provider a model id routes through, or null if it isn't a sealed
53
+ // model (or its client isn't available here). Mirrors the server's prefix routing.
54
+ export function sealedProviderFor(modelId: string): SealedProvider | null {
55
+ if (modelId.startsWith("tinfoil/")) return "tinfoil";
56
+ if (modelId.startsWith("phala/")) return "phala";
57
+ return null;
58
+ }
59
+
60
+ // The blind-relay base for a provider — SecureClient fetches `${base}/attestation`
61
+ // and we POST `${base}/v1/chat/completions`.
62
+ export function relayBase(provider: SealedProvider): string {
63
+ return `${serverBaseUrl().replace(/\/+$/, "")}/api/sealed/${provider}`;
64
+ }
65
+
66
+ // ── One SecureClient per provider (shared: data plane + posture) ──────────────
67
+
68
+ const clients = new Map<SealedProvider, SecureClient>();
69
+ const readyPromises = new Map<SealedProvider, Promise<void>>();
70
+
71
+ function client(provider: SealedProvider): SecureClient {
72
+ let c = clients.get(provider);
73
+ if (!c) {
74
+ const base = relayBase(provider);
75
+ // attestationBundleURL == base: the SDK appends `/attestation`, which the relay
76
+ // proxies to Tinfoil's ATC. transport 'ehbp' = HPKE body sealing.
77
+ c = new SecureClient({ baseURL: base, attestationBundleURL: base, transport: "ehbp" });
78
+ clients.set(provider, c);
79
+ }
80
+ return c;
81
+ }
82
+
83
+ // Attest once and cache; on failure drop the memo so a later call re-attests
84
+ // rather than caching the error (mirrors the treeview provider).
85
+ export function ready(provider: SealedProvider): Promise<void> {
86
+ let p = readyPromises.get(provider);
87
+ if (!p) {
88
+ p = client(provider)
89
+ .ready()
90
+ .catch((err) => {
91
+ readyPromises.delete(provider);
92
+ throw err as Error;
93
+ });
94
+ readyPromises.set(provider, p);
95
+ }
96
+ return p;
97
+ }
98
+
99
+ export interface SealedAttestation {
100
+ ok: boolean;
101
+ enclave?: string;
102
+ error?: string;
103
+ }
104
+
105
+ // Drive the provider's client-side attestation (the SAME client/keyset the shim
106
+ // seals with). A green result is a quote we verified ourselves, bound to the key we
107
+ // encrypt to — that earns tee-verified. Tinfoil: SecureClient.ready() (HPKE-key
108
+ // match). Phala: verifyReportBinding + TDX quote (see phalaSeal.ts).
109
+ export async function attestSealed(provider: SealedProvider): Promise<SealedAttestation> {
110
+ if (provider === "phala") return attestPhala();
111
+ try {
112
+ await ready(provider);
113
+ return { ok: true, enclave: client(provider).getEnclaveURL() };
114
+ } catch (e) {
115
+ return { ok: false, error: (e as Error).message };
116
+ }
117
+ }
118
+
119
+ // ── Pure request shaping (unit-tested without an enclave) ─────────────────────
120
+
121
+ export interface ForwardPlan {
122
+ url: string;
123
+ headers: Record<string, string>;
124
+ body: string;
125
+ sealedModel: string;
126
+ }
127
+
128
+ // Turn Pi's plain OpenAI request into what we seal to the relay:
129
+ // - strip the `${provider}/` prefix from the body model (the enclave wants the
130
+ // bare id; the body is encrypted so the relay can't strip it — we must),
131
+ // - keep the full prefixed id on the cleartext X-Sealed-Model header (the relay
132
+ // prices billing off it — it never reads the body),
133
+ // - forward Pi's account bearer verbatim (the relay authenticates the JWT; on a
134
+ // 401 the relay's response propagates so Pi refreshes and retries).
135
+ export function buildForward(
136
+ provider: SealedProvider,
137
+ rawBody: string,
138
+ authHeader: string | undefined,
139
+ ): ForwardPlan {
140
+ let body = rawBody;
141
+ let sealedModel = "unknown";
142
+ try {
143
+ const parsed = JSON.parse(rawBody);
144
+ if (parsed && typeof parsed.model === "string") {
145
+ sealedModel = parsed.model;
146
+ const prefix = `${provider}/`;
147
+ if (parsed.model.startsWith(prefix)) parsed.model = parsed.model.slice(prefix.length);
148
+ body = JSON.stringify(parsed);
149
+ }
150
+ } catch {
151
+ // Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
152
+ }
153
+ const headers: Record<string, string> = {
154
+ "Content-Type": "application/json",
155
+ "X-Sealed-Model": sealedModel,
156
+ };
157
+ if (authHeader) headers.Authorization = authHeader;
158
+ return { url: `${relayBase(provider)}/v1/chat/completions`, headers, body, sealedModel };
159
+ }
160
+
161
+ // ── Loopback HTTP shim ────────────────────────────────────────────────────────
162
+
163
+ const PATH_RE = /^\/(tinfoil|phala)\/v1\/chat\/completions$/;
164
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
165
+
166
+ let shimBase: string | null = null;
167
+ let shimStarting: Promise<string> | null = null;
168
+
169
+ // The shim's base URL once listening, else null. account.ts reads this to decide
170
+ // whether a sealed model can point its baseUrl at the shim yet.
171
+ export function sealedShimBase(): string | null {
172
+ return shimBase;
173
+ }
174
+
175
+ // Start the loopback shim once and return its base URL. Idempotent.
176
+ export function ensureSealedShim(): Promise<string> {
177
+ if (shimBase) return Promise.resolve(shimBase);
178
+ if (!shimStarting) shimStarting = startShim();
179
+ return shimStarting;
180
+ }
181
+
182
+ function startShim(): Promise<string> {
183
+ return new Promise((resolve, reject) => {
184
+ const server = http.createServer((req, res) => {
185
+ void handle(req, res).catch((e) => {
186
+ if (!res.headersSent) res.writeHead(502, { "Content-Type": "application/json" });
187
+ res.end(JSON.stringify({ error: { message: `sealed shim: ${(e as Error).message}` } }));
188
+ });
189
+ });
190
+ server.on("error", reject);
191
+ // Loopback only, ephemeral port. unref so the shim never keeps the process alive.
192
+ server.listen(0, "127.0.0.1", () => {
193
+ const addr = server.address();
194
+ if (addr && typeof addr === "object") {
195
+ shimBase = `http://127.0.0.1:${addr.port}`;
196
+ resolve(shimBase);
197
+ } else {
198
+ reject(new Error("sealed shim: could not determine listen port"));
199
+ }
200
+ });
201
+ server.unref();
202
+ });
203
+ }
204
+
205
+ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
206
+ // Defense in depth: only serve loopback peers (the port is ephemeral + bound to
207
+ // 127.0.0.1 already, but reject anything else outright).
208
+ if (!LOOPBACK.has(req.socket.remoteAddress ?? "")) {
209
+ res.writeHead(403).end();
210
+ return;
211
+ }
212
+ const path = (req.url ?? "").split("?")[0];
213
+ const m = PATH_RE.exec(path);
214
+ if (req.method !== "POST" || !m) {
215
+ res.writeHead(404).end();
216
+ return;
217
+ }
218
+ const provider = m[1] as SealedProvider;
219
+ const raw = await readBody(req);
220
+ const authHeader = typeof req.headers["authorization"] === "string" ? req.headers["authorization"] : undefined;
221
+
222
+ if (provider === "phala") {
223
+ await handlePhala(raw.toString("utf8"), authHeader, res);
224
+ return;
225
+ }
226
+
227
+ // Tinfoil (EHBP): SecureClient.fetch seals/opens transparently, so we just pass the
228
+ // body through and pipe the decrypted response.
229
+ const plan = buildForward(provider, raw.toString("utf8"), authHeader);
230
+ await ready(provider);
231
+ const upstream = await client(provider).fetch(plan.url, {
232
+ method: "POST",
233
+ headers: plan.headers,
234
+ body: plan.body,
235
+ });
236
+
237
+ res.writeHead(upstream.status, {
238
+ "Content-Type": upstream.headers.get("content-type") ?? "application/json",
239
+ });
240
+ if (upstream.body) {
241
+ const nodeStream = Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]);
242
+ // If Pi hangs up mid-stream, stop pulling from the enclave.
243
+ res.on("close", () => nodeStream.destroy());
244
+ nodeStream.pipe(res);
245
+ nodeStream.on("error", () => {
246
+ if (!res.writableEnded) res.end();
247
+ });
248
+ } else {
249
+ res.end(await upstream.text());
250
+ }
251
+ }
252
+
253
+ // Phala (ACI E2EE): fields are encrypted individually, so unlike Tinfoil we must
254
+ // decrypt the response with the per-call channel — chunk by chunk for SSE, or the
255
+ // whole JSON when non-streaming — and re-emit cleartext OpenAI to Pi.
256
+ async function handlePhala(
257
+ rawBody: string,
258
+ authHeader: string | undefined,
259
+ res: http.ServerResponse,
260
+ ): Promise<void> {
261
+ const ac = new AbortController();
262
+ res.on("close", () => ac.abort());
263
+ const { res: up, channel, streaming } = await phalaSealedFetch(rawBody, authHeader, ac.signal);
264
+
265
+ if (!up.ok) {
266
+ res.writeHead(up.status, { "Content-Type": up.headers.get("content-type") ?? "application/json" });
267
+ res.end(await up.text());
268
+ return;
269
+ }
270
+
271
+ if (streaming && up.body) {
272
+ res.writeHead(up.status, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" });
273
+ for await (const obj of iterateSSE(up.body as ReadableStream<Uint8Array>)) {
274
+ const opened = await channel.openChunk(obj as Record<string, unknown>);
275
+ res.write(`data: ${JSON.stringify(opened)}\n\n`);
276
+ }
277
+ res.write("data: [DONE]\n\n");
278
+ res.end();
279
+ return;
280
+ }
281
+
282
+ // Non-streaming: decrypt the single JSON body.
283
+ const opened = await channel.open((await up.json()) as Record<string, unknown>);
284
+ res.writeHead(up.status, { "Content-Type": "application/json" });
285
+ res.end(JSON.stringify(opened));
286
+ }
287
+
288
+ function readBody(req: http.IncomingMessage): Promise<Buffer> {
289
+ return new Promise((resolve, reject) => {
290
+ const chunks: Buffer[] = [];
291
+ req.on("data", (c: Buffer) => chunks.push(c));
292
+ req.on("end", () => resolve(Buffer.concat(chunks)));
293
+ req.on("error", reject);
294
+ });
295
+ }
@@ -20,10 +20,10 @@ import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
21
  import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
22
  import { makePiPrivacyExtension } from "pi-privacy";
23
- import { makeAccountProvider } from "../providers/account.ts";
23
+ import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
24
24
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
25
25
  import { RemoteBridge } from "./remoteBridge.ts";
26
- import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
26
+ import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
27
27
 
28
28
  export interface LiveTaskHandle {
29
29
  termId: string;
@@ -138,7 +138,15 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
138
138
  cwd,
139
139
  agentDir: agentDir(),
140
140
  resourceLoaderOptions: {
141
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
141
+ extensionFactories: [
142
+ makePermissionGate(gate),
143
+ // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
144
+ // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
145
+ makePiPrivacyExtension({
146
+ privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
147
+ }),
148
+ makeAccountProvider(),
149
+ ] as any,
142
150
  },
143
151
  });
144
152
  servicesRef = services as any;