talon-agent 3.8.2 → 3.9.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 (54) hide show
  1. package/package.json +1 -2
  2. package/src/backend/claude-sdk/handler.ts +1 -1
  3. package/src/backend/codex/handler/message.ts +1 -1
  4. package/src/backend/kilo/handler/index.ts +1 -2
  5. package/src/backend/kilo/handler/state.ts +3 -8
  6. package/src/backend/kilo/index.ts +2 -28
  7. package/src/backend/openai-agents/handler/message.ts +1 -1
  8. package/src/backend/openai-agents/session.ts +1 -1
  9. package/src/backend/opencode/handler/index.ts +1 -2
  10. package/src/backend/opencode/handler/state.ts +3 -8
  11. package/src/backend/opencode/index.ts +0 -7
  12. package/src/backend/shared/delivery.ts +1 -1
  13. package/src/backend/shared/handle-retry.ts +1 -1
  14. package/src/backend/shared/index.ts +3 -2
  15. package/src/backend/shared/turn-interrupt.ts +1 -1
  16. package/src/cli/index.ts +0 -8
  17. package/src/core/bus/index.ts +1 -9
  18. package/src/core/mesh/registry.ts +106 -4
  19. package/src/core/mesh/service.ts +22 -7
  20. package/src/core/mesh/transfers.ts +33 -4
  21. package/src/core/plugin/index.ts +0 -9
  22. package/src/core/prompt/index.ts +1 -16
  23. package/src/core/vfs/index.ts +2 -21
  24. package/src/core/weaver/index.ts +5 -9
  25. package/src/frontend/discord/actions/messaging.ts +73 -13
  26. package/src/frontend/discord/actions/shared.ts +21 -3
  27. package/src/frontend/discord/commands/admin.ts +1 -1
  28. package/src/frontend/discord/errors.ts +16 -5
  29. package/src/frontend/discord/formatting.ts +19 -4
  30. package/src/frontend/native/index.ts +22 -8
  31. package/src/frontend/native/protocol.ts +6 -0
  32. package/src/frontend/native/server.ts +171 -12
  33. package/src/frontend/shared/format.ts +6 -0
  34. package/src/frontend/telegram/actions/messaging.ts +120 -24
  35. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  36. package/src/frontend/telegram/commands/admin.ts +1 -1
  37. package/src/frontend/telegram/formatting.ts +64 -19
  38. package/src/frontend/terminal/commands.ts +46 -8
  39. package/src/frontend/terminal/input.ts +110 -12
  40. package/src/frontend/terminal/renderer.ts +2 -0
  41. package/src/{util → storage}/metrics.ts +1 -1
  42. package/src/storage/scheduled-store.ts +6 -3
  43. package/src/util/config.ts +8 -0
  44. package/src/util/log.ts +1 -0
  45. package/src/backend/codex/index.ts +0 -33
  46. package/src/backend/openai-agents/index.ts +0 -48
  47. package/src/cli/plugin-entries.ts +0 -104
  48. package/src/core/agent-runtime/index.ts +0 -62
  49. package/src/core/background/index.ts +0 -21
  50. package/src/core/engine/index.ts +0 -20
  51. package/src/core/models/index.ts +0 -17
  52. package/src/core/soul/index.ts +0 -140
  53. package/src/core/tools/mcp-server.ts +0 -111
  54. /package/src/{backend/shared → util}/session-name.ts +0 -0
@@ -1,13 +1,9 @@
1
- export { Thread, type Warp, type ThreadSnapshot } from "./thread.js";
2
- export { ThreadSession, type SessionSummary } from "./thread-session.js";
1
+ export { Thread, type ThreadSnapshot } from "./thread.js";
2
+
3
3
  export { Loom, type ContextRegistry } from "./loom.js";
