@songsid/agend 2.2.1 → 2.3.0-beta.10

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 (437) hide show
  1. package/dist/agent-cli-instructions.md +5 -2
  2. package/dist/agent-cli.js +284 -2
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.d.ts +2 -0
  5. package/dist/agent-endpoint.js +29 -0
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/agent-file-read.d.ts +38 -0
  8. package/dist/agent-file-read.js +317 -0
  9. package/dist/agent-file-read.js.map +1 -0
  10. package/dist/agent-whats-new.d.ts +11 -0
  11. package/dist/agent-whats-new.js +29 -2
  12. package/dist/agent-whats-new.js.map +1 -1
  13. package/dist/atomic-write.js +9 -1
  14. package/dist/atomic-write.js.map +1 -1
  15. package/dist/auth-api.d.ts +30 -1
  16. package/dist/auth-api.js +61 -12
  17. package/dist/auth-api.js.map +1 -1
  18. package/dist/authority/authority-state.d.ts +38 -0
  19. package/dist/authority/authority-state.js +114 -0
  20. package/dist/authority/authority-state.js.map +1 -0
  21. package/dist/authority/session-authorizations.d.ts +43 -0
  22. package/dist/authority/session-authorizations.js +102 -0
  23. package/dist/authority/session-authorizations.js.map +1 -0
  24. package/dist/authority/web-authority.d.ts +120 -0
  25. package/dist/authority/web-authority.js +157 -0
  26. package/dist/authority/web-authority.js.map +1 -0
  27. package/dist/backend/antigravity.js +3 -3
  28. package/dist/backend/antigravity.js.map +1 -1
  29. package/dist/backend/claude-code.d.ts +2 -2
  30. package/dist/backend/claude-code.js +10 -5
  31. package/dist/backend/claude-code.js.map +1 -1
  32. package/dist/backend/launch-effort.js +1 -1
  33. package/dist/backend/launch-effort.js.map +1 -1
  34. package/dist/backend/muse.js +10 -2
  35. package/dist/backend/muse.js.map +1 -1
  36. package/dist/backend/types.d.ts +14 -0
  37. package/dist/backend/types.js +25 -0
  38. package/dist/backend/types.js.map +1 -1
  39. package/dist/backend-availability.d.ts +120 -0
  40. package/dist/backend-availability.js +396 -0
  41. package/dist/backend-availability.js.map +1 -0
  42. package/dist/backend-command.d.ts +21 -3
  43. package/dist/backend-command.js +23 -4
  44. package/dist/backend-command.js.map +1 -1
  45. package/dist/bundled-skills.d.ts +14 -0
  46. package/dist/bundled-skills.js +29 -0
  47. package/dist/bundled-skills.js.map +1 -0
  48. package/dist/cache-analysis.d.ts +47 -1
  49. package/dist/cache-analysis.js +68 -8
  50. package/dist/cache-analysis.js.map +1 -1
  51. package/dist/cache-ledger.d.ts +16 -3
  52. package/dist/cache-ledger.js +33 -13
  53. package/dist/cache-ledger.js.map +1 -1
  54. package/dist/cache-report-tool.d.ts +17 -0
  55. package/dist/cache-report-tool.js +40 -0
  56. package/dist/cache-report-tool.js.map +1 -0
  57. package/dist/cache-service.d.ts +70 -3
  58. package/dist/cache-service.js +178 -17
  59. package/dist/cache-service.js.map +1 -1
  60. package/dist/channel/adapters/discord.d.ts +9 -0
  61. package/dist/channel/adapters/discord.js +52 -2
  62. package/dist/channel/adapters/discord.js.map +1 -1
  63. package/dist/channel/adapters/telegram.d.ts +8 -1
  64. package/dist/channel/adapters/telegram.js +74 -4
  65. package/dist/channel/adapters/telegram.js.map +1 -1
  66. package/dist/channel/mcp-tools.js +48 -2
  67. package/dist/channel/mcp-tools.js.map +1 -1
  68. package/dist/channel/types.d.ts +21 -0
  69. package/dist/classic-channel-manager.d.ts +10 -5
  70. package/dist/classic-channel-manager.js +12 -9
  71. package/dist/classic-channel-manager.js.map +1 -1
  72. package/dist/cli.js +130 -31
  73. package/dist/cli.js.map +1 -1
  74. package/dist/config-validator.js +119 -4
  75. package/dist/config-validator.js.map +1 -1
  76. package/dist/daemon.d.ts +5 -0
  77. package/dist/daemon.js +273 -22
  78. package/dist/daemon.js.map +1 -1
  79. package/dist/decision-list.d.ts +25 -0
  80. package/dist/decision-list.js +30 -0
  81. package/dist/decision-list.js.map +1 -0
  82. package/dist/delivery-outbox.d.ts +10 -0
  83. package/dist/delivery-outbox.js +12 -1
  84. package/dist/delivery-outbox.js.map +1 -1
  85. package/dist/fleet-context.d.ts +15 -0
  86. package/dist/fleet-manager.d.ts +506 -11
  87. package/dist/fleet-manager.js +1784 -130
  88. package/dist/fleet-manager.js.map +1 -1
  89. package/dist/fleet-traffic.d.ts +74 -0
  90. package/dist/fleet-traffic.js +196 -0
  91. package/dist/fleet-traffic.js.map +1 -0
  92. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
  93. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
  94. package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
  95. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  96. package/dist/general-knowledge/skills/web-appearance/SKILL.md +492 -0
  97. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +476 -0
  98. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  99. package/dist/human-delivery-receipts.d.ts +24 -1
  100. package/dist/human-delivery-receipts.js +64 -11
  101. package/dist/human-delivery-receipts.js.map +1 -1
  102. package/dist/instance-avatar.js +3 -0
  103. package/dist/instance-avatar.js.map +1 -1
  104. package/dist/instance-config-impact.js +6 -1
  105. package/dist/instance-config-impact.js.map +1 -1
  106. package/dist/instance-lifecycle.js +9 -4
  107. package/dist/instance-lifecycle.js.map +1 -1
  108. package/dist/instance-names.d.ts +4 -0
  109. package/dist/instance-names.js +12 -0
  110. package/dist/instance-names.js.map +1 -0
  111. package/dist/instructions.d.ts +12 -1
  112. package/dist/instructions.js +13 -2
  113. package/dist/instructions.js.map +1 -1
  114. package/dist/locale.js +100 -8
  115. package/dist/locale.js.map +1 -1
  116. package/dist/ls-attach-resolve.d.ts +95 -0
  117. package/dist/ls-attach-resolve.js +174 -0
  118. package/dist/ls-attach-resolve.js.map +1 -0
  119. package/dist/memory-hogs.d.ts +27 -0
  120. package/dist/memory-hogs.js +124 -0
  121. package/dist/memory-hogs.js.map +1 -0
  122. package/dist/needs-you-hub.d.ts +4 -0
  123. package/dist/needs-you-hub.js +31 -6
  124. package/dist/needs-you-hub.js.map +1 -1
  125. package/dist/needs-you.d.ts +1 -13
  126. package/dist/needs-you.js +3 -37
  127. package/dist/needs-you.js.map +1 -1
  128. package/dist/outbound-handlers.d.ts +15 -0
  129. package/dist/outbound-handlers.js +152 -11
  130. package/dist/outbound-handlers.js.map +1 -1
  131. package/dist/outbound-schemas.d.ts +228 -4
  132. package/dist/outbound-schemas.js +147 -1
  133. package/dist/outbound-schemas.js.map +1 -1
  134. package/dist/public-web-gateway.js +21 -1
  135. package/dist/public-web-gateway.js.map +1 -1
  136. package/dist/public-web-link.d.ts +9 -0
  137. package/dist/public-web-link.js +8 -0
  138. package/dist/public-web-link.js.map +1 -1
  139. package/dist/quickstart-api.d.ts +10 -1
  140. package/dist/quickstart-api.js +12 -4
  141. package/dist/quickstart-api.js.map +1 -1
  142. package/dist/reply-buttons-controller.d.ts +4 -0
  143. package/dist/reply-buttons-controller.js +2 -2
  144. package/dist/reply-buttons-controller.js.map +1 -1
  145. package/dist/repo-worktree-guard.d.ts +36 -0
  146. package/dist/repo-worktree-guard.js +143 -0
  147. package/dist/repo-worktree-guard.js.map +1 -0
  148. package/dist/restart-progress.d.ts +65 -1
  149. package/dist/restart-progress.js +236 -10
  150. package/dist/restart-progress.js.map +1 -1
  151. package/dist/rollout-index.d.ts +5 -0
  152. package/dist/rollout-index.js +26 -5
  153. package/dist/rollout-index.js.map +1 -1
  154. package/dist/scheduler/scheduler.d.ts +70 -1
  155. package/dist/scheduler/scheduler.js +256 -17
  156. package/dist/scheduler/scheduler.js.map +1 -1
  157. package/dist/service-installer.d.ts +2 -1
  158. package/dist/service-installer.js +18 -3
  159. package/dist/service-installer.js.map +1 -1
  160. package/dist/settings-api.d.ts +19 -0
  161. package/dist/settings-api.js +210 -2
  162. package/dist/settings-api.js.map +1 -1
  163. package/dist/settings-change.js +112 -0
  164. package/dist/settings-change.js.map +1 -1
  165. package/dist/settings-effect.js +32 -2
  166. package/dist/settings-effect.js.map +1 -1
  167. package/dist/settings-http-confirmation.js +5 -0
  168. package/dist/settings-http-confirmation.js.map +1 -1
  169. package/dist/tmux-manager.d.ts +5 -0
  170. package/dist/tmux-manager.js +16 -0
  171. package/dist/tmux-manager.js.map +1 -1
  172. package/dist/tool-permissions.d.ts +4 -6
  173. package/dist/tool-permissions.js +100 -2
  174. package/dist/tool-permissions.js.map +1 -1
  175. package/dist/topic-commands.d.ts +17 -7
  176. package/dist/topic-commands.js +39 -21
  177. package/dist/topic-commands.js.map +1 -1
  178. package/dist/topic-link.d.ts +128 -0
  179. package/dist/topic-link.js +269 -0
  180. package/dist/topic-link.js.map +1 -0
  181. package/dist/types.d.ts +61 -1
  182. package/dist/ui/app-effects.js +233 -0
  183. package/dist/ui/chat-ambient.js +307 -0
  184. package/dist/ui/chat-effect-posters.js +42 -0
  185. package/dist/ui/chat-effects.js +160 -0
  186. package/dist/ui/chat-live-replies.js +58 -0
  187. package/dist/ui/chat-render.js +9 -6
  188. package/dist/ui/chat-store.js +9 -3
  189. package/dist/ui/chat-strings.js +70 -2
  190. package/dist/ui/chat-thread.js +191 -37
  191. package/dist/ui/chat-tour.js +1 -1
  192. package/dist/ui/effect-trial-controller.js +124 -0
  193. package/dist/ui/effect-trial-offer.js +31 -0
  194. package/dist/ui/effect-trial-panel.js +40 -0
  195. package/dist/ui/fleet-cache.js +23 -1
  196. package/dist/ui/fleet-canvas-layout.js +111 -0
  197. package/dist/ui/fleet-canvas.js +525 -0
  198. package/dist/ui/fleet-org.js +27 -12
  199. package/dist/ui/fleet-traffic.js +343 -0
  200. package/dist/ui/fleet-usage.js +401 -0
  201. package/dist/ui/image-lightbox.js +1 -1
  202. package/dist/ui/panel-chat.js +203 -57
  203. package/dist/ui/panel-details.js +20 -6
  204. package/dist/ui/panel-fleet.js +43 -18
  205. package/dist/ui/panel-log.js +6 -6
  206. package/dist/ui/panel-needs.js +3 -3
  207. package/dist/ui/panel-settings.js +34 -13
  208. package/dist/ui/preview-panel.js +25 -9
  209. package/dist/ui/preview.js +3 -2
  210. package/dist/ui/settings-appearance.js +349 -0
  211. package/dist/ui/settings-catalog.js +92 -0
  212. package/dist/ui/settings-dialogs.js +40 -12
  213. package/dist/ui/settings-effects.js +202 -0
  214. package/dist/ui/settings-guests.js +117 -0
  215. package/dist/ui/settings-learn.js +25 -0
  216. package/dist/ui/settings-model.js +15 -6
  217. package/dist/ui/settings-mods.js +271 -0
  218. package/dist/ui/settings-owner-effects.js +80 -0
  219. package/dist/ui/settings-strings.js +333 -10
  220. package/dist/ui/settings-web-authority.js +91 -0
  221. package/dist/ui/shared/agend-auth.js +28 -4
  222. package/dist/ui/shared/agent-widget.js +248 -0
  223. package/dist/ui/shared/app-i18n.js +6 -2
  224. package/dist/ui/shared/app-prefs.js +34 -13
  225. package/dist/ui/shared/app-route.js +24 -2
  226. package/dist/ui/shared/app-session.js +3 -1
  227. package/dist/ui/shared/app-shell.js +102 -23
  228. package/dist/ui/shared/app-store.js +28 -1
  229. package/dist/ui/shared/app-stream.js +14 -7
  230. package/dist/ui/shared/app-themes.js +206 -0
  231. package/dist/ui/shared/app.css +565 -4
  232. package/dist/ui/shared/app.js +23 -10
  233. package/dist/ui/shared/card-strings.js +32 -0
  234. package/dist/ui/shared/effect-app-runtime.js +297 -0
  235. package/dist/ui/shared/effect-baseline.js +64 -0
  236. package/dist/ui/shared/effect-chat-runtime.js +167 -0
  237. package/dist/ui/shared/effect-cursor.js +125 -0
  238. package/dist/ui/shared/effect-frame.js +146 -0
  239. package/dist/ui/shared/effect-geometry.js +137 -0
  240. package/dist/ui/shared/effect-host-style.js +10 -0
  241. package/dist/ui/shared/effect-owner-regions.js +74 -0
  242. package/dist/ui/shared/effect-owner-source.js +115 -0
  243. package/dist/ui/shared/effect-policy.js +151 -0
  244. package/dist/ui/shared/effect-poster-reader.js +98 -0
  245. package/dist/ui/shared/effect-posters.js +117 -0
  246. package/dist/ui/shared/effect-profiler.js +184 -0
  247. package/dist/ui/shared/effect-profiles.js +65 -0
  248. package/dist/ui/shared/effect-registry.js +535 -0
  249. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  250. package/dist/ui/shared/effect-safety.js +153 -0
  251. package/dist/ui/shared/effect-stock-snapshot.js +43 -0
  252. package/dist/ui/shared/effect-stock-trust.js +217 -0
  253. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  254. package/dist/ui/shared/effect-trial-source.js +103 -0
  255. package/dist/ui/shared/effect-trial-stage.js +170 -0
  256. package/dist/ui/shared/effect-trial.css +40 -0
  257. package/dist/ui/shared/fx-motion.js +67 -0
  258. package/dist/ui/shared/header-tools.js +1 -1
  259. package/dist/ui/shared/identity-card.js +273 -0
  260. package/dist/ui/shared/identity-color.js +261 -0
  261. package/dist/ui/shared/instance-nav.js +215 -41
  262. package/dist/ui/shared/owner-css.js +221 -0
  263. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  264. package/dist/ui/shared/panel-view.js +103 -10
  265. package/dist/ui/shared/signin.js +2 -0
  266. package/dist/ui/shared/tokens.css +9 -3
  267. package/dist/ui/shared/ui-dialog.js +1 -1
  268. package/dist/ui/shared/ui-icons.js +3 -0
  269. package/dist/ui/shared/ui-menu.js +1 -1
  270. package/dist/ui/shared/ui-toast.js +1 -1
  271. package/dist/ui/shared/usage-dialog.js +22 -2
  272. package/dist/ui/shared/view-strings.js +98 -2
  273. package/dist/ui/shared/web-authorize.js +151 -0
  274. package/dist/ui/signin.html +1 -0
  275. package/dist/ui/web-terminal/terminal.css +4 -4
  276. package/dist/usage/providers.d.ts +28 -0
  277. package/dist/usage/providers.js +126 -36
  278. package/dist/usage/providers.js.map +1 -1
  279. package/dist/usage-report.d.ts +158 -0
  280. package/dist/usage-report.js +226 -0
  281. package/dist/usage-report.js.map +1 -0
  282. package/dist/usage-rollup.d.ts +51 -0
  283. package/dist/usage-rollup.js +168 -0
  284. package/dist/usage-rollup.js.map +1 -0
  285. package/dist/view-api.d.ts +68 -0
  286. package/dist/view-api.js +249 -8
  287. package/dist/view-api.js.map +1 -1
  288. package/dist/web-ambient.d.ts +72 -0
  289. package/dist/web-ambient.js +161 -0
  290. package/dist/web-ambient.js.map +1 -0
  291. package/dist/web-api.d.ts +111 -5
  292. package/dist/web-api.js +1235 -58
  293. package/dist/web-api.js.map +1 -1
  294. package/dist/web-appearance-limits.d.ts +82 -0
  295. package/dist/web-appearance-limits.js +83 -0
  296. package/dist/web-appearance-limits.js.map +1 -0
  297. package/dist/web-appearance-policy.d.ts +64 -0
  298. package/dist/web-appearance-policy.js +87 -0
  299. package/dist/web-appearance-policy.js.map +1 -0
  300. package/dist/web-appearance-tools.d.ts +53 -0
  301. package/dist/web-appearance-tools.js +124 -0
  302. package/dist/web-appearance-tools.js.map +1 -0
  303. package/dist/web-appearance-view.d.ts +124 -0
  304. package/dist/web-appearance-view.js +278 -0
  305. package/dist/web-appearance-view.js.map +1 -0
  306. package/dist/web-appearance.d.ts +93 -0
  307. package/dist/web-appearance.js +373 -0
  308. package/dist/web-appearance.js.map +1 -0
  309. package/dist/web-asset-parts.d.ts +93 -0
  310. package/dist/web-asset-parts.js +133 -0
  311. package/dist/web-asset-parts.js.map +1 -0
  312. package/dist/web-auth.js +20 -2
  313. package/dist/web-auth.js.map +1 -1
  314. package/dist/web-card.d.ts +110 -0
  315. package/dist/web-card.js +266 -0
  316. package/dist/web-card.js.map +1 -0
  317. package/dist/web-channel-echo.d.ts +7 -1
  318. package/dist/web-channel-echo.js +6 -2
  319. package/dist/web-channel-echo.js.map +1 -1
  320. package/dist/web-chat-history.d.ts +59 -0
  321. package/dist/web-chat-history.js +102 -4
  322. package/dist/web-chat-history.js.map +1 -1
  323. package/dist/web-commands.d.ts +1 -0
  324. package/dist/web-effect-bootstrap.d.ts +12 -0
  325. package/dist/web-effect-bootstrap.js +49 -0
  326. package/dist/web-effect-bootstrap.js.map +1 -0
  327. package/dist/web-effect-bundle.d.ts +28 -0
  328. package/dist/web-effect-bundle.js +171 -0
  329. package/dist/web-effect-bundle.js.map +1 -0
  330. package/dist/web-effect-canonical.d.ts +8 -0
  331. package/dist/web-effect-canonical.js +27 -0
  332. package/dist/web-effect-canonical.js.map +1 -0
  333. package/dist/web-effect-library.d.ts +114 -0
  334. package/dist/web-effect-library.js +530 -0
  335. package/dist/web-effect-library.js.map +1 -0
  336. package/dist/web-effect-playback.d.ts +30 -0
  337. package/dist/web-effect-playback.js +71 -0
  338. package/dist/web-effect-playback.js.map +1 -0
  339. package/dist/web-effect-poster.d.ts +8 -0
  340. package/dist/web-effect-poster.js +52 -0
  341. package/dist/web-effect-poster.js.map +1 -0
  342. package/dist/web-effect-profiles.d.ts +118 -0
  343. package/dist/web-effect-profiles.js +180 -0
  344. package/dist/web-effect-profiles.js.map +1 -0
  345. package/dist/web-effect-service.d.ts +93 -0
  346. package/dist/web-effect-service.js +335 -0
  347. package/dist/web-effect-service.js.map +1 -0
  348. package/dist/web-effect-tools.d.ts +48 -0
  349. package/dist/web-effect-tools.js +129 -0
  350. package/dist/web-effect-tools.js.map +1 -0
  351. package/dist/web-effect-trials.d.ts +86 -0
  352. package/dist/web-effect-trials.js +204 -0
  353. package/dist/web-effect-trials.js.map +1 -0
  354. package/dist/web-effect-upload.d.ts +27 -0
  355. package/dist/web-effect-upload.js +185 -0
  356. package/dist/web-effect-upload.js.map +1 -0
  357. package/dist/web-effect-worker.d.ts +8 -0
  358. package/dist/web-effect-worker.js +170 -0
  359. package/dist/web-effect-worker.js.map +1 -0
  360. package/dist/web-effect.d.ts +103 -0
  361. package/dist/web-effect.js +153 -0
  362. package/dist/web-effect.js.map +1 -0
  363. package/dist/web-guest-access.d.ts +125 -0
  364. package/dist/web-guest-access.js +234 -0
  365. package/dist/web-guest-access.js.map +1 -0
  366. package/dist/web-guest-rooms.d.ts +82 -0
  367. package/dist/web-guest-rooms.js +135 -0
  368. package/dist/web-guest-rooms.js.map +1 -0
  369. package/dist/web-identity-color.d.ts +87 -0
  370. package/dist/web-identity-color.js +209 -0
  371. package/dist/web-identity-color.js.map +1 -0
  372. package/dist/web-identity-palette.d.ts +113 -0
  373. package/dist/web-identity-palette.js +84 -0
  374. package/dist/web-identity-palette.js.map +1 -0
  375. package/dist/web-image-meta.d.ts +25 -0
  376. package/dist/web-image-meta.js +200 -0
  377. package/dist/web-image-meta.js.map +1 -0
  378. package/dist/web-login.d.ts +47 -0
  379. package/dist/web-login.js +115 -0
  380. package/dist/web-login.js.map +1 -1
  381. package/dist/web-mod-agent.d.ts +50 -0
  382. package/dist/web-mod-agent.js +207 -0
  383. package/dist/web-mod-agent.js.map +1 -0
  384. package/dist/web-mod-bundle.d.ts +157 -0
  385. package/dist/web-mod-bundle.js +529 -0
  386. package/dist/web-mod-bundle.js.map +1 -0
  387. package/dist/web-mod-export.d.ts +68 -0
  388. package/dist/web-mod-export.js +100 -0
  389. package/dist/web-mod-export.js.map +1 -0
  390. package/dist/web-mod-store.d.ts +289 -0
  391. package/dist/web-mod-store.js +638 -0
  392. package/dist/web-mod-store.js.map +1 -0
  393. package/dist/web-org.d.ts +3 -3
  394. package/dist/web-org.js +11 -17
  395. package/dist/web-org.js.map +1 -1
  396. package/dist/web-owner-css-store.d.ts +180 -0
  397. package/dist/web-owner-css-store.js +384 -0
  398. package/dist/web-owner-css-store.js.map +1 -0
  399. package/dist/web-owner-css.d.ts +58 -0
  400. package/dist/web-owner-css.js +552 -0
  401. package/dist/web-owner-css.js.map +1 -0
  402. package/dist/web-prefs.d.ts +62 -0
  403. package/dist/web-prefs.js +232 -36
  404. package/dist/web-prefs.js.map +1 -1
  405. package/dist/web-preview.d.ts +15 -3
  406. package/dist/web-preview.js +28 -9
  407. package/dist/web-preview.js.map +1 -1
  408. package/dist/web-returning-device.d.ts +41 -0
  409. package/dist/web-returning-device.js +57 -5
  410. package/dist/web-returning-device.js.map +1 -1
  411. package/dist/web-session-authorization.d.ts +132 -0
  412. package/dist/web-session-authorization.js +193 -0
  413. package/dist/web-session-authorization.js.map +1 -0
  414. package/dist/web-session.d.ts +70 -10
  415. package/dist/web-session.js +137 -22
  416. package/dist/web-session.js.map +1 -1
  417. package/dist/web-settings-patch.d.ts +67 -2
  418. package/dist/web-settings-patch.js +196 -1
  419. package/dist/web-settings-patch.js.map +1 -1
  420. package/dist/web-settings-tools.d.ts +89 -0
  421. package/dist/web-settings-tools.js +270 -0
  422. package/dist/web-settings-tools.js.map +1 -0
  423. package/dist/web-shell-routes.d.ts +2 -2
  424. package/dist/web-shell-routes.js +2 -2
  425. package/dist/web-shell-routes.js.map +1 -1
  426. package/dist/web-theme-store.d.ts +257 -0
  427. package/dist/web-theme-store.js +627 -0
  428. package/dist/web-theme-store.js.map +1 -0
  429. package/dist/web-theme.d.ts +123 -0
  430. package/dist/web-theme.js +416 -0
  431. package/dist/web-theme.js.map +1 -0
  432. package/dist/web-widget.d.ts +72 -0
  433. package/dist/web-widget.js +245 -0
  434. package/dist/web-widget.js.map +1 -0
  435. package/dist/workflow-templates/default.md +1 -1
  436. package/package.json +1 -1
  437. package/templates/systemd.service.ejs +8 -0
