@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.20

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 (389) hide show
  1. package/README.md +40 -24
  2. package/README.zh-TW.md +40 -24
  3. package/dist/agent-cli-instructions.md +6 -2
  4. package/dist/agent-cli.js +15 -7
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +3 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.js +16 -4
  9. package/dist/agent-whats-new.js.map +1 -1
  10. package/dist/auth-api.d.ts +3 -1
  11. package/dist/auth-api.js +17 -1
  12. package/dist/auth-api.js.map +1 -1
  13. package/dist/backend/antigravity.d.ts +33 -0
  14. package/dist/backend/antigravity.js +126 -18
  15. package/dist/backend/antigravity.js.map +1 -1
  16. package/dist/backend/claude-carry.d.ts +22 -0
  17. package/dist/backend/claude-carry.js +170 -0
  18. package/dist/backend/claude-carry.js.map +1 -0
  19. package/dist/backend/claude-code.d.ts +65 -2
  20. package/dist/backend/claude-code.js +226 -33
  21. package/dist/backend/claude-code.js.map +1 -1
  22. package/dist/backend/codex-metadata.d.ts +33 -0
  23. package/dist/backend/codex-metadata.js +122 -18
  24. package/dist/backend/codex-metadata.js.map +1 -1
  25. package/dist/backend/codex.d.ts +3 -0
  26. package/dist/backend/codex.js +17 -1
  27. package/dist/backend/codex.js.map +1 -1
  28. package/dist/backend/credential-profile.d.ts +40 -3
  29. package/dist/backend/credential-profile.js +113 -5
  30. package/dist/backend/credential-profile.js.map +1 -1
  31. package/dist/backend/effort-metadata.d.ts +37 -1
  32. package/dist/backend/effort-metadata.js +86 -15
  33. package/dist/backend/effort-metadata.js.map +1 -1
  34. package/dist/backend/grok.d.ts +4 -0
  35. package/dist/backend/grok.js +14 -0
  36. package/dist/backend/grok.js.map +1 -1
  37. package/dist/backend/kiro.d.ts +27 -1
  38. package/dist/backend/kiro.js +63 -10
  39. package/dist/backend/kiro.js.map +1 -1
  40. package/dist/backend/launch-effort.d.ts +61 -0
  41. package/dist/backend/launch-effort.js +170 -0
  42. package/dist/backend/launch-effort.js.map +1 -0
  43. package/dist/backend/muse.d.ts +84 -0
  44. package/dist/backend/muse.js +449 -53
  45. package/dist/backend/muse.js.map +1 -1
  46. package/dist/backend/types.d.ts +48 -2
  47. package/dist/backend/types.js +12 -1
  48. package/dist/backend/types.js.map +1 -1
  49. package/dist/backend-command.d.ts +91 -0
  50. package/dist/backend-command.js +122 -0
  51. package/dist/backend-command.js.map +1 -0
  52. package/dist/background-work-ended.d.ts +18 -0
  53. package/dist/background-work-ended.js +30 -0
  54. package/dist/background-work-ended.js.map +1 -0
  55. package/dist/cache-service.d.ts +2 -0
  56. package/dist/cache-service.js +2 -1
  57. package/dist/cache-service.js.map +1 -1
  58. package/dist/channel/adapters/discord.d.ts +26 -1
  59. package/dist/channel/adapters/discord.js +103 -3
  60. package/dist/channel/adapters/discord.js.map +1 -1
  61. package/dist/channel/adapters/telegram.d.ts +35 -3
  62. package/dist/channel/adapters/telegram.js +122 -23
  63. package/dist/channel/adapters/telegram.js.map +1 -1
  64. package/dist/channel/mcp-tools.js +5 -3
  65. package/dist/channel/mcp-tools.js.map +1 -1
  66. package/dist/channel/message-edit-errors.d.ts +5 -0
  67. package/dist/channel/message-edit-errors.js +24 -0
  68. package/dist/channel/message-edit-errors.js.map +1 -0
  69. package/dist/channel/telegram-markdown.d.ts +6 -0
  70. package/dist/channel/telegram-markdown.js +104 -0
  71. package/dist/channel/telegram-markdown.js.map +1 -0
  72. package/dist/channel/tool-router.d.ts +2 -0
  73. package/dist/channel/tool-router.js +6 -5
  74. package/dist/channel/tool-router.js.map +1 -1
  75. package/dist/channel/types.d.ts +46 -4
  76. package/dist/cli.js +184 -34
  77. package/dist/cli.js.map +1 -1
  78. package/dist/command-table.d.ts +2 -2
  79. package/dist/command-table.js +5 -2
  80. package/dist/command-table.js.map +1 -1
  81. package/dist/compact-watch.d.ts +86 -0
  82. package/dist/compact-watch.js +288 -0
  83. package/dist/compact-watch.js.map +1 -0
  84. package/dist/config-validator.js +55 -5
  85. package/dist/config-validator.js.map +1 -1
  86. package/dist/config.d.ts +1 -0
  87. package/dist/config.js +2 -1
  88. package/dist/config.js.map +1 -1
  89. package/dist/cross-instance-envelope.js +2 -0
  90. package/dist/cross-instance-envelope.js.map +1 -1
  91. package/dist/daemon.d.ts +129 -1
  92. package/dist/daemon.js +754 -94
  93. package/dist/daemon.js.map +1 -1
  94. package/dist/delivery-outbox.d.ts +64 -8
  95. package/dist/delivery-outbox.js +315 -24
  96. package/dist/delivery-outbox.js.map +1 -1
  97. package/dist/delivery-reconciliation.d.ts +18 -1
  98. package/dist/delivery-reconciliation.js +67 -2
  99. package/dist/delivery-reconciliation.js.map +1 -1
  100. package/dist/doctor.js +7 -0
  101. package/dist/doctor.js.map +1 -1
  102. package/dist/fleet-context.d.ts +12 -1
  103. package/dist/fleet-manager.d.ts +392 -19
  104. package/dist/fleet-manager.js +2030 -275
  105. package/dist/fleet-manager.js.map +1 -1
  106. package/dist/forged-envelope-scan.d.ts +4 -3
  107. package/dist/forged-envelope-scan.js +4 -3
  108. package/dist/forged-envelope-scan.js.map +1 -1
  109. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +84 -16
  110. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +16 -1
  111. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  112. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
  113. package/dist/host-memory.d.ts +10 -0
  114. package/dist/host-memory.js +33 -1
  115. package/dist/host-memory.js.map +1 -1
  116. package/dist/human-delivery-receipts.d.ts +83 -0
  117. package/dist/human-delivery-receipts.js +194 -0
  118. package/dist/human-delivery-receipts.js.map +1 -0
  119. package/dist/install-lock.d.ts +18 -1
  120. package/dist/install-lock.js +30 -3
  121. package/dist/install-lock.js.map +1 -1
  122. package/dist/instance-avatar.d.ts +5 -4
  123. package/dist/instance-avatar.js +12 -8
  124. package/dist/instance-avatar.js.map +1 -1
  125. package/dist/instance-config-impact.js +1 -0
  126. package/dist/instance-config-impact.js.map +1 -1
  127. package/dist/instance-lifecycle.d.ts +25 -6
  128. package/dist/instance-lifecycle.js +76 -27
  129. package/dist/instance-lifecycle.js.map +1 -1
  130. package/dist/instructions.d.ts +1 -1
  131. package/dist/instructions.js +3 -3
  132. package/dist/instructions.js.map +1 -1
  133. package/dist/kiro-compact-pane.d.ts +38 -0
  134. package/dist/kiro-compact-pane.js +57 -0
  135. package/dist/kiro-compact-pane.js.map +1 -0
  136. package/dist/kiro-log-gate.d.ts +42 -0
  137. package/dist/kiro-log-gate.js +39 -0
  138. package/dist/kiro-log-gate.js.map +1 -0
  139. package/dist/launchd-handoff.d.ts +43 -0
  140. package/dist/launchd-handoff.js +56 -0
  141. package/dist/launchd-handoff.js.map +1 -0
  142. package/dist/locale.js +268 -42
  143. package/dist/locale.js.map +1 -1
  144. package/dist/log-service.d.ts +99 -0
  145. package/dist/log-service.js +243 -0
  146. package/dist/log-service.js.map +1 -0
  147. package/dist/log-steps.d.ts +169 -0
  148. package/dist/log-steps.js +858 -0
  149. package/dist/log-steps.js.map +1 -0
  150. package/dist/login-controller.d.ts +20 -3
  151. package/dist/login-controller.js +71 -19
  152. package/dist/login-controller.js.map +1 -1
  153. package/dist/login-flows.d.ts +5 -2
  154. package/dist/login-flows.js +5 -4
  155. package/dist/login-flows.js.map +1 -1
  156. package/dist/login-shell.d.ts +21 -0
  157. package/dist/login-shell.js +98 -0
  158. package/dist/login-shell.js.map +1 -0
  159. package/dist/memory-pressure.d.ts +13 -1
  160. package/dist/memory-pressure.js +50 -10
  161. package/dist/memory-pressure.js.map +1 -1
  162. package/dist/needs-you-hub.js +2 -0
  163. package/dist/needs-you-hub.js.map +1 -1
  164. package/dist/needs-you-live.d.ts +6 -0
  165. package/dist/needs-you-live.js +23 -3
  166. package/dist/needs-you-live.js.map +1 -1
  167. package/dist/outbound-handlers.d.ts +100 -0
  168. package/dist/outbound-handlers.js +277 -93
  169. package/dist/outbound-handlers.js.map +1 -1
  170. package/dist/outbound-schemas.d.ts +30 -15
  171. package/dist/outbound-schemas.js +23 -5
  172. package/dist/outbound-schemas.js.map +1 -1
  173. package/dist/provider-probe.d.ts +29 -0
  174. package/dist/provider-probe.js +198 -2
  175. package/dist/provider-probe.js.map +1 -1
  176. package/dist/public-link-progress.d.ts +1 -1
  177. package/dist/public-link-progress.js +8 -2
  178. package/dist/public-link-progress.js.map +1 -1
  179. package/dist/public-preview.d.ts +101 -0
  180. package/dist/public-preview.js +236 -0
  181. package/dist/public-preview.js.map +1 -0
  182. package/dist/public-web-gateway.d.ts +2 -0
  183. package/dist/public-web-gateway.js +20 -6
  184. package/dist/public-web-gateway.js.map +1 -1
  185. package/dist/public-web-link.d.ts +24 -1
  186. package/dist/public-web-link.js +63 -9
  187. package/dist/public-web-link.js.map +1 -1
  188. package/dist/quickstart-api.d.ts +1 -1
  189. package/dist/quickstart-api.js +8 -6
  190. package/dist/quickstart-api.js.map +1 -1
  191. package/dist/quickstart.d.ts +12 -0
  192. package/dist/quickstart.js +36 -6
  193. package/dist/quickstart.js.map +1 -1
  194. package/dist/reply-buttons-controller.d.ts +89 -5
  195. package/dist/reply-buttons-controller.js +273 -8
  196. package/dist/reply-buttons-controller.js.map +1 -1
  197. package/dist/reply-buttons.d.ts +48 -3
  198. package/dist/reply-buttons.js +105 -10
  199. package/dist/reply-buttons.js.map +1 -1
  200. package/dist/reply-polls.d.ts +85 -0
  201. package/dist/reply-polls.js +154 -0
  202. package/dist/reply-polls.js.map +1 -0
  203. package/dist/restart-guard.d.ts +6 -3
  204. package/dist/restart-guard.js +6 -3
  205. package/dist/restart-guard.js.map +1 -1
  206. package/dist/restart-progress.d.ts +33 -2
  207. package/dist/restart-progress.js +30 -2
  208. package/dist/restart-progress.js.map +1 -1
  209. package/dist/sd-notify.d.ts +22 -1
  210. package/dist/sd-notify.js +63 -10
  211. package/dist/sd-notify.js.map +1 -1
  212. package/dist/service-activation.d.ts +15 -7
  213. package/dist/service-activation.js +42 -26
  214. package/dist/service-activation.js.map +1 -1
  215. package/dist/service-installer.d.ts +55 -0
  216. package/dist/service-installer.js +90 -3
  217. package/dist/service-installer.js.map +1 -1
  218. package/dist/service-plan.d.ts +11 -0
  219. package/dist/service-plan.js +23 -0
  220. package/dist/service-plan.js.map +1 -1
  221. package/dist/settings-api.js +14 -4
  222. package/dist/settings-api.js.map +1 -1
  223. package/dist/settings-change.js +4 -2
  224. package/dist/settings-change.js.map +1 -1
  225. package/dist/settings-effect.js +2 -0
  226. package/dist/settings-effect.js.map +1 -1
  227. package/dist/spawn-gate.js +5 -1
  228. package/dist/spawn-gate.js.map +1 -1
  229. package/dist/spawn-hold-reply.d.ts +12 -0
  230. package/dist/spawn-hold-reply.js +39 -0
  231. package/dist/spawn-hold-reply.js.map +1 -0
  232. package/dist/systemd-peer-bus.d.ts +95 -0
  233. package/dist/systemd-peer-bus.js +430 -0
  234. package/dist/systemd-peer-bus.js.map +1 -0
  235. package/dist/systemd-runtime.d.ts +27 -1
  236. package/dist/systemd-runtime.js +69 -7
  237. package/dist/systemd-runtime.js.map +1 -1
  238. package/dist/tips.js +1 -1
  239. package/dist/tips.js.map +1 -1
  240. package/dist/tool-permissions.js +3 -3
  241. package/dist/tool-permissions.js.map +1 -1
  242. package/dist/tool-progress.js +3 -2
  243. package/dist/tool-progress.js.map +1 -1
  244. package/dist/topic-commands.d.ts +71 -4
  245. package/dist/topic-commands.js +167 -34
  246. package/dist/topic-commands.js.map +1 -1
  247. package/dist/transcript-delta-reader.d.ts +3 -3
  248. package/dist/transcript-delta-reader.js +8 -8
  249. package/dist/transcript-delta-reader.js.map +1 -1
  250. package/dist/transcript-jsonl.d.ts +85 -3
  251. package/dist/transcript-jsonl.js +156 -13
  252. package/dist/transcript-jsonl.js.map +1 -1
  253. package/dist/transcript-monitor.d.ts +11 -0
  254. package/dist/transcript-monitor.js +61 -13
  255. package/dist/transcript-monitor.js.map +1 -1
  256. package/dist/transcript-sources.d.ts +24 -0
  257. package/dist/transcript-sources.js +211 -56
  258. package/dist/transcript-sources.js.map +1 -1
  259. package/dist/tunnel/cloudflared.d.ts +7 -15
  260. package/dist/tunnel/cloudflared.js +36 -1
  261. package/dist/tunnel/cloudflared.js.map +1 -1
  262. package/dist/tunnel/purpose-lane.d.ts +5 -1
  263. package/dist/tunnel/purpose-lane.js +14 -7
  264. package/dist/tunnel/purpose-lane.js.map +1 -1
  265. package/dist/tunnel/types.d.ts +5 -1
  266. package/dist/tunnel/types.js.map +1 -1
  267. package/dist/turn-resume.d.ts +2 -0
  268. package/dist/turn-resume.js +1 -1
  269. package/dist/turn-resume.js.map +1 -1
  270. package/dist/types.d.ts +22 -0
  271. package/dist/ui/chat-commands.js +1 -0
  272. package/dist/ui/chat-mentions.js +63 -0
  273. package/dist/ui/chat-render.js +80 -10
  274. package/dist/ui/chat-store.js +70 -6
  275. package/dist/ui/chat-strings.js +62 -32
  276. package/dist/ui/chat-thread.js +199 -24
  277. package/dist/ui/chat-time.js +80 -0
  278. package/dist/ui/chat-tour.js +17 -14
  279. package/dist/ui/panel-chat.js +45 -21
  280. package/dist/ui/panel-details.js +2 -0
  281. package/dist/ui/panel-fleet.js +213 -58
  282. package/dist/ui/panel-log.js +428 -0
  283. package/dist/ui/panel-needs.js +14 -10
  284. package/dist/ui/panel-settings.js +82 -17
  285. package/dist/ui/preview-panel.js +55 -15
  286. package/dist/ui/preview-status.js +28 -0
  287. package/dist/ui/preview.js +44 -3
  288. package/dist/ui/settings-dialogs.js +11 -6
  289. package/dist/ui/settings-model.js +3 -1
  290. package/dist/ui/settings-strings.js +32 -12
  291. package/dist/ui/settings-token.js +8 -1
  292. package/dist/ui/settings-wizard.js +5 -3
  293. package/dist/ui/shared/app-i18n.js +41 -15
  294. package/dist/ui/shared/app-prefs.js +250 -0
  295. package/dist/ui/shared/app-route.js +6 -1
  296. package/dist/ui/shared/app-shell.js +165 -8
  297. package/dist/ui/shared/app-store.js +5 -1
  298. package/dist/ui/shared/app-stream.js +26 -5
  299. package/dist/ui/shared/app.css +170 -30
  300. package/dist/ui/shared/app.js +11 -1
  301. package/dist/ui/shared/fonts/README.md +1 -1
  302. package/dist/ui/shared/header-tools.js +83 -13
  303. package/dist/ui/shared/instance-nav.js +178 -51
  304. package/dist/ui/shared/instance-switch.js +8 -5
  305. package/dist/ui/shared/panel-view.js +21 -4
  306. package/dist/ui/shared/signin.js +15 -9
  307. package/dist/ui/shared/theme.js +39 -7
  308. package/dist/ui/shared/tokens.css +1 -1
  309. package/dist/ui/shared/ui-icons.js +2 -0
  310. package/dist/ui/shared/usage-dialog.js +23 -5
  311. package/dist/ui/shared/view-strings.js +40 -4
  312. package/dist/ui/signin.html +8 -3
  313. package/dist/unrecognised-prompt.d.ts +23 -0
  314. package/dist/unrecognised-prompt.js +74 -0
  315. package/dist/unrecognised-prompt.js.map +1 -0
  316. package/dist/update-check.d.ts +31 -0
  317. package/dist/update-check.js +47 -0
  318. package/dist/update-check.js.map +1 -1
  319. package/dist/update-install.d.ts +13 -1
  320. package/dist/update-install.js +16 -4
  321. package/dist/update-install.js.map +1 -1
  322. package/dist/update-launch.d.ts +48 -1
  323. package/dist/update-launch.js +159 -5
  324. package/dist/update-launch.js.map +1 -1
  325. package/dist/update-marker.d.ts +51 -0
  326. package/dist/update-marker.js +61 -0
  327. package/dist/update-marker.js.map +1 -1
  328. package/dist/usage/i18n-keys.d.ts +1 -1
  329. package/dist/usage/i18n-keys.js +1 -1
  330. package/dist/usage/i18n-keys.js.map +1 -1
  331. package/dist/usage/providers.d.ts +62 -13
  332. package/dist/usage/providers.js +256 -68
  333. package/dist/usage/providers.js.map +1 -1
  334. package/dist/usage/statusline-usage.d.ts +6 -2
  335. package/dist/usage/statusline-usage.js +10 -4
  336. package/dist/usage/statusline-usage.js.map +1 -1
  337. package/dist/view-api.js +31 -9
  338. package/dist/view-api.js.map +1 -1
  339. package/dist/web-api.d.ts +82 -4
  340. package/dist/web-api.js +632 -88
  341. package/dist/web-api.js.map +1 -1
  342. package/dist/web-auth.js +5 -2
  343. package/dist/web-auth.js.map +1 -1
  344. package/dist/web-chat-history.d.ts +26 -1
  345. package/dist/web-chat-history.js +47 -8
  346. package/dist/web-chat-history.js.map +1 -1
  347. package/dist/web-commands.d.ts +41 -5
  348. package/dist/web-commands.js +25 -7
  349. package/dist/web-commands.js.map +1 -1
  350. package/dist/web-login.d.ts +14 -0
  351. package/dist/web-login.js +4 -2
  352. package/dist/web-login.js.map +1 -1
  353. package/dist/web-mentions.d.ts +44 -0
  354. package/dist/web-mentions.js +75 -0
  355. package/dist/web-mentions.js.map +1 -0
  356. package/dist/web-prefs.d.ts +81 -0
  357. package/dist/web-prefs.js +297 -0
  358. package/dist/web-prefs.js.map +1 -0
  359. package/dist/web-preview.d.ts +14 -1
  360. package/dist/web-preview.js +35 -13
  361. package/dist/web-preview.js.map +1 -1
  362. package/dist/web-profile.d.ts +32 -0
  363. package/dist/web-profile.js +132 -0
  364. package/dist/web-profile.js.map +1 -0
  365. package/dist/web-reply-files.d.ts +45 -0
  366. package/dist/web-reply-files.js +223 -0
  367. package/dist/web-reply-files.js.map +1 -0
  368. package/dist/web-schedules.d.ts +12 -0
  369. package/dist/web-schedules.js +34 -0
  370. package/dist/web-schedules.js.map +1 -0
  371. package/dist/web-server-drain.d.ts +10 -0
  372. package/dist/web-server-drain.js +50 -0
  373. package/dist/web-server-drain.js.map +1 -0
  374. package/dist/web-session.d.ts +14 -0
  375. package/dist/web-session.js +11 -1
  376. package/dist/web-session.js.map +1 -1
  377. package/dist/web-settings-patch.d.ts +3 -1
  378. package/dist/web-settings-patch.js +4 -1
  379. package/dist/web-settings-patch.js.map +1 -1
  380. package/dist/web-shell-routes.d.ts +4 -0
  381. package/dist/web-shell-routes.js +9 -0
  382. package/dist/web-shell-routes.js.map +1 -1
  383. package/dist/web-upload.d.ts +135 -7
  384. package/dist/web-upload.js +315 -51
  385. package/dist/web-upload.js.map +1 -1
  386. package/launcher/install-admission.cjs +61 -6
  387. package/package.json +3 -3
  388. package/templates/launchd.plist.ejs +3 -1
  389. package/templates/systemd.service.ejs +2 -1
