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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -67,12 +67,14 @@
67
67
  "@noble/ciphers": "^2.1.1",
68
68
  "@noble/curves": "^1.9.7",
69
69
  "@noble/hashes": "^1.7.1",
70
+ "@phala/dcap-qvl": "^0.5.2",
70
71
  "patch-package": "^8.0.1",
71
72
  "pi-mcp-adapter": "^2.11.0",
72
- "pi-privacy": "^0.3.0",
73
+ "pi-privacy": "^0.7.0",
73
74
  "pi-subagents": "^0.34.0",
74
75
  "picomatch": "^4.0.4",
75
76
  "privateer-workflow": "^0.1.0",
77
+ "tinfoil": "^1.1.11",
76
78
  "tsx": "^4.16.0",
77
79
  "typebox": "^1.3.4",
78
80
  "undici": "^7.28.0",
@@ -109,7 +109,8 @@ async function main() {
109
109
  const { makePermissionGate } = await import("../ext/permissionGate.ts");
110
110
  type GateController = import("../ext/permissionGate.ts").GateController;
111
111
  const { makePiPrivacyExtension } = await import("pi-privacy");
112
- const { makeAccountProvider } = await import("../providers/account.ts");
112
+ const { makeAccountProvider, privateerChannel } = await import("../providers/account.ts");
113
+ const { hasCredentials } = await import("../auth/privateer.ts");
113
114
  const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
114
115
  const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
115
116
  const { redactText, collectSecrets } = await import("../util/redact.ts");
@@ -181,7 +182,15 @@ async function main() {
181
182
  cwd,
182
183
  agentDir: agentDir(),
183
184
  resourceLoaderOptions: {
184
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
185
+ extensionFactories: [
186
+ makePermissionGate(gate),
187
+ // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
188
+ // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
189
+ makePiPrivacyExtension({
190
+ privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
191
+ }),
192
+ makeAccountProvider(),
193
+ ] as any,
185
194
  },
186
195
  });
187
196
 
package/src/cli/chat.ts CHANGED
@@ -38,7 +38,7 @@ async function main() {
38
38
  const { authorizeControl } = await import("../remote/controlAuth.ts");
39
39
  const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
40
40
  const priv = await import("../auth/privateer.ts");
41
- const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
41
+ const { makeAccountProvider, accountPosture, privateerChannel } = await import("../providers/account.ts");
42
42
  const { agentVersion } = await import("../config/version.ts");
43
43
  const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
44
44
 
@@ -329,7 +329,15 @@ async function main() {
329
329
  agentDir: agentDir(),
330
330
  resourceLoaderOptions: {
331
331
  // Structural ext types are intentionally narrow; cast to Pi's ExtensionFactory.
332
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
332
+ extensionFactories: [
333
+ makePermissionGate(gate),
334
+ // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
335
+ // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
336
+ makePiPrivacyExtension({
337
+ privateerVerifiedTee: (m) => priv.hasCredentials() && privateerChannel(m.id ?? "") === "tee",
338
+ }),
339
+ makeAccountProvider(),
340
+ ] as any,
333
341
  },
334
342
  });
335
343
  for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
@@ -14,7 +14,7 @@ import { agentVersion } from "../config/version.ts";
14
14
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
15
15
  import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
16
16
  import { makePiPrivacyExtension } from "pi-privacy";
17
- import { makeAccountProvider } from "../providers/account.ts";
17
+ import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
18
18
  import { resolveDefaultModel } from "../providers/defaultModel.ts";
19
19
  import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
20
20
  import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
@@ -30,7 +30,7 @@ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
30
30
  import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
31
31
  import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
32
32
  import { authorizeControl } from "../remote/controlAuth.ts";
