privateer-agent 0.6.10 → 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.
Files changed (59) hide show
  1. package/README.md +20 -20
  2. package/SECURITY.md +1 -1
  3. package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
  4. package/bin/privateer-launch.mjs +7 -6
  5. package/bin/privateer-subagent.mjs +1 -1
  6. package/extensions/privateer-brand.ts +1 -1
  7. package/extensions/privateer-connect.ts +2 -2
  8. package/extensions/privateer-tools.ts +1 -1
  9. package/package.json +4 -2
  10. package/src/auth/privateer.ts +2 -2
  11. package/src/channels/run.ts +16 -7
  12. package/src/channels/status.ts +7 -7
  13. package/src/cli/chat.ts +39 -5
  14. package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
  15. package/src/config/hosted.ts +5 -5
  16. package/src/crypto/accountTrust.ts +2 -2
  17. package/src/crypto/accountVerify.ts +1 -1
  18. package/src/{daemon → harbor}/index.ts +81 -44
  19. package/src/harbor/ipc.ts +175 -0
  20. package/src/{daemon → harbor}/service.ts +61 -28
  21. package/src/main.ts +1 -1
  22. package/src/mcp/catalog.ts +106 -0
  23. package/src/providers/account.ts +58 -11
  24. package/src/providers/catalog.ts +8 -6
  25. package/src/providers/defaultModel.ts +1 -1
  26. package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
  27. package/src/providers/phala/aci-verifier/crypto.ts +95 -0
  28. package/src/providers/phala/aci-verifier/digest.ts +116 -0
  29. package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
  30. package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
  31. package/src/providers/phala/aci-verifier/errors.ts +41 -0
  32. package/src/providers/phala/aci-verifier/index.ts +87 -0
  33. package/src/providers/phala/aci-verifier/jcs.ts +69 -0
  34. package/src/providers/phala/aci-verifier/receipt.ts +139 -0
  35. package/src/providers/phala/aci-verifier/report.ts +126 -0
  36. package/src/providers/phala/aci-verifier/types.ts +139 -0
  37. package/src/providers/phala/sse.ts +43 -0
  38. package/src/providers/phala/webcrypto-globals.d.ts +17 -0
  39. package/src/providers/phalaSeal.ts +200 -0
  40. package/src/providers/sealedShim.ts +295 -0
  41. package/src/remote/channelsControl.ts +8 -8
  42. package/src/remote/controlAuth.ts +1 -1
  43. package/src/remote/liveTaskSession.ts +15 -7
  44. package/src/remote/mcpControl.ts +2 -2
  45. package/src/remote/relayClient.ts +41 -21
  46. package/src/remote/remoteBridge.ts +17 -7
  47. package/src/remote/routinesControl.ts +7 -7
  48. package/src/remote/workflowsControl.ts +6 -6
  49. package/src/routines/delivery.ts +6 -6
  50. package/src/routines/schema.ts +3 -3
  51. package/src/routines/store.ts +3 -3
  52. package/src/routines/trigger.ts +1 -1
  53. package/src/tools/routine.ts +8 -8
  54. package/src/util/fileMentions.ts +232 -0
  55. package/src/workflows/expr.ts +1 -1
  56. package/src/workflows/runner.ts +3 -3
  57. package/src/workflows/schema.ts +1 -1
  58. package/src/workflows/store.ts +1 -1
  59. package/src/daemon/ipc.ts +0 -127
@@ -1,28 +1,35 @@
1
- // Install the resident daemon as a per-user OS service so it auto-starts at login
1
+ // Install the resident harbor as a per-user OS service so it auto-starts at login
2
2
  // and survives the terminal closing — the difference between "the CLI is running"
3
- // and "the daemon is reachable from the app even when no CLI is". macOS → launchd
3
+ // and "the harbor is reachable from the app even when no CLI is". macOS → launchd
4
4
  // user agent; Linux → systemd --user unit. No root: everything lives under the
5
5
  // user's own home and login session.
6
6
  //
7
7
  // ORDERING NOTE: this module is import-safe (node builtins + our paths only, no Pi),
8
- // so the daemon CLI can load it without going through boot.ts.
8
+ // so the harbor CLI can load it without going through boot.ts.
9
9
  import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { homedir } from "node:os";
