@songsid/agend 2.1.6-beta.6 → 2.1.6-beta.8

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 (51) hide show
  1. package/dist/backend/claude-code.d.ts +25 -0
  2. package/dist/backend/claude-code.js +103 -0
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/types.d.ts +27 -0
  5. package/dist/backend/types.js.map +1 -1
  6. package/dist/channel/adapters/discord.d.ts +14 -0
  7. package/dist/channel/adapters/discord.js +59 -1
  8. package/dist/channel/adapters/discord.js.map +1 -1
  9. package/dist/channel/adapters/telegram.d.ts +6 -0
  10. package/dist/channel/adapters/telegram.js +36 -0
  11. package/dist/channel/adapters/telegram.js.map +1 -1
  12. package/dist/channel/tool-router.js +20 -1
  13. package/dist/channel/tool-router.js.map +1 -1
  14. package/dist/channel/types.d.ts +18 -0
  15. package/dist/config-validator.js +3 -0
  16. package/dist/config-validator.js.map +1 -1
  17. package/dist/connection-secrets.d.ts +96 -0
  18. package/dist/connection-secrets.js +25 -0
  19. package/dist/connection-secrets.js.map +1 -0
  20. package/dist/daemon.d.ts +38 -0
  21. package/dist/daemon.js +246 -80
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +171 -0
  24. package/dist/fleet-manager.js +954 -5
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/outbound-schemas.js +1 -1
  27. package/dist/outbound-schemas.js.map +1 -1
  28. package/dist/provider-probe.d.ts +3 -0
  29. package/dist/provider-probe.js +12 -2
  30. package/dist/provider-probe.js.map +1 -1
  31. package/dist/provider-secret-registry.d.ts +98 -0
  32. package/dist/provider-secret-registry.js +334 -0
  33. package/dist/provider-secret-registry.js.map +1 -0
  34. package/dist/quickstart-api.d.ts +1 -1
  35. package/dist/quickstart-api.js +14 -4
  36. package/dist/quickstart-api.js.map +1 -1
  37. package/dist/secret-store.d.ts +25 -0
  38. package/dist/secret-store.js +165 -0
  39. package/dist/secret-store.js.map +1 -0
  40. package/dist/settings-api.d.ts +101 -0
  41. package/dist/settings-api.js +404 -4
  42. package/dist/settings-api.js.map +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/dist/ui/settings.html +171 -6
  45. package/dist/usage/providers.d.ts +8 -6
  46. package/dist/usage/providers.js +83 -9
  47. package/dist/usage/providers.js.map +1 -1
  48. package/dist/usage/usage-api.d.ts +12 -0
  49. package/dist/usage/usage-api.js +55 -0
  50. package/dist/usage/usage-api.js.map +1 -1
  51. 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);
@@ -2832,6 +2892,7 @@ export class FleetManager {
2832
2892
  }
2833
2893
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
2834
2894
  await adapterStartup;
2895
+ this.startDiscordUsagePresence();
2835
2896
  // Bind every fleet instance deterministically. Explicit channel_id wins;
2836
2897
  // otherwise channels[0] is authoritative. Do not infer identity from
2837
2898
  // concurrent adapter startup or whichever bot receives a message first.
@@ -2980,6 +3041,48 @@ export class FleetManager {
2980
3041
  this.logger.warn({ health }, "Fleet started with problems — see /health");
2981
3042
  }
