privateer-agent 0.12.18 → 0.12.20

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.12.18",
3
+ "version": "0.12.20",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,14 +1,23 @@
1
1
  /**
2
2
  * buildMoat() — the one place that decides which extensions a session gets, in what order.
3
3
  *
4
- * WHAT THIS REPLACES. Five entry points each hand-assembled their own `extensionFactories`
4
+ * WHAT THIS REPLACES. Six entry points each hand-assembled their own `extensionFactories`
5
5
  * array: the harbor's sessions (routines, workflows, tasks), its live task spawns, the
6
- * channels runner, ACP, and the dev REPL. The five lists were the same list — same gate,
7
- * same privacy extension, same account provider, the same `webEnabled()`/`mediaEnabled()`
8
- * conditionals — copied with small deliberate differences and a large amount of duplicated
9
- * comment explaining them. Nothing held them together, so an extension added to one path
10
- * silently didn't exist on the other four (privateer-media had reached three of five), and
11
- * the ordering rule below lived as prose repeated in each copy.
6
+ * channels runner, ACP, the dev REPL, and the desktop app's per-window session. The lists
7
+ * were the same list — same gate, same privacy extension, same account provider, the same
8
+ * `webEnabled()`/`mediaEnabled()` conditionals — copied with small deliberate differences
9
+ * and a large amount of duplicated comment explaining them. Nothing held them together, so
10
+ * an extension added to one path silently didn't exist on the others (privateer-media had
11
+ * reached three of five), and the ordering rule below lived as prose repeated in each copy.
12
+ *
13
+ * THE DESKTOP IS WHY THIS NOTE IS NOW ABOUT SIX. It was left out of the first pass and
14
+ * proved the point within the month: the app's Super Computer had NO generation tools at
15
+ * all — no generate_image / _video / _model / _speech / _music / _sfx, no
16
+ * media_capabilities, and not even video_compose, which every other kind gets
17
+ * unconditionally because it is local ffmpeg work that costs nothing. Its MCP connectors
18
+ * worked, so Godot and Unreal drove the editor fine while the same window could not make
19
+ * a texture to put in it. The lesson is the one this module was written for: the fix is
20
+ * not "add the media factory to that array", it is "there are no arrays".
12
21
  *
13
22
  * ORDER MATTERS, ONCE. pi-privacy's own catalog registers a `privateer` provider (its
14
23
  * PUBLIC developer-key channel, one seed model), and Pi's registerProvider REPLACES a
@@ -51,7 +60,8 @@ export type MoatKind =
51
60
  | "live-task" // a drivable session the harbor spawns for the app
52
61
  | "channels" // Telegram / Slack / Discord / WhatsApp bridge sessions
53
62
  | "acp" // `privateer acp` — an ACP host (Zed, Buzz) drives
54
- | "repl"; // the lean dev REPL (npm run chat)
63
+ | "repl" // the lean dev REPL (npm run chat)
64
+ | "desktop"; // the desktop app's Super Computer — one session per window
55
65
 
56
66
  export interface MoatOptions {
57
67
  kind: MoatKind;
@@ -72,24 +82,83 @@ export interface MoatOptions {
72
82
  * the point: a run whose result goes to a webhook or a file has nothing to attach to.
73
83
  */
74
84
  resultMedia?: import("../routines/resultMedia.ts").ResultMedia;
85
+ /**
86
+ * What a SIGNED-OUT session hears when it calls web_search / web_fetch. Required by
87
+ * `web: "guarded"` and ignored otherwise. It lives on the caller because only the caller
88
+ * knows what the user can do about it — a desktop window can point at its account menu,
89
+ * a headless host has no menu to point at (src/tools/web.ts).
90
+ */
91
+ webHint?: string;
92
+ /**
93
+ * How to import a THIRD-PARTY dependency (today: pi-mcp-adapter). Defaults to a plain
94
+ * dynamic import, which is right for every process that runs from a normal Node
95
+ * resolution root.
96
+ *
97
+ * The desktop is not one. It runs the agent's copy of every shared package — one Pi
98
+ * instance, one captured-cert map — so it resolves from privateer-agent's own
99
+ * package.json rather than the app's, and it needs a fallback for adapters whose entry
100
+ * is an `index.ts` (under Electron, tsx's `register()` patches only the ESM loader, so
101
+ * a vanilla CJS resolve looks for index.js and reports MODULE_NOT_FOUND). Taking the
102
+ * resolver from the host keeps that knowledge in the host, where it belongs, instead of
103
+ * teaching this module about Electron.
104
+ */
105
+ hostImport?: (spec: string) => Promise<any>;
75
106
  }
