privateer-agent 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,7 @@ What Privateer adds is a *moat* of Pi extensions layered on top:
73
73
  | `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
74
74
  | `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
75
75
  | `rpiv-web-tools` | private-by-default web search (self-hosted SearXNG, no WebView) |
76
+ | `rpiv-ask-user-question` | `ask_user_question` — a structured questionnaire the agent puts to you instead of guessing |
76
77
  | `pi-mcp-adapter`, `pi-subagents` | MCP servers · bounded parallel sub-agents |
77
78
 
78
79
  They're ordinary Pi extensions — inspect them, replace them, or build your own alongside.
@@ -509,6 +510,9 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
509
510
  [Workflows](#workflows).
510
511
  - **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
511
512
  (self-hosted SearXNG for fully private search).
513
+ - **Ask user question** (`rpiv-ask-user-question`) — when a request is underspecified the agent
514
+ raises a structured questionnaire (typed options, multi-select, markdown previews, or type your
515
+ own answer) instead of guessing. Ungated by design — it only asks you something.
512
516
 
513
517
  ## Command reference
514
518
 
@@ -162,7 +162,8 @@ else {
162
162
  "privateer-brand", "privateer-context", "privateer-gate", "privateer-account",
163
163
  "privateer-models", "privateer-posture", "privateer-tools", "privateer-privacy",
164
164
  "privateer-connect",
165
- "pi-privacy", "pi-web-access", "rpiv-web-tools", "pi-mcp-adapter", "pi-hypa", "pi-subagents",
165
+ "pi-privacy", "pi-web-access", "rpiv-web-tools", "rpiv-ask-user-question",
166
+ "pi-mcp-adapter", "pi-hypa", "pi-subagents",
166
167
  ];
167
168
  for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
168
169
 
@@ -189,6 +190,7 @@ else {
189
190
  shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
190
191
  shim("privateer-connect", ext("privateer-connect.ts")); // /connect — MCP connector manager
191
192
  shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
193
+ shim("rpiv-ask-user-question", dep("@juicesharp/rpiv-ask-user-question", "index.ts")); // ask_user_question
192
194
  shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
193
195
  shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
194
196
  shim("pi-subagents", dep("pi-subagents", "src", "extension", "index.ts"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,6 +63,7 @@
63
63
  "@earendil-works/pi-coding-agent": "0.80.3",
64
64
  "@earendil-works/pi-tui": "^0.80.3",
65
65
  "@hypabolic/pi-hypa": "^0.1.6",
66
+ "@juicesharp/rpiv-ask-user-question": "^2.2.0",
66
67
  "@juicesharp/rpiv-web-tools": "^1.20.0",
67
68
  "@noble/ciphers": "^2.1.1",
68
69
  "@noble/curves": "^1.9.7",
package/src/cli/chat.ts CHANGED
@@ -157,14 +157,16 @@ async function main() {
157
157
  console.log(`\n${DIM}⟿ [app] ${text}${RESET}`);
158
158
  void (async () => { if (!(await runCommand(text, true))) await runTurn(text, true, false); })();
159
159
  },
160
- // On (re)attach, resync the transcript, push live context (model + version) so
161
- // the app's banner shows what this terminal runs, AND advertise the available
162
- // commands so the composer can autocomplete them (incl. extension commands). The
163
- // model catalog isn't pushed — /model relays it on demand as a selection prompt.
164
- // NON-PII: no cwd see RelayClient.sendContext.
160
+ // On (re)attach, resync the transcript, push live context (model + cwd +
161
+ // version) so the app's banner shows what this terminal runs, AND advertise the
162
+ // available commands so the composer can autocomplete them (incl. extension
163
+ // commands). The model catalog isn't pushed — /model relays it on demand as a
164
+ // selection prompt. `cwd` is home-collapsed on the way out (see sendContext), so
165
+ // the driver learns which folder they're driving without the OS username
166
+ // crossing the relay.
165
167
  onControllerAttached: () => {
166
168
  relay?.sendSnapshot([]);
167
- relay?.sendContext({ model: currentSpec, version: agentVersion() });
169
+ relay?.sendContext({ model: currentSpec, cwd, version: agentVersion() });
168
170
  relay?.sendCommands(availableCommands());
169
171
  },
170
172
  onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
@@ -602,7 +604,7 @@ async function main() {
602
604
  currentSpec = sp;
603
605
  const m = `model → ${sp}`;
604
606
  console.log(`${DIM}${m}${RESET}`);
605
- relay?.sendContext({ model: currentSpec, version: agentVersion() }); // banner follows the switch
607
+ relay?.sendContext({ model: currentSpec, cwd, version: agentVersion() }); // banner follows the switch
606
608
  if (remote) relay?.sendNotice(m);
607
609
  } catch (e) {
608
610
  const m = `Couldn't switch model: ${(e as Error).message}`;
@@ -141,8 +141,17 @@ function unknownTarget(toolName: string, kind: "write" | "edit"): PermissionRequ
141
141
  // machine: no gate regardless of arguments. Tunable — the conservative default for
142
142
  // anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
143
143
  // builtin tool catalog as it's enumerated in Phase 5.
144
+ // `ask_user_question` (rpiv-ask-user-question, shimmed by the launcher) is here on
145
+ // purpose: it is a QUESTION PUT TO THE USER — it renders a dialog and returns what the
146
+ // human picked. It touches nothing, sends nothing, and the human is already in the loop
147
+ // by construction. Gating it would fall through to the unknown-tool branch below, which
148
+ // classifies as bash-kind: a pointless "Run ask_user_question" prompt in default mode,
149
+ // and an outright DENY in plan/readonly — the very posture where a model most needs to
150
+ // ask instead of guess. Headless surfaces need no guard either: the tool self-checks
151
+ // ctx.hasUI and returns an error result when there's no one to ask.
144
152
  const NON_GATED = new Set([
145
153
  "todo", "todowrite", "todo_write", "todoread", "think", "plan_note",
154
+ "ask_user_question",
146
155
  ]);
147
156
 
148
157
  // Read-ish builtins: gated ONLY when the target resolves outside scope.
@@ -114,10 +114,37 @@ export function loadCachedCatalogIds(): string[] {
114
114
  }
115
115
  }
116
116
 
117
+ // Whether the account channel can actually SERVE a catalog model right now.
118
+ //
119
+ // `phala/*` is sealed-only: it runs through the sealed blind relay and nowhere else.
120
+ // The server's cleartext `/api/agent/v1` has no Phala route and rejects the id
121
+ // outright (verified live 2026-07-31: 400 "phala/… is not a valid model ID"), because
122
+ // Phala models are the Sealed tier by design — the server is not meant to be able to
123
+ // read them. But `/api/models` advertises them to every client regardless of whether
124
+ // that client can reach the sealed path, so they were pickable and then failed on the
125
+ // first prompt. Offering a model we know cannot answer is worse than a shorter list.
126
+ //
127
+ // The condition is the SHIM, not the flag. Sealed mode being enabled only means we
128
+ // intend to seal; `phala/*` is unservable until the loopback shim is actually
129
+ // listening, because that is what its per-model baseUrl points at (see modelEntry).
130
+ // With the flag now defaulting on, "enabled but the shim failed to bind" is a state a
131
+ // user can really land in, and it must not re-offer models that would 400.
132
+ //
133
+ // `tinfoil/*` is deliberately NOT filtered: the cleartext path serves it fine (sealed
134
+ // mode only upgrades the badge from unconfirmed to verified), so it stays either way.
135
+ export function isServableAccountModel(id: string): boolean {
136
+ if (!id.startsWith("phala/")) return true;
137
+ return sealedEnabled() && sealedShimBase() !== null;
138
+ }
139
+
117
140
  // The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
118
141
  // default has to be index 0 both because it must always resolve and because Pi clones the
119
142
  // provider's first/default model when it synthesizes a custom model id
120
143
  // (model-resolver.js buildFallbackModel).
144
+ //
145
+ // Returns the server's list as cached, unfiltered — accountProviderConfig decides what
146
+ // is servable at each registration, so a model dropped now (shim not up yet) can be
147
+ // re-offered by a later re-registration without the cache being rewritten.
121
148
  export function seedCatalogIds(): string[] {
122
149
  const ids = [...DEFAULT_MODELS];
123
150
  const seen = new Set(ids);
@@ -202,7 +229,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
202
229
  .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
203
230
  .filter((x): x is AccountModelInfo => !!x);
204
231
  // Cache only a real LIVE listing — never the fallback, which would freeze the six
205
- // seed ids on disk and read back as though it were the catalog.
232
+ // seed ids on disk and read back as though it were the catalog. Both the cache and
233
+ // the returned list are the server's UNFILTERED offer; servability is decided at
234
+ // registration (accountProviderConfig), which re-evaluates it every time.
206
235
  if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
207
236
  infos = parsed.length ? parsed : fallback();
208
237
  }
@@ -402,14 +431,15 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
402
431
  const att = await attestSealed(sealedProvider);
403
432
  return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
404
433
  }
405
- // Honest labelling for the non-NEAR enclaves without sealed mode. Tinfoil and Phala
406
- // publish real attestations, but the server proxies the inference in cleartext, so
407
- // from here we cannot bind a quote to the connection actually carrying our tokens —
408
- // only the account's word that it did. That's `tee-unverified` (yellow "confidential
409
- // compute, unconfirmed"), never the green tee-verified we reserve for a quote we
410
- // checked ourselves. Turn on sealed mode (PRIVATEER_SEALED=1) for the verified
411
- // shield, or set TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests
412
- // client-side over the TLS binding).
434
+ // Honest labelling for the non-NEAR enclaves when we are NOT sealing — sealed mode
435
+ // explicitly disabled (PRIVATEER_SEALED=0), or on but the shim never came up. Tinfoil
436
+ // and Phala publish real attestations, but the server proxies the inference in
437
+ // cleartext, so from here we cannot bind a quote to the connection actually carrying
438
+ // our tokens only the account's word that it did. That's `tee-unverified` (yellow
439
+ // "confidential compute, unconfirmed"), never the green tee-verified we reserve for a
440
+ // quote we checked ourselves. Re-enable sealed mode for the verified shield, or set
441
+ // TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests client-side over the
442
+ // TLS binding).
413
443
  if (!modelId.startsWith("near/")) {
414
444
  return { tier: "tee-unverified" };
415
445
  }
@@ -429,11 +459,13 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
429
459
  }
430
460
  }
431
461
 
432
- // A model entry, with a per-model baseUrl override once the EHBP shim is listening:
433
- // `tinfoil/*` then route through the loopback shim (which seals to the blind relay)
434
- // instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the provider
435
- // baseUrl. Until the shim is up (or when sealed mode is off) sealed models fall back to
436
- // the cleartext path and the badge stays honestly `tee-unverified` (see accountPosture).
462
+ // A model entry, with a per-model baseUrl override once the sealed shim is listening:
463
+ // `tinfoil/*` and `phala/*` then route through the loopback shim (which seals to the
464
+ // blind relay) instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the
465
+ // provider baseUrl. Until the shim is up (or with sealed mode disabled) `tinfoil/*` falls
466
+ // back to the cleartext path and the badge stays honestly `tee-unverified` (see
467
+ // accountPosture); `phala/*` has no cleartext path at all and is not registered in that
468
+ // state (see isServableAccountModel).
437
469
  function modelEntry(id: string) {
438
470
  const base = seedModel(id);
439
471
  const provider = sealedEnabled() ? sealedProviderFor(id) : null;
@@ -448,7 +480,13 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
448
480
  baseUrl: `${serverBaseUrl()}/api/agent/v1`,
449
481
  api: "openai-completions",
450
482
  oauth: privateerOAuthProvider,
451
- models: ids.map(modelEntry),
483
+ // Filter HERE rather than at the catalog, so callers keep passing the server's
484
+ // full list and every registration re-evaluates servability against the CURRENT
485
+ // shim state. That is what lets the post-shim re-registration in makeAccountProvider
486
+ // put `phala/*` back: had the ids been filtered upstream, the sealed-only models
487
+ // would have been dropped from `lastIds` before the shim ever finished starting and
488
+ // nothing would have brought them back.
489
+ models: ids.filter(isServableAccountModel).map(modelEntry),
452
490
  };
453
491
  }