2982
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
+ }
2983
3086
  /**
2984
3087
  * Delete inbox files older than retentionDays (by mtime). Cleans the shared
2985
3088
  * inbox (`<dataDir>/inbox`) and every workspace inbox
@@ -3097,6 +3200,11 @@ export class FleetManager {
3097
3200
  }
3098
3201
  bindAdapterHealth(adapter, adapterId) {
3099
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;
3100
3208
  const previous = this.adapterState.get(adapterId);
3101
3209
  const status = snapshot.status === "connected" ? "connected"
3102
3210
  : snapshot.status === "stopped" ? "failed"
@@ -3177,7 +3285,7 @@ export class FleetManager {
3177
3285
  };
3178
3286
  }
3179
3287
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
3180
- async startSingleAdapter(fleet, channelConfig) {
3288
+ async startSingleAdapter(fleet, channelConfig, onStarted) {
3181
3289
  const botToken = process.env[channelConfig.bot_token_env];
3182
3290
  if (!botToken) {
3183
3291
  this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter");
@@ -3203,13 +3311,20 @@ export class FleetManager {
3203
3311
  this.worlds.set(adapterId, world);
3204
3312
  this.adapters.set(adapterId, adapter);
3205
3313
  this.bindAdapterHealth(adapter, adapterId);
3314
+ const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
3206
3315
  this.adapter.on("message", safeHandler(async (msg) => {
3316
+ if (!isCurrentAdapter())
3317
+ return;
3207
3318
  await this.handleInboundMessage(msg);
3208
3319
  }, this.logger, "adapter.message"));
3209
3320
  this.adapter.on("reaction", safeHandler(async (r) => {
3321
+ if (!isCurrentAdapter())
3322
+ return;
3210
3323
  await this.handleInboundReaction(r);
3211
3324
  }, this.logger, "adapter.reaction"));
3212
3325
  this.adapter.on("callback_query", safeHandler(async (data) => {
3326
+ if (!isCurrentAdapter())
3327
+ return;
3213
3328
  if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
3214
3329
  return;
3215
3330
  if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
@@ -3250,6 +3365,8 @@ export class FleetManager {
3250
3365
  this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
3251
3366
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
3252
3367
  this.adapter.on("slash_command", safeHandler(async (data) => {
3368
+ if (!isCurrentAdapter())
3369
+ return;
3253
3370
  if (data.command === "start") {
3254
3371
  await this.handleClassicStartSlash(data, adapterId);
3255
3372
  }
@@ -3529,6 +3646,8 @@ export class FleetManager {
3529
3646
  // Non-blocking: /model & status views read the cache; never delays startup.
3530
3647
  this.probeCliEnvs();
3531
3648
  this.adapter.on("started", safeHandler((username, userId) => {
3649
+ if (!isCurrentAdapter())
3650
+ return;
3532
3651
  this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
3533
3652
  // Concurrent startup can insert a secondary world first. Update the
3534
3653
  // configured primary world, not Map insertion order.
@@ -3540,6 +3659,7 @@ export class FleetManager {
3540
3659
  }
3541
3660
  if (userId)
3542
3661
  this.botUserId = userId;
3662
+ onStarted?.();
3543
3663
  }, this.logger, "adapter.started"));
3544
3664
  this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
3545
3665
  this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
@@ -3548,10 +3668,14 @@ export class FleetManager {
3548
3668
  this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
3549
3669
  }, this.logger, "adapter.handler_error"));
3550
3670
  this.adapter.on("error", (err) => {
3671
+ if (!isCurrentAdapter())
3672
+ return;
3551
3673
  this.logger.error({ err }, "Primary adapter fatal error");
3552
3674
  this.restartAdapter(this.adapter, adapterId).catch(() => { });
3553
3675
  });
3554
3676
  this.adapter.on("new_group_detected", safeHandler(async (data) => {
3677
+ if (!isCurrentAdapter())
3678
+ return;
3555
3679
  const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
3556
3680
  const generalId = this.findGeneralInstance();
3557
3681
  // No user to promote: the bot was just added, nobody has run /start yet.
@@ -3563,6 +3687,9 @@ export class FleetManager {
3563
3687
  if (fleet.channel?.group_id) {
3564
3688
  this.adapter.setChatId(String(fleet.channel.group_id));
3565
3689
  }
3690
+ if (this.discordPresenceTimer && this.adapter.type === "discord") {
3691
+ void this.refreshDiscordUsagePresence();
3692
+ }
3566
3693
  this.startTopicCleanupPoller();
3567
3694
  // Prune stale external sessions every 5 minutes
3568
3695
  this.sessionPruneTimer = setInterval(() => {
@@ -3570,7 +3697,7 @@ export class FleetManager {
3570
3697
  }, 5 * 60 * 1000);
3571
3698
  }
3572
3699
  /** Start an additional (non-primary) adapter */
