@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.
Files changed (43) hide show
  1. package/dist/backend/muse.js +27 -0
  2. package/dist/backend/muse.js.map +1 -1
  3. package/dist/channel/adapters/discord.d.ts +14 -0
  4. package/dist/channel/adapters/discord.js +59 -1
  5. package/dist/channel/adapters/discord.js.map +1 -1
  6. package/dist/channel/adapters/telegram.d.ts +6 -0
  7. package/dist/channel/adapters/telegram.js +36 -0
  8. package/dist/channel/adapters/telegram.js.map +1 -1
  9. package/dist/channel/types.d.ts +18 -0
  10. package/dist/config-validator.js +3 -0
  11. package/dist/config-validator.js.map +1 -1
  12. package/dist/connection-secrets.d.ts +96 -0
  13. package/dist/connection-secrets.js +25 -0
  14. package/dist/connection-secrets.js.map +1 -0
  15. package/dist/daemon.d.ts +29 -0
  16. package/dist/daemon.js +145 -77
  17. package/dist/daemon.js.map +1 -1
  18. package/dist/fleet-manager.d.ts +180 -0
  19. package/dist/fleet-manager.js +1008 -9
  20. package/dist/fleet-manager.js.map +1 -1
  21. package/dist/locale.js +14 -0
  22. package/dist/locale.js.map +1 -1
  23. package/dist/provider-probe.d.ts +3 -0
  24. package/dist/provider-probe.js +12 -2
  25. package/dist/provider-probe.js.map +1 -1
  26. package/dist/provider-secret-registry.d.ts +98 -0
  27. package/dist/provider-secret-registry.js +334 -0
  28. package/dist/provider-secret-registry.js.map +1 -0
  29. package/dist/quickstart-api.d.ts +1 -1
  30. package/dist/quickstart-api.js +14 -4
  31. package/dist/quickstart-api.js.map +1 -1
  32. package/dist/secret-store.d.ts +25 -0
  33. package/dist/secret-store.js +165 -0
  34. package/dist/secret-store.js.map +1 -0
  35. package/dist/settings-api.d.ts +101 -0
  36. package/dist/settings-api.js +404 -4
  37. package/dist/settings-api.js.map +1 -1
  38. package/dist/types.d.ts +2 -0
  39. package/dist/ui/settings.html +171 -6
  40. package/dist/usage/usage-api.d.ts +12 -0
  41. package/dist/usage/usage-api.js +39 -0
  42. package/dist/usage/usage-api.js.map +1 -1
  43. package/package.json +1 -1
@@ -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
- this.rawFleetConfig = loadRawFleetConfig(configPath);
776
- this.fleetConfig = loadFleetConfig(configPath);
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 choices = Object.keys(LOGIN_FLOWS)
8020
- .filter(backend => configured.size === 0 || configured.has(backend))
8021
- .map(backend => ({ action: backend, label: backend }));
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
- await chat.adapter.sendText(chat.chatId, t("login.unsupported", [...configured].join(", ")), { threadId: chat.threadId });
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
  *