@songsid/agend 2.1.9 → 2.1.10-beta.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 (101) hide show
  1. package/README.md +8 -3
  2. package/README.zh-TW.md +11 -5
  3. package/dist/backend/antigravity.js +2 -1
  4. package/dist/backend/antigravity.js.map +1 -1
  5. package/dist/backend/codex.d.ts +13 -0
  6. package/dist/backend/codex.js +74 -2
  7. package/dist/backend/codex.js.map +1 -1
  8. package/dist/backend/kiro.d.ts +81 -5
  9. package/dist/backend/kiro.js +370 -36
  10. package/dist/backend/kiro.js.map +1 -1
  11. package/dist/backend/types.d.ts +10 -0
  12. package/dist/backend/types.js +13 -0
  13. package/dist/backend/types.js.map +1 -1
  14. package/dist/channel/adapters/telegram.js +4 -3
  15. package/dist/channel/adapters/telegram.js.map +1 -1
  16. package/dist/channel/ipc-bridge.js +10 -3
  17. package/dist/channel/ipc-bridge.js.map +1 -1
  18. package/dist/channel/mcp-operation-response.js +5 -4
  19. package/dist/channel/mcp-operation-response.js.map +1 -1
  20. package/dist/channel/mcp-server.d.ts +1 -8
  21. package/dist/channel/mcp-server.js +10 -0
  22. package/dist/channel/mcp-server.js.map +1 -1
  23. package/dist/channel/types.d.ts +7 -0
  24. package/dist/cli.d.ts +1 -1
  25. package/dist/cli.js +93 -10
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config-validator.js +13 -0
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/config.js +26 -0
  30. package/dist/config.js.map +1 -1
  31. package/dist/daemon.d.ts +68 -0
  32. package/dist/daemon.js +211 -27
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/delivery-outbox.js +18 -23
  35. package/dist/delivery-outbox.js.map +1 -1
  36. package/dist/fleet-manager.d.ts +59 -1
  37. package/dist/fleet-manager.js +208 -35
  38. package/dist/fleet-manager.js.map +1 -1
  39. package/dist/forged-envelope-scan.js +2 -5
  40. package/dist/forged-envelope-scan.js.map +1 -1
  41. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  42. package/dist/instance-lifecycle.d.ts +25 -5
  43. package/dist/instance-lifecycle.js +68 -33
  44. package/dist/instance-lifecycle.js.map +1 -1
  45. package/dist/locale.js +174 -4
  46. package/dist/locale.js.map +1 -1
  47. package/dist/logger.js +19 -16
  48. package/dist/logger.js.map +1 -1
  49. package/dist/login-controller.d.ts +38 -0
  50. package/dist/login-controller.js +227 -6
  51. package/dist/login-controller.js.map +1 -1
  52. package/dist/login-flows.d.ts +7 -0
  53. package/dist/login-flows.js +1 -0
  54. package/dist/login-flows.js.map +1 -1
  55. package/dist/muse-usage-relay.js +3 -2
  56. package/dist/muse-usage-relay.js.map +1 -1
  57. package/dist/node-warnings.d.ts +1 -0
  58. package/dist/node-warnings.js +26 -0
  59. package/dist/node-warnings.js.map +1 -0
  60. package/dist/outbound-handlers.js +14 -16
  61. package/dist/outbound-handlers.js.map +1 -1
  62. package/dist/pause-marker.js +3 -2
  63. package/dist/pause-marker.js.map +1 -1
  64. package/dist/private-dir.d.ts +54 -0
  65. package/dist/private-dir.js +140 -0
  66. package/dist/private-dir.js.map +1 -0
  67. package/dist/routing-engine.d.ts +17 -2
  68. package/dist/routing-engine.js +48 -5
  69. package/dist/routing-engine.js.map +1 -1
  70. package/dist/service-installer.d.ts +46 -0
  71. package/dist/service-installer.js +224 -4
  72. package/dist/service-installer.js.map +1 -1
  73. package/dist/setup-wizard.js +3 -1
  74. package/dist/setup-wizard.js.map +1 -1
  75. package/dist/tunnel/cloudflared.d.ts +23 -5
  76. package/dist/tunnel/cloudflared.js +32 -13
  77. package/dist/tunnel/cloudflared.js.map +1 -1
  78. package/dist/tunnel/manager.d.ts +6 -0
  79. package/dist/tunnel/manager.js +60 -22
  80. package/dist/tunnel/manager.js.map +1 -1
  81. package/dist/tunnel/public-fetch.d.ts +30 -0
  82. package/dist/tunnel/public-fetch.js +156 -0
  83. package/dist/tunnel/public-fetch.js.map +1 -0
  84. package/dist/tunnel/types.d.ts +8 -2
  85. package/dist/tunnel/types.js +8 -2
  86. package/dist/tunnel/types.js.map +1 -1
  87. package/dist/types.d.ts +24 -0
  88. package/dist/ui/settings.html +2 -2
  89. package/dist/ui/view.html +14 -0
  90. package/dist/ui/web-terminal/terminal.html +1 -0
  91. package/dist/wake-coordinator.d.ts +0 -15
  92. package/dist/wake-coordinator.js +19 -3
  93. package/dist/wake-coordinator.js.map +1 -1
  94. package/dist/web-host-guard.d.ts +47 -0
  95. package/dist/web-host-guard.js +75 -0
  96. package/dist/web-host-guard.js.map +1 -0
  97. package/dist/web-terminal-http.d.ts +34 -0
  98. package/dist/web-terminal-http.js +63 -0
  99. package/dist/web-terminal-http.js.map +1 -1
  100. package/package.json +1 -1
  101. package/templates/systemd.service.ejs +17 -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";