12
12
  import { join, dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { globalDir } from "../config/paths.ts";
15
- import { daemonIsRunning } from "./ipc.ts";
15
+ import { harborIsRunning } from "./ipc.ts";
16
16
 
17
- const LABEL = "pro.privateer.daemon"; // launchd label / reverse-dns id
18
- const UNIT = "privateer-daemon.service"; // systemd --user unit name
17
+ const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
18
+ const UNIT = "privateer-harbor.service"; // systemd --user unit name
19
19
 
20
- // Absolute path to the node launcher that boots + runs the daemon (bin/privateer-daemon.mjs).
20
+ // Pre-rename service identity ("daemon"). Kept ONLY so install/uninstall can evict a
21
+ // service a user installed before the harbor rename — otherwise it lingers as an
22
+ // orphaned launchd agent / systemd unit still running the old launcher. Never written,
23
+ // only torn down.
24
+ const OLD_LABEL = "pro.privateer.daemon";
25
+ const OLD_UNIT = "privateer-daemon.service";
26
+
27
+ // Absolute path to the node launcher that boots + runs the harbor (bin/privateer-harbor.mjs).
21
28
  // Resolved from THIS module so it's correct for both a dev checkout and a global npm
22
- // install (…/node_modules/privateer-agent/bin/privateer-daemon.mjs).
23
- function daemonLauncherPath(): string {
24
- const here = dirname(fileURLToPath(import.meta.url)); // …/src/daemon
25
- return resolve(here, "../../bin/privateer-daemon.mjs");
29
+ // install (…/node_modules/privateer-agent/bin/privateer-harbor.mjs).
30
+ function harborLauncherPath(): string {
31
+ const here = dirname(fileURLToPath(import.meta.url)); // …/src/harbor
32
+ return resolve(here, "../../bin/privateer-harbor.mjs");
26
33
  }
27
34
 
28
35
  // The node binary to bake into the unit. We use the CURRENT interpreter (>=22, the
@@ -32,12 +39,12 @@ function nodeBinaryPath(): string {
32
39
  return process.execPath;
33
40
  }
34
41
 
35
- function daemonLogPath(): string {
36
- return join(globalDir(), "daemon.log");
42
+ function harborLogPath(): string {
43
+ return join(globalDir(), "harbor.log");
37
44
  }
38
45
 
39
46
  // Env we forward into the service so a non-default home / server URL survives. Kept
40
- // tiny and explicit — the daemon reads the rest from ~/.privateer.
47
+ // tiny and explicit — the harbor reads the rest from ~/.privateer.
41
48
  function forwardedEnv(): Record<string, string> {
42
49
  const env: Record<string, string> = {};
43
50
  if (process.env.PRIVATEER_HOME) env.PRIVATEER_HOME = process.env.PRIVATEER_HOME;
@@ -56,13 +63,13 @@ function xmlEscape(s: string): string {
56
63
  }
57
64
 
58
65
  function launchdPlist(): string {
59
- const args = [nodeBinaryPath(), daemonLauncherPath(), "run"];
66
+ const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
60
67
  const envVars = forwardedEnv();
61
68
  const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
62
69
  const envXml = Object.entries(envVars)
63
70
  .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
64
71
  .join("\n");
65
- const log = xmlEscape(daemonLogPath());
72
+ const log = xmlEscape(harborLogPath());
66
73
  return `<?xml version="1.0" encoding="UTF-8"?>
67
74
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
75
  <plist version="1.0">
@@ -86,7 +93,18 @@ ${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVa
86
93
  `;
87
94
  }
88
95
 
96
+ // Evict a pre-rename launchd agent (pro.privateer.daemon) if one is installed, so the
97
+ // harbor rename doesn't leave the old service running the old launcher alongside it.
98
+ function evictOldLaunchd(): void {
99
+ const oldPlist = join(homedir(), "Library", "LaunchAgents", `${OLD_LABEL}.plist`);
100
+ if (existsSync(oldPlist)) {
101
+ spawnSync("launchctl", ["unload", "-w", oldPlist], { stdio: "ignore" });
102
+ rmSync(oldPlist, { force: true });
103
+ }
104
+ }
105
+
89
106
  function installLaunchd(): void {
107
+ evictOldLaunchd();
90
108
  const plist = launchAgentPath();
91
109
  mkdirSync(dirname(plist), { recursive: true });
92
110
  writeFileSync(plist, launchdPlist());
@@ -100,6 +118,7 @@ function installLaunchd(): void {
100
118
  }
101
119
 
102
120
  function uninstallLaunchd(): void {
121
+ evictOldLaunchd();
103
122
  const plist = launchAgentPath();
104
123
  if (existsSync(plist)) {
105
124
  spawnSync("launchctl", ["unload", "-w", plist], { stdio: "ignore" });
@@ -115,12 +134,12 @@ function systemdUnitPath(): string {
115
134
  }
116
135
 
117
136
  function systemdUnit(): string {
118
- const exec = [nodeBinaryPath(), daemonLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
137
+ const exec = [nodeBinaryPath(), harborLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
119
138
  const envLines = Object.entries(forwardedEnv())
120
139
  .map(([k, v]) => `Environment=${k}=${v}`)
121
140
  .join("\n");
122
141
  return `[Unit]
123
- Description=Privateer resident agent daemon (routines + app-driven task spawns)
142
+ Description=Privateer resident agent harbor (routines + app-driven task spawns)
124
143
  After=network-online.target
125
144
  Wants=network-online.target
126
145
 
@@ -136,7 +155,20 @@ WantedBy=default.target
136
155
  `;
137
156
  }
138
157
 
158
+ // Evict a pre-rename systemd --user unit (privateer-daemon.service) if present, so the
159
+ // harbor rename doesn't leave the old unit enabled alongside the new one.
160
+ function evictOldSystemd(): void {
161
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
162
+ const oldUnit = join(base, "systemd", "user", OLD_UNIT);
163
+ if (existsSync(oldUnit)) {
164
+ spawnSync("systemctl", ["--user", "disable", "--now", OLD_UNIT], { stdio: "ignore" });
165
+ rmSync(oldUnit, { force: true });
166
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
167
+ }
168
+ }
169
+
139
170
  function installSystemd(): void {
171
+ evictOldSystemd();
140
172
  const unit = systemdUnitPath();
141
173
  mkdirSync(dirname(unit), { recursive: true });
142
174
  writeFileSync(unit, systemdUnit());
@@ -152,6 +184,7 @@ function installSystemd(): void {
152
184
  }
153
185
 
154
186
  function uninstallSystemd(): void {
187
+ evictOldSystemd();
155
188
  const unit = systemdUnitPath();
156
189
  spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "ignore" });
157
190
  if (existsSync(unit)) rmSync(unit, { force: true });
@@ -182,7 +215,7 @@ export function serviceInfo(): ServiceInfo {
182
215
  supported: platform === "darwin" || platform === "linux",
183
216
  installed: !!unitPath && existsSync(unitPath),
184
217
  unitPath,
185
- logPath: daemonLogPath(),
218
+ logPath: harborLogPath(),
186
219
  };
187
220
  }
188
221
 
@@ -191,7 +224,7 @@ export function installService(): ServiceInfo {
191
224
  const platform = process.platform;
192
225
  if (platform === "darwin") installLaunchd();
193
226
  else if (platform === "linux") installSystemd();
194
- else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer daemon\` yourself, or keep a terminal open.`);
227
+ else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer harbor\` yourself, or keep a terminal open.`);
195
228
  return serviceInfo();
196
229
  }
197
230
 
@@ -203,15 +236,15 @@ export function uninstallService(): ServiceInfo {
203
236
  return serviceInfo();
204
237
  }
205
238
 
206
- // Human-readable status line for `privateer daemon status`: whether the service is
207
- // installed AND whether a daemon is actually answering on the IPC socket right now.
239
+ // Human-readable status line for `privateer harbor status`: whether the service is
240
+ // installed AND whether a harbor is actually answering on the IPC socket right now.
208
241
  export async function statusReport(): Promise<string> {
209
242
  const info = serviceInfo();
210
- const live = await daemonIsRunning();
243
+ const live = await harborIsRunning();
211
244
  const lines = [
212
- `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer daemon` manually)"}`,
245
+ `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
213
246
  `service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
214
- `daemon: ${live ? "running (answering IPC)" : "not reachable"}`,
247
+ `harbor: ${live ? "running (answering IPC)" : "not reachable"}`,
215
248
  `logs: ${info.logPath}`,
216
249
  ];
217
250
  // Surface a stale-unit hint: file present but nothing answering usually means it
@@ -220,11 +253,11 @@ export async function statusReport(): Promise<string> {
220
253
  return lines.join("\n");
221
254
  }
222
255
 
223
- // Best-effort read of the tail of the daemon log (for a `status --log` affordance or
256
+ // Best-effort read of the tail of the harbor log (for a `status --log` affordance or
224
257
  // error surfacing). Returns "" if absent.
225
- export function tailDaemonLog(maxBytes = 4_000): string {
258
+ export function tailHarborLog(maxBytes = 4_000): string {
226
259
  try {
227
- const buf = readFileSync(daemonLogPath(), "utf8");
260
+ const buf = readFileSync(harborLogPath(), "utf8");
228
261
  return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
229
262
  } catch {
230
263
  return "";
package/src/main.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Phase 1 skeleton: this prints the resolved boot state so `npm start` proves
6
6
  // the boot chain end-to-end (env pinned, dispatcher installed) without a TUI.
7
- // Phases 4/6 replace the body with the daemon/relay wiring and the pi-tui app.
7
+ // Phases 4/6 replace the body with the harbor/relay wiring and the pi-tui app.
8
8
 
9
9
  import "./boot.ts";
10
10
 
@@ -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()
@@ -322,7 +369,7 @@ export function makeAccountProvider() {
322
369
  // auth.json entry (see the LIFECYCLE HAZARD note in src/auth/privateer.ts). So a
323
370
  // signed-in user who quits and relaunches lands on privateer/* with no key at
324
371
  // all, and the first prompt dead-ends on "No API key found for privateer." — even
325
- // though the banner says "connected". The REPL (cli/chat.ts) and the daemon
372
+ // though the banner says "connected". The REPL (cli/chat.ts) and the harbor
326
373
  // already spawn one at startup; this gives the TUI the same seed.
327
374
  pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
328
375
  };
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  // The single source of truth for "which model do we default to?" — shared by every
2
2
  // entry point that has to pick a model when the user hasn't named one: the REPL
3
- // (cli/chat.ts), the daemon (routines), the channels runner, and the login-time hook
3
+ // (cli/chat.ts), the harbor (routines), the channels runner, and the login-time hook
4
4
  // that seeds Pi's TUI default (ensurePiDefaultModel).
5
5
  //
6
6
  // The bug this fixes: each of those sites used to hardcode `openrouter/openai/gpt-4o-
@@ -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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Cryptographic primitives, all via the Web Crypto API (`globalThis.crypto`) so
3
+ * the same code runs in browsers and in Node 20+ with no third-party deps.
4
+ * Only SHA-256 and Ed25519 verification are needed for Level 1.
5
+ */
6
+
7
+ import { AciFormatError, UnsupportedAlgorithmError } from './errors';
8
+
9
+ const subtle = globalThis.crypto.subtle;
10
+
11
+ /** Lowercase-hex encode bytes. */
12
+ export function toHex(bytes: Uint8Array): string {
13
+ let out = '';
14
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
15
+ return out;
16
+ }
17
+
18
+ /** Decode hex (optionally `0x`-prefixed) to bytes. */
19
+ export function fromHex(hex: string): Uint8Array {
20
+ const h = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
21
+ if (h.length % 2 !== 0) {
22
+ throw new AciFormatError(`hex string has odd length: ${hex.length} chars`);
23
+ }
24
+ const out = new Uint8Array(h.length / 2);
25
+ for (let i = 0; i < out.length; i++) {
26
+ const byte = Number.parseInt(h.substr(i * 2, 2), 16);
27
+ if (Number.isNaN(byte)) {
28
+ throw new AciFormatError(`invalid hex at offset ${i * 2}: "${h.substr(i * 2, 2)}"`);
29
+ }
30
+ out[i] = byte;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** SHA-256 of the given bytes. */
36
+ export async function sha256(bytes: Uint8Array): Promise<Uint8Array> {
37
+ return new Uint8Array(await subtle.digest('SHA-256', bytes as BufferSource));
38
+ }
39
+
40
+ /** Lowercase-hex SHA-256 of the given bytes. */
41
+ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
42
+ return toHex(await sha256(bytes));
43
+ }
44
+
45
+ /**
46
+ * `sha256:<lowercase-hex>` digest string of the given bytes — the ACI digest
47
+ * form (§3) used for `workload_id`, keyset digests, and body hashes.
48
+ */
49
+ export async function sha256Prefixed(bytes: Uint8Array): Promise<string> {
50
+ return 'sha256:' + (await sha256Hex(bytes));
51
+ }
52
+
53
+ /**
54
+ * Verify an Ed25519 signature (RFC 8032, §4.3/§8.5) over `message`.
55
+ * `publicKeyRaw` is the 32-byte raw key; `signature` the 64-byte value.
56
+ * Returns false on a bad signature or malformed key — never throws for those.
57
+ */
58
+ export async function verifyEd25519(
59
+ publicKeyRaw: Uint8Array,
60
+ signature: Uint8Array,
61
+ message: Uint8Array,
62
+ ): Promise<boolean> {
63
+ let key: CryptoKey;
64
+ try {
65
+ key = await subtle.importKey('raw', publicKeyRaw as BufferSource, { name: 'Ed25519' }, false, [
66
+ 'verify',
67
+ ]);
68
+ } catch {
69
+ // A key that will not import cannot verify anything.
70
+ return false;
71
+ }
72
+ try {
73
+ return await subtle.verify({ name: 'Ed25519' }, key, signature as BufferSource, message as BufferSource);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Verify a signature by ACI signature `algo`, dispatching on the algorithm the
81
+ * attested keyset entry declares. Only `ed25519` is verifiable here; every other
82
+ * algorithm (including `ecdsa-secp256k1`) raises {@link UnsupportedAlgorithmError}.
83
+ */
84
+ export async function verifySignature(
85
+ algo: string,
86
+ publicKeyRaw: Uint8Array,
87
+ signature: Uint8Array,
88
+ message: Uint8Array,
89
+ context: string,
90
+ ): Promise<boolean> {
91
+ if (algo === 'ed25519') {
92
+ return verifyEd25519(publicKeyRaw, signature, message);
93
+ }
94
+ throw new UnsupportedAlgorithmError(algo, context);
95
+ }