talon-agent 3.31.0 → 3.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.31.0",
3
+ "version": "3.32.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -0,0 +1,181 @@
1
+ /**
2
+ * CompanionPairStore — one-time grants that connect a phone to this bridge
3
+ * without anyone typing a host, a port, or a bearer token.
4
+ *
5
+ * The problem it removes: a companion joins the mesh by being told the
6
+ * bridge's address, its bearer token, and (over TLS) the certificate
7
+ * fingerprint to pin. Reading those off a terminal and typing them into a
8
+ * phone is the single worst step in setting Talon up, and a mistyped token
9
+ * fails in a way that looks like a network problem.
10
+ *
11
+ * So the daemon mints a grant and hands out ONE link. The bridge serves it
12
+ * on a route that must work pre-auth — the phone holds no credential yet, so
13
+ * the grant token IS the authorization, the same trust model as node
14
+ * provisioning (node-provision.ts) and streamed transfers (transfers.ts):
15
+ * random 192-bit, single-use, expiring unused.
16
+ *
17
+ * GET /pair?grant=<token> → the pairing page (once)
18
+ * GET /pair?grant=<token>&format=json → the same payload as JSON
19
+ *
20
+ * Opening the link on the phone lands on a page whose button is a
21
+ * `talon://pair` deep link carrying the credentials, so the companion fills
22
+ * its own connection form. The page also prints the values for anyone who
23
+ * would rather type them, because a deep link is exactly the thing that
24
+ * silently does nothing when the app isn't installed.
25
+ *
26
+ * The grant is consumed by whichever leg is served first, and both legs
27
+ * render the same claimed grant — by the time a page has rendered, the
28
+ * credentials are already on the phone, so a second serve would only widen
29
+ * the window for someone else to replay the URL.
30
+ */
31
+
32
+ import { randomBytes } from "node:crypto";
33
+
34
+ /** Unclaimed grants die after this long. A pairing link is used at once. */
35
+ const GRANT_TTL_MS = 10 * 60 * 1000;
36
+
37
+ export type CompanionPairGrant = {
38
+ token: string;
39
+ /** Bridge base URL as reachable from the phone. */
40
+ bridgeUrl: string;
41
+ /** Bearer token the companion will authenticate with. */
42
+ bearerToken: string;
43
+ /** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
44
+ fingerprint?: string;
45
+ /** What this daemon calls itself, so the phone can label the connection. */
46
+ label?: string;
47
+ createdAt: number;
48
+ used: boolean;
49
+ };
50
+
51
+ /** What the phone needs to connect, as served to the companion. */
52
+ export type CompanionPairPayload = {
53
+ url: string;
54
+ token: string;
55
+ fingerprint?: string;
56
+ label?: string;
57
+ };
58
+
59
+ export class CompanionPairStore {
60
+ private readonly grants = new Map<string, CompanionPairGrant>();
61
+
62
+ constructor(private readonly ttlMs = GRANT_TTL_MS) {}
63
+
64
+ create(
65
+ grant: Omit<CompanionPairGrant, "token" | "createdAt" | "used">,
66
+ ): CompanionPairGrant {
67
+ this.sweep();
68
+ const full: CompanionPairGrant = {
69
+ ...grant,
70
+ // The label is interpolated into generated HTML and a URL query —
71
+ // keep it to characters that can't carry markup out of either.
72
+ ...(grant.label
73
+ ? { label: grant.label.replace(/[^\w .-]+/g, "").slice(0, 64) }
74
+ : {}),
75
+ token: randomBytes(24).toString("base64url"),
76
+ createdAt: Date.now(),
77
+ used: false,
78
+ };
79
+ this.grants.set(full.token, full);
80
+ return full;
81
+ }
82
+
83
+ /** Resolve a live grant — once. */
84
+ claim(token: string): CompanionPairPayload | null {
85
+ this.sweep();
86
+ const grant = this.grants.get(token);
87
+ if (!grant || grant.used) return null;
88
+ grant.used = true;
89
+ this.grants.delete(token);
90
+ return {
91
+ url: grant.bridgeUrl,
92
+ token: grant.bearerToken,
93
+ ...(grant.fingerprint ? { fingerprint: grant.fingerprint } : {}),
94
+ ...(grant.label ? { label: grant.label } : {}),
95
+ };
96
+ }
97
+
98
+ private sweep(): void {
99
+ const cutoff = Date.now() - this.ttlMs;
100
+ for (const [token, grant] of this.grants) {
101
+ if (grant.createdAt < cutoff) this.grants.delete(token);
102
+ }
103
+ }
104
+ }
105
+
106
+ /** The link a human opens on the phone. */
107
+ export function pairLink(grant: CompanionPairGrant): string {
108
+ return `${grant.bridgeUrl}/pair?grant=${grant.token}`;
109
+ }
110
+
111
+ /**
112
+ * The `talon://pair` deep link the companion registers for. Carries the
113
+ * credentials themselves rather than the grant, because the grant is spent
114
+ * by the time this reaches the phone — and because a deep link that needed
115
+ * another round trip would fail on exactly the flaky first connection it
116
+ * exists to make painless.
117
+ */
118
+ export function pairDeepLink(payload: CompanionPairPayload): string {
119
+ const q = new URLSearchParams({ u: payload.url, t: payload.token });
120
+ if (payload.fingerprint) q.set("f", payload.fingerprint);
121
+ if (payload.label) q.set("n", payload.label);
122
+ return `talon://pair?${q.toString()}`;
123
+ }
124
+
125
+ /** Minimal HTML escape for the values interpolated into the pairing page. */
126
+ function esc(s: string): string {
127
+ return s
128
+ .replace(/&/g, "&amp;")
129
+ .replace(/</g, "&lt;")
130
+ .replace(/>/g, "&gt;")
131
+ .replace(/"/g, "&quot;");
132
+ }
133
+
134
+ /**
135
+ * The page the phone lands on. Deliberately one self-contained file with no
136
+ * external assets: it is served by a bridge the browser has usually just
137
+ * warned about (self-signed cert), often over a LAN with no internet route,
138
+ * and a page that half-loads there is worse than no page at all.
139
+ */
140
+ export function pairPage(payload: CompanionPairPayload): string {
141
+ const deep = pairDeepLink(payload);
142
+ const fp = payload.fingerprint;
143
+ return `<!doctype html>
144
+ <html lang="en">
145
+ <head>
146
+ <meta charset="utf-8">
147
+ <meta name="viewport" content="width=device-width, initial-scale=1">
148
+ <title>Connect to Talon</title>
149
+ <style>
150
+ :root { color-scheme: light dark; }
151
+ body { margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif; }
152
+ main { max-width: 32rem; margin: 0 auto; }
153
+ h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
154
+ p.sub { margin: 0 0 1.5rem; opacity: .7; }
155
+ a.go { display: block; text-align: center; padding: 14px; border-radius: 10px;
156
+ background: #2f6fed; color: #fff; text-decoration: none; font-weight: 600; }
157
+ dl { margin: 1.5rem 0 0; }
158
+ dt { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; opacity: .6; margin-top: 1rem; }
159
+ dd { margin: .2rem 0 0; font-family: ui-monospace, monospace; font-size: .85rem; word-break: break-all; }
160
+ footer { margin-top: 2rem; font-size: .8rem; opacity: .6; }
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <main>
165
+ <h1>Connect to Talon</h1>
166
+ <p class="sub">${payload.label ? esc(payload.label) : "This daemon"} is ready to pair.</p>
167
+ <a class="go" href="${esc(deep)}">Open in Talon</a>
168
+ <dl>
169
+ <dt>Bridge</dt><dd>${esc(payload.url)}</dd>
170
+ <dt>Token</dt><dd>${esc(payload.token)}</dd>
171
+ ${fp ? `<dt>Certificate</dt><dd>${esc(fp)}</dd>` : ""}
172
+ </dl>
173
+ <footer>
174
+ If the button does nothing, the companion isn't installed yet — install it,
175
+ then enter the values above by hand. This link is single-use and is now spent.
176
+ </footer>
177
+ </main>
178
+ </body>
179
+ </html>
180
+ `;
181
+ }
@@ -43,6 +43,12 @@ import {
43
43
  resolveNodeBinary,
44
44
  type NodeBinaryResolver,
45
45
  } from "./node-binaries.js";
46
+ import {
47
+ CompanionPairStore,
48
+ pairLink,
49
+ pairPage,
50
+ type CompanionPairPayload,
51
+ } from "./companion-pairing.js";
46
52
  import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
47
53
  import { MeshRegistry } from "./registry.js";
48
54
  import { TransferStore } from "./transfers.js";
@@ -174,6 +180,8 @@ export class MeshService {
174
180
  private readonly transfers = new TransferStore();
175
181
  /** One-time grants for bridge-served node installers. */
176
182
  private readonly provision = new NodeProvisionStore();
183
+ /** One-time grants that hand a phone this bridge's connection details. */
184
+ private readonly companionPairs = new CompanionPairStore();
177
185
  private readonly resolveNode: NodeBinaryResolver;
178
186
  private bridgeInfo: MeshBridgeInfo | null = null;
179
187
  private readonly freshFixTimeoutMs: number;
@@ -1162,6 +1170,112 @@ export class MeshService {
1162
1170
  };
1163
1171
  }
1164
1172
 
1173
+ /**
1174
+ * Mint a single-use link that connects a phone to this bridge without
1175
+ * anyone typing an address or a token — the companion half of
1176
+ * {@link makeNodeInstallLink}.
1177
+ *
1178
+ * Returns the link plus the values it carries, so a caller (a `/mesh`
1179
+ * reply, say) can print both: the link is the one-tap path, the values are
1180
+ * what someone falls back to when the app isn't installed yet.
1181
+ */
1182
+ makeCompanionPairLink(
1183
+ label?: unknown,
1184
+ bridgeUrl?: unknown,
1185
+ ):
1186
+ | {
1187
+ ok: true;
1188
+ link: string;
1189
+ url: string;
1190
+ token: string;
1191
+ fingerprint?: string;
1192
+ }
1193
+ | { ok: false; text: string } {
1194
+ const info = this.bridgeInfo;
1195
+ if (!info) {
1196
+ return {
1197
+ ok: false,
1198
+ text: "The native bridge isn't running, so there is nothing for a phone to connect to. Enable the native frontend first.",
1199
+ };
1200
+ }
1201
+ if (!info.token) {
1202
+ return {
1203
+ ok: false,
1204
+ text: "The bridge has no bearer token (loopback-only bind), and companions authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
1205
+ };
1206
+ }
1207
+ const base = this.bridgeBaseUrl(info, bridgeUrl);
1208
+ if (typeof base !== "string") return { ok: false, text: base.error };
1209
+ const grant = this.companionPairs.create({
1210
+ bridgeUrl: base,
1211
+ bearerToken: info.token,
1212
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1213
+ ...(typeof label === "string" && label.trim()
1214
+ ? { label: label.trim() }
1215
+ : {}),
1216
+ });
1217
+ return {
1218
+ ok: true,
1219
+ link: pairLink(grant),
1220
+ url: base,
1221
+ token: info.token,
1222
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1223
+ };
1224
+ }
1225
+
1226
+ /**
1227
+ * GET /pair — serve a pairing grant (single-use), as the landing page or
1228
+ * as the JSON the companion reads.
1229
+ */
1230
+ openCompanionPair(
1231
+ token: string,
1232
+ format: "html" | "json",
1233
+ ): { contentType: string; body: string } | null {
1234
+ const payload = this.companionPairs.claim(token);
1235
+ if (!payload) return null;
1236
+ return format === "json"
1237
+ ? {
1238
+ contentType: "application/json; charset=utf-8",
1239
+ body: JSON.stringify(payload satisfies CompanionPairPayload),
1240
+ }
1241
+ : {
1242
+ contentType: "text/html; charset=utf-8",
1243
+ body: pairPage(payload),
1244
+ };
1245
+ }
1246
+
1247
+ /**
1248
+ * How this bridge is reachable, for an operator asking "what do I point a
1249
+ * device at?".
1250
+ *
1251
+ * The bearer token comes back with it. Deciding who may see a credential is
1252
+ * the caller's job, not this seam's — the Telegram `/mesh` shows it to the
1253
+ * configured admin and withholds it from everyone else — and an operator who
1254
+ * asked their own daemon for its own connection details should get an
1255
+ * answer, not a lecture.
1256
+ */
1257
+ bridgeReachability():
1258
+ | {
1259
+ ok: true;
1260
+ url: string;
1261
+ authRequired: boolean;
1262
+ token?: string;
1263
+ fingerprint?: string;
1264
+ }
1265
+ | { ok: false; text: string } {
1266
+ const info = this.bridgeInfo;
1267
+ if (!info) return { ok: false, text: "The native bridge isn't running." };
1268
+ const base = this.bridgeBaseUrl(info);
1269
+ if (typeof base !== "string") return { ok: false, text: base.error };
1270
+ return {
1271
+ ok: true,
1272
+ url: base,
1273
+ authRequired: Boolean(info.token),
1274
+ ...(info.token ? { token: info.token } : {}),
1275
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1276
+ };
1277
+ }
1278
+
1165
1279
  /** GET /node/install — serve a grant's installer script (single-use). */
1166
1280
  openNodeInstall(token: string): { script: string; filename: string } | null {
1167
1281
  return this.provision.openScript(token);
@@ -1263,6 +1263,7 @@ export function createNativeFrontend(
1263
1263
  mesh.acceptFileUpload(token, body, fromDeviceId),
1264
1264
  openFileDownload: (token, fromDeviceId) =>
1265
1265
  mesh.openFileDownload(token, fromDeviceId),
1266
+ openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
1266
1267
  openNodeInstall: (token) => mesh.openNodeInstall(token),
1267
1268
  openNodeBinary: (token) => mesh.openNodeBinary(token),
1268
1269
  };
@@ -145,6 +145,11 @@ export type BridgeServerHandlers = {
145
145
  token: string,
146
146
  fromDeviceId?: string,
147
147
  ): Promise<{ path: string; size: number } | null>;
148
+ /** Resolve a companion-pairing grant to its page/payload, or null. */
149
+ openCompanionPair(
150
+ token: string,
151
+ format: "html" | "json",
152
+ ): { contentType: string; body: string } | null;
148
153
  /** Resolve a node-provisioning token to its installer script, or null. */
149
154
  openNodeInstall(token: string): { script: string; filename: string } | null;
150
155
  /** Resolve a node-provisioning token to the binary to stream, or null. */
@@ -464,6 +469,34 @@ export class BridgeServer {
464
469
  });
465
470
  }