76
107
 
77
108
  /**
78
109
  * Per-kind capabilities. `web` and `media` are the CEILING — each is still ANDed with its
79
110
  * runtime switch (webEnabled/mediaEnabled), so a false here means "never", not "by default".
80
111
  *
81
- * web is deliberately off for live-task and repl: makeWebTools() routes through the account
82
- * API precisely because an unattended run must not hold a search provider key, and both of
83
- * those paths have a human at the other end who can use their own provider (src/tools/web.ts).
84
- * compose is unconditional everywhere: local ffmpeg work, no account, no network, no spend —
85
- * so a run with generation off can still assemble media that already exists on disk.
112
+ * web is deliberately off for live-task and repl: the account form routes through the
113
+ * account API precisely because an unattended run must not hold a search provider key, and
114
+ * both of those paths have a human at the other end who can use their own provider
115
+ * (src/tools/web.ts). compose is unconditional everywhere: local ffmpeg work, no account,
116
+ * no network, no spend — so a run with generation off can still assemble media that
117
+ * already exists on disk.
118
+ *
119
+ * The three optional rows below are all FALSE for the unattended kinds, which is exactly
120
+ * what those five paths did before the desktop joined the table. They exist because the
121
+ * desktop is the first ATTENDED session built from here, and an attended session differs
122
+ * from a headless one in ways that are real rather than cosmetic — a project context file
123
+ * to load, a folder whose skills follow it, and a model PICKER whose registry has to be
124
+ * repaired. Leaving them off keeps every pre-existing kind byte-identical.
86
125
  */
