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.
@@ -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: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
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) throw new Error(`relay ticket HTTP ${res.status}`);
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