@songsid/agend 2.3.0-beta.3 → 2.3.0-beta.4

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 (112) hide show
  1. package/dist/auth-api.d.ts +30 -1
  2. package/dist/auth-api.js +32 -7
  3. package/dist/auth-api.js.map +1 -1
  4. package/dist/backend/types.d.ts +1 -0
  5. package/dist/backend/types.js +15 -0
  6. package/dist/backend/types.js.map +1 -1
  7. package/dist/backend-availability.d.ts +120 -0
  8. package/dist/backend-availability.js +396 -0
  9. package/dist/backend-availability.js.map +1 -0
  10. package/dist/backend-command.d.ts +21 -3
  11. package/dist/backend-command.js +23 -4
  12. package/dist/backend-command.js.map +1 -1
  13. package/dist/channel/adapters/discord.d.ts +7 -0
  14. package/dist/channel/adapters/discord.js +35 -0
  15. package/dist/channel/adapters/discord.js.map +1 -1
  16. package/dist/channel/mcp-tools.js +4 -4
  17. package/dist/channel/mcp-tools.js.map +1 -1
  18. package/dist/channel/types.d.ts +6 -0
  19. package/dist/config-validator.js +73 -2
  20. package/dist/config-validator.js.map +1 -1
  21. package/dist/fleet-context.d.ts +10 -0
  22. package/dist/fleet-manager.d.ts +174 -6
  23. package/dist/fleet-manager.js +642 -41
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/general-knowledge/skills/web-appearance/SKILL.md +17 -7
  26. package/dist/human-delivery-receipts.js +2 -1
  27. package/dist/human-delivery-receipts.js.map +1 -1
  28. package/dist/locale.js +42 -4
  29. package/dist/locale.js.map +1 -1
  30. package/dist/outbound-schemas.d.ts +4 -0
  31. package/dist/outbound-schemas.js +5 -1
  32. package/dist/outbound-schemas.js.map +1 -1
  33. package/dist/public-web-link.d.ts +9 -0
  34. package/dist/public-web-link.js +8 -0
  35. package/dist/public-web-link.js.map +1 -1
  36. package/dist/quickstart-api.d.ts +10 -1
  37. package/dist/quickstart-api.js +12 -4
  38. package/dist/quickstart-api.js.map +1 -1
  39. package/dist/reply-buttons-controller.d.ts +2 -0
  40. package/dist/reply-buttons-controller.js +1 -1
  41. package/dist/reply-buttons-controller.js.map +1 -1
  42. package/dist/topic-commands.d.ts +5 -0
  43. package/dist/topic-commands.js +16 -0
  44. package/dist/topic-commands.js.map +1 -1
  45. package/dist/types.d.ts +24 -0
  46. package/dist/ui/chat-thread.js +4 -1
  47. package/dist/ui/panel-chat.js +4 -3
  48. package/dist/ui/panel-fleet.js +15 -6
  49. package/dist/ui/panel-settings.js +3 -2
  50. package/dist/ui/settings-catalog.js +89 -0
  51. package/dist/ui/settings-dialogs.js +37 -6
  52. package/dist/ui/settings-strings.js +6 -0
  53. package/dist/ui/shared/app.css +36 -3
  54. package/dist/ui/shared/card-strings.js +4 -0
  55. package/dist/ui/shared/effect-stock-trust.js +23 -2
  56. package/dist/ui/shared/identity-card.js +70 -2
  57. package/dist/ui/shared/panel-view.js +22 -2
  58. package/dist/ui/shared/view-strings.js +6 -0
  59. package/dist/view-api.d.ts +11 -0
  60. package/dist/view-api.js +27 -4
  61. package/dist/view-api.js.map +1 -1
  62. package/dist/web-api.d.ts +16 -0
  63. package/dist/web-api.js +133 -22
  64. package/dist/web-api.js.map +1 -1
  65. package/dist/web-appearance-policy.d.ts +4 -6
  66. package/dist/web-appearance-policy.js +5 -12
  67. package/dist/web-appearance-policy.js.map +1 -1
  68. package/dist/web-appearance-tools.js +5 -1
  69. package/dist/web-appearance-tools.js.map +1 -1
  70. package/dist/web-appearance-view.d.ts +4 -1
  71. package/dist/web-appearance-view.js +21 -2
  72. package/dist/web-appearance-view.js.map +1 -1
  73. package/dist/web-appearance.d.ts +2 -2
  74. package/dist/web-appearance.js +5 -6
  75. package/dist/web-appearance.js.map +1 -1
  76. package/dist/web-card.d.ts +14 -0
  77. package/dist/web-card.js +40 -6
  78. package/dist/web-card.js.map +1 -1
  79. package/dist/web-channel-echo.d.ts +7 -1
  80. package/dist/web-channel-echo.js +6 -2
  81. package/dist/web-channel-echo.js.map +1 -1
  82. package/dist/web-chat-history.d.ts +40 -0
  83. package/dist/web-chat-history.js +66 -3
  84. package/dist/web-chat-history.js.map +1 -1
  85. package/dist/web-commands.d.ts +1 -0
  86. package/dist/web-effect-tools.js +2 -3
  87. package/dist/web-effect-tools.js.map +1 -1
  88. package/dist/web-guest-access.d.ts +65 -7
  89. package/dist/web-guest-access.js +124 -18
  90. package/dist/web-guest-access.js.map +1 -1
  91. package/dist/web-guest-rooms.d.ts +82 -0
  92. package/dist/web-guest-rooms.js +135 -0
  93. package/dist/web-guest-rooms.js.map +1 -0
  94. package/dist/web-login.d.ts +47 -0
  95. package/dist/web-login.js +115 -0
  96. package/dist/web-login.js.map +1 -1
  97. package/dist/web-mod-agent.d.ts +1 -1
  98. package/dist/web-mod-agent.js +2 -4
  99. package/dist/web-mod-agent.js.map +1 -1
  100. package/dist/web-returning-device.d.ts +39 -0
  101. package/dist/web-returning-device.js +46 -5
  102. package/dist/web-returning-device.js.map +1 -1
  103. package/dist/web-session.d.ts +43 -3
  104. package/dist/web-session.js +79 -14
  105. package/dist/web-session.js.map +1 -1
  106. package/dist/web-settings-tools.d.ts +12 -0
  107. package/dist/web-settings-tools.js +10 -0
  108. package/dist/web-settings-tools.js.map +1 -1
  109. package/dist/web-theme.d.ts +2 -0
  110. package/dist/web-theme.js +10 -1
  111. package/dist/web-theme.js.map +1 -1
  112. package/package.json +1 -1
@@ -48,6 +48,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
48
48
  import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
49
49
  import { EventLog } from "./event-log.js";
50
50
  import { binaryProbe } from "./binary-probe.js";
51
+ import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
51
52
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
52
53
  import { OutboxOpenError } from "./outbox-open-error.js";
53
54
  import { AdapterWorld } from "./adapter-world.js";
@@ -65,7 +66,7 @@ import { createBackend } from "./backend/factory.js";
65
66
  import { readEffortMetadata } from "./backend/effort-metadata.js";
66
67
  import { codexEffort, flagEffort } from "./backend/launch-effort.js";
67
68
  import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
68
- import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
69
+ import { CLI_ENV_TTL_MS, isModelCompatible, noteResolvedBinary, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
69
70
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
70
71
  import { processAttachments } from "./channel/attachment-handler.js";
71
72
  import { routeToolCall } from "./channel/tool-router.js";
@@ -134,9 +135,9 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
134
135
  }
135
136
  return tasks;
136
137
  }
137
- import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
138
+ import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
138
139
  import { LogService } from "./log-service.js";
139
- import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
140
+ import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
140
141
  import { WebChatDiskStore } from "./web-chat-store.js";
141
142
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
142
143
  import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