87
- const CAPABILITIES: Record<MoatKind, { web: boolean; media: boolean; mcp: boolean }> = {
88
- "harbor-session": { web: true, media: true, mcp: true },
126
+ interface MoatCaps {
127
+ /**
128
+ * false — never; the tools do not exist for this kind.
129
+ * "account" — registered only once webEnabled() says there are credentials. The
130
+ * UNATTENDED shape: decide at build, because nothing will change mid-run.
131
+ * "guarded" — always registered, each call re-checks sign-in and answers with
132
+ * `webHint` when there is none. The ATTENDED shape: the session outlives
133
+ * `/signin`, so the question has to be asked when the tool RUNS
134
+ * (src/tools/web.ts spells out why these are two functions, not one).
135
+ */
136
+ web: false | "account" | "guarded";
137
+ media: boolean;
138
+ mcp: boolean;
139
+ /** PRIVATEER.md project context + /init (extensions/privateer-context.ts). */
140
+ context?: boolean;
141
+ /** The folder's own skills, contributed from ~/.privateer rather than the user's tree. */
142
+ spawnSkills?: boolean;
143
+ /**
144
+ * Load the privacy SHIM (extensions/privateer-privacy.ts) rather than the bare
145
+ * privacyExtension() below — the same configuration plus two provider REPAIRS.
146
+ * pi-privacy re-registers `tinfoil` with a one-model seed catalog and `privateer` with
147
+ * its public developer-key channel, and registerProvider REPLACES a provider's models.
148
+ * A kind that resolves ONE configured model never notices; a kind with a model picker
149
+ * does, loudly — every build throwing "Model tinfoil/… not found" on a machine with a
150
+ * working key. See the shim's header for the full account.
151
+ */
152
+ privacyRepairs?: boolean;
153
+ }
154
+
155
+ const CAPABILITIES: Record<MoatKind, MoatCaps> = {
156
+ "harbor-session": { web: "account", media: true, mcp: true },
89
157
  "live-task": { web: false, media: true, mcp: false },
90
- channels: { web: true, media: true, mcp: false },
91
- acp: { web: true, media: true, mcp: false },
158
+ channels: { web: "account", media: true, mcp: false },
159
+ acp: { web: "account", media: true, mcp: false },
92
160
  repl: { web: false, media: true, mcp: false },
161
+ desktop: { web: "guarded", media: true, mcp: true, context: true, spawnSkills: true, privacyRepairs: true },
93
162
  };
94
163
 
95
164
  /**
@@ -180,16 +249,32 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
180
249
  const { makePermissionGate } = await import("../ext/permissionGate.ts");
181
250
  const { makeAccountProvider } = await import("../providers/account.ts");
182
251
  const { webEnabled, mediaEnabled } = await import("./hosted.ts");
183
- const { privacyExtension } = await import("./privacyPolicy.ts");
184
252
 
185
253
  const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
186
254
 
187
255
  // pi-privacy is configured in exactly ONE place, shared with the DISCOVERED copy of this
188
256
  // extension (the TUI's, and every subagent child's) — see ./privacyPolicy.ts for the two
189
- // bugs that came of configuring it in two.
190
- factories.push(privacyExtension());
257
+ // bugs that came of configuring it in two. `privacyRepairs` picks which of the two
258
+ // ROUTES into that one configuration a kind takes, never which options it gets.
259
+ if (caps.privacyRepairs) {
260
+ const { default: privateerPrivacy } = await import("../../extensions/privateer-privacy.ts");
261
+ factories.push(privateerPrivacy);
262
+ } else {
263
+ const { privacyExtension } = await import("./privacyPolicy.ts");
264
+ factories.push(privacyExtension());
265
+ }
191
266
  factories.push(makeAccountProvider()); // must follow pi-privacy — see header
192
267
 
268
+ if (caps.context) {
269
+ const { default: privateerContext } = await import("../../extensions/privateer-context.ts");
270
+ factories.push(privateerContext);
271
+ }
272
+
273
+ if (caps.spawnSkills) {
274
+ const { default: privateerSpawnSkills } = await import("../../extensions/privateer-spawn-skills.ts");
275
+ factories.push(privateerSpawnSkills);
276
+ }
277
+
193
278
  if (opts.relayFiles) {
194
279
  const { makeRelayFileTools } = await import("../tools/relayFileTools.ts");
195
280
  factories.push(makeRelayFileTools(opts.relayFiles.bridge, opts.relayFiles.attachments));
@@ -200,9 +285,16 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
200
285
  factories.push(makeAttachResultTools(opts.resultMedia));
201
286
  }
202
287
 
203
- if (caps.web && webEnabled()) {
288
+ if (caps.web === "account" && webEnabled()) {
204
289
  const { makeWebTools } = await import("../tools/web.ts");
205
290
  factories.push(makeWebTools());
291
+ } else if (caps.web === "guarded") {
292
+ if (!opts.webHint) throw new Error(`buildMoat: kind "${opts.kind}" needs a webHint for guarded web tools`);
293
+ const hint = opts.webHint;
294
+ const { guardedWebToolDefinitions } = await import("../tools/web.ts");
295
+ factories.push((pi: any) => {
296
+ for (const def of guardedWebToolDefinitions(hint)) pi.registerTool?.(def);
297
+ });
206
298
  }
207
299
 
208
300
  if (caps.media && mediaEnabled()) {
@@ -222,7 +314,8 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
222
314
  // (inside createAgentSessionServices), not when it is imported here, so the env has to
223
315
  // be set around session creation rather than around this call.
224
316
  const mcpAdapterSpec = "pi-mcp-adapter";
225
- const { default: mcpAdapter } = await import(mcpAdapterSpec);
317
+ const hostImport = opts.hostImport ?? ((spec: string) => import(spec));
318
+ const { default: mcpAdapter } = await hostImport(mcpAdapterSpec);
226
319
  factories.push(mcpAdapter);
227
320
  }
228
321
 
@@ -651,6 +651,16 @@ export class Harbor {
651
651
  }
652
652
 
653
653
  private async tick(): Promise<void> {
654
+ // Retry the relay every tick, not just at startup. syncRelay() bails when the
655
+ // account isn't signed in on this machine — and a harbor that came up in that
656
+ // state used to stay off the relay FOREVER, because start() was the only caller
657
+ // that mattered (the three IPC commands that also call it are ones the desktop
658
+ // app never sends). Signing in afterwards wrote credentials.json and changed
659
+ // nothing: the harbor kept answering IPC, so the app called it running, while
660
+ // its relay socket had never been opened — the permanent "Connecting" with a
661
+ // harbor that fires no routine and answers no spawn. Cheap and idempotent: it
662
+ // returns immediately once a client exists, or while remote access is off.
663
+ this.syncRelay();
654
664
  void this.flushPendingCloud();
655
665
  const now = Date.now();
656
666
  for (const r of loadRoutines()) {
@@ -1269,19 +1279,35 @@ export class Harbor {
1269
1279
  private relayStatus(): RelayStatus {
1270
1280
  const termId = routineRelayId();
1271
1281
  if (this.relayTerminated) {
1272
- return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1282
+ return { termId, connected: false, reason: "terminated", detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1273
1283
  }
1274
1284
  if (!this.relay) {
1285
+ // "relay not started" with credentials present is now a sub-tick window, not a
1286
+ // permanent state: tick() re-runs syncRelay(), so a harbor that came up signed
1287
+ // out connects on its own once you sign in. Reported as "connecting" because
1288
+ // that is what it now is.
1289
+ const signedIn = hasCredentials();
1275
1290
  return {
1276
1291
  termId,
1277
1292
  connected: false,
1278
- detail: hasCredentials()
1293
+ reason: signedIn ? "connecting" : "signed-out",
1294
+ detail: signedIn
1279
1295
  ? "relay not started"
1280
- : "no account signed in on this machine — run `privateer` and /login, then restart the harbor",
1296
+ : "no account signed in on this machine — run `privateer` and /login",
1281
1297
  };
1282
1298
  }
1283
1299
  const conn = this.relay.connectionStatus();
1284
- if (!conn.connected) return { termId, connected: false, detail: "connecting…" };
1300
+ // The client knows why it isn't up — a refused ticket, an unreachable server —
1301
+ // and reporting a flat "connecting…" over the top of that is what made a harbor
1302
+ // that will never connect look like one that is about to.
1303
+ if (!conn.connected) {
1304
+ return {
1305
+ termId,
1306
+ connected: false,
1307
+ reason: conn.refused ? "refused" : "connecting",
1308
+ detail: conn.detail ?? "connecting…",
1309
+ };
1310
+ }
1285
1311
  return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
1286
1312
  }
1287
1313
 
package/src/harbor/ipc.ts CHANGED
@@ -42,6 +42,21 @@ export type IpcRequest =
42
42
  | { cmd: "run-now"; idOrName: string }
43
43
  | { cmd: "reload" };
44
44
 
45
+ /**
46
+ * Why a harbor isn't on the relay, as a code rather than a sentence.
47
+ *
48
+ * `detail` below is written for a terminal — it names CLI commands and is English
49
+ * only — so the app can't put it in front of a user. It used to have nothing else
50
+ * to go on, and said "give it a moment" about every one of these, including the
51
+ * three that never clear on their own. This is the machine-readable half.
52
+ *
53
+ * terminated remote access was switched off from the app; needs a restart
54
+ * signed-out no account credentials on this machine
55
+ * refused the server said no (plan's agent cap, rejected ticket) — standing
56
+ * connecting genuinely still trying; this one really is worth a moment
57
+ */
58
+ export type RelayReason = "terminated" | "signed-out" | "refused" | "connecting";
59
+
45
60
  /**
46
61
  * The harbor's view of its own relay connection, reported by `status`.
47
62
  *
@@ -62,6 +77,8 @@ export interface RelayStatus {
62
77
  quietSec?: number;
63
78
  /** Why it isn't connected, when we know: signed out, turned off from the app, … */
64
79
  detail?: string;
80
+ /** The same fact as `detail`, for a caller that has to localize it. */
81
+ reason?: RelayReason;
65
82
  }
