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 +1 -1
- package/src/config/moat.ts +115 -22
- package/src/harbor/index.ts +30 -4
- package/src/harbor/ipc.ts +17 -0
- package/src/mcp/catalog.ts +41 -0
- package/src/providers/phala/aci-verifier/VENDORED.md +54 -30
- package/src/providers/phala/aci-verifier/crypto.ts +35 -27
- package/src/providers/phala/aci-verifier/digest.ts +31 -95
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +3 -1
- package/src/providers/phala/aci-verifier/errors.ts +2 -21
- package/src/providers/phala/aci-verifier/index.ts +29 -35
- package/src/providers/phala/aci-verifier/jcs.ts +8 -3
- package/src/providers/phala/aci-verifier/receipt.ts +96 -88
- package/src/providers/phala/aci-verifier/report.ts +62 -85
- package/src/providers/phala/aci-verifier/session.ts +40 -0
- package/src/providers/phala/aci-verifier/types.ts +90 -80
- package/src/providers/phalaSeal.ts +8 -9
- package/src/remote/relayClient.ts +24 -2
- package/src/providers/phala/reportBinding.ts +0 -193
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
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",
|
package/src/config/moat.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
7
|
-
* same privacy extension, same account provider, the same
|
|
8
|
-
* conditionals — copied with small deliberate differences
|
|
9
|
-
* comment explaining them. Nothing held them together, so
|
|
10
|
-
* silently didn't exist on the
|
|
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"
|
|
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:
|
|
82
|
-
* API precisely because an unattended run must not hold a search provider key, and
|
|
83
|
-
* those paths have a human at the other end who can use their own provider
|
|
84
|
-
* compose is unconditional everywhere: local ffmpeg work, no account,
|
|
85
|
-
* so a run with generation off can still assemble media that
|
|
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
|
-
|
|
88
|
-
|
|
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:
|
|
91
|
-
acp: { web:
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/harbor/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1296
|
+
: "no account signed in on this machine — run `privateer` and /login",
|
|
1281
1297
|
};
|
|
1282
1298
|
}
|
|
1283
1299
|
const conn = this.relay.connectionStatus();
|
|
1284
|
-
|
|
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 {
|
package/src/mcp/catalog.ts
CHANGED
|
@@ -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:
|
|
1
|
+
# Vendored: the ACI verifier
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **`
|
|
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
|
-
##
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
21
|
-
(Node ≥ 22) these are all native — **no polyfills needed** (unlike
|
|
22
|
-
which bridges them via `react-native-quick-crypto`).
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
}
|