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
@@ -12,6 +12,13 @@
12
12
  * `failed` with its reason and the run carries on — the next run retries
13
13
  * it. The manifest keeps the per-target state alongside the parts, so a
14
14
  * snapshot restored onto a new machine still knows where its copies are.
15
+ *
16
+ * Plaintext never leaves the box: before any target is contacted, every
17
+ * part's own bytes (not the manifest's say-so) must carry the encryption
18
+ * header. A snapshot taken without `backup.encryption` stays local, and
19
+ * each target records why. Parts marked `localOnly` (login sessions, by
20
+ * default) are never offered to a target at all; the manifest still lists
21
+ * them, so a restore from the remote copy knows what it is missing.
15
22
  */
16
23
 
17
24
  import { bus } from "../bus/index.js";
@@ -21,6 +28,7 @@ import {
21
28
  deleteBackupRemote,
22
29
  recordBackupRemote,
23
30
  } from "../../storage/backup/index.js";
31
+ import { isEncryptedFile } from "./archive/crypt.js";
24
32
  import {
25
33
  partPath,
26
34
  reindexSnapshot,
@@ -28,7 +36,7 @@ import {
28
36
  writeManifest,
29
37
  } from "./store.js";
30
38
  import type { BackupTarget } from "./targets.js";
31
- import type { Manifest, RemoteState } from "./types.js";
39
+ import type { Manifest, RemoteState, SnapshotPart } from "./types.js";
32
40
 
33
41
  function recordState(id: string, targetId: string, state: RemoteState): void {
34
42
  recordBackupRemote({
@@ -41,14 +49,20 @@ function recordState(id: string, targetId: string, state: RemoteState): void {
41
49
  });
42
50
  }
43
51
 
52
+ /** The parts a remote target may receive: everything not marked local-only. */
53
+ function remoteParts(manifest: Manifest): SnapshotPart[] {
54
+ return manifest.parts.filter((part) => part.localOnly !== true);
55
+ }
56
+
44
57
  /** Push every part, then the manifest. Throws with the target's own words. */
45
58
  async function sendSnapshot(
46
59
  target: BackupTarget,
47
60
  manifest: Manifest,
48
61
  home: string,
49
62
  ): Promise<{ state: RemoteState; deduplicated: boolean }> {
50
- let deduplicated = manifest.parts.length > 0;
51
- for (const part of manifest.parts) {
63
+ const parts = remoteParts(manifest);
64
+ let deduplicated = parts.length > 0;
65
+ for (const part of parts) {
52
66
  const result = await target.upload(
53
67
  manifest.id,
54
68
  { ...part, path: partPath(manifest.id, part.name, home) },
@@ -63,6 +77,39 @@ async function sendSnapshot(
63
77
  };
64
78
  }
65
79
 
80
+ /** The refusal every target records for an unencrypted snapshot. */
81
+ export const PLAINTEXT_REFUSAL =
82
+ "remote backup targets require backup.encryption";
83
+
84
+ /** The first part whose file is not encrypted (or is missing), if any. */
85
+ async function firstPlaintextPart(
86
+ manifest: Manifest,
87
+ home: string,
88
+ ): Promise<string | undefined> {
89
+ for (const part of remoteParts(manifest)) {
90
+ const encrypted = await isEncryptedFile(
91
+ partPath(manifest.id, part.name, home),
92
+ ).catch(() => false);
93
+ if (!encrypted) return part.name;
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /** Mark every target failed without contacting any of them. */
99
+ function refuseAll(
100
+ manifest: Manifest,
101
+ targets: readonly BackupTarget[],
102
+ partName: string,
103
+ ): void {
104
+ const error = `${PLAINTEXT_REFUSAL} (part ${partName} is not encrypted; the snapshot stays local)`;
105
+ for (const target of targets) {
106
+ const state: RemoteState = { status: "failed", error };
107
+ manifest.remote[target.id] = state;
108
+ recordState(manifest.id, target.id, state);
109
+ }
110
+ logWarn("backup", `Not uploading ${manifest.id}: ${error}`);
111
+ }
112
+
66
113
  /**
67
114
  * Upload one snapshot to every target. Returns the manifest with its
68
115
  * `remote` map filled in; it is rewritten on disk and reindexed so the
@@ -74,6 +121,22 @@ export async function uploadSnapshot(
74
121
  home: string = dirs.root,
75
122
  ): Promise<Manifest> {
76
123
  if (targets.length === 0) return manifest;
124
+ const plaintext = await firstPlaintextPart(manifest, home);
125
+ if (plaintext !== undefined) {
126
+ refuseAll(manifest, targets, plaintext);
127
+ } else {
128
+ await uploadToAll(manifest, targets, home);
129
+ }
130
+ await writeManifest(manifest, home);
131
+ reindexSnapshot(manifest);
132
+ return manifest;
133
+ }
134
+
135
+ async function uploadToAll(
136
+ manifest: Manifest,
137
+ targets: readonly BackupTarget[],
138
+ home: string,
139
+ ): Promise<void> {
77
140
  await Promise.all(
78
141
  targets.map(async (target) => {
79
142
  if (!target.ready) {
@@ -116,9 +179,6 @@ export async function uploadSnapshot(
116
179
  }
117
180
  }),
118
181
  );
119
- await writeManifest(manifest, home);
120
- reindexSnapshot(manifest);
121
- return manifest;
122
182
  }
123
183
 
124
184
  /**
@@ -175,6 +175,25 @@ const nativeConfigSchema = z
175
175
  * (~/.talon/keys/bridge-token) — the network never gets an open bridge.
176
176
  */
177
177
  token: z.string().optional(),
178
+ /**
179
+ * Start the bridge on a network-reachable `host` even though `token`
180
+ * looks weak (under ~128 bits by a length × alphabet estimate, e.g.
181
+ * `hunter2`). Off by default: a weak token on a non-loopback bind
182
+ * refuses to start, with instructions to generate a strong one. With
183
+ * this set the bridge starts and logs a security warning every time.
184
+ * Loopback binds only ever warn.
185
+ */
186
+ allowWeakToken: z.boolean().optional(),
187
+ /**
188
+ * Maximum lifetime of one authenticated event stream (`GET /events`),
189
+ * in ms. When set, each stream is closed after roughly this long
190
+ * (±10% jitter) and the client reconnects, presenting its token again.
191
+ * Unset (default) = streams live until the client leaves. The companion
192
+ * and talon-node both reconnect on their own, but with their own
193
+ * backoff, and a device command sent in that gap is dropped, so this is
194
+ * opt-in. Minimum 60000.
195
+ */
196
+ sseMaxLifetimeMs: z.number().int().min(60_000).optional(),
178
197
  /**
179
198
  * Origins allowed to call the bridge from a BROWSER. Native clients
180
199
  * (Electron main, Flutter, curl, talon-node) send no Origin header and
@@ -202,6 +221,29 @@ const nativeConfigSchema = z
202
221
  .string()
203
222
  .regex(/^https?:\/\/\S+$/, "native.publicUrl must be an http(s) URL")
204
223
  .optional(),
224
+ /**
225
+ * Accept the shared `token` from remote (non-loopback, or proxied)
226
+ * clients. Every device now gets its own credential when it pairs, and
227
+ * devices still holding the shared token trade it for one in-band on
228
+ * their next connect; the daemon log and `talon mesh` list the ones
229
+ * that have not. Once none remain, set this to false and rotate
230
+ * `token` — it then only works for same-machine clients (the desktop
231
+ * app and CLI, via the 0600 discovery file). Default true for this
232
+ * release; the default flips to false in a later one.
233
+ */
234
+ legacySharedToken: z.boolean().optional(),
235
+ /**
236
+ * Scopes a companion's per-device credential carries — at pairing and
237
+ * on the in-band upgrade. Default ["device", "client"]: the mesh plus
238
+ * the chat UI. Adding "operator" lets every paired phone change config,
239
+ * toggle plugins and read logs; prefer granting it to one device with
240
+ * `talon mesh scopes <device> device,client,operator`. Nodes always get
241
+ * ["device"].
242
+ */
243
+ companionScopes: z
244
+ .array(z.enum(["device", "client", "operator"]))
245
+ .min(1)
246
+ .optional(),
205
247
  })
206
248
  .strict();
207
249
 
@@ -407,6 +449,19 @@ const configSchema = z.object({
407
449
  apiHash: z.string().optional(),
408
450
  adminUserId: z.number().int().optional(),
409
451
  allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
452
+ /**
453
+ * Telegram groups the bot serves. When unset, groups are admitted by the
454
+ * admin's membership (legacy, warned at startup). Only the operator's own
455
+ * messages get the full tool set in any group.
456
+ */
457
+ allowedGroups: z.array(z.number().int()).optional(),
458
+ /**
459
+ * Further operator identities, beyond `adminUserId`: messages from these
460
+ * senders get the full tool set; everyone else is guest-scoped. Forms:
461
+ * Telegram user id ("123"), WhatsApp "wa_dm_<number>", "discord:<userId>",
462
+ * "teams:<userId>". Discord `adminUserIds` are included automatically.
463
+ */
464
+ operatorIds: z.array(z.string()).optional(),
410
465
  // Denylist of user IDs dropped in silence — no warning reply, no admin
411
466
  // notification. For spam and prompt-injection senders, where the warning
412
467
  // itself is the reward: it confirms a live bot is reading.
@@ -539,12 +594,16 @@ const configSchema = z.object({
539
594
  *
540
595
  * - `workspaceInclude` — the workspace is mostly bulk that can be
541
596
  * refetched; this is the subset that IS the agent.
597
+ * - `includeSessions` — backend session transcripts (Claude Code
598
+ * projects for the workspace, Codex/OpenCode/Kilo/Antigravity
599
+ * stores for enabled backends) and data/traces, in their own part.
542
600
  * - `extraPaths` — absolute or `~/…` paths outside ~/.talon worth
543
601
  * carrying along (a Claude Code memory directory, say).
544
602
  * - `checkpointBeforeUpdate` — pinned checkpoint before `/update`,
545
603
  * so a bad update is one restore away from undone.
546
604
  * - `notifyChatId` — where failures are reported; falls back to the
547
605
  * admin chat.
606
+ * - `encryption` / `loginSessions` — see docs/backup-security.md.
548
607
  */
549
608
  backup: z
550
609
  .object({
@@ -568,6 +627,17 @@ const configSchema = z.object({
568
627
  .max(1000)
569
628
  .default(DEFAULT_BACKUP_SETTINGS.keepRemote),
570
629
  includePalace: z.boolean().default(DEFAULT_BACKUP_SETTINGS.includePalace),
630
+ /**
631
+ * WhatsApp auth + the userbot's Telegram login. "local" (default)
632
+ * keeps them in local snapshots only; "remote" also uploads them
633
+ * (encrypted); "off" leaves them out entirely.
634
+ */
635
+ loginSessions: z
636
+ .enum(["off", "local", "remote"])
637
+ .default(DEFAULT_BACKUP_SETTINGS.loginSessions),
638
+ includeSessions: z
639
+ .boolean()
640
+ .default(DEFAULT_BACKUP_SETTINGS.includeSessions),
571
641
  workspaceInclude: z
572
642
  .array(z.string().min(1))
573
643
  .default([...DEFAULT_BACKUP_SETTINGS.workspaceInclude]),
@@ -578,10 +648,31 @@ const configSchema = z.object({
578
648
  .boolean()
579
649
  .default(DEFAULT_BACKUP_SETTINGS.checkpointBeforeUpdate),
580
650
  notifyChatId: z.string().optional(),
651
+ /**
652
+ * Encrypt every part (AES-256-GCM, scrypt-derived key). The
653
+ * passphrase comes from TALON_BACKUP_PASSPHRASE or this file —
654
+ * never inline, since config.json is itself inside the backup.
655
+ * Without a passphrase, remote targets refuse the upload.
656
+ */
657
+ encryption: z
658
+ .object({ passphraseFile: z.string().trim().min(1).optional() })
659
+ .strict()
660
+ .optional(),
581
661
  })
582
662
  .strict()
583
663
  .optional(),
584
664
  braveApiKey: z.string().optional(),
665
+ /**
666
+ * `fetch_url` refuses hosts that resolve to loopback, private (RFC 1918,
667
+ * CGNAT, ULA), link-local (incl. the 169.254.169.254 metadata endpoint)
668
+ * or reserved addresses, re-checking every redirect hop. Set
669
+ * `allowPrivateNetworks: true` only on a host where the agent should
670
+ * read local services (a home lab, a dev server).
671
+ */
672
+ fetchUrl: z
673
+ .object({ allowPrivateNetworks: z.boolean().default(false) })
674
+ .strict()
675
+ .optional(),
585
676
  /**
586
677
  * Codex-specific OpenAI API key. Prefer this, CODEX_API_KEY, or
587
678
  * TALON_CODEX_KEY when the Codex backend should use API-key billing
@@ -641,18 +732,24 @@ const configSchema = z.object({
641
732
  disabledToolTags: z.array(z.string()).optional(),
642
733
 
643
734
  /**
644
- * Conversation-only tool surface for DMs from anyone who isn't an
645
- * operator. The admin's Telegram DM and any `operatorChats` keep
646
- * everything; other DMs (Telegram user ids, `wa_dm_*`) get reply/react/
735
+ * Conversation-only ("guest") tool surface for anyone who isn't an
736
+ * operator (`adminUserId`, `operatorIds`, `operatorChats`): reply/react/
647
737
  * history/stickers plus the `guestPlugins` servers — no shell, files,
648
- * mail, devices, cron, memory, agents or cross-chat sends. Groups are
649
- * unaffected. Off by default. See core/mcp-hub/guest-scope.ts.
738
+ * mail, devices, cron, memory, agents or cross-chat sends. Always applied
739
+ * to non-operator senders in groups; applied to non-operator DMs unless
740
+ * `enabled: false` (legacy opt-out). See core/mcp-hub/guest-scope.ts.
650
741
  */
651
742
  guestDmScope: z
652
743
  .object({
653
- enabled: z.boolean().default(false),
744
+ enabled: z.boolean().default(true),
654
745
  operatorChats: z.array(z.string()).default([]),
655
746
  guestPlugins: z.array(z.string()).optional(),
747
+ /**
748
+ * Groups the operator is a member of give EVERY member the full
749
+ * surface (shell, files, mail, devices…). Off by default; only for
750
+ * groups whose members the operator trusts with the host.
751
+ */
752
+ operatorGroups: z.boolean().default(false),
656
753
  })
657
754
  .optional(),
658
755
 
@@ -1016,9 +1113,11 @@ export function loadConfig(): TalonConfig {
1016
1113
  ? parsed.frontend
1017
1114
  : [parsed.frontend];
1018
1115
  for (const fe of frontends) {
1019
- if (fe === "telegram" && !parsed.botToken) {
1116
+ if (fe === "telegram" && (!parsed.botToken || !parsed.adminUserId)) {
1020
1117
  throw new Error(
1021
- `Telegram frontend requires "botToken" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
1118
+ parsed.botToken
1119
+ ? TELEGRAM_ADMIN_REQUIRED
1120
+ : `Telegram frontend requires "botToken" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
1022
1121
  );
1023
1122
  }
1024
1123
  if (fe === "teams" && !parsed.teamsWebhookUrl) {
@@ -1045,6 +1144,39 @@ export function loadConfig(): TalonConfig {
1045
1144
  };
1046
1145
  }
1047
1146
 
1147
+ /**
1148
+ * Why a Telegram frontend without `adminUserId` refuses to start: the admin
1149
+ * is who may run operator commands and who the DM allowlist defaults to, so
1150
+ * without one the bot would have no owner at all.
1151
+ */
1152
+ export const TELEGRAM_ADMIN_REQUIRED =
1153
+ `Telegram frontend requires "adminUserId" (your numeric Telegram user id) in ${CONFIG_FILE}. ` +
1154
+ `It decides who may run admin commands and, unless "allowedUsers" lists more people, who may DM the bot. ` +
1155
+ `Find your id by messaging @userinfobot, then run "talon setup" or add "adminUserId": <id> to the config.`;
1156
+
1157
+ /**
1158
+ * Only the `backup` block, validated — without writing a default config
1159
+ * or checking frontend requirements. `talon backup restore` on a fresh
1160
+ * host has no real config yet (it is inside the snapshot), and the
1161
+ * default one would fail on its empty botToken before anything restored.
1162
+ */
1163
+ export function loadBackupConfig(): TalonConfig["backup"] {
1164
+ const fileConfig = loadConfigFile();
1165
+ const result = configSchema.shape.backup.safeParse(fileConfig.backup);
1166
+ if (!result.success) {
1167
+ const issues = formatSchemaIssues(result.error).map(
1168
+ (line) => `backup.${line}`,
1169
+ );
1170
+ throw new ConfigFileError(
1171
+ `Invalid backup config in ${CONFIG_FILE}:\n` +
1172
+ issues.map((line) => ` - ${line}`).join("\n"),
1173
+ CONFIG_FILE,
1174
+ issues,
1175
+ );
1176
+ }
1177
+ return result.data;
1178
+ }
1179
+
1048
1180
  /**
1049
1181
  * Rebuild the system prompt with plugin additions.
1050
1182
  * Called after plugins are loaded to inject their prompt contributions.
@@ -15,6 +15,10 @@
15
15
  import { spawn } from "node:child_process";
16
16
  import { dirname, resolve } from "node:path";
17
17
  import { isBunRuntime } from "../../util/runtime.js";
18
+ import {
19
+ gatewayAuthHeaders,
20
+ readGatewayToken,
21
+ } from "../engine/gateway-auth.js";
18
22
  import {
19
23
  readPidRecord,
20
24
  writePidRecord,
@@ -157,6 +161,11 @@ async function requestShutdown(port: number): Promise<boolean> {
157
161
  try {
158
162
  const resp = await fetch(`http://127.0.0.1:${port}/shutdown`, {
159
163
  method: "POST",
164
+ headers: {
165
+ "Content-Type": "application/json",
166
+ ...gatewayAuthHeaders(readGatewayToken()),
167
+ },
168
+ body: "{}",
160
169
  signal: AbortSignal.timeout(2000),
161
170
  });
162
171
  if (!resp.ok) return false;
@@ -17,6 +17,10 @@
17
17
  */
18
18
 
19
19
  import { readPidRecord, isProcessAlive } from "./pidfile.js";
20
+ import {
21
+ gatewayAuthHeaders,
22
+ readGatewayToken,
23
+ } from "../engine/gateway-auth.js";
20
24
 
21
25
  export type DaemonHealth = {
22
26
  app?: string;
@@ -78,7 +82,10 @@ export async function probeHealth(
78
82
  timeoutMs = 800,
79
83
  ): Promise<DaemonHealth | null> {
80
84
  try {
85
+ // The token is optional here: identity fields answer without it, the
86
+ // live counters `talon status` shows need it.
81
87
  const resp = await fetch(`http://127.0.0.1:${port}/health`, {
88
+ headers: gatewayAuthHeaders(readGatewayToken()),
82
89
  signal: AbortSignal.timeout(timeoutMs),
83
90
  });
84
91
  if (!resp.ok) return null;
@@ -26,6 +26,7 @@
26
26
  import type { PlanUsage } from "../../agent-runtime/capabilities.js";
27
27
  import type { TalonConfig } from "../../config/index.js";
28
28
  import {
29
+ acquireBackendInstance,
29
30
  getPooledBackend,
30
31
  listAvailableBackends,
31
32
  } from "../backend-controller/index.js";
@@ -191,12 +192,35 @@ function unknownHeadroom(
191
192
  return { id, label, headroom: 1, source: "none", fetchedAt: now };
192
193
  }
193
194
 
194
- /** Ask a pooled backend for its plan windows. Rejects like the backend does. */
195
+ /**
196
+ * Ask a backend for its plan windows. Rejects like the backend does.
197
+ *
198
+ * A pooled instance is used as-is. An idle backend (no chat bound to it) is
199
+ * booted transiently for the read and released again: its quota is just as
200
+ * real when nothing is routed to it, and reporting "not running" there made
201
+ * `/usage` hide exactly the headroom the router needs to pick a backend that
202
+ * is *not* currently in use. The read is the backend's own cached one, so at
203
+ * most one boot per cache window.
204
+ */
195
205
  async function readPlanUsage(id: string): Promise<PlanUsage | undefined> {
196
- const backend = getPooledBackend(id);
197
- const read = backend?.usage?.getPlanUsage;
198
- if (!read || !backend?.usage) return undefined;
199
- return read.call(backend.usage);
206
+ const pooled = getPooledBackend(id);
207
+ if (pooled?.usage?.getPlanUsage) {
208
+ return pooled.usage.getPlanUsage.call(pooled.usage);
209
+ }
210
+ if (pooled) return undefined; // pooled, but reports no plan windows
211
+ let acquired;
212
+ try {
213
+ acquired = await acquireBackendInstance(id);
214
+ } catch {
215
+ return undefined; // can't boot it (not configured, no auth) — stay quiet
216
+ }
217
+ try {
218
+ const read = acquired.backend.usage?.getPlanUsage;
219
+ if (!read || !acquired.backend.usage) return undefined;
220
+ return await read.call(acquired.backend.usage);
221
+ } finally {
222
+ await acquired.release().catch(() => {});
223
+ }
200
224
  }
201
225
 
202
226
  /**
@@ -34,9 +34,13 @@ export interface BackendUsageSnapshot {
34
34
  /** The reason a backend has no plan windows to show. */
35
35
  function noteFor(id: string, headroom: BackendHeadroom): string {
36
36
  const backend = getPooledBackend(id);
37
- if (!backend) return "not running";
38
- if (!backend.usage?.getPlanUsage) return "no plan limits on this backend";
37
+ // An idle backend is booted transiently for the plan read (see
38
+ // headroom.ts), so reaching here without windows means it really has none
39
+ // to report — not that it is "not running".
40
+ if (backend && !backend.usage?.getPlanUsage)
41
+ return "no plan limits on this backend";
39
42
  if (headroom.source === "ledger") return "tracked against a local budget";
43
+ if (headroom.source === "none") return "no usage information available";
40
44
  return "no usage information available";
41
45
  }
42
46
 
@@ -0,0 +1,201 @@
1
+ /**
2
+ * SSRF guard for `fetch_url`.
3
+ *
4
+ * The URL comes from the model, and the model reads untrusted pages, so
5
+ * "fetch this" can be steered at anything the daemon's host can reach:
6
+ * the cloud metadata endpoint (169.254.169.254 hands out instance
7
+ * credentials), the loopback gateway, a router admin page on the LAN.
8
+ * So before every request — the first one and each redirect hop — the
9
+ * host is resolved and EVERY address it resolves to must be public.
10
+ * Redirects are followed by hand (never by fetch) so a public page
11
+ * cannot bounce the request into a private one.
12
+ *
13
+ * Residual risk, stated plainly: fetch resolves the name again after we
14
+ * checked it, so a DNS-rebinding server with a zero TTL can still race
15
+ * us. Pinning the checked address would need a custom connector, which
16
+ * Bun (one of the two runtimes) does not honour. The guard closes the
17
+ * direct, redirect and static-DNS paths; rebinding needs a hostile DNS
18
+ * server and a lucky race.
19
+ *
20
+ * Operators who run Talon next to services they WANT it to read (a home
21
+ * lab, a local dev server) can opt out with
22
+ * `fetchUrl.allowPrivateNetworks: true`.
23
+ */
24
+
25
+ import { lookup } from "node:dns/promises";
26
+ import { isIP } from "node:net";
27
+
28
+ /** Resolves a host name to every address it maps to. Injected in tests. */
29
+ export type Resolver = (host: string) => Promise<string[]>;
30
+
31
+ const defaultResolver: Resolver = async (host) =>
32
+ (await lookup(host, { all: true, verbatim: true })).map((r) => r.address);
33
+
34
+ export class BlockedUrlError extends Error {}
35
+
36
+ const MAX_REDIRECTS = 5;
37
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
38
+
39
+ // ── Address classification ──────────────────────────────────────────────────
40
+
41
+ /** Non-public IPv4 ranges: [network, prefix length]. */
42
+ const BLOCKED_V4: ReadonlyArray<[string, number]> = [
43
+ ["0.0.0.0", 8], // "this network"
44
+ ["10.0.0.0", 8], // RFC 1918
45
+ ["100.64.0.0", 10], // CGNAT (also Tailscale)
46
+ ["127.0.0.0", 8], // loopback
47
+ ["169.254.0.0", 16], // link-local, cloud metadata
48
+ ["172.16.0.0", 12], // RFC 1918
49
+ ["192.0.0.0", 24], // IETF protocol assignments
50
+ ["192.0.2.0", 24], // TEST-NET-1
51
+ ["192.88.99.0", 24], // 6to4 relay anycast
52
+ ["192.168.0.0", 16], // RFC 1918
53
+ ["198.18.0.0", 15], // benchmarking
54
+ ["198.51.100.0", 24], // TEST-NET-2
55
+ ["203.0.113.0", 24], // TEST-NET-3
56
+ ["224.0.0.0", 4], // multicast
57
+ ["240.0.0.0", 4], // reserved + broadcast
58
+ ];
59
+
60
+ /** Non-public IPv6 ranges (IPv4-mapped/compatible are unwrapped first). */
61
+ const BLOCKED_V6: ReadonlyArray<[string, number]> = [
62
+ ["::", 128], // unspecified
63
+ ["::1", 128], // loopback
64
+ ["64:ff9b::", 96], // NAT64 — reaches whatever IPv4 it embeds
65
+ ["64:ff9b:1::", 48], // local-use NAT64
66
+ ["100::", 64], // discard
67
+ ["2001::", 32], // Teredo
68
+ ["2001:db8::", 32], // documentation
69
+ ["2002::", 16], // 6to4 — embeds an IPv4 address
70
+ ["fc00::", 7], // unique local
71
+ ["fe80::", 10], // link-local
72
+ ["fec0::", 10], // site-local (deprecated)
73
+ ["ff00::", 8], // multicast
74
+ ];
75
+
76
+ function v4ToInt(ip: string): number {
77
+ return ip
78
+ .split(".")
79
+ .reduce((acc, octet) => ((acc << 8) | Number(octet)) >>> 0, 0);
80
+ }
81
+
82
+ function v6ToBigInt(ip: string): bigint {
83
+ let text = ip.toLowerCase().split("%")[0];
84
+ // A trailing dotted quad (::ffff:1.2.3.4) becomes two hextets.
85
+ const dotted = text.match(/(\d+\.\d+\.\d+\.\d+)$/);
86
+ if (dotted) {
87
+ const n = v4ToInt(dotted[1]);
88
+ text =
89
+ text.slice(0, -dotted[1].length) +
90
+ `${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`;
91
+ }
92
+ const [head, tail] = text.includes("::") ? text.split("::") : [text, null];
93
+ const headParts = head ? head.split(":") : [];
94
+ const tailParts = tail ? tail.split(":") : [];
95
+ const fill = tail === null ? 0 : 8 - headParts.length - tailParts.length;
96
+ const parts = [...headParts, ...Array(fill).fill("0"), ...tailParts];
97
+ return parts.reduce(
98
+ (acc, part) => (acc << 16n) | BigInt(parseInt(part || "0", 16)),
99
+ 0n,
100
+ );
101
+ }
102
+
103
+ function inV4(ip: number, [net, bits]: [string, number]): boolean {
104
+ if (bits === 0) return true;
105
+ const mask = (~0 << (32 - bits)) >>> 0;
106
+ return (ip & mask) === (v4ToInt(net) & mask);
107
+ }
108
+
109
+ function inV6(ip: bigint, [net, bits]: [string, number]): boolean {
110
+ const shift = BigInt(128 - bits);
111
+ return ip >> shift === v6ToBigInt(net) >> shift;
112
+ }
113
+
114
+ /** True when `ip` is loopback, private, link-local, reserved or otherwise non-public. */
115
+ export function isBlockedAddress(ip: string): boolean {
116
+ const family = isIP(ip.split("%")[0]);
117
+ if (family === 4) {
118
+ const n = v4ToInt(ip);
119
+ return BLOCKED_V4.some((range) => inV4(n, range));
120
+ }
121
+ if (family !== 6) return true; // not an address at all: refuse
122
+ const n = v6ToBigInt(ip);
123
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses
124
+ // reach the embedded IPv4 host — judge that instead.
125
+ const high = n >> 32n;
126
+ if (high === 0xffffn || (high === 0n && n > 1n)) {
127
+ const v4 = Number(n & 0xffffffffn);
128
+ return BLOCKED_V4.some((range) => inV4(v4, range));
129
+ }
130
+ return BLOCKED_V6.some((range) => inV6(n, range));
131
+ }
132
+
133
+ // ── URL checks ──────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Throw unless `url` is http(s) and its host resolves only to public
137
+ * addresses. A literal IP is judged directly; a name is resolved.
138
+ */
139
+ export async function assertPublicUrl(
140
+ url: URL,
141
+ resolve: Resolver = defaultResolver,
142
+ ): Promise<void> {
143
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
144
+ throw new BlockedUrlError("URL must use http or https protocol");
145
+ }
146
+ const host = url.hostname.replace(/^\[|\]$/g, "");
147
+ let addresses: string[];
148
+ if (isIP(host)) {
149
+ addresses = [host];
150
+ } else {
151
+ try {
152
+ addresses = await resolve(host);
153
+ } catch (err) {
154
+ throw new BlockedUrlError(
155
+ `Cannot resolve ${host}: ${err instanceof Error ? err.message : String(err)}`,
156
+ );
157
+ }
158
+ }
159
+ if (addresses.length === 0) {
160
+ throw new BlockedUrlError(`Cannot resolve ${host}`);
161
+ }
162
+ const blocked = addresses.find(isBlockedAddress);
163
+ if (blocked) {
164
+ throw new BlockedUrlError(
165
+ `Refusing to fetch ${host}: it resolves to a private, loopback or link-local address (${blocked}). ` +
166
+ `Set fetchUrl.allowPrivateNetworks in config.json to allow local addresses.`,
167
+ );
168
+ }
169
+ }
170
+
171
+ export type GuardedFetchOptions = {
172
+ allowPrivateNetworks?: boolean;
173
+ resolve?: Resolver;
174
+ maxRedirects?: number;
175
+ };
176
+
177
+ /**
178
+ * `fetch` with the guard applied to the first request and to every
179
+ * redirect hop. Returns the final (non-redirect) response.
180
+ */
181
+ export async function guardedFetch(
182
+ input: string,
183
+ init: RequestInit,
184
+ options: GuardedFetchOptions = {},
185
+ ): Promise<Response> {
186
+ const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS;
187
+ let url = new URL(input);
188
+ for (let hop = 0; ; hop++) {
189
+ if (!options.allowPrivateNetworks) {
190
+ await assertPublicUrl(url, options.resolve);
191
+ }
192
+ const resp = await fetch(url, { ...init, redirect: "manual" });
193
+ const location = resp.headers.get("location");
194
+ if (!REDIRECT_STATUSES.has(resp.status) || !location) return resp;
195
+ if (hop >= maxRedirects) {
196
+ throw new BlockedUrlError(`Too many redirects (max ${maxRedirects})`);
197
+ }
198
+ await resp.body?.cancel().catch(() => {});
199
+ url = new URL(location, url);
200
+ }
201
+ }