privateer-agent 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/channels/run.ts +11 -2
- package/src/cli/chat.ts +10 -2
- package/src/harbor/index.ts +45 -8
- package/src/harbor/ipc.ts +82 -34
- package/src/mcp/catalog.ts +106 -0
- package/src/providers/account.ts +57 -10
- 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 +11 -3
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Phala (ACI E2EE) sealed transport for the account channel — the Node port of
|
|
2
|
+
// treeview's client PhalaProvider.
|
|
3
|
+
//
|
|
4
|
+
// Where Tinfoil seals the whole body at the transport layer (EHBP via SecureClient),
|
|
5
|
+
// Phala's Attested Confidential Inference encrypts the request's *content fields*
|
|
6
|
+
// (x25519-aes-256-gcm-hkdf-sha256) to the enclave's attested X25519 key, sends the
|
|
7
|
+
// `X-E2EE-*` headers alongside, and decrypts the response fields. The Privateer relay
|
|
8
|
+
// (`${server}/api/sealed/phala`, treeview/server/routes/sealed.js) injects PHALA_API_KEY
|
|
9
|
+
// and forwards ciphertext — it can't read prompts or responses.
|
|
10
|
+
//
|
|
11
|
+
// Crypto is the vendored @dstack/aci-verifier (./phala/aci-verifier), pure Web Crypto
|
|
12
|
+
// (X25519/HKDF/AES-GCM/Ed25519). Node ≥ 22 provides all of it on globalThis.crypto —
|
|
13
|
+
// no polyfills, unlike the RN app.
|
|
14
|
+
//
|
|
15
|
+
// Two-layer attestation, fail-secure:
|
|
16
|
+
// (1) verifyReportBinding — the report's crypto binding (keyset digest,
|
|
17
|
+
// report_data == statement(nonce), endorsement sig). Self-attesting alone.
|
|
18
|
+
// (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
|
|
19
|
+
// against Intel collateral and binds the quote's report_data to (1)'s statement
|
|
20
|
+
// digest. requireQuote defaults TRUE; PRIVATEER_PHALA_REQUIRE_QUOTE=0 drops it
|
|
21
|
+
// (local testing only — removes the hardware root of trust).
|
|
22
|
+
|
|
23
|
+
import type { Report } from "@phala/dcap-qvl";
|
|
24
|
+
import {
|
|
25
|
+
verifyReportBinding,
|
|
26
|
+
openE2eeChannel,
|
|
27
|
+
toHex,
|
|
28
|
+
fromHex,
|
|
29
|
+
type AttestationReport,
|
|
30
|
+
type ReportVerification,
|
|
31
|
+
type E2eeChannel,
|
|
32
|
+
} from "./phala/aci-verifier/index.ts";
|
|
33
|
+
import { serverBaseUrl } from "../auth/privateer.ts";
|
|
34
|
+
|
|
35
|
+
const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
|
|
36
|
+
|
|
37
|
+
function relayBase(): string {
|
|
38
|
+
return `${serverBaseUrl().replace(/\/+$/, "")}/api/sealed/phala`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Hardware quote check on by default (fail-secure). Only "0"/"false" disables it.
|
|
42
|
+
function requireQuote(): boolean {
|
|
43
|
+
const v = process.env.PRIVATEER_PHALA_REQUIRE_QUOTE;
|
|
44
|
+
return !(v === "0" || v === "false");
|
|
45
|
+
}
|
|
46
|
+
function pccsUrl(): string | undefined {
|
|
47
|
+
return process.env.PRIVATEER_PHALA_PCCS_URL || undefined;
|
|
48
|
+
}
|
|
49
|
+
function acceptableTcb(): Set<string> {
|
|
50
|
+
const v = process.env.PRIVATEER_PHALA_TCB;
|
|
51
|
+
const list = v ? v.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_ACCEPTABLE_TCB;
|
|
52
|
+
return new Set(list);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The 64-byte report_data from a verified TDX quote report (TD1.0/1.5 layouts).
|
|
56
|
+
function extractQuoteReportData(report: Report): Uint8Array {
|
|
57
|
+
const td10 = report.asTd10?.();
|
|
58
|
+
if (td10?.reportData) return new Uint8Array(td10.reportData);
|
|
59
|
+
const td15 = report.asTd15?.();
|
|
60
|
+
if (td15?.base?.reportData) return new Uint8Array(td15.base.reportData);
|
|
61
|
+
const data = report.data as { reportData?: Uint8Array } | undefined;
|
|
62
|
+
if (data?.reportData) return new Uint8Array(data.reportData);
|
|
63
|
+
throw new Error("phala: verified quote report has no reportData");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface VerifiedAttestation {
|
|
67
|
+
report: AttestationReport;
|
|
68
|
+
verification: ReportVerification;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Attest once, cache the verified report; drop the memo on failure so a later call
|
|
72
|
+
// re-attests rather than caching the error.
|
|
73
|
+
let attestationPromise: Promise<VerifiedAttestation> | null = null;
|
|
74
|
+
|
|
75
|
+
function attest(): Promise<VerifiedAttestation> {
|
|
76
|
+
if (!attestationPromise) {
|
|
77
|
+
attestationPromise = establishAttestation().catch((err) => {
|
|
78
|
+
attestationPromise = null;
|
|
79
|
+
throw err as Error;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return attestationPromise;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resetPhala(): void {
|
|
86
|
+
attestationPromise = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function establishAttestation(): Promise<VerifiedAttestation> {
|
|
90
|
+
const nonce = toHex(globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
91
|
+
// The relay proxies GET /attestation?nonce=… → the gateway's
|
|
92
|
+
// GET /v1/aci/attestation?nonce=… (public; no user content).
|
|
93
|
+
const res = await fetch(`${relayBase()}/attestation?nonce=${nonce}`, { method: "GET" });
|
|
94
|
+
if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
|
|
95
|
+
const report = (await res.json()) as AttestationReport;
|
|
96
|
+
|
|
97
|
+
const verification = await verifyReportBinding(report, nonce);
|
|
98
|
+
if (!verification.ok) {
|
|
99
|
+
const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
|
|
100
|
+
throw new Error(`phala attestation binding failed: ${failed}`);
|
|
101
|
+
}
|
|
102
|
+
await verifyHardwareQuote(report);
|
|
103
|
+
return { report, verification };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function verifyHardwareQuote(report: AttestationReport): Promise<void> {
|
|
107
|
+
if (!requireQuote()) return;
|
|
108
|
+
|
|
109
|
+
const attestation = report.attestation as unknown as {
|
|
110
|
+
tee_type?: string;
|
|
111
|
+
report_data?: string;
|
|
112
|
+
evidence?: { quote?: string; quote_report_data?: string };
|
|
113
|
+
};
|
|
114
|
+
const teeType = String(attestation?.tee_type || "");
|
|
115
|
+
if (teeType !== "tdx") throw new Error(`phala: unsupported/absent tee_type "${teeType}" (only tdx is wired)`);
|
|
116
|
+
const quoteHex = attestation.evidence?.quote;
|
|
117
|
+
if (typeof quoteHex !== "string" || !quoteHex) throw new Error("phala: attestation evidence has no TDX quote");
|
|
118
|
+
const reportDataHex = String(attestation.report_data || "").toLowerCase();
|
|
119
|
+
if (!reportDataHex) throw new Error("phala: report has no report_data");
|
|
120
|
+
|
|
121
|
+
// Verify the quote against fetched Intel/Phala collateral (pure-JS dcap-qvl).
|
|
122
|
+
const { getCollateralAndVerify } = await import("@phala/dcap-qvl");
|
|
123
|
+
const verified = await getCollateralAndVerify(fromHex(quoteHex), pccsUrl());
|
|
124
|
+
|
|
125
|
+
// 1) Genuine hardware + acceptable TCB status.
|
|
126
|
+
const status = String(verified.status);
|
|
127
|
+
if (!acceptableTcb().has(status)) throw new Error(`phala: TDX quote TCB status not accepted: "${status}"`);
|
|
128
|
+
|
|
129
|
+
// 2) The genuine quote committed to our attested statement digest.
|
|
130
|
+
const quoteReportData = extractQuoteReportData(verified.report);
|
|
131
|
+
if (toHex(quoteReportData.slice(0, 32)) !== reportDataHex) {
|
|
132
|
+
throw new Error("phala: TDX quote report_data does not bind the attested report_data");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3) Consistency: the report's declared quote_report_data matches the real quote.
|
|
136
|
+
const declared = attestation.evidence?.quote_report_data;
|
|
137
|
+
if (typeof declared === "string" && declared && toHex(quoteReportData) !== declared.toLowerCase()) {
|
|
138
|
+
throw new Error("phala: evidence.quote_report_data does not match the verified quote");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Posture signal: does the attested keyset verify (crypto binding + hardware quote)?
|
|
143
|
+
// A green result is a quote WE checked, bound to the E2EE key we seal to.
|
|
144
|
+
export async function attestPhala(): Promise<{ ok: boolean; error?: string }> {
|
|
145
|
+
try {
|
|
146
|
+
await attest();
|
|
147
|
+
return { ok: true };
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return { ok: false, error: (e as Error).message };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface PhalaExchange {
|
|
154
|
+
res: Response;
|
|
155
|
+
channel: E2eeChannel;
|
|
156
|
+
streaming: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Run one sealed request for Pi: attest, open a fresh per-call E2EE channel (the
|
|
160
|
+
// channel's request state is single-shot → not safe to share across concurrent
|
|
161
|
+
// calls), seal the request fields, and POST to the relay with the X-E2EE-* headers +
|
|
162
|
+
// the cleartext X-Sealed-Model (relay billing) + Pi's account bearer. Returns the
|
|
163
|
+
// upstream response and the channel so the caller can decrypt it.
|
|
164
|
+
export async function phalaSealedFetch(
|
|
165
|
+
rawBody: string,
|
|
166
|
+
authHeader: string | undefined,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
): Promise<PhalaExchange> {
|
|
169
|
+
const { report, verification } = await attest();
|
|
170
|
+
const channel = await openE2eeChannel(report, verification);
|
|
171
|
+
|
|
172
|
+
let request: Record<string, unknown>;
|
|
173
|
+
try {
|
|
174
|
+
request = JSON.parse(rawBody) as Record<string, unknown>;
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error("phala: request body is not JSON");
|
|
177
|
+
}
|
|
178
|
+
const fullModel = typeof request.model === "string" ? request.model : "unknown";
|
|
179
|
+
const streaming = request.stream !== false;
|
|
180
|
+
// Bare model id for the enclave (the `phala/` prefix is app-side only); keep the
|
|
181
|
+
// full id on the cleartext X-Sealed-Model billing header.
|
|
182
|
+
request.model = fullModel.replace(/^phala\//, "");
|
|
183
|
+
request.stream = streaming;
|
|
184
|
+
|
|
185
|
+
const { body, headers: e2ee } = await channel.seal(request);
|
|
186
|
+
const headers: Record<string, string> = {
|
|
187
|
+
"Content-Type": "application/json",
|
|
188
|
+
"X-Sealed-Model": fullModel,
|
|
189
|
+
...e2ee,
|
|
190
|
+
};
|
|
191
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
192
|
+
|
|
193
|
+
const res = await fetch(`${relayBase()}/v1/chat/completions`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers,
|
|
196
|
+
body: JSON.stringify(body),
|
|
197
|
+
signal,
|
|
198
|
+
});
|
|
199
|
+
return { res, channel, streaming };
|
|
200
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Sealed-mode (EHBP) transport for the account channel.
|
|
2
|
+
//
|
|
3
|
+
// Background: the `privateer` provider runs inference through the server at
|
|
4
|
+
// `${server}/api/agent/v1`, which reads the prompt in cleartext (it assembles/
|
|
5
|
+
// forwards the body). For `tinfoil/*` models that means the badge honestly reads
|
|
6
|
+
// "Trusted Execution (unconfirmed)": the enclave is real, but a quote fetched
|
|
7
|
+
// through the proxy can't be bound to THIS connection (see account.ts and
|
|
8
|
+
// docs/tee-privateer-tinfoil-ehbp.md).
|
|
9
|
+
//
|
|
10
|
+
// Sealed mode closes that. Tinfoil's EHBP (Encrypted HTTP Body Protocol) is HPKE
|
|
11
|
+
// applied to the HTTP body only, independent of TLS, and the enclave's attestation
|
|
12
|
+
// binds the HPKE key. The Privateer server already exposes a blind relay for it
|
|
13
|
+
// (`${server}/api/sealed/:provider`, treeview/server/routes/sealed.js): it forwards
|
|
14
|
+
// the ciphertext body + the `Ehbp-*` headers, injects the provider key, and meters
|
|
15
|
+
// on a cleartext usage header — it never sees the prompt. The treeview app already
|
|
16
|
+
// speaks this (client/services/pipeline/transport/tinfoilProvider.ts).
|
|
17
|
+
//
|
|
18
|
+
// Pi's `openai-completions` adapter does the HTTP itself and exposes no custom-fetch
|
|
19
|
+
// hook, so we can't seal from inside the provider config. Instead we run an
|
|
20
|
+
// in-process loopback HTTP server (this module): Pi POSTs a plain OpenAI request to
|
|
21
|
+
// it, the shim seals the body to the attested enclave via Tinfoil's `SecureClient`,
|
|
22
|
+
// forwards Pi's account bearer + the cleartext `X-Sealed-Model` billing header, and
|
|
23
|
+
// streams the decrypted response back. The provider points the `tinfoil/*` models'
|
|
24
|
+
// per-model `baseUrl` at this shim (account.ts).
|
|
25
|
+
//
|
|
26
|
+
// The one SecureClient per provider is shared by the data plane (the shim) and the
|
|
27
|
+
// posture check (attestSealed), so the green shield reflects the exact client that
|
|
28
|
+
// carries the tokens — the invariant: attest the key we actually seal to.
|
|
29
|
+
|
|
30
|
+
import http from "node:http";
|
|
31
|
+
import { Readable } from "node:stream";
|
|
32
|
+
import { SecureClient } from "tinfoil";
|
|
33
|
+
import { serverBaseUrl } from "../auth/privateer.ts";
|
|
34
|
+
import { attestPhala, phalaSealedFetch } from "./phalaSeal.ts";
|
|
35
|
+
import { iterateSSE } from "./phala/sse.ts";
|
|
36
|
+
|
|
37
|
+
// Providers whose enclave supports application-layer body encryption + client-verified
|
|
38
|
+
// attestation, and for which we have a Node client: Tinfoil (EHBP) and Phala (ACI
|
|
39
|
+
// E2EE). NEAR does NOT — it's attested TLS only, so it stays the confidential
|
|
40
|
+
// (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
|
|
41
|
+
export type SealedProvider = "tinfoil" | "phala";
|
|
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.
|
|
47
|
+
export function sealedEnabled(): boolean {
|
|
48
|
+
const v = process.env.PRIVATEER_SEALED;
|
|
49
|
+
return v === "1" || v === "true";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The sealed provider a model id routes through, or null if it isn't a sealed
|
|
53
|
+
// model (or its client isn't available here). Mirrors the server's prefix routing.
|
|
54
|
+
export function sealedProviderFor(modelId: string): SealedProvider | null {
|
|
55
|
+
if (modelId.startsWith("tinfoil/")) return "tinfoil";
|
|
56
|
+
if (modelId.startsWith("phala/")) return "phala";
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The blind-relay base for a provider — SecureClient fetches `${base}/attestation`
|
|
61
|
+
// and we POST `${base}/v1/chat/completions`.
|
|
62
|
+
export function relayBase(provider: SealedProvider): string {
|
|
63
|
+
return `${serverBaseUrl().replace(/\/+$/, "")}/api/sealed/${provider}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── One SecureClient per provider (shared: data plane + posture) ──────────────
|
|
67
|
+
|
|
68
|
+
const clients = new Map<SealedProvider, SecureClient>();
|
|
69
|
+
const readyPromises = new Map<SealedProvider, Promise<void>>();
|
|
70
|
+
|
|
71
|
+
function client(provider: SealedProvider): SecureClient {
|
|
72
|
+
let c = clients.get(provider);
|
|
73
|
+
if (!c) {
|
|
74
|
+
const base = relayBase(provider);
|
|
75
|
+
// attestationBundleURL == base: the SDK appends `/attestation`, which the relay
|
|
76
|
+
// proxies to Tinfoil's ATC. transport 'ehbp' = HPKE body sealing.
|
|
77
|
+
c = new SecureClient({ baseURL: base, attestationBundleURL: base, transport: "ehbp" });
|
|
78
|
+
clients.set(provider, c);
|
|
79
|
+
}
|
|
80
|
+
return c;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Attest once and cache; on failure drop the memo so a later call re-attests
|
|
84
|
+
// rather than caching the error (mirrors the treeview provider).
|
|
85
|
+
export function ready(provider: SealedProvider): Promise<void> {
|
|
86
|
+
let p = readyPromises.get(provider);
|
|
87
|
+
if (!p) {
|
|
88
|
+
p = client(provider)
|
|
89
|
+
.ready()
|
|
90
|
+
.catch((err) => {
|
|
91
|
+
readyPromises.delete(provider);
|
|
92
|
+
throw err as Error;
|
|
93
|
+
});
|
|
94
|
+
readyPromises.set(provider, p);
|
|
95
|
+
}
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SealedAttestation {
|
|
100
|
+
ok: boolean;
|
|
101
|
+
enclave?: string;
|
|
102
|
+
error?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Drive the provider's client-side attestation (the SAME client/keyset the shim
|
|
106
|
+
// seals with). A green result is a quote we verified ourselves, bound to the key we
|
|
107
|
+
// encrypt to — that earns tee-verified. Tinfoil: SecureClient.ready() (HPKE-key
|
|
108
|
+
// match). Phala: verifyReportBinding + TDX quote (see phalaSeal.ts).
|
|
109
|
+
export async function attestSealed(provider: SealedProvider): Promise<SealedAttestation> {
|
|
110
|
+
if (provider === "phala") return attestPhala();
|
|
111
|
+
try {
|
|
112
|
+
await ready(provider);
|
|
113
|
+
return { ok: true, enclave: client(provider).getEnclaveURL() };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { ok: false, error: (e as Error).message };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Pure request shaping (unit-tested without an enclave) ─────────────────────
|
|
120
|
+
|
|
121
|
+
export interface ForwardPlan {
|
|
122
|
+
url: string;
|
|
123
|
+
headers: Record<string, string>;
|
|
124
|
+
body: string;
|
|
125
|
+
sealedModel: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Turn Pi's plain OpenAI request into what we seal to the relay:
|
|
129
|
+
// - strip the `${provider}/` prefix from the body model (the enclave wants the
|
|
130
|
+
// bare id; the body is encrypted so the relay can't strip it — we must),
|
|
131
|
+
// - keep the full prefixed id on the cleartext X-Sealed-Model header (the relay
|
|
132
|
+
// prices billing off it — it never reads the body),
|
|
133
|
+
// - forward Pi's account bearer verbatim (the relay authenticates the JWT; on a
|
|
134
|
+
// 401 the relay's response propagates so Pi refreshes and retries).
|
|
135
|
+
export function buildForward(
|
|
136
|
+
provider: SealedProvider,
|
|
137
|
+
rawBody: string,
|
|
138
|
+
authHeader: string | undefined,
|
|
139
|
+
): ForwardPlan {
|
|
140
|
+
let body = rawBody;
|
|
141
|
+
let sealedModel = "unknown";
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(rawBody);
|
|
144
|
+
if (parsed && typeof parsed.model === "string") {
|
|
145
|
+
sealedModel = parsed.model;
|
|
146
|
+
const prefix = `${provider}/`;
|
|
147
|
+
if (parsed.model.startsWith(prefix)) parsed.model = parsed.model.slice(prefix.length);
|
|
148
|
+
body = JSON.stringify(parsed);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
|
|
152
|
+
}
|
|
153
|
+
const headers: Record<string, string> = {
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
"X-Sealed-Model": sealedModel,
|
|
156
|
+
};
|
|
157
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
158
|
+
return { url: `${relayBase(provider)}/v1/chat/completions`, headers, body, sealedModel };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Loopback HTTP shim ────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const PATH_RE = /^\/(tinfoil|phala)\/v1\/chat\/completions$/;
|
|
164
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
165
|
+
|
|
166
|
+
let shimBase: string | null = null;
|
|
167
|
+
let shimStarting: Promise<string> | null = null;
|
|
168
|
+
|
|
169
|
+
// The shim's base URL once listening, else null. account.ts reads this to decide
|
|
170
|
+
// whether a sealed model can point its baseUrl at the shim yet.
|
|
171
|
+
export function sealedShimBase(): string | null {
|
|
172
|
+
return shimBase;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Start the loopback shim once and return its base URL. Idempotent.
|
|
176
|
+
export function ensureSealedShim(): Promise<string> {
|
|
177
|
+
if (shimBase) return Promise.resolve(shimBase);
|
|
178
|
+
if (!shimStarting) shimStarting = startShim();
|
|
179
|
+
return shimStarting;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function startShim(): Promise<string> {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const server = http.createServer((req, res) => {
|
|
185
|
+
void handle(req, res).catch((e) => {
|
|
186
|
+
if (!res.headersSent) res.writeHead(502, { "Content-Type": "application/json" });
|
|
187
|
+
res.end(JSON.stringify({ error: { message: `sealed shim: ${(e as Error).message}` } }));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
server.on("error", reject);
|
|
191
|
+
// Loopback only, ephemeral port. unref so the shim never keeps the process alive.
|
|
192
|
+
server.listen(0, "127.0.0.1", () => {
|
|
193
|
+
const addr = server.address();
|
|
194
|
+
if (addr && typeof addr === "object") {
|
|
195
|
+
shimBase = `http://127.0.0.1:${addr.port}`;
|
|
196
|
+
resolve(shimBase);
|
|
197
|
+
} else {
|
|
198
|
+
reject(new Error("sealed shim: could not determine listen port"));
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
server.unref();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
206
|
+
// Defense in depth: only serve loopback peers (the port is ephemeral + bound to
|
|
207
|
+
// 127.0.0.1 already, but reject anything else outright).
|
|
208
|
+
if (!LOOPBACK.has(req.socket.remoteAddress ?? "")) {
|
|
209
|
+
res.writeHead(403).end();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const path = (req.url ?? "").split("?")[0];
|
|
213
|
+
const m = PATH_RE.exec(path);
|
|
214
|
+
if (req.method !== "POST" || !m) {
|
|
215
|
+
res.writeHead(404).end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const provider = m[1] as SealedProvider;
|
|
219
|
+
const raw = await readBody(req);
|
|
220
|
+
const authHeader = typeof req.headers["authorization"] === "string" ? req.headers["authorization"] : undefined;
|
|
221
|
+
|
|
222
|
+
if (provider === "phala") {
|
|
223
|
+
await handlePhala(raw.toString("utf8"), authHeader, res);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Tinfoil (EHBP): SecureClient.fetch seals/opens transparently, so we just pass the
|
|
228
|
+
// body through and pipe the decrypted response.
|
|
229
|
+
const plan = buildForward(provider, raw.toString("utf8"), authHeader);
|
|
230
|
+
await ready(provider);
|
|
231
|
+
const upstream = await client(provider).fetch(plan.url, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: plan.headers,
|
|
234
|
+
body: plan.body,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
res.writeHead(upstream.status, {
|
|
238
|
+
"Content-Type": upstream.headers.get("content-type") ?? "application/json",
|
|
239
|
+
});
|
|
240
|
+
if (upstream.body) {
|
|
241
|
+
const nodeStream = Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]);
|
|
242
|
+
// If Pi hangs up mid-stream, stop pulling from the enclave.
|
|
243
|
+
res.on("close", () => nodeStream.destroy());
|
|
244
|
+
nodeStream.pipe(res);
|
|
245
|
+
nodeStream.on("error", () => {
|
|
246
|
+
if (!res.writableEnded) res.end();
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
res.end(await upstream.text());
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Phala (ACI E2EE): fields are encrypted individually, so unlike Tinfoil we must
|
|
254
|
+
// decrypt the response with the per-call channel — chunk by chunk for SSE, or the
|
|
255
|
+
// whole JSON when non-streaming — and re-emit cleartext OpenAI to Pi.
|
|
256
|
+
async function handlePhala(
|
|
257
|
+
rawBody: string,
|
|
258
|
+
authHeader: string | undefined,
|
|
259
|
+
res: http.ServerResponse,
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
const ac = new AbortController();
|
|
262
|
+
res.on("close", () => ac.abort());
|
|
263
|
+
const { res: up, channel, streaming } = await phalaSealedFetch(rawBody, authHeader, ac.signal);
|
|
264
|
+
|
|
265
|
+
if (!up.ok) {
|
|
266
|
+
res.writeHead(up.status, { "Content-Type": up.headers.get("content-type") ?? "application/json" });
|
|
267
|
+
res.end(await up.text());
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (streaming && up.body) {
|
|
272
|
+
res.writeHead(up.status, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" });
|
|
273
|
+
for await (const obj of iterateSSE(up.body as ReadableStream<Uint8Array>)) {
|
|
274
|
+
const opened = await channel.openChunk(obj as Record<string, unknown>);
|
|
275
|
+
res.write(`data: ${JSON.stringify(opened)}\n\n`);
|
|
276
|
+
}
|
|
277
|
+
res.write("data: [DONE]\n\n");
|
|
278
|
+
res.end();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Non-streaming: decrypt the single JSON body.
|
|
283
|
+
const opened = await channel.open((await up.json()) as Record<string, unknown>);
|
|
284
|
+
res.writeHead(up.status, { "Content-Type": "application/json" });
|
|
285
|
+
res.end(JSON.stringify(opened));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function readBody(req: http.IncomingMessage): Promise<Buffer> {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const chunks: Buffer[] = [];
|
|
291
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
292
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
293
|
+
req.on("error", reject);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
@@ -20,10 +20,10 @@ import { agentVersion } from "../config/version.ts";
|
|
|
20
20
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
21
21
|
import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
|
|
22
22
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
23
|
-
import { makeAccountProvider } from "../providers/account.ts";
|
|
23
|
+
import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
|
|
24
24
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
25
25
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
26
|
-
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
26
|
+
import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
|
|
27
27
|
|
|
28
28
|
export interface LiveTaskHandle {
|
|
29
29
|
termId: string;
|
|
@@ -138,7 +138,15 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
138
138
|
cwd,
|
|
139
139
|
agentDir: agentDir(),
|
|
140
140
|
resourceLoaderOptions: {
|
|
141
|
-
extensionFactories: [
|
|
141
|
+
extensionFactories: [
|
|
142
|
+
makePermissionGate(gate),
|
|
143
|
+
// Per-model verified-TEE label for the /models picker (see harbor/index.ts):
|
|
144
|
+
// TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
|
|
145
|
+
makePiPrivacyExtension({
|
|
146
|
+
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
147
|
+
}),
|
|
148
|
+
makeAccountProvider(),
|
|
149
|
+
] as any,
|
|
142
150
|
},
|
|
143
151
|
});
|
|
144
152
|
servicesRef = services as any;
|