@songsid/agend 2.3.0-beta.3 → 2.3.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-file-read.d.ts +4 -1
- package/dist/agent-file-read.js +129 -16
- package/dist/agent-file-read.js.map +1 -1
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +32 -7
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/web-authority.d.ts +100 -0
- package/dist/authority/web-authority.js +130 -0
- package/dist/authority/web-authority.js.map +1 -0
- package/dist/backend/types.d.ts +1 -0
- package/dist/backend/types.js +15 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +120 -0
- package/dist/backend-availability.js +396 -0
- package/dist/backend-availability.js.map +1 -0
- package/dist/backend-command.d.ts +21 -3
- package/dist/backend-command.js +23 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +7 -0
- package/dist/channel/adapters/discord.js +35 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -5
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +6 -0
- package/dist/config-validator.js +91 -2
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.js +5 -5
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +2 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -0
- package/dist/fleet-manager.d.ts +230 -6
- package/dist/fleet-manager.js +719 -42
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +45 -18
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +7 -3
- package/dist/human-delivery-receipts.d.ts +10 -1
- package/dist/human-delivery-receipts.js +26 -9
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/locale.js +42 -4
- package/dist/locale.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -3
- package/dist/outbound-schemas.js +5 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -0
- package/dist/public-web-link.js +8 -0
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +10 -1
- package/dist/quickstart-api.js +12 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +4 -0
- package/dist/reply-buttons-controller.js +2 -2
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/settings-api.d.ts +9 -0
- package/dist/settings-api.js +76 -2
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +66 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +8 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/topic-commands.d.ts +5 -0
- package/dist/topic-commands.js +16 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +36 -0
- package/dist/ui/chat-strings.js +4 -2
- package/dist/ui/chat-thread.js +4 -1
- package/dist/ui/panel-chat.js +19 -9
- package/dist/ui/panel-fleet.js +18 -6
- package/dist/ui/panel-settings.js +5 -2
- package/dist/ui/settings-catalog.js +92 -0
- package/dist/ui/settings-dialogs.js +37 -6
- package/dist/ui/settings-guests.js +117 -0
- package/dist/ui/settings-strings.js +28 -0
- package/dist/ui/shared/app-i18n.js +2 -0
- package/dist/ui/shared/app-prefs.js +3 -3
- package/dist/ui/shared/app-shell.js +36 -6
- package/dist/ui/shared/app-store.js +14 -0
- package/dist/ui/shared/app.css +54 -3
- package/dist/ui/shared/app.js +21 -14
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/effect-stock-trust.js +23 -2
- package/dist/ui/shared/identity-card.js +70 -2
- package/dist/ui/shared/instance-nav.js +8 -5
- package/dist/ui/shared/panel-view.js +22 -2
- package/dist/ui/shared/view-strings.js +6 -0
- package/dist/view-api.d.ts +11 -0
- package/dist/view-api.js +27 -4
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +32 -1
- package/dist/web-api.js +166 -28
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-policy.d.ts +4 -6
- package/dist/web-appearance-policy.js +5 -12
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.js +5 -1
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.d.ts +4 -1
- package/dist/web-appearance-view.js +21 -2
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +2 -2
- package/dist/web-appearance.js +5 -6
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-card.d.ts +14 -0
- package/dist/web-card.js +40 -6
- package/dist/web-card.js.map +1 -1
- package/dist/web-channel-echo.d.ts +7 -1
- package/dist/web-channel-echo.js +6 -2
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +40 -0
- package/dist/web-chat-history.js +66 -3
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +1 -0
- package/dist/web-effect-tools.js +2 -3
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-upload.js +1 -1
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-guest-access.d.ts +65 -7
- package/dist/web-guest-access.js +124 -18
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-guest-rooms.d.ts +82 -0
- package/dist/web-guest-rooms.js +135 -0
- package/dist/web-guest-rooms.js.map +1 -0
- package/dist/web-identity-color.d.ts +1 -1
- package/dist/web-identity-color.js +2 -2
- package/dist/web-identity-color.js.map +1 -1
- package/dist/web-login.d.ts +47 -0
- package/dist/web-login.js +115 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mod-agent.d.ts +1 -1
- package/dist/web-mod-agent.js +2 -4
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-prefs.js +1 -1
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-returning-device.d.ts +41 -0
- package/dist/web-returning-device.js +57 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-session.d.ts +50 -3
- package/dist/web-session.js +96 -14
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +25 -2
- package/dist/web-settings-patch.js +96 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +12 -0
- package/dist/web-settings-tools.js +10 -0
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-theme.d.ts +2 -0
- package/dist/web-theme.js +10 -1
- package/dist/web-theme.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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,9 @@ 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 {
|
|
229
|
+
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
230
|
+
import { evaluateWebAuthority, webAuthorityPolicy } from "./authority/web-authority.js";
|
|
231
|
+
import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
229
232
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
230
233
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
231
234
|
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
@@ -238,7 +241,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
238
241
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
239
242
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
240
243
|
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";
|
|
244
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
242
245
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
243
246
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
244
247
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
@@ -800,6 +803,9 @@ export class FleetManager {
|
|
|
800
803
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
801
804
|
fullRestartLauncher = launchFullRestartHelper;
|
|
802
805
|
eventLogPruneTimer = null;
|
|
806
|
+
/** #1920 §3.2: the background reads of guests' ClassicBot room membership (a minute's tick; reads only what is due). */
|
|
807
|
+
guestRoomsTimer = null;
|
|
808
|
+
guestRoomMembership = null;
|
|
803
809
|
outboxPruneTimer = null;
|
|
804
810
|
logRotateTimer = null;
|
|
805
811
|
discordPresenceTimer = null;
|
|
@@ -1111,6 +1117,9 @@ export class FleetManager {
|
|
|
1111
1117
|
colour: (name) => personIdentityColour(this.dataDir, name),
|
|
1112
1118
|
setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
|
|
1113
1119
|
this.identityChanged(); return w; },
|
|
1120
|
+
bannerFocus: (name) => personBannerFocus(this.dataDir, name),
|
|
1121
|
+
setBannerFocus: (name, focus) => { const w = setPersonBannerFocus(this.dataDir, name, focus); if (w.ok)
|
|
1122
|
+
this.identityChanged(); return w; },
|
|
1114
1123
|
placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
|
|
1115
1124
|
announce: () => { try {
|
|
1116
1125
|
this.emitSseEvent("status", this.getUiStatus());
|
|
@@ -1388,9 +1397,26 @@ export class FleetManager {
|
|
|
1388
1397
|
sseHeartbeatMs = SSE_HEARTBEAT_MS;
|
|
1389
1398
|
/** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
|
|
1390
1399
|
webLoginCodes = null;
|
|
1400
|
+
/**
|
|
1401
|
+
* #1920 step 4: guests' codes, apart from the admins' — one per account, so no guest's `/web` withdraws another's or
|
|
1402
|
+
* an admin's code (#2093 review), at most 32 at once. Local sign-in only for now (audience "local").
|
|
1403
|
+
*/
|
|
1404
|
+
webGuestLoginCodes = null;
|
|
1405
|
+
/** #1920 step 4: when each account last got a guest code (monotonic) — one a minute, so `/web` is no DM flood. */
|
|
1406
|
+
guestCodeAskedAt = new Map();
|
|
1407
|
+
/** #2093 review: an account's guest /web in progress — one at a time, so two at once cannot both pass the minute. */
|
|
1408
|
+
guestCodeInFlight = new Set();
|
|
1409
|
+
/**
|
|
1410
|
+
* #2093 review: revokes a guest /web in progress must see — every web session's (`/web revoke` from an admin) bumps
|
|
1411
|
+
* the global count, an account's own bumps its entry — checked before the code is issued and while it is delivered.
|
|
1412
|
+
*/
|
|
1413
|
+
guestRevokes = 0;
|
|
1414
|
+
guestAccountRevokes = new Map();
|
|
1391
1415
|
/** #1570: browsers that may ask the sign-in page for a fresh code, and how often they may. */
|
|
1392
1416
|
returningDevices = null;
|
|
1393
1417
|
codeRequestLimiter = new CodeRequestLimiter();
|
|
1418
|
+
/** #1920 step 4b: guests' device code requests, limited apart — guests never use up the admins' fleet-wide budget. */
|
|
1419
|
+
guestCodeRequestLimiter = new CodeRequestLimiter();
|
|
1394
1420
|
/**
|
|
1395
1421
|
* Set while a Settings apply job is driving the reconcile. The reconcile
|
|
1396
1422
|
* stays the single doer; it just says out loud what it is doing to whom, so
|
|
@@ -1538,6 +1564,7 @@ export class FleetManager {
|
|
|
1538
1564
|
meta: {
|
|
1539
1565
|
chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
|
|
1540
1566
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1567
|
+
...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
|
|
1541
1568
|
...reactions.meta,
|
|
1542
1569
|
},
|
|
1543
1570
|
}, by.humanOrigin, button.label)) !== false;
|
|
@@ -1558,6 +1585,9 @@ export class FleetManager {
|
|
|
1558
1585
|
this.emitSseEvent("message", {
|
|
1559
1586
|
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1560
1587
|
click: { label: button.label, of: set.id },
|
|
1588
|
+
// #1920: a platform click is its clicker's; it is the instance's channel's only when its buttons are there (the
|
|
1589
|
+
// same rule as a reply's: a set sent to another topic or a DM is not). A web click is the web's.
|
|
1590
|
+
...(web ? {} : { from: webChatFrom(set.adapterId, by.userId), ...(this.clickInOwnChannel(set) ? { channel: true } : {}) }),
|
|
1561
1591
|
});
|
|
1562
1592
|
return true;
|
|
1563
1593
|
}
|
|
@@ -2439,6 +2469,7 @@ export class FleetManager {
|
|
|
2439
2469
|
this.fleetConfig = loaded;
|
|
2440
2470
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2441
2471
|
this.warnAboutRemovedLoginMode(loaded);
|
|
2472
|
+
this.enforceGuestSwitch();
|
|
2442
2473
|
return this.fleetConfig;
|
|
2443
2474
|
}
|
|
2444
2475
|
relayLoginModeWarned = false;
|
|
@@ -3576,6 +3607,15 @@ export class FleetManager {
|
|
|
3576
3607
|
* already in flight may still land late — the contract permits that.
|
|
3577
3608
|
*/
|
|
3578
3609
|
/** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
|
|
3610
|
+
/**
|
|
3611
|
+
* #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
|
|
3612
|
+
* to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
|
|
3613
|
+
*/
|
|
3614
|
+
webMessageInChannel(instance, messageId) {
|
|
3615
|
+
const m = this.webChatHistory.markChannel(instance, messageId);
|
|
3616
|
+
if (m)
|
|
3617
|
+
broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
|
|
3618
|
+
}
|
|
3579
3619
|
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
|
|
3580
3620
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
3581
3621
|
const handled = new Set();
|
|
@@ -3796,6 +3836,18 @@ export class FleetManager {
|
|
|
3796
3836
|
return payload;
|
|
3797
3837
|
const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
|
|
3798
3838
|
origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
|
|
3839
|
+
// #1939 §7: each ordinary verified web message, for the S1 beta to watch. No text, cookie or session id: the handle.
|
|
3840
|
+
const authority = origin.authority;
|
|
3841
|
+
if (authority?.kind === "web-session" && authority.actor && authority.session) {
|
|
3842
|
+
try {
|
|
3843
|
+
this.eventLog?.insert(instanceName, "web_authority", { receipt_id: row.receipt_id, session: authority.session.handle,
|
|
3844
|
+
peer: authority.session.peer, surface: "local", actor: `${authority.actor.adapter_id}:${authority.actor.user_id}`,
|
|
3845
|
+
input_kind: String(payload.type), outcome: "web-session", authority_epoch: authority.authority_epoch });
|
|
3846
|
+
}
|
|
3847
|
+
catch (err) {
|
|
3848
|
+
this.logger.warn({ err, instanceName }, "Could not record web authority");
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3799
3851
|
return { ...payload, meta: { ...(payload.meta ?? {}),
|
|
3800
3852
|
human_receipt_id: row.receipt_id, verification_id: row.message_id } };
|
|
3801
3853
|
}
|
|
@@ -4011,6 +4063,14 @@ export class FleetManager {
|
|
|
4011
4063
|
return;
|
|
4012
4064
|
}
|
|
4013
4065
|
const scope = door.scope;
|
|
4066
|
+
// #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)
|
|
4067
|
+
// — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
|
|
4068
|
+
// Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
|
|
4069
|
+
if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
|
|
4070
|
+
await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
|
|
4071
|
+
arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
|
|
4072
|
+
return;
|
|
4073
|
+
}
|
|
4014
4074
|
const spec = commandSpec(data.command);
|
|
4015
4075
|
if (!spec) {
|
|
4016
4076
|
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
@@ -5455,10 +5515,14 @@ export class FleetManager {
|
|
|
5455
5515
|
// #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
|
|
5456
5516
|
// (shortening ends the sessions it now outlives). The public link's policy stays fixed.
|
|
5457
5517
|
localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
|
|
5518
|
+
// #1920 §5.4: a guest's, from web.guests.session (never longer than the surface's own).
|
|
5519
|
+
guestPolicy: () => guestSessionPolicy(this.fleetConfig?.web?.guests?.session),
|
|
5458
5520
|
});
|
|
5521
|
+
this.enforceGuestSwitch(); // #1920: guests off while the fleet was down
|
|
5459
5522
|
}
|
|
5460
5523
|
if (!this.returningDevices) {
|
|
5461
5524
|
this.returningDevices = new ReturningDevices({ dataDir: this.dataDir, onWarn: message => this.logger.warn(message) });
|
|
5525
|
+
this.enforceGuestSwitch(); // #1920: guests' devices too, if guests went off while down
|
|
5462
5526
|
}
|
|
5463
5527
|
if (!this.webLoginCodes) {
|
|
5464
5528
|
this.webLoginCodes = new WebLoginCodes({
|
|
@@ -5470,6 +5534,16 @@ export class FleetManager {
|
|
|
5470
5534
|
},
|
|
5471
5535
|
});
|
|
5472
5536
|
}
|
|
5537
|
+
if (!this.webGuestLoginCodes) {
|
|
5538
|
+
this.webGuestLoginCodes = new GuestLoginCodes({
|
|
5539
|
+
onEvent: event => {
|
|
5540
|
+
if (event === "burned")
|
|
5541
|
+
this.logger.warn("A guest web login code was used up by wrong attempts");
|
|
5542
|
+
else if (event === "breaker-open")
|
|
5543
|
+
this.logger.warn("Guest web sign-in paused: too many wrong login codes");
|
|
5544
|
+
},
|
|
5545
|
+
});
|
|
5546
|
+
}
|
|
5473
5547
|
}
|
|
5474
5548
|
/**
|
|
5475
5549
|
* A single-use login code for the dashboard, for a channel only the operator
|
|
@@ -5593,6 +5667,7 @@ export class FleetManager {
|
|
|
5593
5667
|
dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
|
|
5594
5668
|
revoke: id => {
|
|
5595
5669
|
this.webLoginCodes?.revokeAudience(id);
|
|
5670
|
+
this.webGuestLoginCodes?.revokeAudience(id);
|
|
5596
5671
|
const result = this.webSessions?.revokeExposure(id);
|
|
5597
5672
|
if (result && !result.durable)
|
|
5598
5673
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
@@ -5624,6 +5699,24 @@ export class FleetManager {
|
|
|
5624
5699
|
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
5625
5700
|
throw new Error("public owner changed");
|
|
5626
5701
|
}
|
|
5702
|
+
/**
|
|
5703
|
+
* #1920 §5.5 (step 4b): a guest signing in through the public link — said in the General of the admin who opened it
|
|
5704
|
+
* (who, how many agents), awaited as an admin's public sign-in is, while that link is the open one, its opener still
|
|
5705
|
+
* current, and the account still an admitted guest. A failure refuses the sign-in.
|
|
5706
|
+
*/
|
|
5707
|
+
async confirmGuestPublicWebLogin(info, current) {
|
|
5708
|
+
const admitted = () => {
|
|
5709
|
+
const open = this.publicWebLink?.openSignin();
|
|
5710
|
+
return current() && open && open.exposureId === info.exposureId && this.publicOwnerCurrent(open.owner) && this.guestSignInAllowed(info.account) ? open : null;
|
|
5711
|
+
};
|
|
5712
|
+
const open = admitted();
|
|
5713
|
+
if (!open)
|
|
5714
|
+
throw new Error("guest public sign-in unavailable");
|
|
5715
|
+
const adapter = open.owner.binding;
|
|
5716
|
+
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: [] } });
|
|
5717
|
+
if (!admitted())
|
|
5718
|
+
throw new Error("guest public sign-in changed");
|
|
5719
|
+
}
|
|
5627
5720
|
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
5628
5721
|
async dashboardMenu(msg) {
|
|
5629
5722
|
const id = msg.adapterId;
|
|
@@ -5947,6 +6040,8 @@ export class FleetManager {
|
|
|
5947
6040
|
this.initializeWebSessions();
|
|
5948
6041
|
void this.publicWebLink?.close("dashboard revoke");
|
|
5949
6042
|
this.webLoginCodes.revoke();
|
|
6043
|
+
this.webGuestLoginCodes.revokeAll();
|
|
6044
|
+
this.guestRevokes++; // and every guest /web still on its way
|
|
5950
6045
|
// #1570: no browser may ask the sign-in page for a code any more.
|
|
5951
6046
|
const devices = this.returningDevices.revokeAll();
|
|
5952
6047
|
const sessions = this.webSessions.revokeAll();
|
|
@@ -5957,6 +6052,183 @@ export class FleetManager {
|
|
|
5957
6052
|
this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
|
|
5958
6053
|
return result;
|
|
5959
6054
|
}
|
|
6055
|
+
/**
|
|
6056
|
+
* #1920 phase 3: Settings → Web sign-ins → Guests — the switches, each connection's access mode and the agents named for
|
|
6057
|
+
* an open one, and every guest account (signed in, with a returning device, or narrowed): who, its platform, what it
|
|
6058
|
+
* reaches now, its sessions and devices, and its narrowing. Ids are the accounts' own (an admin's page).
|
|
6059
|
+
*/
|
|
6060
|
+
guestOverview() {
|
|
6061
|
+
this.initializeWebSessions();
|
|
6062
|
+
const cfg = this.fleetConfig?.web?.guests;
|
|
6063
|
+
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
6064
|
+
const connections = [...this.worlds.entries()].map(([id, w]) => ({ id, type: w.adapter.type, mode: (w.accessManager.getMode() === "open" ? "open" : "allowlist"),
|
|
6065
|
+
named: [...(own(cfg?.open_instances, id) ?? [])] }));
|
|
6066
|
+
const accounts = new Map();
|
|
6067
|
+
for (const a of this.webSessions.guestActivity())
|
|
6068
|
+
accounts.set(`${a.adapterId}\u0000${a.userId}`, a);
|
|
6069
|
+
const devices = this.returningDevices.guestCounts();
|
|
6070
|
+
for (const k of devices.keys())
|
|
6071
|
+
if (!accounts.has(k)) {
|
|
6072
|
+
const [adapterId, userId] = k.split("\u0000");
|
|
6073
|
+
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6074
|
+
}
|
|
6075
|
+
for (const key of Object.keys(cfg?.narrow ?? {})) {
|
|
6076
|
+
const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
|
|
6077
|
+
if (at > 0 && !accounts.has(k))
|
|
6078
|
+
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6079
|
+
}
|
|
6080
|
+
const guests = [...accounts.entries()].map(([k, a]) => {
|
|
6081
|
+
const owner = { adapterId: a.adapterId, userId: a.userId };
|
|
6082
|
+
const badge = this.guestBadge(owner);
|
|
6083
|
+
// `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
|
|
6084
|
+
return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
|
|
6085
|
+
devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null };
|
|
6086
|
+
}).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
|
|
6087
|
+
return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests };
|
|
6088
|
+
}
|
|
6089
|
+
/** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
|
|
6090
|
+
guestBadge(owner) {
|
|
6091
|
+
return { name: this.webPerson(owner).name, platform: this.worlds.get(owner.adapterId)?.adapter.type ?? owner.adapterId };
|
|
6092
|
+
}
|
|
6093
|
+
/** #1920 step 4: whether a `/web` is a guest's — guest sign-in on, and not from a fleet admin (theirs runs as before). */
|
|
6094
|
+
isGuestWebAsk(adapterId, userId) {
|
|
6095
|
+
return !!adapterId && this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(userId, adapterId);
|
|
6096
|
+
}
|
|
6097
|
+
/** #1920 step 4 (auth-api): a guest code's account may still sign in — guests on, and something in its scope now. */
|
|
6098
|
+
guestSignInAllowed(account) {
|
|
6099
|
+
return this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(account.userId, account.adapterId) && this.guestScope(account).size > 0;
|
|
6100
|
+
}
|
|
6101
|
+
/**
|
|
6102
|
+
* #1920 §5.4: a guest's own `/web revoke` — its sessions and its outstanding code; nobody else's (an admin's revokes
|
|
6103
|
+
* everyone's, as before).
|
|
6104
|
+
*/
|
|
6105
|
+
revokeGuestWebSessions(owner) {
|
|
6106
|
+
this.initializeWebSessions();
|
|
6107
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`;
|
|
6108
|
+
this.guestAccountRevokes.set(key, (this.guestAccountRevokes.get(key) ?? 0) + 1); // its /web on its way, too
|
|
6109
|
+
while (this.guestAccountRevokes.size > 4096)
|
|
6110
|
+
this.guestAccountRevokes.delete(this.guestAccountRevokes.keys().next().value);
|
|
6111
|
+
this.webGuestLoginCodes.revokeRecipient(owner);
|
|
6112
|
+
// §5.4: its sessions and its returning devices (no browser of theirs may ask for a code any more).
|
|
6113
|
+
const devices = this.returningDevices.revokeGuest(owner);
|
|
6114
|
+
const ended = this.webSessions.revokeAll(r => r.tier === "guest" && r.owner?.adapterId === owner.adapterId && r.owner.userId === owner.userId);
|
|
6115
|
+
this.logger.info({ adapterId: owner.adapterId, count: ended.count, devices: devices.count, durable: ended.durable && devices.durable }, "Guest web sessions revoked by their account");
|
|
6116
|
+
return { count: ended.count, durable: ended.durable && devices.durable };
|
|
6117
|
+
}
|
|
6118
|
+
/**
|
|
6119
|
+
* #1920 §2.3 (step 4): `/web` from someone who is not a fleet admin, while guest sign-in is on. `revoke` ends their
|
|
6120
|
+
* own guest sessions. Otherwise a guest code (tier guest, bound to this account) goes privately — a DM, else the
|
|
6121
|
+
* slash command's ephemeral answer (`replyPrivately`) — once a minute at most, and only when their scope (from what
|
|
6122
|
+
* they may already use in chat, rooms read at sign-in) holds something. The code itself is never posted where asked.
|
|
6123
|
+
* Callers ask isGuestWebAsk first (synchronously); false here too when it is not a guest's.
|
|
6124
|
+
*/
|
|
6125
|
+
async guestWebCommand(ask) {
|
|
6126
|
+
if (!this.isGuestWebAsk(ask.adapterId, ask.userId))
|
|
6127
|
+
return false;
|
|
6128
|
+
const owner = { adapterId: ask.adapterId, userId: ask.userId };
|
|
6129
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6130
|
+
if (ask.arg === "revoke") {
|
|
6131
|
+
const ended = this.revokeGuestWebSessions(owner);
|
|
6132
|
+
await say(t(ended.durable ? "web.guest_revoked" : "web.guest_revoked_not_durable", ended.count));
|
|
6133
|
+
return true;
|
|
6134
|
+
}
|
|
6135
|
+
const token = this.webToken;
|
|
6136
|
+
const adapter = this.adapters.get(ask.adapterId);
|
|
6137
|
+
if (this.shuttingDown || !token || !adapter) {
|
|
6138
|
+
await say(t("dashboard.starting"));
|
|
6139
|
+
return true;
|
|
6140
|
+
}
|
|
6141
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
|
|
6142
|
+
if (this.guestCodeInFlight.has(key) || (asked !== undefined && performance.now() - asked < 60_000)) {
|
|
6143
|
+
await say(t("web.guest_wait"));
|
|
6144
|
+
return true;
|
|
6145
|
+
}
|
|
6146
|
+
this.guestCodeInFlight.add(key);
|
|
6147
|
+
try {
|
|
6148
|
+
await this.issueGuestCode(ask, owner, key, adapter, token);
|
|
6149
|
+
}
|
|
6150
|
+
finally {
|
|
6151
|
+
this.guestCodeInFlight.delete(key);
|
|
6152
|
+
}
|
|
6153
|
+
return true;
|
|
6154
|
+
}
|
|
6155
|
+
/** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
|
|
6156
|
+
async issueGuestCode(ask, owner, key, adapter, token) {
|
|
6157
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6158
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6159
|
+
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
|
|
6160
|
+
// §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
|
|
6161
|
+
await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
|
|
6162
|
+
if (!unrevoked())
|
|
6163
|
+
return; // revoked while its rooms were read: no code
|
|
6164
|
+
const scope = this.guestScope(owner);
|
|
6165
|
+
if (!scope.size) {
|
|
6166
|
+
const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
|
|
6167
|
+
await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
this.initializeWebSessions();
|
|
6171
|
+
const codes = this.webGuestLoginCodes;
|
|
6172
|
+
if (codes.pausedForMs() > 0) {
|
|
6173
|
+
await say(t("web.guest_paused"));
|
|
6174
|
+
return;
|
|
6175
|
+
}
|
|
6176
|
+
// §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
|
|
6177
|
+
// guest there) — most guests are not on the host's network. Its closing withdraws that audience (revokeAudience).
|
|
6178
|
+
const open = this.publicWebLink?.openSignin() ?? null;
|
|
6179
|
+
// One code per account (a new one replaces only its own); at the cap, refused — never another guest's evicted.
|
|
6180
|
+
const login = codes.issue({ epoch: tokenEpoch(token), audiences: ["local", ...(open ? [open.exposureId] : [])],
|
|
6181
|
+
recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
|
|
6182
|
+
if (!login) {
|
|
6183
|
+
await say(t("web.guest_busy"));
|
|
6184
|
+
return;
|
|
6185
|
+
}
|
|
6186
|
+
this.guestCodeAskedAt.delete(key);
|
|
6187
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6188
|
+
while (this.guestCodeAskedAt.size > 256)
|
|
6189
|
+
this.guestCodeAskedAt.delete(this.guestCodeAskedAt.keys().next().value);
|
|
6190
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(owner.adapterId) === adapter
|
|
6191
|
+
&& unrevoked() && this.guestSignInAllowed(owner);
|
|
6192
|
+
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))}`;
|
|
6193
|
+
const deadline = performance.now() + 10_000;
|
|
6194
|
+
try {
|
|
6195
|
+
let byDm = false;
|
|
6196
|
+
try {
|
|
6197
|
+
if (!adapter.sendDirect)
|
|
6198
|
+
throw new Error("DM unavailable");
|
|
6199
|
+
await withinBudget(adapter.sendDirect(owner.userId, `${privateText}\n${t("dashboard.code_next")}`, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
|
|
6200
|
+
byDm = true;
|
|
6201
|
+
}
|
|
6202
|
+
catch (err) {
|
|
6203
|
+
this.logDashboardDmFailure(err);
|
|
6204
|
+
if (!ask.replyPrivately || !current())
|
|
6205
|
+
throw err;
|
|
6206
|
+
const id = await withinBudget(ask.replyPrivately(privateText), deadline); // a slash command's answer: ephemeral
|
|
6207
|
+
if (!id)
|
|
6208
|
+
throw new Error("private delivery unconfirmed");
|
|
6209
|
+
}
|
|
6210
|
+
if (!current())
|
|
6211
|
+
throw new Error("guest no longer admitted");
|
|
6212
|
+
if (byDm) {
|
|
6213
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(owner.userId, body, opts), adapter.type, login.display);
|
|
6214
|
+
if (!current())
|
|
6215
|
+
throw new Error("guest no longer admitted");
|
|
6216
|
+
await say(t("web.guest_sent"));
|
|
6217
|
+
}
|
|
6218
|
+
}
|
|
6219
|
+
catch (err) {
|
|
6220
|
+
codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
|
|
6221
|
+
this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
|
|
6222
|
+
if (!unrevoked())
|
|
6223
|
+
return; // revoked meanwhile: the revoke's own answer said so
|
|
6224
|
+
const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
6225
|
+
// #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
|
|
6226
|
+
// the DM — once, within the window.
|
|
6227
|
+
if (adapter.type === "telegram")
|
|
6228
|
+
this.openTgWebStartWindow(owner.adapterId, owner.userId);
|
|
6229
|
+
await say(start ? t("web.guest_dm_failed_link", start) : t("web.guest_dm_failed"));
|
|
6230
|
+
}
|
|
6231
|
+
}
|
|
5960
6232
|
/** #1570 (auth-api): this browser becomes a returning device of the code's owner. The cookie value, or null. */
|
|
5961
6233
|
rememberReturningDevice(owner, input) {
|
|
5962
6234
|
const token = this.webToken;
|
|
@@ -5984,6 +6256,12 @@ export class FleetManager {
|
|
|
5984
6256
|
if (!token || this.shuttingDown)
|
|
5985
6257
|
return { kind: "refused" };
|
|
5986
6258
|
this.initializeWebSessions();
|
|
6259
|
+
// #2095 review: verified before the request's body was read — a revoke (its own, or an admin's) made meanwhile
|
|
6260
|
+
// removed it, and nothing it asks for may then go out.
|
|
6261
|
+
if (!this.returningDevices.current(device, tokenEpoch(token)))
|
|
6262
|
+
return { kind: "refused" };
|
|
6263
|
+
if (device.tier === "guest")
|
|
6264
|
+
return this.requestGuestDeviceCode(device, input, token);
|
|
5987
6265
|
const paused = this.webLoginCodes.pausedForMs();
|
|
5988
6266
|
if (paused > 0)
|
|
5989
6267
|
return { kind: "limited", retryAfterMs: paused };
|
|
@@ -6015,7 +6293,7 @@ export class FleetManager {
|
|
|
6015
6293
|
this.logger.info({ err: err?.message, adapterId }, "Sign-in code request: the private message could not be sent");
|
|
6016
6294
|
return { kind: "refused" };
|
|
6017
6295
|
}
|
|
6018
|
-
if (this.webToken !== token || !this.publicOwnerCurrent(owner)) {
|
|
6296
|
+
if (this.webToken !== token || !this.publicOwnerCurrent(owner) || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
6019
6297
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
6020
6298
|
return { kind: "refused" };
|
|
6021
6299
|
}
|
|
@@ -6023,15 +6301,94 @@ export class FleetManager {
|
|
|
6023
6301
|
// Asked again after the follow-up's wait: the owner, the token, shutdown, and a public request's exposure.
|
|
6024
6302
|
const exposureGone = input.surface === "gateway"
|
|
6025
6303
|
&& (this.publicWebLink?.exposureId !== input.exposureId || this.publicWebLink?.status().state !== "open");
|
|
6026
|
-
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone) {
|
|
6304
|
+
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
6027
6305
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
6028
6306
|
return { kind: "refused" };
|
|
6029
6307
|
}
|
|
6030
6308
|
this.logger.info({ adapterId, surface: input.surface }, "Web sign-in code sent privately to a returning device's owner");
|
|
6031
6309
|
return { kind: "sent" };
|
|
6032
6310
|
}
|
|
6311
|
+
/**
|
|
6312
|
+
* #1920 step 4b: a guest's returning device asks for a code — a guest code for that account, by DM only, under the
|
|
6313
|
+
* same rules as its /web (guests on, not an admin, something in scope now; one per account and the cap; one flow per
|
|
6314
|
+
* account at a time; a revoke meanwhile ends it), its own limiter, and the guests' breaker. Asked through the public
|
|
6315
|
+
* link: only while that exposure is the open one (its code then works through it alone), as an admin's device.
|
|
6316
|
+
*/
|
|
6317
|
+
async requestGuestDeviceCode(device, input, token) {
|
|
6318
|
+
const codes = this.webGuestLoginCodes;
|
|
6319
|
+
const account = { adapterId: device.owner.adapterId, userId: device.owner.userId };
|
|
6320
|
+
const key = `${account.adapterId}\u0000${account.userId}`;
|
|
6321
|
+
const adapter = this.adapters.get(account.adapterId);
|
|
6322
|
+
const viaLink = input.surface === "gateway";
|
|
6323
|
+
const linkOpen = () => !!input.exposureId && this.publicWebLink?.openSignin()?.exposureId === input.exposureId;
|
|
6324
|
+
if ((viaLink ? !linkOpen() : input.exposureId !== undefined) || !adapter?.sendDirect || !this.guestSignInAllowed(account)) {
|
|
6325
|
+
this.logger.info({ adapterId: account.adapterId, surface: input.surface }, "Guest sign-in code request refused");
|
|
6326
|
+
return { kind: "refused" };
|
|
6327
|
+
}
|
|
6328
|
+
const paused = codes.pausedForMs();
|
|
6329
|
+
if (paused > 0)
|
|
6330
|
+
return { kind: "limited", retryAfterMs: paused };
|
|
6331
|
+
const asked = this.guestCodeAskedAt.get(key);
|
|
6332
|
+
if (this.guestCodeInFlight.has(key))
|
|
6333
|
+
return { kind: "limited", retryAfterMs: 5_000 };
|
|
6334
|
+
if (asked !== undefined && performance.now() - asked < 60_000)
|
|
6335
|
+
return { kind: "limited", retryAfterMs: 60_000 - (performance.now() - asked) };
|
|
6336
|
+
const admitted = this.guestCodeRequestLimiter.admit(device.idHash);
|
|
6337
|
+
if (!admitted.ok)
|
|
6338
|
+
return { kind: "limited", retryAfterMs: admitted.retryAfterMs };
|
|
6339
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6340
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(account.adapterId) === adapter
|
|
6341
|
+
&& this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && this.guestSignInAllowed(account)
|
|
6342
|
+
&& (!viaLink || linkOpen()) && this.returningDevices.current(device, tokenEpoch(token));
|
|
6343
|
+
const issued = codes.issue({ epoch: tokenEpoch(token), recipient: account, audiences: [viaLink ? input.exposureId : "local"] });
|
|
6344
|
+
if (!issued)
|
|
6345
|
+
return { kind: "limited", retryAfterMs: 60_000 }; // the guests' cap: never someone's evicted
|
|
6346
|
+
this.guestCodeInFlight.add(key);
|
|
6347
|
+
this.guestCodeAskedAt.delete(key);
|
|
6348
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6349
|
+
try {
|
|
6350
|
+
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"))
|
|
6351
|
+
+ "\n" + t("dashboard.code_next");
|
|
6352
|
+
try {
|
|
6353
|
+
await withinBudget(adapter.sendDirect(account.userId, text, { disablePreview: true }), performance.now() + 5_000);
|
|
6354
|
+
}
|
|
6355
|
+
catch (err) {
|
|
6356
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6357
|
+
this.logger.info({ err: err?.message, adapterId: account.adapterId }, "Guest sign-in code request: the private message could not be sent");
|
|
6358
|
+
return { kind: "refused" };
|
|
6359
|
+
}
|
|
6360
|
+
if (!current()) {
|
|
6361
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6362
|
+
return { kind: "refused" };
|
|
6363
|
+
}
|
|
6364
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(account.userId, body, opts), adapter.type, issued.display);
|
|
6365
|
+
if (!current()) {
|
|
6366
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6367
|
+
return { kind: "refused" };
|
|
6368
|
+
}
|
|
6369
|
+
this.logger.info({ adapterId: account.adapterId }, "Guest sign-in code sent privately to a returning device's account");
|
|
6370
|
+
return { kind: "sent" };
|
|
6371
|
+
}
|
|
6372
|
+
finally {
|
|
6373
|
+
this.guestCodeInFlight.delete(key);
|
|
6374
|
+
}
|
|
6375
|
+
}
|
|
6376
|
+
/** #1920 step 4b (auth-api): this browser becomes a guest's returning device. The cookie value, or null. */
|
|
6377
|
+
rememberGuestDevice(account, input) {
|
|
6378
|
+
const token = this.webToken;
|
|
6379
|
+
if (!token)
|
|
6380
|
+
return null;
|
|
6381
|
+
this.initializeWebSessions();
|
|
6382
|
+
return this.returningDevices.rememberGuest(account, { ...input, tokenEpoch: tokenEpoch(token), webToken: token });
|
|
6383
|
+
}
|
|
6033
6384
|
/** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
|
|
6034
6385
|
onWebLogin(info) {
|
|
6386
|
+
// #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
|
|
6387
|
+
// and how many agents they now reach — admins stay aware of everyone who is not one of them.
|
|
6388
|
+
if (info.tier === "guest" && info.owner) {
|
|
6389
|
+
this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
|
|
6390
|
+
return;
|
|
6391
|
+
}
|
|
6035
6392
|
if (this.fleetConfig?.web?.notify_login === false)
|
|
6036
6393
|
return;
|
|
6037
6394
|
// The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
|
|
@@ -6396,6 +6753,8 @@ export class FleetManager {
|
|
|
6396
6753
|
TmuxManager.setSocketName(getSocket());
|
|
6397
6754
|
await TmuxManager.ensureSession(getTmuxSession());
|
|
6398
6755
|
this.tmuxSessionEnsured = true;
|
|
6756
|
+
// #2072: what this host can run — probed in the background, published for every picker.
|
|
6757
|
+
this.startAvailabilityRefresh();
|
|
6399
6758
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
6400
6759
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
6401
6760
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
@@ -6719,6 +7078,11 @@ export class FleetManager {
|
|
|
6719
7078
|
this.pruneEventLog();
|
|
6720
7079
|
this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
|
|
6721
7080
|
this.eventLogPruneTimer.unref?.();
|
|
7081
|
+
// #1920 §3.2: guests' ClassicBot room membership, read in the background — now, then each key ten minutes after
|
|
7082
|
+
// its answer (a minute after a failed read; the tick only finds what is due). A request only reads the answers.
|
|
7083
|
+
void this.refreshAllGuestRooms();
|
|
7084
|
+
this.guestRoomsTimer = setInterval(() => { void this.refreshAllGuestRooms(); }, GUEST_ROOM_TICK_MS);
|
|
7085
|
+
this.guestRoomsTimer.unref?.();
|
|
6722
7086
|
// #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
|
|
6723
7087
|
// + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
|
|
6724
7088
|
void this.pruneOutboxAndTasks();
|
|
@@ -8371,6 +8735,13 @@ export class FleetManager {
|
|
|
8371
8735
|
if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
|
|
8372
8736
|
return; // bare slash in group — ignore silently
|
|
8373
8737
|
}
|
|
8738
|
+
// #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
|
|
8739
|
+
// while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
|
|
8740
|
+
if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
|
|
8741
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
|
|
8742
|
+
arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8743
|
+
return;
|
|
8744
|
+
}
|
|
8374
8745
|
// Handle /start command
|
|
8375
8746
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
8376
8747
|
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
@@ -8379,15 +8750,21 @@ export class FleetManager {
|
|
|
8379
8750
|
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
8380
8751
|
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
8381
8752
|
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
8753
|
+
// #1920 step 4b-3: a guest's too (guest sign-in on, not an admin) — its /web reached it no other way.
|
|
8754
|
+
const guest = isPrivateChat && !admin && this.isGuestWebAsk(msg.adapterId, msg.userId);
|
|
8382
8755
|
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
8383
|
-
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8756
|
+
|| (text === "/start" && (admin || guest) && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8384
8757
|
if (webStart) {
|
|
8385
8758
|
if (requestedBackend === "web")
|
|
8386
8759
|
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
8387
8760
|
// #1742: the /web menu right here; the code comes to this DM.
|
|
8388
8761
|
if (admin)
|
|
8389
8762
|
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
8390
|
-
//
|
|
8763
|
+
// A guest: its guest code, here (this DM is where it goes).
|
|
8764
|
+
else if (guest && msgAdapter)
|
|
8765
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username, arg: "",
|
|
8766
|
+
replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8767
|
+
// Anyone else: /start alone is enough to open the DM channel; no further action.
|
|
8391
8768
|
return;
|
|
8392
8769
|
}
|
|
8393
8770
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
@@ -8691,10 +9068,7 @@ export class FleetManager {
|
|
|
8691
9068
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
8692
9069
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
8693
9070
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
|
|
8694
|
-
this.
|
|
8695
|
-
instance: generalInstance, sender: msg.username, role: "user",
|
|
8696
|
-
text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
8697
|
-
});
|
|
9071
|
+
this.emitPlatformInbound(generalInstance, msg, text ?? "");
|
|
8698
9072
|
}
|
|
8699
9073
|
catch (err) {
|
|
8700
9074
|
this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
|
|
@@ -8803,9 +9177,16 @@ export class FleetManager {
|
|
|
8803
9177
|
this.afterUserInboundDelivered(instanceName, msg);
|
|
8804
9178
|
this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
|
|
8805
9179
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
|
|
9180
|
+
this.emitPlatformInbound(instanceName, msg, text ?? "");
|
|
9181
|
+
}
|
|
9182
|
+
/**
|
|
9183
|
+
* A person's platform message as the web chat shows it — the user's own words, as a user (#1306), in the instance's
|
|
9184
|
+
* history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
|
|
9185
|
+
*/
|
|
9186
|
+
emitPlatformInbound(instance, msg, text) {
|
|
8806
9187
|
this.emitSseEvent("message", {
|
|
8807
|
-
instance
|
|
8808
|
-
|
|
9188
|
+
instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
9189
|
+
channel: true, from: webChatFrom(msg.adapterId, msg.userId),
|
|
8809
9190
|
});
|
|
8810
9191
|
}
|
|
8811
9192
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -9107,7 +9488,14 @@ export class FleetManager {
|
|
|
9107
9488
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
9108
9489
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
9109
9490
|
try {
|
|
9110
|
-
|
|
9491
|
+
// #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
|
|
9492
|
+
let inChannel = false;
|
|
9493
|
+
try {
|
|
9494
|
+
inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
|
|
9495
|
+
&& this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
|
|
9496
|
+
}
|
|
9497
|
+
catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
|
|
9498
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
|
|
9111
9499
|
}
|
|
9112
9500
|
catch (err) {
|
|
9113
9501
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -9161,7 +9549,35 @@ export class FleetManager {
|
|
|
9161
9549
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
9162
9550
|
}
|
|
9163
9551
|
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
9164
|
-
|
|
9552
|
+
/**
|
|
9553
|
+
* #1920 §5.2: whether a platform send landed in the instance's own place — its ClassicBot room, or its topic in its
|
|
9554
|
+
* connection's group — so its channel's members saw it. Anything else (another chat, a DM, a context left from General,
|
|
9555
|
+
* an answer without the chat it went to) is not the channel's.
|
|
9556
|
+
*/
|
|
9557
|
+
landedInOwnChannel(instance, adapterId, sent) {
|
|
9558
|
+
const s = sent && typeof sent === "object" ? sent : {};
|
|
9559
|
+
if (!adapterId || typeof s.chatId !== "string" || !s.chatId)
|
|
9560
|
+
return false;
|
|
9561
|
+
const roomId = this.classicChannels?.getChannelIdByInstance(instance);
|
|
9562
|
+
if (roomId !== undefined)
|
|
9563
|
+
return (this.classicChannels?.getAdapterIdByInstance(instance) ?? this.getPrimaryAdapterId()) === adapterId && roomId === s.chatId;
|
|
9564
|
+
const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
9565
|
+
const world = this.worlds.get(adapterId);
|
|
9566
|
+
if (!cfg || this.getInstanceAdapterId(instance) !== adapterId || !world?.groupId || s.chatId !== world.groupId)
|
|
9567
|
+
return false;
|
|
9568
|
+
const thread = typeof s.threadId === "string" && s.threadId ? s.threadId : null;
|
|
9569
|
+
return cfg.topic_id == null ? thread === null : thread === String(cfg.topic_id);
|
|
9570
|
+
}
|
|
9571
|
+
/** #1920 §5.2 (#2085 review): a platform click's buttons are in the instance's own place — else not its channel's. */
|
|
9572
|
+
clickInOwnChannel(set) {
|
|
9573
|
+
try {
|
|
9574
|
+
return this.landedInOwnChannel(set.instance, set.adapterId, { chatId: set.chatId, threadId: set.threadId || undefined });
|
|
9575
|
+
}
|
|
9576
|
+
catch {
|
|
9577
|
+
return false;
|
|
9578
|
+
}
|
|
9579
|
+
}
|
|
9580
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
|
|
9165
9581
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
9166
9582
|
// more work either. Split the difference: an instance that is clearly
|
|
9167
9583
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -9197,6 +9613,7 @@ export class FleetManager {
|
|
|
9197
9613
|
ts,
|
|
9198
9614
|
...(attachments.length ? { attachments } : {}),
|
|
9199
9615
|
...(buttons ? { buttons } : {}), // #1266
|
|
9616
|
+
...(inChannel ? { channel: true } : {}), // #1920 §5.2
|
|
9200
9617
|
});
|
|
9201
9618
|
};
|
|
9202
9619
|
const before = this.webReplyOrder.get(instanceName);
|
|
@@ -10450,6 +10867,7 @@ export class FleetManager {
|
|
|
10450
10867
|
*/
|
|
10451
10868
|
saveFleetConfig(explicitPatches = []) {
|
|
10452
10869
|
this.publicWebLink?.refresh();
|
|
10870
|
+
this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
|
|
10453
10871
|
if (!this.fleetConfig || !this.configPath)
|
|
10454
10872
|
return;
|
|
10455
10873
|
if (!this.savedFleetConfigSnapshot)
|
|
@@ -13153,6 +13571,19 @@ export class FleetManager {
|
|
|
13153
13571
|
webBackendTokens = new Map();
|
|
13154
13572
|
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
13155
13573
|
webClearTokens = new Map();
|
|
13574
|
+
/**
|
|
13575
|
+
* #1939 S1: the design's §3 for one web request (authority/web-authority.ts), with this fleet's admins, policy and
|
|
13576
|
+
* token; `instance` once the body has named it. Observe only: receipts record it, no agent acts on it yet.
|
|
13577
|
+
*/
|
|
13578
|
+
webAuthority(input) {
|
|
13579
|
+
return evaluateWebAuthority({
|
|
13580
|
+
session: input.session, gateway: input.gateway, peer: input.peer,
|
|
13581
|
+
tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13582
|
+
...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
|
|
13583
|
+
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13584
|
+
policy: webAuthorityPolicy(this.fleetConfig?.web), epoch: 0, now: Date.now(),
|
|
13585
|
+
});
|
|
13586
|
+
}
|
|
13156
13587
|
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
13157
13588
|
webCommand(input, opts) {
|
|
13158
13589
|
const webMeta = (instance) => {
|
|
@@ -13161,7 +13592,7 @@ export class FleetManager {
|
|
|
13161
13592
|
// #1827: a /steer or /btw reaches the agent as the web person who wrote it.
|
|
13162
13593
|
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
|
|
13163
13594
|
username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
|
|
13164
|
-
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
13595
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true, opts.authority),
|
|
13165
13596
|
};
|
|
13166
13597
|
};
|
|
13167
13598
|
const choicesFromCache = (instance) => {
|
|
@@ -13217,7 +13648,7 @@ export class FleetManager {
|
|
|
13217
13648
|
return step.noop ? { text: step.text } : { error: step.text };
|
|
13218
13649
|
if (step.kind === "choose") {
|
|
13219
13650
|
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 })) } };
|
|
13651
|
+
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
13652
|
}
|
|
13222
13653
|
const now = performance.now();
|
|
13223
13654
|
for (const [k, v] of this.webBackendTokens)
|
|
@@ -14355,7 +14786,12 @@ export class FleetManager {
|
|
|
14355
14786
|
fleetConfig: () => this.fleetConfig,
|
|
14356
14787
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
14357
14788
|
eventLog: () => this.eventLog,
|
|
14358
|
-
recoverBackendInstances: (backend, profile) =>
|
|
14789
|
+
recoverBackendInstances: (backend, profile) => {
|
|
14790
|
+
// #2072: a sign-in just finished — the pickers learn it now, not at the next pass.
|
|
14791
|
+
if (!profile)
|
|
14792
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
14793
|
+
return this.recoverBackendInstances(backend, undefined, profile ?? null);
|
|
14794
|
+
},
|
|
14359
14795
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
14360
14796
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
14361
14797
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -14874,6 +15310,8 @@ export class FleetManager {
|
|
|
14874
15310
|
// binary the same way, so a binary only a login shell could see was
|
|
14875
15311
|
// installed, reported as verified, and then offered nowhere.
|
|
14876
15312
|
this.adoptBinaryDirectory(installedAt, backend);
|
|
15313
|
+
// #2072: an install just finished — the pickers learn it now, not at the next pass.
|
|
15314
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
14877
15315
|
if (!LOGIN_FLOWS[backend]) {
|
|
14878
15316
|
this.installHandoff = null;
|
|
14879
15317
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
@@ -15108,6 +15546,8 @@ export class FleetManager {
|
|
|
15108
15546
|
buttons: m.buttons,
|
|
15109
15547
|
click: m.click,
|
|
15110
15548
|
links: m.links,
|
|
15549
|
+
channel: m.channel,
|
|
15550
|
+
from: m.from,
|
|
15111
15551
|
});
|
|
15112
15552
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
15113
15553
|
// #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
|
|
@@ -15908,10 +16348,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15908
16348
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
15909
16349
|
// #1592: the web chat shows what was asked, as for every other inbound — the user's own words (no chat-log
|
|
15910
16350
|
// context), as a user (#1306: never a role read from text), in the room's history (#1565).
|
|
15911
|
-
this.
|
|
15912
|
-
instance: instanceName, sender: msg.username, role: "user",
|
|
15913
|
-
text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
15914
|
-
});
|
|
16351
|
+
this.emitPlatformInbound(instanceName, msg, text);
|
|
15915
16352
|
}
|
|
15916
16353
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
15917
16354
|
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
@@ -17148,7 +17585,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17148
17585
|
return {
|
|
17149
17586
|
currentBackend: (name) => this.backendNameForInstance(name),
|
|
17150
17587
|
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
17151
|
-
|
|
17588
|
+
availability: () => this.backendAvailabilityFor(instanceName),
|
|
17152
17589
|
models: (backend) => this.backendModelOptions(backend, opts),
|
|
17153
17590
|
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
17154
17591
|
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
@@ -17247,10 +17684,76 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17247
17684
|
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
17248
17685
|
return () => true;
|
|
17249
17686
|
}
|
|
17250
|
-
/**
|
|
17251
|
-
|
|
17252
|
-
|
|
17253
|
-
|
|
17687
|
+
/**
|
|
17688
|
+
* #2072: what this host can run, from the published availability snapshot (backend-availability.ts). Read only: a
|
|
17689
|
+
* handler never starts a probe, not even in the background — launch, the timer, and /login install or sign-in do.
|
|
17690
|
+
*/
|
|
17691
|
+
backendAvailabilityRows() {
|
|
17692
|
+
return this.backendAvailability.snapshot();
|
|
17693
|
+
}
|
|
17694
|
+
/**
|
|
17695
|
+
* #2072 part 3: the models a Settings field offers for `backend` — the cached catalog (an instance's own credential
|
|
17696
|
+
* profile's, when it is named and has one), claude's account catalog included. Read only: never a probe.
|
|
17697
|
+
*/
|
|
17698
|
+
modelCatalogFor(backend, instance) {
|
|
17699
|
+
if (!KNOWN_BACKENDS.includes(backend) || backend === "mock")
|
|
17700
|
+
return [];
|
|
17701
|
+
const own = instance && Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, instance) ? instance
|
|
17702
|
+
: instance && this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? instance : null;
|
|
17703
|
+
const env = this.readCliEnv(backend, this.catalogProfile(backend, own));
|
|
17704
|
+
const seen = new Set(), out = [];
|
|
17705
|
+
for (const m of [...(env?.models ?? []), ...(env?.apiModels ?? [])])
|
|
17706
|
+
if (!seen.has(m.id)) {
|
|
17707
|
+
seen.add(m.id);
|
|
17708
|
+
out.push(m);
|
|
17709
|
+
}
|
|
17710
|
+
return out;
|
|
17711
|
+
}
|
|
17712
|
+
/**
|
|
17713
|
+
* #2072 (#2078 review): the snapshot as THIS instance would see it. Its sign-in on a backend is its own credential
|
|
17714
|
+
* profile's when it has one there — the host's default login says nothing about it. A profile's login is a file check
|
|
17715
|
+
* (credentialProfileLogin), no process.
|
|
17716
|
+
*/
|
|
17717
|
+
backendAvailabilityFor(instanceName) {
|
|
17718
|
+
return this.backendAvailabilityRows().map(row => {
|
|
17719
|
+
const profile = this.credentialProfileOf(instanceName, row.backend);
|
|
17720
|
+
if (!profile || (row.state !== "usable" && row.state !== "not_signed_in" && row.state !== "unknown") || !row.path)
|
|
17721
|
+
return row;
|
|
17722
|
+
let login = "unknown";
|
|
17723
|
+
try {
|
|
17724
|
+
login = credentialProfileLogin(this.dataDir, row.backend, profile).state;
|
|
17725
|
+
}
|
|
17726
|
+
catch { /* unknown */ }
|
|
17727
|
+
// Signed in there: usable for this instance. Signed out (or unknown): the host's default login says nothing about
|
|
17728
|
+
// it, so no "not signed in" claim from it — update_instance_config's profile gate refuses a signed-out profile
|
|
17729
|
+
// when applied, naming the profile and the command that signs it in.
|
|
17730
|
+
if (login === "logged-in")
|
|
17731
|
+
return { ...row, auth: "valid", state: "usable" };
|
|
17732
|
+
return row.state === "not_signed_in" ? { ...row, auth: "unknown", state: "unknown" } : row;
|
|
17733
|
+
});
|
|
17734
|
+
}
|
|
17735
|
+
/** The probes the snapshot runs (tests replace them: no CLI, no account, no network). */
|
|
17736
|
+
availabilityProbes = hostAvailabilityProbes;
|
|
17737
|
+
availabilityStore = null;
|
|
17738
|
+
availabilityTimer = null;
|
|
17739
|
+
get backendAvailability() {
|
|
17740
|
+
return this.availabilityStore ??= new BackendAvailabilityStore(KNOWN_BACKENDS.filter(b => b !== "mock" && !!BACKEND_INSTALLATION_INFO[b]), this.availabilityProbes, { onChange: () => this.noteAvailabilityPaths() });
|
|
17741
|
+
}
|
|
17742
|
+
/** A launch need not look the binary up again (a synchronous `which` on the loop) when the snapshot found it. */
|
|
17743
|
+
noteAvailabilityPaths() {
|
|
17744
|
+
for (const row of this.availabilityStore?.snapshot() ?? []) {
|
|
17745
|
+
const binary = BACKEND_INSTALLATION_INFO[row.backend]?.binary;
|
|
17746
|
+
if (binary)
|
|
17747
|
+
noteResolvedBinary(binary, row.state === "not_installed" ? null : row.path);
|
|
17748
|
+
}
|
|
17749
|
+
}
|
|
17750
|
+
/** Probe now and then every AVAILABILITY_STALE_MS; a pass that finds nothing changed starts no CLI. */
|
|
17751
|
+
startAvailabilityRefresh() {
|
|
17752
|
+
void this.backendAvailability.refresh();
|
|
17753
|
+
if (this.availabilityTimer)
|
|
17754
|
+
clearInterval(this.availabilityTimer);
|
|
17755
|
+
this.availabilityTimer = setInterval(() => { void this.backendAvailability.refresh(); }, AVAILABILITY_STALE_MS);
|
|
17756
|
+
this.availabilityTimer.unref?.();
|
|
17254
17757
|
}
|
|
17255
17758
|
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
17256
17759
|
async backendModelOptions(backend, opts) {
|
|
@@ -17592,9 +18095,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17592
18095
|
}
|
|
17593
18096
|
return undefined;
|
|
17594
18097
|
}
|
|
18098
|
+
/** #2072: from the published snapshot (no `which` on the loop); not checked yet counts as installed (no false alarm). */
|
|
17595
18099
|
isBackendInstalled(backend) {
|
|
17596
|
-
|
|
17597
|
-
|
|
18100
|
+
if (!BACKEND_INSTALLATION_INFO[backend])
|
|
18101
|
+
return false;
|
|
18102
|
+
return this.backendAvailability.get(backend)?.state !== "not_installed";
|
|
17598
18103
|
}
|
|
17599
18104
|
getMissingBackendWarning(backend) {
|
|
17600
18105
|
if (!backend)
|
|
@@ -17932,6 +18437,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17932
18437
|
clearInterval(this.eventLogPruneTimer);
|
|
17933
18438
|
this.eventLogPruneTimer = null;
|
|
17934
18439
|
}
|
|
18440
|
+
if (this.guestRoomsTimer) {
|
|
18441
|
+
clearInterval(this.guestRoomsTimer);
|
|
18442
|
+
this.guestRoomsTimer = null;
|
|
18443
|
+
}
|
|
17935
18444
|
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
17936
18445
|
this.compactWatchService?.stopAll();
|
|
17937
18446
|
if (this.outboxPruneTimer) {
|
|
@@ -18031,6 +18540,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18031
18540
|
this.adapters.clear();
|
|
18032
18541
|
this.controlClient?.stop();
|
|
18033
18542
|
this.controlClient = null;
|
|
18543
|
+
if (this.availabilityTimer) {
|
|
18544
|
+
clearInterval(this.availabilityTimer);
|
|
18545
|
+
this.availabilityTimer = null;
|
|
18546
|
+
}
|
|
18034
18547
|
if (this.healthServer) {
|
|
18035
18548
|
this.healthServerListening = false;
|
|
18036
18549
|
const server = this.healthServer;
|
|
@@ -20016,11 +20529,157 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20016
20529
|
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
20530
|
}
|
|
20018
20531
|
/**
|
|
20019
|
-
* #1920: the instances a guest session may see — derived on every request
|
|
20020
|
-
*
|
|
20532
|
+
* #1920 §3: the instances a guest session may see — derived on every request from the account's chat access, never
|
|
20533
|
+
* stored; nothing while `web.guests.enabled` is not true.
|
|
20534
|
+
* - Its connection's fleet topics and General (what it may talk to in chat): when that connection's access lets the
|
|
20535
|
+
* account in (AccessManager.isAllowed — allowed_users or paired). An `open` connection lets everyone in, so it gives
|
|
20536
|
+
* only the instances an admin named for its guests (`web.guests.open_instances`; the user's decision: least
|
|
20537
|
+
* privilege, as an open group has no list of people).
|
|
20538
|
+
* - A Telegram private ClassicBot room that is the account's own (its chat id is the user id).
|
|
20539
|
+
* - A ClassicBot group or guild room on its connection: only while its membership is verified (§3.2 — a "member"
|
|
20540
|
+
* answer read in the background, at most 15 minutes old; unknown, failed or stale hides it).
|
|
20541
|
+
* - Then an admin's narrowing (`web.guests.narrow`), which only takes away.
|
|
20542
|
+
* A fleet admin is not a guest: an admin outside the connection's access gets nothing here (admins sign in as admins).
|
|
20543
|
+
*/
|
|
20544
|
+
guestScope(owner) {
|
|
20545
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
20546
|
+
if (!owner || guests?.enabled !== true)
|
|
20547
|
+
return NO_GUEST_SCOPE;
|
|
20548
|
+
const scope = new Set(this.guestReach(owner));
|
|
20549
|
+
const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
|
|
20550
|
+
if (narrow) {
|
|
20551
|
+
const keep = new Set(narrow);
|
|
20552
|
+
for (const name of [...scope])
|
|
20553
|
+
if (!keep.has(name))
|
|
20554
|
+
scope.delete(name);
|
|
20555
|
+
}
|
|
20556
|
+
return scope.size ? scope : NO_GUEST_SCOPE;
|
|
20557
|
+
}
|
|
20558
|
+
/**
|
|
20559
|
+
* #1920 (#2104 review): what an account reaches before an admin's narrowing — its connection's topics (an open one's
|
|
20560
|
+
* named agents), its own Telegram private room, its verified group rooms. Narrowing can only take from this set, so it
|
|
20561
|
+
* is what Settings offers to narrow to. Empty while guest sign-in is off.
|
|
20562
|
+
*/
|
|
20563
|
+
guestReach(owner) {
|
|
20564
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
20565
|
+
const scope = new Set();
|
|
20566
|
+
if (guests?.enabled !== true)
|
|
20567
|
+
return scope;
|
|
20568
|
+
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
20569
|
+
const world = this.worlds.get(owner.adapterId);
|
|
20570
|
+
if (world && world.accessManager.isAllowed(owner.userId)) {
|
|
20571
|
+
const named = world.accessManager.getMode() === "open" ? new Set(own(guests.open_instances, owner.adapterId) ?? []) : null;
|
|
20572
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
20573
|
+
if (this.getInstanceAdapterId(name) !== owner.adapterId || this.isClassicInstance(name))
|
|
20574
|
+
continue;
|
|
20575
|
+
if (!named || named.has(name))
|
|
20576
|
+
scope.add(name);
|
|
20577
|
+
}
|
|
20578
|
+
}
|
|
20579
|
+
const rooms = this.classicChannels?.getAll() ?? [];
|
|
20580
|
+
for (const room of rooms) {
|
|
20581
|
+
if (this.isOwnPrivateRoom(owner, room))
|
|
20582
|
+
scope.add(room.instanceName);
|
|
20583
|
+
}
|
|
20584
|
+
const members = this.guestRooms();
|
|
20585
|
+
for (const room of guestGroupRooms(rooms, owner)) {
|
|
20586
|
+
if (members.verified(room, owner.userId))
|
|
20587
|
+
scope.add(room.instance);
|
|
20588
|
+
}
|
|
20589
|
+
return scope;
|
|
20590
|
+
}
|
|
20591
|
+
/**
|
|
20592
|
+
* #1920 §3.5: a guest session still admitted — current, with something in scope. A scope that became empty (guest
|
|
20593
|
+
* sign-in off, the account removed from the connection's access, everything narrowed away) ends the session here,
|
|
20594
|
+
* so the next request and the open stream find it gone.
|
|
20021
20595
|
*/
|
|
20022
|
-
|
|
20023
|
-
return
|
|
20596
|
+
guestStillAdmitted(record) {
|
|
20597
|
+
return this.guestAdmission(record) === "ok";
|
|
20598
|
+
}
|
|
20599
|
+
/**
|
|
20600
|
+
* The same, with a third answer (§3.2): "pending" — nothing in scope now, but a room's membership is not read yet
|
|
20601
|
+
* (a restart, a room just created). The request is refused and the session kept; its read starts in the background.
|
|
20602
|
+
*/
|
|
20603
|
+
guestAdmission(record) {
|
|
20604
|
+
if (!this.webSessions || !this.webToken || !this.webSessions.isCurrent(record, tokenEpoch(this.webToken)))
|
|
20605
|
+
return "gone";
|
|
20606
|
+
const owner = record.owner;
|
|
20607
|
+
if (this.guestScope(owner).size)
|
|
20608
|
+
return "ok";
|
|
20609
|
+
if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
|
|
20610
|
+
const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
|
|
20611
|
+
if (rooms.some((room) => members.pending(room, owner.userId))) {
|
|
20612
|
+
void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
|
|
20613
|
+
return "pending";
|
|
20614
|
+
}
|
|
20615
|
+
}
|
|
20616
|
+
this.enforceGuestSwitch();
|
|
20617
|
+
const ended = this.webSessions.revokeByHandle(record.handle);
|
|
20618
|
+
if (ended.found)
|
|
20619
|
+
this.logger.info({ adapterId: record.owner?.adapterId, durable: ended.durable }, "Guest web session ended: nothing left in its scope");
|
|
20620
|
+
return "gone";
|
|
20621
|
+
}
|
|
20622
|
+
/** #1920 §3.2: the membership answers; the read goes to the room's own connection, never waits past its deadline. */
|
|
20623
|
+
guestRooms() {
|
|
20624
|
+
this.guestRoomMembership ??= new GuestRoomMembership({
|
|
20625
|
+
now: () => performance.now(),
|
|
20626
|
+
read: (room, userId) => {
|
|
20627
|
+
const adapter = this.worlds.get(room.adapterId)?.adapter;
|
|
20628
|
+
return room.kind === "telegram" ? adapter?.isGroupMember?.(room.channelId, userId) : adapter?.canViewChannel?.(room.channelId, userId);
|
|
20629
|
+
},
|
|
20630
|
+
// Said once per key while its reads keep failing (no user id: the room and connection are what an admin fixes).
|
|
20631
|
+
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"),
|
|
20632
|
+
onRecovered: (room) => this.logger.info({ adapterId: room.adapterId, instance: room.instance }, "Guest room membership reads again"),
|
|
20633
|
+
});
|
|
20634
|
+
return this.guestRoomMembership;
|
|
20635
|
+
}
|
|
20636
|
+
/** #1920 §3.2: read this guest's rooms that are due (GuestRoomMembership.refresh says when each mode reads). */
|
|
20637
|
+
async refreshGuestRooms(owner, opts = {}) {
|
|
20638
|
+
if (this.fleetConfig?.web?.guests?.enabled !== true)
|
|
20639
|
+
return;
|
|
20640
|
+
try {
|
|
20641
|
+
await this.guestRooms().refresh(owner, guestGroupRooms(this.classicChannels?.getAll() ?? [], owner), opts);
|
|
20642
|
+
}
|
|
20643
|
+
catch (err) {
|
|
20644
|
+
this.logger.warn({ err: err.message }, "Guest room membership could not be read");
|
|
20645
|
+
}
|
|
20646
|
+
}
|
|
20647
|
+
/** Every account with a live guest session, one after another (the guest caps bound them); one pass at a time. */
|
|
20648
|
+
async refreshAllGuestRooms() {
|
|
20649
|
+
if (this.guestRoomsPass || this.fleetConfig?.web?.guests?.enabled !== true || !this.webSessions)
|
|
20650
|
+
return;
|
|
20651
|
+
this.guestRoomsPass = true;
|
|
20652
|
+
try {
|
|
20653
|
+
for (const owner of this.webSessions.guestOwners())
|
|
20654
|
+
await this.refreshGuestRooms(owner);
|
|
20655
|
+
}
|
|
20656
|
+
finally {
|
|
20657
|
+
this.guestRoomsPass = false;
|
|
20658
|
+
}
|
|
20659
|
+
}
|
|
20660
|
+
guestRoomsPass = false;
|
|
20661
|
+
/**
|
|
20662
|
+
* #1920 §3.5: guest sign-in turned off ends every guest session — checked when the configuration is read or saved
|
|
20663
|
+
* and when the sessions are opened, so turning it back on never revives one.
|
|
20664
|
+
*/
|
|
20665
|
+
enforceGuestSwitch() {
|
|
20666
|
+
if (this.fleetConfig?.web?.guests?.enabled === true || !this.webSessions)
|
|
20667
|
+
return;
|
|
20668
|
+
const ended = this.webSessions.revokeAll((r) => r.tier === "guest");
|
|
20669
|
+
if (ended.count)
|
|
20670
|
+
this.logger.info({ count: ended.count, durable: ended.durable }, "Guest web sign-in is off: every guest session ended");
|
|
20671
|
+
// #1920 step 4b: and every guest's returning device — turning it back on lets no old browser ask for a code.
|
|
20672
|
+
const devices = this.returningDevices?.revokeGuest();
|
|
20673
|
+
if (devices?.count)
|
|
20674
|
+
this.logger.info({ count: devices.count, durable: devices.durable }, "Guest web sign-in is off: every guest's returning device forgotten");
|
|
20675
|
+
}
|
|
20676
|
+
/** #1920 §5.2: whether this instance is the account's own Telegram private ClassicBot room (its chat id is the user id). */
|
|
20677
|
+
guestOwnRoom(owner, instance) {
|
|
20678
|
+
return (this.classicChannels?.getAll() ?? []).some((room) => room.instanceName === instance && this.isOwnPrivateRoom(owner, room));
|
|
20679
|
+
}
|
|
20680
|
+
/** A private room's chat id is its one user's id (a group's is negative, so never equal); Telegram only. */
|
|
20681
|
+
isOwnPrivateRoom(owner, room) {
|
|
20682
|
+
return room.adapterId === owner.adapterId && room.channelId === owner.userId && inferClassicChannelType(room.channelId) === "telegram";
|
|
20024
20683
|
}
|
|
20025
20684
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
20026
20685
|
dispatchWebHttp(req, res, port, closing = false) {
|
|
@@ -20086,6 +20745,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20086
20745
|
// handler, on this listener and the public link's gateway alike (both come through here).
|
|
20087
20746
|
if (decision.via === "session" && decision.session.tier === "guest") {
|
|
20088
20747
|
const owner = decision.session.owner;
|
|
20748
|
+
const admitted = decision.session;
|
|
20749
|
+
const admission = this.guestAdmission(admitted);
|
|
20750
|
+
if (admission === "pending") {
|
|
20751
|
+
// §3.2: a room's membership is being read — try again shortly; the session stays.
|
|
20752
|
+
res.writeHead(503, { "Retry-After": "5" });
|
|
20753
|
+
res.end(JSON.stringify({ error: "guest_scope_pending" }));
|
|
20754
|
+
return;
|
|
20755
|
+
}
|
|
20756
|
+
if (admission === "gone") {
|
|
20757
|
+
res.writeHead(401);
|
|
20758
|
+
res.end(JSON.stringify({ error: "guest_scope_empty" }));
|
|
20759
|
+
return;
|
|
20760
|
+
}
|
|
20089
20761
|
const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
|
|
20090
20762
|
if (verdict.kind === "deny") {
|
|
20091
20763
|
res.writeHead(verdict.status);
|
|
@@ -20093,10 +20765,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20093
20765
|
return;
|
|
20094
20766
|
}
|
|
20095
20767
|
// 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
|
-
|
|
20098
|
-
|
|
20099
|
-
|
|
20768
|
+
// ask its scope anew each time; an emptied scope ends the session (and closes its stream).
|
|
20769
|
+
bindGuestRequest(req, { owner, scope: () => this.guestScope(owner), current: () => this.guestStillAdmitted(admitted),
|
|
20770
|
+
// #1920 §5.2: its own Telegram private room shows it everything; the kept messages place ticks and buttons.
|
|
20771
|
+
ownRoom: (instance) => this.guestOwnRoom(owner, instance),
|
|
20772
|
+
// §3.2: a stream that connects reads its rooms again (in the background; at most once a minute).
|
|
20773
|
+
streamOpened: () => { void this.refreshGuestRooms(owner, { mode: "force" }); },
|
|
20774
|
+
history: () => this.webChatHistory,
|
|
20775
|
+
// #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
|
|
20776
|
+
chat: () => this.fleetConfig?.web?.guests?.chat !== false });
|
|
20100
20777
|
}
|
|
20101
20778
|
}
|
|
20102
20779
|
if (req.method === "GET" && req.url === "/health") {
|