66
83
 
67
84
  export interface IpcResponse {
@@ -375,6 +375,47 @@ export const MCP_CATALOG: CatalogEntry[] = [
375
375
  hosted: false,
376
376
  docsUrl: "https://dev.epicgames.com/documentation/unreal-engine/unreal-mcp-in-unreal-editor",
377
377
  },
378
+ {
379
+ // Godot ships no MCP server of its own. This is the "Godot MCP Native" editor
380
+ // plugin (MIT, Godot Asset Library) — chosen because it serves streamable HTTP
381
+ // from INSIDE the editor process, which is the same shape as the Unreal entry
382
+ // above and needs nothing installed on the machine outside Godot itself.
383
+ //
384
+ // Deliberately NOT one of the npx-launched Godot servers: those shell out to the
385
+ // `godot` BINARY headlessly (a second, non-interactive copy of the project rather
386
+ // than the editor the user is looking at), and every one of them is configured by
387
+ // a GODOT_PATH env var — a filesystem path, which is neither a token nor a
388
+ // placeholder arg, so this catalog has no `needs` that could ask for it honestly.
389
+ //
390
+ // Two things differ from Unreal, and they are the only two:
391
+ // 1. THE PLUGIN IS NOT PART OF THE ENGINE. Install "Godot MCP Native" from the
392
+ // Asset Library and enable it in Project → Project Settings → Plugins. Until
393
+ // that is done the port is simply closed, which looks identical to "Godot is
394
+ // not running" — so the setup guide below matters more here than it does for
395
+ // Unreal, where the plugin ships with 5.8.
396
+ // 2. ITS BIND ADDRESS IS NOT DOCUMENTED UPSTREAM. Unreal's is (loopback, plus an
397
+ // Origin check); this one's is not, so the honest claim is only that WE dial
398
+ // 127.0.0.1 — never that the editor cannot be reached from the LAN. Its own
399
+ // `auth_enabled` is off by default (user://mcp_settings.cfg), which is the
400
+ // knob to turn on when the machine sits on a network you don't trust.
401
+ //
402
+ // Everything else matches Unreal: nothing to authorize on the wire we use (hence
403
+ // localHttp + auth:"none"), it answers only while the editor is open, and the port
404
+ // is editable (`http_port`) — hence needs:"url", confirm the endpoint.
405
+ id: "godot",
406
+ name: "godot",
407
+ label: "Godot Engine",
408
+ blurb: "Drive the Godot editor — scenes, nodes, scripts, resources.",
409
+ transport: "http",
410
+ url: "http://127.0.0.1:9080/mcp",
411
+ localHttp: true,
412
+ needs: "url",
413
+ // Same reason as Unreal: hostedCapable() derives its answer from `oauth`, so
414
+ // http-with-no-auth would otherwise read as hostable. An enclave cannot reach a
415
+ // loopback port on the user's desk.
416
+ hosted: false,
417
+ docsUrl: "https://github.com/yurineko73/Godot-MCP-Native",
418
+ },
378
419
  ];
