talon-agent 1.49.0 → 1.50.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.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/backend/codex/factory.ts +2 -0
  4. package/src/backend/codex/handler/message.ts +10 -0
  5. package/src/backend/kilo/factory.ts +2 -0
  6. package/src/backend/kilo/handler/message.ts +10 -0
  7. package/src/backend/openai-agents/factory.ts +2 -0
  8. package/src/backend/openai-agents/handler/message.ts +10 -0
  9. package/src/backend/opencode/factory.ts +2 -0
  10. package/src/backend/opencode/handler/message.ts +10 -0
  11. package/src/backend/shared/index.ts +4 -0
  12. package/src/backend/shared/turn-interrupt.ts +61 -0
  13. package/src/bootstrap.ts +5 -0
  14. package/src/cli/events.ts +47 -12
  15. package/src/cli/fs.ts +138 -0
  16. package/src/cli/index.ts +31 -7
  17. package/src/cli/tasks.ts +85 -20
  18. package/src/core/engine/gateway-actions/index.ts +3 -0
  19. package/src/core/engine/gateway-actions/native.ts +151 -24
  20. package/src/core/engine/gateway-actions/vfs.ts +69 -0
  21. package/src/core/engine/gateway.ts +28 -0
  22. package/src/core/tasks/table.ts +1 -1
  23. package/src/core/tasks/types.ts +6 -2
  24. package/src/core/tools/index.ts +2 -0
  25. package/src/core/tools/native.ts +19 -6
  26. package/src/core/tools/types.ts +2 -1
  27. package/src/core/tools/vfs.ts +61 -0
  28. package/src/core/vfs/index.ts +113 -0
  29. package/src/core/vfs/mounts/files.ts +154 -0
  30. package/src/core/vfs/mounts/plugins.ts +72 -0
  31. package/src/core/vfs/mounts/proc.ts +125 -0
  32. package/src/core/vfs/types.ts +103 -0
  33. package/src/core/vfs/vfs.ts +237 -0
  34. package/src/core/weaver/weaver.ts +40 -3
  35. package/src/frontend/native/discovery.ts +3 -0
  36. package/src/frontend/native/index.ts +10 -1
  37. package/src/frontend/native/server.ts +65 -6
  38. package/src/frontend/native/tls.ts +313 -0
  39. package/src/storage/journal.ts +91 -0
  40. package/src/storage/repositories/journal-repo.ts +38 -0
  41. package/src/storage/sql/journal.sql +16 -0
  42. package/src/storage/sql/schema.sql +15 -0
  43. package/src/storage/sql/statements.generated.ts +24 -1
  44. package/src/util/config.ts +9 -0
  45. package/src/util/log.ts +1 -0
  46. package/src/util/paths.ts +2 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * VFS vocabulary — the unified `talon://` namespace.
