@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.11

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 (357) hide show
  1. package/dist/agent-cli-instructions.md +5 -2
  2. package/dist/agent-cli.js +43 -7
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +4 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +14 -6
  7. package/dist/agent-file-read.js +207 -19
  8. package/dist/agent-file-read.js.map +1 -1
  9. package/dist/agent-whats-new.d.ts +11 -0
  10. package/dist/agent-whats-new.js +29 -15
  11. package/dist/agent-whats-new.js.map +1 -1
  12. package/dist/auth-api.d.ts +30 -1
  13. package/dist/auth-api.js +45 -13
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/authority/authority-state.d.ts +38 -0
  16. package/dist/authority/authority-state.js +114 -0
  17. package/dist/authority/authority-state.js.map +1 -0
  18. package/dist/authority/session-authorizations.d.ts +43 -0
  19. package/dist/authority/session-authorizations.js +102 -0
  20. package/dist/authority/session-authorizations.js.map +1 -0
  21. package/dist/authority/web-authority.d.ts +120 -0
  22. package/dist/authority/web-authority.js +157 -0
  23. package/dist/authority/web-authority.js.map +1 -0
  24. package/dist/backend/claude-code.d.ts +2 -2
  25. package/dist/backend/claude-code.js +10 -5
  26. package/dist/backend/claude-code.js.map +1 -1
  27. package/dist/backend/types.d.ts +14 -0
  28. package/dist/backend/types.js +25 -0
  29. package/dist/backend/types.js.map +1 -1
  30. package/dist/backend-availability.d.ts +120 -0
  31. package/dist/backend-availability.js +396 -0
  32. package/dist/backend-availability.js.map +1 -0
  33. package/dist/backend-command.d.ts +21 -3
  34. package/dist/backend-command.js +23 -4
  35. package/dist/backend-command.js.map +1 -1
  36. package/dist/bundled-skills.d.ts +14 -0
  37. package/dist/bundled-skills.js +29 -0
  38. package/dist/bundled-skills.js.map +1 -0
  39. package/dist/cache-analysis.d.ts +47 -1
  40. package/dist/cache-analysis.js +68 -8
  41. package/dist/cache-analysis.js.map +1 -1
  42. package/dist/cache-ledger.d.ts +16 -3
  43. package/dist/cache-ledger.js +33 -13
  44. package/dist/cache-ledger.js.map +1 -1
  45. package/dist/cache-report-tool.d.ts +17 -0
  46. package/dist/cache-report-tool.js +40 -0
  47. package/dist/cache-report-tool.js.map +1 -0
  48. package/dist/cache-service.d.ts +70 -3
  49. package/dist/cache-service.js +178 -17
  50. package/dist/cache-service.js.map +1 -1
  51. package/dist/channel/adapters/discord.d.ts +7 -0
  52. package/dist/channel/adapters/discord.js +35 -0
  53. package/dist/channel/adapters/discord.js.map +1 -1
  54. package/dist/channel/mcp-tools.js +22 -10
  55. package/dist/channel/mcp-tools.js.map +1 -1
  56. package/dist/channel/types.d.ts +6 -0
  57. package/dist/classic-channel-manager.d.ts +10 -5
  58. package/dist/classic-channel-manager.js +12 -9
  59. package/dist/classic-channel-manager.js.map +1 -1
  60. package/dist/cli.js +130 -31
  61. package/dist/cli.js.map +1 -1
  62. package/dist/config-validator.js +103 -4
  63. package/dist/config-validator.js.map +1 -1
  64. package/dist/daemon.d.ts +7 -0
  65. package/dist/daemon.js +63 -16
  66. package/dist/daemon.js.map +1 -1
  67. package/dist/decision-list.d.ts +25 -0
  68. package/dist/decision-list.js +30 -0
  69. package/dist/decision-list.js.map +1 -0
  70. package/dist/delivery-outbox.d.ts +10 -0
  71. package/dist/delivery-outbox.js +12 -1
  72. package/dist/delivery-outbox.js.map +1 -1
  73. package/dist/fleet-context.d.ts +10 -0
  74. package/dist/fleet-manager.d.ts +417 -11
  75. package/dist/fleet-manager.js +1387 -109
  76. package/dist/fleet-manager.js.map +1 -1
  77. package/dist/fleet-traffic.d.ts +74 -0
  78. package/dist/fleet-traffic.js +196 -0
  79. package/dist/fleet-traffic.js.map +1 -0
  80. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
  81. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
  82. package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
  83. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  84. package/dist/general-knowledge/skills/web-appearance/SKILL.md +257 -23
  85. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +478 -0
  86. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  87. package/dist/human-delivery-receipts.d.ts +24 -1
  88. package/dist/human-delivery-receipts.js +64 -11
  89. package/dist/human-delivery-receipts.js.map +1 -1
  90. package/dist/instance-config-impact.js +3 -1
  91. package/dist/instance-config-impact.js.map +1 -1
  92. package/dist/instance-names.d.ts +4 -0
  93. package/dist/instance-names.js +12 -0
  94. package/dist/instance-names.js.map +1 -0
  95. package/dist/instructions.d.ts +12 -1
  96. package/dist/instructions.js +13 -2
  97. package/dist/instructions.js.map +1 -1
  98. package/dist/locale.js +100 -8
  99. package/dist/locale.js.map +1 -1
  100. package/dist/ls-attach-resolve.d.ts +95 -0
  101. package/dist/ls-attach-resolve.js +174 -0
  102. package/dist/ls-attach-resolve.js.map +1 -0
  103. package/dist/memory-hogs.d.ts +27 -0
  104. package/dist/memory-hogs.js +124 -0
  105. package/dist/memory-hogs.js.map +1 -0
  106. package/dist/outbound-handlers.d.ts +10 -0
  107. package/dist/outbound-handlers.js +122 -2
  108. package/dist/outbound-handlers.js.map +1 -1
  109. package/dist/outbound-schemas.d.ts +164 -4
  110. package/dist/outbound-schemas.js +99 -3
  111. package/dist/outbound-schemas.js.map +1 -1
  112. package/dist/public-web-gateway.js +8 -2
  113. package/dist/public-web-gateway.js.map +1 -1
  114. package/dist/public-web-link.d.ts +9 -0
  115. package/dist/public-web-link.js +8 -0
  116. package/dist/public-web-link.js.map +1 -1
  117. package/dist/quickstart-api.d.ts +10 -1
  118. package/dist/quickstart-api.js +12 -4
  119. package/dist/quickstart-api.js.map +1 -1
  120. package/dist/reply-buttons-controller.d.ts +4 -0
  121. package/dist/reply-buttons-controller.js +2 -2
  122. package/dist/reply-buttons-controller.js.map +1 -1
  123. package/dist/restart-progress.d.ts +65 -1
  124. package/dist/restart-progress.js +236 -10
  125. package/dist/restart-progress.js.map +1 -1
  126. package/dist/rollout-index.d.ts +5 -0
  127. package/dist/rollout-index.js +26 -5
  128. package/dist/rollout-index.js.map +1 -1
  129. package/dist/scheduler/scheduler.d.ts +12 -0
  130. package/dist/scheduler/scheduler.js +83 -10
  131. package/dist/scheduler/scheduler.js.map +1 -1
  132. package/dist/service-installer.d.ts +2 -1
  133. package/dist/service-installer.js +18 -3
  134. package/dist/service-installer.js.map +1 -1
  135. package/dist/settings-api.d.ts +19 -0
  136. package/dist/settings-api.js +137 -2
  137. package/dist/settings-api.js.map +1 -1
  138. package/dist/settings-change.js +104 -0
  139. package/dist/settings-change.js.map +1 -1
  140. package/dist/settings-effect.js +17 -1
  141. package/dist/settings-effect.js.map +1 -1
  142. package/dist/tmux-manager.d.ts +5 -0
  143. package/dist/tmux-manager.js +16 -0
  144. package/dist/tmux-manager.js.map +1 -1
  145. package/dist/tool-permissions.d.ts +4 -6
  146. package/dist/tool-permissions.js +53 -5
  147. package/dist/tool-permissions.js.map +1 -1
  148. package/dist/topic-commands.d.ts +7 -1
  149. package/dist/topic-commands.js +18 -4
  150. package/dist/topic-commands.js.map +1 -1
  151. package/dist/types.d.ts +48 -1
  152. package/dist/ui/app-effects.js +77 -31
  153. package/dist/ui/chat-ambient.js +307 -0
  154. package/dist/ui/chat-effect-posters.js +8 -8
  155. package/dist/ui/chat-effects.js +101 -13
  156. package/dist/ui/chat-render.js +21 -3
  157. package/dist/ui/chat-strings.js +59 -6
  158. package/dist/ui/chat-thread.js +159 -21
  159. package/dist/ui/effect-trial-controller.js +13 -8
  160. package/dist/ui/effect-trial-panel.js +19 -16
  161. package/dist/ui/fleet-cache.js +23 -1
  162. package/dist/ui/fleet-canvas-layout.js +111 -0
  163. package/dist/ui/fleet-canvas.js +525 -0
  164. package/dist/ui/fleet-org.js +27 -12
  165. package/dist/ui/fleet-traffic.js +343 -0
  166. package/dist/ui/fleet-usage.js +401 -0
  167. package/dist/ui/panel-chat.js +154 -49
  168. package/dist/ui/panel-details.js +2 -1
  169. package/dist/ui/panel-fleet.js +43 -17
  170. package/dist/ui/panel-settings.js +26 -8
  171. package/dist/ui/preview.js +3 -2
  172. package/dist/ui/settings-appearance.js +42 -18
  173. package/dist/ui/settings-catalog.js +92 -0
  174. package/dist/ui/settings-dialogs.js +40 -12
  175. package/dist/ui/settings-effects.js +4 -3
  176. package/dist/ui/settings-guests.js +117 -0
  177. package/dist/ui/settings-learn.js +25 -0
  178. package/dist/ui/settings-model.js +15 -6
  179. package/dist/ui/settings-mods.js +23 -9
  180. package/dist/ui/settings-owner-effects.js +9 -9
  181. package/dist/ui/settings-strings.js +120 -42
  182. package/dist/ui/settings-web-authority.js +91 -0
  183. package/dist/ui/shared/agend-auth.js +1 -1
  184. package/dist/ui/shared/app-i18n.js +4 -2
  185. package/dist/ui/shared/app-prefs.js +5 -4
  186. package/dist/ui/shared/app-route.js +19 -1
  187. package/dist/ui/shared/app-session.js +2 -0
  188. package/dist/ui/shared/app-shell.js +92 -27
  189. package/dist/ui/shared/app-store.js +26 -1
  190. package/dist/ui/shared/app-stream.js +3 -1
  191. package/dist/ui/shared/app-themes.js +24 -6
  192. package/dist/ui/shared/app.css +365 -17
  193. package/dist/ui/shared/app.js +21 -14
  194. package/dist/ui/shared/card-strings.js +4 -0
  195. package/dist/ui/shared/effect-app-runtime.js +68 -19
  196. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  197. package/dist/ui/shared/effect-frame.js +26 -7
  198. package/dist/ui/shared/effect-geometry.js +24 -9
  199. package/dist/ui/shared/effect-policy.js +21 -7
  200. package/dist/ui/shared/effect-profiler.js +31 -9
  201. package/dist/ui/shared/effect-registry.js +87 -48
  202. package/dist/ui/shared/effect-stock-snapshot.js +9 -0
  203. package/dist/ui/shared/effect-stock-trust.js +62 -10
  204. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  205. package/dist/ui/shared/effect-trial-source.js +1 -1
  206. package/dist/ui/shared/effect-trial-stage.js +54 -31
  207. package/dist/ui/shared/effect-trial.css +40 -0
  208. package/dist/ui/shared/fx-motion.js +67 -0
  209. package/dist/ui/shared/identity-card.js +75 -4
  210. package/dist/ui/shared/identity-color.js +3 -3
  211. package/dist/ui/shared/instance-nav.js +194 -28
  212. package/dist/ui/shared/panel-view.js +24 -4
  213. package/dist/ui/shared/tokens.css +9 -3
  214. package/dist/ui/shared/ui-icons.js +1 -0
  215. package/dist/ui/shared/usage-dialog.js +22 -2
  216. package/dist/ui/shared/view-strings.js +68 -2
  217. package/dist/ui/shared/web-authorize.js +151 -0
  218. package/dist/ui/signin.html +1 -1
  219. package/dist/ui/web-terminal/terminal.css +4 -4
  220. package/dist/usage/providers.d.ts +28 -0
  221. package/dist/usage/providers.js +126 -36
  222. package/dist/usage/providers.js.map +1 -1
  223. package/dist/usage-report.d.ts +158 -0
  224. package/dist/usage-report.js +226 -0
  225. package/dist/usage-report.js.map +1 -0
  226. package/dist/usage-rollup.d.ts +51 -0
  227. package/dist/usage-rollup.js +168 -0
  228. package/dist/usage-rollup.js.map +1 -0
  229. package/dist/view-api.d.ts +39 -0
  230. package/dist/view-api.js +65 -4
  231. package/dist/view-api.js.map +1 -1
  232. package/dist/web-ambient.d.ts +72 -0
  233. package/dist/web-ambient.js +161 -0
  234. package/dist/web-ambient.js.map +1 -0
  235. package/dist/web-api.d.ts +69 -5
  236. package/dist/web-api.js +362 -45
  237. package/dist/web-api.js.map +1 -1
  238. package/dist/web-appearance-policy.d.ts +4 -6
  239. package/dist/web-appearance-policy.js +5 -12
  240. package/dist/web-appearance-policy.js.map +1 -1
  241. package/dist/web-appearance-tools.d.ts +3 -0
  242. package/dist/web-appearance-tools.js +22 -3
  243. package/dist/web-appearance-tools.js.map +1 -1
  244. package/dist/web-appearance-view.d.ts +27 -2
  245. package/dist/web-appearance-view.js +129 -14
  246. package/dist/web-appearance-view.js.map +1 -1
  247. package/dist/web-appearance.d.ts +17 -3
  248. package/dist/web-appearance.js +82 -7
  249. package/dist/web-appearance.js.map +1 -1
  250. package/dist/web-asset-parts.d.ts +5 -10
  251. package/dist/web-asset-parts.js +3 -3
  252. package/dist/web-asset-parts.js.map +1 -1
  253. package/dist/web-auth.js +3 -1
  254. package/dist/web-auth.js.map +1 -1
  255. package/dist/web-card.d.ts +14 -0
  256. package/dist/web-card.js +40 -6
  257. package/dist/web-card.js.map +1 -1
  258. package/dist/web-channel-echo.d.ts +7 -1
  259. package/dist/web-channel-echo.js +6 -2
  260. package/dist/web-channel-echo.js.map +1 -1
  261. package/dist/web-chat-history.d.ts +50 -0
  262. package/dist/web-chat-history.js +87 -5
  263. package/dist/web-chat-history.js.map +1 -1
  264. package/dist/web-commands.d.ts +1 -0
  265. package/dist/web-effect-bootstrap.d.ts +12 -0
  266. package/dist/web-effect-bootstrap.js +49 -0
  267. package/dist/web-effect-bootstrap.js.map +1 -0
  268. package/dist/web-effect-bundle.js +46 -1
  269. package/dist/web-effect-bundle.js.map +1 -1
  270. package/dist/web-effect-playback.d.ts +30 -0
  271. package/dist/web-effect-playback.js +71 -0
  272. package/dist/web-effect-playback.js.map +1 -0
  273. package/dist/web-effect-profiles.d.ts +4 -4
  274. package/dist/web-effect-service.d.ts +11 -4
  275. package/dist/web-effect-service.js +28 -5
  276. package/dist/web-effect-service.js.map +1 -1
  277. package/dist/web-effect-tools.d.ts +1 -0
  278. package/dist/web-effect-tools.js +8 -5
  279. package/dist/web-effect-tools.js.map +1 -1
  280. package/dist/web-effect-trials.d.ts +4 -4
  281. package/dist/web-effect-trials.js +6 -6
  282. package/dist/web-effect-trials.js.map +1 -1
  283. package/dist/web-effect-upload.d.ts +2 -4
  284. package/dist/web-effect-upload.js +3 -8
  285. package/dist/web-effect-upload.js.map +1 -1
  286. package/dist/web-effect-worker.d.ts +1 -1
  287. package/dist/web-effect-worker.js +2 -2
  288. package/dist/web-effect-worker.js.map +1 -1
  289. package/dist/web-effect.d.ts +14 -0
  290. package/dist/web-effect.js +21 -0
  291. package/dist/web-effect.js.map +1 -1
  292. package/dist/web-guest-access.d.ts +125 -0
  293. package/dist/web-guest-access.js +234 -0
  294. package/dist/web-guest-access.js.map +1 -0
  295. package/dist/web-guest-rooms.d.ts +82 -0
  296. package/dist/web-guest-rooms.js +135 -0
  297. package/dist/web-guest-rooms.js.map +1 -0
  298. package/dist/web-identity-color.d.ts +1 -1
  299. package/dist/web-identity-color.js +2 -2
  300. package/dist/web-identity-color.js.map +1 -1
  301. package/dist/web-identity-palette.d.ts +1 -1
  302. package/dist/web-identity-palette.js +2 -2
  303. package/dist/web-identity-palette.js.map +1 -1
  304. package/dist/web-image-meta.d.ts +13 -0
  305. package/dist/web-image-meta.js +62 -0
  306. package/dist/web-image-meta.js.map +1 -1
  307. package/dist/web-login.d.ts +47 -0
  308. package/dist/web-login.js +115 -0
  309. package/dist/web-login.js.map +1 -1
  310. package/dist/web-mod-agent.d.ts +1 -1
  311. package/dist/web-mod-agent.js +12 -8
  312. package/dist/web-mod-agent.js.map +1 -1
  313. package/dist/web-mod-bundle.d.ts +14 -1
  314. package/dist/web-mod-bundle.js +68 -3
  315. package/dist/web-mod-bundle.js.map +1 -1
  316. package/dist/web-mod-export.d.ts +9 -0
  317. package/dist/web-mod-export.js +21 -0
  318. package/dist/web-mod-export.js.map +1 -1
  319. package/dist/web-mod-store.d.ts +7 -1
  320. package/dist/web-mod-store.js +21 -7
  321. package/dist/web-mod-store.js.map +1 -1
  322. package/dist/web-prefs.d.ts +14 -0
  323. package/dist/web-prefs.js +24 -4
  324. package/dist/web-prefs.js.map +1 -1
  325. package/dist/web-preview.d.ts +6 -2
  326. package/dist/web-preview.js +4 -2
  327. package/dist/web-preview.js.map +1 -1
  328. package/dist/web-returning-device.d.ts +41 -0
  329. package/dist/web-returning-device.js +57 -5
  330. package/dist/web-returning-device.js.map +1 -1
  331. package/dist/web-session-authorization.d.ts +132 -0
  332. package/dist/web-session-authorization.js +193 -0
  333. package/dist/web-session-authorization.js.map +1 -0
  334. package/dist/web-session.d.ts +70 -10
  335. package/dist/web-session.js +137 -22
  336. package/dist/web-session.js.map +1 -1
  337. package/dist/web-settings-patch.d.ts +49 -2
  338. package/dist/web-settings-patch.js +145 -1
  339. package/dist/web-settings-patch.js.map +1 -1
  340. package/dist/web-settings-tools.d.ts +89 -0
  341. package/dist/web-settings-tools.js +270 -0
  342. package/dist/web-settings-tools.js.map +1 -0
  343. package/dist/web-shell-routes.d.ts +1 -1
  344. package/dist/web-shell-routes.js +1 -1
  345. package/dist/web-shell-routes.js.map +1 -1
  346. package/dist/web-theme-store.d.ts +39 -0
  347. package/dist/web-theme-store.js +122 -11
  348. package/dist/web-theme-store.js.map +1 -1
  349. package/dist/web-theme.d.ts +2 -0
  350. package/dist/web-theme.js +10 -1
  351. package/dist/web-theme.js.map +1 -1
  352. package/dist/web-upload.d.ts +13 -0
  353. package/dist/web-upload.js +39 -3
  354. package/dist/web-upload.js.map +1 -1
  355. package/dist/workflow-templates/default.md +1 -1
  356. package/package.json +1 -1
  357. package/templates/systemd.service.ejs +8 -0