package/dist/web-api.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { webHumanOrigin } from "./human-delivery-receipts.js";
2
+ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
1
3
  import { binaryProbe } from "./binary-probe.js";
2
4
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
3
5
  import { readBoundedWebBody } from "./web-body.js";
@@ -13,19 +15,26 @@ import { isSafeInstanceName } from "./web-shell-routes.js";
13
15
  import { MAX_COMMAND_ARGS } from "./web-commands.js";
14
16
  import { sendPanelHtml } from "./web-host-guard.js";
15
17
  import { shellRoute } from "./web-shell-routes.js";
16
- import { readFileSync } from "node:fs";
18
+ import { createReadStream, createWriteStream, readFileSync } from "node:fs";
17
19
  import { join, dirname } from "node:path";
18
20
  import { fileURLToPath } from "node:url";
19
- import { CreateInstanceArgs, validateArgs } from "./outbound-schemas.js";
21
+ import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
22
+ import { scheduleView } from "./web-schedules.js";
23
+ import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
20
24
  import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
21
25
  import { z } from "zod";
22
26
  import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
23
27
  import { dashboardOriginFor } from "./web-preview.js";
24
28
  import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
25
- import { attachmentDelivery, displayName, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, sniffUpload, UPLOAD_LIMITS, wellFormed } from "./web-upload.js";
29
+ 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";
26
30
  import { getAgendHome } from "./paths.js";