3
+ *
4
+ * One rooted, mountable namespace over everything the daemon owns, in the
5
+ * Plan 9 tradition: real-file stores (workspace, skills, scripts, logs)
6
+ * and synthetic /proc-style views of live state (task table, event bus,
7
+ * plugin registry) answer the same four operations. The namespace is for
8
+ * unification and introspection — mounts decide what they can do (a
9
+ * synthetic view has nothing meaningful to write), but the VFS is never a
10
+ * permission layer.
11
+ *
12
+ * An address reaches the namespace in one of three spellings, all resolved
13
+ * by the same grammar (see `Vfs.#parse`):
14
+ *
15
+ * - scheme: `talon://skills/review-pr/SKILL.md`
16
+ * - mount-relative: `skills/review-pr/SKILL.md` (`/`-separated everywhere)
17
+ * - OS-absolute: the disk spelling of a node inside a file-backed
18
+ * mount's root — the same node, other coordinate system
19
+ *
20
+ * Mounts receive mount-relative paths ("" = the mount root) and report
21
+ * entry paths mount-relative too — the resolver owns prefixing.
22
+ */
23
+
24
+ /** What a path points at. */
25
+ export type VfsNodeKind = "dir" | "file";
26
+
27
+ /** Errno-style failure vocabulary — every operation fails with one of these. */
28
+ export type VfsErrorCode =
29
+ | "not-found"
30
+ | "invalid-path"
31
+ | "is-a-directory"
32
+ | "not-a-directory"
33
+ | "not-writable"
34
+ | "too-large"
35
+ | "binary-file"
36
+ | "io-error";
37
+
38
+ export type VfsResult<T> =
39
+ | { readonly ok: true; readonly value: T }
40
+ | {
41
+ readonly ok: false;
42
+ readonly error: VfsErrorCode;
43
+ readonly detail?: string;
44
+ };
45
+
46
+ export function vfsOk<T>(value: T): VfsResult<T> {
47
+ return { ok: true, value };
48
+ }
49
+
50
+ export function vfsError<T>(
51
+ error: VfsErrorCode,
52
+ detail?: string,
53
+ ): VfsResult<T> {
54
+ return { ok: false, error, ...(detail !== undefined ? { detail } : {}) };
55
+ }
56
+
57
+ /** One node's metadata — also the shape of directory listing entries. */
58
+ export interface VfsStat {
59
+ /** Root-relative path (`skills/review-pr/SKILL.md`); "" is the root. */
60
+ readonly path: string;
61
+ /** Last path segment; the mount name for a mount root. */
62
+ readonly name: string;
63
+ readonly kind: VfsNodeKind;
64
+ /** Content size in bytes, when the mount can know it cheaply. */
65
+ readonly size?: number;
66
+ /** Last modification, epoch ms, when known. */
67
+ readonly modifiedAt?: number;
68
+ /** Whether write() can target this node (or nodes under this dir). */
69
+ readonly writable: boolean;
70
+ /**
71
+ * Where this node lives on disk, for file-backed mounts. Absent on
72
+ * synthetic nodes — they have no disk representation. This is what lets
73
+ * OS-level tools (shell, editors) reach a node the namespace named.
74
+ */
75
+ readonly osPath?: string;
76
+ }
77
+
78
+ /**
79
+ * One mounted store. Implementations receive mount-relative paths that the
80
+ * resolver has already normalized (no "..", no backslashes, no empty
81
+ * segments) and return stats with mount-relative `path`s.
82
+ */
83
+ export interface VfsMount {
84
+ /** One line shown when listing the namespace root. */
85
+ readonly description: string;
86
+ /** Can write() ever succeed here? Synthetic views say false. */
87
+ readonly writable: boolean;
88
+ /**
89
+ * Absolute disk root, for file-backed mounts. The single fact that makes
90
+ * addresses bidirectional: the resolver routes OS-absolute addresses into
91
+ * the mount by containment, and `Vfs.locate` maps namespace addresses
92
+ * back to disk. Synthetic mounts omit it.
93
+ */
94
+ readonly osRoot?: string;
95
+ stat(rel: string): VfsResult<VfsStat>;
96
+ list(rel: string): VfsResult<VfsStat[]>;
97
+ read(rel: string): VfsResult<string>;
98
+ /** Optional — absent means the whole mount is read-only. */
99
+ write?(rel: string, content: string): VfsResult<VfsStat>;
100
+ }
101
+
102
+ /** Read cap — a namespace read is a context payload, not a file transfer. */
103
+ export const VFS_MAX_READ_BYTES = 256 * 1024;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * VFS resolver — the mount table and the address grammar.
3
+ *
4
+ * The resolver owns everything mounts shouldn't have to repeat: address
5
+ * parsing, normalization, traversal rejection, routing to the mount, and
6
+ * re-prefixing the mount-relative stats that come back. Mounts therefore
7
+ * only ever see clean relative paths.
8
+ *
9
+ * An address is one of three spellings of the same node:
10
+ *
11
+ * talon://home/notes.md scheme form — always namespace-interpreted
12
+ * home/notes.md mount-relative form
13
+ * <workspace>/notes.md OS-absolute form — routed through the mount
14
+ * table by containment, exactly like a kernel
15
+ * resolving a path through its mounts
16
+ *
17
+ * The grammar is total: an unschemed address starting with a separator or
18
+ * drive prefix is always an OS path, never mount-relative — so an OS path
19
+ * can't silently misroute into a like-named mount, and a namespace path
20
+ * can't be mistaken for disk.
21
+ */
22
+
23
+ import { isAbsolute, relative, resolve, sep } from "node:path";
24
+ import type { VfsMount, VfsResult, VfsStat } from "./types.js";
25
+ import { vfsError, vfsOk } from "./types.js";
26
+
27
+ const SCHEME = "talon://";
28
+
29
+ /** OS-absolute spellings: `/x`, `\\server\x`, `C:\x`, `C:/x`. */
30
+ const OS_ABSOLUTE = /^(?:[\\/]|[A-Za-z]:[\\/])/;
31
+
32
+ /** A parsed path: which mount, and the path inside it. */
33
+ type Resolved = { mount: VfsMount; prefix: string; rel: string };
34
+
35
+ export class Vfs {
36
+ readonly #mounts = new Map<string, VfsMount>();
37
+
38
+ /** Register a mount under a single-segment name ("skills", "proc"). */
39
+ mount(name: string, mount: VfsMount): void {
40
+ if (!/^[a-z0-9-]+$/.test(name)) {
41
+ throw new Error(`Invalid mount name: "${name}"`);
42
+ }
43
+ if (this.#mounts.has(name)) {
44
+ throw new Error(`Mount "${name}" already registered`);
45
+ }
46
+ this.#mounts.set(name, mount);
47
+ }
48
+
49
+ stat(path: string): VfsResult<VfsStat> {
50
+ const parsed = this.#parse(path);
51
+ if (!parsed.ok) return parsed;
52
+ if (parsed.value === null) {
53
+ return vfsOk({
54
+ path: "",
55
+ name: SCHEME,
56
+ kind: "dir" as const,
57
+ writable: false,
58
+ });
59
+ }
60
+ const { mount, prefix, rel } = parsed.value;
61
+ const result = mount.stat(rel);
62
+ return result.ok ? vfsOk(withPrefix(result.value, prefix)) : result;
63
+ }
64
+
65
+ list(path: string): VfsResult<VfsStat[]> {
66
+ const parsed = this.#parse(path);
67
+ if (!parsed.ok) return parsed;
68
+ if (parsed.value === null) {
69
+ return vfsOk(
70
+ [...this.#mounts.entries()].map(([name, mount]) => ({
71
+ path: name,
72
+ name,
73
+ kind: "dir" as const,
74
+ writable: mount.writable,
75
+ ...(mount.osRoot !== undefined ? { osPath: mount.osRoot } : {}),
76
+ })),
77
+ );
78
+ }
79
+ const { mount, prefix, rel } = parsed.value;
80
+ const result = mount.list(rel);
81
+ return result.ok
82
+ ? vfsOk(result.value.map((entry) => withPrefix(entry, prefix)))
83
+ : result;
84
+ }
85
+
86
+ read(path: string): VfsResult<string> {
87
+ const parsed = this.#parse(path);
88
+ if (!parsed.ok) return parsed;
89
+ if (parsed.value === null) return vfsError("is-a-directory");
90
+ return parsed.value.mount.read(parsed.value.rel);
91
+ }
92
+
93
+ write(path: string, content: string): VfsResult<VfsStat> {
94
+ const parsed = this.#parse(path);
95
+ if (!parsed.ok) return parsed;
96
+ if (parsed.value === null) return vfsError("is-a-directory");
97
+ const { mount, prefix, rel } = parsed.value;
98
+ if (!mount.write) {
99
+ return vfsError("not-writable", `talon://${prefix} is read-only`);
100
+ }
101
+ const result = mount.write(rel, content);
102
+ return result.ok ? vfsOk(withPrefix(result.value, prefix)) : result;
103
+ }
104
+
105
+ /**
106
+ * Where an address lives on disk: the absolute path for nodes of
107
+ * file-backed mounts (whether or not the node exists yet), `undefined`
108
+ * for addresses with no disk representation (the root, synthetic
109
+ * mounts). This is the namespace→OS direction of the address grammar —
110
+ * OS-level tools call it to run real file operations on a talon://
111
+ * address.
112
+ */
113
+ locate(path: string): VfsResult<string | undefined> {
114
+ const parsed = this.#parse(path);
115
+ if (!parsed.ok) return parsed;
116
+ if (parsed.value === null) return vfsOk(undefined);
117
+ const { mount, rel } = parsed.value;
118
+ if (mount.osRoot === undefined) return vfsOk(undefined);
119
+ return vfsOk(
120
+ rel === "" ? mount.osRoot : resolve(mount.osRoot, ...rel.split("/")),
121
+ );
122
+ }
123
+
124
+ /** Mount names + descriptions, for docs/tool output. */
125
+ describeMounts(): { name: string; description: string; writable: boolean }[] {
126
+ return [...this.#mounts.entries()].map(([name, mount]) => ({
127
+ name,
128
+ description: mount.description,
129
+ writable: mount.writable,
130
+ }));
131
+ }
132
+
133
+ /** null = the namespace root. */
134
+ #parse(raw: string): VfsResult<Resolved | null> {
135
+ let path = raw.trim();
136
+ const schemed = path.startsWith(SCHEME);
137
+ if (schemed) {
138
+ path = path.slice(SCHEME.length);
139
+ } else {
140
+ // Bare separators address the namespace root ("talon ls /").
141
+ if (/^[\\/]+$/.test(path)) return vfsOk(null);
142
+ if (OS_ABSOLUTE.test(path)) return this.#parseOsPath(path);
143
+ if (path.startsWith("~")) {
144
+ return vfsError(
145
+ "invalid-path",
146
+ '"~" is not expanded — use an absolute path or a talon:// address',
147
+ );
148
+ }
149
+ }
150
+ if (path.includes("\\")) {
151
+ return vfsError(
152
+ "invalid-path",
153
+ "Namespace paths use / on every platform",
154
+ );
155
+ }
156
+ const segments = path.split("/").filter((s) => s.length > 0);
157
+ if (segments.some((s) => s === "." || s === "..")) {
158
+ return vfsError("invalid-path", "Relative segments are not allowed");
159
+ }
160
+ if (segments.length === 0) return vfsOk(null);
161
+
162
+ const [head, ...rest] = segments;
163
+ const mount = this.#mounts.get(head!);
164
+ if (!mount) {
165
+ return vfsError(
166
+ "not-found",
167
+ `No mount "${head}" — the root lists what exists`,
168
+ );
169
+ }
170
+ return vfsOk({ mount, prefix: head!, rel: rest.join("/") });
171
+ }
172
+
173
+ /**
174
+ * Route an OS-absolute address through the mount table by containment —
175
+ * the most specific (longest) disk root wins, so a mount nested inside
176
+ * another's root (skills/ inside the workspace) claims its own subtree.
177
+ * Outside every mount there is nothing to address: refuse with the mount
178
+ * table rather than guess.
179
+ */
180
+ #parseOsPath(osPath: string): VfsResult<Resolved> {
181
+ // isAbsolute guards the host boundary: a foreign spelling (a drive path
182
+ // on POSIX) can't be resolved against this host's roots.
183
+ if (isAbsolute(osPath)) {
184
+ const abs = resolve(osPath);
185
+ let best: (Resolved & { rootLength: number }) | undefined;
186
+ for (const [name, mount] of this.#mounts) {
187
+ if (mount.osRoot === undefined) continue;
188
+ const offset = relative(mount.osRoot, abs);
189
+ if (
190
+ offset === ".." ||
191
+ offset.startsWith(`..${sep}`) ||
192
+ isAbsolute(offset)
193
+ ) {
194
+ continue;
195
+ }
196
+ if (best !== undefined && mount.osRoot.length <= best.rootLength) {
197
+ continue;
198
+ }
199
+ best = {
200
+ mount,
201
+ prefix: name,
202
+ rel: offset
203
+ .split(sep)
204
+ .filter((s) => s.length > 0)
205
+ .join("/"),
206
+ rootLength: mount.osRoot.length,
207
+ };
208
+ }
209
+ if (best !== undefined) {
210
+ return vfsOk({ mount: best.mount, prefix: best.prefix, rel: best.rel });
211
+ }
212
+ }
213
+ const respelled = osPath
214
+ .split(/[\\/]+/)
215
+ .filter((s) => s.length > 0 && !/^[A-Za-z]:$/.test(s));
216
+ if (respelled[0] !== undefined && this.#mounts.has(respelled[0])) {
217
+ return vfsError(
218
+ "not-found",
219
+ `OS paths resolve only inside a mounted directory — did you mean "talon://${respelled.join("/")}"?`,
220
+ );
221
+ }
222
+ const roots = [...this.#mounts.entries()]
223
+ .filter(([, mount]) => mount.osRoot !== undefined)
224
+ .map(([name, mount]) => `${name} → ${mount.osRoot}`);
225
+ return vfsError(
226
+ "not-found",
227
+ roots.length > 0
228
+ ? `Not inside any mounted directory. Mounts on disk: ${roots.join(", ")}`
229
+ : "Not inside any mounted directory",
230
+ );
231
+ }
232
+ }
233
+
234
+ function withPrefix(stat: VfsStat, prefix: string): VfsStat {
235
+ const path = stat.path === "" ? prefix : `${prefix}/${stat.path}`;
236
+ return { ...stat, path, name: stat.path === "" ? prefix : stat.name };
237
+ }
@@ -73,14 +73,32 @@ export class Weaver {
73
73
 
74
74
  runTurn(params: ExecuteParams): Promise<ExecuteResult> {
75
75
  const thread = this.loom.thread(params.chatId);
76
+ // A turn is killable when its backend can interrupt an in-flight
77
+ // chat turn. The abort hook tracks lifecycle through this closure:
78
+ // before the turn starts, a kill just marks it (run() then refuses to
79
+ // start); once running, the kill signals the backend — which stops
80
+ // the turn cleanly per the interruptChatTurn contract. The started
81
+ // guard matters in a queue: killing a queued turn must never
82
+ // interrupt the same chat's currently running one.
83
+ const chat = this.deps.getBackend(params.chatId).chat;
84
+ const interrupt = chat?.interruptChatTurn?.bind(chat);
85
+ const lifecycle = { started: false, killed: false };
76
86
  // Registered before enqueueing so a turn waiting in its chat's FIFO is
77
87
  // visible as `queued` in the task table, not invisible until it runs.
78
88
  const task = taskTable.enqueue({
79
89
  kind: "turn",
80
90
  label: params.source,
81
91
  chatId: params.chatId,
92
+ ...(interrupt
93
+ ? {
94
+ abort: () => {
95
+ lifecycle.killed = true;
96
+ if (lifecycle.started) void interrupt(params.chatId);
97
+ },
98
+ }
99
+ : {}),
82
100
  });
83
- return thread.enqueue(() => this.run(thread, params, task));
101
+ return thread.enqueue(() => this.run(thread, params, task, lifecycle));
84
102
  }
85
103
 
86
104
  /** Number of turns currently running (not queued) across all chats. */
@@ -101,17 +119,36 @@ export class Weaver {
101
119
  thread: Thread,
102
120
  params: ExecuteParams,
103
121
  task: TaskHandle,
122
+ lifecycle: { started: boolean; killed: boolean },
104
123
  ): Promise<ExecuteResult> {
124
+ if (lifecycle.killed) {
125
+ // Killed while queued — the turn never reaches the backend. The
126
+ // caller still gets a resolved (empty) result; nothing is delivered
127
+ // to the chat, which is the point of the kill.
128
+ task.fail(new Error("killed while queued"));
129
+ return this.emptyResult("Turn killed before it started.", params);
130
+ }
131
+ lifecycle.started = true;
105
132
  this.activeCount++;
106
133
  task.start();
107
134
  try {
108
135
  const result = await this.executeInner(thread, params, task);
109
- task.succeed({
136
+ const usage = {
110
137
  inputTokens: result.inputTokens,
111
138
  outputTokens: result.outputTokens,
112
139
  cacheRead: result.cacheRead,
113
140
  cacheWrite: result.cacheWrite,
114
- });
141
+ };
142
+ if (lifecycle.killed) {
143
+ // The interrupt landed and the backend closed the turn early but
144
+ // cleanly — that IS the interrupt contract, so the stream ends as
145
+ // a completion, not an error. The task still settles as killed
146
+ // (the truthful outcome of `talon kill`), usage included; the
147
+ // partial result flows back to the caller for normal handling.
148
+ task.fail(new Error("interrupted by kill"), usage);
149
+ } else {
150
+ task.succeed(usage);
151
+ }
115
152
  return result;
116
153
  } catch (err) {
117
154
  task.fail(err);
@@ -9,6 +9,8 @@ export type BridgeDiscoveryInfo = {
9
9
  port: number;
10
10
  token?: string;
11
11
  scheme?: string;
12
+ /** Certificate SHA-256 (hex) when the bridge serves HTTPS. */
13
+ fingerprint?: string;
12
14
  startedAt?: number;
13
15
  };
14
16
 
@@ -24,6 +26,7 @@ export async function writeBridgeDiscovery(
24
26
  port: info.port,
25
27
  token: info.token ?? null,
26
28
  scheme: info.scheme ?? "http",
29
+ fingerprint: info.fingerprint ?? null,
27
30
  pid: process.pid,
28
31
  protocol: BRIDGE_PROTOCOL_VERSION,
29
32
  startedAt: info.startedAt ?? now,
@@ -68,6 +68,7 @@ import { BridgeServer, type BridgeServerHandlers } from "./server.js";
68
68
  import { createNativeActionHandler } from "./actions.js";
69
69
  import { getMeshService } from "../../core/mesh/index.js";
70
70
  import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
71
+ import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./tls.js";
71
72
  import { readLogEntries } from "./logs.js";
72
73
  import {
73
74
  BRIDGE_PROTOCOL_VERSION,
@@ -1183,12 +1184,17 @@ export function createNativeFrontend(
1183
1184
  };
1184
1185
 
1185
1186
  const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
1187
+ const bridgeHost = nativeCfg.host ?? "127.0.0.1";
1188
+ // Encrypted by default the moment the bridge leaves the machine; loopback
1189
+ // stays plain HTTP unless explicitly opted in (`native.tls`).
1190
+ const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
1186
1191
  const server = new BridgeServer(
1187
1192
  {
1188
- host: nativeCfg.host ?? "127.0.0.1",
1193
+ host: bridgeHost,
1189
1194
  port: nativeCfg.port ?? 19880,
1190
1195
  token: nativeCfg.token,
1191
1196
  startedAt,
1197
+ ...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
1192
1198
  },
1193
1199
  handlers,
1194
1200
  );
@@ -1253,9 +1259,12 @@ export function createNativeFrontend(
1253
1259
  log("native", `Gateway on :${gatewayPort}`);
1254
1260
  chats.restore();
1255
1261
  await server.start();
1262
+ const fingerprint = server.getFingerprint();
1256
1263
  await writeBridgeDiscovery({
1257
1264
  port: server.getPort(),
1258
1265
  token: nativeCfg.token,
1266
+ scheme: server.getScheme(),
1267
+ ...(fingerprint ? { fingerprint } : {}),
1259
1268
  startedAt: Date.parse(startedAt),
1260
1269
  });
1261
1270
  },
@@ -9,7 +9,9 @@
9
9
  * curl one-liner identically.
10
10
  *
11
11
  * Binds `host` (loopback by default) with the gateway's EADDRINUSE +1..+5
12
- * fallback so two daemons on one machine don't collide.
12
+ * fallback so two daemons on one machine don't collide. With a TLS identity
13
+ * injected (see tls.ts) the same server speaks HTTPS instead — clients pin
14
+ * the certificate fingerprint surfaced on `/health`.
13
15
  */
14
16
 
15
17
  import {
@@ -18,10 +20,13 @@ import {
18
20
  type Server,
19
21
  type ServerResponse,
20
22
  } from "node:http";
23
+ import { createServer as createTlsServer } from "node:https";
24
+ import { createHash, timingSafeEqual } from "node:crypto";
21
25
  import { createReadStream } from "node:fs";
22
26
  import { stat } from "node:fs/promises";
23
27
  import { extname } from "node:path";
24
28
  import { log, logError, logDebug } from "../../util/log.js";
29
+ import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
25
30
  import {
26
31
  BRIDGE_PROTOCOL_VERSION,
27
32
  isLogLevel,
@@ -138,6 +143,7 @@ export class BridgeServer {
138
143
  private clients = new Set<ServerResponse>();
139
144
  private pingTimer: ReturnType<typeof setInterval> | undefined;
140
145
  private port = 0;
146
+ private tlsIdentity: BridgeTlsIdentity | null = null;
141
147
 
142
148
  constructor(
143
149
  private readonly opts: {
@@ -145,6 +151,12 @@ export class BridgeServer {
145
151
  port: number;
146
152
  token?: string;
147
153
  startedAt: string;
154
+ /**
155
+ * When present, the bridge serves HTTPS with this identity. A provider
156
+ * (not the identity itself) so the transport stays free of key-file
157
+ * I/O — it resolves once, inside `start()`.
158
+ */
159
+ tls?: () => Promise<BridgeTlsIdentity>;
148
160
  },
149
161
  private readonly handlers: BridgeServerHandlers,
150
162
  ) {}
@@ -153,6 +165,16 @@ export class BridgeServer {
153
165
  return this.port;
154
166
  }
155
167
 
168
+ /** "https" once started with a TLS identity, else "http". */
169
+ getScheme(): "http" | "https" {
170
+ return this.tlsIdentity ? "https" : "http";
171
+ }
172
+
173
+ /** The served certificate's SHA-256 fingerprint (hex), or null over HTTP. */
174
+ getFingerprint(): string | null {
175
+ return this.tlsIdentity?.fingerprint ?? null;
176
+ }
177
+
156
178
  /** Push an event to every connected SSE client. */
157
179
  broadcast(event: BridgeEvent): void {
158
180
  if (this.clients.size === 0) return;
@@ -168,7 +190,8 @@ export class BridgeServer {
168
190
 
169
191
  async start(): Promise<number> {
170
192
  if (this.server) return this.port;
171
- const server = createServer((req, res) => {
193
+ this.tlsIdentity = this.opts.tls ? await this.opts.tls() : null;
194
+ const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
172
195
  this.handle(req, res).catch((err) => {
173
196
  logError("native", "Bridge request handler threw", err);
174
197
  if (!res.headersSent) {
@@ -176,7 +199,15 @@ export class BridgeServer {
176
199
  res.end(JSON.stringify({ ok: false, error: "Internal error" }));
177
200
  }
178
201
  });
179
- });
202
+ };
203
+ // https.Server extends http.Server's request/lifecycle surface — one
204
+ // `Server`-typed field serves both transports.
205
+ const server: Server = this.tlsIdentity
206
+ ? createTlsServer(
207
+ { key: this.tlsIdentity.keyPem, cert: this.tlsIdentity.certPem },
208
+ onRequest,
209
+ )
210
+ : createServer(onRequest);
180
211
 
181
212
  this.pingTimer = setInterval(() => {
182
213
  for (const res of this.clients) {
@@ -215,9 +246,17 @@ export class BridgeServer {
215
246
  );
216
247
  log(
217
248
  "native",
218
- `Bridge listening on ${this.opts.host}:${this.port}` +
249
+ `Bridge listening on ${this.getScheme()}://${this.opts.host}:${this.port}` +
219
250
  (this.opts.token ? " (token required)" : ""),
220
251
  );
252
+ if (this.tlsIdentity) {
253
+ // The pairing datum: clients confirm this fingerprint on first
254
+ // connect, so it belongs in the log where the operator looks.
255
+ log(
256
+ "native",
257
+ `Bridge certificate fingerprint ${formatFingerprint(this.tlsIdentity.fingerprint)}`,
258
+ );
259
+ }
221
260
  resolve(this.port);
222
261
  });
223
262
  };
@@ -271,6 +310,10 @@ export class BridgeServer {
271
310
  protocol: BRIDGE_PROTOCOL_VERSION,
272
311
  host: this.opts.host,
273
312
  port: this.port,
313
+ scheme: this.getScheme(),
314
+ // The certificate's own hash — public by definition (any TLS client
315
+ // sees the certificate), surfaced so pairing UIs can display it.
316
+ fingerprint: this.getFingerprint(),
274
317
  authRequired: Boolean(this.opts.token),
275
318
  startedAt: this.opts.startedAt,
276
319
  botName: s.botName,
@@ -600,10 +643,26 @@ export class BridgeServer {
600
643
  private authOk(req: IncomingMessage, url: URL): boolean {
601
644
  if (!this.opts.token) return true;
602
645
  const header = req.headers["authorization"];
603
- if (typeof header === "string" && header === `Bearer ${this.opts.token}`)
646
+ if (
647
+ typeof header === "string" &&
648
+ header.startsWith("Bearer ") &&
649
+ this.tokenMatches(header.slice("Bearer ".length))
650
+ )
604
651
  return true;
605
652
  // EventSource can't set headers, so SSE clients pass ?token=… instead.
606
- return url.searchParams.get("token") === this.opts.token;
653
+ return this.tokenMatches(url.searchParams.get("token"));
654
+ }
655
+
656
+ /**
657
+ * Constant-time token comparison. Hashing both sides first equalizes
658
+ * lengths (timingSafeEqual demands it) without leaking the real length.
659
+ */
660
+ private tokenMatches(candidate: string | null): boolean {
661
+ if (candidate === null || !this.opts.token) return false;
662
+ return timingSafeEqual(
663
+ createHash("sha256").update(candidate).digest(),
664
+ createHash("sha256").update(this.opts.token).digest(),
665
+ );
607
666
  }
608
667
 
609
668
  private corsHeaders(): Record<string, string> {