@songsid/agend 2.2.0-beta.9 → 2.3.0-beta.1

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 (422) hide show
  1. package/README.md +40 -24
  2. package/README.zh-TW.md +40 -24
  3. package/dist/agent-cli.js +248 -2
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +2 -0
  6. package/dist/agent-endpoint.js +28 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-file-read.d.ts +30 -0
  9. package/dist/agent-file-read.js +129 -0
  10. package/dist/agent-file-read.js.map +1 -0
  11. package/dist/agent-whats-new.js +20 -1
  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 +3 -1
  16. package/dist/auth-api.js +32 -1
  17. package/dist/auth-api.js.map +1 -1
  18. package/dist/backend/antigravity.d.ts +33 -0
  19. package/dist/backend/antigravity.js +129 -21
  20. package/dist/backend/antigravity.js.map +1 -1
  21. package/dist/backend/claude-code.d.ts +3 -0
  22. package/dist/backend/claude-code.js +9 -0
  23. package/dist/backend/claude-code.js.map +1 -1
  24. package/dist/backend/codex-metadata.d.ts +33 -0
  25. package/dist/backend/codex-metadata.js +122 -18
  26. package/dist/backend/codex-metadata.js.map +1 -1
  27. package/dist/backend/codex.d.ts +3 -0
  28. package/dist/backend/codex.js +17 -1
  29. package/dist/backend/codex.js.map +1 -1
  30. package/dist/backend/effort-metadata.d.ts +37 -1
  31. package/dist/backend/effort-metadata.js +86 -15
  32. package/dist/backend/effort-metadata.js.map +1 -1
  33. package/dist/backend/grok.d.ts +3 -0
  34. package/dist/backend/grok.js +9 -0
  35. package/dist/backend/grok.js.map +1 -1
  36. package/dist/backend/kiro.d.ts +3 -1
  37. package/dist/backend/kiro.js +7 -3
  38. package/dist/backend/kiro.js.map +1 -1
  39. package/dist/backend/launch-effort.d.ts +61 -0
  40. package/dist/backend/launch-effort.js +170 -0
  41. package/dist/backend/launch-effort.js.map +1 -0
  42. package/dist/backend/muse.js +10 -2
  43. package/dist/backend/muse.js.map +1 -1
  44. package/dist/backend/types.d.ts +24 -0
  45. package/dist/backend/types.js +12 -1
  46. package/dist/backend/types.js.map +1 -1
  47. package/dist/backend-command.d.ts +91 -0
  48. package/dist/backend-command.js +122 -0
  49. package/dist/backend-command.js.map +1 -0
  50. package/dist/channel/adapters/discord.d.ts +14 -0
  51. package/dist/channel/adapters/discord.js +66 -4
  52. package/dist/channel/adapters/discord.js.map +1 -1
  53. package/dist/channel/adapters/telegram.d.ts +26 -1
  54. package/dist/channel/adapters/telegram.js +98 -7
  55. package/dist/channel/adapters/telegram.js.map +1 -1
  56. package/dist/channel/mcp-tools.js +36 -0
  57. package/dist/channel/mcp-tools.js.map +1 -1
  58. package/dist/channel/tool-router.d.ts +2 -0
  59. package/dist/channel/tool-router.js +1 -1
  60. package/dist/channel/tool-router.js.map +1 -1
  61. package/dist/channel/types.d.ts +31 -0
  62. package/dist/cli.js +124 -24
  63. package/dist/cli.js.map +1 -1
  64. package/dist/command-table.d.ts +2 -2
  65. package/dist/command-table.js +5 -2
  66. package/dist/command-table.js.map +1 -1
  67. package/dist/compact-watch.d.ts +86 -0
  68. package/dist/compact-watch.js +288 -0
  69. package/dist/compact-watch.js.map +1 -0
  70. package/dist/config-validator.js +55 -0
  71. package/dist/config-validator.js.map +1 -1
  72. package/dist/config.d.ts +1 -0
  73. package/dist/config.js +2 -1
  74. package/dist/config.js.map +1 -1
  75. package/dist/daemon.d.ts +9 -0
  76. package/dist/daemon.js +394 -29
  77. package/dist/daemon.js.map +1 -1
  78. package/dist/delivery-outbox.d.ts +8 -1
  79. package/dist/delivery-outbox.js +6 -2
  80. package/dist/delivery-outbox.js.map +1 -1
  81. package/dist/doctor.js +7 -0
  82. package/dist/doctor.js.map +1 -1
  83. package/dist/fleet-context.d.ts +12 -0
  84. package/dist/fleet-manager.d.ts +247 -9
  85. package/dist/fleet-manager.js +1227 -142
  86. package/dist/fleet-manager.js.map +1 -1
  87. package/dist/forged-envelope-scan.d.ts +4 -3
  88. package/dist/forged-envelope-scan.js +4 -3
  89. package/dist/forged-envelope-scan.js.map +1 -1
  90. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  91. package/dist/general-knowledge/skills/web-appearance/SKILL.md +258 -0
  92. package/dist/install-lock.d.ts +18 -1
  93. package/dist/install-lock.js +30 -3
  94. package/dist/install-lock.js.map +1 -1
  95. package/dist/instance-avatar.d.ts +5 -4
  96. package/dist/instance-avatar.js +15 -8
  97. package/dist/instance-avatar.js.map +1 -1
  98. package/dist/instance-config-impact.js +3 -0
  99. package/dist/instance-config-impact.js.map +1 -1
  100. package/dist/instance-lifecycle.js +9 -4
  101. package/dist/instance-lifecycle.js.map +1 -1
  102. package/dist/instructions.js +2 -2
  103. package/dist/instructions.js.map +1 -1
  104. package/dist/kiro-compact-pane.d.ts +38 -0
  105. package/dist/kiro-compact-pane.js +57 -0
  106. package/dist/kiro-compact-pane.js.map +1 -0
  107. package/dist/launchd-handoff.d.ts +43 -0
  108. package/dist/launchd-handoff.js +56 -0
  109. package/dist/launchd-handoff.js.map +1 -0
  110. package/dist/locale.js +202 -30
  111. package/dist/locale.js.map +1 -1
  112. package/dist/log-service.js +10 -2
  113. package/dist/log-service.js.map +1 -1
  114. package/dist/login-shell.d.ts +21 -0
  115. package/dist/login-shell.js +98 -0
  116. package/dist/login-shell.js.map +1 -0
  117. package/dist/needs-you-hub.d.ts +4 -0
  118. package/dist/needs-you-hub.js +31 -6
  119. package/dist/needs-you-hub.js.map +1 -1
  120. package/dist/needs-you.d.ts +1 -13
  121. package/dist/needs-you.js +3 -37
  122. package/dist/needs-you.js.map +1 -1
  123. package/dist/outbound-handlers.d.ts +32 -0
  124. package/dist/outbound-handlers.js +150 -45
  125. package/dist/outbound-handlers.js.map +1 -1
  126. package/dist/outbound-schemas.d.ts +76 -0
  127. package/dist/outbound-schemas.js +65 -4
  128. package/dist/outbound-schemas.js.map +1 -1
  129. package/dist/provider-probe.d.ts +29 -0
  130. package/dist/provider-probe.js +198 -2
  131. package/dist/provider-probe.js.map +1 -1
  132. package/dist/public-web-gateway.d.ts +2 -0
  133. package/dist/public-web-gateway.js +25 -1
  134. package/dist/public-web-gateway.js.map +1 -1
  135. package/dist/quickstart-api.d.ts +1 -1
  136. package/dist/quickstart-api.js +8 -6
  137. package/dist/quickstart-api.js.map +1 -1
  138. package/dist/quickstart.d.ts +12 -0
  139. package/dist/quickstart.js +36 -6
  140. package/dist/quickstart.js.map +1 -1
  141. package/dist/reply-buttons-controller.d.ts +86 -5
  142. package/dist/reply-buttons-controller.js +271 -7
  143. package/dist/reply-buttons-controller.js.map +1 -1
  144. package/dist/reply-buttons.d.ts +48 -3
  145. package/dist/reply-buttons.js +105 -10
  146. package/dist/reply-buttons.js.map +1 -1
  147. package/dist/reply-polls.d.ts +85 -0
  148. package/dist/reply-polls.js +154 -0
  149. package/dist/reply-polls.js.map +1 -0
  150. package/dist/repo-worktree-guard.d.ts +36 -0
  151. package/dist/repo-worktree-guard.js +143 -0
  152. package/dist/repo-worktree-guard.js.map +1 -0
  153. package/dist/restart-guard.d.ts +6 -3
  154. package/dist/restart-guard.js +6 -3
  155. package/dist/restart-guard.js.map +1 -1
  156. package/dist/restart-progress.d.ts +0 -2
  157. package/dist/restart-progress.js +0 -2
  158. package/dist/restart-progress.js.map +1 -1
  159. package/dist/scheduler/scheduler.d.ts +58 -1
  160. package/dist/scheduler/scheduler.js +176 -10
  161. package/dist/scheduler/scheduler.js.map +1 -1
  162. package/dist/sd-notify.d.ts +22 -1
  163. package/dist/sd-notify.js +63 -10
  164. package/dist/sd-notify.js.map +1 -1
  165. package/dist/service-activation.d.ts +15 -7
  166. package/dist/service-activation.js +42 -26
  167. package/dist/service-activation.js.map +1 -1
  168. package/dist/service-installer.d.ts +24 -0
  169. package/dist/service-installer.js +49 -1
  170. package/dist/service-installer.js.map +1 -1
  171. package/dist/service-plan.d.ts +11 -0
  172. package/dist/service-plan.js +23 -0
  173. package/dist/service-plan.js.map +1 -1
  174. package/dist/settings-api.js +74 -1
  175. package/dist/settings-api.js.map +1 -1
  176. package/dist/settings-change.js +8 -0
  177. package/dist/settings-change.js.map +1 -1
  178. package/dist/settings-effect.js +16 -2
  179. package/dist/settings-effect.js.map +1 -1
  180. package/dist/settings-http-confirmation.js +5 -0
  181. package/dist/settings-http-confirmation.js.map +1 -1
  182. package/dist/systemd-peer-bus.d.ts +95 -0
  183. package/dist/systemd-peer-bus.js +430 -0
  184. package/dist/systemd-peer-bus.js.map +1 -0
  185. package/dist/systemd-runtime.d.ts +27 -1
  186. package/dist/systemd-runtime.js +69 -7
  187. package/dist/systemd-runtime.js.map +1 -1
  188. package/dist/tool-permissions.js +53 -3
  189. package/dist/tool-permissions.js.map +1 -1
  190. package/dist/topic-commands.d.ts +72 -2
  191. package/dist/topic-commands.js +156 -38
  192. package/dist/topic-commands.js.map +1 -1
  193. package/dist/topic-link.d.ts +128 -0
  194. package/dist/topic-link.js +269 -0
  195. package/dist/topic-link.js.map +1 -0
  196. package/dist/transcript-jsonl.d.ts +7 -2
  197. package/dist/transcript-jsonl.js +13 -7
  198. package/dist/transcript-jsonl.js.map +1 -1
  199. package/dist/transcript-sources.js +6 -1
  200. package/dist/transcript-sources.js.map +1 -1
  201. package/dist/types.d.ts +21 -0
  202. package/dist/ui/app-effects.js +187 -0
  203. package/dist/ui/chat-commands.js +1 -0
  204. package/dist/ui/chat-effect-posters.js +42 -0
  205. package/dist/ui/chat-effects.js +72 -0
  206. package/dist/ui/chat-live-replies.js +58 -0
  207. package/dist/ui/chat-render.js +59 -14
  208. package/dist/ui/chat-store.js +78 -8
  209. package/dist/ui/chat-strings.js +43 -16
  210. package/dist/ui/chat-thread.js +175 -31
  211. package/dist/ui/chat-tour.js +18 -15
  212. package/dist/ui/effect-trial-controller.js +119 -0
  213. package/dist/ui/effect-trial-offer.js +31 -0
  214. package/dist/ui/effect-trial-panel.js +37 -0
  215. package/dist/ui/image-lightbox.js +1 -1
  216. package/dist/ui/panel-chat.js +101 -27
  217. package/dist/ui/panel-details.js +18 -5
  218. package/dist/ui/panel-fleet.js +18 -5
  219. package/dist/ui/panel-log.js +10 -10
  220. package/dist/ui/panel-needs.js +13 -10
  221. package/dist/ui/panel-settings.js +66 -12
  222. package/dist/ui/preview-panel.js +25 -9
  223. package/dist/ui/settings-appearance.js +325 -0
  224. package/dist/ui/settings-dialogs.js +11 -6
  225. package/dist/ui/settings-effects.js +201 -0
  226. package/dist/ui/settings-model.js +3 -1
  227. package/dist/ui/settings-mods.js +257 -0
  228. package/dist/ui/settings-owner-effects.js +80 -0
  229. package/dist/ui/settings-strings.js +269 -10
  230. package/dist/ui/settings-token.js +7 -0
  231. package/dist/ui/settings-wizard.js +5 -3
  232. package/dist/ui/shared/agend-auth.js +27 -3
  233. package/dist/ui/shared/agent-widget.js +248 -0
  234. package/dist/ui/shared/app-i18n.js +3 -1
  235. package/dist/ui/shared/app-prefs.js +30 -10
  236. package/dist/ui/shared/app-route.js +23 -1
  237. package/dist/ui/shared/app-session.js +1 -1
  238. package/dist/ui/shared/app-shell.js +29 -16
  239. package/dist/ui/shared/app-store.js +2 -0
  240. package/dist/ui/shared/app-stream.js +15 -7
  241. package/dist/ui/shared/app-themes.js +188 -0
  242. package/dist/ui/shared/app.css +266 -11
  243. package/dist/ui/shared/app.js +6 -0
  244. package/dist/ui/shared/card-strings.js +28 -0
  245. package/dist/ui/shared/effect-app-runtime.js +248 -0
  246. package/dist/ui/shared/effect-baseline.js +64 -0
  247. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  248. package/dist/ui/shared/effect-cursor.js +125 -0
  249. package/dist/ui/shared/effect-frame.js +127 -0
  250. package/dist/ui/shared/effect-geometry.js +122 -0
  251. package/dist/ui/shared/effect-host-style.js +10 -0
  252. package/dist/ui/shared/effect-owner-regions.js +74 -0
  253. package/dist/ui/shared/effect-owner-source.js +115 -0
  254. package/dist/ui/shared/effect-policy.js +137 -0
  255. package/dist/ui/shared/effect-poster-reader.js +98 -0
  256. package/dist/ui/shared/effect-posters.js +117 -0
  257. package/dist/ui/shared/effect-profiler.js +162 -0
  258. package/dist/ui/shared/effect-profiles.js +65 -0
  259. package/dist/ui/shared/effect-registry.js +496 -0
  260. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  261. package/dist/ui/shared/effect-safety.js +153 -0
  262. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  263. package/dist/ui/shared/effect-stock-trust.js +165 -0
  264. package/dist/ui/shared/effect-trial-source.js +103 -0
  265. package/dist/ui/shared/effect-trial-stage.js +147 -0
  266. package/dist/ui/shared/header-tools.js +35 -15
  267. package/dist/ui/shared/identity-card.js +202 -0
  268. package/dist/ui/shared/identity-color.js +261 -0
  269. package/dist/ui/shared/instance-nav.js +22 -14
  270. package/dist/ui/shared/instance-switch.js +5 -2
  271. package/dist/ui/shared/owner-css.js +221 -0
  272. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  273. package/dist/ui/shared/panel-view.js +96 -12
  274. package/dist/ui/shared/signin.js +17 -9
  275. package/dist/ui/shared/ui-dialog.js +1 -1
  276. package/dist/ui/shared/ui-icons.js +3 -0
  277. package/dist/ui/shared/ui-menu.js +1 -1
  278. package/dist/ui/shared/ui-toast.js +1 -1
  279. package/dist/ui/shared/view-strings.js +40 -2
  280. package/dist/ui/signin.html +9 -3
  281. package/dist/update-check.d.ts +31 -0
  282. package/dist/update-check.js +47 -0
  283. package/dist/update-check.js.map +1 -1
  284. package/dist/update-launch.d.ts +48 -1
  285. package/dist/update-launch.js +159 -5
  286. package/dist/update-launch.js.map +1 -1
  287. package/dist/usage/providers.d.ts +28 -8
  288. package/dist/usage/providers.js +96 -41
  289. package/dist/usage/providers.js.map +1 -1
  290. package/dist/view-api.d.ts +29 -0
  291. package/dist/view-api.js +214 -17
  292. package/dist/view-api.js.map +1 -1
  293. package/dist/web-api.d.ts +86 -2
  294. package/dist/web-api.js +1160 -92
  295. package/dist/web-api.js.map +1 -1
  296. package/dist/web-appearance-limits.d.ts +82 -0
  297. package/dist/web-appearance-limits.js +83 -0
  298. package/dist/web-appearance-limits.js.map +1 -0
  299. package/dist/web-appearance-policy.d.ts +66 -0
  300. package/dist/web-appearance-policy.js +94 -0
  301. package/dist/web-appearance-policy.js.map +1 -0
  302. package/dist/web-appearance-tools.d.ts +50 -0
  303. package/dist/web-appearance-tools.js +105 -0
  304. package/dist/web-appearance-tools.js.map +1 -0
  305. package/dist/web-appearance-view.d.ts +99 -0
  306. package/dist/web-appearance-view.js +163 -0
  307. package/dist/web-appearance-view.js.map +1 -0
  308. package/dist/web-appearance.d.ts +79 -0
  309. package/dist/web-appearance.js +298 -0
  310. package/dist/web-appearance.js.map +1 -0
  311. package/dist/web-asset-parts.d.ts +98 -0
  312. package/dist/web-asset-parts.js +133 -0
  313. package/dist/web-asset-parts.js.map +1 -0
  314. package/dist/web-auth.js +17 -1
  315. package/dist/web-auth.js.map +1 -1
  316. package/dist/web-card.d.ts +96 -0
  317. package/dist/web-card.js +232 -0
  318. package/dist/web-card.js.map +1 -0
  319. package/dist/web-chat-history.d.ts +39 -1
  320. package/dist/web-chat-history.js +69 -9
  321. package/dist/web-chat-history.js.map +1 -1
  322. package/dist/web-commands.d.ts +39 -3
  323. package/dist/web-commands.js +22 -5
  324. package/dist/web-commands.js.map +1 -1
  325. package/dist/web-effect-bundle.d.ts +28 -0
  326. package/dist/web-effect-bundle.js +126 -0
  327. package/dist/web-effect-bundle.js.map +1 -0
  328. package/dist/web-effect-canonical.d.ts +8 -0
  329. package/dist/web-effect-canonical.js +27 -0
  330. package/dist/web-effect-canonical.js.map +1 -0
  331. package/dist/web-effect-library.d.ts +114 -0
  332. package/dist/web-effect-library.js +530 -0
  333. package/dist/web-effect-library.js.map +1 -0
  334. package/dist/web-effect-poster.d.ts +8 -0
  335. package/dist/web-effect-poster.js +52 -0
  336. package/dist/web-effect-poster.js.map +1 -0
  337. package/dist/web-effect-profiles.d.ts +118 -0
  338. package/dist/web-effect-profiles.js +180 -0
  339. package/dist/web-effect-profiles.js.map +1 -0
  340. package/dist/web-effect-service.d.ts +86 -0
  341. package/dist/web-effect-service.js +312 -0
  342. package/dist/web-effect-service.js.map +1 -0
  343. package/dist/web-effect-tools.d.ts +47 -0
  344. package/dist/web-effect-tools.js +126 -0
  345. package/dist/web-effect-tools.js.map +1 -0
  346. package/dist/web-effect-trials.d.ts +86 -0
  347. package/dist/web-effect-trials.js +204 -0
  348. package/dist/web-effect-trials.js.map +1 -0
  349. package/dist/web-effect-upload.d.ts +29 -0
  350. package/dist/web-effect-upload.js +190 -0
  351. package/dist/web-effect-upload.js.map +1 -0
  352. package/dist/web-effect-worker.d.ts +8 -0
  353. package/dist/web-effect-worker.js +170 -0
  354. package/dist/web-effect-worker.js.map +1 -0
  355. package/dist/web-effect.d.ts +89 -0
  356. package/dist/web-effect.js +132 -0
  357. package/dist/web-effect.js.map +1 -0
  358. package/dist/web-identity-color.d.ts +87 -0
  359. package/dist/web-identity-color.js +209 -0
  360. package/dist/web-identity-color.js.map +1 -0
  361. package/dist/web-identity-palette.d.ts +113 -0
  362. package/dist/web-identity-palette.js +84 -0
  363. package/dist/web-identity-palette.js.map +1 -0
  364. package/dist/web-image-meta.d.ts +25 -0
  365. package/dist/web-image-meta.js +200 -0
  366. package/dist/web-image-meta.js.map +1 -0
  367. package/dist/web-login.d.ts +14 -0
  368. package/dist/web-login.js +4 -2
  369. package/dist/web-login.js.map +1 -1
  370. package/dist/web-mod-agent.d.ts +50 -0
  371. package/dist/web-mod-agent.js +203 -0
  372. package/dist/web-mod-agent.js.map +1 -0
  373. package/dist/web-mod-bundle.d.ts +144 -0
  374. package/dist/web-mod-bundle.js +464 -0
  375. package/dist/web-mod-bundle.js.map +1 -0
  376. package/dist/web-mod-export.d.ts +59 -0
  377. package/dist/web-mod-export.js +79 -0
  378. package/dist/web-mod-export.js.map +1 -0
  379. package/dist/web-mod-store.d.ts +283 -0
  380. package/dist/web-mod-store.js +624 -0
  381. package/dist/web-mod-store.js.map +1 -0
  382. package/dist/web-org.d.ts +3 -3
  383. package/dist/web-org.js +11 -17
  384. package/dist/web-org.js.map +1 -1
  385. package/dist/web-owner-css-store.d.ts +180 -0
  386. package/dist/web-owner-css-store.js +384 -0
  387. package/dist/web-owner-css-store.js.map +1 -0
  388. package/dist/web-owner-css.d.ts +58 -0
  389. package/dist/web-owner-css.js +552 -0
  390. package/dist/web-owner-css.js.map +1 -0
  391. package/dist/web-prefs.d.ts +66 -0
  392. package/dist/web-prefs.js +243 -35
  393. package/dist/web-prefs.js.map +1 -1
  394. package/dist/web-preview.d.ts +9 -1
  395. package/dist/web-preview.js +24 -7
  396. package/dist/web-preview.js.map +1 -1
  397. package/dist/web-profile.d.ts +32 -0
  398. package/dist/web-profile.js +132 -0
  399. package/dist/web-profile.js.map +1 -0
  400. package/dist/web-session.d.ts +14 -0
  401. package/dist/web-session.js +10 -0
  402. package/dist/web-session.js.map +1 -1
  403. package/dist/web-settings-patch.d.ts +18 -0
  404. package/dist/web-settings-patch.js +51 -0
  405. package/dist/web-settings-patch.js.map +1 -1
  406. package/dist/web-shell-routes.d.ts +1 -1
  407. package/dist/web-shell-routes.js +1 -1
  408. package/dist/web-shell-routes.js.map +1 -1
  409. package/dist/web-theme-store.d.ts +218 -0
  410. package/dist/web-theme-store.js +516 -0
  411. package/dist/web-theme-store.js.map +1 -0
  412. package/dist/web-theme.d.ts +121 -0
  413. package/dist/web-theme.js +407 -0
  414. package/dist/web-theme.js.map +1 -0
  415. package/dist/web-upload.d.ts +119 -4
  416. package/dist/web-upload.js +252 -39
  417. package/dist/web-upload.js.map +1 -1
  418. package/dist/web-widget.d.ts +72 -0
  419. package/dist/web-widget.js +245 -0
  420. package/dist/web-widget.js.map +1 -0
  421. package/launcher/install-admission.cjs +61 -6
  422. package/package.json +3 -3
