@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.
- package/README.md +8 -3
- package/README.zh-TW.md +11 -5
- package/dist/backend/antigravity.js +2 -1
- package/dist/backend/antigravity.js.map +1 -1
- 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 +81 -5
- package/dist/backend/kiro.js +370 -36
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +10 -0
- package/dist/backend/types.js +13 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/telegram.js +4 -3
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/ipc-bridge.js +10 -3
- package/dist/channel/ipc-bridge.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.d.ts +1 -8
- package/dist/channel/mcp-server.js +10 -0
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/types.d.ts +7 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +93 -10
- 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 +68 -0
- package/dist/daemon.js +211 -27
- 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 +59 -1
- package/dist/fleet-manager.js +208 -35
- 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/general-knowledge/skills/fleet-config/SKILL.md +1 -1
- package/dist/instance-lifecycle.d.ts +25 -5
- package/dist/instance-lifecycle.js +68 -33
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +174 -4
- package/dist/locale.js.map +1 -1
- package/dist/logger.js +19 -16
- package/dist/logger.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/muse-usage-relay.js +3 -2
- package/dist/muse-usage-relay.js.map +1 -1
- package/dist/node-warnings.d.ts +1 -0
- package/dist/node-warnings.js +26 -0
- package/dist/node-warnings.js.map +1 -0
- package/dist/outbound-handlers.js +14 -16
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/pause-marker.js +3 -2
- package/dist/pause-marker.js.map +1 -1
- package/dist/private-dir.d.ts +54 -0
- package/dist/private-dir.js +140 -0
- package/dist/private-dir.js.map +1 -0
- 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/service-installer.d.ts +46 -0
- package/dist/service-installer.js +224 -4
- package/dist/service-installer.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/settings.html +2 -2
- 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/templates/systemd.service.ejs +17 -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";
|
|
@@ -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 = `
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
5853
|
-
|
|
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
|
-
/**
|
|
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
|
-
}, {
|
|
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
|
-
//
|
|
10515
|
-
this.
|
|
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:
|
|
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
|
|
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
|
|
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
|