33
- import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
33
+ import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke, defaultDeviceLabel } from "../auth/privateer.ts";
34
34
  import {
35
35
  loadRoutines,
36
36
  upsertRoutine,
@@ -50,7 +50,7 @@ import { splitRoutineTools } from "../routines/toolSelect.ts";
50
50
  import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
51
51
  import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
52
52
  import { redactText, collectSecrets } from "../util/redact.ts";
53
- import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
53
+ import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
54
54
  import { isHosted, publishRelayPub } from "../config/hosted.ts";
55
55
 
56
56
  // The safe, read-only toolset for unattended runs — Pi builtins with no
@@ -226,13 +226,19 @@ export class Harbor {
226
226
  return "queued";
227
227
  };
228
228
 
229
- start(): void {
229
+ async start(): Promise<void> {
230
+ // Single-instance lock FIRST, before any other side effect: binding the IPC
231
+ // socket is the machine's mutex. If a live harbor already holds it this throws
232
+ // HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
233
+ // routineRelayId(), so a second would collide on the relay and double-fire
234
+ // routines. Doing this first means a rejected second instance never publishes a
235
+ // relay key, connects, or fires a tick.
236
+ this.server = await startIpcServer((req) => this.handleIpc(req));
230
237
  // Hosted only: publish our relay pubkey for the host to bind into the SEV-SNP
231
238
  // report. Before syncRelay() so the key exists by the time we're reachable.
232
239
  publishRelayPub();
233
240
  this.primeSchedule();
234
241
  this.timer = setInterval(() => void this.tick(), TICK_MS);
235
- this.server = startIpcServer((req) => this.handleIpc(req));
236
242
  this.syncRelay();
237
243
  void this.flushPendingCloud();
238
244
  const count = loadRoutines().filter((r) => r.enabled).length;
@@ -517,11 +523,22 @@ export class Harbor {
517
523
  }
518
524
  }
519
525
 
526
+ // This machine's origin tag, embedded (E2EE) in every sealed result so the app can
527
+ // show WHICH box/environment produced it — the outbox record itself is account-only,
528
+ // so attribution can only live inside the sealed blob (where hostnames are allowed;
529
+ // the server never sees it). `id` is this install's stable relay id; `label` is the
530
+ // hostname-based device name. Cached — it never changes for the process lifetime.
531
+ private originCache?: { id: string; label: string };
532
+ private machineOrigin(): { id: string; label: string } {
533
+ if (!this.originCache) this.originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
534
+ return this.originCache;
535
+ }
536
+
520
537
  private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: "routine" | "task" = "routine"): Promise<boolean> {
521
538
  const pub = await this.ensureOutboxPub();
522
539
  if (!pub) return false;
523
540
  const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
524
- const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body });
541
+ const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: this.machineOrigin() });
525
542
  try {
526
543
  const res = await apiRequest("/api/outbox", {
527
544
  method: "POST",
@@ -727,7 +744,18 @@ export class Harbor {
727
744
  cwd: spec.cwd,
728
745
  agentDir: agentDir(),
729
746
  resourceLoaderOptions: {
730
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider(), mcpAdapter] as any,
747
+ extensionFactories: [
748
+ makePermissionGate(gate),
749
+ // Per-model verified-TEE capability for pi-privacy's /models picker: show
750
+ // Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
751
+ // when logged in; ZDR-channel models stay at their honest floor. The live
752
+ // verdict still comes from accountPosture on select — this only lifts the label.
753
+ makePiPrivacyExtension({
754
+ privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
755
+ }),
756
+ makeAccountProvider(),
757
+ mcpAdapter,
758
+ ] as any,
731
759
  },
732
760
  });
733
761
  servicesRef = services as any;
@@ -1034,7 +1062,6 @@ export class Harbor {
1034
1062
  // Entry point for `privateer harbor`. Caller must have imported ./boot.ts first.
1035
1063
  export function runHarbor(): void {
1036
1064
  const harbor = new Harbor();
1037
- harbor.start();
1038
1065
  const shutdown = () => {
1039
1066
  log("shutting down");
1040
1067
  harbor.stop();
@@ -1042,4 +1069,14 @@ export function runHarbor(): void {
1042
1069
  };
1043
1070
  process.on("SIGINT", shutdown);
1044
1071
  process.on("SIGTERM", shutdown);
1072
+ harbor.start().catch((err) => {
1073
+ if (err instanceof HarborAlreadyRunningError) {
1074
+ // A resident harbor already owns this machine — leave it in charge. Exit 0 so a
1075
+ // manual `privateer harbor run` beside the installed login service isn't an error.
1076
+ process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
1077
+ process.exit(0);
1078
+ }
1079
+ process.stderr.write(`Harbor failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
1080
+ process.exit(1);
1081
+ });
1045
1082
  }
package/src/harbor/ipc.ts CHANGED
@@ -33,44 +33,82 @@ export interface IpcResponse {
33
33
 
34
34
  export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
35
35
 
36
- // Start the harbor-side socket server. Returns the Server so the caller can close it.
37
- export function startIpcServer(handler: IpcHandler): Server {
36
+ // Probe whether a LIVE process is listening on the socket at `path`. Used as the
37
+ // single-instance test: a successful connect (or a slow-to-answer one) means a real
38
+ // harbor holds the lock; ECONNREFUSED/ENOENT means the socket file is stale (no
39
+ // listener behind it) and is safe to reclaim. Conservative — any ambiguous error
40
+ // resolves `true` so we never steal a path that might still be owned.
41
+ function probeExistingListener(path: string, timeoutMs = 1000): Promise<boolean> {
42
+ return new Promise((resolve) => {
43
+ const sock = createConnection(path);
44
+ const done = (live: boolean) => {
45
+ clearTimeout(timer);
46
+ try { sock.destroy(); } catch { /* already gone */ }
47
+ resolve(live);
48
+ };
49
+ const timer = setTimeout(() => done(true), timeoutMs); // slow to answer ⇒ assume live
50
+ sock.on("connect", () => done(true));
51
+ sock.on("error", (err: NodeJS.ErrnoException) => {
52
+ done(!(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
53
+ });
54
+ });
55
+ }
56
+
57
+ // Start the harbor-side socket server. Resolves with the Server (so the caller can
58
+ // close it), or REJECTS with HarborAlreadyRunningError if a live harbor already owns
59
+ // the socket — the bind is the machine's single-instance lock. Two harbors under one
60
+ // ~/.privateer share a single routineRelayId(), so a second instance would collide on
61
+ // the relay and double-fire routines; refusing to start is the fix. A stale socket
62
+ // file (crash with no live listener) is detected and reclaimed, so recovery still works.
63
+ export function startIpcServer(handler: IpcHandler): Promise<Server> {
38
64
  const path = harborSocketPath();
39
- // A stale socket file from a previous crash would block bind; remove it first.
40
- if (existsSync(path)) {
41
- try {
42
- unlinkSync(path);
43
- } catch {
44
- /* ignore bind will surface a clearer error */
45
- }
46
- }
47
- const server = createServer((sock: Socket) => {
48
- let buf = "";
49
- sock.on("data", (chunk) => {
50
- buf += chunk.toString("utf8");
51
- const nl = buf.indexOf("\n");
52
- if (nl < 0) return; // wait for the full line
53
- const line = buf.slice(0, nl);
54
- void (async () => {
55
- let res: IpcResponse;
65
+ const build = (): Server =>
66
+ createServer((sock: Socket) => {
67
+ let buf = "";
68
+ sock.on("data", (chunk) => {
69
+ buf += chunk.toString("utf8");
70
+ const nl = buf.indexOf("\n");
71
+ if (nl < 0) return; // wait for the full line
72
+ const line = buf.slice(0, nl);
73
+ void (async () => {
74
+ let res: IpcResponse;
75
+ try {
76
+ res = await handler(JSON.parse(line) as IpcRequest);
77
+ } catch (err) {
78
+ res = { ok: false, message: err instanceof Error ? err.message : String(err) };
79
+ }
80
+ sock.end(JSON.stringify(res) + "\n");
81
+ })();
82
+ });
83
+ sock.on("error", () => sock.destroy());
84
+ });
85
+
86
+ return new Promise<Server>((resolve, reject) => {
87
+ // `reclaimed` guards a single stale-socket reclaim so a persistent bind failure
88
+ // can't loop. On EADDRINUSE we probe for a live listener rather than unlinking
89
+ // blindly (the old behavior, which let a second harbor silently steal the path).
90
+ const attempt = (reclaimed: boolean) => {
91
+ const server = build();
92
+ server.once("error", (err: NodeJS.ErrnoException) => {
93
+ if (err.code !== "EADDRINUSE") { reject(err); return; }
94
+ void probeExistingListener(path).then((live) => {
95
+ if (live) { reject(new HarborAlreadyRunningError()); return; }
96
+ if (reclaimed) { reject(err); return; } // already reclaimed once — give up
97
+ try { unlinkSync(path); } catch { /* ignore — retry surfaces a clearer error */ }
98
+ attempt(true);
99
+ });
100
+ });
101
+ server.listen(path, () => {
56
102
  try {
57
- res = await handler(JSON.parse(line) as IpcRequest);
58
- } catch (err) {
59
- res = { ok: false, message: err instanceof Error ? err.message : String(err) };
103
+ chmodSync(path, 0o600); // owner-only IPC endpoint
104
+ } catch {
105
+ /* non-POSIX best effort */
60
106
  }
61
- sock.end(JSON.stringify(res) + "\n");
62
- })();
63
- });
64
- sock.on("error", () => sock.destroy());
65
- });
66
- server.listen(path, () => {
67
- try {
68
- chmodSync(path, 0o600); // owner-only IPC endpoint
69
- } catch {
70
- /* non-POSIX — best effort */
71
- }
107
+ resolve(server);
108
+ });
109
+ };
110
+ attempt(false);
72
111
  });
73
- return server;
74
112
  }
75
113
 
76
114
  // Client side: send one request, resolve with the response. Rejects if the harbor
@@ -116,6 +154,16 @@ export class HarborNotRunningError extends Error {
116
154
  }
117
155
  }
118
156
 
157
+ // Thrown by startIpcServer when a live harbor already holds this machine's socket —
158
+ // i.e. a second instance is trying to start under the same ~/.privateer. The caller
159
+ // (runHarbor) treats this as a clean no-op exit, not a crash.
160
+ export class HarborAlreadyRunningError extends Error {
161
+ constructor() {
162
+ super("A Harbor is already running on this machine.");
163
+ this.name = "HarborAlreadyRunningError";
164
+ }
165
+ }
166
+
119
167
  // Convenience: is the harbor reachable right now?
120
168
  export async function harborIsRunning(): Promise<boolean> {
121
169
  try {
@@ -130,6 +130,112 @@ export const MCP_CATALOG: CatalogEntry[] = [
130
130
  args: ["-y", "@modelcontextprotocol/server-memory"],
131
131
  needs: "none",
132
132
  },
133
+
134
+ // ── Remote, browser-authorized (OAuth) ──────────────────────────────────────
135
+ {
136
+ id: "sentry",
137
+ name: "sentry",
138
+ label: "Sentry",
139
+ blurb: "Errors, issues, and releases. Sign in via browser.",
140
+ transport: "http",
141
+ url: "https://mcp.sentry.dev/mcp",
142
+ oauth: true,
143
+ needs: "oauth",
144
+ },
145
+ {
146
+ id: "atlassian",
147
+ name: "atlassian",
148
+ label: "Jira & Confluence",
149
+ blurb: "Atlassian issues and pages. Sign in via browser.",
150
+ transport: "http",
151
+ url: "https://mcp.atlassian.com/v1/sse",
152
+ oauth: true,
153
+ needs: "oauth",
154
+ },
155
+ {
156
+ id: "stripe",
157
+ name: "stripe",
158
+ label: "Stripe",
159
+ blurb: "Payments, customers, and invoices. Sign in via browser.",
160
+ transport: "http",
161
+ url: "https://mcp.stripe.com",
162
+ oauth: true,
163
+ needs: "oauth",
164
+ },
165
+ {
166
+ id: "asana",
167
+ name: "asana",
168
+ label: "Asana",
169
+ blurb: "Tasks and projects. Sign in via browser.",
170
+ transport: "http",
171
+ url: "https://mcp.asana.com/sse",
172
+ oauth: true,
173
+ needs: "oauth",
174
+ },
175
+
176
+ // ── Local, token-authorized ─────────────────────────────────────────────────
177
+ {
178
+ id: "brave-search",
179
+ name: "brave-search",
180
+ label: "Brave Search",
181
+ blurb: "Web and local search results.",
182
+ transport: "stdio",
183
+ command: "npx",
184
+ args: ["-y", "@modelcontextprotocol/server-brave-search"],
185
+ env: { BRAVE_API_KEY: "" },
186
+ needs: "token",
187
+ fill: "BRAVE_API_KEY",
188
+ credUrl: "https://brave.com/search/api/",
189
+ },
190
+ {
191
+ id: "google-maps",
192
+ name: "google-maps",
193
+ label: "Google Maps",
194
+ blurb: "Places, directions, and geocoding.",
195
+ transport: "stdio",
196
+ command: "npx",
197
+ args: ["-y", "@modelcontextprotocol/server-google-maps"],
198
+ env: { GOOGLE_MAPS_API_KEY: "" },
199
+ needs: "token",
200
+ fill: "GOOGLE_MAPS_API_KEY",
201
+ credUrl: "https://console.cloud.google.com/google/maps-apis/credentials",
202
+ },
203
+ {
204
+ id: "supabase",
205
+ name: "supabase",
206
+ label: "Supabase",
207
+ blurb: "Query and manage your Supabase project.",
208
+ transport: "stdio",
209
+ command: "npx",
210
+ args: ["-y", "@supabase/mcp-server-supabase@latest"],
211
+ env: { SUPABASE_ACCESS_TOKEN: "" },
212
+ needs: "token",
213
+ fill: "SUPABASE_ACCESS_TOKEN",
214
+ credUrl: "https://supabase.com/dashboard/account/tokens",
215
+ },
216
+ {
217
+ id: "figma",
218
+ name: "figma",
219
+ label: "Figma",
220
+ blurb: "Read designs, frames, and components.",
221
+ transport: "stdio",
222
+ command: "npx",
223
+ args: ["-y", "figma-developer-mcp", "--stdio"],
224
+ env: { FIGMA_API_KEY: "" },
225
+ needs: "token",
226
+ fill: "FIGMA_API_KEY",
227
+ credUrl: "https://www.figma.com/developers/api#access-tokens",
228
+ },
229
+ {
230
+ id: "sequential-thinking",
231
+ name: "sequential-thinking",
232
+ label: "Sequential Thinking",
233
+ blurb: "A step-by-step reasoning scratchpad.",
234
+ transport: "stdio",
235
+ command: "npx",
236
+ args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
237
+ needs: "none",
238
+ },
133
239
  ];
134
240
 
135
241
  export function catalogEntry(id: string): CatalogEntry | undefined {
@@ -22,6 +22,13 @@ import {
22
22
  } from "../auth/privateer.ts";
23
23
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
24
24
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
25
+ import {
26
+ sealedEnabled,
27
+ sealedProviderFor,
28
+ sealedShimBase,
29
+ ensureSealedShim,
30
+ attestSealed,
31
+ } from "./sealedShim.ts";
25
32
 
26
33
  // Seed/fallback catalog: registered synchronously so the account provider has real
27
34
  // models the instant it loads (before the live /api/models fetch resolves) — in
@@ -73,7 +80,9 @@ const VALID_TIERS = new Set<PrivacyTier>([
73
80
  // upgrades it to tee-verified live via attestation (accountPosture). Everything
74
81
  // else with no server signal is "standard": we don't assert ZDR we can't back.
75
82
  function tierFromPrefix(modelId: string): PrivacyTier {
76
- return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") ? "tee-unverified" : "standard";
83
+ return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") || modelId.startsWith("phala/")
84
+ ? "tee-unverified"
85
+ : "standard";
77
86
  }
78
87
 
79
88
  function normalizeTier(tier: string | undefined, modelId: string): PrivacyTier {
@@ -248,13 +257,26 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
248
257
  if (privateerChannel(modelId) === "zdr") {
249
258
  return { tier: "zdr-policy" };
250
259
  }
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.
260
+ // Sealed (EHBP) path. When sealed mode is on and the model has a Node sealed
261
+ // client (tinfoil/*), inference goes through the blind relay with the body
262
+ // HPKE-sealed to the enclave, and we attest that enclave client-side with the SAME
263
+ // SecureClient that carries the tokens. A green ready() is a quote WE checked,
264
+ // bound to the HPKE key we seal to so it earns tee-verified. A failure stays
265
+ // tee-unverified with the reason surfaced (never a silent green). See
266
+ // docs/tee-privateer-tinfoil-ehbp.md.
267
+ const sealedProvider = sealedEnabled() ? sealedProviderFor(modelId) : null;
268
+ if (sealedProvider) {
269
+ const att = await attestSealed(sealedProvider);
270
+ return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
271
+ }
272
+ // Honest labelling for the non-NEAR enclaves without sealed mode. Tinfoil and Phala
273
+ // publish real attestations, but the server proxies the inference in cleartext, so
274
+ // from here we cannot bind a quote to the connection actually carrying our tokens —
275
+ // only the account's word that it did. That's `tee-unverified` (yellow "confidential
276
+ // compute, unconfirmed"), never the green tee-verified we reserve for a quote we
277
+ // checked ourselves. Turn on sealed mode (PRIVATEER_SEALED=1) for the verified
278
+ // shield, or set TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests
279
+ // client-side over the TLS binding).
258
280
  if (!modelId.startsWith("near/")) {
259
281
  return { tier: "tee-unverified" };
260
282
  }
@@ -299,15 +321,40 @@ export function makeAccountProvider() {
299
321
  on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
300
322
  }): void => {
301
323
  if (typeof pi.registerProvider !== "function") return;
302
- const register = (ids: string[]): void =>
324
+ // A model entry, with a per-model baseUrl override for sealed models once the
325
+ // EHBP shim is listening: `tinfoil/*` then route through the loopback shim (which
326
+ // seals to the blind relay) instead of the cleartext `/api/agent/v1` proxy.
327
+ // Everything else keeps the provider baseUrl below. Until the shim is up (or when
328
+ // sealed mode is off) sealed models fall back to the cleartext path — and the
329
+ // badge stays honestly `tee-unverified` (see accountPosture).
330
+ const modelEntry = (id: string) => {
331
+ const base = seedModel(id);
332
+ const provider = sealedEnabled() ? sealedProviderFor(id) : null;
333
+ const shim = sealedShimBase();
334
+ return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
335
+ };
336
+ let lastIds: string[] = DEFAULT_MODELS;
337
+ const register = (ids: string[]): void => {
338
+ lastIds = ids;
303
339
  pi.registerProvider!("privateer", {
304
340
  name: "Privateer account",
305
341
  baseUrl: `${serverBaseUrl()}/api/agent/v1`,
306
342
  api: "openai-completions",
307
343
  oauth: privateerOAuthProvider,
308
- models: ids.map(seedModel),
344
+ models: ids.map(modelEntry),
309
345
  });
346
+ };
310
347
  register(DEFAULT_MODELS); // immediate: provider exists this tick
348
+ // Bring up the sealed shim, then re-register so sealed models pick up their shim
349
+ // baseUrl. Registration re-runs anyway after the catalog fetch; this just makes
350
+ // sure the switch lands even if the fetch is slow or fails.
351
+ if (sealedEnabled()) {
352
+ void ensureSealedShim()
353
+ .then(() => register(lastIds))
354
+ .catch(() => {
355
+ /* shim failed to start → sealed models stay on the cleartext path */
356
+ });
357
+ }
311
358
  // Refine to the live catalog. fetchAccountCatalog also populates accountTierMap
312
359
  // as a side effect, so the /models picker can shield each row without re-fetching.
313
360
  void fetchAccountCatalog()
@@ -2,9 +2,9 @@
2
2
  // becomes reachable under Pi. Written after verifying (2026-07-07) that:
3
3
  // - pi-ai ships STATIC model catalogs for its 14 built-in providers, so they're
4
4
  // selectable once a key is present — privateer emits NO models.json entry for them;
5
- // - the `pi-privacy` extension registers the 6 privacy providers (tinfoil, nearai,
6
- // venice, ollama, custom, privateer-api) at load — privateer emits NO entry for
7
- // them either;
5
+ // - the `pi-privacy` extension registers the privacy providers (tinfoil, nearai,
6
+ // venice, ollama, and — since pi-privacy 0.7`privateer`, its posture-aware
7
+ // public dev-key surface) at load — privateer emits NO entry for them either;
8
8
  // - so the ONLY provider this generator must emit is `qwen` (config-only,
9
9
  // non-privacy, no built-in catalog), and `privateer` (the account OAuth channel)
10
10
  // is handled in code, not config (Phase 4).
@@ -66,9 +66,11 @@ export const PROVIDERS: ProviderEntry[] = [
66
66
  { id: "tinfoil", source: "pi-privacy" },
67
67
  { id: "venice", source: "pi-privacy" },
68
68
  { id: "custom", source: "pi-privacy" },
69
- // Public developer-API surface (sk-priv-… key). Registered by the pi-privacy
70
- // extension like the others; distinct from the in-app `privateer` account channel.
71
- { id: "privateer-api", source: "pi-privacy" },
69
+ // The in-app account OAuth channel. NOTE: since pi-privacy 0.7 the extension ALSO
70
+ // registers a `privateer` provider (its posture-aware public sk-priv- dev-key surface,
71
+ // floored to zdr-policy) but makeAccountProvider runs AFTER pi-privacy in the
72
+ // extension list and re-registers the same id, so the account channel wins. There is
73
+ // no longer a separate `privateer-api` id (renamed to `privateer` upstream).
72
74
  { id: "privateer", source: "account" },
73
75
  ];
74
76
 
@@ -0,0 +1,23 @@
1
+ # Vendored: `@dstack/aci-verifier`
2
+
3
+ Faithful copy of the zero-dependency TypeScript ACI verifier from
4
+ [Dstack-TEE/private-ai-gateway](https://github.com/Dstack-TEE/private-ai-gateway)
5
+ (`clients/verifier-ts/src`), Apache-2.0. It is `private: true` upstream (not on
6
+ npm), so it is vendored here rather than installed.
7
+
8
+ Provides the pieces `PhalaProvider` needs:
9
+ - **`verifyReportBinding`** (`report.ts`) — §10.1 checks 2–6 (crypto binding of the
10
+ attestation report to the attested keyset for a supplied nonce). NOT the hardware
11
+ TDX quote (check 1) — that is layered on with `@phala/dcap-qvl` in the provider.
12
+ - **`openE2eeChannel`** (`e2ee-channel.ts`) — the ACI E2EE channel:
13
+ `x25519-aes-256-gcm-hkdf-sha256`, per-field seal/open, `X-E2EE-*` headers.
14
+
15
+ ## Local adaptation (the only change from upstream)
16
+ - Relative import specifiers had their `.js` extension stripped (`'./jcs.js'` →
17
+ `'./jcs'`) so Metro + TS (`moduleResolution: bundler`) resolve to the `.ts` files.
18
+
19
+ Everything else is byte-for-byte upstream. The crypto runs on `globalThis.crypto`
20
+ (Web Crypto: X25519, HKDF, AES-GCM, Ed25519, `getRandomValues`). In privateer-agent
21
+ (Node ≥ 22) these are all native — **no polyfills needed** (unlike the treeview RN app,
22
+ which bridges them via `react-native-quick-crypto`). Re-pull from upstream to update;
23
+ re-apply only the `.js`-extension strip.