package/dist/web-api.js CHANGED
@@ -3,6 +3,12 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
3
3
  import { binaryProbe } from "./binary-probe.js";
4
4
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
5
5
  import { readBoundedWebBody } from "./web-body.js";
6
+ import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
7
+ import { buildModBundle } from "./web-mod-export.js";
8
+ import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
9
+ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
10
+ import { canonicalText } from "./web-theme.js";
11
+ import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
6
12
  import { gatewayRequestContext } from "./web-request-context.js";
7
13
  import { permitWebContinuation } from "./web-continuation.js";
8
14
  /**
@@ -15,7 +21,7 @@ import { isSafeInstanceName } from "./web-shell-routes.js";
15
21
  import { MAX_COMMAND_ARGS } from "./web-commands.js";
16
22
  import { sendPanelHtml } from "./web-host-guard.js";
17
23
  import { shellRoute } from "./web-shell-routes.js";
18
- import { readFileSync } from "node:fs";
24
+ import { createReadStream, createWriteStream, readFileSync } from "node:fs";
19
25
  import { join, dirname } from "node:path";
20
26
  import { fileURLToPath } from "node:url";
21
27
  import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
@@ -26,9 +32,10 @@ import { z } from "zod";
26
32
  import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
27
33
  import { dashboardOriginFor } from "./web-preview.js";
28
34
  import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
29
- import { attachmentDelivery, displayName, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, sniffUpload, UPLOAD_LIMITS, wellFormed } from "./web-upload.js";
35
+ 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
36
  import { getAgendHome } from "./paths.js";
31
- import { OPERATOR } from "./web-prefs.js";
37
+ import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
38
+ import { WEB_NAME_FALLBACK, webPersonKey } from "./web-profile.js";
32
39
  import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
33
40
  import { parseMentionQuery } from "./web-mentions.js";
34
41
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
@@ -149,6 +156,732 @@ function stillAuthorized(req, res, url, ctx) {
149
156
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
150
157
  return false;
151
158
  }
159
+ /**
160
+ * #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
161
+ * signed-in human session is, or the status to answer.
162
+ */
163
+ function handleOwnerCss(req, res, url, ctx, path, method) {
164
+ const store = ctx.ownerCss;
165
+ if (!store) {
166
+ json(res, 404, { error: "Owner CSS is not kept on this fleet" });
167
+ return true;
168
+ }
169
+ const reading = method === "GET" || method === "HEAD";
170
+ if (gatewayRequestContext(req)) {
171
+ json(res, reading ? 404 : 403, { error: "owner_css_not_over_public_link" });
172
+ return true;
173
+ }
174
+ const who = () => {
175
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
176
+ if (verdict.kind !== "allow")
177
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
178
+ if (verdict.via !== "session" || !verdict.session)
179
+ return { status: 403, error: "owner_css_needs_a_signed_in_person" };
180
+ if (!reading && verdict.session.tier !== "admin")
181
+ return { status: 403, error: "owner_css_read_only" };
182
+ return { person: webPersonKey(verdict.session.owner) };
183
+ };
184
+ const first = who();
185
+ if ("status" in first) {
186
+ json(res, first.status, { error: first.error });
187
+ return true;
188
+ }
189
+ const again = () => {
190
+ if (!permitWebContinuation(req, res, ctx))
191
+ return null;
192
+ const now = who();
193
+ if ("status" in now) {
194
+ json(res, now.status, { error: now.error });
195
+ return null;
196
+ }
197
+ if (now.person !== first.person) {
198
+ json(res, 409, { error: "owner_css_person_changed" });
199
+ return null;
200
+ }
201
+ return now.person;
202
+ };
203
+ const announce = () => ctx.emitSseEvent("status", ctx.getUiStatus()); // their other devices see the new prefs rev
204
+ const fail = (err) => {
205
+ ctx.logger.error({ err: err.message }, "Owner CSS request failed");
206
+ try {
207
+ json(res, 500, { error: "Owner CSS request failed" });
208
+ }
209
+ catch { /* already answered */ }
210
+ };
211
+ if (path === "/ui/owner-css.css" && method === "GET") {
212
+ const v = url.searchParams.get("v");
213
+ const sha = v ?? store.state(first.person).active;
214
+ const text = sha ? store.served(first.person, sha) : null;
215
+ if (text === null) {
216
+ json(res, 404, { error: "no owner CSS" });
217
+ return true;
218
+ }
219
+ res.writeHead(200, {
220
+ "Content-Type": "text/css; charset=utf-8", "X-Content-Type-Options": "nosniff",
221
+ // Keyed by its sha (?v): a version's text never changes. Without ?v it is whatever is in force now.
222
+ "Cache-Control": v ? "private, max-age=31536000, immutable" : "private, no-store",
223
+ });
224
+ res.end(text);
225
+ return true;
226
+ }
227
+ const assetPath = /^\/ui\/owner-css\/asset\/([0-9a-f]{32})$/.exec(path);
228
+ if (assetPath && method === "GET") {
229
+ const asset = store.asset(first.person, assetPath[1]);
230
+ if (!asset) {
231
+ json(res, 404, { error: "no such asset" });
232
+ return true;
233
+ }
234
+ res.writeHead(200, {
235
+ "Content-Type": asset.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable",
236
+ "Content-Security-Policy": "default-src 'none'; sandbox",
237
+ });
238
+ res.end(asset.bytes);
239
+ return true;
240
+ }
241
+ if (path === "/ui/owner-css/state" && method === "GET") {
242
+ json(res, 200, store.state(first.person));
243
+ return true;
244
+ }
245
+ if (assetPath && method === "DELETE") {
246
+ const removed = store.removeAsset(first.person, assetPath[1]);
247
+ json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
248
+ return true;
249
+ }
250
+ if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
251
+ json(res, 404, { error: "not found" });
252
+ return true;
253
+ }
254
+ (async () => {
255
+ if (path === "/ui/owner-css/asset") {
256
+ let bytes;
257
+ try {
258
+ bytes = await readBoundedWebBody(req, 1024 * 1024);
259
+ }
260
+ catch {
261
+ json(res, 413, { error: "asset_quota", detail: "at most 1 MiB" });
262
+ return;
263
+ }
264
+ const person = again();
265
+ if (!person)
266
+ return;
267
+ const added = store.addAsset(person, bytes);
268
+ if (!added.ok) {
269
+ json(res, added.status, { error: added.error, ...(added.detail ? { detail: added.detail } : {}) });
270
+ return;
271
+ }
272
+ json(res, 200, { id: added.id, kind: added.kind, bytes: added.bytes });
273
+ return;
274
+ }
275
+ let body;
276
+ try {
277
+ body = await parseBody(req);
278
+ }
279
+ catch {
280
+ json(res, 400, { error: "Invalid JSON" });
281
+ return;
282
+ }
283
+ const person = again();
284
+ if (!person)
285
+ return;
286
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
287
+ json(res, 400, { error: "an object" });
288
+ return;
289
+ }
290
+ if (path === "/ui/owner-css") {
291
+ const saved = store.save(person, body.text);
292
+ if (!saved.ok) {
293
+ json(res, saved.status, { error: saved.error, ...(saved.detail ? { detail: saved.detail } : {}) });
294
+ return;
295
+ }
296
+ announce();
297
+ json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
298
+ }
299
+ else if (path === "/ui/owner-css/keep") {
300
+ const kept = store.keep(person, body.trial);
301
+ if (!kept.ok) {
302
+ json(res, kept.status, { error: kept.error });
303
+ return;
304
+ }
305
+ announce(); // the person's other devices drop their bar
306
+ json(res, 200, { active: kept.active });
307
+ }
308
+ else if (path === "/ui/owner-css/revert") {
309
+ // Exactly this trial, while it still is one: a trial kept (or ended) on another device meanwhile stays (409).
310
+ const reverted = store.revertTrial(person, body.trial);
311
+ if (!reverted.ok) {
312
+ json(res, reverted.status, { error: reverted.error });
313
+ return;
314
+ }
315
+ announce();
316
+ json(res, 200, { active: reverted.active });
317
+ }
318
+ else {
319
+ const restored = store.restore(person, body.sha);
320
+ if (!restored.ok) {
321
+ json(res, restored.status, { error: restored.error });
322
+ return;
323
+ }
324
+ announce();
325
+ json(res, 200, { active: restored.active });
326
+ }
327
+ })().catch(fail);
328
+ return true;
329
+ }
330
+ /** #1629: static current-placement posters, separate from the local human-only runtime API. */
331
+ function handleEffectPosters(res, url, ctx, path, method) {
332
+ res.setHeader("Cache-Control", "private, no-store");
333
+ res.setHeader("X-Content-Type-Options", "nosniff");
334
+ const image = /^\/ui\/effect-posters\/([0-9a-f]{32})\.png$/.exec(path);
335
+ const names = [...url.searchParams.keys()], instance = url.searchParams.get("instance");
336
+ if (method !== "GET" || path !== "/ui/effect-posters" && !image || names.length !== 1 || names[0] !== "instance" || !instance || instance.length > 128) {
337
+ json(res, 404, { error: "not_found" });
338
+ return true;
339
+ }
340
+ const service = ctx.effectService;
341
+ if (!service) {
342
+ json(res, 404, { error: "effects_unavailable" });
343
+ return true;
344
+ }
345
+ if (image) {
346
+ const r = service.placedPoster(image[1], instance);
347
+ if (!r.ok)
348
+ json(res, 404, { error: "not_found" });
349
+ else {
350
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
351
+ res.end(r.bytes);
352
+ }
353
+ }
354
+ else {
355
+ const result = service.posterPlacements(instance);
356
+ json(res, result.error === "themes_unreadable" ? 503 : result.error ? 404 : 200, result);
357
+ }
358
+ return true;
359
+ }
360
+ /** #1629 Try: separate local-admin routes. These never borrow ordinary read-tier/placement authority. */
361
+ function handleEffectTrials(req, res, url, ctx, path, method) {
362
+ res.setHeader("Cache-Control", "private, no-store");
363
+ res.setHeader("X-Content-Type-Options", "nosniff");
364
+ const reading = method === "GET";
365
+ if (gatewayRequestContext(req)) {
366
+ json(res, reading ? 404 : 403, { error: "trials_not_over_public_link" });
367
+ return true;
368
+ }
369
+ const who = () => {
370
+ const sessions = ctx.webSessions;
371
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, sessions, { touch: false });
372
+ if (v.kind !== "allow")
373
+ return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
374
+ if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
375
+ return { error: "trials_need_a_local_admin", status: 403 };
376
+ const person = webPersonKey(v.session.owner);
377
+ let css = "unknown";
378
+ try {
379
+ const value = ctx.ownerCss?.effectState(person);
380
+ if (value === "off" || value === "on")
381
+ css = value;
382
+ }
383
+ catch { /* unavailable is never off */ }
384
+ // CSS remains a checked runtime value. The authority refuses anything except off on start/read.
385
+ const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
386
+ return { actor, record: v.session, sessions };
387
+ };
388
+ const first = who();
389
+ if ("error" in first) {
390
+ json(res, first.status, { error: first.error });
391
+ return true;
392
+ }
393
+ const service = ctx.effectService;
394
+ if (!service) {
395
+ json(res, 404, { error: "trials_unavailable" });
396
+ return true;
397
+ }
398
+ const start = path === "/ui/effect-trials/start";
399
+ const route = /^\/ui\/effect-trials\/([0-9a-f]{32})\/(resource|poster\.png|profile|stop)$/.exec(path);
400
+ const resource = reading && route && ["resource", "poster.png", "profile"].includes(route[2]);
401
+ const stop = method === "POST" && route?.[2] === "stop";
402
+ if (!(resource || stop || method === "POST" && start)) {
403
+ json(res, 404, { error: "not_found" });
404
+ return true;
405
+ }
406
+ const allowed = resource ? ["ownerEpoch", "instance"] : ["ownerEpoch"];
407
+ if ([...url.searchParams.keys()].some(k => !allowed.includes(k)) || allowed.some(k => url.searchParams.getAll(k).length !== 1)
408
+ || url.searchParams.get("ownerEpoch") !== first.actor.epoch) {
409
+ json(res, reading ? 404 : 409, { error: reading ? "not_found" : "trials_person_changed" });
410
+ return true;
411
+ }
412
+ const instance = url.searchParams.get("instance") ?? "";
413
+ const answer = (result) => json(res, result.error ? result.error === "not_found" ? 404
414
+ : ["profiles_unreadable", "profiles_busy", "profiles_write_failed"].includes(String(result.error)) ? 503 : 400 : 200, result);
415
+ if (resource) {
416
+ if (route[2] === "poster.png") {
417
+ const result = service.trialPoster(route[1], instance, first.actor);
418
+ if (!result.ok)
419
+ json(res, 404, { error: "not_found" });
420
+ else {
421
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
422
+ res.end(result.bytes);
423
+ }
424
+ }
425
+ else
426
+ answer(route[2] === "profile" ? service.trialProfile(route[1], instance, first.actor) : service.trialResource(route[1], instance, first.actor));
427
+ return true;
428
+ }
429
+ (async () => {
430
+ let bytes;
431
+ try {
432
+ bytes = await readBoundedWebBody(req, 16 * 1024);
433
+ }
434
+ catch {
435
+ json(res, 413, { error: "trial_body_bound" });
436
+ return;
437
+ }
438
+ if (!permitWebContinuation(req, res, ctx))
439
+ return;
440
+ const now = who();
441
+ if ("error" in now) {
442
+ json(res, now.status, { error: now.error });
443
+ return;
444
+ }
445
+ if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
446
+ || now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
447
+ json(res, 409, { error: "trials_person_changed" });
448
+ return;
449
+ }
450
+ let body;
451
+ try {
452
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
453
+ }
454
+ catch {
455
+ json(res, 400, { error: "trial_json" });
456
+ return;
457
+ }
458
+ if (start) {
459
+ const parsed = z.strictObject({ offer: z.string().regex(/^[0-9a-f]{32}$/), instance: z.string().min(1).max(128) }).safeParse(body);
460
+ if (!parsed.success) {
461
+ json(res, 400, { error: "trial_args" });
462
+ return;
463
+ }
464
+ answer(service.trialStart(parsed.data.offer, parsed.data.instance, now.actor));
465
+ }
466
+ else {
467
+ if (!z.strictObject({}).safeParse(body).success) {
468
+ json(res, 400, { error: "trial_args" });
469
+ return;
470
+ }
471
+ answer(service.trialStop(route[1], now.actor));
472
+ }
473
+ })().catch(() => { try {
474
+ json(res, 500, { error: "trial_request_failed" });
475
+ }
476
+ catch { /* response ended */ } });
477
+ return true;
478
+ }
479
+ /** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
480
+ function handleEffects(req, res, url, ctx, path, method) {
481
+ res.setHeader("Cache-Control", "private, no-store");
482
+ res.setHeader("X-Content-Type-Options", "nosniff");
483
+ const reading = method === "GET";
484
+ // Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
485
+ const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
486
+ if (gatewayRequestContext(req)) {
487
+ json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
488
+ return true;
489
+ }
490
+ const who = () => {
491
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
492
+ if (verdict.kind !== "allow")
493
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
494
+ if (verdict.via !== "session" || !verdict.session)
495
+ return { status: 403, error: "effects_need_a_signed_in_person" };
496
+ if (!reading && !profilePost && verdict.session.tier !== "admin")
497
+ return { status: 403, error: "effects_read_only" };
498
+ return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle };
499
+ };
500
+ const first = who();
501
+ if ("status" in first) {
502
+ json(res, first.status, { error: first.error });
503
+ return true;
504
+ }
505
+ const service = ctx.effectService;
506
+ if (!service) {
507
+ json(res, 404, { error: "effects_unavailable" });
508
+ return true;
509
+ }
510
+ const unavailable = new Set(["themes_unreadable", "profiles_unreadable", "library_unreadable", "references_unreadable", "library_busy", "profiles_busy", "profiles_write_failed", "library_write_failed", "prefs_unreadable"]);
511
+ 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);
512
+ const idPath = /^\/ui\/effects\/(resource|profile)\/([0-9a-f]{32})$/.exec(path);
513
+ const instance = url.searchParams.get("instance") ?? undefined, slot = url.searchParams.get("slot");
514
+ if (slot !== null && instance !== undefined) {
515
+ json(res, 404, { error: "not_found" });
516
+ return true;
517
+ }
518
+ const runtimeRead = reading && (slot !== null || path === "/ui/effects/owner" || path === "/ui/effects/placements" || idPath?.[1] === "resource" || instance !== undefined && /^\/ui\/effects\/image\//.test(path));
519
+ if ((runtimeRead || profilePost) && (url.searchParams.getAll("ownerEpoch").length !== 1 || url.searchParams.get("ownerEpoch") !== first.epoch)) {
520
+ const privateResource = reading && (slot !== null || idPath?.[1] === "resource" || /^\/ui\/effects\/image\//.test(path));
521
+ json(res, privateResource ? 404 : 409, { error: privateResource ? "not_found" : "effects_person_changed" });
522
+ return true;
523
+ }
524
+ if (reading) {
525
+ const image = /^\/ui\/effects\/image\/([0-9a-f]{32})\/(poster|cursor)\.png$/.exec(path);
526
+ if (image) {
527
+ const r = slot === null ? service.image(first.person, image[1], image[2], instance) : service.ownerImage(first.person, image[1], image[2], slot);
528
+ if (!r.ok)
529
+ json(res, 404, { error: "not_found" });
530
+ else {
531
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
532
+ res.end(r.bytes);
533
+ }
534
+ return true;
535
+ }
536
+ if (path === "/ui/effects")
537
+ answer({ ...service.list(first.person), canWrite: first.canWrite });
538
+ else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
539
+ answer(service.ownerSelections(first.person));
540
+ else if (path === "/ui/effects/placements" && instance !== undefined)
541
+ answer(service.placements(first.person, instance));
542
+ else if (idPath?.[1] === "resource")
543
+ answer(slot === null ? service.resource(first.person, idPath[2], instance) : service.ownerResource(first.person, idPath[2], slot));
544
+ else if (idPath?.[1] === "profile" && instance === undefined)
545
+ answer(service.profile(first.person, idPath[2]));
546
+ else
547
+ json(res, 404, { error: "not_found" });
548
+ return true;
549
+ }
550
+ const deletion = /^\/ui\/effects\/([0-9a-f]{32})$/.exec(path);
551
+ if (method === "DELETE" && deletion) {
552
+ answer(service.delete(first.person, deletion[1]));
553
+ return true;
554
+ }
555
+ if (method !== "POST" || !["/ui/effects/upload", "/ui/effects/apply", "/ui/effects/remove", "/ui/effects/share"].includes(path) && idPath?.[1] !== "profile") {
556
+ json(res, 404, { error: "not_found" });
557
+ return true;
558
+ }
559
+ (async () => {
560
+ let bytes;
561
+ try {
562
+ bytes = await readBoundedWebBody(req, path === "/ui/effects/upload" ? EFFECT_WIRE_LIMIT : 16 * 1024);
563
+ }
564
+ catch {
565
+ json(res, 413, { error: "effect_body_bound" });
566
+ return;
567
+ }
568
+ if (!permitWebContinuation(req, res, ctx))
569
+ return;
570
+ const now = who();
571
+ if ("status" in now) {
572
+ json(res, now.status, { error: now.error });
573
+ return;
574
+ }
575
+ if (now.person !== first.person || now.epoch !== first.epoch) {
576
+ json(res, 409, { error: "effects_person_changed" });
577
+ return;
578
+ }
579
+ if (path === "/ui/effects/upload") {
580
+ answer(service.upload(now.person, bytes));
581
+ return;
582
+ }
583
+ let body;
584
+ try {
585
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
586
+ }
587
+ catch {
588
+ json(res, 400, { error: "effect_json" });
589
+ return;
590
+ }
591
+ if (path === "/ui/effects/apply")
592
+ answer(service.apply(now.person, body));
593
+ else if (path === "/ui/effects/remove")
594
+ answer(service.remove(now.person, body));
595
+ else if (path === "/ui/effects/share")
596
+ answer(service.share(now.person, body));
597
+ else
598
+ answer(service.report(now.person, idPath[2], body, instance));
599
+ })().catch(() => { try {
600
+ json(res, 500, { error: "effect_request_failed" });
601
+ }
602
+ catch { /* response ended */ } });
603
+ return true;
604
+ }
605
+ /**
606
+ * #1629 r4 §4 (r4-5): mods — a person's import (stage, then commit), uninstall and export. Every route is a signed-in
607
+ * session's, writes an admin's: never the CLI's header token (the way an agent could reach it) and never the public link
608
+ * (absent from its manifest, and refused here too). `who()` is asked first and again once a body is in.
609
+ */
610
+ function handleMods(req, res, url, ctx, path, method) {
611
+ const mods = ctx.mods;
612
+ if (!mods) {
613
+ json(res, 404, { error: "Mods are not kept on this fleet" });
614
+ return true;
615
+ }
616
+ const reading = method === "GET";
617
+ if (gatewayRequestContext(req)) {
618
+ json(res, reading ? 404 : 403, { error: "mods_not_over_public_link" });
619
+ return true;
620
+ }
621
+ const who = () => {
622
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
623
+ if (verdict.kind !== "allow")
624
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
625
+ if (verdict.via !== "session" || !verdict.session)
626
+ return { status: 403, error: "mods_need_a_signed_in_session" };
627
+ if (!reading && verdict.session.tier !== "admin")
628
+ return { status: 403, error: "mods_need_an_admin_session" };
629
+ return { person: webPersonKey(verdict.session.owner) };
630
+ };
631
+ const first = who();
632
+ if ("status" in first) {
633
+ json(res, first.status, { error: first.error });
634
+ return true;
635
+ }
636
+ const again = () => {
637
+ if (!permitWebContinuation(req, res, ctx))
638
+ return null;
639
+ const now = who();
640
+ if ("status" in now) {
641
+ json(res, now.status, { error: now.error });
642
+ return null;
643
+ }
644
+ if (now.person !== first.person) {
645
+ json(res, 409, { error: "mods_person_changed" });
646
+ return null;
647
+ }
648
+ return now.person;
649
+ };
650
+ const answer = (r, body) => {
651
+ if (r.ok)
652
+ json(res, 200, body);
653
+ else
654
+ json(res, r.status ?? 500, { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.problems ? { problems: r.problems } : {}) });
655
+ };
656
+ const fail = (err) => {
657
+ ctx.logger.error({ err: err.message }, "Mod request failed");
658
+ try {
659
+ json(res, 500, { error: "Mod request failed" });
660
+ }
661
+ catch { /* already answered */ }
662
+ };
663
+ if (path === "/ui/mods" && method === "GET") {
664
+ json(res, 200, { ...mods.list(), staged: mods.staged(first.person), proposals: mods.proposals() });
665
+ return true;
666
+ }
667
+ // A staged mod's picture (this session's person's stage only): the plan shows the actual pictures before install.
668
+ const stagedPic = /^\/ui\/mods\/staged\/([0-9a-f]{32})$/.exec(path);
669
+ if (stagedPic && method === "GET") {
670
+ const pic = mods.stagedPicture(first.person, stagedPic[1]);
671
+ if (!pic) {
672
+ json(res, 404, { error: "No such image" });
673
+ return true;
674
+ }
675
+ res.writeHead(200, { "Content-Type": pic.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store", "Content-Security-Policy": "default-src 'none'; sandbox" });
676
+ res.end(Buffer.from(pic.bytes));
677
+ return true;
678
+ }
679
+ const fileMatch = /^\/ui\/mods\/file\/([0-9a-f]{32})$/.exec(path);
680
+ if (fileMatch && method === "GET") {
681
+ const text = mods.bundleText(fileMatch[1]);
682
+ const rec = mods.list().mods.find(m => m.id === fileMatch[1]);
683
+ if (text === null || !rec) {
684
+ json(res, 404, { error: "no_such_mod" });
685
+ return true;
686
+ }
687
+ // The file is the installed bundle, effects and all — also effects its import left out, which nobody installed.
688
+ // So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
689
+ // read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
690
+ const fx = mods.bundleEffects(fileMatch[1]);
691
+ if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
692
+ 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" });
693
+ return true;
694
+ }
695
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
696
+ "Content-Disposition": `attachment; filename="${rec.name}-${rec.version}.agendmod.json"` });
697
+ res.end(text);
698
+ return true;
699
+ }
700
+ 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)) {
701
+ json(res, 404, { error: "not found" });
702
+ return true;
703
+ }
704
+ (async () => {
705
+ if (path === "/ui/mods/stage") {
706
+ // Too large by its own declared length: answered before a byte is read (the body is drained, not cut off).
707
+ if (Number(req.headers["content-length"]) > MOD_MAX_BYTES) {
708
+ req.resume();
709
+ json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
710
+ return;
711
+ }
712
+ let raw;
713
+ try {
714
+ raw = await readBoundedWebBody(req, MOD_MAX_BYTES);
715
+ }
716
+ catch {
717
+ json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
718
+ return;
719
+ }
720
+ const person = again();
721
+ if (!person)
722
+ return;
723
+ let text;
724
+ try {
725
+ text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
726
+ }
727
+ catch {
728
+ json(res, 400, { error: "mod_invalid", problems: [{ path: "", problem: "not UTF-8" }] });
729
+ return;
730
+ }
731
+ const r = mods.stage(person, text);
732
+ answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
733
+ return;
734
+ }
735
+ let body;
736
+ try {
737
+ body = await parseBody(req);
738
+ }
739
+ catch {
740
+ json(res, 400, { error: "Invalid JSON" });
741
+ return;
742
+ }
743
+ const person = again();
744
+ if (!person)
745
+ return;
746
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
747
+ json(res, 400, { error: "an object" });
748
+ return;
749
+ }
750
+ if (path === "/ui/mods/abort") {
751
+ const r = mods.abort(person, body.stage);
752
+ answer(r, { ok: true });
753
+ return;
754
+ }
755
+ // r4-5b: an agent's proposal, opened into the usual review (staged as this person's), or dismissed.
756
+ if (path === "/ui/mods/proposal/stage") {
757
+ const r = mods.stageProposal(person, body.instance);
758
+ answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
759
+ return;
760
+ }
761
+ if (path === "/ui/mods/proposal/dismiss") {
762
+ const r = mods.dismiss(body.instance);
763
+ answer(r, { ok: true });
764
+ return;
765
+ }
766
+ if (path === "/ui/mods/commit") {
767
+ const r = mods.commit(person, body);
768
+ if (r.ok)
769
+ ctx.emitSseEvent("status", ctx.getUiStatus()); // themes_rev and prefs_rev moved: every page re-reads
770
+ answer(r, r.ok ? { id: r.id, applied: r.applied, ...(r.css ? { css: r.css } : {}) } : null);
771
+ return;
772
+ }
773
+ if (path === "/ui/mods/uninstall") {
774
+ const r = mods.uninstall(body.id);
775
+ answer(r, r.ok ? { ok: true, released: r.released } : null);
776
+ return;
777
+ }
778
+ // Export: what is in force now, as a bundle file.
779
+ const r = exportMod(ctx, person, body);
780
+ if (!r.ok) {
781
+ json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
782
+ return;
783
+ }
784
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
785
+ "Content-Disposition": `attachment; filename="${r.filename}"`, "X-AgEnD-Effects-Omitted": String(r.omitted) });
786
+ res.end(r.text);
787
+ })().catch(fail);
788
+ return true;
789
+ }
790
+ /** A person's export: an instance's look (theme, identity, its widgets), their sidebar widget and their own CSS. */
791
+ export function exportMod(ctx, person, body) {
792
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
793
+ const inc = isObj(body.include) ? body.include : null;
794
+ if (!inc)
795
+ return { ok: false, status: 400, error: "include" };
796
+ const meta = { name: body.name, version: body.version, title: body.title };
797
+ if (typeof meta.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(meta.name))
798
+ return { ok: false, status: 400, error: "name", detail: "a-z, 0-9 and - (1..64)" };
799
+ 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))
800
+ return { ok: false, status: 400, error: "version", detail: "MAJOR.MINOR.PATCH" };
801
+ // The importer's own rule (#1897 review): what is exported always imports.
802
+ const title = canonicalText(meta.title, 1, 60);
803
+ if (title === null)
804
+ return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
805
+ const parts = {};
806
+ let omitted = 0;
807
+ const wantsInstance = inc.theme === true || inc.identity === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
808
+ let name = null;
809
+ if (wantsInstance) {
810
+ if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
811
+ return { ok: false, status: 400, error: "instance" };
812
+ name = body.instance;
813
+ }
814
+ if (name && ctx.themeStore) {
815
+ const store = ctx.themeStore;
816
+ const rec = store.get(name);
817
+ if (inc.theme === true) {
818
+ // The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
819
+ // Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
820
+ const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e) {
821
+ omitted++;
822
+ return [];
823
+ } return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
824
+ 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 };
825
+ }
826
+ if (inc.identity === true)
827
+ parts.identity = ctx.modCardText?.(name) ?? {};
828
+ for (const slot of AGENT_WIDGET_SLOTS) {
829
+ const w = rec.placements.widgets[slot];
830
+ if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
831
+ (parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
832
+ }
833
+ }
834
+ if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
835
+ const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
836
+ const spec = v ? checkWidgetSpec(v.spec, "sidebar") : null;
837
+ const data = spec?.ok ? checkWidgetData(v.data, spec.value) : null;
838
+ if (spec?.ok && data?.ok)
839
+ (parts.widgets ??= {}).sidebar = { spec: spec.value, data: { ...data.value } };
840
+ }
841
+ if (inc.ownerCss === true && ctx.ownerCss) {
842
+ const state = ctx.ownerCss.state(person);
843
+ const checked = state.text ? checkOwnerCss(state.text, new Set(state.assets.map(a => a.id))) : null;
844
+ if (state.text && checked?.ok) {
845
+ // Only the assets this text names travel (read from its tokens: url("\\61sset:<id>") names one too).
846
+ const named = ownerCssAssetRefs(checked.tokens);
847
+ 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 }] : []; });
848
+ parts.ownerCss = { text: state.text, assets };
849
+ }
850
+ }
851
+ const built = buildModBundle({ name: meta.name, version: meta.version, title }, parts);
852
+ return built.ok ? { ...built, omitted } : { ok: false, status: built.error === "nothing_to_export" ? 400 : 409, error: built.error };
853
+ }
854
+ /**
855
+ * #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
856
+ * no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
857
+ * session, the CLI's header token) none: the operator. For recognition only; nothing here authorizes anything.
858
+ */
859
+ function webAccountOf(req, url, ctx) {
860
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
861
+ if (verdict.kind !== "allow")
862
+ return null;
863
+ return verdict.via === "session" && verdict.session?.owner ? { owner: verdict.session.owner } : {};
864
+ }
865
+ function webPersonOf(req, url, ctx) {
866
+ // No people kept here: everyone is "web-user", as before, and there is no identity to protect.
867
+ if (!ctx.webPerson)
868
+ return { key: OPERATOR, bound: false, userId: WEB_NAME_FALLBACK, voterKey: "web:operator", name: WEB_NAME_FALLBACK, set: false };
869
+ const who = webAccountOf(req, url, ctx);
870
+ return who ? ctx.webPerson(who.owner) : null;
871
+ }
872
+ /** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
873
+ function webPersonOr401(req, res, url, ctx) {
874
+ const p = webPersonOf(req, url, ctx);
875
+ if (!p)
876
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
877
+ return p;
878
+ }
879
+ /** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
880
+ function profileBody(p) {
881
+ return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
882
+ }
883
+ /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
884
+ function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
152
885
  /**
153
886
  * Handle a Web UI request. Returns true if handled, false to pass through.
154
887
  */