4
- export { carryTurnEvents, type EventSink } from "./shuttle.js";
5
- export { startTypingLoop, TYPING_REFRESH_MS } from "./typing-loop.js";
6
- export {
7
- resolveWarp,
8
- type WarpResolution,
9
- type WarpResolverDeps,
10
- } from "./warp-resolver.js";
4
+ export { carryTurnEvents } from "./shuttle.js";
5
+ export { startTypingLoop } from "./typing-loop.js";
6
+ export { resolveWarp } from "./warp-resolver.js";
11
7
  export {
12
8
  Weaver,
13
9
  getActiveLoom,
@@ -6,7 +6,11 @@
6
6
  import type { Client } from "discord.js";
7
7
  import { withRetry } from "../../../core/engine/gateway.js";
8
8
  import { sendChunked } from "../handlers/index.js";
9
- import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
9
+ import {
10
+ suppressMentions,
11
+ splitMessage,
12
+ DISCORD_MAX_TEXT,
13
+ } from "../formatting.js";
10
14
  import { log, logError } from "../../../util/log.js";
11
15
  import {
12
16
  saveScheduled,
@@ -24,6 +28,41 @@ import type { DiscordActionHandlers } from "./types.js";
24
28
  /** Longest schedulable delay: 24h. Timers re-arm from the store on boot. */
25
29
  const MAX_DELAY_SEC = 24 * 60 * 60;
26
30
 
31
+ /**
32
+ * Deliver one scheduled entry, replaying whatever it was scheduled with.
33
+ *
34
+ * The `send` tool documents that "buttons and reply threading survive the
35
+ * delay — the schedule handler replays them at fire time". Discord's path
36
+ * used to call `sendChunked(c, entry.text)` and drop both, so a scheduled
37
+ * message with buttons arrived bare while the caller had been told ok:true.
38
+ */
39
+ async function fireScheduled(
40
+ channel: Awaited<ReturnType<typeof resolveChannel>>,
41
+ entry: ScheduledMessage,
42
+ ): Promise<void> {
43
+ if (!channel) return;
44
+ const replyTo =
45
+ entry.replyTo !== undefined ? String(entry.replyTo) : undefined;
46
+ if (!entry.rows || entry.rows.length === 0) {
47
+ await sendChunked(channel, entry.text, replyTo);
48
+ return;
49
+ }
50
+ const components = buildButtonRows(entry.rows);
51
+ const chunks = splitMessage(suppressMentions(entry.text), DISCORD_MAX_TEXT);
52
+ for (let i = 0; i < chunks.length; i++) {
53
+ if (!channel.isSendable()) return;
54
+ const last = i === chunks.length - 1;
55
+ await channel.send({
56
+ content: chunks[i],
57
+ ...(last ? { components } : {}),
58
+ allowedMentions: { parse: [] },
59
+ ...(i === 0 && replyTo
60
+ ? { reply: { messageReference: replyTo, failIfNotExists: false } }
61
+ : {}),
62
+ });
63
+ }
64
+ }
65
+
27
66
  /** Arm a timer for a stored entry; fires, then cleans up store + map. */
28
67
  function armScheduled(
29
68
  client: Client,
@@ -34,7 +73,7 @@ function armScheduled(
34
73
  const timer = setTimeout(async () => {
35
74
  try {
36
75
  const c = await resolveChannel(client, Number(entry.chatId));
37
- if (c) await sendChunked(c, entry.text);
76
+ if (c) await fireScheduled(c, entry);
38
77
  } catch (err) {
39
78
  logError(
40
79
  "discord",
@@ -189,25 +228,44 @@ export const messagingHandlers: DiscordActionHandlers = {
189
228
  }>
190
229
  >;
191
230
  gateway.incrementMessages(chatId);
192
- const components = buildButtonRows(rows);
193
- const safe = suppressMentions(text).slice(0, DISCORD_MAX_TEXT);
194
231
  if (!channel!.isSendable())
195
232
  return { ok: false, error: "Channel not sendable" };
196
233
  return tryAction("send_message_with_buttons", async () => {
197
- const sent = await withRetry(
198
- () =>
199
- channel!.send({
200
- content: safe,
201
- components,
202
- allowedMentions: { parse: [] },
203
- }) as Promise<{ id: string }>,
204
- );
205
- return { ok: true, message_id: sent.id };
234
+ // buildButtonRows is inside tryAction on purpose: it can still reject a
235
+ // row, and escaping here would bypass the action's error mapping and
236
+ // lose the text as well as the buttons.
237
+ const components = buildButtonRows(rows);
238
+ // Chunk rather than slice. `.slice(0, 2000)` dropped everything past
239
+ // the limit mid-word and still answered ok:true, so the model never
240
+ // learned its reply had been cut — while the SAME text without buttons
241
+ // went through sendChunked and arrived whole.
242
+ const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
243
+ const ids: string[] = [];
244
+ for (let i = 0; i < chunks.length; i++) {
245
+ const last = i === chunks.length - 1;
246
+ const sent = await withRetry(
247
+ () =>
248
+ channel!.send({
249
+ content: chunks[i],
250
+ // Buttons belong on the final chunk: they act on the whole
251
+ // message, and Discord would render a set per chunk otherwise.
252
+ ...(last ? { components } : {}),
253
+ allowedMentions: { parse: [] },
254
+ }) as Promise<{ id: string }>,
255
+ );
256
+ ids.push(sent.id);
257
+ }
258
+ return { ok: true, message_id: ids[0], message_ids: ids };
206
259
  });
207
260
  },
208
261
 
209
262
  schedule_message: (body, chatId, { client, scheduledMessages }) => {
210
263
  const text = String(body.text ?? "");
264
+ const rows = body.rows as ScheduledMessage["rows"];
265
+ const replyTo =
266
+ typeof body.reply_to_message_id === "string"
267
+ ? body.reply_to_message_id
268
+ : undefined;
211
269
  // NaN (e.g. delay_seconds: "5m") must fall back to the default, not
212
270
  // propagate: setTimeout(fn, NaN) fires immediately.
213
271
  const requested = Number(body.delay_seconds ?? 60);
@@ -222,6 +280,8 @@ export const messagingHandlers: DiscordActionHandlers = {
222
280
  text,
223
281
  fireAt: Date.now() + delaySec * 1000,
224
282
  createdAt: Date.now(),
283
+ replyTo,
284
+ rows,
225
285
  };
226
286
  // Persist before arming: if we crash between the two, the restore
227
287
  // path delivers it; the reverse order could lose it forever.
@@ -70,11 +70,21 @@ export function buildButtonRows(
70
70
  >,
71
71
  ): ActionRowBuilder<ButtonBuilder>[] {
72
72
  const out: ActionRowBuilder<ButtonBuilder>[] = [];
73
+ // custom_id must be unique within a message or Discord rejects the send.
74
+ // Two buttons with the same label — or none at all, which both collapse to
75
+ // the bare `ai:` prefix — used to produce a duplicate pair.
76
+ const usedIds = new Set<string>();
73
77
  for (const row of rows.slice(0, 5)) {
74
78
  const arb = new ActionRowBuilder<ButtonBuilder>();
75
79
  for (const btn of row.slice(0, 5)) {
76
80
  const b = new ButtonBuilder().setLabel(safeSlice(btn.text || "•", 80));
77
- if (btn.url) {
81
+ // Only http(s) can be a Link button. discord.js throws on anything
82
+ // else — a bare domain ("docs.example.com", a common model output) or
83
+ // a javascript: URL — and the throw escapes before the send, so ONE
84
+ // malformed URL discarded the reply text and every other button too.
85
+ // Downgrade to a plain non-link button instead: the label still says
86
+ // what it meant, and the rest of the message survives.
87
+ if (btn.url !== undefined && /^https?:\/\//i.test(btn.url)) {
78
88
  b.setStyle(ButtonStyle.Link).setURL(btn.url);
79
89
  } else {
80
90
  const styleMap: Record<string, ButtonStyle> = {
@@ -88,13 +98,21 @@ export function buildButtonRows(
88
98
  // Namespace AI-generated custom_ids under `ai:` so the callback router
89
99
  // never confuses them with system custom_ids like `settings:done`.
90
100
  // The router strips the prefix before forwarding to the agent.
101
+ //
102
+ // Budget is 100 UTF-16 units total (Discord's cap), so the raw part
103
+ // gets 100 minus the 3-unit prefix minus room for a de-dupe suffix.
91
104
  const raw = btn.callback_data || btn.text || "";
92
- const id = `ai:${safeSlice(raw, 96)}`;
105
+ let id = `ai:${safeSlice(raw, 93)}`;
106
+ for (let n = 2; usedIds.has(id); n++)
107
+ id = `ai:${safeSlice(raw, 93)}#${n}`;
108
+ usedIds.add(id);
93
109
  b.setStyle(style).setCustomId(id);
94
110
  }
95
111
  arb.addComponents(b);
96
112
  }
97
- out.push(arb);
113
+ // Discord requires at least one component per action row; an empty row
114
+ // (from an empty inner array) fails the whole message.
115
+ if (arb.components.length > 0) out.push(arb);
98
116
  }
99
117
  return out;
100
118
  }
@@ -9,7 +9,7 @@ import type { Gateway } from "../../../core/engine/gateway.js";
9
9
  import { respawnSelf } from "../../../util/respawn.js";
10
10
  import { forceDream } from "../../../core/background/dream.js";
11
11
  import { formatDuration, renderMetricsMessages } from "../helpers.js";
12
- import { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
12
+ import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
13
13
  import { handleAdminSubcommand } from "../admin.js";
14
14
  import { isAdmin } from "../handlers/index.js";
15
15
  import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
@@ -18,13 +18,24 @@ type DiscordAPIErrorLike = {
18
18
  rawError?: { code?: number };
19
19
  };
20
20
 
21
- /** Extract numeric JSON error code, if this looks like a DiscordAPIError. */
22
- function discordErrorCode(err: unknown): number | null {
23
- if (!err || typeof err !== "object") return null;
24
- const e = err as DiscordAPIErrorLike;
21
+ /**
22
+ * Extract numeric JSON error code, if this looks like a DiscordAPIError.
23
+ *
24
+ * Walks the `cause` chain, which is what makes this work at all on the
25
+ * primary send paths. Those run inside `withRetry`, which rethrows
26
+ * `classify(err)` — a TalonError carrying only the message, with the
27
+ * DiscordAPIError demoted to `cause`. A shallow look therefore found no
28
+ * code and every curated message below was dead on `send_message`,
29
+ * `reply_to`, `send_message_with_buttons`, the media sends, and
30
+ * forward/copy: the agent got a bare "Missing Permissions" instead of
31
+ * being told which permission and on what.
32
+ */
33
+ function discordErrorCode(err: unknown, depth = 0): number | null {
34
+ if (depth > 5 || !err || typeof err !== "object") return null;
35
+ const e = err as DiscordAPIErrorLike & { cause?: unknown };
25
36
  if (typeof e.code === "number") return e.code;
26
37
  if (typeof e.rawError?.code === "number") return e.rawError.code;
27
- return null;
38
+ return discordErrorCode(e.cause, depth + 1);
28
39
  }
29
40
 
30
41
  /**
@@ -58,12 +58,27 @@ export function suppressMentions(text: string): string {
58
58
  }
59
59
 
60
60
  /**
61
- * Slice a string to `max` user-visible characters without splitting multi-byte
62
- * code points (emoji, CJK) on a UTF-16 surrogate boundary. Used for Discord
63
- * `customId`, `label`, `value`, etc. where Discord measures char count.
61
+ * Slice a string so it satisfies a Discord length limit, without splitting a
62
+ * surrogate pair. Used for `customId`, `label`, `value`, etc.
63
+ *
64
+ * `max` counts UTF-16 code units, because that is what Discord (and
65
+ * discord.js's validators, which call `String.length`) measure. Counting
66
+ * code points instead — `Array.from(text).slice(0, max)` — silently
67
+ * overshoots for any astral character: 80 code points of emoji is 160 code
68
+ * units, so the guard passed and discord.js then threw
69
+ * `ExpectedConstraintError: Invalid string length`, taking the whole message
70
+ * with it. The guard worked precisely when it wasn't needed and failed when
71
+ * it was.
64
72
  */
65
73
  export function safeSlice(text: string, max: number): string {
66
- return Array.from(text).slice(0, max).join("");
74
+ if (text.length <= max) return text;
75
+ let out = "";
76
+ // Iterating a string yields whole code points, so a pair is never split.
77
+ for (const char of text) {
78
+ if (out.length + char.length > max) break;
79
+ out += char;
80
+ }
81
+ return out;
67
82
  }
68
83
 
69
84
  /** Simple HTML-style escape used when displaying raw text inside code blocks. */
@@ -69,7 +69,7 @@ import {
69
69
  } from "../../core/engine/backend-controller/index.js";
70
70
  import { getActiveReasoningLevels } from "../shared/reasoning-levels.js";
71
71
  import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
72
- import { extractSessionName } from "../../backend/shared/session-name.js";
72
+ import { extractSessionName } from "../../util/session-name.js";
73
73
  import { BridgeServer, type BridgeServerHandlers } from "./server.js";
74
74
  import { createNativeActionHandler } from "./actions.js";
75
75
  import { getMeshService } from "../../core/mesh/index.js";
@@ -1192,8 +1192,10 @@ export function createNativeFrontend(
1192
1192
  storeLocation: (body) => mesh.storeLocation(body),
1193
1193
  listDevices: () => mesh.list(),
1194
1194
  completeCommand: (body) => mesh.completeCommand(body),
1195
- acceptFileUpload: (token, body) => mesh.acceptFileUpload(token, body),
1196
- openFileDownload: (token) => mesh.openFileDownload(token),
1195
+ acceptFileUpload: (token, body, fromDeviceId) =>
1196
+ mesh.acceptFileUpload(token, body, fromDeviceId),
1197
+ openFileDownload: (token, fromDeviceId) =>
1198
+ mesh.openFileDownload(token, fromDeviceId),
1197
1199
  openNodeInstall: (token) => mesh.openNodeInstall(token),
1198
1200
  openNodeBinary: (token) => mesh.openNodeBinary(token),
1199
1201
  };
@@ -1205,14 +1207,22 @@ export function createNativeFrontend(
1205
1207
  const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
1206
1208
  // Never serve the agent API to the network unauthenticated: a non-loopback
1207
1209
  // bind with no configured token gets a persistent auto-minted one instead.
1210
+ // `?? ` alone would treat an empty string as a configured token and skip
1211
+ // the mint, serving the LAN unauthenticated while looking configured —
1212
+ // easy to hit from `"token": "${BRIDGE_TOKEN}"` with the var unset.
1213
+ const configuredToken =
1214
+ nativeCfg.token !== undefined && nativeCfg.token !== ""
1215
+ ? nativeCfg.token
1216
+ : undefined;
1208
1217
  const bridgeToken =
1209
- nativeCfg.token ??
1218
+ configuredToken ??
1210
1219
  (isLoopbackHost(bridgeHost) ? undefined : loadOrCreateBridgeToken());
1211
1220
  const server = new BridgeServer(
1212
1221
  {
1213
1222
  host: bridgeHost,
1214
1223
  port: nativeCfg.port ?? 19880,
1215
1224
  token: bridgeToken,
1225
+ allowedOrigins: nativeCfg.allowedOrigins,
1216
1226
  startedAt,
1217
1227
  ...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
1218
1228
  },
@@ -1249,13 +1259,17 @@ export function createNativeFrontend(
1249
1259
  async init() {
1250
1260
  await mesh.load();
1251
1261
  // Plug this bridge in as the mesh's transport: locates and device
1252
- // commands (from ANY frontend's mesh tool calls) fan out to every
1253
- // connected companion client as SSE events; each client filters by
1254
- // its own device id.
1262
+ // commands (from ANY frontend's mesh tool calls) leave as SSE events.
1263
+ //
1264
+ // A locate is a bare "who's there?" — no secret in the frame, and
1265
+ // pre-command app builds rely on receiving it — so it still fans out.
1266
+ // A command is the opposite: its params carry transfer tokens, exec
1267
+ // command lines and (on the chunked fallback) file bodies, so it goes
1268
+ // to the target device's own client(s) only.
1255
1269
  unregisterMeshTransport = mesh.registerTransport({
1256
1270
  locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
1257
1271
  command: (command) =>
1258
- broadcast({
1272
+ server.sendToDevice(command.deviceId, {
1259
1273
  kind: "device_command",
1260
1274
  id: command.id,
1261
1275
  deviceId: command.deviceId,
@@ -286,6 +286,12 @@ export type BridgeEvent =
286
286
  * target device executes it and answers via POST /devices/command-result
287
287
  * with the same `id` as `commandId`. Additive in v1 — app builds that
288
288
  * predate the command channel simply ignore the event.
289
+ *
290
+ * Addressed, not broadcast: params carry one-time transfer tokens, exec
291
+ * command lines and (on the chunked fallback) file bodies, so the frame
292
+ * goes only to the SSE client(s) that claimed this `deviceId` via
293
+ * `GET /events?deviceId=…`. Clients that claim no id still receive
294
+ * commands for devices nobody claimed, so pre-claim builds keep working.
289
295
  */
290
296
  | {
291
297
  kind: "device_command";
@@ -133,14 +133,17 @@ export type BridgeServerHandlers = {
133
133
  | Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
134
134
  /** A device answered a device_command; true when a call was waiting. */
135
135
  completeCommand(body: Record<string, unknown>): boolean;
136
- /** A device streams a pull-transfer's file body up (raw request body). */
136
+ /** A device streams a pull-transfer's file body up (raw request body).
137
+ * `fromDeviceId` is the caller's claimed identity, when it sent one. */
137
138
  acceptFileUpload(
138
139
  token: string,
139
140
  body: IncomingMessage,
141
+ fromDeviceId?: string,
140
142
  ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }>;
141
143
  /** Resolve a push-transfer token to the file to stream down, or null. */
142
144
  openFileDownload(
143
145
  token: string,
146
+ fromDeviceId?: string,
144
147
  ): Promise<{ path: string; size: number } | null>;
145
148
  /** Resolve a node-provisioning token to its installer script, or null. */
146
149
  openNodeInstall(token: string): { script: string; filename: string } | null;
@@ -152,6 +155,12 @@ const SSE_PING_MS = 25_000;
152
155
  const MAX_BODY_BYTES = 256 * 1024;
153
156
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
154
157
  const PORT_FALLBACKS = 5;
158
+ /**
159
+ * Longest device id a client may claim (`?deviceId=…`). Matches the
160
+ * registry's own id cap — a longer id can never name a real device, and the
161
+ * claim is held for the life of a connection, so it stays a bounded key.
162
+ */
163
+ const MAX_DEVICE_ID_CHARS = 128;
155
164
 
156
165
  // Failed-auth lockout: after this many wrong tokens from one address inside
157
166
  // the window, that address gets 429s until the window lapses. The token's
@@ -173,7 +182,13 @@ type AuthState = "ok" | "anonymous" | "bad";
173
182
 
174
183
  export class BridgeServer {
175
184
  private server: Server | null = null;
176
- private clients = new Set<ServerResponse>();
185
+ /**
186
+ * Live SSE connections → the mesh device id each one claimed on connect
187
+ * (undefined for clients that didn't claim one: desktop UIs, and companion
188
+ * builds from before the claim existed). The claim is what makes
189
+ * `sendToDevice` addressable rather than a shout.
190
+ */
191
+ private clients = new Map<ServerResponse, string | undefined>();
177
192
  private pingTimer: ReturnType<typeof setInterval> | undefined;
178
193
  private port = 0;
179
194
  private tlsIdentity: BridgeTlsIdentity | null = null;
@@ -185,6 +200,9 @@ export class BridgeServer {
185
200
  host: string;
186
201
  port: number;
187
202
  token?: string;
203
+ /** Origins permitted to call the bridge from a browser. Empty by
204
+ * default: native clients send no Origin and need no entry here. */
205
+ allowedOrigins?: readonly string[];
188
206
  startedAt: string;
189
207
  /**
190
208
  * When present, the bridge serves HTTPS with this identity. A provider
@@ -213,8 +231,50 @@ export class BridgeServer {
213
231
  /** Push an event to every connected SSE client. */
214
232
  broadcast(event: BridgeEvent): void {
215
233
  if (this.clients.size === 0) return;
234
+ this.write(this.clients.keys(), event);
235
+ }
236
+
237
+ /**
238
+ * Push an event to the client(s) that claimed `deviceId` — the delivery
239
+ * path for anything addressed to ONE device.
240
+ *
241
+ * Device commands are not public: their params carry one-time transfer
242
+ * tokens, exec command lines, remote paths, and — on the chunked fallback —
243
+ * whole base64 file bodies. Broadcasting them handed every connected client
244
+ * another device's secrets and relied on each client discarding what wasn't
245
+ * addressed to it, which is courtesy, not enforcement.
246
+ *
247
+ * A claim is an ADDRESS, not a credential: any client holding the bridge
248
+ * token could claim any id, and the bridge token is (still) the only trust
249
+ * boundary here. What this buys is that a device no longer passively
250
+ * receives traffic meant for its peers.
251
+ *
252
+ * Clients that claimed nothing are the fallback audience, and only when the
253
+ * target claimed nothing either: a companion build that predates the claim
254
+ * can't be addressed, and dropping its commands would take the mesh offline
255
+ * for it. So an updated device's traffic never reaches them — the fallback
256
+ * shrinks to nothing as the fleet updates.
257
+ */
258
+ sendToDevice(deviceId: string, event: BridgeEvent): void {
259
+ if (this.clients.size === 0) return;
260
+ const claimed: ServerResponse[] = [];
261
+ const unclaimed: ServerResponse[] = [];
262
+ for (const [res, id] of this.clients) {
263
+ if (id === deviceId) claimed.push(res);
264
+ else if (id === undefined) unclaimed.push(res);
265
+ }
266
+ if (claimed.length === 0) {
267
+ logDebug(
268
+ "native",
269
+ `No SSE client claims device ${deviceId} — delivering to ${unclaimed.length} unclaimed client(s)`,
270
+ );
271
+ }
272
+ this.write(claimed.length > 0 ? claimed : unclaimed, event);
273
+ }
274
+
275
+ private write(targets: Iterable<ServerResponse>, event: BridgeEvent): void {
216
276
  const payload = `data: ${JSON.stringify(event)}\n\n`;
217
- for (const res of this.clients) {
277
+ for (const res of targets) {
218
278
  try {
219
279
  res.write(payload);
220
280
  } catch {
@@ -245,7 +305,7 @@ export class BridgeServer {
245
305
  : createServer(onRequest);
246
306
 
247
307
  this.pingTimer = setInterval(() => {
248
- for (const res of this.clients) {
308
+ for (const res of this.clients.keys()) {
249
309
  try {
250
310
  res.write(": ping\n\n");
251
311
  } catch {
@@ -301,7 +361,7 @@ export class BridgeServer {
301
361
 
302
362
  async stop(): Promise<void> {
303
363
  clearInterval(this.pingTimer);
304
- for (const res of this.clients) {
364
+ for (const res of this.clients.keys()) {
305
365
  try {
306
366
  res.end();
307
367
  } catch {
@@ -329,6 +389,29 @@ export class BridgeServer {
329
389
  const path = url.pathname;
330
390
  const method = req.method ?? "GET";
331
391
 
392
+ // Origin / Host guard runs before everything, including OPTIONS: a
393
+ // preflight that answers 204 to any origin is itself the permission
394
+ // slip the browser is asking for.
395
+ const origin =
396
+ typeof req.headers.origin === "string" ? req.headers.origin : undefined;
397
+ const refusal = this.originGuard(req);
398
+ if (refusal !== undefined) {
399
+ res.writeHead(403, {
400
+ ...this.corsHeaders(),
401
+ "Content-Type": "application/json",
402
+ });
403
+ res.end(JSON.stringify({ ok: false, error: refusal }));
404
+ return;
405
+ }
406
+ // Set once here rather than in corsHeaders(): setHeader values survive
407
+ // every later writeHead(code, {...}) that does not name the same key,
408
+ // so each of the ~8 response sites keeps the grant without threading
409
+ // the origin through all of them.
410
+ if (origin !== undefined && this.isAllowedOrigin(origin)) {
411
+ res.setHeader("Access-Control-Allow-Origin", origin);
412
+ res.setHeader("Vary", "Origin");
413
+ }
414
+
332
415
  if (method === "OPTIONS") {
333
416
  res.writeHead(204, this.corsHeaders());
334
417
  res.end();
@@ -425,7 +508,10 @@ export class BridgeServer {
425
508
  }
426
509
 
427
510
  try {
428
- if (method === "GET" && path === "/events") return this.openStream(res);
511
+ // A mesh client names itself here so device-addressed events reach it
512
+ // alone (see sendToDevice); UI clients simply omit it.
513
+ if (method === "GET" && path === "/events")
514
+ return this.openStream(res, deviceIdParam(url));
429
515
 
430
516
  if (method === "GET" && path === "/chats")
431
517
  return this.json(res, 200, { chats: this.handlers.listChats() });
@@ -549,12 +635,15 @@ export class BridgeServer {
549
635
  const token = url.searchParams.get("transfer") ?? "";
550
636
  if (!token)
551
637
  return this.json(res, 400, { ok: false, error: "transfer required" });
638
+ // The caller names itself so the token's device binding can be
639
+ // checked (see core/mesh/transfers.ts take()).
640
+ const from = deviceIdParam(url);
552
641
  if (method === "POST") {
553
- const result = await this.handlers.acceptFileUpload(token, req);
642
+ const result = await this.handlers.acceptFileUpload(token, req, from);
554
643
  return this.json(res, result.ok ? 200 : 409, result);
555
644
  }
556
645
  if (method === "GET") {
557
- const file = await this.handlers.openFileDownload(token);
646
+ const file = await this.handlers.openFileDownload(token, from);
558
647
  if (!file)
559
648
  return this.json(res, 404, {
560
649
  ok: false,
@@ -725,7 +814,7 @@ export class BridgeServer {
725
814
  }
726
815
  }
727
816
 
728
- private openStream(res: ServerResponse): void {
817
+ private openStream(res: ServerResponse, deviceId?: string): void {
729
818
  res.writeHead(200, {
730
819
  ...this.corsHeaders(),
731
820
  "Content-Type": "text/event-stream",
@@ -752,8 +841,11 @@ export class BridgeServer {
752
841
  } catch (err) {
753
842
  logError("native", "Failed to replay live turn to new client", err);
754
843
  }
755
- this.clients.add(res);
756
- logDebug("native", `SSE client connected (${this.clients.size} total)`);
844
+ this.clients.set(res, deviceId);
845
+ logDebug(
846
+ "native",
847
+ `SSE client connected${deviceId ? ` as device ${deviceId}` : ""} (${this.clients.size} total)`,
848
+ );
757
849
  res.on("close", () => {
758
850
  this.clients.delete(res);
759
851
  logDebug("native", `SSE client left (${this.clients.size} total)`);
@@ -824,9 +916,17 @@ export class BridgeServer {
824
916
  );
825
917
  }
826
918
 
919
+ /**
920
+ * CORS headers.
921
+ *
922
+ * Deliberately NOT `Access-Control-Allow-Origin: *`. The bridge's clients
923
+ * are native apps (Electron main process, Flutter, curl, talon-node),
924
+ * which send no `Origin` at all — a wildcard buys them nothing and hands
925
+ * every web page on the internet a readable cross-origin channel to the
926
+ * agent API. Only an explicitly configured origin is echoed back.
927
+ */
827
928
  private corsHeaders(): Record<string, string> {
828
929
  return {
829
- "Access-Control-Allow-Origin": "*",
830
930
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
831
931
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
832
932
  "Access-Control-Max-Age": "86400",
@@ -835,6 +935,55 @@ export class BridgeServer {
835
935
  };
836
936
  }
837
937
 
938
+ /** True when `origin` is on the operator's `native.allowedOrigins` list. */
939
+ private isAllowedOrigin(origin: string): boolean {
940
+ return this.opts.allowedOrigins?.includes(origin) ?? false;
941
+ }
942
+
943
+ /**
944
+ * Reject browser-driven cross-origin requests and DNS-rebinding.
945
+ *
946
+ * Two independent checks, because they stop different attacks:
947
+ *
948
+ * - `Origin`: browsers attach it to every cross-origin request and
949
+ * scripts cannot forge it. Native clients omit it entirely. So "an
950
+ * Origin we did not allow" means "a web page is driving us" — which,
951
+ * on the default unauthenticated loopback bind, would let any site
952
+ * the user visits POST /send and run tools on this machine.
953
+ * - `Host`: a name that resolves to 127.0.0.1 makes the request
954
+ * SAME-origin, so no Origin header is sent and the check above never
955
+ * fires. Pinning Host to loopback/the configured bind closes that.
956
+ *
957
+ * Returns an error string when the request must be refused.
958
+ */
959
+ private originGuard(req: IncomingMessage): string | undefined {
960
+ const origin = req.headers.origin;
961
+ if (typeof origin === "string" && origin !== "" && origin !== "null") {
962
+ if (!this.isAllowedOrigin(origin)) {
963
+ return `Origin ${origin} is not allowed. Add it to native.allowedOrigins to permit browser clients.`;
964
+ }
965
+ }
966
+
967
+ const host = req.headers.host;
968
+ if (typeof host === "string" && host !== "") {
969
+ // Strip the port; bracketed IPv6 keeps its brackets off.
970
+ const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
971
+ const allowed =
972
+ name === "127.0.0.1" ||
973
+ name === "localhost" ||
974
+ name === "::1" ||
975
+ name === this.opts.host ||
976
+ // A wildcard bind is reachable under every local name; the bearer
977
+ // token is the control there, not the Host header.
978
+ this.opts.host === "0.0.0.0" ||
979
+ this.opts.host === "::";
980
+ if (!allowed) {
981
+ return `Host ${host} is not recognised for this bridge (DNS-rebinding guard).`;
982
+ }
983
+ }
984
+ return undefined;
985
+ }
986
+
838
987
  private jsonHeaders(): Record<string, string> {
839
988
  return { ...this.corsHeaders(), "Content-Type": "application/json" };
840
989
  }
@@ -880,6 +1029,16 @@ function asString(v: unknown): string | undefined {
880
1029
  return typeof v === "string" ? v : undefined;
881
1030
  }
882
1031
 
1032
+ /**
1033
+ * The `deviceId` a mesh client claims on `/events` and `/devices/file`.
1034
+ * Undefined when absent or blank — every consumer treats "no claim" as the
1035
+ * legacy case, so an empty string must never look like a claimed id.
1036
+ */
1037
+ function deviceIdParam(url: URL): string | undefined {
1038
+ const raw = (url.searchParams.get("deviceId") ?? "").trim();
1039
+ return raw ? raw.slice(0, MAX_DEVICE_ID_CHARS) : undefined;
1040
+ }
1041
+
883
1042
  /** Parse a positive-integer query param; undefined when absent/invalid. */
884
1043
  function asPositiveInt(v: string | null): number | undefined {
885
1044
  if (!v) return undefined;
@@ -38,6 +38,12 @@ export function formatTokenCount(n: number): string {
38
38
  return String(n);
39
39
  }
40
40
 
41
+ /** Keep small model costs legible without making larger totals noisy. */
42
+ export function formatUsd(cost: number): string {
43
+ const safeCost = Number.isFinite(cost) && cost > 0 ? cost : 0;
44
+ return `$${safeCost > 0.5 ? safeCost.toFixed(2) : safeCost.toFixed(4)}`;
45
+ }
46
+
41
47
  export function formatBytes(bytes: number): string {
42
48
  if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
43
49
  if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;