466
471
 
472
+ // Companion pairing is PRE-AUTH for the same reason as node
473
+ // provisioning below: the phone holds no bridge credential yet, and the
474
+ // single-use grant is what it comes to collect. Serving the page IS the
475
+ // handover, so the grant is spent whichever leg is hit.
476
+ if (method === "GET" && path === "/pair") {
477
+ const token = url.searchParams.get("grant") ?? "";
478
+ const wantsJson =
479
+ url.searchParams.get("format") === "json" ||
480
+ (req.headers.accept ?? "").includes("application/json");
481
+ const served = token
482
+ ? this.handlers.openCompanionPair(token, wantsJson ? "json" : "html")
483
+ : null;
484
+ if (!served) {
485
+ return this.json(res, 404, {
486
+ ok: false,
487
+ error: "Unknown, expired, or already-used pairing link",
488
+ });
489
+ }
490
+ res.writeHead(200, {
491
+ "Content-Type": served.contentType,
492
+ // A pairing payload is a credential; nothing may keep a copy.
493
+ "Cache-Control": "no-store",
494
+ ...this.corsHeaders(),
495
+ });
496
+ res.end(served.body);
497
+ return;
498
+ }
499
+
467
500
  // Node provisioning runs PRE-AUTH by design: the target host holds no
