talon-agent 5.14.0 → 5.18.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.
Files changed (116) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +14 -7
  5. package/package.json +6 -4
  6. package/prompts/system/heartbeat-agent.md +1 -1
  7. package/src/backend/agy/factory.ts +3 -0
  8. package/src/backend/agy/mcp/config.ts +14 -2
  9. package/src/backend/claude-sdk/factory.ts +3 -0
  10. package/src/backend/claude-sdk/options.ts +24 -3
  11. package/src/backend/codex/factory.ts +3 -0
  12. package/src/backend/codex/init.ts +4 -0
  13. package/src/backend/codex/mcp-config.ts +10 -0
  14. package/src/backend/codex/oauth-incompat.ts +1 -1
  15. package/src/backend/codex/token-usage.ts +2 -2
  16. package/src/backend/openai-agents/factory.ts +3 -0
  17. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  18. package/src/backend/remote-server/factory.ts +3 -0
  19. package/src/backend/remote-server/mcp.ts +3 -0
  20. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  21. package/src/bootstrap.ts +8 -0
  22. package/src/cli/commands/backup.ts +61 -5
  23. package/src/cli/commands/mesh.ts +133 -0
  24. package/src/cli/config.ts +3 -1
  25. package/src/cli/daemon-api.ts +22 -0
  26. package/src/cli/index.ts +6 -0
  27. package/src/cli/install-sources.ts +40 -5
  28. package/src/cli/plugin.ts +10 -0
  29. package/src/cli/setup.ts +45 -4
  30. package/src/cli/skill.ts +3 -0
  31. package/src/core/agent-runtime/backend-registry.ts +16 -0
  32. package/src/core/backup/archive/crypt.ts +429 -0
  33. package/src/core/backup/archive/manifest-auth.ts +98 -0
  34. package/src/core/backup/passphrase.ts +130 -0
  35. package/src/core/backup/plan.ts +66 -13
  36. package/src/core/backup/restore-guard.ts +101 -0
  37. package/src/core/backup/restore.ts +249 -32
  38. package/src/core/backup/snapshot.ts +418 -60
  39. package/src/core/backup/sources/plugins.ts +223 -0
  40. package/src/core/backup/sources/relocate.ts +136 -0
  41. package/src/core/backup/sources/sessions.ts +198 -0
  42. package/src/core/backup/store.ts +3 -1
  43. package/src/core/backup/types.ts +66 -0
  44. package/src/core/backup/upload.ts +66 -6
  45. package/src/core/config/index.ts +140 -8
  46. package/src/core/daemon/control.ts +9 -0
  47. package/src/core/daemon/discovery.ts +7 -0
  48. package/src/core/engine/backend-router/headroom.ts +29 -5
  49. package/src/core/engine/backend-router/usage.ts +6 -2
  50. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  51. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  52. package/src/core/engine/gateway-actions/index.ts +4 -2
  53. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  54. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  55. package/src/core/engine/gateway-auth.ts +164 -0
  56. package/src/core/engine/gateway-routes.ts +100 -5
  57. package/src/core/engine/gateway.ts +12 -4
  58. package/src/core/mcp-hub/guest-scope.ts +170 -29
  59. package/src/core/mcp-hub/index.ts +33 -16
  60. package/src/core/mcp-hub/talon-server.ts +71 -14
  61. package/src/core/mesh/credentials/admin.ts +146 -0
  62. package/src/core/mesh/credentials/index.ts +19 -0
  63. package/src/core/mesh/credentials/store.ts +443 -0
  64. package/src/core/mesh/credentials/token.ts +45 -0
  65. package/src/core/mesh/credentials/types.ts +83 -0
  66. package/src/core/mesh/devices/service.ts +58 -6
  67. package/src/core/mesh/links/bridge-links.ts +46 -4
  68. package/src/core/mesh/links/node-binaries.ts +1 -1
  69. package/src/core/mesh/links/node-provision.ts +8 -1
  70. package/src/core/models/active-model.ts +1 -1
  71. package/src/core/plugin/loader.ts +4 -0
  72. package/src/core/plugin/mcp.ts +4 -0
  73. package/src/core/tools/bridge.ts +2 -1
  74. package/src/core/types.ts +13 -0
  75. package/src/core/weaver/weaver.ts +47 -15
  76. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  77. package/src/frontend/discord/handlers/delivery.ts +3 -0
  78. package/src/frontend/discord/handlers/queue.ts +1 -0
  79. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  80. package/src/frontend/native/bridge/auth.ts +84 -1
  81. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  82. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  83. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  84. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  85. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  86. package/src/frontend/native/bridge/routes/host.ts +11 -1
  87. package/src/frontend/native/bridge/routes/index.ts +2 -0
  88. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  89. package/src/frontend/native/bridge/routes/table.ts +73 -51
  90. package/src/frontend/native/bridge/server.ts +266 -83
  91. package/src/frontend/native/index.ts +54 -3
  92. package/src/frontend/native/turn/turn.ts +2 -0
  93. package/src/frontend/teams/turn.ts +1 -0
  94. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  95. package/src/frontend/telegram/actions/send.ts +4 -0
  96. package/src/frontend/telegram/admin.ts +20 -0
  97. package/src/frontend/telegram/commands/admin.ts +1 -1
  98. package/src/frontend/telegram/commands/state.ts +7 -9
  99. package/src/frontend/telegram/handlers/access.ts +31 -10
  100. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  101. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  102. package/src/frontend/telegram/handlers/messages.ts +1 -0
  103. package/src/frontend/telegram/handlers/queue.ts +14 -0
  104. package/src/frontend/telegram/handlers/state.ts +2 -2
  105. package/src/frontend/telegram/index.ts +32 -9
  106. package/src/frontend/telegram/middleware.ts +2 -2
  107. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  108. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  109. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  110. package/src/frontend/telegram/userbot.ts +103 -10
  111. package/src/frontend/terminal/index.ts +8 -1
  112. package/src/frontend/whatsapp/commands.ts +3 -3
  113. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  114. package/src/plugins/playwright/index.ts +1 -1
  115. package/src/storage/backup/index.ts +1 -1
  116. package/src/storage/db.ts +23 -0
