@songsid/agend 2.1.9 → 2.1.10-beta.1

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 (74) hide show
  1. package/README.md +8 -3
  2. package/README.zh-TW.md +11 -5
  3. package/dist/backend/codex.d.ts +13 -0
  4. package/dist/backend/codex.js +74 -2
  5. package/dist/backend/codex.js.map +1 -1
  6. package/dist/backend/kiro.d.ts +0 -2
  7. package/dist/backend/kiro.js +8 -7
  8. package/dist/backend/kiro.js.map +1 -1
  9. package/dist/channel/adapters/telegram.js +4 -3
  10. package/dist/channel/adapters/telegram.js.map +1 -1
  11. package/dist/channel/mcp-operation-response.js +5 -4
  12. package/dist/channel/mcp-operation-response.js.map +1 -1
  13. package/dist/channel/mcp-server.js +9 -0
  14. package/dist/channel/mcp-server.js.map +1 -1
  15. package/dist/channel/types.d.ts +7 -0
  16. package/dist/cli.js +12 -4
  17. package/dist/cli.js.map +1 -1
  18. package/dist/config-validator.js +13 -0
  19. package/dist/config-validator.js.map +1 -1
  20. package/dist/config.js +26 -0
  21. package/dist/config.js.map +1 -1
  22. package/dist/daemon.d.ts +23 -0
  23. package/dist/daemon.js +63 -6
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/delivery-outbox.js +18 -23
  26. package/dist/delivery-outbox.js.map +1 -1
  27. package/dist/fleet-manager.d.ts +34 -0
  28. package/dist/fleet-manager.js +121 -22
  29. package/dist/fleet-manager.js.map +1 -1
  30. package/dist/forged-envelope-scan.js +2 -5
  31. package/dist/forged-envelope-scan.js.map +1 -1
  32. package/dist/instance-lifecycle.d.ts +9 -0
  33. package/dist/instance-lifecycle.js +37 -13
  34. package/dist/instance-lifecycle.js.map +1 -1
  35. package/dist/locale.js +166 -4
  36. package/dist/locale.js.map +1 -1
  37. package/dist/login-controller.d.ts +38 -0
  38. package/dist/login-controller.js +227 -6
  39. package/dist/login-controller.js.map +1 -1
  40. package/dist/login-flows.d.ts +7 -0
  41. package/dist/login-flows.js +1 -0
  42. package/dist/login-flows.js.map +1 -1
  43. package/dist/outbound-handlers.js +14 -16
  44. package/dist/outbound-handlers.js.map +1 -1
  45. package/dist/routing-engine.d.ts +17 -2
  46. package/dist/routing-engine.js +48 -5
  47. package/dist/routing-engine.js.map +1 -1
  48. package/dist/setup-wizard.js +3 -1
  49. package/dist/setup-wizard.js.map +1 -1
  50. package/dist/tunnel/cloudflared.d.ts +23 -5
  51. package/dist/tunnel/cloudflared.js +32 -13
  52. package/dist/tunnel/cloudflared.js.map +1 -1
  53. package/dist/tunnel/manager.d.ts +6 -0
  54. package/dist/tunnel/manager.js +60 -22
  55. package/dist/tunnel/manager.js.map +1 -1
  56. package/dist/tunnel/public-fetch.d.ts +30 -0
  57. package/dist/tunnel/public-fetch.js +156 -0
  58. package/dist/tunnel/public-fetch.js.map +1 -0
  59. package/dist/tunnel/types.d.ts +8 -2
  60. package/dist/tunnel/types.js +8 -2
  61. package/dist/tunnel/types.js.map +1 -1
  62. package/dist/types.d.ts +24 -0
  63. package/dist/ui/view.html +14 -0
  64. package/dist/ui/web-terminal/terminal.html +1 -0
  65. package/dist/wake-coordinator.d.ts +0 -15
  66. package/dist/wake-coordinator.js +19 -3
  67. package/dist/wake-coordinator.js.map +1 -1
  68. package/dist/web-host-guard.d.ts +47 -0
  69. package/dist/web-host-guard.js +75 -0
  70. package/dist/web-host-guard.js.map +1 -0
  71. package/dist/web-terminal-http.d.ts +34 -0
  72. package/dist/web-terminal-http.js +63 -0
  73. package/dist/web-terminal-http.js.map +1 -1
  74. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