454
492
 
@@ -21,3 +21,19 @@ Everything else is byte-for-byte upstream. The crypto runs on `globalThis.crypto
21
21
  (Node ≥ 22) these are all native — **no polyfills needed** (unlike the treeview RN app,
22
22
  which bridges them via `react-native-quick-crypto`). Re-pull from upstream to update;
23
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.
@@ -0,0 +1,193 @@
1
+ // Algorithm dispatch for the ACI report-binding checks (§10.1 checks 2–6).
2
+ //
3
+ // The ACI spec (§4.3) allows the keyset endorsement to be signed with EITHER
4
+ // `ed25519` OR `ecdsa-secp256k1` — the former because "every primitive in it is
5
+ // available in the Web Crypto API", the latter for "clients in the EVM/dstack
6
+ // ecosystem". Upstream's reference TS verifier implements only the Web Crypto half:
7
+ // `verifySignature` throws `UnsupportedAlgorithmError` on secp256k1
8
+ // (aci-verifier/crypto.ts), and `verifyReportBinding` propagates that.
9
+ //
10
+ // The deployed gateway (inference.phala.com) signs with `ecdsa-secp256k1`, so the
11
+ // vendored verifier can never attest it — a limit of upstream's CLIENT, not of the
12
+ // spec or the gateway. Verified live 2026-07-31: attestation fetched, endorsement
13
+ // rejected with UnsupportedAlgorithmError.
14
+ //
15
+ // This module owns the dispatch so `aci-verifier/` stays byte-for-byte upstream
16
+ // (see its VENDORED.md — only the `.js`-extension strip diverges, and re-pulls stay
17
+ // mechanical):
18
+ // ed25519 → delegate to the vendored verifyReportBinding, verbatim
19
+ // ecdsa-secp256k1 → the same checks 2–6, with check 5 done over @noble/curves
20
+ // anything else → still throws (never a silent pass)
21
+ //
22
+ // The secp256k1 path deliberately mirrors report.ts check-for-check, in the same
23
+ // order and with the same check names, so a caller cannot tell which path ran.
24
+
25
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
26
+ import {
27
+ verifyReportBinding,
28
+ computeWorkloadId,
29
+ computeKeysetDigest,
30
+ computeReportData,
31
+ keysetEndorsementPayload,
32
+ sha256,
33
+ fromHex,
34
+ UnsupportedAlgorithmError,
35
+ type AttestationReport,
36
+ type Check,
37
+ type ReportVerification,
38
+ type ReportBindingOptions,
39
+ } from "./aci-verifier/index.ts";
40
+
41
+ const ED25519 = "ed25519";
42
+ const SECP256K1 = "ecdsa-secp256k1";
43
+
44
+ /**
45
+ * Verify a report's cryptographic bindings for `nonce` (§10.1 checks 2–6),
46
+ * dispatching on the algorithm the attested identity key declares. Drop-in
47
+ * replacement for the vendored `verifyReportBinding`: same arguments, same result
48
+ * shape, same "a failed check is `ok: false`, never thrown" contract.
49
+ *
50
+ * Like upstream, this is the crypto-binding half only — compose it with a hardware
51
+ * quote verifier (phalaSeal.ts `verifyHardwareQuote`) for Level 2.
52
+ */
53
+ export async function verifyAciReportBinding(
54
+ report: AttestationReport,
55
+ nonce: string | null | undefined,
56
+ options: ReportBindingOptions = {},
57
+ ): Promise<ReportVerification> {
58
+ const algo = report.attestation.workload_keyset.workload_identity.public_key.algo;
59
+ if (algo === ED25519) return verifyReportBinding(report, nonce, options);
60
+ if (algo !== SECP256K1) {
61
+ // Same fail-closed posture as upstream: an algorithm we cannot check is a
62
+ // refusal, not a pass.
63
+ throw new UnsupportedAlgorithmError(algo, "keyset endorsement (§4.3)");
64
+ }
65
+ return verifySecp256k1ReportBinding(report, nonce, options);
66
+ }
67
+
68
+ async function verifySecp256k1ReportBinding(
69
+ report: AttestationReport,
70
+ nonce: string | null | undefined,
71
+ options: ReportBindingOptions,
72
+ ): Promise<ReportVerification> {
73
+ const now = options.now ?? Math.floor(Date.now() / 1000);
74
+ const checks: Check[] = [];
75
+
76
+ const keyset = report.attestation.workload_keyset;
77
+ const identityKey = keyset.workload_identity.public_key;
78
+
79
+ // Check 2: workload_id == digest of the identity public key in the report's keyset.
80
+ const workloadId = await computeWorkloadId(identityKey);
81
+ pushEqual(checks, "workload_id", report.workload_id, workloadId);
82
+
83
+ // Check 3: workload_keyset_digest == digest of the report's keyset.
84
+ const workloadKeysetDigest = await computeKeysetDigest(keyset);
85
+ pushEqual(checks, "workload_keyset_digest", report.workload_keyset_digest, workloadKeysetDigest);
86
+
87
+ // Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
88
+ // The hardware-evidence-binds-report_data half is verifyHardwareQuote's job.
89
+ const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
90
+ pushEqual(checks, "report_data", report.attestation.report_data, expectedReportData);
91
+
92
+ // Check 5: keyset endorsement verifies under the identity key, algo matching.
93
+ const endorsement = report.attestation.keyset_endorsement;
94
+ if (endorsement.algo !== identityKey.algo) {
95
+ checks.push({
96
+ name: "keyset_endorsement",
97
+ ok: false,
98
+ detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
99
+ });
100
+ } else {
101
+ const ok = await verifySecp256k1(
102
+ identityKey.public_key,
103
+ endorsement.value,
104
+ keysetEndorsementPayload(workloadKeysetDigest),
105
+ );
106
+ checks.push({
107
+ name: "keyset_endorsement",
108
+ ok,
109
+ ...(ok ? {} : { detail: "endorsement signature failed under identity key" }),
110
+ });
111
+ }
112
+
113
+ // Check 6: freshness. Nonce binding is check 4; here bound the epoch and, when
114
+ // the profile trusts it, the declared validity window.
115
+ const notAfter = keyset.keyset_epoch.not_after;
116
+ const epochOk = now < notAfter;
117
+ checks.push({
118
+ name: "keyset_epoch.not_after",
119
+ ok: epochOk,
120
+ ...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
121
+ });
122
+ if (options.trustPlatformClock) {
123
+ const freshness = report.attestation.freshness;
124
+ const fetchedAt = freshness?.fetched_at;
125
+ const staleAfter = freshness?.stale_after;
126
+ const windowOk =
127
+ typeof fetchedAt === "number" &&
128
+ typeof staleAfter === "number" &&
129
+ fetchedAt <= now &&
130
+ now < staleAfter;
131
+ checks.push({
132
+ name: "freshness_window",
133
+ ok: windowOk,
134
+ ...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
135
+ });
136
+ }
137
+
138
+ return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
139
+ }
140
+
141
+ /**
142
+ * §4.3 secp256k1 endorsement: a 64-byte `r || s` signature over
143
+ * `sha256(payload bytes)`. Returns false on malformed input rather than throwing —
144
+ * a bad signature is a failed check, not an exception.
145
+ *
146
+ * NOT the §8.5 *receipt* shape, which is a 65-byte recoverable `r || s || v` and
147
+ * where the spec says 64-byte signatures MUST be rejected. Different shapes; easy
148
+ * to conflate if this ever grows a receipt path.
149
+ */
150
+ async function verifySecp256k1(
151
+ publicKeyHex: string,
152
+ signatureHex: string,
153
+ payload: Uint8Array,
154
+ ): Promise<boolean> {
155
+ try {
156
+ const sig = fromHex(signatureHex);
157
+ if (sig.length !== 64) return false; // r||s only; DER / recoverable forms are not §4.3
158
+ const msgHash = await sha256(payload);
159
+ return secp256k1.verify(sig, msgHash, publicKey(publicKeyHex), {
160
+ prehash: false, // we hand it the sha256 digest, per §4.3
161
+ // Accept high-s as well as low-s. ECDSA malleability is meaningless for a
162
+ // signature over a FIXED payload — an attacker who can flip s already has a
163
+ // valid endorsement and still cannot sign a different keyset digest. Leaving
164
+ // the default on would reject ~half of otherwise-valid endorsements from any
165
+ // signer that doesn't normalize, as an intermittent attestation failure.
166
+ lowS: false,
167
+ });
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Identity key bytes. §7.1 pins secp256k1 public keys as 65-byte uncompressed SEC1
175
+ * and requires that "the 64-byte uncompressed form without the `0x04` prefix MUST be
176
+ * accepted and treated as the same key" — so restore the prefix when it's absent.
177
+ * The live gateway sends the 65-byte form; do NOT prefix that one again.
178
+ */
179
+ function publicKey(hex: string): Uint8Array {
180
+ const raw = fromHex(hex);
181
+ if (raw.length === 64) {
182
+ const sec1 = new Uint8Array(65);
183
+ sec1[0] = 0x04;
184
+ sec1.set(raw, 1);
185
+ return sec1;
186
+ }
187
+ return raw;
188
+ }
189
+
190
+ function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
191
+ const ok = actual === expected;
192
+ checks.push({ name, ok, ...(ok ? {} : { detail: `report ${actual} != recomputed ${expected}` }) });
193
+ }
@@ -13,7 +13,7 @@
13
13
  // no polyfills, unlike the RN app.
14
14
  //
15
15
  // Two-layer attestation, fail-secure:
16
- // (1) verifyReportBinding — the report's crypto binding (keyset digest,
16
+ // (1) verifyAciReportBinding — the report's crypto binding (keyset digest,
17
17
  // report_data == statement(nonce), endorsement sig). Self-attesting alone.
18
18
  // (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
19
19
  // against Intel collateral and binds the quote's report_data to (1)'s statement
@@ -22,7 +22,6 @@
22
22
 
23
23
  import type { Report } from "@phala/dcap-qvl";
24
24
  import {
25
- verifyReportBinding,
26
25
  openE2eeChannel,
27
26
  toHex,
28
27
  fromHex,
@@ -30,6 +29,11 @@ import {
30
29
  type ReportVerification,
31
30
  type E2eeChannel,
32
31
  } from "./phala/aci-verifier/index.ts";
32
+ // Not the vendored verifyReportBinding directly: the deployed gateway signs its
33
+ // keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
34
+ // refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
35
+ // the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
36
+ import { verifyAciReportBinding } from "./phala/reportBinding.ts";
33
37
  import { serverBaseUrl } from "../auth/privateer.ts";
34
38
 
35
39
  const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
@@ -94,7 +98,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
94
98
  if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
95
99
  const report = (await res.json()) as AttestationReport;
96
100
 
97
- const verification = await verifyReportBinding(report, nonce);
101
+ const verification = await verifyAciReportBinding(report, nonce);
98
102
  if (!verification.ok) {
99
103
  const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
100
104
  throw new Error(`phala attestation binding failed: ${failed}`);
@@ -40,13 +40,22 @@ import { iterateSSE } from "./phala/sse.ts";
40
40
  // (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
41
41
  export type SealedProvider = "tinfoil" | "phala";
42
42
 
43
- // Sealed mode is OFF until verified end-to-end against a live relay (a real EHBP
44
- // round-trip needs the deployed relay + TINFOIL_API_KEY; see the live checklist in
45
- // docs/tee-privateer-tinfoil-ehbp.md). Off = the current plaintext path + honest
46
- // yellow badge, untouched. Flip with PRIVATEER_SEALED=1.
43
+ // Sealed mode is ON by default as of 2026-07-31, when the live checklist in
44
+ // docs/tee-verified-tinfoil-ehbp.md passed end to end against the deployed relay:
45
+ // both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
46
+ // TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
47
+ // refused rather than silently greened, and the server bills the turn.
48
+ //
49
+ // What it buys: the prompt is sealed to the enclave the client itself attested, so the
50
+ // badge is a quote WE checked rather than the account's word — green instead of
51
+ // "Trusted Execution (unconfirmed)".
52
+ //
53
+ // PRIVATEER_SEALED=0 (or =false) drops back to the cleartext `/api/agent/v1` path and
54
+ // the honest yellow badge. Note that is a real downgrade for `phala/*`, which is
55
+ // sealed-only and simply disappears from the catalog (see isServableAccountModel).
47
56
  export function sealedEnabled(): boolean {
48
57
  const v = process.env.PRIVATEER_SEALED;
49
- return v === "1" || v === "true";
58
+ return !(v === "0" || v === "false");
50
59
  }
51
60
 
52
61
  // The sealed provider a model id routes through, or null if it isn't a sealed
@@ -151,7 +160,12 @@ export function buildForward(
151
160
  // Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
152
161
  }
153
162
  const headers: Record<string, string> = {
154
- "Content-Type": "application/json",
163
+ // MUST carry the charset. EHBP seals the body but headers travel in cleartext
164
+ // (tinfoil/dist/encrypted-body-fetch.js: "EHBP only seals the body"), so this
165
+ // Content-Type is what the enclave's router actually validates — and it rejects a
166
+ // bare `application/json` with "Unsupported Media Type: Only 'application/json' is
167
+ // allowed" (verified live 2026-07-31: bare → 400, any `charset=` variant → 200).
168
+ "Content-Type": "application/json; charset=utf-8",
155
169
  "X-Sealed-Model": sealedModel,
156
170
  };
157
171
  if (authHeader) headers.Authorization = authHeader;
@@ -165,6 +179,7 @@ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
165
179
 
166
180
  let shimBase: string | null = null;
167
181
  let shimStarting: Promise<string> | null = null;
182
+ let shimServer: http.Server | null = null;
168
183
 
169
184
  // The shim's base URL once listening, else null. account.ts reads this to decide
170
185
  // whether a sealed model can point its baseUrl at the shim yet.
@@ -179,6 +194,19 @@ export function ensureSealedShim(): Promise<string> {
179
194
  return shimStarting;
180
195
  }
181
196
 
197
+ // Close the shim and forget it, so a later ensureSealedShim() starts a fresh one.
198
+ // The listener is `unref`'d and never blocks exit, so this is not needed for
199
+ // shutdown — it exists so a caller that stops sealing (or a test) can drop the
200
+ // socket deterministically rather than leaving a port open for the process lifetime.
201
+ export function stopSealedShim(): Promise<void> {
202
+ const server = shimServer;
203
+ shimServer = null;
204
+ shimBase = null;
205
+ shimStarting = null;
206
+ if (!server) return Promise.resolve();
207
+ return new Promise((resolve) => server.close(() => resolve()));
208
+ }
209
+
182
210
  function startShim(): Promise<string> {
183
211
  return new Promise((resolve, reject) => {
184
212
  const server = http.createServer((req, res) => {
@@ -192,6 +220,7 @@ function startShim(): Promise<string> {
192
220
  server.listen(0, "127.0.0.1", () => {
193
221
  const addr = server.address();
194
222
  if (addr && typeof addr === "object") {
223
+ shimServer = server;
195
224
  shimBase = `http://127.0.0.1:${addr.port}`;
196
225
  resolve(shimBase);
197
226
  } else {
@@ -61,6 +61,8 @@ const RESERVED = new Set([
61
61
  "pi-web-access",
62
62
  "rpiv-web-tools",
63
63
  "@juicesharp/rpiv-web-tools",
64
+ "rpiv-ask-user-question",
65
+ "@juicesharp/rpiv-ask-user-question",
64
66
  "pi-mcp-adapter",
65
67
  "pi-hypa",
66
68
  "@hypabolic/pi-hypa",
@@ -996,13 +996,14 @@ export class RelayClient {
996
996
  // stance (the server/controller learns as little as possible about the machine).
997
997
  // Empty/absent fields are omitted so the app renders less rather than blank.
998
998
  //
999
- // `cwd` is the one scoped exception, and ONLY a harbor-spawned live session passes
1000
- // it (see liveTaskSession): the driver chose that directory in the spawn form — or,
1001
- // having left it blank, needs to see which one the harbor picked because it is
1002
- // where everything that session reads, writes and `@`-mentions lives, and unlike an
1003
- // interactive terminal there is no human sitting in it to already know. It is
1004
- // home-collapsed (`~/…`) on the way out, so the banner reads like the CLI's own and
1005
- // the OS username still never crosses the relay.
999
+ // `cwd` is the one scoped exception, sent by BOTH session kinds: it is where
1000
+ // everything the agent reads, writes and `@`-mentions lives, so a driver who can't
1001
+ // see it is guessing at the blast radius of every prompt they send. (It used to be
1002
+ // harbor-spawned sessions only on the theory that a human sits in an interactive
1003
+ // terminal and already knows the folder. They don't when they're driving it from a
1004
+ // phone, which is the entire point of this transport.) It is home-collapsed (`~/…`)
1005
+ // on the way out, so the banner reads like the CLI's own and the OS username still
1006
+ // never crosses the relay.
1006
1007
  sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
1007
1008
  const frame: Record<string, unknown> = { type: "context" };
1008
1009
  if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;