@@ -24,8 +24,14 @@ import { createServer as createTlsServer } from "node:https";
24
24
  import { createHash, timingSafeEqual } from "node:crypto";
25
25
  import { createReadStream } from "node:fs";
26
26
  import { stat } from "node:fs/promises";
27
- import { log, logError, logDebug, logWarn } from "../../../util/log.js";
28
- import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
27
+ import { log, logError, logDebug } from "../../../util/log.js";
28
+ import {
29
+ formatFingerprint,
30
+ isLoopbackHost,
31
+ type BridgeTlsIdentity,
32
+ } from "./tls.js";
33
+ import { checkBridgeTokenStrength } from "./auth.js";
34
+ import { AuthGuard, type AuthGuardPolicy } from "./auth-guard.js";
29
35
  import { contentTypeFor } from "../media/media.js";
30
36
  import { type BridgeEvent } from "../protocol.js";
31
37
  import { buildRoutes } from "./routes/index.js";
@@ -37,39 +43,73 @@ import {
37
43
  type RouteContext,
38
44
  type RouteHandler,
39
45
  } from "./routes/table.js";
46
+ import {
47
+ describeTier,
48
+ hasScope,
49
+ resolvePrincipal,
50
+ routeAllows,
51
+ type BridgeCredentials,
52
+ type BridgePrincipal,
53
+ } from "./credentials/principal.js";
40
54
 
41
55
  export type { BridgeServerHandlers, SendOptions } from "./routes/host.js";
42
56
  export { BRIDGE_ROUTE_AUTH, type BridgeRouteKey } from "./routes/table.js";
57
+ export type { BridgeCredentials } from "./credentials/principal.js";
58
+
59
+ /** One live SSE connection: the device it claimed and who opened it. */
60
+ type StreamSession = {
61
+ deviceId: string | undefined;
62
+ principal: BridgePrincipal;
63
+ };
43
64
 
44
65
  const SSE_PING_MS = 25_000;
45
66
  const MAX_BODY_BYTES = 256 * 1024;
46
67
  const PORT_FALLBACKS = 5;
47
68
 