31
+ import { OPERATOR } from "./web-prefs.js";
32
+ import { WEB_NAME_FALLBACK } from "./web-profile.js";
33
+ import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
34
+ import { parseMentionQuery } from "./web-mentions.js";
27
35
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
28
36
  import { measureSyncWork } from "./sync-work-attribution.js";
37
+ import { resolveTz } from "./tz-utils.js";
29
38
  // ── Strict public-facing schemas ────────────────────────────────────────────
30
39
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
31
40
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -45,18 +54,17 @@ const TaskUpdateSchema = z.object({
45
54
  description: z.string().max(MAX_TEXT).optional(),
46
55
  priority: z.string().max(64).optional(),
47
56
  }).strict();
48
- const ScheduleCreateSchema = z.object({
49
- cron: z.string().min(1).max(256),
50
- message: z.string().min(1).max(MAX_TEXT),
51
- target: z.string().min(1).max(256),
52
- label: z.string().max(256).optional(),
53
- timezone: z.string().max(128).optional(),
54
- }).strict();
55
57
  const TeamCreateSchema = z.object({
56
58
  name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
57
59
  members: z.array(z.string().min(1).max(256)).min(1).max(256),
58
60
  description: z.string().max(MAX_TEXT).optional(),
59
61
  }).strict();