- import { freemem, totalmem, cpus } from "node:os";
4
+ import { freemem, totalmem, cpus, homedir } from "node:os";
5
5
  import { createServer } from "node:http";
6
6
  import { Worker } from "node:worker_threads";
7
7
  import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
@@ -77,6 +77,7 @@ import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
77
77
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
78
78
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
79
79
  import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
80
+ import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
80
81
  import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
81
82
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
82
83
  import { SecretStore } from "./secret-store.js";
@@ -593,7 +594,7 @@ export class FleetManager {
593
594
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
594
595
  const row = outbox.get(event.deliveryId);
595
596
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
596
- const notice = `Durable delivery ${row.state}: operation_id=${row.operationId}; target=${row.targetInstance}; delivery_id=${row.deliveryId}.`;
597
+ const notice = t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
597
598
  this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state }, notice);
598
599
  this.notifyFleetError(notice);
599
600
  }
@@ -614,7 +615,7 @@ export class FleetManager {
614
615
  this.scheduleDeliveryOutboxPump();
615
616
  this.wakeCoordinator?.kick();
616
617
  if (event.count)
617
- this.notifyFleetError(`${event.count} durable delivery row(s) reached the 24-hour outbox bound; ${event.uncertain ?? 0} unresolved submissions are uncertain and sender notices were queued.`);
618
+ this.notifyFleetError(t("delivery.expired", event.count, event.uncertain ?? 0));
618
619
  });
619
620
  this.scheduleDeliveryOutboxPump();
620
621
  }
@@ -926,7 +927,7 @@ export class FleetManager {
926
927
  if ((current?.state === "delivering" || current?.state === "submission_started")
927
928
  && current.targetDaemonBootId === claimed.targetDaemonBootId
928
929
  && current.managerBootId === claimed.managerBootId) {
929
- this.notifyFleetError(`Durable delivery to ${claimed.targetInstance} is still waiting after ${Math.round(DURABLE_DELIVERY_LANE_ALERT_MS / 60_000)} minutes; the same daemon generation retains delivery ${deliveryId}'s lane until it reports or is replaced.`);
930
+ this.notifyFleetError(t("delivery.lane_waiting", claimed.targetInstance, Math.round(DURABLE_DELIVERY_LANE_ALERT_MS / 60_000), deliveryId));
930
931
  }
931
932
  }, DURABLE_DELIVERY_LANE_ALERT_MS);
932
933
  alertTimer.unref?.();
@@ -4773,6 +4774,7 @@ export class FleetManager {
4773
4774
  const targetInstance = String(msg.targetInstance ?? name);
4774
4775
  const correlationId = String(msg.correlationId ?? "unknown");
4775
4776
  const error = String(msg.error ?? "unknown delivery failure");
4777
+ const displayError = error.replace(/^delivery failed: phase=(.*); proof=(.*)$/, (_match, phase, proof) => t("delivery.pane_failure", phase, proof));
4776
4778
  const senderInstance = this.instanceIpcClients.has(senderSession)
4777
4779
  ? senderSession
4778
4780
  : this.sessionRegistry.get(senderSession);
@@ -4781,11 +4783,11 @@ export class FleetManager {
4781
4783
  from: senderSession, correlation_id: correlationId, error,
4782
4784
  });