48
- // Failed-auth lockout: after this many wrong tokens from one address inside
49
- // the window, that address gets 429s until the window lapses. The token's
50
- // 256 bits make brute force hopeless anyway — this is about not letting an
51
- // internet-facing bridge be hammered for free (and giving fail2ban-style
52
- // tooling a clean signal in the log). Only *presented-and-wrong* secrets
53
- // count: tokenless probes are just scanners finding a locked door.
54
- const AUTH_LOCKOUT_MAX_FAILURES = 20;
55
- const AUTH_LOCKOUT_WINDOW_MS = 15 * 60 * 1000;
56
- /** Hard cap on tracked addresses so the map can't become a memory lever. */
57
- const AUTH_LOCKOUT_MAX_TRACKED = 10_000;
69
+ /**
70
+ * Server-level socket deadlines (slow-loris resistance). Node applies
71
+ * `requestTimeout` only until the request has been fully RECEIVED, so the
72
+ * long-lived SSE stream (a bodyless GET) and file downloads (response
73
+ * bodies) are unaffected. It does bound request bodies, and every
74
+ * body-reading route sits behind the bearer check (an unauthenticated
75
+ * request is answered 401/429 with `Connection: close` before its body is
76
+ * read), so the budget is sized for the largest authenticated upload
77
+ * (512 MB) on a slow link, not for an attacker.
78
+ */
79
+ export type BridgeTimeouts = {
80
+ /** Whole header block must arrive within this. Node default: 60s. */
81
+ headersMs: number;
82
+ /** Whole request (headers + body) must arrive within this. Node: 300s. */
83
+ requestMs: number;
84
+ /** Idle keep-alive sockets are closed after this. */
85
+ keepAliveMs: number;
86
+ /** How often Node sweeps for expired header/request deadlines. */
87
+ checkIntervalMs: number;
88
+ };
89
+
90
+ export const DEFAULT_BRIDGE_TIMEOUTS: BridgeTimeouts = {
91
+ headersMs: 20_000,
92
+ requestMs: 30 * 60_000,
93
+ keepAliveMs: 5_000,
94
+ checkIntervalMs: 30_000,
95
+ };
58
96
 