468
501
  // bridge credential yet — the single-use grant token (minted by
469
502
  // make_node_install_link, expiring, one serve per leg) is the entire
@@ -22,7 +22,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
22
22
  },
23
23
  { command: "status", description: "Session info, usage, and stats" },
24
24
  { command: "ping", description: "Health check with latency" },
25
- { command: "mesh", description: "Ping and list mesh devices" },
25
+ {
26
+ command: "mesh",
27
+ description: "Mesh devices; /mesh link pairs a new one",
28
+ },
26
29
  { command: "model", description: "Show or change model" },
27
30
  { command: "effort", description: "Set thinking effort level" },
28
31
  { command: "pulse", description: "Conversation engagement settings" },
@@ -2,10 +2,16 @@
2
2
  * Informational commands — /start, /help, /ping, /plugins.
3
3
  */
4
4
 
5
- import type { Bot } from "grammy";
5
+ import type { Bot, Context } from "grammy";
6
6
  import { isUserClientReady } from "../userbot.js";
7
7
  import { escapeHtml } from "../formatting.js";
8
- import { formatDuration, renderMeshReport } from "../helpers/index.js";
8
+ import {
9
+ formatDuration,
10
+ renderMeshPairLink,
11
+ renderMeshReport,
12
+ type MeshReachability,
13
+ } from "../helpers/index.js";
14
+ import { isAuthorizedAdmin } from "./state.js";
9
15
  import { getLoadedPlugins } from "../../../core/plugin/index.js";
