privateer-agent 0.7.0 → 0.8.2
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/README.md +27 -11
- package/bin/privateer-launch.mjs +1 -0
- package/extensions/privateer-gate.ts +97 -5
- package/extensions/privateer-privacy.ts +21 -0
- package/package.json +4 -2
- package/src/channels/run.ts +11 -2
- package/src/cli/chat.ts +65 -5
- package/src/harbor/index.ts +50 -9
- package/src/harbor/ipc.ts +82 -34
- package/src/mcp/catalog.ts +162 -0
- package/src/permissions/noQuarter.ts +42 -0
- package/src/providers/account.ts +60 -12
- package/src/providers/catalog.ts +8 -6
- package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
- package/src/providers/phala/aci-verifier/crypto.ts +95 -0
- package/src/providers/phala/aci-verifier/digest.ts +116 -0
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
- package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
- package/src/providers/phala/aci-verifier/errors.ts +41 -0
- package/src/providers/phala/aci-verifier/index.ts +87 -0
- package/src/providers/phala/aci-verifier/jcs.ts +69 -0
- package/src/providers/phala/aci-verifier/receipt.ts +139 -0
- package/src/providers/phala/aci-verifier/report.ts +126 -0
- package/src/providers/phala/aci-verifier/types.ts +139 -0
- package/src/providers/phala/sse.ts +43 -0
- package/src/providers/phala/webcrypto-globals.d.ts +17 -0
- package/src/providers/phalaSeal.ts +200 -0
- package/src/providers/sealedShim.ts +295 -0
- package/src/remote/liveTaskSession.ts +20 -3
- package/src/remote/relayClient.ts +63 -1
- package/src/ui/palette.ts +3 -0
package/src/harbor/ipc.ts
CHANGED
|
@@ -33,44 +33,82 @@ export interface IpcResponse {
|
|
|
33
33
|
|
|
34
34
|
export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
|
|
36
|
+
// Probe whether a LIVE process is listening on the socket at `path`. Used as the
|
|
37
|
+
// single-instance test: a successful connect (or a slow-to-answer one) means a real
|
|
38
|
+
// harbor holds the lock; ECONNREFUSED/ENOENT means the socket file is stale (no
|
|
39
|
+
// listener behind it) and is safe to reclaim. Conservative — any ambiguous error
|
|
40
|
+
// resolves `true` so we never steal a path that might still be owned.
|
|
41
|
+
function probeExistingListener(path: string, timeoutMs = 1000): Promise<boolean> {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const sock = createConnection(path);
|
|
44
|
+
const done = (live: boolean) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
try { sock.destroy(); } catch { /* already gone */ }
|
|
47
|
+
resolve(live);
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => done(true), timeoutMs); // slow to answer ⇒ assume live
|
|
50
|
+
sock.on("connect", () => done(true));
|
|
51
|
+
sock.on("error", (err: NodeJS.ErrnoException) => {
|
|
52
|
+
done(!(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Start the harbor-side socket server. Resolves with the Server (so the caller can
|
|
58
|
+
// close it), or REJECTS with HarborAlreadyRunningError if a live harbor already owns
|
|
59
|
+
// the socket — the bind is the machine's single-instance lock. Two harbors under one
|
|
60
|
+
// ~/.privateer share a single routineRelayId(), so a second instance would collide on
|
|
61
|
+
// the relay and double-fire routines; refusing to start is the fix. A stale socket
|
|
62
|
+
// file (crash with no live listener) is detected and reclaimed, so recovery still works.
|
|
63
|
+
export function startIpcServer(handler: IpcHandler): Promise<Server> {
|
|
38
64
|
const path = harborSocketPath();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
const build = (): Server =>
|
|
66
|
+
createServer((sock: Socket) => {
|
|
67
|
+
let buf = "";
|
|
68
|
+
sock.on("data", (chunk) => {
|
|
69
|
+
buf += chunk.toString("utf8");
|
|
70
|
+
const nl = buf.indexOf("\n");
|
|
71
|
+
if (nl < 0) return; // wait for the full line
|
|
72
|
+
const line = buf.slice(0, nl);
|
|
73
|
+
void (async () => {
|
|
74
|
+
let res: IpcResponse;
|
|
75
|
+
try {
|
|
76
|
+
res = await handler(JSON.parse(line) as IpcRequest);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
res = { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
79
|
+
}
|
|
80
|
+
sock.end(JSON.stringify(res) + "\n");
|
|
81
|
+
})();
|
|
82
|
+
});
|
|
83
|
+
sock.on("error", () => sock.destroy());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return new Promise<Server>((resolve, reject) => {
|
|
87
|
+
// `reclaimed` guards a single stale-socket reclaim so a persistent bind failure
|
|
88
|
+
// can't loop. On EADDRINUSE we probe for a live listener rather than unlinking
|
|
89
|
+
// blindly (the old behavior, which let a second harbor silently steal the path).
|
|
90
|
+
const attempt = (reclaimed: boolean) => {
|
|
91
|
+
const server = build();
|
|
92
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
93
|
+
if (err.code !== "EADDRINUSE") { reject(err); return; }
|
|
94
|
+
void probeExistingListener(path).then((live) => {
|
|
95
|
+
if (live) { reject(new HarborAlreadyRunningError()); return; }
|
|
96
|
+
if (reclaimed) { reject(err); return; } // already reclaimed once — give up
|
|
97
|
+
try { unlinkSync(path); } catch { /* ignore — retry surfaces a clearer error */ }
|
|
98
|
+
attempt(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
server.listen(path, () => {
|
|
56
102
|
try {
|
|
57
|
-
|
|
58
|
-
} catch
|
|
59
|
-
|
|
103
|
+
chmodSync(path, 0o600); // owner-only IPC endpoint
|
|
104
|
+
} catch {
|
|
105
|
+
/* non-POSIX — best effort */
|
|
60
106
|
}
|
|
61
|
-
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
});
|
|
66
|
-
server.listen(path, () => {
|
|
67
|
-
try {
|
|
68
|
-
chmodSync(path, 0o600); // owner-only IPC endpoint
|
|
69
|
-
} catch {
|
|
70
|
-
/* non-POSIX — best effort */
|
|
71
|
-
}
|
|
107
|
+
resolve(server);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
attempt(false);
|
|
72
111
|
});
|
|
73
|
-
return server;
|
|
74
112
|
}
|
|
75
113
|
|
|
76
114
|
// Client side: send one request, resolve with the response. Rejects if the harbor
|
|
@@ -116,6 +154,16 @@ export class HarborNotRunningError extends Error {
|
|
|
116
154
|
}
|
|
117
155
|
}
|
|
118
156
|
|
|
157
|
+
// Thrown by startIpcServer when a live harbor already holds this machine's socket —
|
|
158
|
+
// i.e. a second instance is trying to start under the same ~/.privateer. The caller
|
|
159
|
+
// (runHarbor) treats this as a clean no-op exit, not a crash.
|
|
160
|
+
export class HarborAlreadyRunningError extends Error {
|
|
161
|
+
constructor() {
|
|
162
|
+
super("A Harbor is already running on this machine.");
|
|
163
|
+
this.name = "HarborAlreadyRunningError";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
119
167
|
// Convenience: is the harbor reachable right now?
|
|
120
168
|
export async function harborIsRunning(): Promise<boolean> {
|
|
121
169
|
try {
|
package/src/mcp/catalog.ts
CHANGED
|
@@ -130,6 +130,168 @@ 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
|
+
// ── Google Workspace (needs a Google Cloud OAuth client, then browser sign-in) ─
|
|
230
|
+
// One server (workspace-mcp) scoped per service via --tools. Runs on uv (uvx),
|
|
231
|
+
// not npx. Create an OAuth client in Google Cloud once and paste its ID + secret;
|
|
232
|
+
// the first request opens Google's consent screen on THIS machine.
|
|
233
|
+
{
|
|
234
|
+
id: "gmail",
|
|
235
|
+
name: "gmail",
|
|
236
|
+
label: "Gmail",
|
|
237
|
+
blurb: "Read, search, and send email. Google sign-in.",
|
|
238
|
+
transport: "stdio",
|
|
239
|
+
command: "uvx",
|
|
240
|
+
args: ["workspace-mcp", "--tools", "gmail"],
|
|
241
|
+
env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
|
|
242
|
+
needs: "token",
|
|
243
|
+
fill: "GOOGLE_OAUTH_CLIENT_ID",
|
|
244
|
+
credUrl: "https://console.cloud.google.com/apis/credentials",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "google-calendar",
|
|
248
|
+
name: "google-calendar",
|
|
249
|
+
label: "Google Calendar",
|
|
250
|
+
blurb: "Events and scheduling. Google sign-in.",
|
|
251
|
+
transport: "stdio",
|
|
252
|
+
command: "uvx",
|
|
253
|
+
args: ["workspace-mcp", "--tools", "calendar"],
|
|
254
|
+
env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
|
|
255
|
+
needs: "token",
|
|
256
|
+
fill: "GOOGLE_OAUTH_CLIENT_ID",
|
|
257
|
+
credUrl: "https://console.cloud.google.com/apis/credentials",
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "google-drive",
|
|
261
|
+
name: "google-drive",
|
|
262
|
+
label: "Google Drive",
|
|
263
|
+
blurb: "Files and folders in your Drive. Google sign-in.",
|
|
264
|
+
transport: "stdio",
|
|
265
|
+
command: "uvx",
|
|
266
|
+
args: ["workspace-mcp", "--tools", "drive"],
|
|
267
|
+
env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
|
|
268
|
+
needs: "token",
|
|
269
|
+
fill: "GOOGLE_OAUTH_CLIENT_ID",
|
|
270
|
+
credUrl: "https://console.cloud.google.com/apis/credentials",
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: "google-docs",
|
|
274
|
+
name: "google-docs",
|
|
275
|
+
label: "Google Docs",
|
|
276
|
+
blurb: "Read and edit your documents. Google sign-in.",
|
|
277
|
+
transport: "stdio",
|
|
278
|
+
command: "uvx",
|
|
279
|
+
args: ["workspace-mcp", "--tools", "docs"],
|
|
280
|
+
env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
|
|
281
|
+
needs: "token",
|
|
282
|
+
fill: "GOOGLE_OAUTH_CLIENT_ID",
|
|
283
|
+
credUrl: "https://console.cloud.google.com/apis/credentials",
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: "sequential-thinking",
|
|
287
|
+
name: "sequential-thinking",
|
|
288
|
+
label: "Sequential Thinking",
|
|
289
|
+
blurb: "A step-by-step reasoning scratchpad.",
|
|
290
|
+
transport: "stdio",
|
|
291
|
+
command: "npx",
|
|
292
|
+
args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
|
293
|
+
needs: "none",
|
|
294
|
+
},
|
|
133
295
|
];
|
|
134
296
|
|
|
135
297
|
export function catalogEntry(id: string): CatalogEntry | undefined {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// No-quarter: the moat fully lowered for a session — every action auto-approves
|
|
2
|
+
// with no prompt (dangerous shell, destructive tools, out-of-cwd, protected files).
|
|
3
|
+
// This is the single source of truth for that state; the gate reads it through
|
|
4
|
+
// ModeGate.getSkipAllPermissions, which sits above every other policy check.
|
|
5
|
+
//
|
|
6
|
+
// Two ways in, one state:
|
|
7
|
+
// 1. `privateer --no-quarter` at launch → PRIVATEER_NO_QUARTER=1 (see
|
|
8
|
+
// bin/privateer-launch.mjs), which seeds `active` below.
|
|
9
|
+
// 2. shift+tab in a live session → toggleNoQuarter(). This is the "step away from
|
|
10
|
+
// the keyboard" switch: flip it on and the agent runs to completion instead of
|
|
11
|
+
// stopping on the next approval prompt.
|
|
12
|
+
//
|
|
13
|
+
// Toggling MIRRORS the env var, because that's how the state reaches subagents: a
|
|
14
|
+
// pi-subagents child is a `pi` subprocess that inherits this process's env and reads
|
|
15
|
+
// PRIVATEER_NO_QUARTER in its own gate. Children spawned after a toggle therefore
|
|
16
|
+
// match the parent; ones already running keep the posture they started with.
|
|
17
|
+
//
|
|
18
|
+
// IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere,
|
|
19
|
+
// including boot-ordered entrypoints (see boot.ts's ORDERING CONTRACT).
|
|
20
|
+
|
|
21
|
+
const ENV = "PRIVATEER_NO_QUARTER";
|
|
22
|
+
|
|
23
|
+
// Seeded from the launch flag so `--no-quarter` and the toggle share one state.
|
|
24
|
+
let active = process.env[ENV] === "1";
|
|
25
|
+
|
|
26
|
+
/** True while the gate is fully lowered for this session. */
|
|
27
|
+
export function noQuarterActive(): boolean {
|
|
28
|
+
return active;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Set the state (and mirror it to the env for future subagent children). Returns the new state. */
|
|
32
|
+
export function setNoQuarter(on: boolean): boolean {
|
|
33
|
+
active = on;
|
|
34
|
+
if (on) process.env[ENV] = "1";
|
|
35
|
+
else delete process.env[ENV];
|
|
36
|
+
return active;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Flip the state. Returns the new state. */
|
|
40
|
+
export function toggleNoQuarter(): boolean {
|
|
41
|
+
return setNoQuarter(!active);
|
|
42
|
+
}
|
package/src/providers/account.ts
CHANGED
|
@@ -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
|
|
@@ -33,8 +40,9 @@ import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel }
|
|
|
33
40
|
const DEFAULT_MODELS = [
|
|
34
41
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
35
42
|
ACCOUNT_NEAR_MODEL_ID,
|
|
36
|
-
"anthropic/claude-
|
|
37
|
-
"
|
|
43
|
+
"anthropic/claude-opus-5",
|
|
44
|
+
"anthropic/claude-sonnet-5",
|
|
45
|
+
"openai/gpt-5.6-sol",
|
|
38
46
|
"deepseek/deepseek-v4-flash",
|
|
39
47
|
];
|
|
40
48
|
|
|
@@ -73,7 +81,9 @@ const VALID_TIERS = new Set<PrivacyTier>([
|
|
|
73
81
|
// upgrades it to tee-verified live via attestation (accountPosture). Everything
|
|
74
82
|
// else with no server signal is "standard": we don't assert ZDR we can't back.
|
|
75
83
|
function tierFromPrefix(modelId: string): PrivacyTier {
|
|
76
|
-
return modelId.startsWith("near/") || modelId.startsWith("tinfoil/")
|
|
84
|
+
return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") || modelId.startsWith("phala/")
|
|
85
|
+
? "tee-unverified"
|
|
86
|
+
: "standard";
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
function normalizeTier(tier: string | undefined, modelId: string): PrivacyTier {
|
|
@@ -248,13 +258,26 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
248
258
|
if (privateerChannel(modelId) === "zdr") {
|
|
249
259
|
return { tier: "zdr-policy" };
|
|
250
260
|
}
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// that
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
261
|
+
// Sealed (EHBP) path. When sealed mode is on and the model has a Node sealed
|
|
262
|
+
// client (tinfoil/*), inference goes through the blind relay with the body
|
|
263
|
+
// HPKE-sealed to the enclave, and we attest that enclave client-side with the SAME
|
|
264
|
+
// SecureClient that carries the tokens. A green ready() is a quote WE checked,
|
|
265
|
+
// bound to the HPKE key we seal to — so it earns tee-verified. A failure stays
|
|
266
|
+
// tee-unverified with the reason surfaced (never a silent green). See
|
|
267
|
+
// docs/tee-privateer-tinfoil-ehbp.md.
|
|
268
|
+
const sealedProvider = sealedEnabled() ? sealedProviderFor(modelId) : null;
|
|
269
|
+
if (sealedProvider) {
|
|
270
|
+
const att = await attestSealed(sealedProvider);
|
|
271
|
+
return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
|
|
272
|
+
}
|
|
273
|
+
// Honest labelling for the non-NEAR enclaves without sealed mode. Tinfoil and Phala
|
|
274
|
+
// publish real attestations, but the server proxies the inference in cleartext, so
|
|
275
|
+
// from here we cannot bind a quote to the connection actually carrying our tokens —
|
|
276
|
+
// only the account's word that it did. That's `tee-unverified` (yellow "confidential
|
|
277
|
+
// compute, unconfirmed"), never the green tee-verified we reserve for a quote we
|
|
278
|
+
// checked ourselves. Turn on sealed mode (PRIVATEER_SEALED=1) for the verified
|
|
279
|
+
// shield, or set TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests
|
|
280
|
+
// client-side over the TLS binding).
|
|
258
281
|
if (!modelId.startsWith("near/")) {
|
|
259
282
|
return { tier: "tee-unverified" };
|
|
260
283
|
}
|
|
@@ -299,15 +322,40 @@ export function makeAccountProvider() {
|
|
|
299
322
|
on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
|
|
300
323
|
}): void => {
|
|
301
324
|
if (typeof pi.registerProvider !== "function") return;
|
|
302
|
-
|
|
325
|
+
// A model entry, with a per-model baseUrl override for sealed models once the
|
|
326
|
+
// EHBP shim is listening: `tinfoil/*` then route through the loopback shim (which
|
|
327
|
+
// seals to the blind relay) instead of the cleartext `/api/agent/v1` proxy.
|
|
328
|
+
// Everything else keeps the provider baseUrl below. Until the shim is up (or when
|
|
329
|
+
// sealed mode is off) sealed models fall back to the cleartext path — and the
|
|
330
|
+
// badge stays honestly `tee-unverified` (see accountPosture).
|
|
331
|
+
const modelEntry = (id: string) => {
|
|
332
|
+
const base = seedModel(id);
|
|
333
|
+
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
334
|
+
const shim = sealedShimBase();
|
|
335
|
+
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
336
|
+
};
|
|
337
|
+
let lastIds: string[] = DEFAULT_MODELS;
|
|
338
|
+
const register = (ids: string[]): void => {
|
|
339
|
+
lastIds = ids;
|
|
303
340
|
pi.registerProvider!("privateer", {
|
|
304
341
|
name: "Privateer account",
|
|
305
342
|
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
306
343
|
api: "openai-completions",
|
|
307
344
|
oauth: privateerOAuthProvider,
|
|
308
|
-
models: ids.map(
|
|
345
|
+
models: ids.map(modelEntry),
|
|
309
346
|
});
|
|
347
|
+
};
|
|
310
348
|
register(DEFAULT_MODELS); // immediate: provider exists this tick
|
|
349
|
+
// Bring up the sealed shim, then re-register so sealed models pick up their shim
|
|
350
|
+
// baseUrl. Registration re-runs anyway after the catalog fetch; this just makes
|
|
351
|
+
// sure the switch lands even if the fetch is slow or fails.
|
|
352
|
+
if (sealedEnabled()) {
|
|
353
|
+
void ensureSealedShim()
|
|
354
|
+
.then(() => register(lastIds))
|
|
355
|
+
.catch(() => {
|
|
356
|
+
/* shim failed to start → sealed models stay on the cleartext path */
|
|
357
|
+
});
|
|
358
|
+
}
|
|
311
359
|
// Refine to the live catalog. fetchAccountCatalog also populates accountTierMap
|
|
312
360
|
// as a side effect, so the /models picker can shield each row without re-fetching.
|
|
313
361
|
void fetchAccountCatalog()
|
package/src/providers/catalog.ts
CHANGED
|
@@ -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
|
-
// venice, ollama,
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
69
|
+
// The in-app account OAuth channel. NOTE: since pi-privacy 0.7 the extension ALSO
|
|
70
|
+
// registers a `privateer` provider (its posture-aware public sk-priv- dev-key surface,
|
|
71
|
+
// floored to zdr-policy) — but makeAccountProvider runs AFTER pi-privacy in the
|
|
72
|
+
// extension list and re-registers the same id, so the account channel wins. There is
|
|
73
|
+
// no longer a separate `privateer-api` id (renamed to `privateer` upstream).
|
|
72
74
|
{ id: "privateer", source: "account" },
|
|
73
75
|
];
|
|
74
76
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Vendored: `@dstack/aci-verifier`
|
|
2
|
+
|
|
3
|
+
Faithful copy of the zero-dependency TypeScript ACI verifier from
|
|
4
|
+
[Dstack-TEE/private-ai-gateway](https://github.com/Dstack-TEE/private-ai-gateway)
|
|
5
|
+
(`clients/verifier-ts/src`), Apache-2.0. It is `private: true` upstream (not on
|
|
6
|
+
npm), so it is vendored here rather than installed.
|
|
7
|
+
|
|
8
|
+
Provides the pieces `PhalaProvider` needs:
|
|
9
|
+
- **`verifyReportBinding`** (`report.ts`) — §10.1 checks 2–6 (crypto binding of the
|
|
10
|
+
attestation report to the attested keyset for a supplied nonce). NOT the hardware
|
|
11
|
+
TDX quote (check 1) — that is layered on with `@phala/dcap-qvl` in the provider.
|
|
12
|
+
- **`openE2eeChannel`** (`e2ee-channel.ts`) — the ACI E2EE channel:
|
|
13
|
+
`x25519-aes-256-gcm-hkdf-sha256`, per-field seal/open, `X-E2EE-*` headers.
|
|
14
|
+
|
|
15
|
+
## Local adaptation (the only change from upstream)
|
|
16
|
+
- Relative import specifiers had their `.js` extension stripped (`'./jcs.js'` →
|
|
17
|
+
`'./jcs'`) so Metro + TS (`moduleResolution: bundler`) resolve to the `.ts` files.
|
|
18
|
+
|
|
19
|
+
Everything else is byte-for-byte upstream. The crypto runs on `globalThis.crypto`
|
|
20
|
+
(Web Crypto: X25519, HKDF, AES-GCM, Ed25519, `getRandomValues`). In privateer-agent
|
|
21
|
+
(Node ≥ 22) these are all native — **no polyfills needed** (unlike the treeview RN app,
|
|
22
|
+
which bridges them via `react-native-quick-crypto`). Re-pull from upstream to update;
|
|
23
|
+
re-apply only the `.js`-extension strip.
|
|
@@ -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
|
+
}
|