@@ -158,7 +159,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
158
159
  import { parsePoll } from "./reply-polls.js";
159
160
  import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
160
161
  import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
161
- import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonIdentityColour } from "./view-api.js";
162
+ import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
162
163
  import { autoIdentityColor } from "./web-identity-color.js";
163
164
  import { AppearanceTools } from "./web-appearance-tools.js";
164
165
  import { cardVersion, resolveCard } from "./web-card.js";
@@ -181,6 +182,7 @@ import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command
181
182
  import { crossInstanceVisibility, runVisibilityCommand } from "./cross-instance-notice.js";
182
183
  import { WebSettingsTools } from "./web-settings-tools.js";
183
184
  import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
185
+ import { GUEST_ROOM_TICK_MS, GuestRoomMembership, guestGroupRooms } from "./web-guest-rooms.js";
184
186
  import { TrafficLog, trafficItem } from "./fleet-traffic.js";
185
187
  import { installedChannel, updateNoticeKey } from "./update-check.js";
186
188
  import { resolveInstalledAgend } from "./update-dispatch.js";
@@ -193,7 +195,7 @@ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
193
195
  import { recentChatContext } from "./log-tail.js";
194
196
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
195
197
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
196
- import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
198
+ import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
197
199
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
198
200
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
199
201
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
@@ -224,8 +226,8 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
224
226
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
225
227
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
226
228
  import { isWebPageNavigation } from "./web-shell-routes.js";
227
- import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
228
- import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
229
+ import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
230
+ import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
229
231
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
230
232
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
231
233
  import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
@@ -238,7 +240,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
238
240
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
239
241
  const SELF_RESTART_DEADLINE_MS = 300_000;
240
242
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
241
- import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
243
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
242
244
  import { carryClaudeConversation } from "./backend/claude-carry.js";
