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
|
@@ -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;
|
|
@@ -43,6 +43,10 @@ export interface LiveTaskDeps {
|
|
|
43
43
|
// abandoned spawn can't run the account meter or hold resources forever).
|
|
44
44
|
const ATTACH_GRACE_MS = 180_000; // 3 min to attach after spawn
|
|
45
45
|
const MAX_LIFETIME_MS = 30 * 60_000; // 30 min absolute cap
|
|
46
|
+
// How long to wait for the spawned terminal to actually register on the relay before we give
|
|
47
|
+
// up and report the spawn as failed. `start()` resolves before the socket opens, so without
|
|
48
|
+
// this confirmation the harbor would announce a terminal the app can never attach to.
|
|
49
|
+
const REGISTER_TIMEOUT_MS = 20_000;
|
|
46
50
|
|
|
47
51
|
export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps): Promise<LiveTaskHandle> {
|
|
48
52
|
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
@@ -138,7 +142,15 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
138
142
|
cwd,
|
|
139
143
|
agentDir: agentDir(),
|
|
140
144
|
resourceLoaderOptions: {
|
|
141
|
-
extensionFactories: [
|
|
145
|
+
extensionFactories: [
|
|
146
|
+
makePermissionGate(gate),
|
|
147
|
+
// Per-model verified-TEE label for the /models picker (see harbor/index.ts):
|
|
148
|
+
// TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
|
|
149
|
+
makePiPrivacyExtension({
|
|
150
|
+
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
151
|
+
}),
|
|
152
|
+
makeAccountProvider(),
|
|
153
|
+
] as any,
|
|
142
154
|
},
|
|
143
155
|
});
|
|
144
156
|
servicesRef = services as any;
|
|
@@ -205,6 +217,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
205
217
|
relay = new RelayClient(bridge.callbacks, { termId, label });
|
|
206
218
|
bridge.attachRelay(relay);
|
|
207
219
|
await relay.start();
|
|
220
|
+
// start() resolves before the socket registers; confirm the terminal is actually live on
|
|
221
|
+
// the relay BEFORE returning (→ the harbor announces task_spawned). Rejects on a hard
|
|
222
|
+
// failure (e.g. the concurrency cap) or timeout → the catch below tears down and propagates,
|
|
223
|
+
// so the harbor reports task_spawn_error instead of pointing the app at a dead terminal.
|
|
224
|
+
await relay.awaitRegistered(REGISTER_TIMEOUT_MS);
|
|
208
225
|
|
|
209
226
|
// Reap if nobody ever attaches, and cap the absolute lifetime regardless.
|
|
210
227
|
attachTimer = setTimeout(() => { if (!attached) void stop(); }, ATTACH_GRACE_MS);
|
|
@@ -295,6 +295,14 @@ export class RelayClient {
|
|
|
295
295
|
// random one each time).
|
|
296
296
|
private readonly termId: string;
|
|
297
297
|
private readonly label: string;
|
|
298
|
+
// First-registration signal (opt-in via awaitRegistered): `start()` resolves before the
|
|
299
|
+
// socket actually opens, so a caller that must not act until the terminal is truly live on
|
|
300
|
+
// the relay — e.g. a live-task spawn that announces `task_spawned` — waits on this instead.
|
|
301
|
+
// Settled once: resolved on the first ws `open`, rejected on a hard (4xx) ticket-mint
|
|
302
|
+
// failure. Reconnect blips after a successful first open do NOT re-settle it.
|
|
303
|
+
private firstConnectSettled = false;
|
|
304
|
+
private firstConnectError: Error | null = null;
|
|
305
|
+
private firstConnectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [];
|
|
298
306
|
// In-progress file transfers from the app, keyed by the controller's attachment
|
|
299
307
|
// id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
|
|
300
308
|
private readonly incoming = new Map<
|
|
@@ -322,8 +330,43 @@ export class RelayClient {
|
|
|
322
330
|
await this.connect();
|
|
323
331
|
}
|
|
324
332
|
|
|
333
|
+
// Resolve once this terminal has actually registered on the relay (ws `open`), reject on a
|
|
334
|
+
// hard registration failure (e.g. a 403 concurrency-cap denial) or after `timeoutMs`.
|
|
335
|
+
// `start()` resolves before the socket opens, so a live-task spawn calls this to confirm the
|
|
336
|
+
// app can attach BEFORE it announces the terminal — otherwise the app is told to drive a
|
|
337
|
+
// terminal that never came up and hangs. Idempotent; settling stores its result so a caller
|
|
338
|
+
// that awaits after the fact still gets it (no dangling/unhandled rejection).
|
|
339
|
+
awaitRegistered(timeoutMs: number): Promise<void> {
|
|
340
|
+
if (this.firstConnectSettled) {
|
|
341
|
+
return this.firstConnectError ? Promise.reject(this.firstConnectError) : Promise.resolve();
|
|
342
|
+
}
|
|
343
|
+
return new Promise<void>((resolve, reject) => {
|
|
344
|
+
const waiter = {
|
|
345
|
+
resolve: () => { clearTimeout(timer); resolve(); },
|
|
346
|
+
reject: (e: Error) => { clearTimeout(timer); reject(e); },
|
|
347
|
+
};
|
|
348
|
+
const timer = setTimeout(() => {
|
|
349
|
+
this.firstConnectWaiters = this.firstConnectWaiters.filter((w) => w !== waiter);
|
|
350
|
+
reject(new Error(`relay registration timed out after ${timeoutMs}ms`));
|
|
351
|
+
}, timeoutMs);
|
|
352
|
+
this.firstConnectWaiters.push(waiter);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private settleFirstConnect(err?: Error): void {
|
|
357
|
+
if (this.firstConnectSettled) return;
|
|
358
|
+
this.firstConnectSettled = true;
|
|
359
|
+
this.firstConnectError = err ?? null;
|
|
360
|
+
const waiters = this.firstConnectWaiters;
|
|
361
|
+
this.firstConnectWaiters = [];
|
|
362
|
+
for (const w of waiters) err ? w.reject(err) : w.resolve();
|
|
363
|
+
}
|
|
364
|
+
|
|
325
365
|
stop(): void {
|
|
326
366
|
this.closed = true;
|
|
367
|
+
// Fail any awaitRegistered() waiter promptly instead of leaving it to time out — a
|
|
368
|
+
// terminal stopped before it ever registered is never coming up.
|
|
369
|
+
this.settleFirstConnect(new Error("relay stopped before registering"));
|
|
327
370
|
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
|
|
328
371
|
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
329
372
|
this.bufKind = null;
|
|
@@ -344,7 +387,11 @@ export class RelayClient {
|
|
|
344
387
|
headers: { "Content-Type": "application/json" },
|
|
345
388
|
body: JSON.stringify({ role: "agent", termId: this.termId, label: this.label }),
|
|
346
389
|
});
|
|
347
|
-
if (!res.ok)
|
|
390
|
+
if (!res.ok) {
|
|
391
|
+
const e: Error & { status?: number } = new Error(`relay ticket HTTP ${res.status}`);
|
|
392
|
+
e.status = res.status;
|
|
393
|
+
throw e;
|
|
394
|
+
}
|
|
348
395
|
const { ticket } = (await res.json()) as { ticket: string };
|
|
349
396
|
|
|
350
397
|
const wsUrl =
|
|
@@ -357,6 +404,7 @@ export class RelayClient {
|
|
|
357
404
|
|
|
358
405
|
ws.on("open", () => {
|
|
359
406
|
opened = true;
|
|
407
|
+
this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
|
|
360
408
|
this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
|
|
361
409
|
});
|
|
362
410
|
ws.on("message", (data) => this.handle(data));
|
|
@@ -381,6 +429,13 @@ export class RelayClient {
|
|
|
381
429
|
// Ticket mint failed (auth/network/route) — surface it; a silent failure
|
|
382
430
|
// looks identical to "connected but ignoring me".
|
|
383
431
|
const msg = err instanceof Error ? err.message : String(err);
|
|
432
|
+
// A 4xx (e.g. 403 concurrency cap) won't self-heal by retrying the same request —
|
|
433
|
+
// fail-fast any awaitRegistered() caller (a live-task spawn) so it stops hanging.
|
|
434
|
+
// The management terminal ignores this signal, so its reconnect behavior is unchanged.
|
|
435
|
+
const status = (err as { status?: number })?.status;
|
|
436
|
+
if (typeof status === "number" && status >= 400 && status < 500) {
|
|
437
|
+
this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
|
|
438
|
+
}
|
|
384
439
|
this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
|
|
385
440
|
this.scheduleReconnect();
|
|
386
441
|
} finally {
|
|
@@ -691,6 +746,13 @@ export class RelayClient {
|
|
|
691
746
|
this.rawSend({ type: "task_spawned", termId, label });
|
|
692
747
|
}
|
|
693
748
|
|
|
749
|
+
// Tell the app a live task spawn FAILED (reply to task_spawn), with a short reason, so the
|
|
750
|
+
// spawn screen can stop waiting and show it — a live spawn otherwise has no failure signal
|
|
751
|
+
// and the app would spin until its own timeout. Fire-and-forget over the management relay.
|
|
752
|
+
sendTaskSpawnError(reason: string): void {
|
|
753
|
+
this.rawSend({ type: "task_spawn_error", reason: safe(reason, 300) });
|
|
754
|
+
}
|
|
755
|
+
|
|
694
756
|
sendEvent(ev: EngineEvent): void {
|
|
695
757
|
if (ev.type === "text") return this.bufferDelta("text", ev.text);
|
|
696
758
|
if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
|
package/src/ui/palette.ts
CHANGED
|
@@ -38,6 +38,7 @@ export type Palette = {
|
|
|
38
38
|
DIM: string; // secondary / muted prose
|
|
39
39
|
GREEN: string; // success (connected, verified, context loaded)
|
|
40
40
|
YELLOW: string; // warning (not-signed-in, unconfirmed, update available)
|
|
41
|
+
RED: string; // alarm (the moat lowered — no-quarter)
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
// Last-resort palette for when no theme is reachable (headless surfaces have no banner,
|
|
@@ -52,6 +53,7 @@ export const FALLBACK: Palette = {
|
|
|
52
53
|
DIM: `${ESC}90m`,
|
|
53
54
|
GREEN: `${ESC}32m`,
|
|
54
55
|
YELLOW: `${ESC}33m`,
|
|
56
|
+
RED: `${ESC}31m`,
|
|
55
57
|
};
|
|
56
58
|
|
|
57
59
|
// Build a Palette from a Pi Theme. getFgAnsi(name) returns the raw SGR foreground escape
|
|
@@ -76,6 +78,7 @@ export function paletteFor(theme: any): Palette {
|
|
|
76
78
|
DIM: g("dim", FALLBACK.DIM),
|
|
77
79
|
GREEN: g("success", FALLBACK.GREEN),
|
|
78
80
|
YELLOW: g("warning", FALLBACK.YELLOW),
|
|
81
|
+
RED: g("error", FALLBACK.RED),
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
|