package/dist/web-api.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { webHumanOrigin } from "./human-delivery-receipts.js";
2
+ import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
3
+ import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
2
4
  import { withSpawnHoldReply } from "./spawn-hold-reply.js";
3
5
  import { binaryProbe } from "./binary-probe.js";
4
6
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
@@ -41,6 +43,7 @@ import { parseMentionQuery } from "./web-mentions.js";
41
43
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
42
44
  import { measureSyncWork } from "./sync-work-attribution.js";
43
45
  import { resolveTz } from "./tz-utils.js";
46
+ import { filesPlayInChat } from "./web-effect.js";
44
47
  // ── Strict public-facing schemas ────────────────────────────────────────────
45
48
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
46
49
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -96,11 +99,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
96
99
  * the eviction the dead entry leaks forever, and without the try/catch a
97
100
  * single dead client breaks delivery to every client iterated after it.
98
101
  */
102
+ /** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
103
+ const streamGuests = new WeakMap();
104
+ /**
105
+ * #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
106
+ * its channel saw (an admin's page already has it, unchanged).
107
+ */
108
+ export function broadcastToGuests(clients, event, data, onError) {
109
+ const guests = [...clients].filter((c) => streamGuests.has(c));
110
+ if (!guests.length)
111
+ return;
112
+ const live = new Set(guests);
113
+ broadcastSseEvent(live, event, data, onError);
114
+ for (const c of guests)
115
+ if (!live.has(c))
116
+ clients.delete(c); // the ones it ended (their session went)
117
+ }
99
118
  export function broadcastSseEvent(clients, event, data, onError, id) {
100
119
  const payload = sseFrame(event, data, id);
101
120
  const dead = [];
102
121
  for (const client of clients) {
103
122
  try {
123
+ // An admin's stream gets these bytes; a guest's, the event as projected for its scope now, or nothing (no id: it
124
+ // never replays chat, so a cursor of the admin's stream is not its to hold).
125
+ const guest = streamGuests.get(client);
126
+ if (guest) {
127
+ // The exact session that opened it ended (revoked, expired, rotated away): nothing more, and the stream goes.
128
+ if (!guest.current()) {
129
+ dead.push(client);
130
+ continue;
131
+ }
132
+ const shown = guestFrameData(event, data, guest.scope(), guest);
133
+ if (shown !== undefined)
134
+ client.write(sseFrame(event, shown));
135
+ continue;
136
+ }
104
137
  client.write(payload);
105
138
  }
106
139
  catch (err) {
@@ -381,7 +414,7 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
381
414
  css = value;
382
415
  }
383
416
  catch { /* unavailable is never off */ }
384
- // CSS remains a checked runtime value. The authority refuses anything except off on start/read.
417
+ // CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
385
418
  const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
386
419
  return { actor, record: v.session, sessions };
387
420
  };
@@ -483,7 +516,12 @@ function handleEffects(req, res, url, ctx, path, method) {
483
516
  const reading = method === "GET";
484
517
  // Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
485
518
  const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
486
- if (gatewayRequestContext(req)) {
519
+ const gateway = gatewayRequestContext(req), guest = guestRequestOf(req);
520
+ const instanceQuery = url.searchParams.get("instance"), keys = [...url.searchParams.keys()];
521
+ const chatRead = reading && (path === "/ui/effects/placements" || /^\/ui\/effects\/resource\/[0-9a-f]{32}$/.test(path))
522
+ && keys.length === 2 && keys.includes("instance") && keys.includes("ownerEpoch") && !!instanceQuery && instanceQuery.length <= 128;
523
+ // Public viewing grants current Chat reads, never ordinary library/owner/Trial access or writes.
524
+ if (gateway && (!chatRead || !gateway.isCurrent())) {
487
525
  json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
488
526
  return true;
489
527
  }
@@ -495,7 +533,9 @@ function handleEffects(req, res, url, ctx, path, method) {
495
533
  return { status: 403, error: "effects_need_a_signed_in_person" };
496
534
  if (!reading && !profilePost && verdict.session.tier !== "admin")
497
535
  return { status: 403, error: "effects_read_only" };
498
- return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle };
536
+ if (verdict.session.tier === "guest" && (!chatRead || !guest || !guest.current() || !guest.scope().has(instanceQuery)))
537
+ return { status: 404, error: "not_found" };
538
+ return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle, guest: verdict.session.tier === "guest" };
499
539
  };
500
540
  const first = who();
501
541
  if ("status" in first) {
@@ -538,9 +578,9 @@ function handleEffects(req, res, url, ctx, path, method) {
538
578
  else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
539
579
  answer(service.ownerSelections(first.person));
540
580
  else if (path === "/ui/effects/placements" && instance !== undefined)
541
- answer(service.placements(first.person, instance));
581
+ answer(service.placements(first.person, instance, !!gateway || first.guest));
542
582
  else if (idPath?.[1] === "resource")
543
- answer(slot === null ? service.resource(first.person, idPath[2], instance) : service.ownerResource(first.person, idPath[2], slot));
583
+ answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
544
584
  else if (idPath?.[1] === "profile" && instance === undefined)
545
585
  answer(service.profile(first.person, idPath[2]));
546
586
  else
@@ -594,8 +634,13 @@ function handleEffects(req, res, url, ctx, path, method) {
594
634
  answer(service.remove(now.person, body));
595
635
  else if (path === "/ui/effects/share")
596
636
  answer(service.share(now.person, body));
597
- else
598
- answer(service.report(now.person, idPath[2], body, instance));
637
+ else {
638
+ if (body && typeof body === "object" && body.kind === "playback" && (url.searchParams.getAll("instance").length !== 1 || slot !== null)) {
639
+ json(res, 400, { error: "playback_report" });
640
+ return;
641
+ }
642
+ answer(service.report(now.person, idPath[2], body, instance, now.epoch));
643
+ }
599
644
  })().catch(() => { try {
600
645
  json(res, 500, { error: "effect_request_failed" });
601
646
  }
@@ -804,7 +849,7 @@ export function exportMod(ctx, person, body) {
804
849
  return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
805
850
  const parts = {};
806
851
  let omitted = 0;
807
- const wantsInstance = inc.theme === true || inc.identity === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
852
+ const wantsInstance = inc.theme === true || inc.identity === true || inc.ambient === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
808
853
  let name = null;
809
854
  if (wantsInstance) {
810
855
  if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
@@ -817,7 +862,8 @@ export function exportMod(ctx, person, body) {
817
862
  if (inc.theme === true) {
818
863
  // The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
819
864
  // Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
820
- const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e) {
865
+ // #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
866
+ const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
821
867
  omitted++;
822
868
  return [];
823
869
  } return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
@@ -830,6 +876,11 @@ export function exportMod(ctx, person, body) {
830
876
  if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
831
877
  (parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
832
878
  }
879
+ // #2117: its ambients, whoever set them (a person exports what is on the chat).
880
+ if (inc.ambient === true) {
881
+ const specs = Object.fromEntries(Object.entries(rec.placements.ambient).flatMap(([k, a]) => a ? [[k, a.spec]] : []));
882
+ parts.ambient = { specs, picture: id => { const a = rec.assets.find(x => x.id === id); const b = store.asset(name, id); return a && b ? { bytes: b.bytes, kind: a.kind } : null; } };
883
+ }
833
884
  }
834
885
  if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
835
886
  const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
@@ -869,6 +920,30 @@ function webPersonOf(req, url, ctx) {
869
920
  const who = webAccountOf(req, url, ctx);
870
921
  return who ? ctx.webPerson(who.owner) : null;
871
922
  }
923
+ /**
924
+ * #1939 S1: whether this request carries `web-session` authority, asked as it stands now — on arrival without an instance,
925
+ * after the body with the one it names (then confirmAfterBody joins the two). Never touches the session.
926
+ */
927
+ function webAuthorityOf(req, url, ctx, instance) {
928
+ if (!ctx.webAuthority)
929
+ return { authority: noAuthority(0), refusal: "not_session" };
930
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
931
+ return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
932
+ peer: classifyWebPeer(req), ...(instance !== undefined ? { instance } : {}) });
933
+ }
934
+ /**
935
+ * #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
936
+ * header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
937
+ * one). Equal before and after an await: the same admission still holds. Never touches the session.
938
+ */
939
+ function admissionOf(req, url, ctx) {
940
+ const gateway = gatewayRequestContext(req);
941
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
942
+ if (v.kind !== "allow")
943
+ return null;
944
+ const via = v.via === "session" ? `session:${v.session.handle}:${v.session.tokenEpoch}` : "token";
945
+ return gateway ? `${via}@${gateway.exposureId}` : via;
946
+ }
872
947
  /** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
873
948
  function webPersonOr401(req, res, url, ctx) {
874
949
  const p = webPersonOf(req, url, ctx);
@@ -944,7 +1019,20 @@ export function handleWebRequest(req, res, url, ctx) {
944
1019
  return true;
945
1020
  }
946
1021
  // ── Backend detection ─────────────────────────────────
1022
+ // #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
1023
+ if (method === "GET" && path === "/ui/models") {
1024
+ const backend = url.searchParams.get("backend") ?? "";
1025
+ const instance = url.searchParams.get("instance");
1026
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(backend) || (instance !== null && (instance.length < 1 || instance.length > 256))) {
1027
+ json(res, 400, { error: "backend (and optional instance) required" });
1028
+ return true;
1029
+ }
1030
+ json(res, 200, { backend, models: ctx.modelCatalogFor?.(backend, instance) ?? [] });
1031
+ return true;
1032
+ }
947
1033
  if (method === "GET" && path === "/ui/backends") {
1034
+ // #2072 (#2098 review P3-3): the backend an agent created with "fleet default" runs, so its model list can be shown.
1035
+ const defaultBackend = ctx.fleetConfig?.defaults?.backend ?? "claude-code";
948
1036
  const BACKENDS = [
949
1037
  { name: "claude-code", binary: "claude" },
950
1038
  { name: "codex", binary: "codex" },
@@ -954,6 +1042,16 @@ export function handleWebRequest(req, res, url, ctx) {
954
1042
  { name: "grok", binary: "grok" },
955
1043
  { name: "muse", binary: "muse" },
956
1044
  ];
1045
+ // #2072: the published availability snapshot — installed, signed in, new enough — when the fleet has one.
1046
+ if (ctx.backendAvailabilityRows) {
1047
+ const binaryOf = new Map(BACKENDS.map(b => [b.name, b]));
1048
+ const backends = ctx.backendAvailabilityRows().filter(r => binaryOf.has(r.backend)).map(r => ({
1049
+ name: r.backend, binary: binaryOf.get(r.backend).binary, installed: r.state !== "not_installed" && r.path !== null, path: r.path ?? "",
1050
+ deprecated: binaryOf.get(r.backend).deprecated ?? false, state: r.state, minVersion: r.minVersion, ...(r.state === "unknown" ? { unknown: true } : {}),
1051
+ }));
1052
+ json(res, 200, { backends, defaultBackend });
1053
+ return true;
1054
+ }
957
1055
  // #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
958
1056
  // runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
959
1057
  // time (shown as not installed, as the old timeout was).
@@ -963,7 +1061,7 @@ export function handleWebRequest(req, res, url, ctx) {
963
1061
  return { name: b.name, binary: b.binary, installed: path !== null, path: path ?? "", deprecated: b.deprecated ?? false,
964
1062
  ...(found.known ? {} : { unknown: true }) };
965
1063
  })).then((backends) => { if (!res.headersSent)
966
- json(res, 200, { backends }); }, () => { if (!res.headersSent)
1064
+ json(res, 200, { backends, defaultBackend }); }, () => { if (!res.headersSent)
967
1065
  json(res, 500, { error: "backend probe failed" }); });
968
1066
  return true;
969
1067
  }
@@ -976,9 +1074,24 @@ export function handleWebRequest(req, res, url, ctx) {
976
1074
  // that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
977
1075
  // review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
978
1076
  if (method === "GET" && path === "/ui/poll") {
1077
+ // #1920: a guest's poll is projected for its scope (web-guest-access.ts) — the same rule as its stream.
1078
+ const guest = guestRequestOf(req);
979
1079
  const history = ctx.webChatHistory;
980
1080
  const after = url.searchParams.get("after");
981
1081
  const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
1082
+ if (guest) {
1083
+ // #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
1084
+ json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
1085
+ if (!history)
1086
+ return [];
1087
+ if (fresh)
1088
+ return history.after(0);
1089
+ const cursor = parseLastEventId(after);
1090
+ const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
1091
+ return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
1092
+ }));
1093
+ return true;
1094
+ }
982
1095
  json(res, 200, {
983
1096
  status: ctx.getUiStatus(),
984
1097
  messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
@@ -1011,30 +1124,49 @@ export function handleWebRequest(req, res, url, ctx) {
1011
1124
  });
1012
1125
  // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
1013
1126
  // poll down by (one write: the frames a connect sends are unchanged in number).
1014
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
1127
+ // #1920: a guest's stream: its projected status now and with each heartbeat, its replay and ticks as it may see them
1128
+ // (§5.2), then only what guestFrameData lets through — no prompts or Needs you.
1129
+ const guest = guestRequestOf(req);
1130
+ if (guest) {
1131
+ streamGuests.set(res, guest);
1132
+ guest.streamOpened?.();
1133
+ }
1134
+ const statusFrame = () => `event: status\ndata: ${JSON.stringify(guest ? guestStatus(ctx.getUiStatus(), guest.scope()) : ctx.getUiStatus())}\n\n`;
1135
+ res.write(`${statusFrame()}${sseClock()}`);
1015
1136
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
1016
1137
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
1017
1138
  if (lastSeen !== null && ctx.webChatHistory) {
1018
1139
  const history = ctx.webChatHistory;
1019
- for (const m of history.replayFor(lastSeen))
1020
- res.write(sseFrame("message", m, history.cursorOf(m)));
1140
+ if (guest) {
1141
+ // Its own projection, with no cursor: an admin's ids are not a guest's to hold (it reloads its history instead).
1142
+ for (const m of guestMessages(history.replayFor(lastSeen), guest, guest.scope()))
1143
+ res.write(sseFrame("message", m));
1144
+ }
1145
+ else {
1146
+ for (const m of history.replayFor(lastSeen))
1147
+ res.write(sseFrame("message", m, history.cursorOf(m)));
1148
+ }
1021
1149
  }
1022
1150
  // Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
1023
1151
  // report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
1024
- if (ctx.webChatHistory)
1025
- res.write(`event: deliveries\ndata: ${JSON.stringify(ctx.webChatHistory.deliveries())}\n\n`);
1152
+ if (ctx.webChatHistory) {
1153
+ const ticks = guest ? guestDeliveries(ctx.webChatHistory, guest, guest.scope()) : ctx.webChatHistory.deliveries();
1154
+ res.write(`event: deliveries\ndata: ${JSON.stringify(ticks)}\n\n`);
1155
+ }
1026
1156
  // The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
1027
- if (ctx.listWebPrompts)
1157
+ if (!guest && ctx.listWebPrompts)
1028
1158
  res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
1029
1159
  // #1386: and "Needs you" as it is now; changes follow as `needs` events.
1030
- if (ctx.needsYouItems)
1160
+ if (!guest && ctx.needsYouItems)
1031
1161
  res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
1032
1162
  ctx.sseClients.add(res);
1033
1163
  const interval = setInterval(() => {
1034
1164
  // A stream authorized once must not outlive the authorization. Re-checked
1035
1165
  // without counting as activity, or an open tab would keep an idle session
1036
1166
  // alive forever; a revoked, expired or rotated-away session ends here.
1037
- if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false })) {
1167
+ // #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
1168
+ // still authenticating does not keep a revoked device's stream (with its scope) alive.
1169
+ if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
1038
1170
  // Close the connection too, not just the response: an ended response leaves the socket idle in
1039
1171
  // keep-alive, still held open by whoever's session just ended.
1040
1172
  const socket = req.socket;
@@ -1047,7 +1179,7 @@ export function handleWebRequest(req, res, url, ctx) {
1047
1179
  }
1048
1180
  try {
1049
1181
  // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
1050
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
1182
+ res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
1051
1183
  }
1052
1184
  catch {
1053
1185
  cleanup();
@@ -1081,7 +1213,10 @@ export function handleWebRequest(req, res, url, ctx) {
1081
1213
  json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
1082
1214
  return true;
1083
1215
  }
1084
- json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1216
+ // #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
1217
+ const guest = guestRequestOf(req);
1218
+ const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
1219
+ json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1085
1220
  return true;
1086
1221
  }
1087
1222
  // ── #1218: the Log tab ─────────────────────────────────
@@ -1136,6 +1271,16 @@ export function handleWebRequest(req, res, url, ctx) {
1136
1271
  const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
1137
1272
  if (method === "GET" && fileMatch) {
1138
1273
  const id = fileMatch[1];
1274
+ // #1920: a guest gets a file only when its stored instance is in scope — "no such file" otherwise, like an unknown id
1275
+ // (an id from another instance's chat is useless).
1276
+ const guest = guestRequestOf(req);
1277
+ if (guest) {
1278
+ const served = isFileId(id) ? ctx.webFiles?.servedFile?.(id) ?? null : null;
1279
+ if (!served || !guest.scope().has(served.instance)) {
1280
+ json(res, 404, { error: "No such file" });
1281
+ return true;
1282
+ }
1283
+ }
1139
1284
  // #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
1140
1285
  // memory. Every other type is answered exactly as before.
1141
1286
  const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
@@ -1167,9 +1312,10 @@ export function handleWebRequest(req, res, url, ctx) {
1167
1312
  return true;
1168
1313
  }
1169
1314
  // ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
1170
- // GET /ui/themes: { rev, themes, locked, widgets } when the status frame's themes_rev moves (passive, web-auth.ts).
1315
+ // GET /ui/themes: { rev, themes, locked, widgets, ambient (#2117) } when the status frame's themes_rev moves (passive, web-auth.ts).
1171
1316
  // GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
1172
- // POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out): a person's, from an admin session — never the CLI token, never the public
1317
+ // POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out) | ambient (#2117: { spec } sets one,
1318
+ // { which? } alone clears that kind or both): a person's, from an admin session — never the CLI token, never the public
1173
1319
  // link (absent from its manifest, and refused here too).
1174
1320
  if (method === "GET" && path === "/ui/themes") {
1175
1321
  const store = ctx.themeStore;
@@ -1177,7 +1323,7 @@ export function handleWebRequest(req, res, url, ctx) {
1177
1323
  json(res, 404, { error: "Themes are not kept on this fleet" });
1178
1324
  return true;
1179
1325
  }
1180
- json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets() });
1326
+ json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets(), ambient: store.ambients() });
1181
1327
  return true;
1182
1328
  }
1183
1329
  const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
@@ -1203,7 +1349,7 @@ export function handleWebRequest(req, res, url, ctx) {
1203
1349
  res.end(image.bytes);
1204
1350
  return true;
1205
1351
  }
1206
- const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget)$/);
1352
+ const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget|ambient)$/);
1207
1353
  if (method === "POST" && themeWrite) {
1208
1354
  const service = ctx.webAppearance;
1209
1355
  if (!service) {
@@ -1255,7 +1401,8 @@ export function handleWebRequest(req, res, url, ctx) {
1255
1401
  }
1256
1402
  const r = themeWrite[2] === "reset" ? service.humanReset(name)
1257
1403
  : themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
1258
- : typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
1404
+ : themeWrite[2] === "ambient" ? service.humanSetAmbient(name, body.spec ?? null, body.which)
1405
+ : typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
1259
1406
  if (typeof r.error === "string") {
1260
1407
  json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
1261
1408
  return;
@@ -1439,6 +1586,7 @@ export function handleWebRequest(req, res, url, ctx) {
1439
1586
  return true;
1440
1587
  }
1441
1588
  const click = ctx.clickWebReplyButton.bind(ctx);
1589
+ const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
1442
1590
  (async () => {
1443
1591
  let body;
1444
1592
  try {
@@ -1455,13 +1603,29 @@ export function handleWebRequest(req, res, url, ctx) {
1455
1603
  json(res, 400, { error: "instance, id and index required" });
1456
1604
  return;
1457
1605
  }
1606
+ // #1920 step 2b: a guest clicks only on a reply it may see (§5.2), of an in-scope instance, while guests may chat.
1607
+ const guest = guestRequestOf(req);
1608
+ const refused = guestChatRefusal(guest, instance);
1609
+ if (refused) {
1610
+ json(res, refused.status, { error: refused.error });
1611
+ return;
1612
+ }
1613
+ if (guest) {
1614
+ const row = guest.history?.()?.findByButtons(instance, id);
1615
+ if (!row || !guestSeesMessage(row, guest, guest.scope())) {
1616
+ json(res, 404, { error: "not found" });
1617
+ return;
1618
+ }
1619
+ }
1458
1620
  // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
1459
1621
  // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
1460
1622
  // gate (a session revoked meanwhile) clicks nothing as anyone.
1461
1623
  const person = webPersonOr401(req, res, url, ctx);
1462
1624
  if (!person)
1463
1625
  return;
1464
- const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
1626
+ // #1920 review: a guest's choice reaches the agent marked as a guest's, as its typed messages do.
1627
+ const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
1628
+ const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name, ...(guest ? { guest: true } : {}), authority });
1465
1629
  json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
1466
1630
  })().catch(err => {
1467
1631
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
@@ -1639,12 +1803,57 @@ export function handleWebRequest(req, res, url, ctx) {
1639
1803
  // #1269: an instance's chat command (/ctx, /compact, /clear, /model, …) — a session write (CSRF, checked by the
1640
1804
  // gate), run through the platforms' own handlers and command table. The public link is told it is one (/save is
1641
1805
  // refused there).
1806
+ // ── #1939 S2b: this browser's authorization — what the app shows (GET), and "Authorize this browser" (POST: posts the
1807
+ // Confirm card in the account's DM). Judged on this request (§3), never touching the session.
1808
+ if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
1809
+ const judge = () => {
1810
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1811
+ return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: classifyWebPeer(req) };
1812
+ };
1813
+ res.setHeader("Cache-Control", "no-store");
1814
+ if (method === "GET") {
1815
+ if (!ctx.webSessionAuthorization) {
1816
+ json(res, 404, { error: "not available" });
1817
+ return true;
1818
+ }
1819
+ json(res, 200, ctx.webSessionAuthorization(judge()));
1820
+ return true;
1821
+ }
1822
+ if (!ctx.requestSessionAuthorization) {
1823
+ json(res, 404, { error: "not available" });
1824
+ return true;
1825
+ }
1826
+ const ask = ctx.requestSessionAuthorization.bind(ctx);
1827
+ (async () => {
1828
+ try {
1829
+ await parseBody(req);
1830
+ }
1831
+ catch {
1832
+ json(res, 400, { error: "Invalid JSON" });
1833
+ return;
1834
+ }
1835
+ if (!permitWebContinuation(req, res, ctx))
1836
+ return;
1837
+ const r = await ask(judge()); // asked again now the body is in
1838
+ // A refusal is an answer the page shows (pending, three this hour, why not), not a failed request: 200 with
1839
+ // ok: false — a 4xx would be logged as an error in the browser's console every time.
1840
+ json(res, 200, r);
1841
+ })().catch(err => {
1842
+ ctx.logger.error({ err: err.message }, "Web authorization request failed");
1843
+ try {
1844
+ json(res, 500, { error: "Request failed" });
1845
+ }
1846
+ catch { /* already answered */ }
1847
+ });
1848
+ return true;
1849
+ }
1642
1850
  if (method === "POST" && path === "/ui/command") {
1643
1851
  if (!ctx.webCommand) {
1644
1852
  json(res, 404, { error: "Not available" });
1645
1853
  return true;
1646
1854
  }
1647
1855
  const run = ctx.webCommand.bind(ctx);
1856
+ const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
1648
1857
  (async () => {
1649
1858
  let body;
1650
1859
  try {
@@ -1673,7 +1882,8 @@ export function handleWebRequest(req, res, url, ctx) {
1673
1882
  const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
1674
1883
  if (!person)
1675
1884
  return;
1676
- const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
1885
+ const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
1886
+ const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority });
1677
1887
  json(res, r.status, r.body);
1678
1888
  })().catch(err => {
1679
1889
  ctx.logger.error({ err: err.message }, "Web command failed");
@@ -1703,6 +1913,12 @@ export function handleWebRequest(req, res, url, ctx) {
1703
1913
  json(res, 404, { error: `Instance not found: ${name}` });
1704
1914
  return true;
1705
1915
  }
1916
+ // #1920 step 2b: a guest's Cancel is the platform button's (anyone allowed there), while guests may chat.
1917
+ const cancelRefused = guestChatRefusal(guestRequestOf(req), name);
1918
+ if (cancelRefused) {
1919
+ json(res, cancelRefused.status, { error: cancelRefused.error });
1920
+ return true;
1921
+ }
1706
1922
  if (!ctx.cancelInstance(name)) {
1707
1923
  json(res, 409, { error: `${name} is not running` });
1708
1924
  return true;
@@ -2112,6 +2328,47 @@ export function handleWebRequest(req, res, url, ctx) {
2112
2328
  }
2113
2329
  return true;
2114
2330
  }
2331
+ // #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
2332
+ // usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
2333
+ // from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
2334
+ if (method === "GET" && path === "/ui/usage-report") {
2335
+ // Who may have it, asked on arrival and again before it is sent (the report is awaited: a switch turned off, a
2336
+ // session signed out or a public link ended meanwhile gets nothing). The same boundary both times, no new gate.
2337
+ const refusal = () => {
2338
+ if (!ctx.usageReport || ctx.fleetConfig?.web?.usage_panel === false)
2339
+ return [404, "Not available"];
2340
+ if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.usage_report_on_public_link !== true)
2341
+ return [403, "usage_report_not_over_public_link"];
2342
+ return null;
2343
+ };
2344
+ // Over the public link, where to turn it on: Settings → Web on the dashboard reached directly (this computer's own
2345
+ // address — the public link's Settings only reads). Never the public origin's own Settings, which cannot change it.
2346
+ const answer = ([status, error]) => json(res, status, {
2347
+ error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web` } : {})
2348
+ });
2349
+ const first = refusal();
2350
+ if (first) {
2351
+ answer(first);
2352
+ return true;
2353
+ }
2354
+ const admitted = admissionOf(req, url, ctx);
2355
+ if (!admitted) {
2356
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2357
+ return true;
2358
+ }
2359
+ void import("./usage-report.js").then(({ usageRange, usageGroup }) => ctx.usageReport({
2360
+ range: usageRange(url.searchParams.get("range"), url.searchParams.get("from") ?? undefined, url.searchParams.get("to") ?? undefined),
2361
+ group: usageGroup(url.searchParams.get("group")),
2362
+ })).then((report) => {
2363
+ const now = refusal();
2364
+ if (now)
2365
+ return answer(now);
2366
+ if (admissionOf(req, url, ctx) !== admitted)
2367
+ return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2368
+ json(res, 200, report);
2369
+ }, (err) => { ctx.logger.error({ err }, "usage report failed"); json(res, 500, { error: "Usage report failed" }); });
2370
+ return true;
2371
+ }
2115
2372
  // #1468: the prompt-cache expiry analysis. Opening it is a person's navigation, like any Fleet tab (it counts as
2116
2373
  // use); the page never re-reads it on a timer. Answered from the ledgers as they are; a catch-up runs behind.
2117
2374
  if (method === "GET" && path === "/ui/cache") {
@@ -2129,6 +2386,12 @@ export function handleWebRequest(req, res, url, ctx) {
2129
2386
  json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
2130
2387
  return true;
2131
2388
  }
2389
+ // #1918: the org canvas's traffic on opening, and the public link's (which has no stream) on its poll cadence. The
2390
+ // fleet works it out at most once every two seconds and caps it, whoever asks how often.
2391
+ if (method === "GET" && path === "/ui/traffic") {
2392
+ json(res, 200, ctx.trafficSnapshot?.() ?? { now: new Date().toISOString(), items: [], delegations: [] });
2393
+ return true;
2394
+ }
2132
2395
  if (method === "GET" && path === "/ui/teams") {
2133
2396
  const teams = ctx.fleetConfig?.teams ?? {};
2134
2397
  json(res, 200, { teams });
@@ -2317,6 +2580,7 @@ function webEchoPreview(message, names) {
2317
2580
  }
2318
2581
  /** Handle POST /ui/send — extracted for readability. */
2319
2582
  function handleSendMessage(req, res, ctx) {
2583
+ const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
2320
2584
  let body = "";
2321
2585
  let size = 0;
2322
2586
  req.on("data", (chunk) => {
@@ -2340,6 +2604,17 @@ function handleSendMessage(req, res, ctx) {
2340
2604
  return;
2341
2605
  }
2342
2606
  const { instance, message: typed, attachments = [] } = parsed.data;
2607
+ // #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
2608
+ const guest = guestRequestOf(req);
2609
+ const refused = guestChatRefusal(guest, instance);
2610
+ if (refused) {
2611
+ json(res, refused.status, { error: refused.error });
2612
+ return;
2613
+ }
2614
+ if (guest && guestCommandText(typed)) {
2615
+ json(res, 403, { error: "guest_no_commands" });
2616
+ return;
2617
+ }
2343
2618
  const ipc = ctx.instanceIpcClients.get(instance);
2344
2619
  if (!ipc) {
2345
2620
  json(res, 404, { error: `Instance not found: ${instance}` });
@@ -2373,6 +2648,8 @@ function handleSendMessage(req, res, ctx) {
2373
2648
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2374
2649
  return;
2375
2650
  }
2651
+ // #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
2652
+ const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
2376
2653
  const ts = new Date().toISOString();
2377
2654
  // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
2378
2655
  const messageId = newWebMessageId();
@@ -2387,11 +2664,13 @@ function handleSendMessage(req, res, ctx) {
2387
2664
  && !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
2388
2665
  // Add no runtime credentials or local attachment paths to the display copy.
2389
2666
  const preview = webEchoPreview(message, files.map(f => f.name));
2390
- // ClassicBot echo (#1320 part B): same ordering lane, per-entry
2391
- // opt-in resolved at send time inside sendClassicWebEcho.
2667
+ // ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
2668
+ // the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
2392
2669
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
2393
2670
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2394
- await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
2671
+ const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
2672
+ if (posted > 0)
2673
+ ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
2395
2674
  }) : undefined;
2396
2675
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2397
2676
  // A replacement adapter or edited binding is not the route we reserved.
@@ -2399,12 +2678,14 @@ function handleSendMessage(req, res, ctx) {
2399
2678
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
2400
2679
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
2401
2680
  return;
2402
- return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
2681
+ const sent = await syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
2682
+ ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
2683
+ return sent;
2403
2684
  }) : undefined;
2404
2685
  let delivered;
2405
2686
  try {
2406
2687
  const deliver = (name, payload) => ctx.deliverHumanToInstance
2407
- ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
2688
+ ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed)
2408
2689
  : ctx.deliverToInstance(name, payload);
2409
2690
  delivered = await deliver(instance, {
2410
2691
  type: "fleet_inbound",
@@ -2417,6 +2698,7 @@ function handleSendMessage(req, res, ctx) {
2417
2698
  ts,
2418
2699
  thread_id: topicId != null ? String(topicId) : "",
2419
2700
  source: "web",
2701
+ ...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
2420
2702
  adapter_id: syncAdapter?.id,
2421
2703
  ...delivery.meta,
2422
2704
  },
@@ -2453,7 +2735,8 @@ function handleSendMessage(req, res, ctx) {
2453
2735
  ctx.webFiles?.commit(files);
2454
2736
  ctx.lastInboundUser.set(instance, person.name);
2455
2737
  ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
2456
- ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
2738
+ // #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
2739
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}) });
2457
2740
  json(res, 200, { sent: true, messageId });
2458
2741
  }
2459
2742
  catch {
@@ -2477,6 +2760,13 @@ function handleUpload(req, res, url, ctx) {
2477
2760
  json(res, 404, { error: "uploads are not available" });
2478
2761
  return;
2479
2762
  }
2763
+ // #1920 step 2b: a guest's upload is for an in-scope instance's message, while guests may chat.
2764
+ const guestRefused = guestChatRefusal(guestRequestOf(req), instance);
2765
+ if (guestRefused) {
2766
+ req.resume();
2767
+ json(res, guestRefused.status, { error: guestRefused.error });
2768
+ return;
2769
+ }
2480
2770
  if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
2481
2771
  json(res, 404, { error: `Instance not found: ${instance}` });
2482
2772
  return;
@@ -2671,14 +2961,22 @@ function prefsViewer(req, url, ctx) {
2671
2961
  const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2672
2962
  if (v.kind !== "allow")
2673
2963
  return null;
2964
+ // #1920: a guest is a guest on every listener. The public link's page and reads too — before, a guest signed in through
2965
+ // it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
2966
+ // scope. Its account (phase 3) names it on its page.
2967
+ const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true, ...(v.session.owner ? { owner: v.session.owner } : {}) } : {};
2674
2968
  if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
2675
2969
  const person = webPersonKey(v.session.owner);
2676
- return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
2970
+ return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
2677
2971
  }
2678
- return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
2972
+ return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
2679
2973
  }
2680
2974
  const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
2681
- const prefsView = (prefs, v) => ({ ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) });
2975
+ // #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
2976
+ const prefsView = (prefs, v) => {
2977
+ const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
2978
+ return v?.guest ? guestPrefs(view) : view;
2979
+ };
2682
2980
  function shellHtmlTag(ctx, mode, viewer) {
2683
2981
  if (mode !== "full" || !ctx.webPrefs)
2684
2982
  return '<html lang="en">';
@@ -2692,26 +2990,40 @@ function shellHtmlTag(ctx, mode, viewer) {
2692
2990
  * the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
2693
2991
  */
2694
2992
  function shellOwnerCss(ctx, mode, viewer) {
2695
- return mode === "full" && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
2993
+ return mode === "full" && !viewer?.guest && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
2994
+ }
2995
+ function guestBodyAttrs(ctx, owner, attr) {
2996
+ const badge = ctx.guestBadge?.(owner);
2997
+ return ` data-tier="guest"${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
2998
+ }
2999
+ /** A separate session fence for Chat playback: public/guest viewers acquire no owner-pref identity. */
3000
+ function shellEffect(req, ctx, mode) {
3001
+ if (mode !== "full" || typeof ctx.effectBoot !== "string" || !/^[0-9a-f]{32}$/.test(ctx.effectBoot))
3002
+ return null;
3003
+ const v = evaluateWebRequest(req, new URL(req.url ?? "/", "http://localhost"), ctx.webToken ?? null, ctx.webSessions, { touch: false });
3004
+ if (v.kind !== "allow" || v.via !== "session" || !v.session || !/^[a-f0-9]{16}$/.test(v.session.handle))
3005
+ return null;
3006
+ return { boot: ctx.effectBoot, epoch: v.session.handle, scope: gatewayRequestContext(req) || v.session.tier === "guest" ? "chat" : "owner" };
2696
3007
  }
2697
3008
  function shellBodyTag(req, ctx, mode, viewer) {
2698
3009
  const p = shellPreview(req, ctx, mode);
2699
- const ownerCss = shellOwnerCss(ctx, mode, viewer);
2700
- let ownerCssState = "unknown";
2701
- if (mode === "full" && viewer?.person != null && ctx.ownerCss) {
3010
+ const ownerCss = shellOwnerCss(ctx, mode, viewer), effect = shellEffect(req, ctx, mode);
3011
+ let ownerCssState = mode === "full" && (gatewayRequestContext(req) || viewer?.guest) ? "off" : "unknown";
3012
+ if (mode === "full" && !viewer?.guest && viewer?.person != null && ctx.ownerCss) {
2702
3013
  ownerCssState = ctx.ownerCss.effectState(viewer.person);
2703
3014
  if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
2704
3015
  ownerCssState = "unknown";
2705
3016
  }
2706
3017
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
2707
3018
  return `<body data-mode="${mode}"${mode === "full" && gatewayRequestContext(req) ? ' data-web-transport="poll"' : ""} data-dashboard-origin="${attr(p?.dashboardOrigin)}" data-preview-origin="${attr(p?.previewOrigin)}"`
3019
+ + `${effect ? ` data-effect-boot="${effect.boot}" data-effect-epoch="${effect.epoch}" data-effect-scope="${effect.scope}"` : ""}`
2708
3020
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
2709
3021
  // #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
2710
3022
  + ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
2711
3023
  // #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
2712
3024
  + `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
2713
- // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔.
2714
- + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
3025
+ // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔ — never to a guest (#1920).
3026
+ + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
2715
3027
  // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
2716
3028
  + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
2717
3029
  // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
@@ -2722,7 +3034,9 @@ function shellBodyTag(req, ctx, mode, viewer) {
2722
3034
  // #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
2723
3035
  + ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
2724
3036
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
2725
- + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
3037
+ + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
3038
+ // #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
3039
+ + `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
2726
3040
  }
2727
3041
  /** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
2728
3042
  * when this load chose one. */
@@ -2738,8 +3052,11 @@ export function serveAppShell(req, res, ctx, mode) {
2738
3052
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
2739
3053
  // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
2740
3054
  // the page may load); every other page frames exactly the origin this load chose (#1723).
2741
- const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin
2742
- ? `${p.previewOrigin}/frame${mode === "full" && !gatewayRequestContext(req) ? ` ${p.previewOrigin}/effect` : ""}` : null;
3055
+ const previewFrames = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
3056
+ // This fixed trusted document is independent of HTML preview. Its own response confines all Worker execution.
3057
+ const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
3058
+ const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
3059
+ const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
2743
3060
  sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
2744
3061
  }
2745
3062
  catch {