package/dist/web-api.js CHANGED
@@ -1,8 +1,16 @@
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";
5
7
  import { readBoundedWebBody } from "./web-body.js";
8
+ import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
9
+ import { buildModBundle } from "./web-mod-export.js";
10
+ import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
11
+ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
12
+ import { canonicalText } from "./web-theme.js";
13
+ import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
6
14
  import { gatewayRequestContext } from "./web-request-context.js";
7
15
  import { permitWebContinuation } from "./web-continuation.js";
8
16
  /**
@@ -28,13 +36,14 @@ import { dashboardOriginFor } from "./web-preview.js";
28
36
  import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
29
37
  import { attachmentDelivery, createPartFile, displayName, fileTooLargeText, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, UPLOAD_LIMITS, UPLOAD_MAX_MS, UPLOAD_STALL_MS, UploadSniffer, uploadLimits, wellFormed } from "./web-upload.js";
30
38
  import { getAgendHome } from "./paths.js";
31
- import { OPERATOR } from "./web-prefs.js";
32
- import { WEB_NAME_FALLBACK } from "./web-profile.js";
39
+ import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
40
+ import { WEB_NAME_FALLBACK, webPersonKey } from "./web-profile.js";
33
41
  import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
34
42
  import { parseMentionQuery } from "./web-mentions.js";
35
43
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
36
44
  import { measureSyncWork } from "./sync-work-attribution.js";
37
45
  import { resolveTz } from "./tz-utils.js";
46
+ import { filesPlayInChat } from "./web-effect.js";
38
47
  // ── Strict public-facing schemas ────────────────────────────────────────────
39
48
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
40
49
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -90,11 +99,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
90
99
  * the eviction the dead entry leaks forever, and without the try/catch a
91
100
  * single dead client breaks delivery to every client iterated after it.
92
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
+ }
93
118
  export function broadcastSseEvent(clients, event, data, onError, id) {
94
119
  const payload = sseFrame(event, data, id);
95
120
  const dead = [];
96
121
  for (const client of clients) {
97
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
+ }
98
137
  client.write(payload);
99
138
  }
100
139
  catch (err) {
@@ -150,6 +189,719 @@ function stillAuthorized(req, res, url, ctx) {
150
189
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
151
190
  return false;
152
191
  }
192
+ /**
193
+ * #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
194
+ * signed-in human session is, or the status to answer.
195
+ */
196
+ function handleOwnerCss(req, res, url, ctx, path, method) {
197
+ const store = ctx.ownerCss;
198
+ if (!store) {
199
+ json(res, 404, { error: "Owner CSS is not kept on this fleet" });
200
+ return true;
201
+ }
202
+ const reading = method === "GET" || method === "HEAD";
203
+ if (gatewayRequestContext(req)) {
204
+ json(res, reading ? 404 : 403, { error: "owner_css_not_over_public_link" });
205
+ return true;
206
+ }
207
+ const who = () => {
208
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
209
+ if (verdict.kind !== "allow")
210
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
211
+ if (verdict.via !== "session" || !verdict.session)
212
+ return { status: 403, error: "owner_css_needs_a_signed_in_person" };
213
+ if (!reading && verdict.session.tier !== "admin")
214
+ return { status: 403, error: "owner_css_read_only" };
215
+ return { person: webPersonKey(verdict.session.owner) };
216
+ };
217
+ const first = who();
218
+ if ("status" in first) {
219
+ json(res, first.status, { error: first.error });
220
+ return true;
221
+ }
222
+ const again = () => {
223
+ if (!permitWebContinuation(req, res, ctx))
224
+ return null;
225
+ const now = who();
226
+ if ("status" in now) {
227
+ json(res, now.status, { error: now.error });
228
+ return null;
229
+ }
230
+ if (now.person !== first.person) {
231
+ json(res, 409, { error: "owner_css_person_changed" });
232
+ return null;
233
+ }
234
+ return now.person;
235
+ };
236
+ const announce = () => ctx.emitSseEvent("status", ctx.getUiStatus()); // their other devices see the new prefs rev
237
+ const fail = (err) => {
238
+ ctx.logger.error({ err: err.message }, "Owner CSS request failed");
239
+ try {
240
+ json(res, 500, { error: "Owner CSS request failed" });
241
+ }
242
+ catch { /* already answered */ }
243
+ };
244
+ if (path === "/ui/owner-css.css" && method === "GET") {
245
+ const v = url.searchParams.get("v");
246
+ const sha = v ?? store.state(first.person).active;
247
+ const text = sha ? store.served(first.person, sha) : null;
248
+ if (text === null) {
249
+ json(res, 404, { error: "no owner CSS" });
250
+ return true;
251
+ }
252
+ res.writeHead(200, {
253
+ "Content-Type": "text/css; charset=utf-8", "X-Content-Type-Options": "nosniff",
254
+ // Keyed by its sha (?v): a version's text never changes. Without ?v it is whatever is in force now.
255
+ "Cache-Control": v ? "private, max-age=31536000, immutable" : "private, no-store",
256
+ });
257
+ res.end(text);
258
+ return true;
259
+ }
260
+ const assetPath = /^\/ui\/owner-css\/asset\/([0-9a-f]{32})$/.exec(path);
261
+ if (assetPath && method === "GET") {
262
+ const asset = store.asset(first.person, assetPath[1]);
263
+ if (!asset) {
264
+ json(res, 404, { error: "no such asset" });
265
+ return true;
266
+ }
267
+ res.writeHead(200, {
268
+ "Content-Type": asset.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable",
269
+ "Content-Security-Policy": "default-src 'none'; sandbox",
270
+ });
271
+ res.end(asset.bytes);
272
+ return true;
273
+ }
274
+ if (path === "/ui/owner-css/state" && method === "GET") {
275
+ json(res, 200, store.state(first.person));
276
+ return true;
277
+ }
278
+ if (assetPath && method === "DELETE") {
279
+ const removed = store.removeAsset(first.person, assetPath[1]);
280
+ json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
281
+ return true;
282
+ }
283
+ if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
284
+ json(res, 404, { error: "not found" });
285
+ return true;
286
+ }
287
+ (async () => {
288
+ if (path === "/ui/owner-css/asset") {
289
+ let bytes;
290
+ try {
291
+ bytes = await readBoundedWebBody(req, 1024 * 1024);
292
+ }
293
+ catch {
294
+ json(res, 413, { error: "asset_quota", detail: "at most 1 MiB" });
295
+ return;
296
+ }
297
+ const person = again();
298
+ if (!person)
299
+ return;
300
+ const added = store.addAsset(person, bytes);
301
+ if (!added.ok) {
302
+ json(res, added.status, { error: added.error, ...(added.detail ? { detail: added.detail } : {}) });
303
+ return;
304
+ }
305
+ json(res, 200, { id: added.id, kind: added.kind, bytes: added.bytes });
306
+ return;
307
+ }
308
+ let body;
309
+ try {
310
+ body = await parseBody(req);
311
+ }
312
+ catch {
313
+ json(res, 400, { error: "Invalid JSON" });
314
+ return;
315
+ }
316
+ const person = again();
317
+ if (!person)
318
+ return;
319
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
320
+ json(res, 400, { error: "an object" });
321
+ return;
322
+ }
323
+ if (path === "/ui/owner-css") {
324
+ const saved = store.save(person, body.text);
325
+ if (!saved.ok) {
326
+ json(res, saved.status, { error: saved.error, ...(saved.detail ? { detail: saved.detail } : {}) });
327
+ return;
328
+ }
329
+ announce();
330
+ json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
331
+ }
332
+ else if (path === "/ui/owner-css/keep") {
333
+ const kept = store.keep(person, body.trial);
334
+ if (!kept.ok) {
335
+ json(res, kept.status, { error: kept.error });
336
+ return;
337
+ }
338
+ announce(); // the person's other devices drop their bar
339
+ json(res, 200, { active: kept.active });
340
+ }
341
+ else if (path === "/ui/owner-css/revert") {
342
+ // Exactly this trial, while it still is one: a trial kept (or ended) on another device meanwhile stays (409).
343
+ const reverted = store.revertTrial(person, body.trial);
344
+ if (!reverted.ok) {
345
+ json(res, reverted.status, { error: reverted.error });
346
+ return;
347
+ }
348
+ announce();
349
+ json(res, 200, { active: reverted.active });
350
+ }
351
+ else {
352
+ const restored = store.restore(person, body.sha);
353
+ if (!restored.ok) {
354
+ json(res, restored.status, { error: restored.error });
355
+ return;
356
+ }
357
+ announce();
358
+ json(res, 200, { active: restored.active });
359
+ }
360
+ })().catch(fail);
361
+ return true;
362
+ }
363
+ /** #1629: static current-placement posters, separate from the local human-only runtime API. */
364
+ function handleEffectPosters(res, url, ctx, path, method) {
365
+ res.setHeader("Cache-Control", "private, no-store");
366
+ res.setHeader("X-Content-Type-Options", "nosniff");
367
+ const image = /^\/ui\/effect-posters\/([0-9a-f]{32})\.png$/.exec(path);
368
+ const names = [...url.searchParams.keys()], instance = url.searchParams.get("instance");
369
+ if (method !== "GET" || path !== "/ui/effect-posters" && !image || names.length !== 1 || names[0] !== "instance" || !instance || instance.length > 128) {
370
+ json(res, 404, { error: "not_found" });
371
+ return true;
372
+ }
373
+ const service = ctx.effectService;
374
+ if (!service) {
375
+ json(res, 404, { error: "effects_unavailable" });
376
+ return true;
377
+ }
378
+ if (image) {
379
+ const r = service.placedPoster(image[1], instance);
380
+ if (!r.ok)
381
+ json(res, 404, { error: "not_found" });
382
+ else {
383
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
384
+ res.end(r.bytes);
385
+ }
386
+ }
387
+ else {
388
+ const result = service.posterPlacements(instance);
389
+ json(res, result.error === "themes_unreadable" ? 503 : result.error ? 404 : 200, result);
390
+ }
391
+ return true;
392
+ }
393
+ /** #1629 Try: separate local-admin routes. These never borrow ordinary read-tier/placement authority. */
394
+ function handleEffectTrials(req, res, url, ctx, path, method) {
395
+ res.setHeader("Cache-Control", "private, no-store");
396
+ res.setHeader("X-Content-Type-Options", "nosniff");
397
+ const reading = method === "GET";
398
+ if (gatewayRequestContext(req)) {
399
+ json(res, reading ? 404 : 403, { error: "trials_not_over_public_link" });
400
+ return true;
401
+ }
402
+ const who = () => {
403
+ const sessions = ctx.webSessions;
404
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, sessions, { touch: false });
405
+ if (v.kind !== "allow")
406
+ return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
407
+ if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
408
+ return { error: "trials_need_a_local_admin", status: 403 };
409
+ const person = webPersonKey(v.session.owner);
410
+ let css = "unknown";
411
+ try {
412
+ const value = ctx.ownerCss?.effectState(person);
413
+ if (value === "off" || value === "on")
414
+ css = value;
415
+ }
416
+ catch { /* unavailable is never off */ }
417
+ // CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
418
+ const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
419
+ return { actor, record: v.session, sessions };
420
+ };
421
+ const first = who();
422
+ if ("error" in first) {
423
+ json(res, first.status, { error: first.error });
424
+ return true;
425
+ }
426
+ const service = ctx.effectService;
427
+ if (!service) {
428
+ json(res, 404, { error: "trials_unavailable" });
429
+ return true;
430
+ }
431
+ const start = path === "/ui/effect-trials/start";
432
+ const route = /^\/ui\/effect-trials\/([0-9a-f]{32})\/(resource|poster\.png|profile|stop)$/.exec(path);
433
+ const resource = reading && route && ["resource", "poster.png", "profile"].includes(route[2]);
434
+ const stop = method === "POST" && route?.[2] === "stop";
435
+ if (!(resource || stop || method === "POST" && start)) {
436
+ json(res, 404, { error: "not_found" });
437
+ return true;
438
+ }
439
+ const allowed = resource ? ["ownerEpoch", "instance"] : ["ownerEpoch"];
440
+ if ([...url.searchParams.keys()].some(k => !allowed.includes(k)) || allowed.some(k => url.searchParams.getAll(k).length !== 1)
441
+ || url.searchParams.get("ownerEpoch") !== first.actor.epoch) {
442
+ json(res, reading ? 404 : 409, { error: reading ? "not_found" : "trials_person_changed" });
443
+ return true;
444
+ }
445
+ const instance = url.searchParams.get("instance") ?? "";
446
+ const answer = (result) => json(res, result.error ? result.error === "not_found" ? 404
447
+ : ["profiles_unreadable", "profiles_busy", "profiles_write_failed"].includes(String(result.error)) ? 503 : 400 : 200, result);
448
+ if (resource) {
449
+ if (route[2] === "poster.png") {
450
+ const result = service.trialPoster(route[1], instance, first.actor);
451
+ if (!result.ok)
452
+ json(res, 404, { error: "not_found" });
453
+ else {
454
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
455
+ res.end(result.bytes);
456
+ }
457
+ }
458
+ else
459
+ answer(route[2] === "profile" ? service.trialProfile(route[1], instance, first.actor) : service.trialResource(route[1], instance, first.actor));
460
+ return true;
461
+ }
462
+ (async () => {
463
+ let bytes;
464
+ try {
465
+ bytes = await readBoundedWebBody(req, 16 * 1024);
466
+ }
467
+ catch {
468
+ json(res, 413, { error: "trial_body_bound" });
469
+ return;
470
+ }
471
+ if (!permitWebContinuation(req, res, ctx))
472
+ return;
473
+ const now = who();
474
+ if ("error" in now) {
475
+ json(res, now.status, { error: now.error });
476
+ return;
477
+ }
478
+ if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
479
+ || now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
480
+ json(res, 409, { error: "trials_person_changed" });
481
+ return;
482
+ }
483
+ let body;
484
+ try {
485
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
486
+ }
487
+ catch {
488
+ json(res, 400, { error: "trial_json" });
489
+ return;
490
+ }
491
+ if (start) {
492
+ const parsed = z.strictObject({ offer: z.string().regex(/^[0-9a-f]{32}$/), instance: z.string().min(1).max(128) }).safeParse(body);
493
+ if (!parsed.success) {
494
+ json(res, 400, { error: "trial_args" });
495
+ return;
496
+ }
497
+ answer(service.trialStart(parsed.data.offer, parsed.data.instance, now.actor));
498
+ }
499
+ else {
500
+ if (!z.strictObject({}).safeParse(body).success) {
501
+ json(res, 400, { error: "trial_args" });
502
+ return;
503
+ }
504
+ answer(service.trialStop(route[1], now.actor));
505
+ }
506
+ })().catch(() => { try {
507
+ json(res, 500, { error: "trial_request_failed" });
508
+ }
509
+ catch { /* response ended */ } });
510
+ return true;
511
+ }
512
+ /** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
513
+ function handleEffects(req, res, url, ctx, path, method) {
514
+ res.setHeader("Cache-Control", "private, no-store");
515
+ res.setHeader("X-Content-Type-Options", "nosniff");
516
+ const reading = method === "GET";
517
+ // Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
518
+ const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
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())) {
525
+ json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
526
+ return true;
527
+ }
528
+ const who = () => {
529
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
530
+ if (verdict.kind !== "allow")
531
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
532
+ if (verdict.via !== "session" || !verdict.session)
533
+ return { status: 403, error: "effects_need_a_signed_in_person" };
534
+ if (!reading && !profilePost && verdict.session.tier !== "admin")
535
+ return { status: 403, error: "effects_read_only" };
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" };
539
+ };
540
+ const first = who();
541
+ if ("status" in first) {
542
+ json(res, first.status, { error: first.error });
543
+ return true;
544
+ }
545
+ const service = ctx.effectService;
546
+ if (!service) {
547
+ json(res, 404, { error: "effects_unavailable" });
548
+ return true;
549
+ }
550
+ const unavailable = new Set(["themes_unreadable", "profiles_unreadable", "library_unreadable", "references_unreadable", "library_busy", "profiles_busy", "profiles_write_failed", "library_write_failed", "prefs_unreadable"]);
551
+ const answer = (result) => json(res, typeof result.error === "string" ? result.error === "not_found" ? 404 : result.error === "profile_rate_limited" ? 429 : unavailable.has(result.error) ? 503 : 400 : 200, result);
552
+ const idPath = /^\/ui\/effects\/(resource|profile)\/([0-9a-f]{32})$/.exec(path);
553
+ const instance = url.searchParams.get("instance") ?? undefined, slot = url.searchParams.get("slot");
554
+ if (slot !== null && instance !== undefined) {
555
+ json(res, 404, { error: "not_found" });
556
+ return true;
557
+ }
558
+ const runtimeRead = reading && (slot !== null || path === "/ui/effects/owner" || path === "/ui/effects/placements" || idPath?.[1] === "resource" || instance !== undefined && /^\/ui\/effects\/image\//.test(path));
559
+ if ((runtimeRead || profilePost) && (url.searchParams.getAll("ownerEpoch").length !== 1 || url.searchParams.get("ownerEpoch") !== first.epoch)) {
560
+ const privateResource = reading && (slot !== null || idPath?.[1] === "resource" || /^\/ui\/effects\/image\//.test(path));
561
+ json(res, privateResource ? 404 : 409, { error: privateResource ? "not_found" : "effects_person_changed" });
562
+ return true;
563
+ }
564
+ if (reading) {
565
+ const image = /^\/ui\/effects\/image\/([0-9a-f]{32})\/(poster|cursor)\.png$/.exec(path);
566
+ if (image) {
567
+ const r = slot === null ? service.image(first.person, image[1], image[2], instance) : service.ownerImage(first.person, image[1], image[2], slot);
568
+ if (!r.ok)
569
+ json(res, 404, { error: "not_found" });
570
+ else {
571
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
572
+ res.end(r.bytes);
573
+ }
574
+ return true;
575
+ }
576
+ if (path === "/ui/effects")
577
+ answer({ ...service.list(first.person), canWrite: first.canWrite });
578
+ else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
579
+ answer(service.ownerSelections(first.person));
580
+ else if (path === "/ui/effects/placements" && instance !== undefined)
581
+ answer(service.placements(first.person, instance, !!gateway || first.guest));
582
+ else if (idPath?.[1] === "resource")
583
+ answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
584
+ else if (idPath?.[1] === "profile" && instance === undefined)
585
+ answer(service.profile(first.person, idPath[2]));
586
+ else
587
+ json(res, 404, { error: "not_found" });
588
+ return true;
589
+ }
590
+ const deletion = /^\/ui\/effects\/([0-9a-f]{32})$/.exec(path);
591
+ if (method === "DELETE" && deletion) {
592
+ answer(service.delete(first.person, deletion[1]));
593
+ return true;
594
+ }
595
+ if (method !== "POST" || !["/ui/effects/upload", "/ui/effects/apply", "/ui/effects/remove", "/ui/effects/share"].includes(path) && idPath?.[1] !== "profile") {
596
+ json(res, 404, { error: "not_found" });
597
+ return true;
598
+ }
599
+ (async () => {
600
+ let bytes;
601
+ try {
602
+ bytes = await readBoundedWebBody(req, path === "/ui/effects/upload" ? EFFECT_WIRE_LIMIT : 16 * 1024);
603
+ }
604
+ catch {
605
+ json(res, 413, { error: "effect_body_bound" });
606
+ return;
607
+ }
608
+ if (!permitWebContinuation(req, res, ctx))
609
+ return;
610
+ const now = who();
611
+ if ("status" in now) {
612
+ json(res, now.status, { error: now.error });
613
+ return;
614
+ }
615
+ if (now.person !== first.person || now.epoch !== first.epoch) {
616
+ json(res, 409, { error: "effects_person_changed" });
617
+ return;
618
+ }
619
+ if (path === "/ui/effects/upload") {
620
+ answer(service.upload(now.person, bytes));
621
+ return;
622
+ }
623
+ let body;
624
+ try {
625
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
626
+ }
627
+ catch {
628
+ json(res, 400, { error: "effect_json" });
629
+ return;
630
+ }
631
+ if (path === "/ui/effects/apply")
632
+ answer(service.apply(now.person, body));
633
+ else if (path === "/ui/effects/remove")
634
+ answer(service.remove(now.person, body));
635
+ else if (path === "/ui/effects/share")
636
+ answer(service.share(now.person, body));
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
+ }
644
+ })().catch(() => { try {
645
+ json(res, 500, { error: "effect_request_failed" });
646
+ }
647
+ catch { /* response ended */ } });
648
+ return true;
649
+ }
650
+ /**
651
+ * #1629 r4 §4 (r4-5): mods — a person's import (stage, then commit), uninstall and export. Every route is a signed-in
652
+ * session's, writes an admin's: never the CLI's header token (the way an agent could reach it) and never the public link
653
+ * (absent from its manifest, and refused here too). `who()` is asked first and again once a body is in.
654
+ */
655
+ function handleMods(req, res, url, ctx, path, method) {
656
+ const mods = ctx.mods;
657
+ if (!mods) {
658
+ json(res, 404, { error: "Mods are not kept on this fleet" });
659
+ return true;
660
+ }
661
+ const reading = method === "GET";
662
+ if (gatewayRequestContext(req)) {
663
+ json(res, reading ? 404 : 403, { error: "mods_not_over_public_link" });
664
+ return true;
665
+ }
666
+ const who = () => {
667
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
668
+ if (verdict.kind !== "allow")
669
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
670
+ if (verdict.via !== "session" || !verdict.session)
671
+ return { status: 403, error: "mods_need_a_signed_in_session" };
672
+ if (!reading && verdict.session.tier !== "admin")
673
+ return { status: 403, error: "mods_need_an_admin_session" };
674
+ return { person: webPersonKey(verdict.session.owner) };
675
+ };
676
+ const first = who();
677
+ if ("status" in first) {
678
+ json(res, first.status, { error: first.error });
679
+ return true;
680
+ }
681
+ const again = () => {
682
+ if (!permitWebContinuation(req, res, ctx))
683
+ return null;
684
+ const now = who();
685
+ if ("status" in now) {
686
+ json(res, now.status, { error: now.error });
687
+ return null;
688
+ }
689
+ if (now.person !== first.person) {
690
+ json(res, 409, { error: "mods_person_changed" });
691
+ return null;
692
+ }
693
+ return now.person;
694
+ };
695
+ const answer = (r, body) => {
696
+ if (r.ok)
697
+ json(res, 200, body);
698
+ else
699
+ json(res, r.status ?? 500, { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.problems ? { problems: r.problems } : {}) });
700
+ };
701
+ const fail = (err) => {
702
+ ctx.logger.error({ err: err.message }, "Mod request failed");
703
+ try {
704
+ json(res, 500, { error: "Mod request failed" });
705
+ }
706
+ catch { /* already answered */ }
707
+ };
708
+ if (path === "/ui/mods" && method === "GET") {
709
+ json(res, 200, { ...mods.list(), staged: mods.staged(first.person), proposals: mods.proposals() });
710
+ return true;
711
+ }
712
+ // A staged mod's picture (this session's person's stage only): the plan shows the actual pictures before install.
713
+ const stagedPic = /^\/ui\/mods\/staged\/([0-9a-f]{32})$/.exec(path);
714
+ if (stagedPic && method === "GET") {
715
+ const pic = mods.stagedPicture(first.person, stagedPic[1]);
716
+ if (!pic) {
717
+ json(res, 404, { error: "No such image" });
718
+ return true;
719
+ }
720
+ res.writeHead(200, { "Content-Type": pic.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store", "Content-Security-Policy": "default-src 'none'; sandbox" });
721
+ res.end(Buffer.from(pic.bytes));
722
+ return true;
723
+ }
724
+ const fileMatch = /^\/ui\/mods\/file\/([0-9a-f]{32})$/.exec(path);
725
+ if (fileMatch && method === "GET") {
726
+ const text = mods.bundleText(fileMatch[1]);
727
+ const rec = mods.list().mods.find(m => m.id === fileMatch[1]);
728
+ if (text === null || !rec) {
729
+ json(res, 404, { error: "no_such_mod" });
730
+ return true;
731
+ }
732
+ // The file is the installed bundle, effects and all — also effects its import left out, which nobody installed.
733
+ // So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
734
+ // read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
735
+ const fx = mods.bundleEffects(fileMatch[1]);
736
+ if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
737
+ json(res, 403, { error: "mod_effects_not_yours", detail: "this mod's file carries effects that are not yours to share: export your own look instead" });
738
+ return true;
739
+ }
740
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
741
+ "Content-Disposition": `attachment; filename="${rec.name}-${rec.version}.agendmod.json"` });
742
+ res.end(text);
743
+ return true;
744
+ }
745
+ if (method !== "POST" || !["/ui/mods/stage", "/ui/mods/abort", "/ui/mods/commit", "/ui/mods/uninstall", "/ui/mods/export", "/ui/mods/proposal/stage", "/ui/mods/proposal/dismiss"].includes(path)) {
746
+ json(res, 404, { error: "not found" });
747
+ return true;
748
+ }
749
+ (async () => {
750
+ if (path === "/ui/mods/stage") {
751
+ // Too large by its own declared length: answered before a byte is read (the body is drained, not cut off).
752
+ if (Number(req.headers["content-length"]) > MOD_MAX_BYTES) {
753
+ req.resume();
754
+ json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
755
+ return;
756
+ }
757
+ let raw;
758
+ try {
759
+ raw = await readBoundedWebBody(req, MOD_MAX_BYTES);
760
+ }
761
+ catch {
762
+ json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
763
+ return;
764
+ }
765
+ const person = again();
766
+ if (!person)
767
+ return;
768
+ let text;
769
+ try {
770
+ text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
771
+ }
772
+ catch {
773
+ json(res, 400, { error: "mod_invalid", problems: [{ path: "", problem: "not UTF-8" }] });
774
+ return;
775
+ }
776
+ const r = mods.stage(person, text);
777
+ answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
778
+ return;
779
+ }
780
+ let body;
781
+ try {
782
+ body = await parseBody(req);
783
+ }
784
+ catch {
785
+ json(res, 400, { error: "Invalid JSON" });
786
+ return;
787
+ }
788
+ const person = again();
789
+ if (!person)
790
+ return;
791
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
792
+ json(res, 400, { error: "an object" });
793
+ return;
794
+ }
795
+ if (path === "/ui/mods/abort") {
796
+ const r = mods.abort(person, body.stage);
797
+ answer(r, { ok: true });
798
+ return;
799
+ }
800
+ // r4-5b: an agent's proposal, opened into the usual review (staged as this person's), or dismissed.
801
+ if (path === "/ui/mods/proposal/stage") {
802
+ const r = mods.stageProposal(person, body.instance);
803
+ answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
804
+ return;
805
+ }
806
+ if (path === "/ui/mods/proposal/dismiss") {
807
+ const r = mods.dismiss(body.instance);
808
+ answer(r, { ok: true });
809
+ return;
810
+ }
811
+ if (path === "/ui/mods/commit") {
812
+ const r = mods.commit(person, body);
813
+ if (r.ok)
814
+ ctx.emitSseEvent("status", ctx.getUiStatus()); // themes_rev and prefs_rev moved: every page re-reads
815
+ answer(r, r.ok ? { id: r.id, applied: r.applied, ...(r.css ? { css: r.css } : {}) } : null);
816
+ return;
817
+ }
818
+ if (path === "/ui/mods/uninstall") {
819
+ const r = mods.uninstall(body.id);
820
+ answer(r, r.ok ? { ok: true, released: r.released } : null);
821
+ return;
822
+ }
823
+ // Export: what is in force now, as a bundle file.
824
+ const r = exportMod(ctx, person, body);
825
+ if (!r.ok) {
826
+ json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
827
+ return;
828
+ }
829
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
830
+ "Content-Disposition": `attachment; filename="${r.filename}"`, "X-AgEnD-Effects-Omitted": String(r.omitted) });
831
+ res.end(r.text);
832
+ })().catch(fail);
833
+ return true;
834
+ }
835
+ /** A person's export: an instance's look (theme, identity, its widgets), their sidebar widget and their own CSS. */
836
+ export function exportMod(ctx, person, body) {
837
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
838
+ const inc = isObj(body.include) ? body.include : null;
839
+ if (!inc)
840
+ return { ok: false, status: 400, error: "include" };
841
+ const meta = { name: body.name, version: body.version, title: body.title };
842
+ if (typeof meta.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(meta.name))
843
+ return { ok: false, status: 400, error: "name", detail: "a-z, 0-9 and - (1..64)" };
844
+ if (typeof meta.version !== "string" || !/^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/.test(meta.version))
845
+ return { ok: false, status: 400, error: "version", detail: "MAJOR.MINOR.PATCH" };
846
+ // The importer's own rule (#1897 review): what is exported always imports.
847
+ const title = canonicalText(meta.title, 1, 60);
848
+ if (title === null)
849
+ return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
850
+ const parts = {};
851
+ let omitted = 0;
852
+ const wantsInstance = inc.theme === true || inc.identity === true || inc.ambient === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
853
+ let name = null;
854
+ if (wantsInstance) {
855
+ if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
856
+ return { ok: false, status: 400, error: "instance" };
857
+ name = body.instance;
858
+ }
859
+ if (name && ctx.themeStore) {
860
+ const store = ctx.themeStore;
861
+ const rec = store.get(name);
862
+ if (inc.theme === true) {
863
+ // The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
864
+ // Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
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)) {
867
+ omitted++;
868
+ return [];
869
+ } return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
870
+ parts.theme = { theme: rec.theme, 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; }, effects };
871
+ }
872
+ if (inc.identity === true)
873
+ parts.identity = ctx.modCardText?.(name) ?? {};
874
+ for (const slot of AGENT_WIDGET_SLOTS) {
875
+ const w = rec.placements.widgets[slot];
876
+ if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
877
+ (parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
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
+ }
884
+ }
885
+ if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
886
+ const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
887
+ const spec = v ? checkWidgetSpec(v.spec, "sidebar") : null;
888
+ const data = spec?.ok ? checkWidgetData(v.data, spec.value) : null;
889
+ if (spec?.ok && data?.ok)
890
+ (parts.widgets ??= {}).sidebar = { spec: spec.value, data: { ...data.value } };
891
+ }
892
+ if (inc.ownerCss === true && ctx.ownerCss) {
893
+ const state = ctx.ownerCss.state(person);
894
+ const checked = state.text ? checkOwnerCss(state.text, new Set(state.assets.map(a => a.id))) : null;
895
+ if (state.text && checked?.ok) {
896
+ // Only the assets this text names travel (read from its tokens: url("\\61sset:<id>") names one too).
897
+ const named = ownerCssAssetRefs(checked.tokens);
898
+ const assets = state.assets.filter(a => named.has(a.id)).flatMap(a => { const b = ctx.ownerCss.asset(person, a.id); return b ? [{ id: a.id, bytes: b.bytes, kind: a.kind }] : []; });
899
+ parts.ownerCss = { text: state.text, assets };
900
+ }
901
+ }
902
+ const built = buildModBundle({ name: meta.name, version: meta.version, title }, parts);
903
+ return built.ok ? { ...built, omitted } : { ok: false, status: built.error === "nothing_to_export" ? 400 : 409, error: built.error };
904
+ }
153
905
  /**
154
906
  * #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
155
907
  * no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
@@ -168,6 +920,30 @@ function webPersonOf(req, url, ctx) {
168
920
  const who = webAccountOf(req, url, ctx);
169
921
  return who ? ctx.webPerson(who.owner) : null;
170
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
+ }
171
947
  /** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
172
948
  function webPersonOr401(req, res, url, ctx) {
173
949
  const p = webPersonOf(req, url, ctx);
@@ -243,7 +1019,20 @@ export function handleWebRequest(req, res, url, ctx) {
243
1019
  return true;
244
1020
  }
245
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
+ }
246
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";
247
1036
  const BACKENDS = [
248
1037
  { name: "claude-code", binary: "claude" },
249
1038
  { name: "codex", binary: "codex" },
@@ -253,6 +1042,16 @@ export function handleWebRequest(req, res, url, ctx) {
253
1042
  { name: "grok", binary: "grok" },
254
1043
  { name: "muse", binary: "muse" },
255
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
+ }
256
1055
  // #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
257
1056
  // runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
258
1057
  // time (shown as not installed, as the old timeout was).
@@ -262,7 +1061,7 @@ export function handleWebRequest(req, res, url, ctx) {
262
1061
  return { name: b.name, binary: b.binary, installed: path !== null, path: path ?? "", deprecated: b.deprecated ?? false,
263
1062
  ...(found.known ? {} : { unknown: true }) };
264
1063
  })).then((backends) => { if (!res.headersSent)
265
- json(res, 200, { backends }); }, () => { if (!res.headersSent)
1064
+ json(res, 200, { backends, defaultBackend }); }, () => { if (!res.headersSent)
266
1065
  json(res, 500, { error: "backend probe failed" }); });
267
1066
  return true;
268
1067
  }
@@ -275,9 +1074,24 @@ export function handleWebRequest(req, res, url, ctx) {
275
1074
  // that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
276
1075
  // review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
277
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);
278
1079
  const history = ctx.webChatHistory;
279
1080
  const after = url.searchParams.get("after");
280
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
+ }
281
1095
  json(res, 200, {
282
1096
  status: ctx.getUiStatus(),
283
1097
  messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
@@ -310,30 +1124,49 @@ export function handleWebRequest(req, res, url, ctx) {
310
1124
  });
311
1125
  // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
312
1126
  // poll down by (one write: the frames a connect sends are unchanged in number).
313
- 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()}`);
314
1136
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
315
1137
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
316
1138
  if (lastSeen !== null && ctx.webChatHistory) {
317
1139
  const history = ctx.webChatHistory;
318
- for (const m of history.replayFor(lastSeen))
319
- 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
+ }
320
1149
  }
321
1150
  // Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
322
1151
  // report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
323
- if (ctx.webChatHistory)
324
- 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
+ }
325
1156
  // The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
326
- if (ctx.listWebPrompts)
1157
+ if (!guest && ctx.listWebPrompts)
327
1158
  res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
328
1159
  // #1386: and "Needs you" as it is now; changes follow as `needs` events.
329
- if (ctx.needsYouItems)
1160
+ if (!guest && ctx.needsYouItems)
330
1161
  res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
331
1162
  ctx.sseClients.add(res);
332
1163
  const interval = setInterval(() => {
333
1164
  // A stream authorized once must not outlive the authorization. Re-checked
334
1165
  // without counting as activity, or an open tab would keep an idle session
335
1166
  // alive forever; a revoked, expired or rotated-away session ends here.
336
- 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())) {
337
1170
  // Close the connection too, not just the response: an ended response leaves the socket idle in
338
1171
  // keep-alive, still held open by whoever's session just ended.
339
1172
  const socket = req.socket;
@@ -346,7 +1179,7 @@ export function handleWebRequest(req, res, url, ctx) {
346
1179
  }
347
1180
  try {
348
1181
  // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
349
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
1182
+ res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
350
1183
  }
351
1184
  catch {
352
1185
  cleanup();
@@ -380,7 +1213,10 @@ export function handleWebRequest(req, res, url, ctx) {
380
1213
  json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
381
1214
  return true;
382
1215
  }
383
- 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 });
384
1220
  return true;
385
1221
  }
386
1222
  // ── #1218: the Log tab ─────────────────────────────────
@@ -435,6 +1271,16 @@ export function handleWebRequest(req, res, url, ctx) {
435
1271
  const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
436
1272
  if (method === "GET" && fileMatch) {
437
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
+ }
438
1284
  // #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
439
1285
  // memory. Every other type is answered exactly as before.
440
1286
  const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
@@ -465,6 +1311,112 @@ export function handleWebRequest(req, res, url, ctx) {
465
1311
  res.end(got.bytes);
466
1312
  return true;
467
1313
  }
1314
+ // ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
1315
+ // GET /ui/themes: { rev, themes, locked, widgets, ambient (#2117) } when the status frame's themes_rev moves (passive, web-auth.ts).
1316
+ // GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
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
1319
+ // link (absent from its manifest, and refused here too).
1320
+ if (method === "GET" && path === "/ui/themes") {
1321
+ const store = ctx.themeStore;
1322
+ if (!store) {
1323
+ json(res, 404, { error: "Themes are not kept on this fleet" });
1324
+ return true;
1325
+ }
1326
+ json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets(), ambient: store.ambients() });
1327
+ return true;
1328
+ }
1329
+ const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
1330
+ if (method === "GET" && themeImage) {
1331
+ let name;
1332
+ try {
1333
+ name = decodeURIComponent(themeImage[1]);
1334
+ }
1335
+ catch {
1336
+ json(res, 404, { error: "No such image" });
1337
+ return true;
1338
+ }
1339
+ const image = ctx.themeStore?.asset(name, themeImage[2]) ?? null;
1340
+ if (!image) {
1341
+ json(res, 404, { error: "No such image" });
1342
+ return true;
1343
+ }
1344
+ res.setHeader("Content-Type", image.mime);
1345
+ res.setHeader("X-Content-Type-Options", "nosniff");
1346
+ res.setHeader("Cache-Control", "private, max-age=86400");
1347
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
1348
+ res.writeHead(200);
1349
+ res.end(image.bytes);
1350
+ return true;
1351
+ }
1352
+ const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget|ambient)$/);
1353
+ if (method === "POST" && themeWrite) {
1354
+ const service = ctx.webAppearance;
1355
+ if (!service) {
1356
+ json(res, 404, { error: "Themes are not kept on this fleet" });
1357
+ return true;
1358
+ }
1359
+ if (gatewayRequestContext(req)) {
1360
+ json(res, 403, { error: "theme_not_over_public_link" });
1361
+ return true;
1362
+ }
1363
+ let name;
1364
+ try {
1365
+ name = decodeURIComponent(themeWrite[1]);
1366
+ }
1367
+ catch {
1368
+ json(res, 400, { error: "instance" });
1369
+ return true;
1370
+ }
1371
+ const who = () => {
1372
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1373
+ if (verdict.kind !== "allow")
1374
+ return 401;
1375
+ return verdict.via === "session" && verdict.session?.tier === "admin" ? 200 : 403;
1376
+ };
1377
+ const first = who();
1378
+ if (first !== 200) {
1379
+ json(res, first, { error: first === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
1380
+ return true;
1381
+ }
1382
+ (async () => {
1383
+ let body = {};
1384
+ try {
1385
+ body = await parseBody(req);
1386
+ }
1387
+ catch {
1388
+ json(res, 400, { error: "Invalid JSON" });
1389
+ return;
1390
+ }
1391
+ if (!permitWebContinuation(req, res, ctx))
1392
+ return;
1393
+ const now = who();
1394
+ if (now !== 200) {
1395
+ json(res, now, { error: now === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
1396
+ return;
1397
+ }
1398
+ if (!ctx.isKnownInstance?.(name)) {
1399
+ json(res, 404, { error: "unknown instance" });
1400
+ return;
1401
+ }
1402
+ const r = themeWrite[2] === "reset" ? service.humanReset(name)
1403
+ : themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
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 };
1406
+ if (typeof r.error === "string") {
1407
+ json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
1408
+ return;
1409
+ }
1410
+ json(res, 200, r);
1411
+ })().catch(err => {
1412
+ ctx.logger.error({ err: err.message }, "Theme reset/lock failed");
1413
+ try {
1414
+ json(res, 500, { error: "Theme change failed" });
1415
+ }
1416
+ catch { /* already answered */ }
1417
+ });
1418
+ return true;
1419
+ }
468
1420
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
469
1421
  const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
