@songsid/agend 2.1.6-beta.5 → 2.1.6-beta.7
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/dist/backend/muse.js +27 -0
- package/dist/backend/muse.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +14 -0
- package/dist/channel/adapters/discord.js +59 -1
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +29 -0
- package/dist/daemon.js +145 -77
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +180 -0
- package/dist/fleet-manager.js +1008 -9
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +14 -0
- package/dist/locale.js.map +1 -1
- package/dist/provider-probe.d.ts +3 -0
- package/dist/provider-probe.js +12 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +14 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/settings-api.d.ts +101 -0
- package/dist/settings-api.js +404 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/settings.html +171 -6
- package/dist/usage/usage-api.d.ts +12 -0
- package/dist/usage/usage-api.js +39 -0
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -44,7 +44,7 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
|
44
44
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
45
45
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
46
46
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
47
|
-
import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
47
|
+
import { formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
48
48
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
|
|
49
49
|
import { LoginSession } from "./login-manager.js";
|
|
50
50
|
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
|
|
@@ -67,6 +67,10 @@ import { mayUseTool, resolveToolSet, toolForIpcType, toolRefusedMessage, } from
|
|
|
67
67
|
import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
68
68
|
import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
|
|
69
69
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
70
|
+
import { SecretStore } from "./secret-store.js";
|
|
71
|
+
import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
|
|
72
|
+
import { verifyDiscordToken, verifyTelegramToken } from "./provider-probe.js";
|
|
73
|
+
import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
|
|
70
74
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
71
75
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
72
76
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
@@ -436,6 +440,40 @@ export class FleetManager {
|
|
|
436
440
|
adapterRestarting = new Set();
|
|
437
441
|
// Adapter isolation: track state per adapter for retry + visibility
|
|
438
442
|
adapterState = new Map();
|
|
443
|
+
/** Web Settings secret rotation is deliberately separate from reconnect
|
|
444
|
+
* recovery: a rotation must build a fresh provider client with the new token,
|
|
445
|
+
* and stale callbacks from the old client must not win. */
|
|
446
|
+
connectionSecretChallenges = new Map();
|
|
447
|
+
connectionSecretChallengesByKey = new Map();
|
|
448
|
+
connectionSecretJobs = new Map();
|
|
449
|
+
connectionSecretJobSession = new Map();
|
|
450
|
+
connectionSecretInFlight = new Map();
|
|
451
|
+
connectionSecretGenerations = new Map();
|
|
452
|
+
/** Local epoch that fences a challenge across adapter replacement and
|
|
453
|
+
* provider reconnect generations. The adapter's own generation can reset
|
|
454
|
+
* when a new adapter object is constructed, so keep an independent epoch. */
|
|
455
|
+
connectionSecretAdapterRefs = new Map();
|
|
456
|
+
connectionSecretHealthGenerations = new Map();
|
|
457
|
+
/** Generic API-key verifier/apply state. The challenge scope contains the
|
|
458
|
+
* resolved spec/env key, so a request can never retarget another provider. */
|
|
459
|
+
providerSecretChallenges = new Map();
|
|
460
|
+
providerSecretChallengesByKey = new Map();
|
|
461
|
+
providerSecretJobs = new Map();
|
|
462
|
+
providerSecretJobSession = new Map();
|
|
463
|
+
providerSecretInFlight = new Map();
|
|
464
|
+
providerSecretGenerations = new Map();
|
|
465
|
+
/** Test seam only; production always uses the fixed HTTPS client. */
|
|
466
|
+
providerSecretHttpClient;
|
|
467
|
+
/** Code-owned activation hooks; never populated from a request. */
|
|
468
|
+
providerSecretReloadHooks = new Map();
|
|
469
|
+
/** In-memory snapshots for narrow hot consumers (currently Groq voice). */
|
|
470
|
+
providerSecretHotSnapshots = new Map();
|
|
471
|
+
/** Web Settings connection-binding step-up challenges and apply jobs. */
|
|
472
|
+
connectionBindingChallenges = new Map();
|
|
473
|
+
connectionBindingChallengesByKey = new Map();
|
|
474
|
+
connectionBindingJobs = new Map();
|
|
475
|
+
connectionBindingJobSession = new Map();
|
|
476
|
+
connectionBindingInFlight = new Map();
|
|
439
477
|
collabInstances = new Set();
|
|
440
478
|
// Health endpoint
|
|
441
479
|
healthServer = null;
|
|
@@ -449,6 +487,9 @@ export class FleetManager {
|
|
|
449
487
|
fullRestartLauncher = launchFullRestartHelper;
|
|
450
488
|
eventLogPruneTimer = null;
|
|
451
489
|
logRotateTimer = null;
|
|
490
|
+
discordPresenceTimer = null;
|
|
491
|
+
discordPresenceInFlight = null;
|
|
492
|
+
static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
|
|
452
493
|
/** Days of event/activity history to keep. */
|
|
453
494
|
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
454
495
|
watchdogTimer = null;
|
|
@@ -772,11 +813,30 @@ export class FleetManager {
|
|
|
772
813
|
if (this.rawFleetDocument.errors.length > 0) {
|
|
773
814
|
throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
774
815
|
}
|
|
775
|
-
|
|
776
|
-
|
|
816
|
+
const raw = loadRawFleetConfig(configPath);
|
|
817
|
+
const loaded = loadFleetConfig(configPath);
|
|
818
|
+
this.assertProviderSecretEnvKeys(loaded);
|
|
819
|
+
this.rawFleetConfig = raw;
|
|
820
|
+
this.fleetConfig = loaded;
|
|
777
821
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
778
822
|
return this.fleetConfig;
|
|
779
823
|
}
|
|
824
|
+
/**
|
|
825
|
+
* A channel's configurable bot_token_env is an env-key writer too. Refuse
|
|
826
|
+
* an overlap with a registry API key (or a process-reserved key) before the
|
|
827
|
+
* config becomes live; otherwise a Discord token could be written into
|
|
828
|
+
* GROQ_API_KEY by a perfectly valid-looking rotation request.
|
|
829
|
+
*/
|
|
830
|
+
assertProviderSecretEnvKeys(config) {
|
|
831
|
+
const registryKeys = providerRegistryEnvKeys();
|
|
832
|
+
const channels = config.channels ?? (config.channel ? [config.channel] : []);
|
|
833
|
+
for (const channel of channels) {
|
|
834
|
+
const key = channel.bot_token_env;
|
|
835
|
+
if (registryKeys.has(key) || isReservedProviderEnvKey(key)) {
|
|
836
|
+
throw new Error(`bot_token_env ${key} conflicts with a protected provider secret key`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
780
840
|
/** User-authored fleet.yaml, before defaults are merged into instances. */
|
|
781
841
|
getRawFleetConfig() {
|
|
782
842
|
return structuredClone(this.rawFleetConfig);
|
|
@@ -2021,6 +2081,22 @@ export class FleetManager {
|
|
|
2021
2081
|
names.add(channel.instanceName);
|
|
2022
2082
|
return [...names];
|
|
2023
2083
|
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Probe the same executable set exposed by the web backend catalog.
|
|
2086
|
+
*
|
|
2087
|
+
* This deliberately has no cache: an `/install-cli` completion can add a
|
|
2088
|
+
* binary to PATH while the fleet process remains alive, and the next bare
|
|
2089
|
+
* `/login` must see it without requiring a restart or an explicit cache
|
|
2090
|
+
* invalidation call.
|
|
2091
|
+
*/
|
|
2092
|
+
probeInstalledBackends() {
|
|
2093
|
+
const installed = new Set();
|
|
2094
|
+
for (const [backend, info] of Object.entries(BACKEND_INSTALLATION_INFO)) {
|
|
2095
|
+
if (checkBinaryInstalled(info.binary))
|
|
2096
|
+
installed.add(backend);
|
|
2097
|
+
}
|
|
2098
|
+
return installed;
|
|
2099
|
+
}
|
|
2024
2100
|
/**
|
|
2025
2101
|
* One fleet-level notice per burst, not one per instance: a post-update herd
|
|
2026
2102
|
* fails many instances within the same second. Two notices per incident at
|
|
@@ -2816,6 +2892,7 @@ export class FleetManager {
|
|
|
2816
2892
|
}
|
|
2817
2893
|
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
2818
2894
|
await adapterStartup;
|
|
2895
|
+
this.startDiscordUsagePresence();
|
|
2819
2896
|
// Bind every fleet instance deterministically. Explicit channel_id wins;
|
|
2820
2897
|
// otherwise channels[0] is authoritative. Do not infer identity from
|
|
2821
2898
|
// concurrent adapter startup or whichever bot receives a message first.
|
|
@@ -2964,6 +3041,48 @@ export class FleetManager {
|
|
|
2964
3041
|
this.logger.warn({ health }, "Fleet started with problems — see /health");
|
|
2965
3042
|
}
|
|
2966
3043
|
}
|
|
3044
|
+
/** Keep Discord profile activity aligned with the same cached usage source as /usage. */
|
|
3045
|
+
startDiscordUsagePresence() {
|
|
3046
|
+
if (this.discordPresenceTimer)
|
|
3047
|
+
clearInterval(this.discordPresenceTimer);
|
|
3048
|
+
void this.refreshDiscordUsagePresence();
|
|
3049
|
+
this.discordPresenceTimer = setInterval(() => {
|
|
3050
|
+
void this.refreshDiscordUsagePresence();
|
|
3051
|
+
}, FleetManager.DISCORD_PRESENCE_REFRESH_MS);
|
|
3052
|
+
this.discordPresenceTimer.unref?.();
|
|
3053
|
+
}
|
|
3054
|
+
refreshDiscordUsagePresence() {
|
|
3055
|
+
if (this.discordPresenceInFlight)
|
|
3056
|
+
return this.discordPresenceInFlight;
|
|
3057
|
+
const run = (async () => {
|
|
3058
|
+
const targets = [...this.adapters.values()]
|
|
3059
|
+
.filter(adapter => adapter.type === "discord" && typeof adapter.setActivity === "function");
|
|
3060
|
+
if (targets.length === 0)
|
|
3061
|
+
return;
|
|
3062
|
+
try {
|
|
3063
|
+
const payload = await getUsageSnapshot(false, this.getActiveUsageProviderIds());
|
|
3064
|
+
const text = formatDiscordUsageActivity(payload);
|
|
3065
|
+
for (const adapter of targets) {
|
|
3066
|
+
try {
|
|
3067
|
+
adapter.setActivity?.(text);
|
|
3068
|
+
}
|
|
3069
|
+
catch {
|
|
3070
|
+
// Presence is cosmetic; a failed update must not affect delivery.
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
catch {
|
|
3075
|
+
// Usage providers are best-effort and may be offline. Keep the last
|
|
3076
|
+
// activity rather than replacing it with an untruthful blank state.
|
|
3077
|
+
this.logger.debug("Discord usage presence refresh skipped");
|
|
3078
|
+
}
|
|
3079
|
+
})();
|
|
3080
|
+
this.discordPresenceInFlight = run.finally(() => {
|
|
3081
|
+
if (this.discordPresenceInFlight === run)
|
|
3082
|
+
this.discordPresenceInFlight = null;
|
|
3083
|
+
});
|
|
3084
|
+
return this.discordPresenceInFlight;
|
|
3085
|
+
}
|
|
2967
3086
|
/**
|
|
2968
3087
|
* Delete inbox files older than retentionDays (by mtime). Cleans the shared
|
|
2969
3088
|
* inbox (`<dataDir>/inbox`) and every workspace inbox
|
|
@@ -3081,6 +3200,11 @@ export class FleetManager {
|
|
|
3081
3200
|
}
|
|
3082
3201
|
bindAdapterHealth(adapter, adapterId) {
|
|
3083
3202
|
adapter.on("gateway_health", (snapshot) => {
|
|
3203
|
+
// A token rotation tears down the old EventEmitter before constructing
|
|
3204
|
+
// the replacement. A late health frame from that old client must never
|
|
3205
|
+
// make a failed/new-generation adapter look connected.
|
|
3206
|
+
if (this.adapters.get(adapterId) !== adapter)
|
|
3207
|
+
return;
|
|
3084
3208
|
const previous = this.adapterState.get(adapterId);
|
|
3085
3209
|
const status = snapshot.status === "connected" ? "connected"
|
|
3086
3210
|
: snapshot.status === "stopped" ? "failed"
|
|
@@ -3161,7 +3285,7 @@ export class FleetManager {
|
|
|
3161
3285
|
};
|
|
3162
3286
|
}
|
|
3163
3287
|
/** Start the primary adapter (backward-compatible, sets this.adapter) */
|
|
3164
|
-
async startSingleAdapter(fleet, channelConfig) {
|
|
3288
|
+
async startSingleAdapter(fleet, channelConfig, onStarted) {
|
|
3165
3289
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
3166
3290
|
if (!botToken) {
|
|
3167
3291
|
this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter");
|
|
@@ -3187,13 +3311,20 @@ export class FleetManager {
|
|
|
3187
3311
|
this.worlds.set(adapterId, world);
|
|
3188
3312
|
this.adapters.set(adapterId, adapter);
|
|
3189
3313
|
this.bindAdapterHealth(adapter, adapterId);
|
|
3314
|
+
const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
|
|
3190
3315
|
this.adapter.on("message", safeHandler(async (msg) => {
|
|
3316
|
+
if (!isCurrentAdapter())
|
|
3317
|
+
return;
|
|
3191
3318
|
await this.handleInboundMessage(msg);
|
|
3192
3319
|
}, this.logger, "adapter.message"));
|
|
3193
3320
|
this.adapter.on("reaction", safeHandler(async (r) => {
|
|
3321
|
+
if (!isCurrentAdapter())
|
|
3322
|
+
return;
|
|
3194
3323
|
await this.handleInboundReaction(r);
|
|
3195
3324
|
}, this.logger, "adapter.reaction"));
|
|
3196
3325
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
3326
|
+
if (!isCurrentAdapter())
|
|
3327
|
+
return;
|
|
3197
3328
|
if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
|
|
3198
3329
|
return;
|
|
3199
3330
|
if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
|
|
@@ -3234,6 +3365,8 @@ export class FleetManager {
|
|
|
3234
3365
|
this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
|
|
3235
3366
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
3236
3367
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
3368
|
+
if (!isCurrentAdapter())
|
|
3369
|
+
return;
|
|
3237
3370
|
if (data.command === "start") {
|
|
3238
3371
|
await this.handleClassicStartSlash(data, adapterId);
|
|
3239
3372
|
}
|
|
@@ -3513,6 +3646,8 @@ export class FleetManager {
|
|
|
3513
3646
|
// Non-blocking: /model & status views read the cache; never delays startup.
|
|
3514
3647
|
this.probeCliEnvs();
|
|
3515
3648
|
this.adapter.on("started", safeHandler((username, userId) => {
|
|
3649
|
+
if (!isCurrentAdapter())
|
|
3650
|
+
return;
|
|
3516
3651
|
this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
|
|
3517
3652
|
// Concurrent startup can insert a secondary world first. Update the
|
|
3518
3653
|
// configured primary world, not Map insertion order.
|
|
@@ -3524,6 +3659,7 @@ export class FleetManager {
|
|
|
3524
3659
|
}
|
|
3525
3660
|
if (userId)
|
|
3526
3661
|
this.botUserId = userId;
|
|
3662
|
+
onStarted?.();
|
|
3527
3663
|
}, this.logger, "adapter.started"));
|
|
3528
3664
|
this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
|
|
3529
3665
|
this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
|
|
@@ -3532,10 +3668,14 @@ export class FleetManager {
|
|
|
3532
3668
|
this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
|
|
3533
3669
|
}, this.logger, "adapter.handler_error"));
|
|
3534
3670
|
this.adapter.on("error", (err) => {
|
|
3671
|
+
if (!isCurrentAdapter())
|
|
3672
|
+
return;
|
|
3535
3673
|
this.logger.error({ err }, "Primary adapter fatal error");
|
|
3536
3674
|
this.restartAdapter(this.adapter, adapterId).catch(() => { });
|
|
3537
3675
|
});
|
|
3538
3676
|
this.adapter.on("new_group_detected", safeHandler(async (data) => {
|
|
3677
|
+
if (!isCurrentAdapter())
|
|
3678
|
+
return;
|
|
3539
3679
|
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
3540
3680
|
const generalId = this.findGeneralInstance();
|
|
3541
3681
|
// No user to promote: the bot was just added, nobody has run /start yet.
|
|
@@ -3547,6 +3687,9 @@ export class FleetManager {
|
|
|
3547
3687
|
if (fleet.channel?.group_id) {
|
|
3548
3688
|
this.adapter.setChatId(String(fleet.channel.group_id));
|
|
3549
3689
|
}
|
|
3690
|
+
if (this.discordPresenceTimer && this.adapter.type === "discord") {
|
|
3691
|
+
void this.refreshDiscordUsagePresence();
|
|
3692
|
+
}
|
|
3550
3693
|
this.startTopicCleanupPoller();
|
|
3551
3694
|
// Prune stale external sessions every 5 minutes
|
|
3552
3695
|
this.sessionPruneTimer = setInterval(() => {
|
|
@@ -3554,7 +3697,7 @@ export class FleetManager {
|
|
|
3554
3697
|
}, 5 * 60 * 1000);
|
|
3555
3698
|
}
|
|
3556
3699
|
/** Start an additional (non-primary) adapter */
|
|
3557
|
-
async startAdditionalAdapter(channelConfig, registerCommands = true) {
|
|
3700
|
+
async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted) {
|
|
3558
3701
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
3559
3702
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
3560
3703
|
if (!botToken) {
|
|
@@ -3579,14 +3722,21 @@ export class FleetManager {
|
|
|
3579
3722
|
this.worlds.set(adapterId, world);
|
|
3580
3723
|
this.adapters.set(adapterId, adapter);
|
|
3581
3724
|
this.bindAdapterHealth(adapter, adapterId);
|
|
3725
|
+
const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
|
|
3582
3726
|
// Wire up event handlers (same as primary, routes through shared handleInboundMessage)
|
|
3583
3727
|
adapter.on("message", safeHandler(async (msg) => {
|
|
3728
|
+
if (!isCurrentAdapter())
|
|
3729
|
+
return;
|
|
3584
3730
|
await this.handleInboundMessage(msg);
|
|
3585
3731
|
}, this.logger, `adapter[${adapterId}].message`));
|
|
3586
3732
|
adapter.on("reaction", safeHandler(async (r) => {
|
|
3733
|
+
if (!isCurrentAdapter())
|
|
3734
|
+
return;
|
|
3587
3735
|
await this.handleInboundReaction(r);
|
|
3588
3736
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
3589
3737
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
3738
|
+
if (!isCurrentAdapter())
|
|
3739
|
+
return;
|
|
3590
3740
|
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
3591
3741
|
return;
|
|
3592
3742
|
if (await this.handleTipUnlock(data, adapterId, adapter))
|
|
@@ -3627,6 +3777,8 @@ export class FleetManager {
|
|
|
3627
3777
|
this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
|
|
3628
3778
|
// Slash commands: classic bot + admin commands
|
|
3629
3779
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
3780
|
+
if (!isCurrentAdapter())
|
|
3781
|
+
return;
|
|
3630
3782
|
if (data.command === "start") {
|
|
3631
3783
|
await this.handleClassicStartSlash(data, adapterId);
|
|
3632
3784
|
}
|
|
@@ -3850,6 +4002,8 @@ export class FleetManager {
|
|
|
3850
4002
|
}
|
|
3851
4003
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
3852
4004
|
adapter.on("started", safeHandler((username, userId) => {
|
|
4005
|
+
if (!isCurrentAdapter())
|
|
4006
|
+
return;
|
|
3853
4007
|
this.logger.info(`[${adapterId}] Bot @${username} polling started.`);
|
|
3854
4008
|
const world = this.worlds.get(adapterId);
|
|
3855
4009
|
if (world) {
|
|
@@ -3857,14 +4011,19 @@ export class FleetManager {
|
|
|
3857
4011
|
if (userId)
|
|
3858
4012
|
world.botUserId = userId;
|
|
3859
4013
|
}
|
|
4014
|
+
onStarted?.();
|
|
3860
4015
|
}, this.logger, `adapter[${adapterId}].started`));
|
|
3861
4016
|
adapter.on("new_group_detected", safeHandler(async (data) => {
|
|
4017
|
+
if (!isCurrentAdapter())
|
|
4018
|
+
return;
|
|
3862
4019
|
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
3863
4020
|
const generalId = this.findGeneralInstance(adapterId);
|
|
3864
4021
|
if (generalId)
|
|
3865
4022
|
await this.promptClassicApproval({ generalName: generalId, message: adminMsg, groupId: data.groupId, scope: data.source === "telegram" ? "group" : "guild" });
|
|
3866
4023
|
}, this.logger, `adapter[${adapterId}].new_group_detected`));
|
|
3867
4024
|
adapter.on("error", (err) => {
|
|
4025
|
+
if (!isCurrentAdapter())
|
|
4026
|
+
return;
|
|
3868
4027
|
this.logger.error({ err, adapterId }, "Additional adapter fatal error");
|
|
3869
4028
|
this.restartAdapter(adapter, adapterId).catch(() => { });
|
|
3870
4029
|
});
|
|
@@ -3873,6 +4032,9 @@ export class FleetManager {
|
|
|
3873
4032
|
if (channelConfig.group_id) {
|
|
3874
4033
|
adapter.setChatId(String(channelConfig.group_id));
|
|
3875
4034
|
}
|
|
4035
|
+
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4036
|
+
void this.refreshDiscordUsagePresence();
|
|
4037
|
+
}
|
|
3876
4038
|
this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
|
|
3877
4039
|
}
|
|
3878
4040
|
/** Connect IPC to a single instance with all handlers */
|
|
@@ -4115,6 +4277,9 @@ export class FleetManager {
|
|
|
4115
4277
|
// watchdog/manual/error triggers must converge here instead of stop/start.
|
|
4116
4278
|
await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
|
|
4117
4279
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
4280
|
+
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4281
|
+
void this.refreshDiscordUsagePresence();
|
|
4282
|
+
}
|
|
4118
4283
|
this.logger.info({ id }, "Adapter gateway rebuilt successfully");
|
|
4119
4284
|
}
|
|
4120
4285
|
catch (err) {
|
|
@@ -4140,6 +4305,9 @@ export class FleetManager {
|
|
|
4140
4305
|
await adapter.start();
|
|
4141
4306
|
this.logger.info({ id, attempt }, "Adapter restarted successfully");
|
|
4142
4307
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
4308
|
+
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4309
|
+
void this.refreshDiscordUsagePresence();
|
|
4310
|
+
}
|
|
4143
4311
|
return;
|
|
4144
4312
|
}
|
|
4145
4313
|
catch (err) {
|
|
@@ -8016,11 +8184,39 @@ export class FleetManager {
|
|
|
8016
8184
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
8017
8185
|
configured.add(this.backendNameOf(name));
|
|
8018
8186
|
}
|
|
8019
|
-
const
|
|
8020
|
-
|
|
8021
|
-
|
|
8187
|
+
const installed = this.probeInstalledBackends();
|
|
8188
|
+
const candidates = new Set([...installed, ...configured]);
|
|
8189
|
+
const unsupported = [];
|
|
8190
|
+
const choices = [...candidates].sort().flatMap(backend => {
|
|
8191
|
+
const flow = LOGIN_FLOWS[backend];
|
|
8192
|
+
const remoteLogin = !!flow && flow.remoteLogin !== "unsupported";
|
|
8193
|
+
const status = [];
|
|
8194
|
+
if (installed.has(backend))
|
|
8195
|
+
status.push(t("login.status_installed"));
|
|
8196
|
+
if (configured.has(backend))
|
|
8197
|
+
status.push(t("login.status_configured"));
|
|
8198
|
+
if (remoteLogin)
|
|
8199
|
+
status.push(t("login.status_auth"));
|
|
8200
|
+
else
|
|
8201
|
+
status.push(t("login.status_unsupported"));
|
|
8202
|
+
if (!remoteLogin) {
|
|
8203
|
+
unsupported.push({ backend, flow, status });
|
|
8204
|
+
return [];
|
|
8205
|
+
}
|
|
8206
|
+
return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
|
|
8207
|
+
});
|
|
8208
|
+
if (unsupported.length) {
|
|
8209
|
+
const guidance = unsupported.map(({ backend, flow, status }) => `${backend} · ${status.join(" · ")} — ${flow?.remoteLogin === "unsupported"
|
|
8210
|
+
? t("login.remote_unsupported_agent_cli", backend, flow.command)
|
|
8211
|
+
: backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
|
|
8212
|
+
await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
|
|
8213
|
+
.catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
|
|
8214
|
+
}
|
|
8022
8215
|
if (choices.length === 0) {
|
|
8023
|
-
|
|
8216
|
+
if (!unsupported.length) {
|
|
8217
|
+
await chat.adapter.sendText(chat.chatId, t("login.none_available"), { threadId: chat.threadId })
|
|
8218
|
+
.catch(err => this.logger.warn({ err }, "Failed to post empty login chooser guidance"));
|
|
8219
|
+
}
|
|
8024
8220
|
return;
|
|
8025
8221
|
}
|
|
8026
8222
|
await this.postNonceButtonPrompt({
|
|
@@ -8609,6 +8805,12 @@ export class FleetManager {
|
|
|
8609
8805
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
8610
8806
|
return;
|
|
8611
8807
|
}
|
|
8808
|
+
// The button is deliberately best-effort: it is a short-lived
|
|
8809
|
+
// capability and can be lost during an adapter reconnect. Always
|
|
8810
|
+
// publish a durable completion line first so a successful install can
|
|
8811
|
+
// never look like it silently disappeared. Include the binary that
|
|
8812
|
+
// passed the fresh-login-shell verification and the exact next step.
|
|
8813
|
+
await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
|
|
8612
8814
|
await this.postNonceButtonPrompt({
|
|
8613
8815
|
prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
|
|
8614
8816
|
alertType: "login",
|
|
@@ -10642,6 +10844,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10642
10844
|
clearInterval(this.logRotateTimer);
|
|
10643
10845
|
this.logRotateTimer = null;
|
|
10644
10846
|
}
|
|
10847
|
+
if (this.discordPresenceTimer) {
|
|
10848
|
+
clearInterval(this.discordPresenceTimer);
|
|
10849
|
+
this.discordPresenceTimer = null;
|
|
10850
|
+
}
|
|
10645
10851
|
// Cancel-button timers were never cleared here. The idle-check interval is not
|
|
10646
10852
|
// unref'd, so it held the event loop open past shutdown and kept retrying
|
|
10647
10853
|
// deletes against an adapter that was already gone.
|
|
@@ -11275,6 +11481,799 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11275
11481
|
get applyJobs() {
|
|
11276
11482
|
return (this.applyJobStoreCache ??= new ApplyJobStore(this.dataDir, Date.now, this.logger));
|
|
11277
11483
|
}
|
|
11484
|
+
/** The only channel metadata exposed to the Settings secret UI. */
|
|
11485
|
+
listSecureConnections() {
|
|
11486
|
+
const channels = this.fleetConfig?.channels
|
|
11487
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
11488
|
+
return channels.map((channel, index) => {
|
|
11489
|
+
const id = channel.id ?? channel.type ?? `channel-${index}`;
|
|
11490
|
+
const world = this.worlds.get(id);
|
|
11491
|
+
const state = this.adapterState.get(id);
|
|
11492
|
+
return {
|
|
11493
|
+
id,
|
|
11494
|
+
type: channel.type,
|
|
11495
|
+
token_env: channel.bot_token_env,
|
|
11496
|
+
token_present: !!process.env[channel.bot_token_env],
|
|
11497
|
+
group_id: channel.group_id != null ? String(channel.group_id) : null,
|
|
11498
|
+
general_channel_id: channel.options?.general_channel_id != null
|
|
11499
|
+
? String(channel.options.general_channel_id)
|
|
11500
|
+
: null,
|
|
11501
|
+
status: state?.status ?? (world ? "starting" : "stopped"),
|
|
11502
|
+
...(world ? { identity: { id: world.botUserId ?? null, username: world.botUsername ?? null } } : {}),
|
|
11503
|
+
};
|
|
11504
|
+
});
|
|
11505
|
+
}
|
|
11506
|
+
secureConnectionChannel(connectionId) {
|
|
11507
|
+
const channels = this.fleetConfig?.channels
|
|
11508
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
11509
|
+
const matches = channels.filter((channel, index) => (channel.id ?? channel.type ?? `channel-${index}`) === connectionId);
|
|
11510
|
+
// Ambiguous fallback IDs (for example two unlabelled Discord channels)
|
|
11511
|
+
// must fail closed rather than rotating the first matching token.
|
|
11512
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
11513
|
+
}
|
|
11514
|
+
secureConnectionGeneration(connectionId) {
|
|
11515
|
+
const adapter = this.adapters.get(connectionId);
|
|
11516
|
+
const healthGeneration = adapter?.getHealthSnapshot?.().generation ?? 0;
|
|
11517
|
+
let generation = this.connectionSecretGenerations.get(connectionId) ?? 0;
|
|
11518
|
+
const previousAdapter = this.connectionSecretAdapterRefs.get(connectionId);
|
|
11519
|
+
const previousHealthGeneration = this.connectionSecretHealthGenerations.get(connectionId);
|
|
11520
|
+
if (this.connectionSecretAdapterRefs.has(connectionId) && previousAdapter !== adapter)
|
|
11521
|
+
generation++;
|
|
11522
|
+
if (this.connectionSecretHealthGenerations.has(connectionId) && previousHealthGeneration !== healthGeneration)
|
|
11523
|
+
generation++;
|
|
11524
|
+
this.connectionSecretAdapterRefs.set(connectionId, adapter);
|
|
11525
|
+
this.connectionSecretHealthGenerations.set(connectionId, healthGeneration);
|
|
11526
|
+
this.connectionSecretGenerations.set(connectionId, generation);
|
|
11527
|
+
return generation;
|
|
11528
|
+
}
|
|
11529
|
+
/** Provider API-key rows exposed to Settings (never the env key or secret). */
|
|
11530
|
+
providerSecretsEnabled() {
|
|
11531
|
+
return this.fleetConfig?.web?.provider_secrets === true;
|
|
11532
|
+
}
|
|
11533
|
+
listProviderSecrets() {
|
|
11534
|
+
return PROVIDER_SECRET_SPECS.map(spec => ({
|
|
11535
|
+
id: spec.id,
|
|
11536
|
+
display_name: spec.displayName,
|
|
11537
|
+
kind: spec.kind,
|
|
11538
|
+
token_present: !!process.env[spec.envKey],
|
|
11539
|
+
verifier: spec.verifier ? "available" : "unsupported",
|
|
11540
|
+
activation: spec.activation,
|
|
11541
|
+
stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
|
|
11542
|
+
}));
|
|
11543
|
+
}
|
|
11544
|
+
providerSecretStaleConsumers(envKey) {
|
|
11545
|
+
// A child inherits the manager's environment at spawn. We cannot inspect
|
|
11546
|
+
// a child process's private environment safely, so report the conservative
|
|
11547
|
+
// set of already-running children; the UI can then say "restart these"
|
|
11548
|
+
// rather than claiming an existing process reloaded.
|
|
11549
|
+
if (envKey === "GROQ_API_KEY")
|
|
11550
|
+
return [];
|
|
11551
|
+
return [...this.children.keys()].sort();
|
|
11552
|
+
}
|
|
11553
|
+
providerSecretGeneration(envKey) {
|
|
11554
|
+
return this.providerSecretGenerations.get(envKey) ?? 0;
|
|
11555
|
+
}
|
|
11556
|
+
providerSecretEnvAllowed(envKey) {
|
|
11557
|
+
const configured = new Set((this.fleetConfig?.channels
|
|
11558
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
|
|
11559
|
+
.map(channel => channel.bot_token_env));
|
|
11560
|
+
return !configured.has(envKey) && providerRegistryEnvKeys().has(envKey);
|
|
11561
|
+
}
|
|
11562
|
+
async verifyProviderSecret(input) {
|
|
11563
|
+
const spec = providerSecretSpec(input.specId);
|
|
11564
|
+
if (!spec || !this.providerSecretEnvAllowed(spec.envKey)) {
|
|
11565
|
+
return { ok: false, status: "invalid", error: "provider secret is not configured" };
|
|
11566
|
+
}
|
|
11567
|
+
if (!spec.verifier)
|
|
11568
|
+
return { ok: false, status: "unsupported_verifier", error: "this provider has no supported verifier" };
|
|
11569
|
+
if (!input.secret || input.secret.length > 4096 || /[\r\n\0]/.test(input.secret) || /[^\x20-\x7e]/.test(input.secret)) {
|
|
11570
|
+
return { ok: false, status: "invalid", error: "secret is invalid" };
|
|
11571
|
+
}
|
|
11572
|
+
const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
|
|
11573
|
+
const existingId = this.providerSecretChallengesByKey.get(challengeKey);
|
|
11574
|
+
const existing = existingId ? this.providerSecretChallenges.get(existingId) : undefined;
|
|
11575
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
11576
|
+
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, spec_id: spec.id, activation: spec.activation };
|
|
11577
|
+
}
|
|
11578
|
+
if (existingId)
|
|
11579
|
+
this.providerSecretChallengesByKey.delete(challengeKey);
|
|
11580
|
+
const result = await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
|
|
11581
|
+
if (!result.ok) {
|
|
11582
|
+
// Do not log provider detail: the HTTP verifier already redacted it and
|
|
11583
|
+
// this endpoint has no need to disclose whether a key was close to valid.
|
|
11584
|
+
this.logger.warn({ specId: spec.id, status: result.status }, "Provider API-key verification failed");
|
|
11585
|
+
return { ok: false, status: result.status, error: result.status === "unsupported_verifier" ? "this provider has no supported verifier" : "provider rejected or unavailable" };
|
|
11586
|
+
}
|
|
11587
|
+
const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
|
|
11588
|
+
const challenge = {
|
|
11589
|
+
id: opaqueId("provider_verify"),
|
|
11590
|
+
specId: spec.id,
|
|
11591
|
+
envKey: spec.envKey,
|
|
11592
|
+
kind: "api_key",
|
|
11593
|
+
sessionBinding: input.sessionBinding,
|
|
11594
|
+
generation: this.providerSecretGeneration(spec.envKey),
|
|
11595
|
+
operation: "provider-secret.apply",
|
|
11596
|
+
idempotencyKey: input.idempotencyKey,
|
|
11597
|
+
expiresAt,
|
|
11598
|
+
secret: input.secret,
|
|
11599
|
+
};
|
|
11600
|
+
this.providerSecretChallenges.set(challenge.id, challenge);
|
|
11601
|
+
this.providerSecretChallengesByKey.set(challengeKey, challenge.id);
|
|
11602
|
+
const expiryTimer = setTimeout(() => {
|
|
11603
|
+
if (this.providerSecretChallenges.get(challenge.id) !== challenge)
|
|
11604
|
+
return;
|
|
11605
|
+
this.providerSecretChallenges.delete(challenge.id);
|
|
11606
|
+
if (this.providerSecretChallengesByKey.get(challengeKey) === challenge.id)
|
|
11607
|
+
this.providerSecretChallengesByKey.delete(challengeKey);
|
|
11608
|
+
}, SECRET_CHALLENGE_TTL_MS);
|
|
11609
|
+
expiryTimer.unref?.();
|
|
11610
|
+
return { ok: true, verification_id: challenge.id, expires_at: expiresAt, spec_id: spec.id, activation: spec.activation };
|
|
11611
|
+
}
|
|
11612
|
+
/** Naming aliases used by integrations that call this an API-key operation. */
|
|
11613
|
+
verifyProviderApiKey(input) {
|
|
11614
|
+
return this.verifyProviderSecret(input);
|
|
11615
|
+
}
|
|
11616
|
+
startProviderSecretApply(input) {
|
|
11617
|
+
for (const [jobId, job] of this.providerSecretJobs) {
|
|
11618
|
+
if (job.specId === input.specId && job.idempotencyKey === input.idempotencyKey
|
|
11619
|
+
&& this.providerSecretJobSession.get(jobId) === input.sessionBinding)
|
|
11620
|
+
return { job, reused: true };
|
|
11621
|
+
}
|
|
11622
|
+
const challenge = this.providerSecretChallenges.get(input.verificationId);
|
|
11623
|
+
const spec = providerSecretSpec(input.specId);
|
|
11624
|
+
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
11625
|
+
if (challenge)
|
|
11626
|
+
this.providerSecretChallenges.delete(input.verificationId);
|
|
11627
|
+
return { error: "verification expired; verify the secret again" };
|
|
11628
|
+
}
|
|
11629
|
+
if (!spec || challenge.specId !== spec.id || challenge.envKey !== spec.envKey || challenge.kind !== "api_key"
|
|
11630
|
+
|| challenge.sessionBinding !== input.sessionBinding || challenge.operation !== "provider-secret.apply"
|
|
11631
|
+
|| challenge.idempotencyKey !== input.idempotencyKey || challenge.generation !== this.providerSecretGeneration(challenge.envKey)) {
|
|
11632
|
+
return { error: "verification does not match this provider or session" };
|
|
11633
|
+
}
|
|
11634
|
+
const existingId = this.providerSecretInFlight.get(challenge.envKey);
|
|
11635
|
+
if (existingId) {
|
|
11636
|
+
const existing = this.providerSecretJobs.get(existingId) ?? null;
|
|
11637
|
+
if (existing?.idempotencyKey === input.idempotencyKey)
|
|
11638
|
+
return { job: existing, reused: true };
|
|
11639
|
+
return { busy: existing };
|
|
11640
|
+
}
|
|
11641
|
+
this.providerSecretChallenges.delete(input.verificationId);
|
|
11642
|
+
this.providerSecretChallengesByKey.delete(`${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`);
|
|
11643
|
+
const job = {
|
|
11644
|
+
id: opaqueId("provider_apply"), specId: spec.id, envKey: spec.envKey,
|
|
11645
|
+
idempotencyKey: input.idempotencyKey, result: "applying", status: "running", startedAt: Date.now(),
|
|
11646
|
+
stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
|
|
11647
|
+
};
|
|
11648
|
+
this.providerSecretJobs.set(job.id, job);
|
|
11649
|
+
this.providerSecretJobSession.set(job.id, input.sessionBinding);
|
|
11650
|
+
this.providerSecretInFlight.set(spec.envKey, job.id);
|
|
11651
|
+
queueMicrotask(() => void this.runProviderSecretApply(job, challenge.secret));
|
|
11652
|
+
return { job, reused: false };
|
|
11653
|
+
}
|
|
11654
|
+
startProviderApiKeyApply(input) {
|
|
11655
|
+
return this.startProviderSecretApply(input);
|
|
11656
|
+
}
|
|
11657
|
+
getProviderSecretApply(jobId, sessionBinding) {
|
|
11658
|
+
if (this.providerSecretJobSession.get(jobId) !== sessionBinding)
|
|
11659
|
+
return null;
|
|
11660
|
+
return this.providerSecretJobs.get(jobId) ?? null;
|
|
11661
|
+
}
|
|
11662
|
+
getProviderApiKeyApply(jobId, sessionBinding) {
|
|
11663
|
+
return this.getProviderSecretApply(jobId, sessionBinding);
|
|
11664
|
+
}
|
|
11665
|
+
async runProviderSecretApply(job, secret) {
|
|
11666
|
+
const spec = providerSecretSpec(job.specId);
|
|
11667
|
+
const allowed = new Set([
|
|
11668
|
+
...PROVIDER_SECRET_SPECS.map(item => item.envKey),
|
|
11669
|
+
...(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).map(channel => channel.bot_token_env),
|
|
11670
|
+
]);
|
|
11671
|
+
let store = null;
|
|
11672
|
+
let before = null;
|
|
11673
|
+
const previousProcessValue = process.env[job.envKey];
|
|
11674
|
+
let wrote = false;
|
|
11675
|
+
try {
|
|
11676
|
+
if (!spec || !this.providerSecretEnvAllowed(job.envKey))
|
|
11677
|
+
throw new Error("provider secret is not configured");
|
|
11678
|
+
// Construct inside the transaction: symlink/permission refusal must
|
|
11679
|
+
// settle the job as a safe failure, not escape the queued microtask.
|
|
11680
|
+
store = new SecretStore(join(this.dataDir, ".env"), allowed);
|
|
11681
|
+
before = store.write(job.envKey, secret);
|
|
11682
|
+
wrote = true;
|
|
11683
|
+
process.env[job.envKey] = secret;
|
|
11684
|
+
if (spec.activation === "reload_hook" && spec.reloadHookId) {
|
|
11685
|
+
await this.runProviderSecretReloadHook(spec.reloadHookId, secret, previousProcessValue);
|
|
11686
|
+
job.result = "reloaded";
|
|
11687
|
+
}
|
|
11688
|
+
else {
|
|
11689
|
+
job.result = "applied_next_use";
|
|
11690
|
+
}
|
|
11691
|
+
this.providerSecretGenerations.set(job.envKey, this.providerSecretGeneration(job.envKey) + 1);
|
|
11692
|
+
job.status = "done";
|
|
11693
|
+
job.finishedAt = Date.now();
|
|
11694
|
+
}
|
|
11695
|
+
catch (err) {
|
|
11696
|
+
const safe = safeSecretError(err, secret);
|
|
11697
|
+
this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
|
|
11698
|
+
try {
|
|
11699
|
+
if (wrote && before && store)
|
|
11700
|
+
store.restore(before);
|
|
11701
|
+
if (previousProcessValue === undefined)
|
|
11702
|
+
delete process.env[job.envKey];
|
|
11703
|
+
else
|
|
11704
|
+
process.env[job.envKey] = previousProcessValue;
|
|
11705
|
+
// SecretStore.write is itself transactional; when it fails before a
|
|
11706
|
+
// snapshot is returned there is no new value to roll back. Report the
|
|
11707
|
+
// truthful no-op rather than claiming rollback_failed.
|
|
11708
|
+
job.result = "rolled_back";
|
|
11709
|
+
if (!wrote || !before)
|
|
11710
|
+
job.error = "provider secret was not applied";
|
|
11711
|
+
}
|
|
11712
|
+
catch (rollbackErr) {
|
|
11713
|
+
this.logger.error({ specId: job.specId, reason: safeSecretError(rollbackErr, secret, previousProcessValue ? [previousProcessValue] : []) }, "Provider API-key rollback failed");
|
|
11714
|
+
job.result = "rollback_failed";
|
|
11715
|
+
job.error = "provider secret rollback failed; operator attention required";
|
|
11716
|
+
}
|
|
11717
|
+
job.status = "done";
|
|
11718
|
+
job.finishedAt = Date.now();
|
|
11719
|
+
}
|
|
11720
|
+
finally {
|
|
11721
|
+
this.providerSecretInFlight.delete(job.envKey);
|
|
11722
|
+
secret = "";
|
|
11723
|
+
}
|
|
11724
|
+
}
|
|
11725
|
+
/** Groq is currently read from process.env per voice request, so the hook is
|
|
11726
|
+
* intentionally a no-op. Keeping it as a named code-owned hook makes the
|
|
11727
|
+
* hot activation contract explicit and gives tests a failure seam; no generic
|
|
11728
|
+
* SIGHUP or caller-provided hook is ever executed. */
|
|
11729
|
+
async runProviderSecretReloadHook(hookId, _next, _previous) {
|
|
11730
|
+
if (hookId !== "groq.voice")
|
|
11731
|
+
throw new Error("unknown provider secret reload hook");
|
|
11732
|
+
const before = this.providerSecretHotSnapshots.get(hookId);
|
|
11733
|
+
this.providerSecretHotSnapshots.set(hookId, _next);
|
|
11734
|
+
const hook = this.providerSecretReloadHooks.get(hookId);
|
|
11735
|
+
try {
|
|
11736
|
+
if (hook)
|
|
11737
|
+
await hook(_next, before);
|
|
11738
|
+
}
|
|
11739
|
+
catch (err) {
|
|
11740
|
+
if (before === undefined)
|
|
11741
|
+
this.providerSecretHotSnapshots.delete(hookId);
|
|
11742
|
+
else
|
|
11743
|
+
this.providerSecretHotSnapshots.set(hookId, before);
|
|
11744
|
+
throw err;
|
|
11745
|
+
}
|
|
11746
|
+
}
|
|
11747
|
+
normalizeConnectionBinding(input) {
|
|
11748
|
+
// IDs arrive from JSON and may be Discord snowflakes. Do not accept a
|
|
11749
|
+
// number here: JSON.parse may already have rounded it before verification.
|
|
11750
|
+
if (typeof input.group_id !== "string")
|
|
11751
|
+
return null;
|
|
11752
|
+
const groupId = input.group_id.trim();
|
|
11753
|
+
if (!groupId || groupId.length > 128 || /[\r\n\0]/.test(groupId))
|
|
11754
|
+
return null;
|
|
11755
|
+
let general;
|
|
11756
|
+
if (input.general_channel_id === null || input.general_channel_id === undefined || input.general_channel_id === "") {
|
|
11757
|
+
general = input.general_channel_id === null ? null : undefined;
|
|
11758
|
+
}
|
|
11759
|
+
else if (typeof input.general_channel_id === "string") {
|
|
11760
|
+
general = input.general_channel_id.trim();
|
|
11761
|
+
if (!general || general.length > 128 || /[\r\n\0]/.test(general))
|
|
11762
|
+
return null;
|
|
11763
|
+
}
|
|
11764
|
+
else {
|
|
11765
|
+
return null;
|
|
11766
|
+
}
|
|
11767
|
+
return general === undefined ? { group_id: groupId } : { group_id: groupId, general_channel_id: general };
|
|
11768
|
+
}
|
|
11769
|
+
connectionBindingChannelConfig(channel, binding) {
|
|
11770
|
+
const candidate = structuredClone(channel);
|
|
11771
|
+
// IDs are intentionally normalized to strings at this boundary. Discord
|
|
11772
|
+
// snowflakes must never become YAML numbers (precision loss is silent).
|
|
11773
|
+
candidate.group_id = String(binding.group_id);
|
|
11774
|
+
if (binding.general_channel_id !== undefined) {
|
|
11775
|
+
const options = { ...(candidate.options ?? {}) };
|
|
11776
|
+
if (binding.general_channel_id === null)
|
|
11777
|
+
delete options.general_channel_id;
|
|
11778
|
+
else
|
|
11779
|
+
options.general_channel_id = String(binding.general_channel_id);
|
|
11780
|
+
if (Object.keys(options).length === 0)
|
|
11781
|
+
delete candidate.options;
|
|
11782
|
+
else
|
|
11783
|
+
candidate.options = options;
|
|
11784
|
+
}
|
|
11785
|
+
return candidate;
|
|
11786
|
+
}
|
|
11787
|
+
/** Verify a prospective group/guild binding without mutating fleet state. */
|
|
11788
|
+
async verifyConnectionBinding(input) {
|
|
11789
|
+
const channel = this.secureConnectionChannel(input.connectionId);
|
|
11790
|
+
const binding = this.normalizeConnectionBinding(input.binding);
|
|
11791
|
+
const adapter = this.adapters.get(input.connectionId);
|
|
11792
|
+
if (!channel || !binding || !adapter?.verifyBinding) {
|
|
11793
|
+
return { ok: false, error: "connection binding is unsupported or invalid" };
|
|
11794
|
+
}
|
|
11795
|
+
const key = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
|
|
11796
|
+
const existingId = this.connectionBindingChallengesByKey.get(key);
|
|
11797
|
+
const existing = existingId ? this.connectionBindingChallenges.get(existingId) : undefined;
|
|
11798
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
11799
|
+
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, binding: existing.binding, probe: existing.probe };
|
|
11800
|
+
}
|
|
11801
|
+
if (existingId)
|
|
11802
|
+
this.connectionBindingChallengesByKey.delete(key);
|
|
11803
|
+
const beforeGeneration = this.secureConnectionGeneration(input.connectionId);
|
|
11804
|
+
let probe;
|
|
11805
|
+
try {
|
|
11806
|
+
probe = await adapter.verifyBinding(binding.group_id, binding.general_channel_id ?? undefined);
|
|
11807
|
+
}
|
|
11808
|
+
catch (err) {
|
|
11809
|
+
this.logger.warn({ connectionId: input.connectionId, reason: safeSecretError(err) }, "Settings connection binding verification failed");
|
|
11810
|
+
return { ok: false, error: "binding verification failed" };
|
|
11811
|
+
}
|
|
11812
|
+
const afterGeneration = this.secureConnectionGeneration(input.connectionId);
|
|
11813
|
+
if (beforeGeneration !== afterGeneration || this.adapters.get(input.connectionId) !== adapter) {
|
|
11814
|
+
return { ok: false, error: "connection changed while binding was verified" };
|
|
11815
|
+
}
|
|
11816
|
+
if (probe.group_id !== binding.group_id || !probe.can_view || !probe.can_send) {
|
|
11817
|
+
return { ok: false, error: "provider did not confirm the requested binding" };
|
|
11818
|
+
}
|
|
11819
|
+
const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
|
|
11820
|
+
const challenge = {
|
|
11821
|
+
id: opaqueId("binding_verify"),
|
|
11822
|
+
connectionId: input.connectionId,
|
|
11823
|
+
sessionBinding: input.sessionBinding,
|
|
11824
|
+
generation: afterGeneration,
|
|
11825
|
+
operation: "binding.apply",
|
|
11826
|
+
idempotencyKey: input.idempotencyKey,
|
|
11827
|
+
expiresAt,
|
|
11828
|
+
binding,
|
|
11829
|
+
probe,
|
|
11830
|
+
};
|
|
11831
|
+
this.connectionBindingChallenges.set(challenge.id, challenge);
|
|
11832
|
+
this.connectionBindingChallengesByKey.set(key, challenge.id);
|
|
11833
|
+
const expiryTimer = setTimeout(() => {
|
|
11834
|
+
if (this.connectionBindingChallenges.get(challenge.id) !== challenge)
|
|
11835
|
+
return;
|
|
11836
|
+
this.connectionBindingChallenges.delete(challenge.id);
|
|
11837
|
+
if (this.connectionBindingChallengesByKey.get(key) === challenge.id)
|
|
11838
|
+
this.connectionBindingChallengesByKey.delete(key);
|
|
11839
|
+
}, SECRET_CHALLENGE_TTL_MS);
|
|
11840
|
+
expiryTimer.unref?.();
|
|
11841
|
+
return { ok: true, verification_id: challenge.id, expires_at: expiresAt, binding, probe };
|
|
11842
|
+
}
|
|
11843
|
+
startConnectionBindingApply(input) {
|
|
11844
|
+
for (const [jobId, job] of this.connectionBindingJobs) {
|
|
11845
|
+
if (job.connectionId === input.connectionId && job.idempotencyKey === input.idempotencyKey
|
|
11846
|
+
&& this.connectionBindingJobSession.get(jobId) === input.sessionBinding)
|
|
11847
|
+
return { job, reused: true };
|
|
11848
|
+
}
|
|
11849
|
+
const challenge = this.connectionBindingChallenges.get(input.verificationId);
|
|
11850
|
+
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
11851
|
+
this.connectionBindingChallenges.delete(input.verificationId);
|
|
11852
|
+
return { error: "binding verification expired; verify the binding again" };
|
|
11853
|
+
}
|
|
11854
|
+
if (challenge.connectionId !== input.connectionId || challenge.sessionBinding !== input.sessionBinding
|
|
11855
|
+
|| challenge.operation !== "binding.apply" || challenge.idempotencyKey !== input.idempotencyKey
|
|
11856
|
+
|| challenge.generation !== this.secureConnectionGeneration(input.connectionId)) {
|
|
11857
|
+
return { error: "binding verification does not match this connection or session" };
|
|
11858
|
+
}
|
|
11859
|
+
const existingId = this.connectionBindingInFlight.get(input.connectionId);
|
|
11860
|
+
if (existingId) {
|
|
11861
|
+
const existing = this.connectionBindingJobs.get(existingId) ?? null;
|
|
11862
|
+
if (existing?.idempotencyKey === input.idempotencyKey)
|
|
11863
|
+
return { job: existing, reused: true };
|
|
11864
|
+
return { busy: existing };
|
|
11865
|
+
}
|
|
11866
|
+
this.connectionBindingChallenges.delete(input.verificationId);
|
|
11867
|
+
this.connectionBindingChallengesByKey.delete(`${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`);
|
|
11868
|
+
const job = {
|
|
11869
|
+
id: opaqueId("binding_apply"), connectionId: input.connectionId, idempotencyKey: input.idempotencyKey,
|
|
11870
|
+
result: "applying", status: "running", startedAt: Date.now(),
|
|
11871
|
+
};
|
|
11872
|
+
this.connectionBindingJobs.set(job.id, job);
|
|
11873
|
+
this.connectionBindingJobSession.set(job.id, input.sessionBinding);
|
|
11874
|
+
this.connectionBindingInFlight.set(input.connectionId, job.id);
|
|
11875
|
+
queueMicrotask(() => void this.runConnectionBindingApply(job, challenge.binding));
|
|
11876
|
+
return { job, reused: false };
|
|
11877
|
+
}
|
|
11878
|
+
getConnectionBindingApply(jobId, sessionBinding) {
|
|
11879
|
+
if (this.connectionBindingJobSession.get(jobId) !== sessionBinding)
|
|
11880
|
+
return null;
|
|
11881
|
+
return this.connectionBindingJobs.get(jobId) ?? null;
|
|
11882
|
+
}
|
|
11883
|
+
async runConnectionBindingApply(job, binding) {
|
|
11884
|
+
try {
|
|
11885
|
+
await this.rebuildAdapterForBinding(job.connectionId, binding);
|
|
11886
|
+
job.result = "applied";
|
|
11887
|
+
}
|
|
11888
|
+
catch (err) {
|
|
11889
|
+
const reason = safeSecretError(err);
|
|
11890
|
+
job.result = /rollback failed/i.test(reason) ? "rollback_failed" : "rolled_back";
|
|
11891
|
+
job.error = job.result === "rollback_failed"
|
|
11892
|
+
? "binding rollback failed; adapter requires operator attention"
|
|
11893
|
+
: "binding was not applied; previous binding was restored";
|
|
11894
|
+
this.logger.warn({ connectionId: job.connectionId, reason: safeSecretError(err) }, "Settings connection binding apply failed");
|
|
11895
|
+
}
|
|
11896
|
+
finally {
|
|
11897
|
+
job.status = "done";
|
|
11898
|
+
job.finishedAt = Date.now();
|
|
11899
|
+
this.connectionBindingInFlight.delete(job.connectionId);
|
|
11900
|
+
}
|
|
11901
|
+
}
|
|
11902
|
+
/** Stop, rebuild and wait for a new adapter before committing YAML binding. */
|
|
11903
|
+
async rebuildAdapterForBinding(connectionId, binding) {
|
|
11904
|
+
const channel = this.secureConnectionChannel(connectionId);
|
|
11905
|
+
if (!channel || !this.fleetConfig)
|
|
11906
|
+
throw new Error("connection not found");
|
|
11907
|
+
const candidate = this.connectionBindingChannelConfig(channel, binding);
|
|
11908
|
+
const oldAdapter = this.adapters.get(connectionId);
|
|
11909
|
+
const oldWorld = this.worlds.get(connectionId);
|
|
11910
|
+
const oldPrimary = this.adapter;
|
|
11911
|
+
const oldAccess = this.accessManager;
|
|
11912
|
+
const oldState = this.adapterState.get(connectionId);
|
|
11913
|
+
const oldChannel = structuredClone(channel);
|
|
11914
|
+
const primary = this.getPrimaryAdapterId() === connectionId;
|
|
11915
|
+
if (primary && this.sessionPruneTimer) {
|
|
11916
|
+
clearInterval(this.sessionPruneTimer);
|
|
11917
|
+
this.sessionPruneTimer = null;
|
|
11918
|
+
}
|
|
11919
|
+
let fresh;
|
|
11920
|
+
let persistedBinding = false;
|
|
11921
|
+
try {
|
|
11922
|
+
this.adapterState.set(connectionId, { status: "retrying", retryCount: oldState?.retryCount ?? 0 });
|
|
11923
|
+
if (oldAdapter) {
|
|
11924
|
+
oldAdapter.removeAllListeners();
|
|
11925
|
+
await oldAdapter.stop().catch(() => { });
|
|
11926
|
+
if (this.adapters.get(connectionId) === oldAdapter)
|
|
11927
|
+
this.adapters.delete(connectionId);
|
|
11928
|
+
if (this.worlds.get(connectionId)?.adapter === oldAdapter)
|
|
11929
|
+
this.worlds.delete(connectionId);
|
|
11930
|
+
if (primary && this.adapter === oldAdapter)
|
|
11931
|
+
this.adapter = null;
|
|
11932
|
+
}
|
|
11933
|
+
let startedResolve = null;
|
|
11934
|
+
const started = new Promise(resolve => { startedResolve = resolve; });
|
|
11935
|
+
const onStarted = () => { startedResolve?.(); };
|
|
11936
|
+
if (primary)
|
|
11937
|
+
await this.startSingleAdapter(this.fleetConfig, candidate, onStarted);
|
|
11938
|
+
else
|
|
11939
|
+
await this.startAdditionalAdapter(candidate, true, onStarted);
|
|
11940
|
+
fresh = this.adapters.get(connectionId);
|
|
11941
|
+
if (!fresh)
|
|
11942
|
+
throw new Error("new adapter did not start");
|
|
11943
|
+
const deadline = Date.now() + 15_000;
|
|
11944
|
+
if (!fresh.getHealthSnapshot) {
|
|
11945
|
+
let timer;
|
|
11946
|
+
const timeout = new Promise((_, reject) => {
|
|
11947
|
+
timer = setTimeout(() => reject(new Error("new adapter did not become ready")), Math.max(1, deadline - Date.now()));
|
|
11948
|
+
timer.unref?.();
|
|
11949
|
+
});
|
|
11950
|
+
try {
|
|
11951
|
+
await Promise.race([started, timeout]);
|
|
11952
|
+
}
|
|
11953
|
+
finally {
|
|
11954
|
+
if (timer)
|
|
11955
|
+
clearTimeout(timer);
|
|
11956
|
+
}
|
|
11957
|
+
}
|
|
11958
|
+
else {
|
|
11959
|
+
while (Date.now() < deadline) {
|
|
11960
|
+
const health = fresh.getHealthSnapshot?.();
|
|
11961
|
+
if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
|
|
11962
|
+
break;
|
|
11963
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
11964
|
+
}
|
|
11965
|
+
const health = fresh.getHealthSnapshot?.();
|
|
11966
|
+
if (health && health.status !== "connected" && this.adapterState.get(connectionId)?.status !== "connected") {
|
|
11967
|
+
throw new Error("new adapter did not become connected");
|
|
11968
|
+
}
|
|
11969
|
+
}
|
|
11970
|
+
if (fresh.setChatId)
|
|
11971
|
+
fresh.setChatId(String(candidate.group_id));
|
|
11972
|
+
// Commit only after the replacement adapter is ready. No allowlist,
|
|
11973
|
+
// topic, instance or schedule fields are touched here.
|
|
11974
|
+
channel.group_id = String(binding.group_id);
|
|
11975
|
+
if (binding.general_channel_id !== undefined) {
|
|
11976
|
+
const options = { ...(channel.options ?? {}) };
|
|
11977
|
+
if (binding.general_channel_id === null)
|
|
11978
|
+
delete options.general_channel_id;
|
|
11979
|
+
else
|
|
11980
|
+
options.general_channel_id = String(binding.general_channel_id);
|
|
11981
|
+
if (Object.keys(options).length === 0)
|
|
11982
|
+
delete channel.options;
|
|
11983
|
+
else
|
|
11984
|
+
channel.options = options;
|
|
11985
|
+
}
|
|
11986
|
+
this.saveFleetConfig();
|
|
11987
|
+
persistedBinding = true;
|
|
11988
|
+
this.routing.rebuild(this.fleetConfig);
|
|
11989
|
+
this.reregisterClassicChannels();
|
|
11990
|
+
this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
|
|
11991
|
+
}
|
|
11992
|
+
catch (err) {
|
|
11993
|
+
if (fresh && fresh !== oldAdapter)
|
|
11994
|
+
await fresh.stop().catch(() => { });
|
|
11995
|
+
// Restore only the binding object in memory; unrelated connection and
|
|
11996
|
+
// instance state remains exactly as it was before the attempt.
|
|
11997
|
+
for (const key of Object.keys(channel)) {
|
|
11998
|
+
if (!(key in oldChannel))
|
|
11999
|
+
delete channel[key];
|
|
12000
|
+
}
|
|
12001
|
+
Object.assign(channel, oldChannel);
|
|
12002
|
+
this.adapters.delete(connectionId);
|
|
12003
|
+
this.worlds.delete(connectionId);
|
|
12004
|
+
this.adapterState.delete(connectionId);
|
|
12005
|
+
if (oldAdapter) {
|
|
12006
|
+
try {
|
|
12007
|
+
const onStarted = () => { };
|
|
12008
|
+
if (primary)
|
|
12009
|
+
await this.startSingleAdapter(this.fleetConfig, oldChannel, onStarted);
|
|
12010
|
+
else
|
|
12011
|
+
await this.startAdditionalAdapter(oldChannel, true, onStarted);
|
|
12012
|
+
this.adapterState.set(connectionId, oldState ?? { status: "connected", retryCount: 0 });
|
|
12013
|
+
}
|
|
12014
|
+
catch (restoreErr) {
|
|
12015
|
+
throw new Error(`binding rollback failed: ${safeSecretError(restoreErr)}`);
|
|
12016
|
+
}
|
|
12017
|
+
}
|
|
12018
|
+
else {
|
|
12019
|
+
if (primary)
|
|
12020
|
+
this.adapter = oldPrimary;
|
|
12021
|
+
if (oldWorld)
|
|
12022
|
+
this.worlds.set(connectionId, oldWorld);
|
|
12023
|
+
if (oldAdapter)
|
|
12024
|
+
this.adapters.set(connectionId, oldAdapter);
|
|
12025
|
+
this.accessManager = oldAccess;
|
|
12026
|
+
}
|
|
12027
|
+
// The binding is committed to YAML before routing is rebuilt. If the
|
|
12028
|
+
// post-commit rebuild fails, restore the durable document as well as the
|
|
12029
|
+
// in-memory channel; otherwise a reload would resurrect the failed
|
|
12030
|
+
// binding that the running fleet just rolled back.
|
|
12031
|
+
if (persistedBinding)
|
|
12032
|
+
this.saveFleetConfig();
|
|
12033
|
+
this.routing.rebuild(this.fleetConfig);
|
|
12034
|
+
this.reregisterClassicChannels();
|
|
12035
|
+
throw err;
|
|
12036
|
+
}
|
|
12037
|
+
}
|
|
12038
|
+
async verifyConnectionSecret(input) {
|
|
12039
|
+
const channel = this.secureConnectionChannel(input.connectionId);
|
|
12040
|
+
if (!channel || (channel.type !== "discord" && channel.type !== "telegram")) {
|
|
12041
|
+
return { ok: false, error: "connection not found or unsupported" };
|
|
12042
|
+
}
|
|
12043
|
+
if (!input.secret || input.secret.length > 4096 || /[\r\n\0]/.test(input.secret)) {
|
|
12044
|
+
return { ok: false, error: "secret is invalid" };
|
|
12045
|
+
}
|
|
12046
|
+
const challengeKey = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
|
|
12047
|
+
const existingId = this.connectionSecretChallengesByKey.get(challengeKey);
|
|
12048
|
+
const existing = existingId ? this.connectionSecretChallenges.get(existingId) : undefined;
|
|
12049
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
12050
|
+
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt };
|
|
12051
|
+
}
|
|
12052
|
+
if (existingId)
|
|
12053
|
+
this.connectionSecretChallengesByKey.delete(challengeKey);
|
|
12054
|
+
// Fixed provider endpoints only. Never use a user-supplied URL and never
|
|
12055
|
+
// call Telegram getUpdates (the running adapter owns that long poll).
|
|
12056
|
+
const identity = channel.type === "discord"
|
|
12057
|
+
? await verifyDiscordToken(input.secret)
|
|
12058
|
+
: await verifyTelegramToken(input.secret);
|
|
12059
|
+
if (!identity.valid) {
|
|
12060
|
+
this.logger.warn({ connectionId: input.connectionId, provider: channel.type }, "Settings connection secret verification failed");
|
|
12061
|
+
return { ok: false, error: "provider rejected the secret" };
|
|
12062
|
+
}
|
|
12063
|
+
const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
|
|
12064
|
+
const challenge = {
|
|
12065
|
+
id: opaqueId("verify"),
|
|
12066
|
+
connectionId: input.connectionId,
|
|
12067
|
+
sessionBinding: input.sessionBinding,
|
|
12068
|
+
generation: this.secureConnectionGeneration(input.connectionId),
|
|
12069
|
+
operation: "secret.apply",
|
|
12070
|
+
idempotencyKey: input.idempotencyKey,
|
|
12071
|
+
expiresAt,
|
|
12072
|
+
secret: input.secret,
|
|
12073
|
+
};
|
|
12074
|
+
this.connectionSecretChallenges.set(challenge.id, challenge);
|
|
12075
|
+
this.connectionSecretChallengesByKey.set(challengeKey, challenge.id);
|
|
12076
|
+
const expiryTimer = setTimeout(() => {
|
|
12077
|
+
if (this.connectionSecretChallenges.get(challenge.id) !== challenge)
|
|
12078
|
+
return;
|
|
12079
|
+
this.connectionSecretChallenges.delete(challenge.id);
|
|
12080
|
+
if (this.connectionSecretChallengesByKey.get(challengeKey) === challenge.id) {
|
|
12081
|
+
this.connectionSecretChallengesByKey.delete(challengeKey);
|
|
12082
|
+
}
|
|
12083
|
+
}, SECRET_CHALLENGE_TTL_MS);
|
|
12084
|
+
expiryTimer.unref?.();
|
|
12085
|
+
return {
|
|
12086
|
+
ok: true,
|
|
12087
|
+
verification_id: challenge.id,
|
|
12088
|
+
expires_at: expiresAt,
|
|
12089
|
+
identity: { id: identity.id, username: identity.username },
|
|
12090
|
+
};
|
|
12091
|
+
}
|
|
12092
|
+
startConnectionSecretApply(input) {
|
|
12093
|
+
for (const [jobId, job] of this.connectionSecretJobs) {
|
|
12094
|
+
if (job.connectionId === input.connectionId
|
|
12095
|
+
&& job.idempotencyKey === input.idempotencyKey
|
|
12096
|
+
&& this.connectionSecretJobSession.get(jobId) === input.sessionBinding) {
|
|
12097
|
+
return { job, reused: true };
|
|
12098
|
+
}
|
|
12099
|
+
}
|
|
12100
|
+
const challenge = this.connectionSecretChallenges.get(input.verificationId);
|
|
12101
|
+
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
12102
|
+
this.connectionSecretChallenges.delete(input.verificationId);
|
|
12103
|
+
return { error: "verification expired; verify the secret again" };
|
|
12104
|
+
}
|
|
12105
|
+
if (challenge.connectionId !== input.connectionId
|
|
12106
|
+
|| challenge.sessionBinding !== input.sessionBinding
|
|
12107
|
+
|| challenge.operation !== "secret.apply"
|
|
12108
|
+
|| challenge.idempotencyKey !== input.idempotencyKey
|
|
12109
|
+
|| challenge.generation !== this.secureConnectionGeneration(input.connectionId)) {
|
|
12110
|
+
return { error: "verification does not match this connection or session" };
|
|
12111
|
+
}
|
|
12112
|
+
const existingId = this.connectionSecretInFlight.get(input.connectionId);
|
|
12113
|
+
if (existingId) {
|
|
12114
|
+
const existing = this.connectionSecretJobs.get(existingId) ?? null;
|
|
12115
|
+
if (existing?.idempotencyKey === input.idempotencyKey)
|
|
12116
|
+
return { job: existing, reused: true };
|
|
12117
|
+
return { busy: existing };
|
|
12118
|
+
}
|
|
12119
|
+
// Consume the challenge before scheduling work. A lost HTTP response can
|
|
12120
|
+
// retry with the same idempotency key and rejoin the job, but a second
|
|
12121
|
+
// request cannot replay the secret into a second adapter.
|
|
12122
|
+
this.connectionSecretChallenges.delete(input.verificationId);
|
|
12123
|
+
this.connectionSecretChallengesByKey.delete(`${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`);
|
|
12124
|
+
const job = {
|
|
12125
|
+
id: opaqueId("secret_apply"),
|
|
12126
|
+
connectionId: input.connectionId,
|
|
12127
|
+
idempotencyKey: input.idempotencyKey,
|
|
12128
|
+
result: "applying",
|
|
12129
|
+
status: "running",
|
|
12130
|
+
startedAt: Date.now(),
|
|
12131
|
+
};
|
|
12132
|
+
this.connectionSecretJobs.set(job.id, job);
|
|
12133
|
+
this.connectionSecretJobSession.set(job.id, input.sessionBinding);
|
|
12134
|
+
this.connectionSecretInFlight.set(input.connectionId, job.id);
|
|
12135
|
+
queueMicrotask(() => void this.runConnectionSecretApply(job, challenge.secret));
|
|
12136
|
+
return { job, reused: false };
|
|
12137
|
+
}
|
|
12138
|
+
getConnectionSecretApply(jobId, sessionBinding) {
|
|
12139
|
+
if (this.connectionSecretJobSession.get(jobId) !== sessionBinding)
|
|
12140
|
+
return null;
|
|
12141
|
+
return this.connectionSecretJobs.get(jobId) ?? null;
|
|
12142
|
+
}
|
|
12143
|
+
async runConnectionSecretApply(job, secret) {
|
|
12144
|
+
const channel = this.secureConnectionChannel(job.connectionId);
|
|
12145
|
+
const envKey = channel?.bot_token_env;
|
|
12146
|
+
const allowed = new Set((this.fleetConfig?.channels
|
|
12147
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
|
|
12148
|
+
.map(item => item.bot_token_env));
|
|
12149
|
+
let before = null;
|
|
12150
|
+
let oldToken;
|
|
12151
|
+
let replaced = false;
|
|
12152
|
+
try {
|
|
12153
|
+
if (!channel || !envKey || !allowed.has(envKey))
|
|
12154
|
+
throw new Error("connection is not configured for secret rotation");
|
|
12155
|
+
const owners = (this.fleetConfig?.channels
|
|
12156
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
|
|
12157
|
+
.filter(item => item.bot_token_env === envKey);
|
|
12158
|
+
if (owners.length !== 1)
|
|
12159
|
+
throw new Error("secret key is shared by multiple connections");
|
|
12160
|
+
const store = new SecretStore(join(this.dataDir, ".env"), allowed);
|
|
12161
|
+
before = store.write(envKey, secret);
|
|
12162
|
+
replaced = true;
|
|
12163
|
+
oldToken = process.env[envKey];
|
|
12164
|
+
process.env[envKey] = secret;
|
|
12165
|
+
const generation = this.secureConnectionGeneration(job.connectionId) + 1;
|
|
12166
|
+
this.connectionSecretGenerations.set(job.connectionId, generation);
|
|
12167
|
+
const applied = await this.rebuildAdapterForSecret(job.connectionId, channel);
|
|
12168
|
+
if (!applied) {
|
|
12169
|
+
job.result = "restart_required";
|
|
12170
|
+
job.status = "done";
|
|
12171
|
+
job.finishedAt = Date.now();
|
|
12172
|
+
return;
|
|
12173
|
+
}
|
|
12174
|
+
job.result = "applied";
|
|
12175
|
+
job.status = "done";
|
|
12176
|
+
job.finishedAt = Date.now();
|
|
12177
|
+
}
|
|
12178
|
+
catch (err) {
|
|
12179
|
+
const message = safeSecretError(err, secret);
|
|
12180
|
+
this.logger.warn({ connectionId: job.connectionId, reason: message }, "Settings connection secret apply failed");
|
|
12181
|
+
if (!replaced || !before || !envKey) {
|
|
12182
|
+
job.result = "rollback_failed";
|
|
12183
|
+
job.error = "secret was not applied";
|
|
12184
|
+
}
|
|
12185
|
+
else {
|
|
12186
|
+
try {
|
|
12187
|
+
const store = new SecretStore(join(this.dataDir, ".env"), new Set([envKey]));
|
|
12188
|
+
store.restore(before);
|
|
12189
|
+
if (oldToken === undefined)
|
|
12190
|
+
delete process.env[envKey];
|
|
12191
|
+
else
|
|
12192
|
+
process.env[envKey] = oldToken;
|
|
12193
|
+
// Build a fresh adapter from the restored token. If the old adapter
|
|
12194
|
+
// was stopped already, this is the only safe way to return to the
|
|
12195
|
+
// previous runtime without claiming a disk-only rollback succeeded.
|
|
12196
|
+
const restored = await this.rebuildAdapterForSecret(job.connectionId, channel, true);
|
|
12197
|
+
if (!restored)
|
|
12198
|
+
throw new Error("adapter rollback did not become connected");
|
|
12199
|
+
job.result = "rolled_back";
|
|
12200
|
+
}
|
|
12201
|
+
catch (rollbackErr) {
|
|
12202
|
+
this.logger.error({ connectionId: job.connectionId, reason: safeSecretError(rollbackErr, oldToken, [secret]) }, "Settings connection secret rollback failed");
|
|
12203
|
+
job.result = "rollback_failed";
|
|
12204
|
+
job.error = "secret rollback failed; adapter is disabled";
|
|
12205
|
+
}
|
|
12206
|
+
}
|
|
12207
|
+
job.status = "done";
|
|
12208
|
+
job.finishedAt = Date.now();
|
|
12209
|
+
}
|
|
12210
|
+
finally {
|
|
12211
|
+
this.connectionSecretInFlight.delete(job.connectionId);
|
|
12212
|
+
// Do not retain the token after the apply (success or rollback).
|
|
12213
|
+
secret = "";
|
|
12214
|
+
}
|
|
12215
|
+
}
|
|
12216
|
+
/** Stop the old provider client and construct a new one from process.env. */
|
|
12217
|
+
async rebuildAdapterForSecret(connectionId, channel, force = false) {
|
|
12218
|
+
const old = this.adapters.get(connectionId);
|
|
12219
|
+
if (!old && !force)
|
|
12220
|
+
return false; // The secret is valid on disk; the next start adopts it.
|
|
12221
|
+
const primary = this.getPrimaryAdapterId() === connectionId;
|
|
12222
|
+
const previousState = this.adapterState.get(connectionId);
|
|
12223
|
+
this.adapterState.set(connectionId, { status: "retrying", retryCount: previousState?.retryCount ?? 0 });
|
|
12224
|
+
if (primary && this.sessionPruneTimer) {
|
|
12225
|
+
clearInterval(this.sessionPruneTimer);
|
|
12226
|
+
this.sessionPruneTimer = null;
|
|
12227
|
+
}
|
|
12228
|
+
if (old) {
|
|
12229
|
+
old.removeAllListeners();
|
|
12230
|
+
await old.stop().catch(() => { });
|
|
12231
|
+
if (this.adapters.get(connectionId) === old)
|
|
12232
|
+
this.adapters.delete(connectionId);
|
|
12233
|
+
if (this.worlds.get(connectionId)?.adapter === old)
|
|
12234
|
+
this.worlds.delete(connectionId);
|
|
12235
|
+
if (primary && this.adapter === old)
|
|
12236
|
+
this.adapter = null;
|
|
12237
|
+
}
|
|
12238
|
+
let startedResolve = null;
|
|
12239
|
+
const started = new Promise(resolve => { startedResolve = resolve; });
|
|
12240
|
+
const onStarted = () => { startedResolve?.(); };
|
|
12241
|
+
if (primary)
|
|
12242
|
+
await this.startSingleAdapter(this.fleetConfig, channel, onStarted);
|
|
12243
|
+
else
|
|
12244
|
+
await this.startAdditionalAdapter(channel, true, onStarted);
|
|
12245
|
+
const fresh = this.adapters.get(connectionId);
|
|
12246
|
+
if (!fresh)
|
|
12247
|
+
throw new Error("new adapter did not start");
|
|
12248
|
+
const deadline = Date.now() + 15_000;
|
|
12249
|
+
// Telegram has no gateway health snapshot. Its start() method launches the
|
|
12250
|
+
// grammY polling loop in the background, so completion of start() is not a
|
|
12251
|
+
// connected signal. The adapter's `started` event is emitted only after the
|
|
12252
|
+
// first provider getMe succeeds; require that event before claiming apply.
|
|
12253
|
+
if (!fresh.getHealthSnapshot) {
|
|
12254
|
+
let timer;
|
|
12255
|
+
const timeout = new Promise((_, reject) => {
|
|
12256
|
+
timer = setTimeout(() => reject(new Error("new adapter did not emit started before deadline")), Math.max(1, deadline - Date.now()));
|
|
12257
|
+
timer.unref?.();
|
|
12258
|
+
});
|
|
12259
|
+
try {
|
|
12260
|
+
await Promise.race([started, timeout]);
|
|
12261
|
+
}
|
|
12262
|
+
finally {
|
|
12263
|
+
if (timer)
|
|
12264
|
+
clearTimeout(timer);
|
|
12265
|
+
}
|
|
12266
|
+
this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
|
|
12267
|
+
return true;
|
|
12268
|
+
}
|
|
12269
|
+
while (Date.now() < deadline) {
|
|
12270
|
+
const health = fresh.getHealthSnapshot?.();
|
|
12271
|
+
if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
|
|
12272
|
+
return true;
|
|
12273
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
12274
|
+
}
|
|
12275
|
+
throw new Error("new adapter did not become connected");
|
|
12276
|
+
}
|
|
11278
12277
|
/**
|
|
11279
12278
|
* What a reconcile is about to do, per target.
|
|
11280
12279
|
*
|