@@ -29,7 +29,7 @@ import { IpcClient } from "./channel/ipc-bridge.js";
29
29
  import { createAdapter } from "./channel/factory.js";
30
30
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
31
31
  import { createBackend } from "./backend/factory.js";
32
- import { isModelCompatible, SYSINFO_BACKEND_IDS } from "./backend/types.js";
32
+ import { isModelCompatible, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
33
33
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
34
34
  import { processAttachments } from "./channel/attachment-handler.js";
35
35
  import { routeToolCall } from "./channel/tool-router.js";
@@ -56,6 +56,7 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
56
56
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
57
57
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
58
58
  import { LoginSession } from "./login-manager.js";
59
+ import { tightenInstanceDirs } from "./private-dir.js";
59
60
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
60
61
  import { runBeforeDeadline } from "./deadline.js";
61
62
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -77,6 +78,7 @@ import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
77
78
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
78
79
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
79
80
  import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
81
+ import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
80
82
  import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
81
83
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
82
84
  import { SecretStore } from "./secret-store.js";
@@ -362,6 +364,8 @@ export class FleetManager {
362
364
  "provider-unavailable",
363
365
  ]);
364
366
  logger = createLogger("info");
367
+ /** Report each bundled skill collision once per fleet, across instance starts. */
368
+ skippedBundledSkills = new Set();
365
369
  topicCommands;
366
370
  // sessionName → instanceName mapping for external sessions
367
371
  sessionRegistry = new Map();
@@ -430,6 +434,8 @@ export class FleetManager {
430
434
  stopsInFlight = new Map();
431
435
  /** Aggregation window for the "N instances failed to start" notice. */
432
436
  startupRetryNotices = new Map();
437
+ /** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
438
+ unsupportedCliNotices = new Map();
433
439
  /** Backoff between automatic startup retries; the last step repeats while the backend is down. */
434
440
  static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
435
441
  /** Hard cap on automatic startup retries (3 backoff steps + up to 3 more during a backend outage). */
@@ -593,7 +599,7 @@ export class FleetManager {
593
599
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
594
600
  const row = outbox.get(event.deliveryId);
595
601
  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}.`;
602
+ const notice = t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
597
603
  this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state }, notice);
598
604
  this.notifyFleetError(notice);
599
605
  }
@@ -614,7 +620,7 @@ export class FleetManager {
614
620
  this.scheduleDeliveryOutboxPump();
615
621
  this.wakeCoordinator?.kick();
616
622
  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.`);
623
+ this.notifyFleetError(t("delivery.expired", event.count, event.uncertain ?? 0));
618
624
  });
619
625
  this.scheduleDeliveryOutboxPump();
620
626
  }