4783
4785
  if (senderInstance) {
4784
- this.notifyInstanceTopic(senderInstance, t("cross_instance.delivery_failed_notice", targetInstance, error, correlationId));
4786
+ this.notifyInstanceTopic(senderInstance, t("cross_instance.delivery_failed_notice", targetInstance, displayError, correlationId));
4785
4787
  void this.deliverToInstance(senderInstance, {
4786
4788
  type: "fleet_inbound",
4787
4789
  targetSession: senderSession,
4788
- content: t("cross_instance.delivery_failed_agent", targetInstance, error, correlationId),
4790
+ content: t("cross_instance.delivery_failed_agent", targetInstance, displayError, correlationId),
4789
4791
  meta: {
4790
4792
  chat_id: "",
4791
4793
  message_id: `delivery-failed-${Date.now()}`,
@@ -5061,7 +5063,7 @@ export class FleetManager {
5061
5063
  */
5062
5064
  governingAccess(msg, threadId) {
5063
5065
  if (threadId && !this.classicChannels?.hasChannel(threadId)) {
5064
- const target = this.routing.resolve(threadId);
5066
+ const target = this.resolveInboundTarget(msg, threadId);
5065
5067
  if (target) {
5066
5068
  const owner = this.getInstanceAdapterId(target.name);
5067
5069
  if (owner)
@@ -5097,7 +5099,7 @@ export class FleetManager {
5097
5099
  return null;
5098
5100
  if (this.classicChannels?.hasChannel(threadId))
5099
5101
  return null;
5100
- const target = this.routing.resolve(threadId);
5102
+ const target = this.resolveInboundTarget(msg, threadId);
5101
5103
  if (!target)
5102
5104
  return null;
5103
5105
  const ownerAdapterId = this.getInstanceAdapterId(target.name);
@@ -5129,7 +5131,7 @@ export class FleetManager {
5129
5131
  return "classic: collab off";
5130
5132
  return null;
5131
5133
  }
5132
- const target = this.routing.resolve(threadId);
5134
+ const target = this.resolveInboundTarget(msg, threadId);
5133
5135
  if (!target)
5134
5136
  return "fleet topic: no instance routed for this thread";
5135
5137
  // Fleet topic: this gate lets the copy through when the adapter is open OR
@@ -5162,8 +5164,42 @@ export class FleetManager {
5162
5164
  return;
5163
5165
  this.logger.warn({ statePath, configured, inEffect: accessManager.getMode() }, `access mode "${accessManager.getMode()}" comes from the state file and overrides "${configured}" in fleet.yaml; delete ${statePath} to go back to the configured value`);
5164
5166
  }
5167
+ /**
5168
+ * #1085: the thread id inbound routing may use. Telegram topic ids are small
5169
+ * integers numbered per group, but the routing table is keyed by topic id
5170
+ * alone — so a message in topic 30 of ANY group the bot is in resolved to
5171
+ * the fleet instance that owns topic 30 of the fleet's own group (and a group
5172
+ * that never ran /start was bound by coincidence). A Telegram topic counts
5173
+ * as a fleet topic only in the fleet's group: `chat_id == group_id` of the
5174
+ * receiving bot's channel. From any other group the message is a ClassicBot
5175
+ * candidate keyed by its chat id (served only if /start registered it,
5176
+ * ignored otherwise). Discord channel ids are global, so they are unaffected.
5177
+ */
5178
+ inboundRouteThreadId(msg) {
5179
+ const raw = msg.threadId || undefined;
5180
+ if (!raw || msg.source !== "telegram")
5181
+ return raw;
5182
+ const fleetGroupId = this.getChannelConfig(msg.adapterId)?.group_id;
5183
+ if (fleetGroupId != null && String(fleetGroupId) === msg.chatId)
5184
+ return raw;
5185
+ this.logger.debug({ adapterId: msg.adapterId, chatId: msg.chatId, threadId: raw }, "Telegram topic outside the fleet group — not a fleet topic (#1085)");
5186
+ return undefined;
5187
+ }
5188
+ /**
5189
+ * #1085, defense in depth: a routed Telegram target must belong to the group
5190
+ * the message came from — its own world's group_id. Two Telegram fleet
5191
+ * worlds can number topics alike; a coincidence is dropped, never delivered.
5192
+ */
5193
+ resolveInboundTarget(msg, threadId) {
5194
+ if (msg.source !== "telegram")
5195
+ return this.routing.resolve(threadId);
5196
+ return this.routing.resolveAll(threadId).find(target => {
5197
+ const ownerGroupId = this.getChannelConfig(this.getInstanceAdapterId(target.name))?.group_id;
5198
+ return ownerGroupId != null && String(ownerGroupId) === msg.chatId;
5199
+ });
5200
+ }
5165
5201
  async handleInboundMessage(msg) {
5166
- const threadId = msg.threadId || undefined;
5202
+ const threadId = this.inboundRouteThreadId(msg);
5167
5203
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
5168
5204
  // Ownership and per-adapter filtering both run before the dedup claim below.
5169
5205
  // When several bots share a guild they all receive the same message; a copy
@@ -5219,7 +5255,7 @@ export class FleetManager {
5219
5255
  // the message (the @mention filter downstream decides who actually forwards).
5220
5256
  // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
5221
5257
  if (msg.messageId) {
5222
- const classicCid = msg.threadId || msg.chatId;
5258
+ const classicCid = threadId || msg.chatId;
5223
5259
  const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
5224
5260
  const dedupKey = isClassicMsg
5225
5261
  ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
@@ -5508,7 +5544,7 @@ export class FleetManager {
5508
5544
  // TG ClassicBot: group requires @mention, private chat forwards directly.
5509
5545
  if (!isPrivateChat && !isBotMentioned) {
5510
5546
  // No trigger: save attachments + react, log, but don't forward to agent
5511
- const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
5547
+ const syntheticMsg = { ...msg, threadId: chatId, transportThreadId: msg.threadId || undefined, text: rawText.startsWith("/") ? "" : rawText };
5512
5548
  await this.handleClassicChannelMessage(classicName, syntheticMsg);
5513
5549
  return;
5514
5550
  }
@@ -5522,7 +5558,7 @@ export class FleetManager {
5522
5558
  await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
5523
5559
  return;
5524
5560
  }
5525
- const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
5561
+ const syntheticMsg = { ...msg, threadId: chatId, transportThreadId: msg.threadId || undefined, text: `/chat ${cleanText}` };
5526
5562
  await this.handleClassicChannelMessage(classicName, syntheticMsg);
5527
5563
  return;
5528
5564
  }
@@ -5609,7 +5645,7 @@ export class FleetManager {
5609
5645
  await this.handleClassicChannelMessage(classicName, msg);
5610
5646
  return;
5611
5647
  }
5612
- const target = this.routing.resolve(threadId);
5648
+ const target = this.resolveInboundTarget(msg, threadId);
5613
5649
  if (!target) {
5614
5650
  // Only show unbound message for actual forum topics (same group, has threadId)
5615
5651
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
@@ -5849,8 +5885,18 @@ export class FleetManager {
5849
5885
  const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
5850
5886
  if (classicChannelId) {
5851
5887
  args.chat_id = classicChannelId;
5852
- delete args.thread_id;
5853
- threadId = undefined;
5888
+ // #1085: a Telegram forum ClassicBot replies in the topic it was asked
5889
+ // in. Any other thread_id (threadless Telegram, where it echoes the chat
5890
+ // id, or a Discord channel) is still cleared, as before.
5891
+ const topic = typeof args.thread_id === "string" ? args.thread_id : "";
5892
+ const telegram = (contextWorld?.adapter ?? outAdapter)?.type === "telegram";
5893
+ if (telegram && topic && topic !== classicChannelId) {
5894
+ threadId = topic;
5895
+ }
5896
+ else {
5897
+ delete args.thread_id;
5898
+ threadId = undefined;
5899
+ }
5854
5900
  }
5855
5901
  // Reply dedup: retries land here when the agent was told a send failed
5856
5902
  // (daemon budget elapsed, shell tool killed) while the adapter send was
@@ -6922,7 +6968,7 @@ export class FleetManager {
6922
6968
  quarantineMissingTopic(threadId, target, evidence) {
6923
6969
  if (!isProbeableRouteTarget(target) || this.routing.resolve(threadId) !== target)
6924
6970
  return;
6925
- this.routing.unregister(threadId);
6971
+ this.routing.unregister(threadId, target.name);
6926
6972
  this.logger.error({ instanceName: target.name, threadId, ...evidence }, "Topic is confirmed missing — route quarantined; instance configuration and user data were retained");
6927
6973
  this.notifyFleetError(t("fleet.topic_quarantined", target.name, threadId));
6928
6974
  }
@@ -9375,6 +9421,7 @@ export class FleetManager {
9375
9421
  releaseWindow: claim => { this.loginWindow.release(claim); },
9376
9422
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
9377
9423
  windowBusyMessage: () => this.loginWindow.busyMessage(),
9424
+ tunnelDataDir: () => this.dataDir,
9378
9425
  postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
9379
9426
  await this.postNonceButtonPrompt({
9380
9427
  prefix, alertType: "login", instanceName,
@@ -9892,7 +9939,7 @@ export class FleetManager {
9892
9939
  }
9893
9940
  /** Re-login confirmation (auth pre-check said credentials still work). */
9894
9941
  async handleLoginConfirm(data, callbackAdapterId, receivingAdapter) {
9895
- const claimed = this.consumeNonceCallback(LOGIN_CONFIRM_CALLBACK_PREFIX, /^login-confirm:([0-9a-f]+):(go|go-relogin|cancel)$/, data, callbackAdapterId, receivingAdapter);
9942
+ const claimed = this.consumeNonceCallback(LOGIN_CONFIRM_CALLBACK_PREFIX, /^login-confirm:([0-9a-f]+):(go|go-relogin|go-tunnel|go-relogin-tunnel|cancel)$/, data, callbackAdapterId, receivingAdapter);
9896
9943
  if (claimed === null)
9897
9944
  return false;
9898
9945
  if (claimed === "consumed")
@@ -9910,7 +9957,12 @@ export class FleetManager {
9910
9957
  chatId: entry.chatId,
9911
9958
  threadId: entry.threadId,
9912
9959
  userId: data.userId,
9913
- }, { skipAuthCheck: true, tokenPresent: action === "go-relogin" });
9960
+ }, {
9961
+ skipAuthCheck: true,
9962
+ tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
9963
+ // The consent is the button itself: only the explicit "Open public link" actions carry it.
9964
+ tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
9965
+ });
9914
9966
  if (text)
9915
9967
  await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
9916
9968
  return true;
@@ -10005,7 +10057,7 @@ export class FleetManager {
10005
10057
  }
10006
10058
  // The installer may only have added the binary to a profile PATH; a
10007
10059
  // fresh login shell sees that, the fleet process's PATH may not.
10008
- const installedAt = this.locateBinaryOnLoginShell(info.binary);
10060
+ const installedAt = this.locateBinaryOnLoginShell(info.binary) ?? this.locateInInstallerBinDirs(info);
10009
10061
  if (!installedAt) {
10010
10062
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
10011
10063
  return;
@@ -10094,6 +10146,26 @@ export class FleetManager {
10094
10146
  return null;
10095
10147
  }
10096
10148
  }
10149
+ /**
10150
+ * #1092: the binary in one of the directories its own installer installs to,
10151
+ * when no login shell can see it (the installer recorded its PATH in an
10152
+ * interactive-only rc file). The same executable-file check as the login
10153
+ * shell lookup; a symlink to an executable counts, as codex's launcher is one.
10154
+ */
10155
+ locateInInstallerBinDirs(info) {
10156
+ for (const dir of info.binDirs?.(process.env, homedir()) ?? []) {
10157
+ if (!isAbsolute(dir))
10158
+ continue;
10159
+ const path = join(dir, info.binary);
10160
+ try {
10161
+ accessSync(path, fsConstants.X_OK);
10162
+ if (statSync(path).isFile())
10163
+ return path;
10164
+ }
10165
+ catch { /* not there */ }
10166
+ }
10167
+ return null;
10168
+ }
10097
10169
  /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
10098
10170
  adoptBinaryDirectory(binaryPath, backend) {
10099
10171
  const dir = dirname(binaryPath);
@@ -10854,6 +10926,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10854
10926
  }
10855
10927
  /** Forward a message to a classic channel instance with chat log context */
10856
10928
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
10929
+ // #1085: a Telegram forum ClassicBot is keyed by chat id (threadId), but the
10930
+ // reply context must carry the topic the message was really asked in.
10931
+ const replyThreadId = msg.transportThreadId ?? msg.threadId;
10857
10932
  // Resolve the channel/adapter from the instance itself so per-channel context
10858
10933
  // config is correct even for a same-channel second bot.
10859
10934
  const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
@@ -10869,7 +10944,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10869
10944
  user: msg.username,
10870
10945
  user_id: msg.userId,
10871
10946
  ts: msg.timestamp.toISOString(),
10872
- thread_id: msg.threadId ?? "",
10947
+ thread_id: replyThreadId ?? "",
10873
10948
  ...(msg.adapterId ? { adapter_id: msg.adapterId } : {}),
10874
10949
  source: msg.source,
10875
10950
  ...extraMeta,
@@ -10906,7 +10981,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10906
10981
  }
10907
10982
  this.lastInboundUser.set(instanceName, msg.username);
10908
10983
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
10909
- this.trackInboundMsg(instanceName, msg);
10984
+ this.trackInboundMsg(instanceName, { ...msg, threadId: replyThreadId });
10910
10985
  void this.sendCancelButton(instanceName);
10911
10986
  }
10912
10987
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
@@ -14106,6 +14181,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14106
14181
  return false; // identical
14107
14182
  }
14108
14183
  // ── Health HTTP endpoint ─────────────────────────────────────────────
14184
+ /** Distinct rejected Host names already logged; bounded so a scanner cannot grow it. */
14185
+ rejectedHostsLogged = new Set();
14186
+ /**
14187
+ * Say once per name why a request was refused, so a reverse-proxy deployment
14188
+ * that stopped working after the Host check is diagnosable from fleet.log.
14189
+ * Only the parsed host name is logged, never the raw header.
14190
+ */
14191
+ noteRejectedHost(header) {
14192
+ const name = typeof header === "string" ? (hostnameOf(header) ?? "(malformed)") : "(missing)";
14193
+ if (this.rejectedHostsLogged.has(name) || this.rejectedHostsLogged.size >= 32)
14194
+ return;
14195
+ this.rejectedHostsLogged.add(name);
14196
+ this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
14197
+ }
14109
14198
  startHealthServer(port) {
14110
14199
  this.startedAt = Date.now();
14111
14200
  this.healthServerListening = false;
@@ -14121,6 +14210,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14121
14210
  // Authorization now depends on a cookie, so a shared cache (a tunnel, a
14122
14211
  // corporate proxy) must not serve one visitor's response to another.
14123
14212
  res.setHeader("Vary", "Cookie");
14213
+ applyWebSecurityHeaders(res);
14214
+ // Before any route, /health and /agent included: loopback binding does not
14215
+ // stop DNS rebinding, and the Host the browser sends is the one thing a
14216
+ // rebinding page cannot change.
14217
+ if (!isHostAllowed(req.headers.host, allowedHostNames(this.fleetConfig))) {
14218
+ this.noteRejectedHost(req.headers.host);
14219
+ res.writeHead(403);
14220
+ res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
14221
+ return;
14222
+ }
14124
14223
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
14125
14224
  // Browsers request this automatically and AgEnD does not ship an icon.
14126
14225
  // It is neither user data nor an API route, so do not turn the harmless