59
97
  export class BridgeServer {
60
98
  private server: Server | null = null;
61
99
  /**
62
100
  * Live SSE connections → the mesh device id each one claimed on connect
63
101
  * (undefined for clients that didn't claim one: desktop UIs, and companion
64
- * builds from before the claim existed). The claim is what makes
65
- * `sendToDevice` addressable rather than a shout.
102
+ * builds from before the claim existed) and the principal that opened it.
103
+ * The claim is what makes `sendToDevice` addressable rather than a shout;
104
+ * the principal is what lets a revocation find and drop the session.
66
105
  */
67
- private clients = new Map<ServerResponse, string | undefined>();
106
+ private clients = new Map<ServerResponse, StreamSession>();
107
+ private unsubscribeRevocations: (() => void) | undefined;
68
108
  private pingTimer: ReturnType<typeof setInterval> | undefined;
69
109
  private port = 0;
70
110
  private tlsIdentity: BridgeTlsIdentity | null = null;
71
- /** Wrong-token counts per remote address (behind a proxy: per proxy). */
72
- private authFailures = new Map<string, { count: number; resetAt: number }>();
111
+ /** Backoff, lockout and the global failure budget for wrong tokens. */
112
+ private readonly authGuard: AuthGuard;
73
113
  /** `METHOD /path` → handler; a Map so lookups only ever hit own entries. */
74
114
  private readonly routes: ReadonlyMap<BridgeRouteKey, RouteHandler>;
75
115
 
@@ -88,9 +128,30 @@ export class BridgeServer {
88
128
  * I/O — it resolves once, inside `start()`.
89
129
  */
90
130
  tls?: () => Promise<BridgeTlsIdentity>;
131
+ /**
132
+ * Per-device credentials (core/mesh/credentials) and the migration
133
+ * policy. Absent: the shared `token` is the only credential, as
134
+ * before.
135
+ */
136
+ credentials?: BridgeCredentials;
137
+ /** `native.allowWeakToken`: start on a public bind despite a weak token. */
138
+ allowWeakToken?: boolean;
139
+ /** Operator alert for security events (the global auth cooldown). */
140
+ onSecurityAlert?: (message: string) => void;
141
+ /**
142
+ * `native.sseMaxLifetimeMs`: end each event stream after this long
143
+ * (±10% jitter) so clients re-authenticate. Unset = never.
144
+ */
145
+ sseMaxLifetimeMs?: number;
146
+ /** Overrides for tests; production uses the defaults. */
147
+ authPolicy?: Partial<AuthGuardPolicy>;
148
+ timeouts?: Partial<BridgeTimeouts>;
91
149
  },
92
150
  private readonly handlers: BridgeServerHandlers,
93
151
  ) {
152
+ this.authGuard = new AuthGuard(opts.authPolicy, {
153
+ onAlert: opts.onSecurityAlert,
154
+ });
94
155
  this.routes = new Map(
95
156
  Object.entries(buildRoutes(this.routeHost())) as [
96
157
  BridgeRouteKey,
@@ -113,10 +174,49 @@ export class BridgeServer {
113
174
  return this.tlsIdentity?.fingerprint ?? null;
114
175
  }
115
176
 
116
- /** Push an event to every connected SSE client. */
177
+ /**
178
+ * Push an event to every connected SSE client that may see it. Chat
179
+ * traffic is for `client`-scoped sessions only; a device-only credential
180
+ * hears the mesh-wide `locate` and nothing else.
181
+ */
117
182
  broadcast(event: BridgeEvent): void {
118
183
  if (this.clients.size === 0) return;
119
- this.write(this.clients.keys(), event);
184
+ const targets: ServerResponse[] = [];
185
+ for (const [res, session] of this.clients) {
186
+ if (event.kind === "locate" || hasScope(session.principal, "client")) {
187
+ targets.push(res);
188
+ }
189
+ }
190
+ this.write(targets, event);
191
+ }
192
+
193
+ /**
194
+ * End every live stream opened with one of `credentialIds` — revocation
195
+ * (and a scope change) must bite now, not at the client's next request.
196
+ */
197
+ dropCredentialSessions(credentialIds: readonly string[]): number {
198
+ let dropped = 0;
199
+ for (const [res, { principal }] of this.clients) {
200
+ if (
201
+ principal.kind === "device" &&
202
+ credentialIds.includes(principal.credentialId)
203
+ ) {
204
+ this.clients.delete(res);
205
+ try {
206
+ res.end();
207
+ } catch {
208
+ /* already gone */
209
+ }
210
+ dropped++;
211
+ }
212
+ }
213
+ if (dropped > 0) {
214
+ log(
215
+ "native",
216
+ `Dropped ${dropped} live session(s) of revoked/changed credential(s) ${credentialIds.join(", ")}`,
217
+ );
218
+ }
219
+ return dropped;
120
220
  }
121
221
 
122
222
  /**
@@ -138,15 +238,21 @@ export class BridgeServer {
138
238
  * target claimed nothing either: a companion build that predates the claim
139
239
  * can't be addressed, and dropping its commands would take the mesh offline
140
240
  * for it. So an updated device's traffic never reaches them — the fallback
141
- * shrinks to nothing as the fleet updates.
241
+ * shrinks to nothing as the fleet updates. Only shared-token sessions are
242
+ * in that fallback: a per-device credential is its own device or nobody.
243
+ *
244
+ * With per-device credentials the claim IS enforced: a credential can
245
+ * only claim the device it is bound to (credentials/claims.ts).
142
246
  */
143
247
  sendToDevice(deviceId: string, event: BridgeEvent): void {
144
248
  if (this.clients.size === 0) return;
145
249
  const claimed: ServerResponse[] = [];
146
250
  const unclaimed: ServerResponse[] = [];
147
- for (const [res, id] of this.clients) {
251
+ for (const [res, { deviceId: id, principal }] of this.clients) {
148
252
  if (id === deviceId) claimed.push(res);
149
- else if (id === undefined) unclaimed.push(res);
253
+ else if (id === undefined && principal.kind !== "device") {
254
+ unclaimed.push(res);
255
+ }
150
256
  }
151
257
  if (claimed.length === 0) {
152
258
  logDebug(
@@ -170,6 +276,11 @@ export class BridgeServer {
170
276
 
171
277
  async start(): Promise<number> {
172
278
  if (this.server) return this.port;
279
+ checkBridgeTokenStrength({
280
+ token: this.opts.token,
281
+ loopback: isLoopbackHost(this.opts.host),
282
+ allowWeakToken: this.opts.allowWeakToken,
283
+ });
173
284
  this.tlsIdentity = this.opts.tls ? await this.opts.tls() : null;
174
285
  const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
175
286
  this.handle(req, res).catch((err) => {
@@ -182,12 +293,23 @@ export class BridgeServer {
182
293
  };
183
294
  // https.Server extends http.Server's request/lifecycle surface — one
184
295
  // `Server`-typed field serves both transports.
296
+ const t = { ...DEFAULT_BRIDGE_TIMEOUTS, ...this.opts.timeouts };
297
+ const serverOpts = {
298
+ headersTimeout: t.headersMs,
299
+ requestTimeout: t.requestMs,
300
+ keepAliveTimeout: t.keepAliveMs,
301
+ connectionsCheckingInterval: t.checkIntervalMs,
302
+ };
185
303
  const server: Server = this.tlsIdentity
186
304
  ? createTlsServer(
187
- { key: this.tlsIdentity.keyPem, cert: this.tlsIdentity.certPem },
305
+ {
306
+ ...serverOpts,
307
+ key: this.tlsIdentity.keyPem,
308
+ cert: this.tlsIdentity.certPem,
309
+ },
188
310
  onRequest,
189
311
  )
190
- : createServer(onRequest);
312
+ : createServer(serverOpts, onRequest);
191
313
 
192
314
  this.pingTimer = setInterval(() => {
193
315
  for (const res of this.clients.keys()) {
@@ -199,6 +321,9 @@ export class BridgeServer {
199
321
  }
200
322
  }, SSE_PING_MS);
201
323
  this.pingTimer.unref?.();
324
+ this.unsubscribeRevocations = this.opts.credentials?.authority.onRevoked(
325
+ (ids) => this.dropCredentialSessions(ids),
326
+ );
202
327
 
203
328
  return new Promise<number>((resolve, reject) => {
204
329
  let attempt = 0;
@@ -246,6 +371,8 @@ export class BridgeServer {
246
371
 
247
372
  async stop(): Promise<void> {
248
373
  clearInterval(this.pingTimer);
374
+ this.unsubscribeRevocations?.();
375
+ this.unsubscribeRevocations = undefined;
249
376
  for (const res of this.clients.keys()) {
250
377
  try {
251
378
  res.end();
@@ -304,35 +431,34 @@ export class BridgeServer {
304
431
  }
305
432
 
306
433
  const remote = req.socket.remoteAddress ?? "unknown";
307
- if (this.authLockedOut(remote)) {
308
- res.writeHead(429, {
309
- ...this.jsonHeaders(),
310
- "Retry-After": String(Math.ceil(AUTH_LOCKOUT_WINDOW_MS / 1000)),
311
- });
312
- res.end(
313
- JSON.stringify({ ok: false, error: "Too many failed auth attempts" }),
314
- );
315
- return;
316
- }
317
-
318
- const auth = this.authState(req, url);
319
- if (auth === "bad") this.recordAuthFailure(remote);
320
- else if (auth === "ok") this.authFailures.delete(remote);
434
+ const { state: auth, principal } = this.authState(req, url);
435
+ if (!(await this.admit(res, remote, auth))) return;
321
436
 
322
437
  const key = `${method} ${path}` as BridgeRouteKey;
323
438
  const route = this.routes.get(key);
324
- const ctx: RouteContext = { req, res, url, auth };
439
+ const ctx: RouteContext = { req, res, url, auth, principal };
440
+ const tier = route ? BRIDGE_ROUTE_AUTH[key] : undefined;
325
441
 
326
- if (route && BRIDGE_ROUTE_AUTH[key] === "public") {
442
+ if (route && tier === "public") {
327
443
  await route(ctx);
328
444
  return;
329
445
  }
330
446
  // Unknown routes are 401 before they are 404: an unauthenticated caller
331
447
  // learns nothing about the route map.
332
- if (auth !== "ok") {
333
- return this.json(res, 401, { ok: false, error: "Unauthorized" });
448
+ if (auth !== "ok" || principal === null) {
449
+ return this.refuse(res, 401, "Unauthorized");
450
+ }
451
+ if (!route || tier === undefined) {
452
+ return this.json(res, 404, { ok: false, error: "Not found" });
453
+ }
454
+ // Authenticated, but is this credential allowed HERE? The scope each
455
+ // route needs is declared in routes/table.ts.
456
+ if (!routeAllows(tier, principal)) {
457
+ return this.json(res, 403, {
458
+ ok: false,
459
+ error: `This credential lacks the ${describeTier(tier)} scope ${key} requires`,
460
+ });
334
461
  }
335
- if (!route) return this.json(res, 404, { ok: false, error: "Not found" });
336
462
 
337
463
  try {
338
464
  await route(ctx);
@@ -355,7 +481,9 @@ export class BridgeServer {
355
481
  corsHeaders: () => this.corsHeaders(),
356
482
  streamFile: (res, file) => this.streamFile(res, file),
357
483
  serveMedia: (res, id) => this.serveMedia(res, id),
358
- openStream: (res, deviceId) => this.openStream(res, deviceId),
484
+ openStream: (res, deviceId, principal) =>
485
+ this.openStream(res, deviceId, principal),
486
+ credentials: this.opts.credentials,
359
487
  unknownProvision: (res) => this.unknownProvision(res),
360
488
  };
361
489
  }
@@ -410,7 +538,13 @@ export class BridgeServer {
410
538
  }
411
539
  }
412
540
 
413
- private openStream(res: ServerResponse, deviceId?: string): void {
541
+ private openStream(
542
+ res: ServerResponse,
543
+ deviceId: string | undefined,
544
+ principal: BridgePrincipal,
545
+ ): void {
546
+ // A device-only credential gets its own mesh traffic, not the chats.
547
+ const seesChats = hasScope(principal, "client");
414
548
  res.writeHead(200, {
415
549
  ...this.corsHeaders(),
416
550
  "Content-Type": "text/event-stream",
@@ -424,34 +558,60 @@ export class BridgeServer {
424
558
  `data: ${JSON.stringify({
425
559
  kind: "hello",
426
560
  status: this.handlers.status(),
427
- chats: this.handlers.listChats(),
561
+ chats: seesChats ? this.handlers.listChats() : [],
428
562
  })}\n\n`,
429
563
  );
430
564
  // Replay any in-progress turn so a client that connected mid-turn (or
431
565
  // reconnected after a blip) sees the tool timeline immediately, not just
432
566
  // the tools that fire after it joined.
433
567
  try {
434
- for (const event of this.handlers.liveTurnEvents()) {
568
+ for (const event of seesChats ? this.handlers.liveTurnEvents() : []) {
435
569
  res.write(`data: ${JSON.stringify(event)}\n\n`);
436
570
  }
437
571
  } catch (err) {
438
572
  logError("native", "Failed to replay live turn to new client", err);
439
573
  }
440
- this.clients.set(res, deviceId);
574
+ this.clients.set(res, { deviceId, principal });
441
575
  logDebug(
442
576
  "native",
443
577
  `SSE client connected${deviceId ? ` as device ${deviceId}` : ""} (${this.clients.size} total)`,
444
578
  );
579
+ const lifetime = this.sseLifetimeTimer(res);
445
580
  res.on("close", () => {
581
+ clearTimeout(lifetime);
446
582
  this.clients.delete(res);
447
583
  logDebug("native", `SSE client left (${this.clients.size} total)`);
448
584
  });
449
585
  }
450
586
 
587
+ /**
588
+ * Optional max lifetime for an event stream: ending it makes the client
589
+ * reconnect and present its token again. Off unless configured —
590
+ * clients do reconnect, but with their own backoff, and a device command
591
+ * sent in that gap is lost.
592
+ */
593
+ private sseLifetimeTimer(
594
+ res: ServerResponse,
595
+ ): ReturnType<typeof setTimeout> | undefined {
596
+ const max = this.opts.sseMaxLifetimeMs;
597
+ if (!max || max <= 0) return undefined;
598
+ // Jitter so a fleet that connected together doesn't reconnect together.
599
+ const ms = Math.round(max * (0.9 + Math.random() * 0.2));
600
+ const timer = setTimeout(() => {
601
+ logDebug("native", "bridge.sse event=max_lifetime reason=expired");
602
+ res.end();
603
+ }, ms);
604
+ timer.unref?.();
605
+ return timer;
606
+ }
607
+
451
608
  // ── Helpers ──────────────────────────────────────────────────────────────
452
609
 
453
- private authState(req: IncomingMessage, url: URL): AuthState {
454
- if (!this.opts.token) return "ok";
610
+ private authState(
611
+ req: IncomingMessage,
612
+ url: URL,
613
+ ): { state: AuthState; principal: BridgePrincipal | null } {
614
+ if (!this.opts.token) return { state: "ok", principal: { kind: "open" } };
455
615
  const header = req.headers["authorization"];
456
616
  const fromHeader =
457
617
  typeof header === "string" && header.startsWith("Bearer ")
@@ -459,45 +619,68 @@ export class BridgeServer {
459
619
  : null;
460
620
  // EventSource can't set headers, so SSE clients pass ?token=… instead.
461
621
  const candidate = fromHeader ?? url.searchParams.get("token");
462
- if (candidate === null) return "anonymous";
463
- return this.tokenMatches(candidate) ? "ok" : "bad";
622
+ if (candidate === null) return { state: "anonymous", principal: null };
623
+ // The shared token or a per-device credential (credentials/principal.ts).
624
+ const principal = resolvePrincipal(
625
+ candidate,
626
+ req,
627
+ (c) => this.tokenMatches(c),
628
+ this.opts.credentials,
629
+ );
630
+ return principal
631
+ ? { state: "ok", principal }
632
+ : { state: "bad", principal: null };
464
633
  }
465
634
 
466
- private authLockedOut(remote: string): boolean {
467
- const entry = this.authFailures.get(remote);
468
- if (!entry) return false;
469
- if (Date.now() >= entry.resetAt) {
470
- this.authFailures.delete(remote);
635
+ /**
636
+ * Apply the auth guard's verdict. Returns false once it has answered the
637
+ * request itself (429), true when routing should continue — after any
638
+ * backoff wait has elapsed.
639
+ */
640
+ private async admit(
641
+ res: ServerResponse,
642
+ remote: string,
643
+ auth: AuthState,
644
+ ): Promise<boolean> {
645
+ const verdict = this.authGuard.check(remote, auth);
646
+ if (verdict.kind === "reject") {
647
+ this.refuse(
648
+ res,
649
+ 429,
650
+ verdict.reason === "lockout"
651
+ ? "Too many failed auth attempts"
652
+ : "Too many failed auth attempts; try again later",
653
+ verdict.retryAfterSec,
654
+ );
471
655
  return false;
472
656
  }
473
- return entry.count >= AUTH_LOCKOUT_MAX_FAILURES;
657
+ if (verdict.kind === "delay" && !(await this.authGuard.hold(verdict.ms))) {
658
+ // Too many responses already held: refuse now rather than queue more.
659
+ this.refuse(res, 429, "Too many failed auth attempts", 1);
660
+ return false;
661
+ }
662
+ // The client may have hung up while we waited.
663
+ return !res.destroyed;
474
664
  }
475
665
 
476
- private recordAuthFailure(remote: string): void {
477
- const now = Date.now();
478
- const entry = this.authFailures.get(remote);
479
- if (!entry || now >= entry.resetAt) {
480
- if (this.authFailures.size >= AUTH_LOCKOUT_MAX_TRACKED) {
481
- for (const [ip, e] of this.authFailures) {
482
- if (now >= e.resetAt) this.authFailures.delete(ip);
483
- }
484
- // Still saturated after pruning live entries — under that much churn
485
- // dropping the newest attacker beats unbounded growth.
486
- if (this.authFailures.size >= AUTH_LOCKOUT_MAX_TRACKED) return;
487
- }
488
- this.authFailures.set(remote, {
489
- count: 1,
490
- resetAt: now + AUTH_LOCKOUT_WINDOW_MS,
491
- });
492
- return;
493
- }
494
- entry.count++;
495
- if (entry.count === AUTH_LOCKOUT_MAX_FAILURES) {
496
- logWarn(
497
- "native",
498
- `Bridge auth lockout for ${remote} (${AUTH_LOCKOUT_MAX_FAILURES} wrong tokens in ${AUTH_LOCKOUT_WINDOW_MS / 60_000}m)`,
499
- );
500
- }
666
+ /**
667
+ * An auth refusal. `Connection: close` so an unauthenticated caller
668
+ * can't keep the socket, or dribble an unread request body into it.
669
+ */
670
+ private refuse(
671
+ res: ServerResponse,
672
+ code: 401 | 429,
673
+ error: string,
674
+ retryAfterSec?: number,
675
+ ): void {
676
+ res.writeHead(code, {
677
+ ...this.jsonHeaders(),
678
+ Connection: "close",
679
+ ...(retryAfterSec !== undefined
680
+ ? { "Retry-After": String(retryAfterSec) }
681
+ : {}),
682
+ });
683
+ res.end(JSON.stringify({ ok: false, error }));
501
684
  }
502
685
 
503
686
  /**
@@ -17,7 +17,8 @@
17
17
  import type { TalonConfig } from "../../core/config/index.js";
18
18
  import type { ContextManager } from "../../core/types.js";
19
19
  import type { Gateway } from "../../core/engine/gateway.js";
20
- import { log, logError } from "../../util/log.js";
20
+ import { log, logError, logWarn } from "../../util/log.js";
21
+ import { notifyAdmin } from "../../core/frontend-runtime/admin-notify.js";
21
22
  import { createNativeActionHandler } from "./turn/actions.js";
22
23
  import { loadOrCreateBridgeToken } from "./bridge/auth.js";
23
24
  import { warmContextCache } from "./turn/context.js";
@@ -29,7 +30,11 @@ import { emitAssistant, emitPhoto } from "./turn/emit.js";
29
30
  import { startEmptyChatSweep } from "./chats/empty-chat-sweep.js";
30
31
  import { buildBridgeHandlers } from "./surface/handlers.js";
31
32
  import { createNativeRuntime, type NativeRuntime } from "./runtime.js";
32
- import { BridgeServer } from "./bridge/server.js";
33
+ import { BridgeServer, type BridgeCredentials } from "./bridge/server.js";
34
+ import {
35
+ DEFAULT_COMPANION_SCOPES,
36
+ type MeshScope,
37
+ } from "../../core/mesh/credentials/index.js";
33
38
  import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./bridge/tls.js";
34
39
 
35
40
  export { summarizeToolResult } from "./turn/tool-result.js";
@@ -50,6 +55,8 @@ type BridgeListen = {
50
55
  port: number;
51
56
  token: string | undefined;
52
57
  tls: boolean;
58
+ legacySharedToken: boolean;
59
+ companionScopes: readonly MeshScope[];
53
60
  };
54
61
 
55
62
  /** Where the bridge listens, and how it is secured, from `config.native`. */
@@ -71,7 +78,32 @@ function bridgeListen(config: TalonConfig): BridgeListen {
71
78
  const token =
72
79
  configuredToken ??
73
80
  (isLoopbackHost(host) ? undefined : loadOrCreateBridgeToken());
74
- return { host, port: nativeCfg.port ?? 19880, token, tls };
81
+ return {
82
+ host,
83
+ port: nativeCfg.port ?? 19880,
84
+ token,
85
+ tls,
86
+ legacySharedToken: nativeCfg.legacySharedToken ?? true,
87
+ companionScopes: nativeCfg.companionScopes ?? DEFAULT_COMPANION_SCOPES,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Per-device credentials for the bridge: only meaningful when the bridge
93
+ * authenticates at all (an open loopback bridge has nothing to scope).
94
+ */
95
+ function bridgeCredentials(
96
+ listen: BridgeListen,
97
+ mesh: NativeRuntime["mesh"],
98
+ ): BridgeCredentials | undefined {
99
+ if (!listen.token || !mesh.credentials) return undefined;
100
+ return {
101
+ authority: mesh.credentials,
102
+ policy: {
103
+ legacySharedToken: listen.legacySharedToken,
104
+ companionScopes: listen.companionScopes,
105
+ },
106
+ };
75
107
  }
76
108
 
77
109
  /**
@@ -116,8 +148,13 @@ export function createNativeFrontend(
116
148
  port: listen.port,
117
149
  token: listen.token,
118
150
  allowedOrigins: config.native?.allowedOrigins,
151
+ allowWeakToken: config.native?.allowWeakToken,
152
+ sseMaxLifetimeMs: config.native?.sseMaxLifetimeMs,
153
+ // Distributed token guessing should reach a human, not just the log.
154
+ onSecurityAlert: (message) => void notifyAdmin(message),
119
155
  startedAt: runtime.startedAt,
120
156
  ...(listen.tls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
157
+ credentials: bridgeCredentials(listen, mesh),
121
158
  },
122
159
  buildBridgeHandlers(runtime),
123
160
  );
@@ -176,6 +213,18 @@ export function createNativeFrontend(
176
213
  );
177
214
  stopEmptyChatSweep = startEmptyChatSweep(runtime);
178
215
  await server.start();
216
+ if (
217
+ listen.token &&
218
+ listen.legacySharedToken &&
219
+ !isLoopbackHost(listen.host)
220
+ ) {
221
+ logWarn(
222
+ "native",
223
+ "native.legacySharedToken is on: remote clients may still use the shared bridge token. " +
224
+ "Devices trade it for their own credential on their next connect; once `talon mesh` " +
225
+ "lists none on the shared token, set native.legacySharedToken to false and rotate native.token.",
226
+ );
227
+ }
179
228
  const fingerprint = server.getFingerprint();
180
229
  // Tell the mesh how this bridge is reachable — everything a generated
181
230
  // node installer needs (make_node_install_link fails cleanly without it).
@@ -188,6 +237,8 @@ export function createNativeFrontend(
188
237
  ...(config.native?.publicUrl
189
238
  ? { publicUrl: config.native.publicUrl }
190
239
  : {}),
240
+ legacySharedToken: listen.legacySharedToken,
241
+ companionScopes: listen.companionScopes,
191
242
  });
192
243
  await writeBridgeDiscovery({
193
244
  port: server.getPort(),
@@ -9,6 +9,7 @@
9
9
  import type { ExecuteResult } from "../../../core/types.js";
10
10
  import type { AgentEvent } from "../../../core/agent-runtime/events.js";
11
11
  import { execute } from "../../../core/engine/dispatcher.js";
12
+ import { LOCAL_OPERATOR_SENDER } from "../../../core/mcp-hub/guest-scope.js";
12
13
  import { toolInputToRecord } from "../../../core/agent-runtime/events.js";
13
14
  import { getBackendForChat } from "../../../core/engine/backend-controller/index.js";
14
15
  import { isDeliveryTool } from "../../../core/tools/index.js";
@@ -294,6 +295,7 @@ async function runTurn(
294
295
  numericChatId: entry.numericId,
295
296
  prompt,
296
297
  senderName: "User",
298
+ senderKeys: [LOCAL_OPERATOR_SENDER],
297
299
  isGroup: false,
298
300
  // Give the model the user message's id so `[msg_id:N]` is present and
299
301
  // react/reply/edit target the user's message — not the bot's.
@@ -65,6 +65,7 @@ export function runTurn(
65
65
  numericChatId,
66
66
  prompt: `[${msg.senderName}]: ${msg.text}`,
67
67
  senderName: msg.senderName,
68
+ senderKeys: msg.senderId ? [`teams:${msg.senderId}`] : undefined,
68
69
  isGroup: true,
69
70
  source: "message",
70
71
  onEvent: async (event) => {