3573
- async startAdditionalAdapter(channelConfig, registerCommands = true) {
3700
+ async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted) {
3574
3701
  const adapterId = channelConfig.id ?? channelConfig.type;
3575
3702
  const botToken = process.env[channelConfig.bot_token_env];
3576
3703
  if (!botToken) {
@@ -3595,14 +3722,21 @@ export class FleetManager {
3595
3722
  this.worlds.set(adapterId, world);
3596
3723
  this.adapters.set(adapterId, adapter);
3597
3724
  this.bindAdapterHealth(adapter, adapterId);
3725
+ const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
3598
3726
  // Wire up event handlers (same as primary, routes through shared handleInboundMessage)
3599
3727
  adapter.on("message", safeHandler(async (msg) => {
3728
+ if (!isCurrentAdapter())
3729
+ return;
3600
3730
  await this.handleInboundMessage(msg);
3601
3731
  }, this.logger, `adapter[${adapterId}].message`));
3602
3732
  adapter.on("reaction", safeHandler(async (r) => {
3733
+ if (!isCurrentAdapter())
3734
+ return;
3603
3735
  await this.handleInboundReaction(r);
3604
3736
  }, this.logger, `adapter[${adapterId}].reaction`));
3605
3737
  adapter.on("callback_query", safeHandler(async (data) => {
3738
+ if (!isCurrentAdapter())
3739
+ return;
3606
3740
  if (await this.handleTipDismiss(data, adapterId, adapter))
3607
3741
  return;
3608
3742
  if (await this.handleTipUnlock(data, adapterId, adapter))
@@ -3643,6 +3777,8 @@ export class FleetManager {
3643
3777
  this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
3644
3778
  // Slash commands: classic bot + admin commands
3645
3779
  adapter.on("slash_command", safeHandler(async (data) => {
3780
+ if (!isCurrentAdapter())
3781
+ return;
3646
3782
  if (data.command === "start") {
3647
3783
  await this.handleClassicStartSlash(data, adapterId);
3648
3784
  }
@@ -3866,6 +4002,8 @@ export class FleetManager {
3866
4002
  }
3867
4003
  }, this.logger, `adapter[${adapterId}].slash_command`));
3868
4004
  adapter.on("started", safeHandler((username, userId) => {
4005
+ if (!isCurrentAdapter())
4006
+ return;
3869
4007
  this.logger.info(`[${adapterId}] Bot @${username} polling started.`);
3870
4008
  const world = this.worlds.get(adapterId);
3871
4009
  if (world) {
@@ -3873,14 +4011,19 @@ export class FleetManager {
3873
4011
  if (userId)
3874
4012
  world.botUserId = userId;
3875
4013
  }
4014
+ onStarted?.();
3876
4015
  }, this.logger, `adapter[${adapterId}].started`));
3877
4016
  adapter.on("new_group_detected", safeHandler(async (data) => {
4017
+ if (!isCurrentAdapter())
4018
+ return;
3878
4019
  const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
3879
4020
  const generalId = this.findGeneralInstance(adapterId);
3880
4021
  if (generalId)
3881
4022
  await this.promptClassicApproval({ generalName: generalId, message: adminMsg, groupId: data.groupId, scope: data.source === "telegram" ? "group" : "guild" });
3882
4023
  }, this.logger, `adapter[${adapterId}].new_group_detected`));
3883
4024
  adapter.on("error", (err) => {
4025
+ if (!isCurrentAdapter())
4026
+ return;
3884
4027
  this.logger.error({ err, adapterId }, "Additional adapter fatal error");
3885
4028
  this.restartAdapter(adapter, adapterId).catch(() => { });
3886
4029
  });
@@ -3889,6 +4032,9 @@ export class FleetManager {
3889
4032
  if (channelConfig.group_id) {
3890
4033
  adapter.setChatId(String(channelConfig.group_id));
3891
4034
  }
4035
+ if (this.discordPresenceTimer && adapter.type === "discord") {
4036
+ void this.refreshDiscordUsagePresence();
4037
+ }
3892
4038
  this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
3893
4039
  }
3894
4040
  /** Connect IPC to a single instance with all handlers */
@@ -4131,6 +4277,9 @@ export class FleetManager {
4131
4277
  // watchdog/manual/error triggers must converge here instead of stop/start.
4132
4278
  await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
4133
4279
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
4280
+ if (this.discordPresenceTimer && adapter.type === "discord") {
4281
+ void this.refreshDiscordUsagePresence();
4282
+ }
4134
4283
  this.logger.info({ id }, "Adapter gateway rebuilt successfully");
4135
4284
  }
4136
4285
  catch (err) {
@@ -4156,6 +4305,9 @@ export class FleetManager {
4156
4305
  await adapter.start();
4157
4306
  this.logger.info({ id, attempt }, "Adapter restarted successfully");
4158
4307
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
4308
+ if (this.discordPresenceTimer && adapter.type === "discord") {
4309
+ void this.refreshDiscordUsagePresence();
4310
+ }
4159
4311
  return;
4160
4312
  }
4161
4313
  catch (err) {
@@ -10692,6 +10844,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10692
10844
  clearInterval(this.logRotateTimer);
10693
10845
  this.logRotateTimer = null;
10694
10846
  }
10847
+ if (this.discordPresenceTimer) {
10848
+ clearInterval(this.discordPresenceTimer);
10849
+ this.discordPresenceTimer = null;
10850
+ }
10695
10851
  // Cancel-button timers were never cleared here. The idle-check interval is not
10696
10852
  // unref'd, so it held the event loop open past shutdown and kept retrying
10697
10853
  // deletes against an adapter that was already gone.
@@ -11325,6 +11481,799 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11325
11481
  get applyJobs() {
11326
11482
  return (this.applyJobStoreCache ??= new ApplyJobStore(this.dataDir, Date.now, this.logger));
11327
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
+ }
11328
12277
  /**
11329
12278
  * What a reconcile is about to do, per target.
11330
12279
  *