62
+ // #1689: an edit of a team — its members as they should be, and its description ("" clears it). No name: a team is
63
+ // not renamed.
64
+ const TeamEditSchema = z.object({
65
+ members: z.array(z.string().min(1).max(256)).min(1).max(256),
66
+ description: z.string().max(MAX_TEXT).optional(),
67
+ }).strict();
60
68
  const ConfigUpdateSchema = z.object({
61
69
  channel: z.object({
62
70
  group_id: z.union([z.number(), z.string()]).optional(),
@@ -142,6 +150,37 @@ function stillAuthorized(req, res, url, ctx) {
142
150
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
143
151
  return false;
144
152
  }
153
+ /**
154
+ * #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
155
+ * no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
156
+ * session, the CLI's header token) none: the operator. For recognition only; nothing here authorizes anything.
157
+ */
158
+ function webAccountOf(req, url, ctx) {
159
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
160
+ if (verdict.kind !== "allow")
161
+ return null;
162
+ return verdict.via === "session" && verdict.session?.owner ? { owner: verdict.session.owner } : {};
163
+ }
164
+ function webPersonOf(req, url, ctx) {
165
+ // No people kept here: everyone is "web-user", as before, and there is no identity to protect.
166
+ if (!ctx.webPerson)
167
+ return { key: OPERATOR, bound: false, userId: WEB_NAME_FALLBACK, voterKey: "web:operator", name: WEB_NAME_FALLBACK, set: false };
168
+ const who = webAccountOf(req, url, ctx);
169
+ return who ? ctx.webPerson(who.owner) : null;
170
+ }
171
+ /** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
172
+ function webPersonOr401(req, res, url, ctx) {
173
+ const p = webPersonOf(req, url, ctx);
174
+ if (!p)
175
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
176
+ return p;
177
+ }
178
+ /** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
179
+ function profileBody(p) {
180
+ return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
181
+ }
182
+ /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
183
+ function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
145
184
  /**
146
185
  * Handle a Web UI request. Returns true if handled, false to pass through.
147
186
  */
@@ -254,6 +293,8 @@ export function handleWebRequest(req, res, url, ctx) {
254
293
  // #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
255
294
  // records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
256
295
  cursor: history ? history.pollCursor() : null,
296
+ // #1823 (#1830 review): the server's clock now, for the page's poll countdown (a poll's deadline is the server's).
297
+ now: Date.now(),
257
298
  });
258
299
  return true;
259
300
  }
@@ -267,7 +308,9 @@ export function handleWebRequest(req, res, url, ctx) {
267
308
  "Cache-Control": "no-cache",
268
309
  Connection: "keep-alive",
269
310
  });
270
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`);
311
+ // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
312
+ // poll down by (one write: the frames a connect sends are unchanged in number).
313
+ res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
271
314
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
272
315
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
273
316
  if (lastSeen !== null && ctx.webChatHistory) {
@@ -303,7 +346,7 @@ export function handleWebRequest(req, res, url, ctx) {
303
346
  }
304
347
  try {
305
348
  // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
306
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
349
+ res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
307
350
  }
308
351
  catch {
309
352
  cleanup();
@@ -340,6 +383,45 @@ export function handleWebRequest(req, res, url, ctx) {
340
383
  json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
341
384
  return true;
342
385
  }
386
+ // ── #1218: the Log tab ─────────────────────────────────
387
+ // An instance's steps, read on demand from its own transcript (log-service.ts). Read-only, and offered on the public
388
+ // link on purpose (the user's decision): the same masking applies to every viewer. Passive (web-auth.ts).
389
+ if (method === "GET" && path === "/ui/steps") {
390
+ const p = url.searchParams;
391
+ const instance = p.get("instance") ?? "";
392
+ const num = (k) => {
393
+ const v = p.get(k);
394
+ if (v === null)
395
+ return undefined;
396
+ const n = Number(v);
397
+ return /^\d{1,15}$/.test(v) && Number.isSafeInteger(n) ? n : null;
398
+ };
399
+ const after = num("after"), before = num("before"), ev = num("ev"), limit = num("limit");
400
+ const key = p.get("key");
401
+ if (!instance || instance.length > 128 || after === null || before === null || ev === null || limit === null
402
+ || (limit !== undefined && (limit < 1 || limit > 200)) || (key !== null && !/^\d{1,20}-\d{1,20}$/.test(key))) {
403
+ json(res, 400, { error: "instance (1-128 chars); after, before, ev: offsets; limit 1-200; key: <dev>-<ino>" });
404
+ return true;
405
+ }
406
+ if (!ctx.logSteps) {
407
+ json(res, 404, { error: "not available" });
408
+ return true;
409
+ }
410
+ ctx.logSteps(instance, {
411
+ ...(after !== undefined ? { after } : {}), ...(before !== undefined ? { before } : {}), ...(ev !== undefined ? { ev } : {}),
412
+ ...(limit !== undefined ? { limit } : {}), ...(key !== null ? { key } : {}),
413
+ skip: p.get("skip") === "1", watch: p.get("watch") === "1",
414
+ }).then(answer => {
415
+ if (answer === null)
416
+ json(res, 404, { error: `Instance not found: ${instance}` });
417
+ else
418
+ json(res, 200, answer);
419
+ }).catch(err => {
420
+ ctx.logger.error({ err: err.message, instance }, "Log steps read failed");
421
+ json(res, 500, { error: "could not read the steps" });
422
+ });
423
+ return true;
424
+ }
343
425
  // ── Send message ───────────────────────────────────────
344
426
  if (method === "POST" && path === "/ui/send") {
345
427
  handleSendMessage(req, res, ctx);
@@ -360,12 +442,14 @@ export function handleWebRequest(req, res, url, ctx) {
360
442
  serveMediaRange(req, res, ctx.webFiles, id, media);
361
443
  return true;
362
444
  }
363
- const got = isFileId(id) ? ctx.webFiles?.read(id) : null;
364
- if (!got) {
445
+ // #1937: a file over MEDIA_RANGE_MAX streams from the descriptor its checks were made on — never whole in memory.
446
+ const big = isFileId(id) && (ctx.webFiles?.servedFile?.(id)?.size ?? 0) > MEDIA_RANGE_MAX ? ctx.webFiles?.open?.(id) ?? null : null;
447
+ const got = big ? null : isFileId(id) ? ctx.webFiles?.read(id) : null;
448
+ const file = big?.file ?? got?.file;
449
+ if (!file) {
365
450
  json(res, 404, { error: "No such file" });
366
451
  return true;
367
452
  }
368
- const { file, bytes } = got;
369
453
  const inline = INLINE_MIME.has(file.mime);
370
454
  // Only the four image types are shown in the page; anything else is a download, never rendered.
371
455
  res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
@@ -373,8 +457,12 @@ export function handleWebRequest(req, res, url, ctx) {
373
457
  res.setHeader("X-Content-Type-Options", "nosniff");
374
458
  res.setHeader("Cache-Control", "private, no-store");
375
459
  res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
460
+ if (big) {
461
+ streamWhole(res, big);
462
+ return true;
463
+ }
376
464
  res.writeHead(200);
377
- res.end(bytes);
465
+ res.end(got.bytes);
378
466
  return true;
379
467
  }
380
468
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
@@ -419,6 +507,24 @@ export function handleWebRequest(req, res, url, ctx) {
419
507
  (ctx.webEmojiList ? ctx.webEmojiList() : Promise.resolve([])).then(answer, () => answer([]));
420
508
  return true;
421
509
  }
510
+ // #1682: the names of the mention ids a page found in what it rendered (at most MAX_MENTION_IDS, digits only) — only
511
+ // those, so no page (the public link included) ever gets a directory; from memory, never a request.
512
+ if (method === "GET" && path === "/ui/mentions") {
513
+ const parsed = parseMentionQuery(url.searchParams);
514
+ if (!parsed.ok) {
515
+ json(res, 400, { error: parsed.error });
516
+ return true;
517
+ }
518
+ let names = { u: {}, r: {}, c: {} };
519
+ try {
520
+ names = ctx.webMentionNames?.(parsed.query) ?? names;
521
+ }
522
+ catch (err) {
523
+ ctx.logger.debug({ err: err.message }, "mention names unavailable");
524
+ }
525
+ json(res, 200, names);
526
+ return true;
527
+ }
422
528
  // ── Fleet prompts (C4): the hang / exit / interactive-prompt buttons, answerable here too ──
423
529
  if (method === "GET" && path === "/ui/prompts") {
424
530
  json(res, 200, { prompts: ctx.listWebPrompts?.() ?? [] });
@@ -463,6 +569,63 @@ export function handleWebRequest(req, res, url, ctx) {
463
569
  });
464
570
  return true;
465
571
  }
572
+ // #1827: the person's own name — the one agents and chats see for their web messages. Their own profile, so the public
573
+ // link may read and set it too (profile edits are allowed there); a read-only session may as well: it is about them.
574
+ if (path === "/ui/profile" && method === "GET") {
575
+ if (!ctx.webPerson) {
576
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
577
+ return true;
578
+ }
579
+ const p = webPersonOr401(req, res, url, ctx);
580
+ if (p)
581
+ json(res, 200, profileBody(p));
582
+ return true;
583
+ }
584
+ if (path === "/ui/profile" && method === "POST") {
585
+ if (!ctx.setWebPersonName) {
586
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
587
+ return true;
588
+ }
589
+ const set = ctx.setWebPersonName.bind(ctx);
590
+ (async () => {
591
+ let body;
592
+ try {
593
+ body = await parseBody(req);
594
+ }
595
+ catch {
596
+ json(res, 400, { error: "Invalid JSON" });
597
+ return;
598
+ }
599
+ if (!permitWebContinuation(req, res, ctx))
600
+ return;
601
+ if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "name")) {
602
+ json(res, 400, { error: "name required (text, or null to clear)" });
603
+ return;
604
+ }
605
+ // Whose: asked once the body is in. A session revoked, expired or rotated away meanwhile writes nothing — it is
606
+ // not the operator (#1833 review).
607
+ const who = webAccountOf(req, url, ctx);
608
+ if (!who) {
609
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
610
+ return;
611
+ }
612
+ const r = set(who.owner, body.name);
613
+ if (!r.ok) {
614
+ json(res, r.status, { error: r.error });
615
+ return;
616
+ }
617
+ // The person's other devices read it at their next status (the prefs rev moved).
618
+ ctx.emitSseEvent("status", ctx.getUiStatus());
619
+ json(res, 200, profileBody(r.person));
620
+ })().catch(err => {
621
+ ctx.logger.error({ err: err.message }, "Saving the web name failed");
622
+ try {
623
+ json(res, 500, { error: "Saving the name failed" });
624
+ }
625
+ catch { /* already answered */ }
626
+ });
627
+ return true;
628
+ }
466
629
  // #1266: a click on an agent reply's button. The set id travels in the body; the public link may click too (a click
467
630
  // is a message, and the public link may send messages).
468
631
  if (method === "POST" && path === "/ui/reply-button") {
@@ -487,8 +650,14 @@ export function handleWebRequest(req, res, url, ctx) {
487
650
  json(res, 400, { error: "instance, id and index required" });
488
651
  return;
489
652
  }
490
- const r = await click(instance, id, index);
491
- json(res, r.status, r.status === 200 ? { answered: true } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
653
+ // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
654
+ // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
655
+ // gate (a session revoked meanwhile) clicks nothing as anyone.
656
+ const person = webPersonOr401(req, res, url, ctx);
657
+ if (!person)
658
+ return;
659
+ const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
660
+ json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
492
661
  })().catch(err => {
493
662
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
494
663
  try {
@@ -498,6 +667,97 @@ export function handleWebRequest(req, res, url, ctx) {
498
667
  });
499
668
  return true;
500
669
  }
670
+ // #1723: the public link page's preview, asked while it says "Preview is starting…" — its state and, once up, its
671
+ // origin and boot. Public link only: a local page's preview is decided by its own load.
672
+ if (path === "/ui/preview-status" && method === "GET") {
673
+ const gateway = gatewayRequestContext(req);
674
+ if (!gateway || !ctx.publicPreviewForUi) {
675
+ json(res, 404, { error: "not offered here" });
676
+ return true;
677
+ }
678
+ const p = ctx.publicPreviewForUi(gateway.exposureId, gateway.expectedOrigin);
679
+ json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
680
+ return true;
681
+ }
682
+ // #1665: the web preferences that follow the person between devices. Read by any page of the panel, the public link
683
+ // included (it reads what is in force); written only by a signed-in admin session or the CLI's header token — never
684
+ // over the public link (absent from its route manifest, and refused here too), never by a read-only session.
685
+ if (path === "/ui/prefs" && method === "GET") {
686
+ if (!ctx.webPrefs) {
687
+ json(res, 404, { error: "Preferences are not kept on this fleet" });
688
+ return true;
689
+ }
690
+ json(res, 200, ctx.webPrefs.read(OPERATOR));
691
+ return true;
692
+ }
693
+ if (path === "/ui/prefs" && method === "POST") {
694
+ const prefs = ctx.webPrefs;
695
+ if (!prefs) {
696
+ json(res, 404, { error: "Preferences are not kept on this fleet" });
697
+ return true;
698
+ }
699
+ if (gatewayRequestContext(req)) {
700
+ json(res, 403, { error: "prefs_not_over_public_link" });
701
+ return true;
702
+ }
703
+ // Who may write, asked now and again once the body is in: a session revoked or expired, or a token rotated, while
704
+ // the body was on its way writes nothing and announces nothing.
705
+ const writer = () => {
706
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
707
+ if (verdict.kind !== "allow")
708
+ return 401;
709
+ return verdict.via === "session" && verdict.session?.tier !== "admin" ? 403 : 200;
710
+ };
711
+ const refuse = (code) => json(res, code, code === 401 ? { error: WEB_TOKEN_INVALID_MESSAGE } : { error: "prefs_read_only" });
712
+ const first = writer();
713
+ if (first !== 200) {
714
+ refuse(first);
715
+ return true;
716
+ }
717
+ (async () => {
718
+ let body;
719
+ try {
720
+ body = await parseBody(req);
721
+ }
722
+ catch {
723
+ json(res, 400, { error: "Invalid JSON" });
724
+ return;
725
+ }
726
+ if (!permitWebContinuation(req, res, ctx))
727
+ return;
728
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
729
+ json(res, 400, { error: "set required" });
730
+ return;
731
+ }
732
+ const parsed = prefs.parsePatch(body.set);
733
+ if (!parsed.ok) {
734
+ json(res, 400, { error: parsed.error });
735
+ return;
736
+ }
737
+ const now = writer();
738
+ if (now !== 200) {
739
+ refuse(now);
740
+ return;
741
+ }
742
+ const onlyIfUnset = body.onlyIfUnset === true;
743
+ const written = prefs.write(OPERATOR, parsed.patch, { onlyIfUnset });
744
+ if (!written.ok) {
745
+ json(res, written.status, { error: written.error });
746
+ return;
747
+ }
748
+ // Another device sees the new rev in the next status frame — at once on the live stream.
749
+ if (written.changed.length)
750
+ ctx.emitSseEvent("status", ctx.getUiStatus());
751
+ json(res, 200, { ...written.view, changed: written.changed });
752
+ })().catch(err => {
753
+ ctx.logger.error({ err: err.message }, "Saving web preferences failed");
754
+ try {
755
+ json(res, 500, { error: "Saving preferences failed" });
756
+ }
757
+ catch { /* already answered */ }
758
+ });
759
+ return true;
760
+ }
501
761
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
502
762
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
503
763
  if (method === "POST" && path === "/ui/needs/ack") {
@@ -573,7 +833,10 @@ export function handleWebRequest(req, res, url, ctx) {
573
833
  json(res, 400, { error: "invalid confirmation" });
574
834
  return;
575
835
  }
576
- const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req) });
836
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
837
+ if (!person)
838
+ return;
839
+ const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
577
840
  json(res, r.status, r.body);
578
841
  })().catch(err => {
579
842
  ctx.logger.error({ err: err.message }, "Web command failed");
@@ -607,7 +870,7 @@ export function handleWebRequest(req, res, url, ctx) {
607
870
  json(res, 409, { error: `${name} is not running` });
608
871
  return true;
609
872
  }
610
- ctx.eventLog?.logActivity("cancel", "web-user", "stop the current reply", name);
873
+ ctx.eventLog?.logActivity("cancel", webPersonOf(req, url, ctx)?.name ?? WEB_NAME_FALLBACK, "stop the current reply", name);
611
874
  json(res, 200, { cancelled: name });
612
875
  return true;
613
876
  }
@@ -640,13 +903,18 @@ export function handleWebRequest(req, res, url, ctx) {
640
903
  }
641
904
  const topicMode = ctx.fleetConfig?.channel?.mode === "topic";
642
905
  (async () => {
906
+ let held = false;
643
907
  try {
644
- await ctx.startInstance(name, config, topicMode ?? false);
908
+ await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false));
645
909
  ctx.emitSseEvent("status", ctx.getUiStatus());
646
- json(res, 200, { started: name });
910
+ if (!held)
911
+ json(res, 200, { started: name });
647
912
  }
648
913
  catch (err) {
649
- json(res, 500, { error: err.message });
914
+ if (!held)
915
+ json(res, 500, { error: err.message });
916
+ else
917
+ ctx.logger.error({ err, name }, "Held instance action failed");
650
918
  }
651
919
  })();
652
920
  return true;
@@ -759,14 +1027,19 @@ export function handleWebRequest(req, res, url, ctx) {
759
1027
  if (method === "POST" && restartMatch) {
760
1028
  const name = decodeURIComponent(restartMatch[1]);
761
1029
  (async () => {
1030
+ let held = false;
762
1031
  try {
763
- await ctx.restartSingleInstance(name, { explicit: true });
1032
+ await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.restartSingleInstance(name, { explicit: true }));
764
1033
  ctx.emitSseEvent("status", ctx.getUiStatus());
765
- json(res, 200, { restarted: name });
1034
+ if (!held)
1035
+ json(res, 200, { restarted: name });
766
1036
  }
767
1037
  catch (err) {
768
1038
  const status = err.message.includes("not found") ? 404 : 500;
769
- json(res, status, { error: err.message });
1039
+ if (!held)
1040
+ json(res, status, { error: err.message });
1041
+ else
1042
+ ctx.logger.error({ err, name }, "Held instance action failed");
770
1043
  }
771
1044
  })();
772
1045
  return true;
@@ -828,12 +1101,15 @@ export function handleWebRequest(req, res, url, ctx) {
828
1101
  const parsed = parseOrReject(TaskCreateSchema, body, res);
829
1102
  if (!parsed)
830
1103
  return;
1104
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
1105
+ if (!person)
1106
+ return;
831
1107
  const task = ctx.scheduler.db.createTask({
832
1108
  title: parsed.title,
833
1109
  description: parsed.description,
834
1110
  priority: parsed.priority,
835
1111
  assignee: parsed.assignee,
836
- created_by: "web-user",
1112
+ created_by: person.name, // #1827
837
1113
  });
838
1114
  json(res, 200, task);
839
1115
  }
@@ -860,7 +1136,10 @@ export function handleWebRequest(req, res, url, ctx) {
860
1136
  return;
861
1137
  let result;
862
1138
  if (parsed.action === "claim") {
863
- result = ctx.scheduler.db.claimTask(id, parsed.assignee || "web-user");
1139
+ const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
1140
+ if (!parsed.assignee && !person)
1141
+ return;
1142
+ result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
864
1143
  }
865
1144
  else if (parsed.action === "complete") {
866
1145
  result = ctx.scheduler.db.completeTask(id, parsed.result);
@@ -884,10 +1163,28 @@ export function handleWebRequest(req, res, url, ctx) {
884
1163
  json(res, 200, { schedules: [] });
885
1164
  return true;
886
1165
  }
887
- json(res, 200, { schedules: ctx.scheduler.list() });
1166
+ // #1724: each row with its next run and one-shot time in the fleet's zone — worked out here, not on the page.
1167
+ const scheduler = ctx.scheduler;
1168
+ const now = Date.now();
1169
+ json(res, 200, {
1170
+ schedules: scheduler.list().map((s) => scheduleView(s, scheduler.getRetry?.(s.id) ?? null, now)),
1171
+ // The zone a new schedule (and a "Once" date and time typed without one) is read in.
1172
+ default_timezone: ctx.fleetConfig?.defaults?.scheduler?.default_timezone
1173
+ ?? DEFAULT_SCHEDULER_CONFIG.default_timezone,
1174
+ });
888
1175
  return true;
889
1176
  }
1177
+ // #1724: who may write a schedule, asked again once the body is in (a session revoked meanwhile, a non-admin tier).
1178
+ const scheduleWriterGone = () => {
1179
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1180
+ return verdict.kind !== "allow" || (verdict.via === "session" && verdict.session?.tier !== "admin");
1181
+ };
890
1182
  if (method === "POST" && path === "/ui/schedules") {
1183
+ // #1724: schedules are not created, edited or deleted over the public link (absent from its manifest too, #1689).
1184
+ if (gatewayRequestContext(req)) {
1185
+ json(res, 403, { error: "schedule_not_over_public_link" });
1186
+ return true;
1187
+ }
891
1188
  if (!ctx.scheduler) {
892
1189
  json(res, 500, { error: "Scheduler not initialized" });
893
1190
  return true;
@@ -897,10 +1194,23 @@ export function handleWebRequest(req, res, url, ctx) {
897
1194
  const body = await parseBody(req);
898
1195
  if (!permitWebContinuation(req, res, ctx))
899
1196
  return;
900
- const parsed = parseOrReject(ScheduleCreateSchema, body, res);
1197
+ if (scheduleWriterGone()) {
1198
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1199
+ return;
1200
+ }
1201
+ // #1724: the create_schedule tool's own schema — cron or at (exactly one) — plus a target, which the web names.
1202
+ const parsed = parseOrReject(CreateScheduleArgs, body, res);
901
1203
  if (!parsed)
902
1204
  return;
903
- const schedule = ctx.scheduler.create(parsed);
1205
+ if (typeof parsed.target !== "string" || !parsed.target) {
1206
+ json(res, 400, { error: "target: an instance is required" });
1207
+ return;
1208
+ }
1209
+ const schedule = ctx.scheduler.create({
1210
+ cron: parsed.cron, at: parsed.at, message: parsed.message, target: parsed.target, label: parsed.label, timezone: parsed.timezone,
1211
+ ...(parsed.silent !== undefined ? { silent: parsed.silent } : {}),
1212
+ source: "web", reply_chat_id: "", reply_thread_id: null,
1213
+ });
904
1214
  json(res, 200, schedule);
905
1215
  }
906
1216
  catch (err) {
@@ -910,7 +1220,48 @@ export function handleWebRequest(req, res, url, ctx) {
910
1220
  return true;
911
1221
  }
912
1222
  const schedDelMatch = path.match(/^\/ui\/schedules\/(.+)$/);
1223
+ // #1724: Edit — the update_schedule tool's validation (UpdateScheduleArgs) and its call (scheduler.update): cron and at
1224
+ // clear each other there; silent is not touched.
1225
+ if (method === "PATCH" && schedDelMatch) {
1226
+ if (gatewayRequestContext(req)) {
1227
+ json(res, 403, { error: "schedule_not_over_public_link" });
1228
+ return true;
1229
+ }
1230
+ if (!ctx.scheduler || typeof ctx.scheduler.update !== "function") {
1231
+ json(res, 500, { error: "Scheduler not initialized" });
1232
+ return true;
1233
+ }
1234
+ const id = decodeURIComponent(schedDelMatch[1]);
1235
+ (async () => {
1236
+ try {
1237
+ const body = await parseBody(req);
1238
+ if (!permitWebContinuation(req, res, ctx))
1239
+ return;
1240
+ if (scheduleWriterGone()) {
1241
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1242
+ return;
1243
+ }
1244
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.hasOwn(body, "id")) {
1245
+ json(res, 400, { error: "expected the fields to change (the id is the path's)" });
1246
+ return;
1247
+ }
1248
+ const parsed = parseOrReject(UpdateScheduleArgs, { ...body, id }, res);
1249
+ if (!parsed)
1250
+ return;
1251
+ const { id: _id, ...changes } = parsed;
1252
+ json(res, 200, ctx.scheduler.update(id, changes));
1253
+ }
1254
+ catch (err) {
1255
+ json(res, 400, { error: err.message });
1256
+ }
1257
+ })();
1258
+ return true;
1259
+ }
913
1260
  if (method === "DELETE" && schedDelMatch) {
1261
+ if (gatewayRequestContext(req)) {
1262
+ json(res, 403, { error: "schedule_not_over_public_link" });
1263
+ return true;
1264
+ }
914
1265
  if (!ctx.scheduler) {
915
1266
  json(res, 500, { error: "Scheduler not initialized" });
916
1267
  return true;
@@ -947,26 +1298,82 @@ export function handleWebRequest(req, res, url, ctx) {
947
1298
  return true;
948
1299
  }
949
1300
  if (method === "POST" && path === "/ui/teams") {
1301
+ // #1689: not over the public link (absent from its manifest too), like editing and deleting a team.
1302
+ if (gatewayRequestContext(req)) {
1303
+ json(res, 403, { error: "teams_not_over_public_link" });
1304
+ return true;
1305
+ }
950
1306
  (async () => {
951
1307
  try {
952
1308
  const body = await parseBody(req);
953
1309
  if (!permitWebContinuation(req, res, ctx))
954
1310
  return;
1311
+ // #1692: who may write, asked again once the body is in (as the edit does).
1312
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1313
+ if (verdict.kind !== "allow" || (verdict.via === "session" && verdict.session?.tier !== "admin")) {
1314
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1315
+ return;
1316
+ }
955
1317
  const parsed = parseOrReject(TeamCreateSchema, body, res);
956
1318
  if (!parsed)
957
1319
  return;
958
- if (!ctx.fleetConfig) {
959
- json(res, 500, { error: "No fleet config" });
1320
+ // #1692: the create_team path — members checked, an existing team never overwritten, a failed save undone.
1321
+ const r = commitTeamCreate(ctx.fleetConfig, parsed, () => ctx.saveFleetConfig());
1322
+ if (!r.ok) {
1323
+ json(res, r.status, { error: r.error });
960
1324
  return;
961
1325
  }
962
- if (!ctx.fleetConfig.teams)
963
- ctx.fleetConfig.teams = {};
964
- ctx.fleetConfig.teams[parsed.name] = {
965
- members: parsed.members,
966
- description: parsed.description,
967
- };
968
- ctx.saveFleetConfig();
969
- json(res, 200, { created: parsed.name });
1326
+ json(res, 200, r.result);
1327
+ }
1328
+ catch (err) {
1329
+ json(res, 400, { error: err.message });
1330
+ }
1331
+ })();
1332
+ return true;
1333
+ }
1334
+ // #1689: edit a team — the same update as the update_team tool (applyTeamUpdate), from the members the page sends:
1335
+ // what to add and remove is worked out against the team as it is now. Never over the public link.
1336
+ const teamEditMatch = path.match(/^\/ui\/teams\/(.+)$/);
1337
+ if ((method === "PATCH" || method === "PUT") && teamEditMatch) {
1338
+ if (gatewayRequestContext(req)) {
1339
+ json(res, 403, { error: "teams_not_over_public_link" });
1340
+ return true;
1341
+ }
1342
+ const name = decodeURIComponent(teamEditMatch[1]);
1343
+ // Who may write, asked again once the body is in (#1693 review): a session revoked or a token rotated meanwhile
1344
+ // writes nothing.
1345
+ const writerNow = () => {
1346
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1347
+ return verdict.kind === "allow" && !(verdict.via === "session" && verdict.session?.tier !== "admin");
1348
+ };
1349
+ (async () => {
1350
+ try {
1351
+ const body = await parseBody(req);
1352
+ if (!permitWebContinuation(req, res, ctx))
1353
+ return;
1354
+ if (!writerNow()) {
1355
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1356
+ return;
1357
+ }
1358
+ const parsed = parseOrReject(TeamEditSchema, body, res);
1359
+ if (!parsed)
1360
+ return;
1361
+ const teams = ctx.fleetConfig?.teams;
1362
+ const current = teams && Object.hasOwn(teams, name) ? teams[name].members : null;
1363
+ if (!current) {
1364
+ json(res, 404, { error: `Team not found: ${name}` });
1365
+ return;
1366
+ }
1367
+ const want = [...new Set(parsed.members)];
1368
+ // The update and its save as one: a failed save leaves the team as it was (500).
1369
+ const r = commitTeamUpdate(ctx.fleetConfig, {
1370
+ name, add: want.filter(m => !current.includes(m)), remove: current.filter(m => !want.includes(m)), description: parsed.description,
1371
+ }, () => ctx.saveFleetConfig());
1372
+ if (!r.ok) {
1373
+ json(res, r.status, { error: r.error });
1374
+ return;
1375
+ }
1376
+ json(res, 200, { updated: r.result });
970
1377
  }
971
1378
  catch (err) {
972
1379
  json(res, 400, { error: err.message });
@@ -976,8 +1383,13 @@ export function handleWebRequest(req, res, url, ctx) {
976
1383
  }
977
1384
  const teamDelMatch = path.match(/^\/ui\/teams\/(.+)$/);
978
1385
  if (method === "DELETE" && teamDelMatch) {
1386
+ if (gatewayRequestContext(req)) {
1387
+ json(res, 403, { error: "teams_not_over_public_link" });
1388
+ return true;
1389
+ }
979
1390
  const name = decodeURIComponent(teamDelMatch[1]);
980
- if (!ctx.fleetConfig?.teams?.[name]) {
1391
+ // Own keys (#1689): a team is one fleet.yaml defines — "__proto__" or "constructor" is not found.
1392
+ if (!ctx.fleetConfig?.teams || !Object.hasOwn(ctx.fleetConfig.teams, name)) {
981
1393
  json(res, 404, { error: `Team not found: ${name}` });
982
1394
  return true;
983
1395
  }
@@ -1116,6 +1528,14 @@ function handleSendMessage(req, res, ctx) {
1116
1528
  return;
1117
1529
  }
1118
1530
  const message = typed;
1531
+ // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
1532
+ // body is in: a session revoked meanwhile sends nothing (not as the operator).
1533
+ const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
1534
+ if (!person) {
1535
+ ctx.webFiles?.release(files);
1536
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1537
+ return;
1538
+ }
1119
1539
  const ts = new Date().toISOString();
1120
1540
  // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
1121
1541
  const messageId = newWebMessageId();
@@ -1134,7 +1554,7 @@ function handleSendMessage(req, res, ctx) {
1134
1554
  // opt-in resolved at send time inside sendClassicWebEcho.
1135
1555
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
1136
1556
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1137
- await ctx.sendClassicWebEcho(instance, formatWebChannelEcho("web-user", preview, t("web.echo_full_text")));
1557
+ await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
1138
1558
  }) : undefined;
1139
1559
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1140
1560
  // A replacement adapter or edited binding is not the route we reserved.
@@ -1142,18 +1562,21 @@ function handleSendMessage(req, res, ctx) {
1142
1562
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
1143
1563
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
1144
1564
  return;
1145
- return syncAdapter.sendText(String(groupId), formatWebChannelEcho("web-user", preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
1565
+ return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
1146
1566
  }) : undefined;
1147
1567
  let delivered;
1148
1568
  try {
1149
- delivered = await ctx.deliverToInstance(instance, {
1569
+ const deliver = (name, payload) => ctx.deliverHumanToInstance
1570
+ ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
1571
+ : ctx.deliverToInstance(name, payload);
1572
+ delivered = await deliver(instance, {
1150
1573
  type: "fleet_inbound",
1151
1574
  content: delivery.text,
1152
1575
  targetSession: instance,
1153
1576
  meta: {
1154
1577
  chat_id: groupId ? String(groupId) : "",
1155
1578
  message_id: messageId,
1156
- user: "web-user", user_id: "web-user",
1579
+ user: person.name, user_id: person.userId,
1157
1580
  ts,
1158
1581
  thread_id: topicId != null ? String(topicId) : "",
1159
1582
  source: "web",
@@ -1183,10 +1606,17 @@ function handleSendMessage(req, res, ctx) {
1183
1606
  }
1184
1607
  settleEcho?.(true);
1185
1608
  settleClassicEcho?.(true);
1609
+ // #1666: the same cancel/progress bubble a platform inbound starts, in the topic the echo went to — a second turn
1610
+ // on the echo's lane, so the topic reads "echo, then bubble" and a cancel in between drops it (the lane's epoch).
1611
+ // Not a copy (#1668): a slow bubble never drops the next message's echo, and a slow echo never drops the bubble.
1612
+ if (settleEcho && ctx.startWebChatProgress) {
1613
+ const startProgress = ctx.startWebChatProgress.bind(ctx);
1614
+ ctx.reserveWebChannelEcho?.(instance, () => startProgress(instance), { copy: false })(true);
1615
+ }
1186
1616
  ctx.webFiles?.commit(files);
1187
- ctx.lastInboundUser.set(instance, "web-user");
1188
- ctx.eventLog?.logActivity("message", "web-user", (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1189
- ctx.emitSseEvent("message", { instance, sender: "web-user", role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1617
+ ctx.lastInboundUser.set(instance, person.name);
1618
+ ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1619
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1190
1620
  json(res, 200, { sent: true, messageId });
1191
1621
  }
1192
1622
  catch {
@@ -1198,6 +1628,11 @@ function handleSendMessage(req, res, ctx) {
1198
1628
  * One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
1199
1629
  * read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
1200
1630
  * workspace inbox under a name chosen here; answered with the id the message will name.
1631
+ *
1632
+ * #1937: streamed to a part file in that inbox (web-pending-….part) as it arrives — never held whole in memory — and
1633
+ * counted by the bytes that came, whatever Content-Length said. The limits are the ones in force for this request
1634
+ * (uploadLimits(): the config, and the public link's own cap). An upload that goes UPLOAD_STALL_MS without a byte, or
1635
+ * runs past UPLOAD_MAX_MS, is dropped; any upload that does not end in a stored file takes its part file with it.
1201
1636
  */
1202
1637
  function handleUpload(req, res, url, ctx) {
1203
1638
  const instance = url.searchParams.get("instance") ?? "";
@@ -1209,62 +1644,121 @@ function handleUpload(req, res, url, ctx) {
1209
1644
  json(res, 404, { error: `Instance not found: ${instance}` });
1210
1645
  return;
1211
1646
  }
1647
+ const limits = uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined });
1648
+ const tooLarge = () => json(res, 413, { error: fileTooLargeText(limits), max_bytes: limits.maxFileBytes, ...(limits.publicLinkCapped ? { public_link: true } : {}) });
1212
1649
  const declared = Number(req.headers["content-length"] ?? NaN);
1213
- if (Number.isFinite(declared) && declared > UPLOAD_LIMITS.maxFileBytes) {
1214
- json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
1650
+ if (Number.isFinite(declared) && declared > limits.maxFileBytes) {
1651
+ tooLarge();
1652
+ req.resume();
1653
+ return;
1654
+ }
1655
+ let part, fd, discard;
1656
+ try {
1657
+ ({ path: part, fd, discard } = createPartFile(join(getAgendHome(), "workspaces", instance, "inbox")));
1658
+ }
1659
+ catch (err) {
1660
+ ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1661
+ json(res, 500, { error: "the file could not be stored" });
1215
1662
  req.resume();
1216
1663
  return;
1217
1664
  }
1218
- const chunks = [];
1665
+ const out = createWriteStream(part, { fd, autoClose: true });
1666
+ const sniffer = new UploadSniffer();
1219
1667
  let size = 0;
1220
- let refused = false;
1668
+ let over = false; // ended without a stored file: no further write, and the part file goes
1669
+ let stall;
1670
+ const clocks = () => { clearTimeout(stall); clearTimeout(deadline); };
1671
+ /** Stop here: answer (when the client is still there), remove the part file, and stop a body still arriving. */
1672
+ const drop = (answer) => {
1673
+ if (over)
1674
+ return;
1675
+ over = true;
1676
+ clocks();
1677
+ answer?.();
1678
+ out.destroy();
1679
+ discard();
1680
+ if (!req.complete)
1681
+ req.destroy();
1682
+ };
1683
+ const armStall = () => { clearTimeout(stall); stall = setTimeout(() => drop(() => json(res, 408, { error: "the upload stalled — attach the file again" })), UPLOAD_STALL_MS); stall.unref?.(); };
1684
+ const deadline = setTimeout(() => drop(() => json(res, 408, { error: "the upload took too long — attach the file again" })), UPLOAD_MAX_MS);
1685
+ deadline.unref?.();
1686
+ armStall();
1687
+ 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" })); });
1688
+ out.on("drain", () => { if (!over)
1689
+ req.resume?.(); });
1221
1690
  req.on("data", (c) => {
1222
- if (refused)
1691
+ if (over)
1223
1692
  return;
1224
1693
  size += c.length;
1225
- if (size > UPLOAD_LIMITS.maxFileBytes) {
1226
- refused = true;
1227
- json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
1228
- req.destroy();
1694
+ if (size > limits.maxFileBytes) {
1695
+ drop(tooLarge);
1229
1696
  return;
1230
1697
  }
1231
- chunks.push(c);
1698
+ armStall();
1699
+ sniffer.push(c);
1700
+ if (!out.write(c))
1701
+ req.pause?.();
1232
1702
  });
1233
- req.on("error", () => { });
1703
+ req.on("error", () => drop()); // the client went away
1704
+ req.on("close", () => { if (!over && req.complete === false)
1705
+ drop(); });
1706
+ // The instance is checked again now the whole body is here: it may have been deleted (and its files
1707
+ // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
1708
+ // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
1709
+ // in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
1710
+ const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
1711
+ const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
1234
1712
  req.on("end", () => {
1235
- if (!permitWebContinuation(req, res, ctx))
1713
+ if (over)
1236
1714
  return;
1237
- if (refused)
1715
+ clocks();
1716
+ if (!permitWebContinuation(req, res, ctx)) {
1717
+ drop();
1238
1718
  return;
1239
- const bytes = Buffer.concat(chunks);
1719
+ }
1240
1720
  const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
1241
- const type = sniffUpload(bytes, name);
1721
+ const type = sniffer.result(name);
1242
1722
  if (!type) {
1243
- json(res, 415, { error: "unsupported file type — images (PNG, JPEG, GIF, WebP), PDF and text files only" });
1723
+ drop(() => json(res, 415, { error: "the file is empty" }));
1244
1724
  return;
1245
1725
  }
1246
- // The instance is checked again now the whole body is here: it may have been deleted (and its files
1247
- // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
1248
- // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
1249
- // in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
1250
- const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
1251
- if (!ctx.instanceIpcClients.has(instance) || !registered) {
1252
- json(res, 404, { error: `Instance not found: ${instance}` });
1726
+ if (!deliverable()) {
1727
+ gone();
1253
1728
  return;
1254
1729
  }
1255
- try {
1256
- const entry = ctx.webFiles.storeUpload({ instance, inboxDir: join(getAgendHome(), "workspaces", instance, "inbox"), bytes, name, type });
1257
- json(res, 200, publicAttachment(entry));
1258
- }
1259
- catch (err) {
1260
- ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1261
- json(res, 500, { error: "the file could not be stored" });
1262
- }
1730
+ out.end(() => {
1731
+ if (over)
1732
+ return;
1733
+ // The write's finish is another wait (Prism #1941 P2-1): the session or the public link may have ended in it.
1734
+ if (!permitWebContinuation(req, res, ctx)) {
1735
+ drop();
1736
+ return;
1737
+ }
1738
+ if (!deliverable()) {
1739
+ gone();
1740
+ return;
1741
+ }
1742
+ over = true;
1743
+ try {
1744
+ const entry = ctx.webFiles.storeUploadedFile({ instance, partPath: part, name, type });
1745
+ json(res, 200, publicAttachment(entry));
1746
+ }
1747
+ catch (err) {
1748
+ discard();
1749
+ ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1750
+ json(res, 500, { error: "the file could not be stored" });
1751
+ }
1752
+ });
1263
1753
  });
1264
1754
  }
1265
1755
  function shellPreview(req, ctx, mode) {
1266
- if (mode !== "full" || gatewayRequestContext(req))
1756
+ if (mode !== "full")
1267
1757
  return null;
1758
+ // #1723: a page of the public link asks its own exposure's preview — never the local listener's Host-based choice.
1759
+ const gateway = gatewayRequestContext(req);
1760
+ if (gateway)
1761
+ return ctx.publicPreviewForUi?.(gateway.exposureId, gateway.expectedOrigin) ?? null;
1268
1762
  return ctx.previewForUi?.(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) ?? null;
1269
1763
  }
1270
1764
  /**
@@ -1273,6 +1767,15 @@ function shellPreview(req, ctx, mode) {
1273
1767
  * No Range, several, or one not in that form → 200 with the whole file, as every file is served (the Download link).
1274
1768
  * Inline, nosniff, no caching, and the route's own sandbox policy, as for every file.
1275
1769
  */
1770
+ /** #1937: a whole file (headers already set) streamed from the descriptor its checks were made on: 200, its length. */
1771
+ function streamWhole(res, opened) {
1772
+ res.setHeader("Content-Length", String(opened.file.size));
1773
+ res.writeHead(200);
1774
+ const stream = createReadStream("", { fd: opened.fd, start: 0, end: Math.max(0, opened.file.size - 1), autoClose: true });
1775
+ stream.on("error", () => res.destroy());
1776
+ res.on("close", () => stream.destroy());
1777
+ stream.pipe(res);
1778
+ }
1276
1779
  function serveMediaRange(req, res, files, id, file) {
1277
1780
  const total = file.size;
1278
1781
  const header = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
@@ -1296,23 +1799,52 @@ function serveMediaRange(req, res, files, id, file) {
1296
1799
  }
1297
1800
  }
1298
1801
  end = ranged ? Math.min(end, total - 1, start + MEDIA_RANGE_MAX - 1) : total - 1;
1802
+ const mediaHeaders = () => {
1803
+ res.setHeader("Content-Type", file.mime);
1804
+ res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
1805
+ res.setHeader("Accept-Ranges", "bytes");
1806
+ res.setHeader("X-Content-Type-Options", "nosniff");
1807
+ res.setHeader("Cache-Control", "private, no-store");
1808
+ res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
1809
+ };
1810
+ // #1937 (Prism #1941 P3-2): the whole of a file over MEDIA_RANGE_MAX (no Range asked) streams, as /ui/file's do.
1811
+ if (!ranged && total > MEDIA_RANGE_MAX) {
1812
+ const opened = files.open(id);
1813
+ if (!opened) {
1814
+ json(res, 404, { error: "No such file" });
1815
+ return;
1816
+ }
1817
+ mediaHeaders();
1818
+ streamWhole(res, opened);
1819
+ return;
1820
+ }
1299
1821
  const got = files.readSlice(id, start, end);
1300
1822
  if (!got || (!ranged && got.bytes.length !== total)) {
1301
1823
  json(res, 404, { error: "No such file" });
1302
1824
  return;
1303
1825
  }
1304
- res.setHeader("Content-Type", file.mime);
1305
- res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
1306
- res.setHeader("Accept-Ranges", "bytes");
1826
+ mediaHeaders();
1307
1827
  if (ranged)
1308
1828
  res.setHeader("Content-Range", `bytes ${got.start}-${got.end}/${total}`);
1309
1829
  res.setHeader("Content-Length", String(got.bytes.length));
1310
- res.setHeader("X-Content-Type-Options", "nosniff");
1311
- res.setHeader("Cache-Control", "private, no-store");
1312
- res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
1313
1830
  res.writeHead(ranged ? 206 : 200);
1314
1831
  res.end(got.bytes);
1315
1832
  }
1833
+ /**
1834
+ * #1665: `<html>` carrying the host's preferences (data-prefs: `{"rev":n,"values":{…}}`, attribute-escaped). theme.js (a
1835
+ * blocking script in <head>) and the app's dictionary take the theme and language from it over the browser's cache,
1836
+ * and app-prefs.js copies every value into that cache before any panel reads it — so no device paints a stale theme
1837
+ * or replays a tour already seen, a new device included. Only for the signed-in app (the public link reads what is in
1838
+ * force too); the anonymous View reader and the sign-in page keep the browser's own.
1839
+ */
1840
+ function shellHtmlTag(ctx, mode) {
1841
+ if (mode !== "full" || !ctx.webPrefs)
1842
+ return '<html lang="en">';
1843
+ const view = ctx.webPrefs.read(OPERATOR);
1844
+ const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
1845
+ const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1846
+ return `<html lang="${lang}" data-prefs="${attr(JSON.stringify({ rev: view.rev, values: view.values }))}">`;
1847
+ }
1316
1848
  function shellBodyTag(req, ctx, mode) {
1317
1849
  const p = shellPreview(req, ctx, mode);
1318
1850
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
@@ -1320,8 +1852,17 @@ function shellBodyTag(req, ctx, mode) {
1320
1852
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
1321
1853
  // #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
1322
1854
  + ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
1855
+ // #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
1856
+ + `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
1323
1857
  // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔.
1324
1858
  + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
1859
+ // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
1860
+ + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
1861
+ // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
1862
+ // checks before sending a file, and the numbers its words say. The server checks again on every upload.
1863
+ + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
1864
+ // #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
1865
+ + ` data-fleet-tz="${attr(resolveTz())}"`
1325
1866
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
1326
1867
  + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
1327
1868
  }
@@ -1336,7 +1877,10 @@ export function serveAppShell(req, res, ctx, mode) {
1336
1877
  const own = mode === "full" && !gatewayRequestContext(req)
1337
1878
  ? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) : null;
1338
1879
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
1339
- sendPanelHtml(res, html.replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(p?.previewOrigin ? { frameSrc: `${p.previewOrigin}/frame` } : {}), mediaSrc });
1880
+ // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
1881
+ // the page may load); every other page frames exactly the origin this load chose (#1723).
1882
+ const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
1883
+ sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode)).replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
1340
1884
  }
1341
1885
  catch {
1342
1886
  json(res, 500, { error: "app.html not found" });