@@ -926,7 +932,7 @@ export class FleetManager {
926
932
  if ((current?.state === "delivering" || current?.state === "submission_started")
927
933
  && current.targetDaemonBootId === claimed.targetDaemonBootId
928
934
  && 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.`);
935
+ this.notifyFleetError(t("delivery.lane_waiting", claimed.targetInstance, Math.round(DURABLE_DELIVERY_LANE_ALERT_MS / 60_000), deliveryId));
930
936
  }
931
937
  }, DURABLE_DELIVERY_LANE_ALERT_MS);
932
938
  alertTimer.unref?.();
@@ -1052,6 +1058,7 @@ export class FleetManager {
1052
1058
  // around to notice. Never throws: a lease that cannot be resolved blocks
1053
1059
  // the next tunnel and says so, it does not block the fleet.
1054
1060
  this.announceToolPermissionsChange();
1061
+ this.tightenInstanceDirectories();
1055
1062
  void reapStaleTunnel(this.dataDir)
1056
1063
  .then(outcome => {
1057
1064
  if (outcome.kind === "manual")
@@ -1276,6 +1283,29 @@ export class FleetManager {
1276
1283
  getInstanceDir(name) {
1277
1284
  return join(this.dataDir, "instances", name);
1278
1285
  }
1286
+ /**
1287
+ * One-time repair of what earlier versions left behind: instance directories (agent.token and the IPC
1288
+ * socket live in them) created with the process umask — typically 0775, group-writable and traversable
1289
+ * by everyone. They are made 0700 here, once, and said so once; new ones are born 0700 (ensureInstanceDir).
1290
+ * Idempotent, so a restart that finds nothing to do logs nothing. Never throws.
1291
+ */
1292
+ tightenInstanceDirectories() {
1293
+ try {
1294
+ const report = tightenInstanceDirs(this.dataDir);
1295
+ if (report.tightened.length > 0) {
1296
+ this.logger.info({
1297
+ count: report.tightened.length,
1298
+ modes: [...new Set(report.tightened.map(t => t.from))],
1299
+ }, "Instance directories were open to other users; set to 0700 (they hold agent.token and the IPC socket)");
1300
+ }
1301
+ if (report.skipped.length > 0) {
1302
+ this.logger.warn({ dirs: report.skipped.map(s => `${s.dir} (${s.why})`) }, "Could not tighten these instance directories — fix their owner/permissions by hand (chmod 700)");
1303
+ }
1304
+ }
1305
+ catch (err) {
1306
+ this.logger.warn({ err }, "Instance directory permission check failed");
1307
+ }
1308
+ }
1279
1309
  /** AgEnD package version (for the Settings "current version" / What's New). */
1280
1310
  get currentVersion() {
1281
1311
  try {
@@ -2431,6 +2461,8 @@ export class FleetManager {
2431
2461
  // outcome. Retrying would start an instance the operator just stopped.
2432
2462
  if (err instanceof SupersededStartError)
2433
2463
  return false;
2464
+ if (this.stopOnUnsupportedCli(name, err, what))
2465
+ return false;
2434
2466
  this.logger.error({ err, name }, `Failed to start ${what}`);
2435
2467
  this.scheduleStartupRetry(name, 0);
2436
2468
  return false;
@@ -2555,6 +2587,9 @@ export class FleetManager {
2555
2587
  // Superseded by a stop/restart: that transition owns the outcome.
2556
2588
  if (err instanceof SupersededStartError)
2557
2589
  return;
2590
+ // An earlier attempt could not tell (unknown probe); this one could.
2591
+ if (this.stopOnUnsupportedCli(name, err, "instance"))
2592
+ return;
2558
2593
  this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
2559
2594
  this.scheduleStartupRetry(name, attempt + 1);
2560
2595
  }
@@ -2650,6 +2685,38 @@ export class FleetManager {
2650
2685
  * fails many instances within the same second. Two notices per incident at
2651
2686
  * most — "N failed, retrying in X" and, if it comes to that, "gave up on N".
2652
2687
  */
2688
+ /**
2689
+ * The CLI refuses this instance as configured (#1109): the same binary would
2690
+ * refuse every retry, so say why once and leave it stopped. Shared by the
2691
+ * first unattended start and every scheduled retry.
2692
+ */
2693
+ stopOnUnsupportedCli(name, err, what) {
2694
+ if (!(err instanceof UnsupportedCliError))
2695
+ return false;
2696
+ this.logger.error({ name, reason: err.message }, `Not starting ${what}: the installed CLI cannot run it as configured`);
2697
+ this.queueUnsupportedCliNotice(name, err.message);
2698
+ return true;
2699
+ }
2700
+ /**
2701
+ * One notice per refusal reason: a kiro-cli that dropped the legacy UI
2702
+ * refuses every kiro instance at once, and that is one message, not ten.
2703
+ */
2704
+ queueUnsupportedCliNotice(name, reason) {
2705
+ const pending = this.unsupportedCliNotices.get(reason);
2706
+ if (pending) {
2707
+ if (!pending.names.includes(name))
2708
+ pending.names.push(name);
2709
+ return;
2710
+ }
2711
+ const timer = setTimeout(() => {
2712
+ const entry = this.unsupportedCliNotices.get(reason);
2713
+ this.unsupportedCliNotices.delete(reason);
2714
+ if (entry)
2715
+ this.notifyFleetError(t("fleet.cli_unsupported", entry.names.join(", "), reason));
2716
+ }, FleetManager.STARTUP_RETRY_NOTICE_AGGREGATE_MS);
2717
+ timer.unref?.();
2718
+ this.unsupportedCliNotices.set(reason, { names: [name], timer });
2719
+ }
2653
2720
  queueStartupRetryNotice(kind, name, delayMs) {
2654
2721
  const pending = this.startupRetryNotices.get(kind);
2655
2722
  if (pending) {
@@ -3161,8 +3228,7 @@ export class FleetManager {
3161
3228
  this.configPath = configPath;
3162
3229
  this.loadEnvFile();
3163
3230
  this.ensureDeliveryOutbox();
3164
- // Rotate fleet.log if oversized (before any logging)
3165
- rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
3231
+ this.rotateFleetLogs();
3166
3232
  const fleet = this.loadConfig(configPath);
3167
3233
  this.slimFleetConfigAtStartup();
3168
3234
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
@@ -3276,9 +3342,7 @@ export class FleetManager {
3276
3342
  // Rotate classic channel chat logs daily
3277
3343
  this.classicChannels?.rotateLogs();
3278
3344
  this.rotateInboxes();
3279
- // Rotate fleet.log daily too (besides the startup size check above), so a
3280
- // long-running fleet doesn't accumulate an unbounded log.
3281
- rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
3345
+ this.rotateFleetLogs();
3282
3346
  // Instance output.log is pipe-pane (TUI ANSI). Daemon health ticks rotate a
3283
3347
  // running instance's own log; this sweep is the safety net for every other
3284
3348
  // kind. One implementation, so the two cannot cover different sets.
@@ -3494,11 +3558,14 @@ export class FleetManager {
3494
3558
  this.pruneEventLog();
3495
3559
  this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
3496
3560
  this.eventLogPruneTimer.unref?.();
3497
- // Same shape for pipe-pane logs, and for the same reason: the only sweep that
3561
+ // Same shape for logs, and for the same reason: the only sweep that
3498
3562
  // covered them lived inside the daily-summary callback, so it did not run at
3499
3563
  // all when summaries were off.
3500
3564
  this.rotateAllInstanceLogs();
3501
- this.logRotateTimer = setInterval(() => this.rotateAllInstanceLogs(), 24 * 60 * 60_000);
3565
+ this.logRotateTimer = setInterval(() => {
3566
+ this.rotateFleetLogs();
3567
+ this.rotateAllInstanceLogs();
3568
+ }, 24 * 60 * 60_000);
3502
3569
  this.logRotateTimer.unref?.();
3503
3570
  // Phase 2: Start remaining instances with staggered concurrency
3504
3571
  if (others.length > 0) {
@@ -4773,6 +4840,7 @@ export class FleetManager {
4773
4840
  const targetInstance = String(msg.targetInstance ?? name);
4774
4841
  const correlationId = String(msg.correlationId ?? "unknown");
4775
4842
  const error = String(msg.error ?? "unknown delivery failure");
4843
+ const displayError = error.replace(/^delivery failed: phase=(.*); proof=(.*)$/, (_match, phase, proof) => t("delivery.pane_failure", phase, proof));
4776
4844
  const senderInstance = this.instanceIpcClients.has(senderSession)
4777
4845
  ? senderSession
4778
4846
  : this.sessionRegistry.get(senderSession);
@@ -4781,11 +4849,11 @@ export class FleetManager {
4781
4849
  from: senderSession, correlation_id: correlationId, error,
4782
4850
  });
4783
4851
  if (senderInstance) {
4784
- this.notifyInstanceTopic(senderInstance, t("cross_instance.delivery_failed_notice", targetInstance, error, correlationId));
4852
+ this.notifyInstanceTopic(senderInstance, t("cross_instance.delivery_failed_notice", targetInstance, displayError, correlationId));
4785
4853
  void this.deliverToInstance(senderInstance, {
4786
4854
  type: "fleet_inbound",
4787
4855
  targetSession: senderSession,
4788
- content: t("cross_instance.delivery_failed_agent", targetInstance, error, correlationId),
4856
+ content: t("cross_instance.delivery_failed_agent", targetInstance, displayError, correlationId),
4789
4857
  meta: {
4790
4858
  chat_id: "",
4791
4859
  message_id: `delivery-failed-${Date.now()}`,
@@ -5061,7 +5129,7 @@ export class FleetManager {
5061
5129
  */
5062
5130
  governingAccess(msg, threadId) {
5063
5131
  if (threadId && !this.classicChannels?.hasChannel(threadId)) {
5064
- const target = this.routing.resolve(threadId);
5132
+ const target = this.resolveInboundTarget(msg, threadId);
5065
5133
  if (target) {
5066
5134
  const owner = this.getInstanceAdapterId(target.name);
5067
5135
  if (owner)
@@ -5097,7 +5165,7 @@ export class FleetManager {
5097
5165
  return null;
5098
5166
  if (this.classicChannels?.hasChannel(threadId))
5099
5167
  return null;
5100
- const target = this.routing.resolve(threadId);
5168
+ const target = this.resolveInboundTarget(msg, threadId);
5101
5169
  if (!target)
5102
5170
  return null;
5103
5171
  const ownerAdapterId = this.getInstanceAdapterId(target.name);
@@ -5129,7 +5197,7 @@ export class FleetManager {
5129
5197
  return "classic: collab off";
5130
5198
  return null;
5131
5199
  }
5132
- const target = this.routing.resolve(threadId);
5200
+ const target = this.resolveInboundTarget(msg, threadId);
5133
5201
  if (!target)
5134
5202
  return "fleet topic: no instance routed for this thread";
5135
5203
  // Fleet topic: this gate lets the copy through when the adapter is open OR
@@ -5162,8 +5230,42 @@ export class FleetManager {
5162
5230
  return;
5163
5231
  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
5232
  }
5233
+ /**
5234
+ * #1085: the thread id inbound routing may use. Telegram topic ids are small
5235
+ * integers numbered per group, but the routing table is keyed by topic id
5236
+ * alone — so a message in topic 30 of ANY group the bot is in resolved to
5237
+ * the fleet instance that owns topic 30 of the fleet's own group (and a group
5238
+ * that never ran /start was bound by coincidence). A Telegram topic counts
5239
+ * as a fleet topic only in the fleet's group: `chat_id == group_id` of the
5240
+ * receiving bot's channel. From any other group the message is a ClassicBot
5241
+ * candidate keyed by its chat id (served only if /start registered it,
5242
+ * ignored otherwise). Discord channel ids are global, so they are unaffected.
5243
+ */
5244
+ inboundRouteThreadId(msg) {
5245
+ const raw = msg.threadId || undefined;
5246
+ if (!raw || msg.source !== "telegram")
5247
+ return raw;
5248
+ const fleetGroupId = this.getChannelConfig(msg.adapterId)?.group_id;
5249
+ if (fleetGroupId != null && String(fleetGroupId) === msg.chatId)
5250
+ return raw;
5251
+ this.logger.debug({ adapterId: msg.adapterId, chatId: msg.chatId, threadId: raw }, "Telegram topic outside the fleet group — not a fleet topic (#1085)");
5252
+ return undefined;
5253
+ }
5254
+ /**
5255
+ * #1085, defense in depth: a routed Telegram target must belong to the group
5256
+ * the message came from — its own world's group_id. Two Telegram fleet
5257
+ * worlds can number topics alike; a coincidence is dropped, never delivered.
5258
+ */
5259
+ resolveInboundTarget(msg, threadId) {
5260
+ if (msg.source !== "telegram")
5261
+ return this.routing.resolve(threadId);
5262
+ return this.routing.resolveAll(threadId).find(target => {
5263
+ const ownerGroupId = this.getChannelConfig(this.getInstanceAdapterId(target.name))?.group_id;
5264
+ return ownerGroupId != null && String(ownerGroupId) === msg.chatId;
5265
+ });
5266
+ }
5165
5267
  async handleInboundMessage(msg) {
5166
- const threadId = msg.threadId || undefined;
5268
+ const threadId = this.inboundRouteThreadId(msg);
5167
5269
  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
5270
  // Ownership and per-adapter filtering both run before the dedup claim below.
5169
5271
  // When several bots share a guild they all receive the same message; a copy
@@ -5219,7 +5321,7 @@ export class FleetManager {
5219
5321
  // the message (the @mention filter downstream decides who actually forwards).
5220
5322
  // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
5221
5323
  if (msg.messageId) {
5222
- const classicCid = msg.threadId || msg.chatId;
5324
+ const classicCid = threadId || msg.chatId;
5223
5325
  const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
5224
5326
  const dedupKey = isClassicMsg
5225
5327
  ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
@@ -5508,7 +5610,7 @@ export class FleetManager {
5508
5610
  // TG ClassicBot: group requires @mention, private chat forwards directly.
5509
5611
  if (!isPrivateChat && !isBotMentioned) {
5510
5612
  // No trigger: save attachments + react, log, but don't forward to agent
5511
- const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
5613
+ const syntheticMsg = { ...msg, threadId: chatId, transportThreadId: msg.threadId || undefined, text: rawText.startsWith("/") ? "" : rawText };
5512
5614
  await this.handleClassicChannelMessage(classicName, syntheticMsg);
5513
5615
  return;
5514
5616
  }
@@ -5522,7 +5624,7 @@ export class FleetManager {
5522
5624
  await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
5523
5625
  return;
5524
5626
  }
5525
- const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
5627
+ const syntheticMsg = { ...msg, threadId: chatId, transportThreadId: msg.threadId || undefined, text: `/chat ${cleanText}` };
5526
5628
  await this.handleClassicChannelMessage(classicName, syntheticMsg);
5527
5629
  return;
5528
5630
  }
@@ -5609,7 +5711,7 @@ export class FleetManager {
5609
5711
  await this.handleClassicChannelMessage(classicName, msg);
5610
5712
  return;
5611
5713
  }
5612
- const target = this.routing.resolve(threadId);
5714
+ const target = this.resolveInboundTarget(msg, threadId);
5613
5715
  if (!target) {
5614
5716
  // Only show unbound message for actual forum topics (same group, has threadId)
5615
5717
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
@@ -5849,8 +5951,18 @@ export class FleetManager {
5849
5951
  const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
5850
5952
  if (classicChannelId) {
5851
5953
  args.chat_id = classicChannelId;
5852
- delete args.thread_id;
5853
- threadId = undefined;
5954
+ // #1085: a Telegram forum ClassicBot replies in the topic it was asked
5955
+ // in. Any other thread_id (threadless Telegram, where it echoes the chat
5956
+ // id, or a Discord channel) is still cleared, as before.
5957
+ const topic = typeof args.thread_id === "string" ? args.thread_id : "";
5958
+ const telegram = (contextWorld?.adapter ?? outAdapter)?.type === "telegram";
5959
+ if (telegram && topic && topic !== classicChannelId) {
5960
+ threadId = topic;
5961
+ }
5962
+ else {
5963
+ delete args.thread_id;
5964
+ threadId = undefined;
5965
+ }
5854
5966
  }
5855
5967
  // Reply dedup: retries land here when the agent was told a send failed
5856
5968
  // (daemon budget elapsed, shell tool killed) while the adapter send was
@@ -6922,7 +7034,7 @@ export class FleetManager {
6922
7034
  quarantineMissingTopic(threadId, target, evidence) {
6923
7035
  if (!isProbeableRouteTarget(target) || this.routing.resolve(threadId) !== target)
6924
7036
  return;
6925
- this.routing.unregister(threadId);
7037
+ this.routing.unregister(threadId, target.name);
6926
7038
  this.logger.error({ instanceName: target.name, threadId, ...evidence }, "Topic is confirmed missing — route quarantined; instance configuration and user data were retained");
6927
7039
  this.notifyFleetError(t("fleet.topic_quarantined", target.name, threadId));
6928
7040
  }
@@ -7723,7 +7835,11 @@ export class FleetManager {
7723
7835
  return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
7724
7836
  }
7725
7837
  }
7726
- /** Drop event/activity rows older than the retention window. Best-effort. */
7838
+ /** Cap both structured runtime logs and service bootstrap diagnostics. */
7839
+ rotateFleetLogs() {
7840
+ rotateLogIfNeeded(join(this.dataDir, "daemon.log"));
7841
+ rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
7842
+ }
7727
7843
  /**
7728
7844
  * Cap every instance's pipe-pane log, walking the instances **directory** rather
7729
7845
  * than the config.
@@ -7759,6 +7875,7 @@ export class FleetManager {
7759
7875
  rotateLogIfNeeded(join(root, entry.name, "output.log"));
7760
7876
  }
7761
7877
  }
7878
+ /** Drop event/activity rows older than the retention window. Best-effort. */
7762
7879
  pruneEventLog() {
7763
7880
  try {
7764
7881
  this.eventLog?.prune(FleetManager.EVENT_LOG_RETENTION_DAYS);
@@ -9375,6 +9492,7 @@ export class FleetManager {
9375
9492
  releaseWindow: claim => { this.loginWindow.release(claim); },
9376
9493
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
9377
9494
  windowBusyMessage: () => this.loginWindow.busyMessage(),
9495
+ tunnelDataDir: () => this.dataDir,
9378
9496
  postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
9379
9497
  await this.postNonceButtonPrompt({
9380
9498
  prefix, alertType: "login", instanceName,
@@ -9892,7 +10010,7 @@ export class FleetManager {
9892
10010
  }
9893
10011
  /** Re-login confirmation (auth pre-check said credentials still work). */
9894
10012
  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);
10013
+ 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
10014
  if (claimed === null)
9897
10015
  return false;
9898
10016
  if (claimed === "consumed")
@@ -9910,7 +10028,12 @@ export class FleetManager {
9910
10028
  chatId: entry.chatId,
9911
10029
  threadId: entry.threadId,
9912
10030
  userId: data.userId,
9913
- }, { skipAuthCheck: true, tokenPresent: action === "go-relogin" });
10031
+ }, {
10032
+ skipAuthCheck: true,
10033
+ tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
10034
+ // The consent is the button itself: only the explicit "Open public link" actions carry it.
10035
+ tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
10036
+ });
9914
10037
  if (text)
9915
10038
  await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
9916
10039
  return true;
@@ -10005,7 +10128,7 @@ export class FleetManager {
10005
10128
  }
10006
10129
  // The installer may only have added the binary to a profile PATH; a
10007
10130
  // fresh login shell sees that, the fleet process's PATH may not.
10008
- const installedAt = this.locateBinaryOnLoginShell(info.binary);
10131
+ const installedAt = this.locateBinaryOnLoginShell(info.binary) ?? this.locateInInstallerBinDirs(info);
10009
10132
  if (!installedAt) {
10010
10133
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
10011
10134
  return;
@@ -10094,6 +10217,26 @@ export class FleetManager {
10094
10217
  return null;
10095
10218
  }
10096
10219
  }
10220
+ /**
10221
+ * #1092: the binary in one of the directories its own installer installs to,
10222
+ * when no login shell can see it (the installer recorded its PATH in an
10223
+ * interactive-only rc file). The same executable-file check as the login
10224
+ * shell lookup; a symlink to an executable counts, as codex's launcher is one.
10225
+ */
10226
+ locateInInstallerBinDirs(info) {
10227
+ for (const dir of info.binDirs?.(process.env, homedir()) ?? []) {
10228
+ if (!isAbsolute(dir))
10229
+ continue;
10230
+ const path = join(dir, info.binary);
10231
+ try {
10232
+ accessSync(path, fsConstants.X_OK);
10233
+ if (statSync(path).isFile())
10234
+ return path;
10235
+ }
10236
+ catch { /* not there */ }
10237
+ }
10238
+ return null;
10239
+ }
10097
10240
  /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
10098
10241
  adoptBinaryDirectory(binaryPath, backend) {
10099
10242
  const dir = dirname(binaryPath);
@@ -10511,8 +10654,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10511
10654
  const dest = join(destDir, "SKILL.md");
10512
10655
  const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
10513
10656
  if (!isOurs) {
10514
- // Name collision with a user-authored skill: theirs wins, loudly.
10515
- this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
10657
+ // The user's skill still wins on every sync; only the warning is deduped.
10658
+ if (!this.skippedBundledSkills.has(name)) {
10659
+ this.skippedBundledSkills.add(name);
10660
+ this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
10661
+ }
10516
10662
  continue;
10517
10663
  }
10518
10664
  managed.push(name);
@@ -10854,6 +11000,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10854
11000
  }
10855
11001
  /** Forward a message to a classic channel instance with chat log context */
10856
11002
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
11003
+ // #1085: a Telegram forum ClassicBot is keyed by chat id (threadId), but the
11004
+ // reply context must carry the topic the message was really asked in.
11005
+ const replyThreadId = msg.transportThreadId ?? msg.threadId;
10857
11006
  // Resolve the channel/adapter from the instance itself so per-channel context
10858
11007
  // config is correct even for a same-channel second bot.
10859
11008
  const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
@@ -10869,7 +11018,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10869
11018
  user: msg.username,
10870
11019
  user_id: msg.userId,
10871
11020
  ts: msg.timestamp.toISOString(),
10872
- thread_id: msg.threadId ?? "",
11021
+ thread_id: replyThreadId ?? "",
10873
11022
  ...(msg.adapterId ? { adapter_id: msg.adapterId } : {}),
10874
11023
  source: msg.source,
10875
11024
  ...extraMeta,
@@ -10906,7 +11055,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10906
11055
  }
10907
11056
  this.lastInboundUser.set(instanceName, msg.username);
10908
11057
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
10909
- this.trackInboundMsg(instanceName, msg);
11058
+ this.trackInboundMsg(instanceName, { ...msg, threadId: replyThreadId });
10910
11059
  void this.sendCancelButton(instanceName);
10911
11060
  }
10912
11061
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
@@ -12823,7 +12972,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12823
12972
  return {
12824
12973
  ok: false,
12825
12974
  status: 409,
12826
- error: `fleet.yaml and the running configuration disagree on ${this.fleetSignatureMismatch.join(", ")} — check fleet.log before restarting`,
12975
+ error: `fleet.yaml and the running configuration disagree on ${this.fleetSignatureMismatch.join(", ")} — check daemon.log before restarting`,
12827
12976
  };
12828
12977
  }
12829
12978
  const job = this.applyJobs.get(jobId);
@@ -12898,7 +13047,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12898
13047
  this.applyJobs.setTargetStatus(jobId, APPLY_FLEET_TARGET, "failed", "restart could not be launched");
12899
13048
  this.applyJobs.finish(jobId, "restart could not be launched");
12900
13049
  this.emitSseEvent("apply_progress", viewOf(this.applyJobs.get(jobId)));
12901
- return { ok: false, status: 409, error: "the restart could not be launched — see fleet.log" };
13050
+ return { ok: false, status: 409, error: "the restart could not be launched — see daemon.log" };
12902
13051
  }
12903
13052
  return { ok: true, jobId };
12904
13053
  }
@@ -14106,6 +14255,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14106
14255
  return false; // identical
14107
14256
  }
14108
14257
  // ── Health HTTP endpoint ─────────────────────────────────────────────
14258
+ /** Distinct rejected Host names already logged; bounded so a scanner cannot grow it. */
14259
+ rejectedHostsLogged = new Set();
14260
+ /**
14261
+ * Say once per name why a request was refused, so a reverse-proxy deployment
14262
+ * that stopped working after the Host check is diagnosable from fleet.log.
14263
+ * Only the parsed host name is logged, never the raw header.
14264
+ */
14265
+ noteRejectedHost(header) {
14266
+ const name = typeof header === "string" ? (hostnameOf(header) ?? "(malformed)") : "(missing)";
14267
+ if (this.rejectedHostsLogged.has(name) || this.rejectedHostsLogged.size >= 32)
14268
+ return;
14269
+ this.rejectedHostsLogged.add(name);
14270
+ 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)");
14271
+ }
14109
14272
  startHealthServer(port) {
14110
14273
  this.startedAt = Date.now();
14111
14274
  this.healthServerListening = false;
@@ -14121,6 +14284,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14121
14284
  // Authorization now depends on a cookie, so a shared cache (a tunnel, a
14122
14285
  // corporate proxy) must not serve one visitor's response to another.
14123
14286
  res.setHeader("Vary", "Cookie");
14287
+ applyWebSecurityHeaders(res);
14288
+ // Before any route, /health and /agent included: loopback binding does not
14289
+ // stop DNS rebinding, and the Host the browser sends is the one thing a
14290
+ // rebinding page cannot change.
14291
+ if (!isHostAllowed(req.headers.host, allowedHostNames(this.fleetConfig))) {
14292
+ this.noteRejectedHost(req.headers.host);
14293
+ res.writeHead(403);
14294
+ res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
14295
+ return;
14296
+ }
14124
14297
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
14125
14298
  // Browsers request this automatically and AgEnD does not ship an icon.
14126
14299
  // It is neither user data nor an API route, so do not turn the harmless