243
245
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
244
246
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
@@ -800,6 +802,9 @@ export class FleetManager {
800
802
  /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
801
803
  fullRestartLauncher = launchFullRestartHelper;
802
804
  eventLogPruneTimer = null;
805
+ /** #1920 §3.2: the background reads of guests' ClassicBot room membership (a minute's tick; reads only what is due). */
806
+ guestRoomsTimer = null;
807
+ guestRoomMembership = null;
803
808
  outboxPruneTimer = null;
804
809
  logRotateTimer = null;
805
810
  discordPresenceTimer = null;
@@ -1111,6 +1116,9 @@ export class FleetManager {
1111
1116
  colour: (name) => personIdentityColour(this.dataDir, name),
1112
1117
  setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
1113
1118
  this.identityChanged(); return w; },
1119
+ bannerFocus: (name) => personBannerFocus(this.dataDir, name),
1120
+ setBannerFocus: (name, focus) => { const w = setPersonBannerFocus(this.dataDir, name, focus); if (w.ok)
1121
+ this.identityChanged(); return w; },
1114
1122
  placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
1115
1123
  announce: () => { try {
1116
1124
  this.emitSseEvent("status", this.getUiStatus());
@@ -1388,9 +1396,26 @@ export class FleetManager {
1388
1396
  sseHeartbeatMs = SSE_HEARTBEAT_MS;
1389
1397
  /** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
1390
1398
  webLoginCodes = null;
1399
+ /**
1400
+ * #1920 step 4: guests' codes, apart from the admins' — one per account, so no guest's `/web` withdraws another's or
1401
+ * an admin's code (#2093 review), at most 32 at once. Local sign-in only for now (audience "local").
1402
+ */
1403
+ webGuestLoginCodes = null;
1404
+ /** #1920 step 4: when each account last got a guest code (monotonic) — one a minute, so `/web` is no DM flood. */
1405
+ guestCodeAskedAt = new Map();
1406
+ /** #2093 review: an account's guest /web in progress — one at a time, so two at once cannot both pass the minute. */
1407
+ guestCodeInFlight = new Set();
1408
+ /**
1409
+ * #2093 review: revokes a guest /web in progress must see — every web session's (`/web revoke` from an admin) bumps
1410
+ * the global count, an account's own bumps its entry — checked before the code is issued and while it is delivered.
1411
+ */
1412
+ guestRevokes = 0;
1413
+ guestAccountRevokes = new Map();
1391
1414
  /** #1570: browsers that may ask the sign-in page for a fresh code, and how often they may. */
1392
1415
  returningDevices = null;
1393
1416
  codeRequestLimiter = new CodeRequestLimiter();
1417
+ /** #1920 step 4b: guests' device code requests, limited apart — guests never use up the admins' fleet-wide budget. */
1418
+ guestCodeRequestLimiter = new CodeRequestLimiter();
1394
1419
  /**
1395
1420
  * Set while a Settings apply job is driving the reconcile. The reconcile
1396
1421
  * stays the single doer; it just says out loud what it is doing to whom, so
@@ -1538,6 +1563,7 @@ export class FleetManager {
1538
1563
  meta: {
1539
1564
  chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
1540
1565
  thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
1566
+ ...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
1541
1567
  ...reactions.meta,
1542
1568
  },
1543
1569
  }, by.humanOrigin, button.label)) !== false;
@@ -1558,6 +1584,9 @@ export class FleetManager {
1558
1584
  this.emitSseEvent("message", {
1559
1585
  instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1560
1586
  click: { label: button.label, of: set.id },
1587
+ // #1920: a platform click is its clicker's; it is the instance's channel's only when its buttons are there (the
1588
+ // same rule as a reply's: a set sent to another topic or a DM is not). A web click is the web's.
1589
+ ...(web ? {} : { from: webChatFrom(set.adapterId, by.userId), ...(this.clickInOwnChannel(set) ? { channel: true } : {}) }),
1561
1590
  });
1562
1591
  return true;
1563
1592
  }
@@ -2439,6 +2468,7 @@ export class FleetManager {
2439
2468
  this.fleetConfig = loaded;
2440
2469
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2441
2470
  this.warnAboutRemovedLoginMode(loaded);
2471
+ this.enforceGuestSwitch();
2442
2472
  return this.fleetConfig;
2443
2473
  }
2444
2474
  relayLoginModeWarned = false;
@@ -3576,6 +3606,15 @@ export class FleetManager {
3576
3606
  * already in flight may still land late — the contract permits that.
3577
3607
  */
3578
3608
  /** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
3609
+ /**
3610
+ * #1920 §5.2: a web message's channel copy was sent — it is now what the channel saw. Kept so (and on disk), and sent
3611
+ * to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
3612
+ */
3613
+ webMessageInChannel(instance, messageId) {
3614
+ const m = this.webChatHistory.markChannel(instance, messageId);
3615
+ if (m)
3616
+ broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
3617
+ }
3579
3618
  async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
3580
3619
  const guard = this.webChannelEchoGuards.get(instance);
3581
3620
  const handled = new Set();
@@ -4011,6 +4050,14 @@ export class FleetManager {
4011
4050
  return;
4012
4051
  }
4013
4052
  const scope = door.scope;
4053
+ // #1920 step 4: a non-admin's /web while guest sign-in is on is theirs to sign in as a guest (or revoke their own)
4054
+ // — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
4055
+ // Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
4056
+ if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
4057
+ await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
4058
+ arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
4059
+ return;
4060
+ }
4014
4061
  const spec = commandSpec(data.command);
4015
4062
  if (!spec) {
4016
4063
  this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
@@ -5455,10 +5502,14 @@ export class FleetManager {
5455
5502
  // #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
5456
5503
  // (shortening ends the sessions it now outlives). The public link's policy stays fixed.
5457
5504
  localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
5505
+ // #1920 §5.4: a guest's, from web.guests.session (never longer than the surface's own).
5506
+ guestPolicy: () => guestSessionPolicy(this.fleetConfig?.web?.guests?.session),
5458
5507
  });
5508
+ this.enforceGuestSwitch(); // #1920: guests off while the fleet was down
5459
5509
  }
5460
5510
  if (!this.returningDevices) {
5461
5511
  this.returningDevices = new ReturningDevices({ dataDir: this.dataDir, onWarn: message => this.logger.warn(message) });
5512
+ this.enforceGuestSwitch(); // #1920: guests' devices too, if guests went off while down
5462
5513
  }
5463
5514
  if (!this.webLoginCodes) {
5464
5515
  this.webLoginCodes = new WebLoginCodes({
@@ -5470,6 +5521,16 @@ export class FleetManager {
5470
5521
  },
5471
5522
  });
5472
5523
  }
5524
+ if (!this.webGuestLoginCodes) {
5525
+ this.webGuestLoginCodes = new GuestLoginCodes({
5526
+ onEvent: event => {
5527
+ if (event === "burned")
5528
+ this.logger.warn("A guest web login code was used up by wrong attempts");
5529
+ else if (event === "breaker-open")
5530
+ this.logger.warn("Guest web sign-in paused: too many wrong login codes");
5531
+ },
5532
+ });
5533
+ }
5473
5534
  }
5474
5535
  /**
5475
5536
  * A single-use login code for the dashboard, for a channel only the operator
@@ -5593,6 +5654,7 @@ export class FleetManager {
5593
5654
  dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
5594
5655
  revoke: id => {
5595
5656
  this.webLoginCodes?.revokeAudience(id);
5657
+ this.webGuestLoginCodes?.revokeAudience(id);
5596
5658
  const result = this.webSessions?.revokeExposure(id);
5597
5659
  if (result && !result.durable)
5598
5660
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
@@ -5624,6 +5686,24 @@ export class FleetManager {
5624
5686
  if (!current() || !this.publicOwnerCurrent(info.owner))
5625
5687
  throw new Error("public owner changed");
5626
5688
  }
5689
+ /**
5690
+ * #1920 §5.5 (step 4b): a guest signing in through the public link — said in the General of the admin who opened it
5691
+ * (who, how many agents), awaited as an admin's public sign-in is, while that link is the open one, its opener still
5692
+ * current, and the account still an admitted guest. A failure refuses the sign-in.
5693
+ */
5694
+ async confirmGuestPublicWebLogin(info, current) {
5695
+ const admitted = () => {
5696
+ const open = this.publicWebLink?.openSignin();
5697
+ return current() && open && open.exposureId === info.exposureId && this.publicOwnerCurrent(open.owner) && this.guestSignInAllowed(info.account) ? open : null;
5698
+ };
5699
+ const open = admitted();
5700
+ if (!open)
5701
+ throw new Error("guest public sign-in unavailable");
5702
+ const adapter = open.owner.binding;
5703
+ await adapter.sendText(open.owner.chatId, t("web.guest_public_login_notice", this.webPerson(info.account).name, info.label, this.guestScope(info.account).size, info.handle.slice(0, 8)), { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
5704
+ if (!admitted())
5705
+ throw new Error("guest public sign-in changed");
5706
+ }
5627
5707
  /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
5628
5708
  async dashboardMenu(msg) {
5629
5709
  const id = msg.adapterId;
@@ -5947,6 +6027,8 @@ export class FleetManager {
5947
6027
  this.initializeWebSessions();
5948
6028
  void this.publicWebLink?.close("dashboard revoke");
5949
6029
  this.webLoginCodes.revoke();
6030
+ this.webGuestLoginCodes.revokeAll();
6031
+ this.guestRevokes++; // and every guest /web still on its way
5950
6032
  // #1570: no browser may ask the sign-in page for a code any more.
5951
6033
  const devices = this.returningDevices.revokeAll();
5952
6034
  const sessions = this.webSessions.revokeAll();
@@ -5957,6 +6039,145 @@ export class FleetManager {
5957
6039
  this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
5958
6040
  return result;
5959
6041
  }
6042
+ /** #1920 step 4: whether a `/web` is a guest's — guest sign-in on, and not from a fleet admin (theirs runs as before). */
6043
+ isGuestWebAsk(adapterId, userId) {
6044
+ return !!adapterId && this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(userId, adapterId);
6045
+ }
6046
+ /** #1920 step 4 (auth-api): a guest code's account may still sign in — guests on, and something in its scope now. */
6047
+ guestSignInAllowed(account) {
6048
+ return this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(account.userId, account.adapterId) && this.guestScope(account).size > 0;
6049
+ }
6050
+ /**
6051
+ * #1920 §5.4: a guest's own `/web revoke` — its sessions and its outstanding code; nobody else's (an admin's revokes
6052
+ * everyone's, as before).
6053
+ */
6054
+ revokeGuestWebSessions(owner) {
6055
+ this.initializeWebSessions();
6056
+ const key = `${owner.adapterId}\u0000${owner.userId}`;
6057
+ this.guestAccountRevokes.set(key, (this.guestAccountRevokes.get(key) ?? 0) + 1); // its /web on its way, too
6058
+ while (this.guestAccountRevokes.size > 4096)
6059
+ this.guestAccountRevokes.delete(this.guestAccountRevokes.keys().next().value);
6060
+ this.webGuestLoginCodes.revokeRecipient(owner);
6061
+ // §5.4: its sessions and its returning devices (no browser of theirs may ask for a code any more).
6062
+ const devices = this.returningDevices.revokeGuest(owner);
6063
+ const ended = this.webSessions.revokeAll(r => r.tier === "guest" && r.owner?.adapterId === owner.adapterId && r.owner.userId === owner.userId);
6064
+ this.logger.info({ adapterId: owner.adapterId, count: ended.count, devices: devices.count, durable: ended.durable && devices.durable }, "Guest web sessions revoked by their account");
6065
+ return { count: ended.count, durable: ended.durable && devices.durable };
6066
+ }
6067
+ /**
6068
+ * #1920 §2.3 (step 4): `/web` from someone who is not a fleet admin, while guest sign-in is on. `revoke` ends their
6069
+ * own guest sessions. Otherwise a guest code (tier guest, bound to this account) goes privately — a DM, else the
6070
+ * slash command's ephemeral answer (`replyPrivately`) — once a minute at most, and only when their scope (from what
6071
+ * they may already use in chat, rooms read at sign-in) holds something. The code itself is never posted where asked.
6072
+ * Callers ask isGuestWebAsk first (synchronously); false here too when it is not a guest's.
6073
+ */
6074
+ async guestWebCommand(ask) {
6075
+ if (!this.isGuestWebAsk(ask.adapterId, ask.userId))
6076
+ return false;
6077
+ const owner = { adapterId: ask.adapterId, userId: ask.userId };
6078
+ const say = (text) => ask.replyHere(text).catch(() => undefined);
6079
+ if (ask.arg === "revoke") {
6080
+ const ended = this.revokeGuestWebSessions(owner);
6081
+ await say(t(ended.durable ? "web.guest_revoked" : "web.guest_revoked_not_durable", ended.count));
6082
+ return true;
6083
+ }
6084
+ const token = this.webToken;
6085
+ const adapter = this.adapters.get(ask.adapterId);
6086
+ if (this.shuttingDown || !token || !adapter) {
6087
+ await say(t("dashboard.starting"));
6088
+ return true;
6089
+ }
6090
+ const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
6091
+ if (this.guestCodeInFlight.has(key) || (asked !== undefined && performance.now() - asked < 60_000)) {
6092
+ await say(t("web.guest_wait"));
6093
+ return true;
6094
+ }
6095
+ this.guestCodeInFlight.add(key);
6096
+ try {
6097
+ await this.issueGuestCode(ask, owner, key, adapter, token);
6098
+ }
6099
+ finally {
6100
+ this.guestCodeInFlight.delete(key);
6101
+ }
6102
+ return true;
6103
+ }
6104
+ /** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
6105
+ async issueGuestCode(ask, owner, key, adapter, token) {
6106
+ const say = (text) => ask.replyHere(text).catch(() => undefined);
6107
+ const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6108
+ const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
6109
+ // §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
6110
+ await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
6111
+ if (!unrevoked())
6112
+ return; // revoked while its rooms were read: no code
6113
+ const scope = this.guestScope(owner);
6114
+ if (!scope.size) {
6115
+ const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
6116
+ await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
6117
+ return;
6118
+ }
6119
+ this.initializeWebSessions();
6120
+ const codes = this.webGuestLoginCodes;
6121
+ if (codes.pausedForMs() > 0) {
6122
+ await say(t("web.guest_paused"));
6123
+ return;
6124
+ }
6125
+ // §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
6126
+ // guest there) — most guests are not on the host's network. Its closing withdraws that audience (revokeAudience).
6127
+ const open = this.publicWebLink?.openSignin() ?? null;
6128
+ // One code per account (a new one replaces only its own); at the cap, refused — never another guest's evicted.
6129
+ const login = codes.issue({ epoch: tokenEpoch(token), audiences: ["local", ...(open ? [open.exposureId] : [])],
6130
+ recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
6131
+ if (!login) {
6132
+ await say(t("web.guest_busy"));
6133
+ return;
6134
+ }
6135
+ this.guestCodeAskedAt.delete(key);
6136
+ this.guestCodeAskedAt.set(key, performance.now());
6137
+ while (this.guestCodeAskedAt.size > 256)
6138
+ this.guestCodeAskedAt.delete(this.guestCodeAskedAt.keys().next().value);
6139
+ const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(owner.adapterId) === adapter
6140
+ && unrevoked() && this.guestSignInAllowed(owner);
6141
+ const privateText = `${t("web.guest_intro", scope.size)}\n${this.topicCommands.getDashboardText()}${open ? `\n${t("web.guest_public", open.url)}` : ""}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
6142
+ const deadline = performance.now() + 10_000;
6143
+ try {
6144
+ let byDm = false;
6145
+ try {
6146
+ if (!adapter.sendDirect)
6147
+ throw new Error("DM unavailable");
6148
+ await withinBudget(adapter.sendDirect(owner.userId, `${privateText}\n${t("dashboard.code_next")}`, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
6149
+ byDm = true;
6150
+ }
6151
+ catch (err) {
6152
+ this.logDashboardDmFailure(err);
6153
+ if (!ask.replyPrivately || !current())
6154
+ throw err;
6155
+ const id = await withinBudget(ask.replyPrivately(privateText), deadline); // a slash command's answer: ephemeral
6156
+ if (!id)
6157
+ throw new Error("private delivery unconfirmed");
6158
+ }
6159
+ if (!current())
6160
+ throw new Error("guest no longer admitted");
6161
+ if (byDm) {
6162
+ await this.sendCodeAlone((body, opts) => adapter.sendDirect(owner.userId, body, opts), adapter.type, login.display);
6163
+ if (!current())
6164
+ throw new Error("guest no longer admitted");
6165
+ await say(t("web.guest_sent"));
6166
+ }
6167
+ }
6168
+ catch (err) {
6169
+ codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
6170
+ this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
6171
+ if (!unrevoked())
6172
+ return; // revoked meanwhile: the revoke's own answer said so
6173
+ const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
6174
+ // #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
6175
+ // the DM — once, within the window.
6176
+ if (adapter.type === "telegram")
6177
+ this.openTgWebStartWindow(owner.adapterId, owner.userId);
6178
+ await say(start ? t("web.guest_dm_failed_link", start) : t("web.guest_dm_failed"));
6179
+ }
6180
+ }
5960
6181
  /** #1570 (auth-api): this browser becomes a returning device of the code's owner. The cookie value, or null. */
5961
6182
  rememberReturningDevice(owner, input) {
5962
6183
  const token = this.webToken;
@@ -5984,6 +6205,12 @@ export class FleetManager {
5984
6205
  if (!token || this.shuttingDown)
5985
6206
  return { kind: "refused" };
5986
6207
  this.initializeWebSessions();
6208
+ // #2095 review: verified before the request's body was read — a revoke (its own, or an admin's) made meanwhile
6209
+ // removed it, and nothing it asks for may then go out.
6210
+ if (!this.returningDevices.current(device, tokenEpoch(token)))
6211
+ return { kind: "refused" };
6212
+ if (device.tier === "guest")
6213
+ return this.requestGuestDeviceCode(device, input, token);
5987
6214
  const paused = this.webLoginCodes.pausedForMs();
5988
6215
  if (paused > 0)
5989
6216
  return { kind: "limited", retryAfterMs: paused };
@@ -6015,7 +6242,7 @@ export class FleetManager {
6015
6242
  this.logger.info({ err: err?.message, adapterId }, "Sign-in code request: the private message could not be sent");
6016
6243
  return { kind: "refused" };
6017
6244
  }
6018
- if (this.webToken !== token || !this.publicOwnerCurrent(owner)) {
6245
+ if (this.webToken !== token || !this.publicOwnerCurrent(owner) || !this.returningDevices.current(device, tokenEpoch(token))) {
6019
6246
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
6020
6247
  return { kind: "refused" };
6021
6248
  }
@@ -6023,15 +6250,94 @@ export class FleetManager {
6023
6250
  // Asked again after the follow-up's wait: the owner, the token, shutdown, and a public request's exposure.
6024
6251
  const exposureGone = input.surface === "gateway"
6025
6252
  && (this.publicWebLink?.exposureId !== input.exposureId || this.publicWebLink?.status().state !== "open");
6026
- if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone) {
6253
+ if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone || !this.returningDevices.current(device, tokenEpoch(token))) {
6027
6254
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
6028
6255
  return { kind: "refused" };
6029
6256
  }
6030
6257
  this.logger.info({ adapterId, surface: input.surface }, "Web sign-in code sent privately to a returning device's owner");
6031
6258
  return { kind: "sent" };
6032
6259
  }
6260
+ /**
6261
+ * #1920 step 4b: a guest's returning device asks for a code — a guest code for that account, by DM only, under the
6262
+ * same rules as its /web (guests on, not an admin, something in scope now; one per account and the cap; one flow per
6263
+ * account at a time; a revoke meanwhile ends it), its own limiter, and the guests' breaker. Asked through the public
6264
+ * link: only while that exposure is the open one (its code then works through it alone), as an admin's device.
6265
+ */
6266
+ async requestGuestDeviceCode(device, input, token) {
6267
+ const codes = this.webGuestLoginCodes;
6268
+ const account = { adapterId: device.owner.adapterId, userId: device.owner.userId };
6269
+ const key = `${account.adapterId}\u0000${account.userId}`;
6270
+ const adapter = this.adapters.get(account.adapterId);
6271
+ const viaLink = input.surface === "gateway";
6272
+ const linkOpen = () => !!input.exposureId && this.publicWebLink?.openSignin()?.exposureId === input.exposureId;
6273
+ if ((viaLink ? !linkOpen() : input.exposureId !== undefined) || !adapter?.sendDirect || !this.guestSignInAllowed(account)) {
6274
+ this.logger.info({ adapterId: account.adapterId, surface: input.surface }, "Guest sign-in code request refused");
6275
+ return { kind: "refused" };
6276
+ }
6277
+ const paused = codes.pausedForMs();
6278
+ if (paused > 0)
6279
+ return { kind: "limited", retryAfterMs: paused };
6280
+ const asked = this.guestCodeAskedAt.get(key);
6281
+ if (this.guestCodeInFlight.has(key))
6282
+ return { kind: "limited", retryAfterMs: 5_000 };
6283
+ if (asked !== undefined && performance.now() - asked < 60_000)
6284
+ return { kind: "limited", retryAfterMs: 60_000 - (performance.now() - asked) };
6285
+ const admitted = this.guestCodeRequestLimiter.admit(device.idHash);
6286
+ if (!admitted.ok)
6287
+ return { kind: "limited", retryAfterMs: admitted.retryAfterMs };
6288
+ const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6289
+ const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(account.adapterId) === adapter
6290
+ && this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && this.guestSignInAllowed(account)
6291
+ && (!viaLink || linkOpen()) && this.returningDevices.current(device, tokenEpoch(token));
6292
+ const issued = codes.issue({ epoch: tokenEpoch(token), recipient: account, audiences: [viaLink ? input.exposureId : "local"] });
6293
+ if (!issued)
6294
+ return { kind: "limited", retryAfterMs: 60_000 }; // the guests' cap: never someone's evicted
6295
+ this.guestCodeInFlight.add(key);
6296
+ this.guestCodeAskedAt.delete(key);
6297
+ this.guestCodeAskedAt.set(key, performance.now());
6298
+ try {
6299
+ const text = t("web.resend_code", issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), input.label || t("web.resend_unknown_browser"), t(viaLink ? "web.resend_public" : "web.resend_local"))
6300
+ + "\n" + t("dashboard.code_next");
6301
+ try {
6302
+ await withinBudget(adapter.sendDirect(account.userId, text, { disablePreview: true }), performance.now() + 5_000);
6303
+ }
6304
+ catch (err) {
6305
+ codes.revokeIfCurrent(issued.issuanceId);
6306
+ this.logger.info({ err: err?.message, adapterId: account.adapterId }, "Guest sign-in code request: the private message could not be sent");
6307
+ return { kind: "refused" };
6308
+ }
6309
+ if (!current()) {
6310
+ codes.revokeIfCurrent(issued.issuanceId);
6311
+ return { kind: "refused" };
6312
+ }
6313
+ await this.sendCodeAlone((body, opts) => adapter.sendDirect(account.userId, body, opts), adapter.type, issued.display);
6314
+ if (!current()) {
6315
+ codes.revokeIfCurrent(issued.issuanceId);
6316
+ return { kind: "refused" };
6317
+ }
6318
+ this.logger.info({ adapterId: account.adapterId }, "Guest sign-in code sent privately to a returning device's account");
6319
+ return { kind: "sent" };
6320
+ }
6321
+ finally {
6322
+ this.guestCodeInFlight.delete(key);
6323
+ }
6324
+ }
6325
+ /** #1920 step 4b (auth-api): this browser becomes a guest's returning device. The cookie value, or null. */
6326
+ rememberGuestDevice(account, input) {
6327
+ const token = this.webToken;
6328
+ if (!token)
6329
+ return null;
6330
+ this.initializeWebSessions();
6331
+ return this.returningDevices.rememberGuest(account, { ...input, tokenEpoch: tokenEpoch(token), webToken: token });
6332
+ }
6033
6333
  /** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
6034
6334
  onWebLogin(info) {
6335
+ // #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
6336
+ // and how many agents they now reach — admins stay aware of everyone who is not one of them.
6337
+ if (info.tier === "guest" && info.owner) {
6338
+ this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
6339
+ return;
6340
+ }
6035
6341
  if (this.fleetConfig?.web?.notify_login === false)
6036
6342
  return;
6037
6343
  // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
@@ -6396,6 +6702,8 @@ export class FleetManager {
6396
6702
  TmuxManager.setSocketName(getSocket());
6397
6703
  await TmuxManager.ensureSession(getTmuxSession());
6398
6704
  this.tmuxSessionEnsured = true;
6705
+ // #2072: what this host can run — probed in the background, published for every picker.
6706
+ this.startAvailabilityRefresh();
6399
6707
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
6400
6708
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
6401
6709
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
@@ -6719,6 +7027,11 @@ export class FleetManager {
6719
7027
  this.pruneEventLog();
6720
7028
  this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
6721
7029
  this.eventLogPruneTimer.unref?.();
7030
+ // #1920 §3.2: guests' ClassicBot room membership, read in the background — now, then each key ten minutes after
7031
+ // its answer (a minute after a failed read; the tick only finds what is due). A request only reads the answers.
7032
+ void this.refreshAllGuestRooms();
7033
+ this.guestRoomsTimer = setInterval(() => { void this.refreshAllGuestRooms(); }, GUEST_ROOM_TICK_MS);
7034
+ this.guestRoomsTimer.unref?.();
6722
7035
  // #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
6723
7036
  // + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
6724
7037
  void this.pruneOutboxAndTasks();
@@ -8371,6 +8684,13 @@ export class FleetManager {
8371
8684
  if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
8372
8685
  return; // bare slash in group — ignore silently
8373
8686
  }
8687
+ // #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
8688
+ // while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
8689
+ if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
8690
+ await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
8691
+ arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
8692
+ return;
8693
+ }
8374
8694
  // Handle /start command
8375
8695
  if (text === "/start" || text.startsWith("/start ")) {
8376
8696
  // #1686: intercept /start web before the ClassicBot admission flow runs.
@@ -8379,15 +8699,21 @@ export class FleetManager {
8379
8699
  // #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
8380
8700
  // (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
8381
8701
  const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
8702
+ // #1920 step 4b-3: a guest's too (guest sign-in on, not an admin) — its /web reached it no other way.
8703
+ const guest = isPrivateChat && !admin && this.isGuestWebAsk(msg.adapterId, msg.userId);
8382
8704
  const webStart = isPrivateChat && (requestedBackend === "web"
8383
- || (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
8705
+ || (text === "/start" && (admin || guest) && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
8384
8706
  if (webStart) {
8385
8707
  if (requestedBackend === "web")
8386
8708
  this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
8387
8709
  // #1742: the /web menu right here; the code comes to this DM.
8388
8710
  if (admin)
8389
8711
  await this.startWebInDm(msg, msgAdapter ?? undefined);
8390
- // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
8712
+ // A guest: its guest code, here (this DM is where it goes).
8713
+ else if (guest && msgAdapter)
8714
+ await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username, arg: "",
8715
+ replyHere: body => msgAdapter.sendText(chatId, body) });
8716
+ // Anyone else: /start alone is enough to open the DM channel; no further action.
8391
8717
  return;
8392
8718
  }
8393
8719
  // All new starts, including explicit backend and later chooser callbacks,
@@ -8691,10 +9017,7 @@ export class FleetManager {
8691
9017
  this.afterUserInboundDelivered(generalInstance, msg);
8692
9018
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
8693
9019
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
8694
- this.emitSseEvent("message", {
8695
- instance: generalInstance, sender: msg.username, role: "user",
8696
- text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
8697
- });
9020
+ this.emitPlatformInbound(generalInstance, msg, text ?? "");
8698
9021
  }
8699
9022
  catch (err) {
8700
9023
  this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
@@ -8803,9 +9126,16 @@ export class FleetManager {
8803
9126
  this.afterUserInboundDelivered(instanceName, msg);
8804
9127
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
8805
9128
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
9129
+ this.emitPlatformInbound(instanceName, msg, text ?? "");
9130
+ }
9131
+ /**
9132
+ * A person's platform message as the web chat shows it — the user's own words, as a user (#1306), in the instance's
9133
+ * history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
9134
+ */
9135
+ emitPlatformInbound(instance, msg, text) {
8806
9136
  this.emitSseEvent("message", {
8807
- instance: instanceName, sender: msg.username, role: "user",
8808
- text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9137
+ instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9138
+ channel: true, from: webChatFrom(msg.adapterId, msg.userId),
8809
9139
  });
8810
9140
  }
8811
9141
  /** Handle outbound tool calls from a daemon instance */
@@ -9107,7 +9437,14 @@ export class FleetManager {
9107
9437
  // an agent cannot invent it to suppress the normal completion marker.
9108
9438
  if (!error && result != null && msg.statusOnly !== true) {
9109
9439
  try {
9110
- void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
9440
+ // #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
9441
+ let inChannel = false;
9442
+ try {
9443
+ inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
9444
+ && this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
9445
+ }
9446
+ catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
9447
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
9111
9448
  }
9112
9449
  catch (err) {
9113
9450
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -9161,7 +9498,35 @@ export class FleetManager {
9161
9498
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
9162
9499
  }
9163
9500
  /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
9164
- afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
9501
+ /**
9502
+ * #1920 §5.2: whether a platform send landed in the instance's own place — its ClassicBot room, or its topic in its
9503
+ * connection's group — so its channel's members saw it. Anything else (another chat, a DM, a context left from General,
9504
+ * an answer without the chat it went to) is not the channel's.
9505
+ */
9506
+ landedInOwnChannel(instance, adapterId, sent) {
9507
+ const s = sent && typeof sent === "object" ? sent : {};
9508
+ if (!adapterId || typeof s.chatId !== "string" || !s.chatId)
9509
+ return false;
9510
+ const roomId = this.classicChannels?.getChannelIdByInstance(instance);
9511
+ if (roomId !== undefined)
9512
+ return (this.classicChannels?.getAdapterIdByInstance(instance) ?? this.getPrimaryAdapterId()) === adapterId && roomId === s.chatId;
9513
+ const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
9514
+ const world = this.worlds.get(adapterId);
9515
+ if (!cfg || this.getInstanceAdapterId(instance) !== adapterId || !world?.groupId || s.chatId !== world.groupId)
9516
+ return false;
9517
+ const thread = typeof s.threadId === "string" && s.threadId ? s.threadId : null;
9518
+ return cfg.topic_id == null ? thread === null : thread === String(cfg.topic_id);
9519
+ }
9520
+ /** #1920 §5.2 (#2085 review): a platform click's buttons are in the instance's own place — else not its channel's. */
9521
+ clickInOwnChannel(set) {
9522
+ try {
9523
+ return this.landedInOwnChannel(set.instance, set.adapterId, { chatId: set.chatId, threadId: set.threadId || undefined });
9524
+ }
9525
+ catch {
9526
+ return false;
9527
+ }
9528
+ }
9529
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
9165
9530
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
9166
9531
  // more work either. Split the difference: an instance that is clearly
9167
9532
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -9197,6 +9562,7 @@ export class FleetManager {
9197
9562
  ts,
9198
9563
  ...(attachments.length ? { attachments } : {}),
9199
9564
  ...(buttons ? { buttons } : {}), // #1266
9565
+ ...(inChannel ? { channel: true } : {}), // #1920 §5.2
9200
9566
  });
9201
9567
  };
9202
9568
  const before = this.webReplyOrder.get(instanceName);
@@ -10450,6 +10816,7 @@ export class FleetManager {
10450
10816
  */
10451
10817
  saveFleetConfig(explicitPatches = []) {
10452
10818
  this.publicWebLink?.refresh();
10819
+ this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
10453
10820
  if (!this.fleetConfig || !this.configPath)
10454
10821
  return;
10455
10822
  if (!this.savedFleetConfigSnapshot)
@@ -13217,7 +13584,7 @@ export class FleetManager {
13217
13584
  return step.noop ? { text: step.text } : { error: step.text };
13218
13585
  if (step.kind === "choose") {
13219
13586
  const id = (token) => joinBackendArgs([...step.args, token]);
13220
- return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
13587
+ return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label, ...(c.disabled ? { disabled: c.disabled } : {}) })) } };
13221
13588
  }
13222
13589
  const now = performance.now();
13223
13590
  for (const [k, v] of this.webBackendTokens)
@@ -14355,7 +14722,12 @@ export class FleetManager {
14355
14722
  fleetConfig: () => this.fleetConfig,
14356
14723
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
14357
14724
  eventLog: () => this.eventLog,
14358
- recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
14725
+ recoverBackendInstances: (backend, profile) => {
14726
+ // #2072: a sign-in just finished — the pickers learn it now, not at the next pass.
14727
+ if (!profile)
14728
+ void this.backendAvailability.refresh([backend], { force: true });
14729
+ return this.recoverBackendInstances(backend, undefined, profile ?? null);
14730
+ },
14359
14731
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
14360
14732
  releaseWindow: claim => { this.loginWindow.release(claim); },
14361
14733
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -14874,6 +15246,8 @@ export class FleetManager {
14874
15246
  // binary the same way, so a binary only a login shell could see was
14875
15247
  // installed, reported as verified, and then offered nowhere.
14876
15248
  this.adoptBinaryDirectory(installedAt, backend);
15249
+ // #2072: an install just finished — the pickers learn it now, not at the next pass.
15250
+ void this.backendAvailability.refresh([backend], { force: true });
14877
15251
  if (!LOGIN_FLOWS[backend]) {
14878
15252
  this.installHandoff = null;
14879
15253
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
@@ -15108,6 +15482,8 @@ export class FleetManager {
15108
15482
  buttons: m.buttons,
15109
15483
  click: m.click,
15110
15484
  links: m.links,
15485
+ channel: m.channel,
15486
+ from: m.from,
15111
15487
  });
15112
15488
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
15113
15489
  // #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
@@ -15908,10 +16284,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15908
16284
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
15909
16285
  // #1592: the web chat shows what was asked, as for every other inbound — the user's own words (no chat-log
15910
16286
  // context), as a user (#1306: never a role read from text), in the room's history (#1565).
15911
- this.emitSseEvent("message", {
15912
- instance: instanceName, sender: msg.username, role: "user",
15913
- text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
15914
- });
16287
+ this.emitPlatformInbound(instanceName, msg, text);
15915
16288
  }
15916
16289
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
15917
16290
  /** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
@@ -17148,7 +17521,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17148
17521
  return {
17149
17522
  currentBackend: (name) => this.backendNameForInstance(name),
17150
17523
  currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
17151
- installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
17524
+ availability: () => this.backendAvailabilityFor(instanceName),
17152
17525
  models: (backend) => this.backendModelOptions(backend, opts),
17153
17526
  // #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
17154
17527
  modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
@@ -17247,10 +17620,76 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17247
17620
  // kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
17248
17621
  return () => true;
17249
17622
  }
17250
- /** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
17251
- backendInstalledHere(backend) {
17252
- const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
17253
- return !!binary && resolveBinary(binary) !== binary;
17623
+ /**
17624
+ * #2072: what this host can run, from the published availability snapshot (backend-availability.ts). Read only: a
17625
+ * handler never starts a probe, not even in the background — launch, the timer, and /login install or sign-in do.
17626
+ */
17627
+ backendAvailabilityRows() {
17628
+ return this.backendAvailability.snapshot();
17629
+ }
17630
+ /**
17631
+ * #2072 part 3: the models a Settings field offers for `backend` — the cached catalog (an instance's own credential
17632
+ * profile's, when it is named and has one), claude's account catalog included. Read only: never a probe.
17633
+ */
17634
+ modelCatalogFor(backend, instance) {
17635
+ if (!KNOWN_BACKENDS.includes(backend) || backend === "mock")
17636
+ return [];
17637
+ const own = instance && Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, instance) ? instance
17638
+ : instance && this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? instance : null;
17639
+ const env = this.readCliEnv(backend, this.catalogProfile(backend, own));
17640
+ const seen = new Set(), out = [];
17641
+ for (const m of [...(env?.models ?? []), ...(env?.apiModels ?? [])])
17642
+ if (!seen.has(m.id)) {
17643
+ seen.add(m.id);
17644
+ out.push(m);
17645
+ }
17646
+ return out;
17647
+ }
17648
+ /**
17649
+ * #2072 (#2078 review): the snapshot as THIS instance would see it. Its sign-in on a backend is its own credential
17650
+ * profile's when it has one there — the host's default login says nothing about it. A profile's login is a file check
17651
+ * (credentialProfileLogin), no process.
17652
+ */
17653
+ backendAvailabilityFor(instanceName) {
17654
+ return this.backendAvailabilityRows().map(row => {
17655
+ const profile = this.credentialProfileOf(instanceName, row.backend);
17656
+ if (!profile || (row.state !== "usable" && row.state !== "not_signed_in" && row.state !== "unknown") || !row.path)
17657
+ return row;
17658
+ let login = "unknown";
17659
+ try {
17660
+ login = credentialProfileLogin(this.dataDir, row.backend, profile).state;
17661
+ }
17662
+ catch { /* unknown */ }
17663
+ // Signed in there: usable for this instance. Signed out (or unknown): the host's default login says nothing about
17664
+ // it, so no "not signed in" claim from it — update_instance_config's profile gate refuses a signed-out profile
17665
+ // when applied, naming the profile and the command that signs it in.
17666
+ if (login === "logged-in")
17667
+ return { ...row, auth: "valid", state: "usable" };
17668
+ return row.state === "not_signed_in" ? { ...row, auth: "unknown", state: "unknown" } : row;
17669
+ });
17670
+ }
17671
+ /** The probes the snapshot runs (tests replace them: no CLI, no account, no network). */
17672
+ availabilityProbes = hostAvailabilityProbes;
17673
+ availabilityStore = null;
17674
+ availabilityTimer = null;
17675
+ get backendAvailability() {
17676
+ return this.availabilityStore ??= new BackendAvailabilityStore(KNOWN_BACKENDS.filter(b => b !== "mock" && !!BACKEND_INSTALLATION_INFO[b]), this.availabilityProbes, { onChange: () => this.noteAvailabilityPaths() });
17677
+ }
17678
+ /** A launch need not look the binary up again (a synchronous `which` on the loop) when the snapshot found it. */
17679
+ noteAvailabilityPaths() {
17680
+ for (const row of this.availabilityStore?.snapshot() ?? []) {
17681
+ const binary = BACKEND_INSTALLATION_INFO[row.backend]?.binary;
17682
+ if (binary)
17683
+ noteResolvedBinary(binary, row.state === "not_installed" ? null : row.path);
17684
+ }
17685
+ }
17686
+ /** Probe now and then every AVAILABILITY_STALE_MS; a pass that finds nothing changed starts no CLI. */
17687
+ startAvailabilityRefresh() {
17688
+ void this.backendAvailability.refresh();
17689
+ if (this.availabilityTimer)
17690
+ clearInterval(this.availabilityTimer);
17691
+ this.availabilityTimer = setInterval(() => { void this.backendAvailability.refresh(); }, AVAILABILITY_STALE_MS);
17692
+ this.availabilityTimer.unref?.();
17254
17693
  }
17255
17694
  /** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
17256
17695
  async backendModelOptions(backend, opts) {
@@ -17592,9 +18031,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17592
18031
  }
17593
18032
  return undefined;
17594
18033
  }
18034
+ /** #2072: from the published snapshot (no `which` on the loop); not checked yet counts as installed (no false alarm). */
17595
18035
  isBackendInstalled(backend) {
17596
- const installation = BACKEND_INSTALLATION_INFO[backend];
17597
- return !!installation && checkBinaryInstalled(installation.binary);
18036
+ if (!BACKEND_INSTALLATION_INFO[backend])
18037
+ return false;
18038
+ return this.backendAvailability.get(backend)?.state !== "not_installed";
17598
18039
  }
17599
18040
  getMissingBackendWarning(backend) {
17600
18041
  if (!backend)
@@ -17932,6 +18373,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17932
18373
  clearInterval(this.eventLogPruneTimer);
17933
18374
  this.eventLogPruneTimer = null;
17934
18375
  }
18376
+ if (this.guestRoomsTimer) {
18377
+ clearInterval(this.guestRoomsTimer);
18378
+ this.guestRoomsTimer = null;
18379
+ }
17935
18380
  this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
17936
18381
  this.compactWatchService?.stopAll();
17937
18382
  if (this.outboxPruneTimer) {
@@ -18031,6 +18476,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18031
18476
  this.adapters.clear();
18032
18477
  this.controlClient?.stop();
18033
18478
  this.controlClient = null;
18479
+ if (this.availabilityTimer) {
18480
+ clearInterval(this.availabilityTimer);
18481
+ this.availabilityTimer = null;
18482
+ }
18034
18483
  if (this.healthServer) {
18035
18484
  this.healthServerListening = false;
18036
18485
  const server = this.healthServer;
@@ -20016,11 +20465,145 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20016
20465
  this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
20017
20466
  }
20018
20467
  /**
20019
- * #1920: the instances a guest session may see — derived on every request, never stored. Phase 2 derives it from
20020
- * the account's chat access (and an admin's naming for an open connection); until then a guest sees nothing.
20468
+ * #1920 §3: the instances a guest session may see — derived on every request from the account's chat access, never
20469
+ * stored; nothing while `web.guests.enabled` is not true.
20470
+ * - Its connection's fleet topics and General (what it may talk to in chat): when that connection's access lets the
20471
+ * account in (AccessManager.isAllowed — allowed_users or paired). An `open` connection lets everyone in, so it gives
20472
+ * only the instances an admin named for its guests (`web.guests.open_instances`; the user's decision: least
20473
+ * privilege, as an open group has no list of people).
20474
+ * - A Telegram private ClassicBot room that is the account's own (its chat id is the user id).
20475
+ * - A ClassicBot group or guild room on its connection: only while its membership is verified (§3.2 — a "member"
20476
+ * answer read in the background, at most 15 minutes old; unknown, failed or stale hides it).
20477
+ * - Then an admin's narrowing (`web.guests.narrow`), which only takes away.
20478
+ * A fleet admin is not a guest: an admin outside the connection's access gets nothing here (admins sign in as admins).
20479
+ */
20480
+ guestScope(owner) {
20481
+ const guests = this.fleetConfig?.web?.guests;
20482
+ if (!owner || guests?.enabled !== true)
20483
+ return NO_GUEST_SCOPE;
20484
+ const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
20485
+ const scope = new Set();
20486
+ const world = this.worlds.get(owner.adapterId);
20487
+ if (world && world.accessManager.isAllowed(owner.userId)) {
20488
+ const named = world.accessManager.getMode() === "open" ? new Set(own(guests.open_instances, owner.adapterId) ?? []) : null;
20489
+ for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
20490
+ if (this.getInstanceAdapterId(name) !== owner.adapterId || this.isClassicInstance(name))
20491
+ continue;
20492
+ if (!named || named.has(name))
20493
+ scope.add(name);
20494
+ }
20495
+ }
20496
+ const rooms = this.classicChannels?.getAll() ?? [];
20497
+ for (const room of rooms) {
20498
+ if (this.isOwnPrivateRoom(owner, room))
20499
+ scope.add(room.instanceName);
20500
+ }
20501
+ const members = this.guestRooms();
20502
+ for (const room of guestGroupRooms(rooms, owner)) {
20503
+ if (members.verified(room, owner.userId))
20504
+ scope.add(room.instance);
20505
+ }
20506
+ const narrow = own(guests.narrow, `${owner.adapterId}:${owner.userId}`);
20507
+ if (narrow) {
20508
+ const keep = new Set(narrow);
20509
+ for (const name of [...scope])
20510
+ if (!keep.has(name))
20511
+ scope.delete(name);
20512
+ }
20513
+ return scope.size ? scope : NO_GUEST_SCOPE;
20514
+ }
20515
+ /**
20516
+ * #1920 §3.5: a guest session still admitted — current, with something in scope. A scope that became empty (guest
20517
+ * sign-in off, the account removed from the connection's access, everything narrowed away) ends the session here,
20518
+ * so the next request and the open stream find it gone.
20519
+ */
20520
+ guestStillAdmitted(record) {
20521
+ return this.guestAdmission(record) === "ok";
20522
+ }
20523
+ /**
20524
+ * The same, with a third answer (§3.2): "pending" — nothing in scope now, but a room's membership is not read yet
20525
+ * (a restart, a room just created). The request is refused and the session kept; its read starts in the background.
20526
+ */
20527
+ guestAdmission(record) {
20528
+ if (!this.webSessions || !this.webToken || !this.webSessions.isCurrent(record, tokenEpoch(this.webToken)))
20529
+ return "gone";
20530
+ const owner = record.owner;
20531
+ if (this.guestScope(owner).size)
20532
+ return "ok";
20533
+ if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
20534
+ const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
20535
+ if (rooms.some((room) => members.pending(room, owner.userId))) {
20536
+ void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
20537
+ return "pending";
20538
+ }
20539
+ }
20540
+ this.enforceGuestSwitch();
20541
+ const ended = this.webSessions.revokeByHandle(record.handle);
20542
+ if (ended.found)
20543
+ this.logger.info({ adapterId: record.owner?.adapterId, durable: ended.durable }, "Guest web session ended: nothing left in its scope");
20544
+ return "gone";
20545
+ }
20546
+ /** #1920 §3.2: the membership answers; the read goes to the room's own connection, never waits past its deadline. */
20547
+ guestRooms() {
20548
+ this.guestRoomMembership ??= new GuestRoomMembership({
20549
+ now: () => performance.now(),
20550
+ read: (room, userId) => {
20551
+ const adapter = this.worlds.get(room.adapterId)?.adapter;
20552
+ return room.kind === "telegram" ? adapter?.isGroupMember?.(room.channelId, userId) : adapter?.canViewChannel?.(room.channelId, userId);
20553
+ },
20554
+ // Said once per key while its reads keep failing (no user id: the room and connection are what an admin fixes).
20555
+ onPersistentFailure: (room, failures) => this.logger.warn({ adapterId: room.adapterId, instance: room.instance, failures }, "Guest room membership keeps failing to read (the bot may lack the permission, or was removed); retrying with backoff up to 10 minutes"),
20556
+ onRecovered: (room) => this.logger.info({ adapterId: room.adapterId, instance: room.instance }, "Guest room membership reads again"),
20557
+ });
20558
+ return this.guestRoomMembership;
20559
+ }
20560
+ /** #1920 §3.2: read this guest's rooms that are due (GuestRoomMembership.refresh says when each mode reads). */
20561
+ async refreshGuestRooms(owner, opts = {}) {
20562
+ if (this.fleetConfig?.web?.guests?.enabled !== true)
20563
+ return;
20564
+ try {
20565
+ await this.guestRooms().refresh(owner, guestGroupRooms(this.classicChannels?.getAll() ?? [], owner), opts);
20566
+ }
20567
+ catch (err) {
20568
+ this.logger.warn({ err: err.message }, "Guest room membership could not be read");
20569
+ }
20570
+ }
20571
+ /** Every account with a live guest session, one after another (the guest caps bound them); one pass at a time. */
20572
+ async refreshAllGuestRooms() {
20573
+ if (this.guestRoomsPass || this.fleetConfig?.web?.guests?.enabled !== true || !this.webSessions)
20574
+ return;
20575
+ this.guestRoomsPass = true;
20576
+ try {
20577
+ for (const owner of this.webSessions.guestOwners())
20578
+ await this.refreshGuestRooms(owner);
20579
+ }
20580
+ finally {
20581
+ this.guestRoomsPass = false;
20582
+ }
20583
+ }
20584
+ guestRoomsPass = false;
20585
+ /**
20586
+ * #1920 §3.5: guest sign-in turned off ends every guest session — checked when the configuration is read or saved
20587
+ * and when the sessions are opened, so turning it back on never revives one.
20021
20588
  */
20022
- guestScope(_owner) {
20023
- return NO_GUEST_SCOPE;
20589
+ enforceGuestSwitch() {
20590
+ if (this.fleetConfig?.web?.guests?.enabled === true || !this.webSessions)
20591
+ return;
20592
+ const ended = this.webSessions.revokeAll((r) => r.tier === "guest");
20593
+ if (ended.count)
20594
+ this.logger.info({ count: ended.count, durable: ended.durable }, "Guest web sign-in is off: every guest session ended");
20595
+ // #1920 step 4b: and every guest's returning device — turning it back on lets no old browser ask for a code.
20596
+ const devices = this.returningDevices?.revokeGuest();
20597
+ if (devices?.count)
20598
+ this.logger.info({ count: devices.count, durable: devices.durable }, "Guest web sign-in is off: every guest's returning device forgotten");
20599
+ }
20600
+ /** #1920 §5.2: whether this instance is the account's own Telegram private ClassicBot room (its chat id is the user id). */
20601
+ guestOwnRoom(owner, instance) {
20602
+ return (this.classicChannels?.getAll() ?? []).some((room) => room.instanceName === instance && this.isOwnPrivateRoom(owner, room));
20603
+ }
20604
+ /** A private room's chat id is its one user's id (a group's is negative, so never equal); Telegram only. */
20605
+ isOwnPrivateRoom(owner, room) {
20606
+ return room.adapterId === owner.adapterId && room.channelId === owner.userId && inferClassicChannelType(room.channelId) === "telegram";
20024
20607
  }
20025
20608
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
20026
20609
  dispatchWebHttp(req, res, port, closing = false) {
@@ -20086,6 +20669,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20086
20669
  // handler, on this listener and the public link's gateway alike (both come through here).
20087
20670
  if (decision.via === "session" && decision.session.tier === "guest") {
20088
20671
  const owner = decision.session.owner;
20672
+ const admitted = decision.session;
20673
+ const admission = this.guestAdmission(admitted);
20674
+ if (admission === "pending") {
20675
+ // §3.2: a room's membership is being read — try again shortly; the session stays.
20676
+ res.writeHead(503, { "Retry-After": "5" });
20677
+ res.end(JSON.stringify({ error: "guest_scope_pending" }));
20678
+ return;
20679
+ }
20680
+ if (admission === "gone") {
20681
+ res.writeHead(401);
20682
+ res.end(JSON.stringify({ error: "guest_scope_empty" }));
20683
+ return;
20684
+ }
20089
20685
  const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
20090
20686
  if (verdict.kind === "deny") {
20091
20687
  res.writeHead(verdict.status);
@@ -20093,10 +20689,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20093
20689
  return;
20094
20690
  }
20095
20691
  // Phase 1b: the handlers that answer per recipient (the stream, the poll, a file) know it is a guest's, and
20096
- // ask its scope anew each time.
20097
- const admitted = decision.session;
20098
- bindGuestRequest(req, { owner, scope: () => this.guestScope(owner),
20099
- current: () => !!this.webSessions && !!this.webToken && this.webSessions.isCurrent(admitted, tokenEpoch(this.webToken)) });
20692
+ // ask its scope anew each time; an emptied scope ends the session (and closes its stream).
20693
+ bindGuestRequest(req, { owner, scope: () => this.guestScope(owner), current: () => this.guestStillAdmitted(admitted),
20694
+ // #1920 §5.2: its own Telegram private room shows it everything; the kept messages place ticks and buttons.
20695
+ ownRoom: (instance) => this.guestOwnRoom(owner, instance),
20696
+ // §3.2: a stream that connects reads its rooms again (in the background; at most once a minute).
20697
+ streamOpened: () => { void this.refreshGuestRooms(owner, { mode: "force" }); },
20698
+ history: () => this.webChatHistory,
20699
+ // #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
20700
+ chat: () => this.fleetConfig?.web?.guests?.chat !== false });
20100
20701
  }
20101
20702
  }
20102
20703
  if (req.method === "GET" && req.url === "/health") {