@@ -261,6 +994,8 @@ export function handleWebRequest(req, res, url, ctx) {
261
994
  // #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
262
995
  // records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
263
996
  cursor: history ? history.pollCursor() : null,
997
+ // #1823 (#1830 review): the server's clock now, for the page's poll countdown (a poll's deadline is the server's).
998
+ now: Date.now(),
264
999
  });
265
1000
  return true;
266
1001
  }
@@ -274,7 +1009,9 @@ export function handleWebRequest(req, res, url, ctx) {
274
1009
  "Cache-Control": "no-cache",
275
1010
  Connection: "keep-alive",
276
1011
  });
277
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`);
1012
+ // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
1013
+ // poll down by (one write: the frames a connect sends are unchanged in number).
1014
+ res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
278
1015
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
279
1016
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
280
1017
  if (lastSeen !== null && ctx.webChatHistory) {
@@ -310,7 +1047,7 @@ export function handleWebRequest(req, res, url, ctx) {
310
1047
  }
311
1048
  try {
312
1049
  // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
313
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
1050
+ res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
314
1051
  }
315
1052
  catch {
316
1053
  cleanup();
@@ -406,12 +1143,14 @@ export function handleWebRequest(req, res, url, ctx) {
406
1143
  serveMediaRange(req, res, ctx.webFiles, id, media);
407
1144
  return true;
408
1145
  }
409
- const got = isFileId(id) ? ctx.webFiles?.read(id) : null;
410
- if (!got) {
1146
+ // #1937: a file over MEDIA_RANGE_MAX streams from the descriptor its checks were made on — never whole in memory.
1147
+ const big = isFileId(id) && (ctx.webFiles?.servedFile?.(id)?.size ?? 0) > MEDIA_RANGE_MAX ? ctx.webFiles?.open?.(id) ?? null : null;
1148
+ const got = big ? null : isFileId(id) ? ctx.webFiles?.read(id) : null;
1149
+ const file = big?.file ?? got?.file;
1150
+ if (!file) {
411
1151
  json(res, 404, { error: "No such file" });
412
1152
  return true;
413
1153
  }
414
- const { file, bytes } = got;
415
1154
  const inline = INLINE_MIME.has(file.mime);
416
1155
  // Only the four image types are shown in the page; anything else is a download, never rendered.
417
1156
  res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
@@ -419,8 +1158,116 @@ export function handleWebRequest(req, res, url, ctx) {
419
1158
  res.setHeader("X-Content-Type-Options", "nosniff");
420
1159
  res.setHeader("Cache-Control", "private, no-store");
421
1160
  res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
1161
+ if (big) {
1162
+ streamWhole(res, big);
1163
+ return true;
1164
+ }
422
1165
  res.writeHead(200);
423
- res.end(bytes);
1166
+ res.end(got.bytes);
1167
+ return true;
1168
+ }
1169
+ // ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
1170
+ // GET /ui/themes: { rev, themes, locked, widgets } when the status frame's themes_rev moves (passive, web-auth.ts).
1171
+ // GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
1172
+ // POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out): a person's, from an admin session — never the CLI token, never the public
1173
+ // link (absent from its manifest, and refused here too).
1174
+ if (method === "GET" && path === "/ui/themes") {
1175
+ const store = ctx.themeStore;
1176
+ if (!store) {
1177
+ json(res, 404, { error: "Themes are not kept on this fleet" });
1178
+ return true;
1179
+ }
1180
+ json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets() });
1181
+ return true;
1182
+ }
1183
+ const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
1184
+ if (method === "GET" && themeImage) {
1185
+ let name;
1186
+ try {
1187
+ name = decodeURIComponent(themeImage[1]);
1188
+ }
1189
+ catch {
1190
+ json(res, 404, { error: "No such image" });
1191
+ return true;
1192
+ }
1193
+ const image = ctx.themeStore?.asset(name, themeImage[2]) ?? null;
1194
+ if (!image) {
1195
+ json(res, 404, { error: "No such image" });
1196
+ return true;
1197
+ }
1198
+ res.setHeader("Content-Type", image.mime);
1199
+ res.setHeader("X-Content-Type-Options", "nosniff");
1200
+ res.setHeader("Cache-Control", "private, max-age=86400");
1201
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
1202
+ res.writeHead(200);
1203
+ res.end(image.bytes);
1204
+ return true;
1205
+ }
1206
+ const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget)$/);
1207
+ if (method === "POST" && themeWrite) {
1208
+ const service = ctx.webAppearance;
1209
+ if (!service) {
1210
+ json(res, 404, { error: "Themes are not kept on this fleet" });
1211
+ return true;
1212
+ }
1213
+ if (gatewayRequestContext(req)) {
1214
+ json(res, 403, { error: "theme_not_over_public_link" });
1215
+ return true;
1216
+ }
1217
+ let name;
1218
+ try {
1219
+ name = decodeURIComponent(themeWrite[1]);
1220
+ }
1221
+ catch {
1222
+ json(res, 400, { error: "instance" });
1223
+ return true;
1224
+ }
1225
+ const who = () => {
1226
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1227
+ if (verdict.kind !== "allow")
1228
+ return 401;
1229
+ return verdict.via === "session" && verdict.session?.tier === "admin" ? 200 : 403;
1230
+ };
1231
+ const first = who();
1232
+ if (first !== 200) {
1233
+ json(res, first, { error: first === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
1234
+ return true;
1235
+ }
1236
+ (async () => {
1237
+ let body = {};
1238
+ try {
1239
+ body = await parseBody(req);
1240
+ }
1241
+ catch {
1242
+ json(res, 400, { error: "Invalid JSON" });
1243
+ return;
1244
+ }
1245
+ if (!permitWebContinuation(req, res, ctx))
1246
+ return;
1247
+ const now = who();
1248
+ if (now !== 200) {
1249
+ json(res, now, { error: now === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
1250
+ return;
1251
+ }
1252
+ if (!ctx.isKnownInstance?.(name)) {
1253
+ json(res, 404, { error: "unknown instance" });
1254
+ return;
1255
+ }
1256
+ const r = themeWrite[2] === "reset" ? service.humanReset(name)
1257
+ : themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
1258
+ : typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
1259
+ if (typeof r.error === "string") {
1260
+ json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
1261
+ return;
1262
+ }
1263
+ json(res, 200, r);
1264
+ })().catch(err => {
1265
+ ctx.logger.error({ err: err.message }, "Theme reset/lock failed");
1266
+ try {
1267
+ json(res, 500, { error: "Theme change failed" });
1268
+ }
1269
+ catch { /* already answered */ }
1270
+ });
424
1271
  return true;
425
1272
  }
426
1273
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
@@ -527,6 +1374,63 @@ export function handleWebRequest(req, res, url, ctx) {
527
1374
  });
528
1375
  return true;
529
1376
  }
1377
+ // #1827: the person's own name — the one agents and chats see for their web messages. Their own profile, so the public
1378
+ // link may read and set it too (profile edits are allowed there); a read-only session may as well: it is about them.
1379
+ if (path === "/ui/profile" && method === "GET") {
1380
+ if (!ctx.webPerson) {
1381
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
1382
+ return true;
1383
+ }
1384
+ const p = webPersonOr401(req, res, url, ctx);
1385
+ if (p)
1386
+ json(res, 200, profileBody(p));
1387
+ return true;
1388
+ }
1389
+ if (path === "/ui/profile" && method === "POST") {
1390
+ if (!ctx.setWebPersonName) {
1391
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
1392
+ return true;
1393
+ }
1394
+ const set = ctx.setWebPersonName.bind(ctx);
1395
+ (async () => {
1396
+ let body;
1397
+ try {
1398
+ body = await parseBody(req);
1399
+ }
1400
+ catch {
1401
+ json(res, 400, { error: "Invalid JSON" });
1402
+ return;
1403
+ }
1404
+ if (!permitWebContinuation(req, res, ctx))
1405
+ return;
1406
+ if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "name")) {
1407
+ json(res, 400, { error: "name required (text, or null to clear)" });
1408
+ return;
1409
+ }
1410
+ // Whose: asked once the body is in. A session revoked, expired or rotated away meanwhile writes nothing — it is
1411
+ // not the operator (#1833 review).
1412
+ const who = webAccountOf(req, url, ctx);
1413
+ if (!who) {
1414
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1415
+ return;
1416
+ }
1417
+ const r = set(who.owner, body.name);
1418
+ if (!r.ok) {
1419
+ json(res, r.status, { error: r.error });
1420
+ return;
1421
+ }
1422
+ // The person's other devices read it at their next status (the prefs rev moved).
1423
+ ctx.emitSseEvent("status", ctx.getUiStatus());
1424
+ json(res, 200, profileBody(r.person));
1425
+ })().catch(err => {
1426
+ ctx.logger.error({ err: err.message }, "Saving the web name failed");
1427
+ try {
1428
+ json(res, 500, { error: "Saving the name failed" });
1429
+ }
1430
+ catch { /* already answered */ }
1431
+ });
1432
+ return true;
1433
+ }
530
1434
  // #1266: a click on an agent reply's button. The set id travels in the body; the public link may click too (a click
531
1435
  // is a message, and the public link may send messages).
532
1436
  if (method === "POST" && path === "/ui/reply-button") {
@@ -551,8 +1455,14 @@ export function handleWebRequest(req, res, url, ctx) {
551
1455
  json(res, 400, { error: "instance, id and index required" });
552
1456
  return;
553
1457
  }
554
- const r = await click(instance, id, index);
555
- json(res, r.status, r.status === 200 ? { answered: true } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
1458
+ // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
1459
+ // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
1460
+ // gate (a session revoked meanwhile) clicks nothing as anyone.
1461
+ const person = webPersonOr401(req, res, url, ctx);
1462
+ if (!person)
1463
+ return;
1464
+ const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
1465
+ json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
556
1466
  })().catch(err => {
557
1467
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
558
1468
  try {
@@ -574,15 +1484,19 @@ export function handleWebRequest(req, res, url, ctx) {
574
1484
  json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
575
1485
  return true;
576
1486
  }
577
- // #1665: the web preferences that follow the person between devices. Read by any page of the panel, the public link
578
- // included (it reads what is in force); written only by a signed-in admin session or the CLI's header token — never
579
- // over the public link (absent from its route manifest, and refused here too), never by a read-only session.
1487
+ // Host preferences preserve their scope; only five declared owner slots use the current human session bucket.
580
1488
  if (path === "/ui/prefs" && method === "GET") {
581
1489
  if (!ctx.webPrefs) {
582
1490
  json(res, 404, { error: "Preferences are not kept on this fleet" });
583
1491
  return true;
584
1492
  }
585
- json(res, 200, ctx.webPrefs.read(OPERATOR));
1493
+ const viewer = prefsViewer(req, url, ctx);
1494
+ if (!viewer) {
1495
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1496
+ return true;
1497
+ }
1498
+ res.setHeader("Cache-Control", "private, no-store");
1499
+ json(res, 200, prefsView(ctx.webPrefs, viewer));
586
1500
  return true;
587
1501
  }
588
1502
  if (path === "/ui/prefs" && method === "POST") {
@@ -595,17 +1509,9 @@ export function handleWebRequest(req, res, url, ctx) {
595
1509
  json(res, 403, { error: "prefs_not_over_public_link" });
596
1510
  return true;
597
1511
  }
598
- // Who may write, asked now and again once the body is in: a session revoked or expired, or a token rotated, while
599
- // the body was on its way writes nothing and announces nothing.
600
- const writer = () => {
601
- const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
602
- if (verdict.kind !== "allow")
603
- return 401;
604
- return verdict.via === "session" && verdict.session?.tier !== "admin" ? 403 : 200;
605
- };
606
- const refuse = (code) => json(res, code, code === 401 ? { error: WEB_TOKEN_INVALID_MESSAGE } : { error: "prefs_read_only" });
607
- const first = writer();
608
- if (first !== 200) {
1512
+ const refuse = (viewer) => json(res, viewer ? 403 : 401, { error: viewer ? "prefs_read_only" : WEB_TOKEN_INVALID_MESSAGE });
1513
+ const first = prefsViewer(req, url, ctx);
1514
+ if (!first?.canWrite) {
609
1515
  refuse(first);
610
1516
  return true;
611
1517
  }
@@ -629,21 +1535,39 @@ export function handleWebRequest(req, res, url, ctx) {
629
1535
  json(res, 400, { error: parsed.error });
630
1536
  return;
631
1537
  }
632
- const now = writer();
633
- if (now !== 200) {
1538
+ const now = prefsViewer(req, url, ctx);
1539
+ if (!now?.canWrite) {
634
1540
  refuse(now);
635
1541
  return;
636
1542
  }
637
- const onlyIfUnset = body.onlyIfUnset === true;
638
- const written = prefs.write(OPERATOR, parsed.patch, { onlyIfUnset });
1543
+ if (now.person !== first.person || now.epoch !== first.epoch || now.via !== first.via) {
1544
+ json(res, 409, { error: "prefs_person_changed" });
1545
+ return;
1546
+ }
1547
+ const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
1548
+ if (personal && now.person === null) {
1549
+ json(res, 403, { error: "prefs_need_a_signed_in_person" });
1550
+ return;
1551
+ }
1552
+ // A scope marker rejects a queued old page after a different login, including the same person's new session.
1553
+ // It supplies neither identity nor authority; the current verified session still chooses the bucket.
1554
+ if (personal && body.ownerEpoch !== now.epoch) {
1555
+ json(res, 409, { error: "prefs_person_changed" });
1556
+ return;
1557
+ }
1558
+ const before = prefs.readForViewer(now.person);
1559
+ const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true,
1560
+ validateEffect: (person, key, id) => ctx.effectService ? ctx.effectService.validateOwnerSelection(person, key, id) : "effects_unavailable" });
639
1561
  if (!written.ok) {
640
1562
  json(res, written.status, { error: written.error });
641
1563
  return;
642
1564
  }
643
- // Another device sees the new rev in the next status frame — at once on the live stream.
1565
+ const dropped = written.changed.filter(isOwnerEffectKey).flatMap(key => typeof before.values[key] === "string" && before.values[key] !== written.view.values[key] ? [before.values[key]] : []);
1566
+ if (dropped.length)
1567
+ ctx.effectService?.collectOwnerReplaced(dropped);
644
1568
  if (written.changed.length)
645
1569
  ctx.emitSseEvent("status", ctx.getUiStatus());
646
- json(res, 200, { ...written.view, changed: written.changed });
1570
+ json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
647
1571
  })().catch(err => {
648
1572
  ctx.logger.error({ err: err.message }, "Saving web preferences failed");
649
1573
  try {
@@ -653,6 +1577,24 @@ export function handleWebRequest(req, res, url, ctx) {
653
1577
  });
654
1578
  return true;
655
1579
  }
1580
+ // #1629 r4 §3: owner CSS — one person's own stylesheet, for their own screens. Every route is a signed-in human's
1581
+ // session: never the CLI's header token (no person, and the way an agent could reach it), never the public link
1582
+ // (absent from its manifest, and refused here too), reads included. The stylesheet and its assets are that session's
1583
+ // person's only: no name or identity in any URL.
1584
+ if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
1585
+ return handleOwnerCss(req, res, url, ctx, path, method);
1586
+ }
1587
+ // #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
1588
+ if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
1589
+ return handleMods(req, res, url, ctx, path, method);
1590
+ // Posters remain behind the common session gate, with a closed GET-only public manifest.
1591
+ if (path === "/ui/effect-posters" || path.startsWith("/ui/effect-posters/"))
1592
+ return handleEffectPosters(res, url, ctx, path, method);
1593
+ // Canvas effects are absent from the public gateway manifest and independently refused above.
1594
+ if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
1595
+ return handleEffectTrials(req, res, url, ctx, path, method);
1596
+ if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
1597
+ return handleEffects(req, res, url, ctx, path, method);
656
1598
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
657
1599
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
658
1600
  if (method === "POST" && path === "/ui/needs/ack") {
@@ -728,7 +1670,10 @@ export function handleWebRequest(req, res, url, ctx) {
728
1670
  json(res, 400, { error: "invalid confirmation" });
729
1671
  return;
730
1672
  }
731
- const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req) });
1673
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
1674
+ if (!person)
1675
+ return;
1676
+ const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
732
1677
  json(res, r.status, r.body);
733
1678
  })().catch(err => {
734
1679
  ctx.logger.error({ err: err.message }, "Web command failed");
@@ -762,7 +1707,7 @@ export function handleWebRequest(req, res, url, ctx) {
762
1707
  json(res, 409, { error: `${name} is not running` });
763
1708
  return true;
764
1709
  }
765
- ctx.eventLog?.logActivity("cancel", "web-user", "stop the current reply", name);
1710
+ ctx.eventLog?.logActivity("cancel", webPersonOf(req, url, ctx)?.name ?? WEB_NAME_FALLBACK, "stop the current reply", name);
766
1711
  json(res, 200, { cancelled: name });
767
1712
  return true;
768
1713
  }
@@ -993,12 +1938,15 @@ export function handleWebRequest(req, res, url, ctx) {
993
1938
  const parsed = parseOrReject(TaskCreateSchema, body, res);
994
1939
  if (!parsed)
995
1940
  return;
1941
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
1942
+ if (!person)
1943
+ return;
996
1944
  const task = ctx.scheduler.db.createTask({
997
1945
  title: parsed.title,
998
1946
  description: parsed.description,
999
1947
  priority: parsed.priority,
1000
1948
  assignee: parsed.assignee,
1001
- created_by: "web-user",
1949
+ created_by: person.name, // #1827
1002
1950
  });
1003
1951
  json(res, 200, task);
1004
1952
  }
@@ -1025,7 +1973,10 @@ export function handleWebRequest(req, res, url, ctx) {
1025
1973
  return;
1026
1974
  let result;
1027
1975
  if (parsed.action === "claim") {
1028
- result = ctx.scheduler.db.claimTask(id, parsed.assignee || "web-user");
1976
+ const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
1977
+ if (!parsed.assignee && !person)
1978
+ return;
1979
+ result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
1029
1980
  }
1030
1981
  else if (parsed.action === "complete") {
1031
1982
  result = ctx.scheduler.db.completeTask(id, parsed.result);
@@ -1414,6 +2365,14 @@ function handleSendMessage(req, res, ctx) {
1414
2365
  return;
1415
2366
  }
1416
2367
  const message = typed;
2368
+ // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
2369
+ // body is in: a session revoked meanwhile sends nothing (not as the operator).
2370
+ const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
2371
+ if (!person) {
2372
+ ctx.webFiles?.release(files);
2373
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2374
+ return;
2375
+ }
1417
2376
  const ts = new Date().toISOString();
1418
2377
  // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
1419
2378
  const messageId = newWebMessageId();
@@ -1432,7 +2391,7 @@ function handleSendMessage(req, res, ctx) {
1432
2391
  // opt-in resolved at send time inside sendClassicWebEcho.
1433
2392
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
1434
2393
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1435
- await ctx.sendClassicWebEcho(instance, formatWebChannelEcho("web-user", preview, t("web.echo_full_text")));
2394
+ await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
1436
2395
  }) : undefined;
1437
2396
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1438
2397
  // A replacement adapter or edited binding is not the route we reserved.
@@ -1440,7 +2399,7 @@ function handleSendMessage(req, res, ctx) {
1440
2399
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
1441
2400
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
1442
2401
  return;
1443
- return syncAdapter.sendText(String(groupId), formatWebChannelEcho("web-user", preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
2402
+ return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
1444
2403
  }) : undefined;
1445
2404
  let delivered;
1446
2405
  try {
@@ -1454,7 +2413,7 @@ function handleSendMessage(req, res, ctx) {
1454
2413
  meta: {
1455
2414
  chat_id: groupId ? String(groupId) : "",
1456
2415
  message_id: messageId,
1457
- user: "web-user", user_id: "web-user",
2416
+ user: person.name, user_id: person.userId,
1458
2417
  ts,
1459
2418
  thread_id: topicId != null ? String(topicId) : "",
1460
2419
  source: "web",
@@ -1492,9 +2451,9 @@ function handleSendMessage(req, res, ctx) {
1492
2451
  ctx.reserveWebChannelEcho?.(instance, () => startProgress(instance), { copy: false })(true);
1493
2452
  }
1494
2453
  ctx.webFiles?.commit(files);
1495
- ctx.lastInboundUser.set(instance, "web-user");
1496
- ctx.eventLog?.logActivity("message", "web-user", (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1497
- ctx.emitSseEvent("message", { instance, sender: "web-user", role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
2454
+ ctx.lastInboundUser.set(instance, person.name);
2455
+ ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
2456
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1498
2457
  json(res, 200, { sent: true, messageId });
1499
2458
  }
1500
2459
  catch {
@@ -1506,6 +2465,11 @@ function handleSendMessage(req, res, ctx) {
1506
2465
  * One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
1507
2466
  * read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
1508
2467
  * workspace inbox under a name chosen here; answered with the id the message will name.
2468
+ *
2469
+ * #1937: streamed to a part file in that inbox (web-pending-….part) as it arrives — never held whole in memory — and
2470
+ * counted by the bytes that came, whatever Content-Length said. The limits are the ones in force for this request
2471
+ * (uploadLimits(): the config, and the public link's own cap). An upload that goes UPLOAD_STALL_MS without a byte, or
2472
+ * runs past UPLOAD_MAX_MS, is dropped; any upload that does not end in a stored file takes its part file with it.
1509
2473
  */
1510
2474
  function handleUpload(req, res, url, ctx) {
1511
2475
  const instance = url.searchParams.get("instance") ?? "";
@@ -1517,57 +2481,112 @@ function handleUpload(req, res, url, ctx) {
1517
2481
  json(res, 404, { error: `Instance not found: ${instance}` });
1518
2482
  return;
1519
2483
  }
2484
+ const limits = uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined });
2485
+ const tooLarge = () => json(res, 413, { error: fileTooLargeText(limits), max_bytes: limits.maxFileBytes, ...(limits.publicLinkCapped ? { public_link: true } : {}) });
1520
2486
  const declared = Number(req.headers["content-length"] ?? NaN);
1521
- if (Number.isFinite(declared) && declared > UPLOAD_LIMITS.maxFileBytes) {
1522
- json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
2487
+ if (Number.isFinite(declared) && declared > limits.maxFileBytes) {
2488
+ tooLarge();
1523
2489
  req.resume();
1524
2490
  return;
1525
2491
  }
1526
- const chunks = [];
2492
+ let part, fd, discard;
2493
+ try {
2494
+ ({ path: part, fd, discard } = createPartFile(join(getAgendHome(), "workspaces", instance, "inbox")));
2495
+ }
2496
+ catch (err) {
2497
+ ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
2498
+ json(res, 500, { error: "the file could not be stored" });
2499
+ req.resume();
2500
+ return;
2501
+ }
2502
+ const out = createWriteStream(part, { fd, autoClose: true });
2503
+ const sniffer = new UploadSniffer();
1527
2504
  let size = 0;
1528
- let refused = false;
2505
+ let over = false; // ended without a stored file: no further write, and the part file goes
2506
+ let stall;
2507
+ const clocks = () => { clearTimeout(stall); clearTimeout(deadline); };
2508
+ /** Stop here: answer (when the client is still there), remove the part file, and stop a body still arriving. */
2509
+ const drop = (answer) => {
2510
+ if (over)
2511
+ return;
2512
+ over = true;
2513
+ clocks();
2514
+ answer?.();
2515
+ out.destroy();
2516
+ discard();
2517
+ if (!req.complete)
2518
+ req.destroy();
2519
+ };
2520
+ const armStall = () => { clearTimeout(stall); stall = setTimeout(() => drop(() => json(res, 408, { error: "the upload stalled — attach the file again" })), UPLOAD_STALL_MS); stall.unref?.(); };
2521
+ const deadline = setTimeout(() => drop(() => json(res, 408, { error: "the upload took too long — attach the file again" })), UPLOAD_MAX_MS);
2522
+ deadline.unref?.();
2523
+ armStall();
2524
+ out.on("error", (err) => { ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored"); drop(() => json(res, 500, { error: "the file could not be stored" })); });
2525
+ out.on("drain", () => { if (!over)
2526
+ req.resume?.(); });
1529
2527
  req.on("data", (c) => {
1530
- if (refused)
2528
+ if (over)
1531
2529
  return;
1532
2530
  size += c.length;
1533
- if (size > UPLOAD_LIMITS.maxFileBytes) {
1534
- refused = true;
1535
- json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
1536
- req.destroy();
2531
+ if (size > limits.maxFileBytes) {
2532
+ drop(tooLarge);
1537
2533
  return;
1538
2534
  }
1539
- chunks.push(c);
2535
+ armStall();
2536
+ sniffer.push(c);
2537
+ if (!out.write(c))
2538
+ req.pause?.();
1540
2539
  });
1541
- req.on("error", () => { });
2540
+ req.on("error", () => drop()); // the client went away
2541
+ req.on("close", () => { if (!over && req.complete === false)
2542
+ drop(); });
2543
+ // The instance is checked again now the whole body is here: it may have been deleted (and its files
2544
+ // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
2545
+ // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
2546
+ // in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
2547
+ const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
2548
+ const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
1542
2549
  req.on("end", () => {
1543
- if (!permitWebContinuation(req, res, ctx))
2550
+ if (over)
1544
2551
  return;
1545
- if (refused)
2552
+ clocks();
2553
+ if (!permitWebContinuation(req, res, ctx)) {
2554
+ drop();
1546
2555
  return;
1547
- const bytes = Buffer.concat(chunks);
2556
+ }
1548
2557
  const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
1549
- const type = sniffUpload(bytes, name);
2558
+ const type = sniffer.result(name);
1550
2559
  if (!type) {
1551
- json(res, 415, { error: "unsupported file type — images (PNG, JPEG, GIF, WebP), PDF and text files only" });
2560
+ drop(() => json(res, 415, { error: "the file is empty" }));
1552
2561
  return;
1553
2562
  }
1554
- // The instance is checked again now the whole body is here: it may have been deleted (and its files
1555
- // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
1556
- // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
1557
- // in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
1558
- const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
1559
- if (!ctx.instanceIpcClients.has(instance) || !registered) {
1560
- json(res, 404, { error: `Instance not found: ${instance}` });
2563
+ if (!deliverable()) {
2564
+ gone();
1561
2565
  return;
1562
2566
  }
1563
- try {
1564
- const entry = ctx.webFiles.storeUpload({ instance, inboxDir: join(getAgendHome(), "workspaces", instance, "inbox"), bytes, name, type });
1565
- json(res, 200, publicAttachment(entry));
1566
- }
1567
- catch (err) {
1568
- ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1569
- json(res, 500, { error: "the file could not be stored" });
1570
- }
2567
+ out.end(() => {
2568
+ if (over)
2569
+ return;
2570
+ // The write's finish is another wait (Prism #1941 P2-1): the session or the public link may have ended in it.
2571
+ if (!permitWebContinuation(req, res, ctx)) {
2572
+ drop();
2573
+ return;
2574
+ }
2575
+ if (!deliverable()) {
2576
+ gone();
2577
+ return;
2578
+ }
2579
+ over = true;
2580
+ try {
2581
+ const entry = ctx.webFiles.storeUploadedFile({ instance, partPath: part, name, type });
2582
+ json(res, 200, publicAttachment(entry));
2583
+ }
2584
+ catch (err) {
2585
+ discard();
2586
+ ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
2587
+ json(res, 500, { error: "the file could not be stored" });
2588
+ }
2589
+ });
1571
2590
  });
1572
2591
  }
1573
2592
  function shellPreview(req, ctx, mode) {
@@ -1585,6 +2604,15 @@ function shellPreview(req, ctx, mode) {
1585
2604
  * No Range, several, or one not in that form → 200 with the whole file, as every file is served (the Download link).
1586
2605
  * Inline, nosniff, no caching, and the route's own sandbox policy, as for every file.
1587
2606
  */
2607
+ /** #1937: a whole file (headers already set) streamed from the descriptor its checks were made on: 200, its length. */
2608
+ function streamWhole(res, opened) {
2609
+ res.setHeader("Content-Length", String(opened.file.size));
2610
+ res.writeHead(200);
2611
+ const stream = createReadStream("", { fd: opened.fd, start: 0, end: Math.max(0, opened.file.size - 1), autoClose: true });
2612
+ stream.on("error", () => res.destroy());
2613
+ res.on("close", () => stream.destroy());
2614
+ stream.pipe(res);
2615
+ }
1588
2616
  function serveMediaRange(req, res, files, id, file) {
1589
2617
  const total = file.size;
1590
2618
  const header = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
@@ -1608,40 +2636,73 @@ function serveMediaRange(req, res, files, id, file) {
1608
2636
  }
1609
2637
  }
1610
2638
  end = ranged ? Math.min(end, total - 1, start + MEDIA_RANGE_MAX - 1) : total - 1;
2639
+ const mediaHeaders = () => {
2640
+ res.setHeader("Content-Type", file.mime);
2641
+ res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
2642
+ res.setHeader("Accept-Ranges", "bytes");
2643
+ res.setHeader("X-Content-Type-Options", "nosniff");
2644
+ res.setHeader("Cache-Control", "private, no-store");
2645
+ res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
2646
+ };
2647
+ // #1937 (Prism #1941 P3-2): the whole of a file over MEDIA_RANGE_MAX (no Range asked) streams, as /ui/file's do.
2648
+ if (!ranged && total > MEDIA_RANGE_MAX) {
2649
+ const opened = files.open(id);
2650
+ if (!opened) {
2651
+ json(res, 404, { error: "No such file" });
2652
+ return;
2653
+ }
2654
+ mediaHeaders();
2655
+ streamWhole(res, opened);
2656
+ return;
2657
+ }
1611
2658
  const got = files.readSlice(id, start, end);
1612
2659
  if (!got || (!ranged && got.bytes.length !== total)) {
1613
2660
  json(res, 404, { error: "No such file" });
1614
2661
  return;
1615
2662
  }
1616
- res.setHeader("Content-Type", file.mime);
1617
- res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
1618
- res.setHeader("Accept-Ranges", "bytes");
2663
+ mediaHeaders();
1619
2664
  if (ranged)
1620
2665
  res.setHeader("Content-Range", `bytes ${got.start}-${got.end}/${total}`);
1621
2666
  res.setHeader("Content-Length", String(got.bytes.length));
1622
- res.setHeader("X-Content-Type-Options", "nosniff");
1623
- res.setHeader("Cache-Control", "private, no-store");
1624
- res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
1625
2667
  res.writeHead(ranged ? 206 : 200);
1626
2668
  res.end(got.bytes);
1627
2669
  }
1628
- /**
1629
- * #1665: `<html>` carrying the host's preferences (data-prefs: `{"rev":n,"values":{…}}`, attribute-escaped). theme.js (a
1630
- * blocking script in <head>) and the app's dictionary take the theme and language from it over the browser's cache,
1631
- * and app-prefs.js copies every value into that cache before any panel reads it — so no device paints a stale theme
1632
- * or replays a tour already seen, a new device included. Only for the signed-in app (the public link reads what is in
1633
- * force too); the anonymous View reader and the sign-in page keep the browser's own.
1634
- */
1635
- function shellHtmlTag(ctx, mode) {
2670
+ function prefsViewer(req, url, ctx) {
2671
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2672
+ if (v.kind !== "allow")
2673
+ return null;
2674
+ if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
2675
+ const person = webPersonKey(v.session.owner);
2676
+ return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
2677
+ }
2678
+ return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
2679
+ }
2680
+ const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
2681
+ const prefsView = (prefs, v) => ({ ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) });
2682
+ function shellHtmlTag(ctx, mode, viewer) {
1636
2683
  if (mode !== "full" || !ctx.webPrefs)
1637
2684
  return '<html lang="en">';
1638
- const view = ctx.webPrefs.read(OPERATOR);
2685
+ const view = prefsView(ctx.webPrefs, viewer);
1639
2686
  const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
1640
2687
  const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1641
- return `<html lang="${lang}" data-prefs="${attr(JSON.stringify({ rev: view.rev, values: view.values }))}">`;
2688
+ return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
2689
+ }
2690
+ /**
2691
+ * #1629 r4 §3: the owner CSS this load links (its sha), or null. Only the full app of a signed-in human session — never
2692
+ * the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
2693
+ */
2694
+ function shellOwnerCss(ctx, mode, viewer) {
2695
+ return mode === "full" && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
1642
2696
  }
1643
- function shellBodyTag(req, ctx, mode) {
2697
+ function shellBodyTag(req, ctx, mode, viewer) {
1644
2698
  const p = shellPreview(req, ctx, mode);
2699
+ const ownerCss = shellOwnerCss(ctx, mode, viewer);
2700
+ let ownerCssState = "unknown";
2701
+ if (mode === "full" && viewer?.person != null && ctx.ownerCss) {
2702
+ ownerCssState = ctx.ownerCss.effectState(viewer.person);
2703
+ if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
2704
+ ownerCssState = "unknown";
2705
+ }
1645
2706
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1646
2707
  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)}"`