470
1422
  if (method === "GET" && imageMatch) {
@@ -634,6 +1586,7 @@ export function handleWebRequest(req, res, url, ctx) {
634
1586
  return true;
635
1587
  }
636
1588
  const click = ctx.clickWebReplyButton.bind(ctx);
1589
+ const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
637
1590
  (async () => {
638
1591
  let body;
639
1592
  try {
@@ -650,13 +1603,29 @@ export function handleWebRequest(req, res, url, ctx) {
650
1603
  json(res, 400, { error: "instance, id and index required" });
651
1604
  return;
652
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
+ }
653
1620
  // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
654
1621
  // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
655
1622
  // gate (a session revoked meanwhile) clicks nothing as anyone.
656
1623
  const person = webPersonOr401(req, res, url, ctx);
657
1624
  if (!person)
658
1625
  return;
659
- 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 });
660
1629
  json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
661
1630
  })().catch(err => {
662
1631
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
@@ -679,15 +1648,19 @@ export function handleWebRequest(req, res, url, ctx) {
679
1648
  json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
680
1649
  return true;
681
1650
  }
682
- // #1665: the web preferences that follow the person between devices. Read by any page of the panel, the public link
683
- // included (it reads what is in force); written only by a signed-in admin session or the CLI's header token — never
684
- // over the public link (absent from its route manifest, and refused here too), never by a read-only session.
1651
+ // Host preferences preserve their scope; only five declared owner slots use the current human session bucket.
685
1652
  if (path === "/ui/prefs" && method === "GET") {
686
1653
  if (!ctx.webPrefs) {
687
1654
  json(res, 404, { error: "Preferences are not kept on this fleet" });
688
1655
  return true;
689
1656
  }
690
- json(res, 200, ctx.webPrefs.read(OPERATOR));
1657
+ const viewer = prefsViewer(req, url, ctx);
1658
+ if (!viewer) {
1659
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1660
+ return true;
1661
+ }
1662
+ res.setHeader("Cache-Control", "private, no-store");
1663
+ json(res, 200, prefsView(ctx.webPrefs, viewer));
691
1664
  return true;
692
1665
  }
693
1666
  if (path === "/ui/prefs" && method === "POST") {
@@ -700,17 +1673,9 @@ export function handleWebRequest(req, res, url, ctx) {
700
1673
  json(res, 403, { error: "prefs_not_over_public_link" });
701
1674
  return true;
702
1675
  }
703
- // Who may write, asked now and again once the body is in: a session revoked or expired, or a token rotated, while
704
- // the body was on its way writes nothing and announces nothing.
705
- const writer = () => {
706
- const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
707
- if (verdict.kind !== "allow")
708
- return 401;
709
- return verdict.via === "session" && verdict.session?.tier !== "admin" ? 403 : 200;
710
- };
711
- const refuse = (code) => json(res, code, code === 401 ? { error: WEB_TOKEN_INVALID_MESSAGE } : { error: "prefs_read_only" });
712
- const first = writer();
713
- if (first !== 200) {
1676
+ const refuse = (viewer) => json(res, viewer ? 403 : 401, { error: viewer ? "prefs_read_only" : WEB_TOKEN_INVALID_MESSAGE });
1677
+ const first = prefsViewer(req, url, ctx);
1678
+ if (!first?.canWrite) {
714
1679
  refuse(first);
715
1680
  return true;
716
1681
  }
@@ -734,21 +1699,39 @@ export function handleWebRequest(req, res, url, ctx) {
734
1699
  json(res, 400, { error: parsed.error });
735
1700
  return;
736
1701
  }
737
- const now = writer();
738
- if (now !== 200) {
1702
+ const now = prefsViewer(req, url, ctx);
1703
+ if (!now?.canWrite) {
739
1704
  refuse(now);
740
1705
  return;
741
1706
  }
742
- const onlyIfUnset = body.onlyIfUnset === true;
743
- const written = prefs.write(OPERATOR, parsed.patch, { onlyIfUnset });
1707
+ if (now.person !== first.person || now.epoch !== first.epoch || now.via !== first.via) {
1708
+ json(res, 409, { error: "prefs_person_changed" });
1709
+ return;
1710
+ }
1711
+ const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
1712
+ if (personal && now.person === null) {
1713
+ json(res, 403, { error: "prefs_need_a_signed_in_person" });
1714
+ return;
1715
+ }
1716
+ // A scope marker rejects a queued old page after a different login, including the same person's new session.
1717
+ // It supplies neither identity nor authority; the current verified session still chooses the bucket.
1718
+ if (personal && body.ownerEpoch !== now.epoch) {
1719
+ json(res, 409, { error: "prefs_person_changed" });
1720
+ return;
1721
+ }
1722
+ const before = prefs.readForViewer(now.person);
1723
+ const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true,
1724
+ validateEffect: (person, key, id) => ctx.effectService ? ctx.effectService.validateOwnerSelection(person, key, id) : "effects_unavailable" });
744
1725
  if (!written.ok) {
745
1726
  json(res, written.status, { error: written.error });
746
1727
  return;
747
1728
  }
748
- // Another device sees the new rev in the next status frame — at once on the live stream.
1729
+ const dropped = written.changed.filter(isOwnerEffectKey).flatMap(key => typeof before.values[key] === "string" && before.values[key] !== written.view.values[key] ? [before.values[key]] : []);
1730
+ if (dropped.length)
1731
+ ctx.effectService?.collectOwnerReplaced(dropped);
749
1732
  if (written.changed.length)
750
1733
  ctx.emitSseEvent("status", ctx.getUiStatus());
751
- json(res, 200, { ...written.view, changed: written.changed });
1734
+ json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
752
1735
  })().catch(err => {
753
1736
  ctx.logger.error({ err: err.message }, "Saving web preferences failed");
754
1737
  try {
@@ -758,6 +1741,24 @@ export function handleWebRequest(req, res, url, ctx) {
758
1741
  });
759
1742
  return true;
760
1743
  }
1744
+ // #1629 r4 §3: owner CSS — one person's own stylesheet, for their own screens. Every route is a signed-in human's
1745
+ // session: never the CLI's header token (no person, and the way an agent could reach it), never the public link
1746
+ // (absent from its manifest, and refused here too), reads included. The stylesheet and its assets are that session's
1747
+ // person's only: no name or identity in any URL.
1748
+ if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
1749
+ return handleOwnerCss(req, res, url, ctx, path, method);
1750
+ }
1751
+ // #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
1752
+ if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
1753
+ return handleMods(req, res, url, ctx, path, method);
1754
+ // Posters remain behind the common session gate, with a closed GET-only public manifest.
1755
+ if (path === "/ui/effect-posters" || path.startsWith("/ui/effect-posters/"))
1756
+ return handleEffectPosters(res, url, ctx, path, method);
1757
+ // Canvas effects are absent from the public gateway manifest and independently refused above.
1758
+ if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
1759
+ return handleEffectTrials(req, res, url, ctx, path, method);
1760
+ if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
1761
+ return handleEffects(req, res, url, ctx, path, method);
761
1762
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
762
1763
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
763
1764
  if (method === "POST" && path === "/ui/needs/ack") {
@@ -802,12 +1803,57 @@ export function handleWebRequest(req, res, url, ctx) {
802
1803
  // #1269: an instance's chat command (/ctx, /compact, /clear, /model, …) — a session write (CSRF, checked by the
803
1804
  // gate), run through the platforms' own handlers and command table. The public link is told it is one (/save is
804
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
+ }
805
1850
  if (method === "POST" && path === "/ui/command") {
806
1851
  if (!ctx.webCommand) {
807
1852
  json(res, 404, { error: "Not available" });
808
1853
  return true;
809
1854
  }
810
1855
  const run = ctx.webCommand.bind(ctx);
1856
+ const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
811
1857
  (async () => {
812
1858
  let body;
813
1859
  try {
@@ -836,7 +1882,8 @@ export function handleWebRequest(req, res, url, ctx) {
836
1882
  const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
837
1883
  if (!person)
838
1884
  return;
839
- 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 });
840
1887
  json(res, r.status, r.body);
841
1888
  })().catch(err => {
842
1889
  ctx.logger.error({ err: err.message }, "Web command failed");
@@ -866,6 +1913,12 @@ export function handleWebRequest(req, res, url, ctx) {
866
1913
  json(res, 404, { error: `Instance not found: ${name}` });
867
1914
  return true;
868
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
+ }
869
1922
  if (!ctx.cancelInstance(name)) {
870
1923
  json(res, 409, { error: `${name} is not running` });
871
1924
  return true;
@@ -1275,6 +2328,47 @@ export function handleWebRequest(req, res, url, ctx) {
1275
2328
  }
1276
2329
  return true;
1277
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
+ }
1278
2372
  // #1468: the prompt-cache expiry analysis. Opening it is a person's navigation, like any Fleet tab (it counts as
1279
2373
  // use); the page never re-reads it on a timer. Answered from the ledgers as they are; a catch-up runs behind.
1280
2374
  if (method === "GET" && path === "/ui/cache") {
@@ -1292,6 +2386,12 @@ export function handleWebRequest(req, res, url, ctx) {
1292
2386
  json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
1293
2387
  return true;
1294
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
+ }
1295
2395
  if (method === "GET" && path === "/ui/teams") {
1296
2396
  const teams = ctx.fleetConfig?.teams ?? {};
1297
2397
  json(res, 200, { teams });
@@ -1480,6 +2580,7 @@ function webEchoPreview(message, names) {
1480
2580
  }
1481
2581
  /** Handle POST /ui/send — extracted for readability. */
1482
2582
  function handleSendMessage(req, res, ctx) {
2583
+ const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
1483
2584
  let body = "";
1484
2585
  let size = 0;
1485
2586
  req.on("data", (chunk) => {
@@ -1503,6 +2604,17 @@ function handleSendMessage(req, res, ctx) {
1503
2604
  return;
1504
2605
  }
1505
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
+ }
1506
2618
  const ipc = ctx.instanceIpcClients.get(instance);
1507
2619
  if (!ipc) {
1508
2620
  json(res, 404, { error: `Instance not found: ${instance}` });
@@ -1536,6 +2648,8 @@ function handleSendMessage(req, res, ctx) {
1536
2648
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1537
2649
  return;
1538
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));
1539
2653
  const ts = new Date().toISOString();
1540
2654
  // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
1541
2655
  const messageId = newWebMessageId();
@@ -1550,11 +2664,13 @@ function handleSendMessage(req, res, ctx) {
1550
2664
  && !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
1551
2665
  // Add no runtime credentials or local attachment paths to the display copy.
1552
2666
  const preview = webEchoPreview(message, files.map(f => f.name));
1553
- // ClassicBot echo (#1320 part B): same ordering lane, per-entry
1554
- // 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.
1555
2669
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
1556
2670
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1557
- 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
1558
2674
  }) : undefined;
1559
2675
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1560
2676
  // A replacement adapter or edited binding is not the route we reserved.
@@ -1562,12 +2678,14 @@ function handleSendMessage(req, res, ctx) {
1562
2678
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
1563
2679
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
1564
2680
  return;
1565
- 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;
1566
2684
  }) : undefined;
1567
2685
  let delivered;
1568
2686
  try {
1569
2687
  const deliver = (name, payload) => ctx.deliverHumanToInstance
1570
- ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
2688
+ ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed)
1571
2689
  : ctx.deliverToInstance(name, payload);
1572
2690
  delivered = await deliver(instance, {
1573
2691
  type: "fleet_inbound",
@@ -1580,6 +2698,7 @@ function handleSendMessage(req, res, ctx) {
1580
2698
  ts,
1581
2699
  thread_id: topicId != null ? String(topicId) : "",
1582
2700
  source: "web",
2701
+ ...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
1583
2702
  adapter_id: syncAdapter?.id,
1584
2703
  ...delivery.meta,
1585
2704
  },
@@ -1616,7 +2735,8 @@ function handleSendMessage(req, res, ctx) {
1616
2735
  ctx.webFiles?.commit(files);
1617
2736
  ctx.lastInboundUser.set(instance, person.name);
1618
2737
  ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1619
- 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 } : {}) });
1620
2740
  json(res, 200, { sent: true, messageId });
1621
2741
  }
1622
2742
  catch {
@@ -1640,6 +2760,13 @@ function handleUpload(req, res, url, ctx) {
1640
2760
  json(res, 404, { error: "uploads are not available" });
1641
2761
  return;
1642
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
+ }
1643
2770
  if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
1644
2771
  json(res, 404, { error: `Instance not found: ${instance}` });
1645
2772
  return;
@@ -1830,32 +2957,73 @@ function serveMediaRange(req, res, files, id, file) {
1830
2957
  res.writeHead(ranged ? 206 : 200);
1831
2958
  res.end(got.bytes);
1832
2959
  }
1833
- /**
1834
- * #1665: `<html>` carrying the host's preferences (data-prefs: `{"rev":n,"values":{…}}`, attribute-escaped). theme.js (a
1835
- * blocking script in <head>) and the app's dictionary take the theme and language from it over the browser's cache,
1836
- * and app-prefs.js copies every value into that cache before any panel reads it — so no device paints a stale theme
1837
- * or replays a tour already seen, a new device included. Only for the signed-in app (the public link reads what is in
1838
- * force too); the anonymous View reader and the sign-in page keep the browser's own.
1839
- */
1840
- function shellHtmlTag(ctx, mode) {
2960
+ function prefsViewer(req, url, ctx) {
2961
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2962
+ if (v.kind !== "allow")
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 } : {}) } : {};
2968
+ if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
2969
+ const person = webPersonKey(v.session.owner);
2970
+ return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
2971
+ }
2972
+ return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
2973
+ }
2974
+ const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
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
+ };
2980
+ function shellHtmlTag(ctx, mode, viewer) {
1841
2981
  if (mode !== "full" || !ctx.webPrefs)
1842
2982
  return '<html lang="en">';
1843
- const view = ctx.webPrefs.read(OPERATOR);
2983
+ const view = prefsView(ctx.webPrefs, viewer);
1844
2984
  const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
1845
2985
  const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1846
- return `<html lang="${lang}" data-prefs="${attr(JSON.stringify({ rev: view.rev, values: view.values }))}">`;
2986
+ return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
2987
+ }
2988
+ /**
2989
+ * #1629 r4 §3: the owner CSS this load links (its sha), or null. Only the full app of a signed-in human session — never
2990
+ * the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
2991
+ */
2992
+ function shellOwnerCss(ctx, mode, viewer) {
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" };
1847
3007
  }
1848
- function shellBodyTag(req, ctx, mode) {
3008
+ function shellBodyTag(req, ctx, mode, viewer) {
1849
3009
  const p = shellPreview(req, ctx, mode);
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) {
3013
+ ownerCssState = ctx.ownerCss.effectState(viewer.person);
3014
+ if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
3015
+ ownerCssState = "unknown";
3016
+ }
1850
3017
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1851
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}"` : ""}`
1852
3020
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
1853
3021
  // #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
1854
3022
  + ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
1855
3023
  // #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
1856
3024
  + `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
1857
- // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔.
1858
- + ` 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"}"`
1859
3027
  // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
1860
3028
  + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
1861
3029
  // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
@@ -1863,14 +3031,19 @@ function shellBodyTag(req, ctx, mode) {
1863
3031
  + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
1864
3032
  // #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
1865
3033
  + ` data-fleet-tz="${attr(resolveTz())}"`
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.
3035
+ + ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
1866
3036
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
1867
- + `${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) : ""}>`;
1868
3040
  }
1869
3041
  /** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
1870
3042
  * when this load chose one. */
1871
3043
  export function serveAppShell(req, res, ctx, mode) {
1872
3044
  try {
1873
3045
  const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
3046
+ const viewer = mode === "full" ? prefsViewer(req, new URL(req.url ?? "/", "http://localhost"), ctx) : null;
1874
3047
  const p = shellPreview(req, ctx, mode);
1875
3048
  // #1589: media plays only from this listener's own file route (path-scoped, like frame-src), at the address this page
1876
3049
  // was loaded from (a Host the host guard accepted); never on the public link, nor where no chat is shown.
@@ -1879,8 +3052,12 @@ export function serveAppShell(req, res, ctx, mode) {
1879
3052
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
1880
3053
  // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
1881
3054
  // the page may load); every other page frames exactly the origin this load chose (#1723).
1882
- const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
1883
- sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode)).replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
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(" ");
3060
+ sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
1884
3061
  }
1885
3062
  catch {
1886
3063
  json(res, 500, { error: "app.html not found" });