@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.
- package/README.md +8 -3
- package/README.zh-TW.md +11 -5
- package/dist/backend/codex.d.ts +13 -0
- package/dist/backend/codex.js +74 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.d.ts +0 -2
- package/dist/backend/kiro.js +8 -7
- package/dist/backend/kiro.js.map +1 -1
- package/dist/channel/adapters/telegram.js +4 -3
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-operation-response.js +5 -4
- package/dist/channel/mcp-operation-response.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -0
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/types.d.ts +7 -0
- package/dist/cli.js +12 -4
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +13 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +23 -0
- package/dist/daemon.js +63 -6
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.js +18 -23
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +34 -0
- package/dist/fleet-manager.js +121 -22
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.js +2 -5
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +9 -0
- package/dist/instance-lifecycle.js +37 -13
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +166 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +38 -0
- package/dist/login-controller.js +227 -6
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +7 -0
- package/dist/login-flows.js +1 -0
- package/dist/login-flows.js.map +1 -1
- package/dist/outbound-handlers.js +14 -16
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/routing-engine.d.ts +17 -2
- package/dist/routing-engine.js +48 -5
- package/dist/routing-engine.js.map +1 -1
- package/dist/setup-wizard.js +3 -1
- package/dist/setup-wizard.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +23 -5
- package/dist/tunnel/cloudflared.js +32 -13
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/manager.d.ts +6 -0
- package/dist/tunnel/manager.js +60 -22
- package/dist/tunnel/manager.js.map +1 -1
- package/dist/tunnel/public-fetch.d.ts +30 -0
- package/dist/tunnel/public-fetch.js +156 -0
- package/dist/tunnel/public-fetch.js.map +1 -0
- package/dist/tunnel/types.d.ts +8 -2
- package/dist/tunnel/types.js +8 -2
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/ui/view.html +14 -0
- package/dist/ui/web-terminal/terminal.html +1 -0
- package/dist/wake-coordinator.d.ts +0 -15
- package/dist/wake-coordinator.js +19 -3
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-host-guard.d.ts +47 -0
- package/dist/web-host-guard.js +75 -0
- package/dist/web-host-guard.js.map +1 -0
- package/dist/web-terminal-http.d.ts +34 -0
- package/dist/web-terminal-http.js +63 -0
- package/dist/web-terminal-http.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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 = `
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
5853
|
-
|
|
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
|
-
}, {
|
|
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:
|
|
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
|