1647
2708
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
@@ -1653,8 +2714,13 @@ function shellBodyTag(req, ctx, mode) {
1653
2714
  + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
1654
2715
  // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
1655
2716
  + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
2717
+ // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
2718
+ // checks before sending a file, and the numbers its words say. The server checks again on every upload.
2719
+ + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
1656
2720
  // #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
1657
2721
  + ` data-fleet-tz="${attr(resolveTz())}"`
2722
+ // #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
2723
+ + ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
1658
2724
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
1659
2725
  + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
1660
2726
  }
@@ -1663,6 +2729,7 @@ function shellBodyTag(req, ctx, mode) {
1663
2729
  export function serveAppShell(req, res, ctx, mode) {
1664
2730
  try {
1665
2731
  const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
2732
+ const viewer = mode === "full" ? prefsViewer(req, new URL(req.url ?? "/", "http://localhost"), ctx) : null;
1666
2733
  const p = shellPreview(req, ctx, mode);
1667
2734
  // #1589: media plays only from this listener's own file route (path-scoped, like frame-src), at the address this page
1668
2735
  // was loaded from (a Host the host guard accepted); never on the public link, nor where no chat is shown.
@@ -1671,8 +2738,9 @@ export function serveAppShell(req, res, ctx, mode) {
1671
2738
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
1672
2739
  // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
1673
2740
  // the page may load); every other page frames exactly the origin this load chose (#1723).
1674
- const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
1675
- sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode)).replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
2741
+ const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin
2742
+ ? `${p.previewOrigin}/frame${mode === "full" && !gatewayRequestContext(req) ? ` ${p.previewOrigin}/effect` : ""}` : null;
2743
+ sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
1676
2744
  }
1677
2745
  catch {
1678
2746
  json(res, 500, { error: "app.html not found" });