379
420
 
380
421
  export function catalogEntry(id: string): CatalogEntry | undefined {
@@ -1,39 +1,63 @@
1
- # Vendored: `@dstack/aci-verifier`
1
+ # Vendored: the ACI verifier
2
2
 
3
- Faithful copy of the zero-dependency TypeScript ACI verifier from
3
+ Copy of the zero-dependency TypeScript ACI verifier from
4
4
  [Dstack-TEE/private-ai-gateway](https://github.com/Dstack-TEE/private-ai-gateway)
5
5
  (`clients/verifier-ts/src`), Apache-2.0. It is `private: true` upstream (not on
6
6
  npm), so it is vendored here rather than installed.
7
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:
8
+ Current drop: commit `1a044e960fbec8ab20f38524bc93aa0ced83d5b0` — the `aci/1`
9
+ protocol, which is what `inference.phala.com` actually serves as of 2026-08-24.
10
+
11
+ Provides the pieces the Phala sealed transport needs:
12
+ - **`verifyReportBinding`** (`report.ts`) — §9.1 checks 2–3: the served keyset
13
+ canonicalizes to the digest that the attestation statement for our nonce hashes
14
+ into `report_data`, and the keyset has not expired. NOT the hardware TDX quote
15
+ (check 1) — that is layered on with `@phala/dcap-qvl` in `../../phalaSeal.ts`.
16
+ - **`openE2eeChannel`** (`e2ee-channel.ts`) — the E2EE v2 channel:
13
17
  `x25519-aes-256-gcm-hkdf-sha256`, per-field seal/open, `X-E2EE-*` headers.
14
18
 
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.
19
+ ## Protocol note: what `aci/1` changed
20
+ The previous drop verified a *keyset endorsement*: the keyset carried a
21
+ `workload_identity` key, that key signed the keyset digest, and `workload_id` was
22
+ the digest of the identity key. `aci/1` removes all three. The keyset is now bound
23
+ straight into `report_data` — the statement is
24
+ `{"keyset_digest":…,"nonce":…,"purpose":"aci.report_data.v1"}` — so the hardware
25
+ quote is the only signature over it, and per-key custody (`evidence.key_custody`,
26
+ the dstack-KMS chain) is explicitly policy/caller territory (§9.1 checks 5–6),
27
+ which we do not check.
28
+
29
+ Practical consequence: a client written against the old shape does not degrade,
30
+ it *crashes* — `workload_keyset.workload_identity` is simply absent. That is what
31
+ broke sealed `phala/*` turns with `sealed shim: Cannot read properties of
32
+ undefined (reading 'public_key')` before this drop.
33
+
34
+ ## Local adaptations (the only changes from upstream)
35
+ 1. Relative import specifiers had their `.js` extension stripped (`'./jcs.js'` →
36
+ `'./jcs'`) so Metro + TS (`moduleResolution: bundler`) resolve to the `.ts` files.
37
+ 2. `jcs.ts` is kept as its own module. Upstream folded JCS into `crypto.ts` as a
38
+ sort-and-`JSON.stringify` helper; ours is the stricter RFC 8785 implementation
39
+ from the earlier drop (it *rejects* non-integer numbers rather than
40
+ mis-serializing them), and the E2EE AAD builders depend on it. `digest.ts`,
41
+ `receipt.ts`, and `session.ts` therefore import `jcsBytes` from `./jcs`
42
+ instead of `./crypto`.
43
+ 3. `report.ts` carries only `verifyReportBinding`. Upstream's `verifyQuote` and
44
+ `verifyComposeMeasurement` are omitted: `../../phalaSeal.ts` owns the quote (it
45
+ also gates TCB status and pins the measurements) and `../measurements.ts` owns
46
+ the event-log replay across all four RTMRs, not just RTMR3. Omitting them keeps
47
+ this tree dependency-free and `@phala/dcap-qvl` off every startup's import path.
48
+ 4. `e2ee.ts` and `e2ee-channel.ts` are carried forward from the earlier drop.
49
+ Upstream moved E2EE out of the verifier package ("specified by §6 but not
50
+ constructed by this verifier"); the wire format itself is unchanged and still
51
+ specified in `spec/e2ee-v2.md`, and the gateway still advertises
52
+ `supported_e2ee_versions: ["2"]`.
53
+ 5. `transcript.ts` (upstream's one-call `verifyService` + verdict rendering) is not
54
+ vendored — `phalaSeal.ts` composes its own verdict and enclave identity.
18
55
 
19
56
  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.
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.
57
+ (Web Crypto: X25519, HKDF, AES-GCM, Ed25519, SHA-384, `getRandomValues`). In
58
+ privateer-agent (Node ≥ 22) these are all native — **no polyfills needed** (unlike
59
+ the treeview RN app, which bridges them via `react-native-quick-crypto`).
60
+
61
+ Re-pull recipe: copy `clients/verifier-ts/src/*.ts`, strip the `.js` extensions,
62
+ then re-apply adaptations 2–5. `tests/phalaReportBinding.test.ts` pins the binding
63
+ behaviour against a real report captured from the live gateway.
@@ -1,10 +1,11 @@
1
1
  /**
2
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.
3
+ * the same code runs in browsers and in Node 20+ with no dependencies. ACI's
4
+ * only signature algorithm is Ed25519 and its only hash is SHA-256 (spec
5
+ * Appendix B) — both are in Web Crypto, so nothing needs injecting.
5
6
  */
6
7
 
7
- import { AciFormatError, UnsupportedAlgorithmError } from './errors';
8
+ import { AciFormatError } from './errors';
8
9
 
9
10
  const subtle = globalThis.crypto.subtle;
10
11
 
@@ -23,20 +24,45 @@ export function fromHex(hex: string): Uint8Array {
23
24
  }
24
25
  const out = new Uint8Array(h.length / 2);
25
26
  for (let i = 0; i < out.length; i++) {
26
- const byte = Number.parseInt(h.substr(i * 2, 2), 16);
27
+ const byte = Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
27
28
  if (Number.isNaN(byte)) {
28
- throw new AciFormatError(`invalid hex at offset ${i * 2}: "${h.substr(i * 2, 2)}"`);
29
+ throw new AciFormatError(`invalid hex at offset ${i * 2}: "${h.slice(i * 2, i * 2 + 2)}"`);
29
30
  }
30
31
  out[i] = byte;
31
32
  }
32
33
  return out;
33
34
  }
34
35
 
36
+ /** Encode bytes as standard base64 (RFC 4648 §4, with padding) — the `_b64` field form (Appendix A). */
37
+ export function toBase64(bytes: Uint8Array): string {
38
+ let bin = '';
39
+ for (const b of bytes) bin += String.fromCharCode(b);
40
+ return btoa(bin);
41
+ }
42
+
43
+ /** Decode standard base64 to the exact underlying bytes. */
44
+ export function fromBase64(b64: string): Uint8Array {
45
+ let bin: string;
46
+ try {
47
+ bin = atob(b64);
48
+ } catch {
49
+ throw new AciFormatError('invalid base64');
50
+ }
51
+ const out = new Uint8Array(bin.length);
52
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
53
+ return out;
54
+ }
55
+
35
56
  /** SHA-256 of the given bytes. */
36
57
  export async function sha256(bytes: Uint8Array): Promise<Uint8Array> {
37
58
  return new Uint8Array(await subtle.digest('SHA-256', bytes as BufferSource));
38
59
  }
39
60
 
61
+ /** SHA-384 of the given bytes — the dstack RTMR replay hash (§9.1 policy). */
62
+ export async function sha384(bytes: Uint8Array): Promise<Uint8Array> {
63
+ return new Uint8Array(await subtle.digest('SHA-384', bytes as BufferSource));
64
+ }
65
+
40
66
  /** Lowercase-hex SHA-256 of the given bytes. */
41
67
  export async function sha256Hex(bytes: Uint8Array): Promise<string> {
42
68
  return toHex(await sha256(bytes));
@@ -44,16 +70,16 @@ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
44
70
 
45
71
  /**
46
72
  * `sha256:<lowercase-hex>` digest string of the given bytes — the ACI digest
47
- * form (§3) used for `workload_id`, keyset digests, and body hashes.
73
+ * form (Appendix A) used for keyset digests, body hashes, and session ids.
48
74
  */
49
75
  export async function sha256Prefixed(bytes: Uint8Array): Promise<string> {
50
76
  return 'sha256:' + (await sha256Hex(bytes));
51
77
  }
52
78
 
53
79
  /**
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.
80
+ * Verify an Ed25519 signature (RFC 8032) over `message`. `publicKeyRaw` is the
81
+ * 32-byte raw key; `signature` the 64-byte value. Returns false on a bad
82
+ * signature or malformed key — never throws for those.
57
83
  */
58
84
  export async function verifyEd25519(
59
85
  publicKeyRaw: Uint8Array,
@@ -75,21 +101,3 @@ export async function verifyEd25519(
75
101
  return false;
76
102
  }
77
103
  }
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
- }