10
16
  import { getMeshService } from "../../../core/mesh/index.js";
11
17
  import type { MeshPingResult } from "../../../core/mesh/service.js";
@@ -104,7 +110,49 @@ export function registerInfoCommands(bot: Bot): void {
104
110
  }
105
111
  });
106
112
 
113
+ /**
114
+ * The bridge footer `/mesh` prints for this caller.
115
+ *
116
+ * The admin gets the whole connection profile — URL, bearer token,
117
+ * certificate — because an operator asking their own daemon how to reach
118
+ * itself should get an answer rather than a scavenger hunt through config
119
+ * files. Everyone else gets the address only: a group member reading the
120
+ * fleet has no business holding the key to it.
121
+ */
122
+ const bridgeFor = (ctx: Context): MeshReachability => {
123
+ const reach = getMeshService().bridgeReachability();
124
+ if (!reach.ok || isAuthorizedAdmin(ctx)) return reach;
125
+ return {
126
+ ok: true,
127
+ url: reach.url,
128
+ authRequired: reach.authRequired,
129
+ };
130
+ };
131
+
107
132
  bot.command("mesh", async (ctx) => {
133
+ const arg = (ctx.match ?? "").toString().trim();
134
+ // `/mesh link` mints a bridge credential and posts it into the chat, so
135
+ // it is admin-gated even though plain `/mesh` is not — reading the fleet
136
+ // is not the same act as handing out the keys to it.
137
+ if (/^(link|pair)\b/i.test(arg)) {
138
+ if (!isAuthorizedAdmin(ctx)) {
139
+ await ctx.reply("Only the configured admin can mint a pairing link.");
140
+ return;
141
+ }
142
+ // `/mesh link Car` names the connection on the phone; with no name it
143
+ // inherits the bot's, which is what the operator already calls this
144
+ // daemon everywhere else.
145
+ const named = arg.replace(/^(link|pair)\b/i, "").trim();
146
+ const minted = getMeshService().makeCompanionPairLink(
147
+ named || ctx.me.first_name,
148
+ );
149
+ await ctx.reply(renderMeshPairLink(minted), {
150
+ parse_mode: "HTML",
151
+ link_preview_options: { is_disabled: true },
152
+ });
153
+ return;
154
+ }
155
+
108
156
  const sent = await ctx.reply("Pinging mesh devices…");
109
157
  let results: MeshPingResult[];
110
158
  try {
@@ -122,7 +170,7 @@ export function registerInfoCommands(bot: Bot): void {
122
170
  bot,
123
171
  ctx.chat.id,
124
172
  sent.message_id,
125
- renderMeshReport(results),
173
+ renderMeshReport(results, Date.now(), bridgeFor(ctx)),
126
174
  );
127
175
  });
128
176
 
@@ -310,9 +310,18 @@ function meshDeviceLine(r: MeshPingResult, now: number): string {
310
310
  export function renderMeshReport(
311
311
  results: MeshPingResult[],
312
312
  now = Date.now(),
313
+ bridge?: MeshReachability,
313
314
  ): string {
314
315
  if (results.length === 0) {
315
- return "<b>Mesh</b>\n\n<i>No devices have registered yet.</i>";
316
+ // An empty fleet is the one case where "what do I point a device at?" is
317
+ // the only useful thing to say, so the bridge line carries the answer
318
+ // instead of leaving the operator to hunt for host and port.
319
+ return [
320
+ "<b>Mesh</b>",
321
+ "",
322
+ "<i>No devices have registered yet.</i>",
323
+ ...bridgeLines(bridge),
324
+ ].join("\n");
316
325
  }
317
326
 
318
327
  const responding = results
@@ -344,6 +353,76 @@ export function renderMeshReport(
344
353
  section("Responding", responding);
345
354
  section("Unreachable", unreachable);
346
355
  section("Offline", offline);
356
+ lines.push(...bridgeLines(bridge));
347
357
 
348
358
  return lines.join("\n");
349
359
  }
360
+
361
+ /** What `/mesh` says about the bridge a new device would dial. */
362
+ export type MeshReachability =
363
+ | {
364
+ ok: true;
365
+ url: string;
366
+ authRequired: boolean;
367
+ token?: string;
368
+ fingerprint?: string;
369
+ }
370
+ | { ok: false; text: string };
371
+
372
+ /**
373
+ * The bridge footer.
374
+ *
375
+ * The bearer token and certificate appear when the caller passed them, which
376
+ * `/mesh` does for the configured admin and not for anyone else: an operator
377
+ * asking their own daemon how to reach itself should get the answer, while a
378
+ * group member reading the fleet has no business holding the key to it.
379
+ */
380
+ function bridgeLines(bridge?: MeshReachability): string[] {
381
+ if (!bridge) return [];
382
+ if (!bridge.ok) return ["", `<b>Bridge</b> — ${escapeHtml(bridge.text)}`];
383
+ const auth = bridge.authRequired ? "token required" : "no token";
384
+ return [
385
+ "",
386
+ `<b>Bridge</b> <code>${escapeHtml(bridge.url)}</code> · ${auth}`,
387
+ ...(bridge.token ? [`Token <code>${escapeHtml(bridge.token)}</code>`] : []),
388
+ ...(bridge.fingerprint
389
+ ? [`Certificate <code>${escapeHtml(bridge.fingerprint)}</code>`]
390
+ : []),
391
+ "Run <code>/mesh link</code> for a one-tap pairing link.",
392
+ ];
393
+ }
394
+
395
+ /**
396
+ * Render a minted pairing link, or why one couldn't be minted.
397
+ *
398
+ * The values are printed alongside the link because the link's whole payoff
399
+ * — the phone configuring itself — depends on the companion already being
400
+ * installed, and the fallback needs to be right there when it isn't.
401
+ */
402
+ export function renderMeshPairLink(
403
+ minted:
404
+ | {
405
+ ok: true;
406
+ link: string;
407
+ url: string;
408
+ token: string;
409
+ fingerprint?: string;
410
+ }
411
+ | { ok: false; text: string },
412
+ ): string {
413
+ if (!minted.ok) {
414
+ return `<b>Pairing</b>\n\n${escapeHtml(minted.text)}`;
415
+ }
416
+ return [
417
+ "<b>Pair a device</b>",
418
+ "",
419
+ `Open on the phone: ${escapeHtml(minted.link)}`,
420
+ "",
421
+ "Single-use, expires in 10 minutes. If the companion isn't installed yet:",
422
+ ` Bridge <code>${escapeHtml(minted.url)}</code>`,
423
+ ` Token <code>${escapeHtml(minted.token)}</code>`,
424
+ ...(minted.fingerprint
425
+ ? [` Certificate <code>${escapeHtml(minted.fingerprint)}</code>`]
426
+ : []),
427
+ ].join("\n");
428
+ }