@songsid/agend 2.2.0 → 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 (271) hide show
  1. package/dist/agent-cli.js +248 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +2 -0
  4. package/dist/agent-endpoint.js +25 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +30 -0
  7. package/dist/agent-file-read.js +129 -0
  8. package/dist/agent-file-read.js.map +1 -0
  9. package/dist/agent-whats-new.js +13 -0
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/atomic-write.js +9 -1
  12. package/dist/atomic-write.js.map +1 -1
  13. package/dist/auth-api.js +17 -0
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/backend/antigravity.js +3 -3
  16. package/dist/backend/antigravity.js.map +1 -1
  17. package/dist/backend/launch-effort.js +1 -1
  18. package/dist/backend/launch-effort.js.map +1 -1
  19. package/dist/backend/muse.js +10 -2
  20. package/dist/backend/muse.js.map +1 -1
  21. package/dist/channel/adapters/discord.d.ts +2 -0
  22. package/dist/channel/adapters/discord.js +17 -2
  23. package/dist/channel/adapters/discord.js.map +1 -1
  24. package/dist/channel/adapters/telegram.d.ts +8 -1
  25. package/dist/channel/adapters/telegram.js +74 -4
  26. package/dist/channel/adapters/telegram.js.map +1 -1
  27. package/dist/channel/mcp-tools.js +34 -0
  28. package/dist/channel/mcp-tools.js.map +1 -1
  29. package/dist/channel/types.d.ts +15 -0
  30. package/dist/config-validator.js +17 -1
  31. package/dist/config-validator.js.map +1 -1
  32. package/dist/daemon.js +244 -20
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/fleet-context.d.ts +5 -0
  35. package/dist/fleet-manager.d.ts +89 -0
  36. package/dist/fleet-manager.js +401 -25
  37. package/dist/fleet-manager.js.map +1 -1
  38. package/dist/general-knowledge/skills/web-appearance/SKILL.md +258 -0
  39. package/dist/instance-avatar.js +3 -0
  40. package/dist/instance-avatar.js.map +1 -1
  41. package/dist/instance-config-impact.js +3 -0
  42. package/dist/instance-config-impact.js.map +1 -1
  43. package/dist/instance-lifecycle.js +9 -4
  44. package/dist/instance-lifecycle.js.map +1 -1
  45. package/dist/needs-you-hub.d.ts +4 -0
  46. package/dist/needs-you-hub.js +31 -6
  47. package/dist/needs-you-hub.js.map +1 -1
  48. package/dist/needs-you.d.ts +1 -13
  49. package/dist/needs-you.js +3 -37
  50. package/dist/needs-you.js.map +1 -1
  51. package/dist/outbound-handlers.d.ts +5 -0
  52. package/dist/outbound-handlers.js +30 -9
  53. package/dist/outbound-handlers.js.map +1 -1
  54. package/dist/outbound-schemas.d.ts +64 -0
  55. package/dist/outbound-schemas.js +50 -0
  56. package/dist/outbound-schemas.js.map +1 -1
  57. package/dist/public-web-gateway.js +14 -0
  58. package/dist/public-web-gateway.js.map +1 -1
  59. package/dist/repo-worktree-guard.d.ts +36 -0
  60. package/dist/repo-worktree-guard.js +143 -0
  61. package/dist/repo-worktree-guard.js.map +1 -0
  62. package/dist/scheduler/scheduler.d.ts +58 -1
  63. package/dist/scheduler/scheduler.js +176 -10
  64. package/dist/scheduler/scheduler.js.map +1 -1
  65. package/dist/settings-api.js +74 -1
  66. package/dist/settings-api.js.map +1 -1
  67. package/dist/settings-change.js +8 -0
  68. package/dist/settings-change.js.map +1 -1
  69. package/dist/settings-effect.js +16 -2
  70. package/dist/settings-effect.js.map +1 -1
  71. package/dist/settings-http-confirmation.js +5 -0
  72. package/dist/settings-http-confirmation.js.map +1 -1
  73. package/dist/tool-permissions.js +50 -0
  74. package/dist/tool-permissions.js.map +1 -1
  75. package/dist/topic-commands.d.ts +10 -6
  76. package/dist/topic-commands.js +21 -17
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/topic-link.d.ts +128 -0
  79. package/dist/topic-link.js +269 -0
  80. package/dist/topic-link.js.map +1 -0
  81. package/dist/types.d.ts +13 -0
  82. package/dist/ui/app-effects.js +187 -0
  83. package/dist/ui/chat-effect-posters.js +42 -0
  84. package/dist/ui/chat-effects.js +72 -0
  85. package/dist/ui/chat-live-replies.js +58 -0
  86. package/dist/ui/chat-render.js +4 -4
  87. package/dist/ui/chat-store.js +9 -3
  88. package/dist/ui/chat-strings.js +15 -0
  89. package/dist/ui/chat-thread.js +47 -21
  90. package/dist/ui/chat-tour.js +1 -1
  91. package/dist/ui/effect-trial-controller.js +119 -0
  92. package/dist/ui/effect-trial-offer.js +31 -0
  93. package/dist/ui/effect-trial-panel.js +37 -0
  94. package/dist/ui/image-lightbox.js +1 -1
  95. package/dist/ui/panel-chat.js +85 -17
  96. package/dist/ui/panel-details.js +18 -5
  97. package/dist/ui/panel-fleet.js +3 -3
  98. package/dist/ui/panel-log.js +6 -6
  99. package/dist/ui/panel-needs.js +3 -3
  100. package/dist/ui/panel-settings.js +8 -5
  101. package/dist/ui/preview-panel.js +25 -9
  102. package/dist/ui/settings-appearance.js +325 -0
  103. package/dist/ui/settings-effects.js +201 -0
  104. package/dist/ui/settings-mods.js +257 -0
  105. package/dist/ui/settings-owner-effects.js +80 -0
  106. package/dist/ui/settings-strings.js +245 -0
  107. package/dist/ui/shared/agend-auth.js +27 -3
  108. package/dist/ui/shared/agent-widget.js +248 -0
  109. package/dist/ui/shared/app-i18n.js +2 -0
  110. package/dist/ui/shared/app-prefs.js +30 -10
  111. package/dist/ui/shared/app-route.js +23 -1
  112. package/dist/ui/shared/app-session.js +1 -1
  113. package/dist/ui/shared/app-shell.js +29 -16
  114. package/dist/ui/shared/app-store.js +2 -0
  115. package/dist/ui/shared/app-stream.js +12 -7
  116. package/dist/ui/shared/app-themes.js +188 -0
  117. package/dist/ui/shared/app.css +214 -1
  118. package/dist/ui/shared/app.js +6 -0
  119. package/dist/ui/shared/card-strings.js +28 -0
  120. package/dist/ui/shared/effect-app-runtime.js +248 -0
  121. package/dist/ui/shared/effect-baseline.js +64 -0
  122. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  123. package/dist/ui/shared/effect-cursor.js +125 -0
  124. package/dist/ui/shared/effect-frame.js +127 -0
  125. package/dist/ui/shared/effect-geometry.js +122 -0
  126. package/dist/ui/shared/effect-host-style.js +10 -0
  127. package/dist/ui/shared/effect-owner-regions.js +74 -0
  128. package/dist/ui/shared/effect-owner-source.js +115 -0
  129. package/dist/ui/shared/effect-policy.js +137 -0
  130. package/dist/ui/shared/effect-poster-reader.js +98 -0
  131. package/dist/ui/shared/effect-posters.js +117 -0
  132. package/dist/ui/shared/effect-profiler.js +162 -0
  133. package/dist/ui/shared/effect-profiles.js +65 -0
  134. package/dist/ui/shared/effect-registry.js +496 -0
  135. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  136. package/dist/ui/shared/effect-safety.js +153 -0
  137. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  138. package/dist/ui/shared/effect-stock-trust.js +165 -0
  139. package/dist/ui/shared/effect-trial-source.js +103 -0
  140. package/dist/ui/shared/effect-trial-stage.js +147 -0
  141. package/dist/ui/shared/header-tools.js +1 -1
  142. package/dist/ui/shared/identity-card.js +202 -0
  143. package/dist/ui/shared/identity-color.js +261 -0
  144. package/dist/ui/shared/instance-nav.js +22 -14
  145. package/dist/ui/shared/owner-css.js +221 -0
  146. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  147. package/dist/ui/shared/panel-view.js +81 -8
  148. package/dist/ui/shared/signin.js +2 -0
  149. package/dist/ui/shared/ui-dialog.js +1 -1
  150. package/dist/ui/shared/ui-icons.js +2 -0
  151. package/dist/ui/shared/ui-menu.js +1 -1
  152. package/dist/ui/shared/ui-toast.js +1 -1
  153. package/dist/ui/shared/view-strings.js +30 -0
  154. package/dist/ui/signin.html +1 -0
  155. package/dist/view-api.d.ts +29 -0
  156. package/dist/view-api.js +188 -8
  157. package/dist/view-api.js.map +1 -1
  158. package/dist/web-api.d.ts +42 -0
  159. package/dist/web-api.js +896 -36
  160. package/dist/web-api.js.map +1 -1
  161. package/dist/web-appearance-limits.d.ts +82 -0
  162. package/dist/web-appearance-limits.js +83 -0
  163. package/dist/web-appearance-limits.js.map +1 -0
  164. package/dist/web-appearance-policy.d.ts +66 -0
  165. package/dist/web-appearance-policy.js +94 -0
  166. package/dist/web-appearance-policy.js.map +1 -0
  167. package/dist/web-appearance-tools.d.ts +50 -0
  168. package/dist/web-appearance-tools.js +105 -0
  169. package/dist/web-appearance-tools.js.map +1 -0
  170. package/dist/web-appearance-view.d.ts +99 -0
  171. package/dist/web-appearance-view.js +163 -0
  172. package/dist/web-appearance-view.js.map +1 -0
  173. package/dist/web-appearance.d.ts +79 -0
  174. package/dist/web-appearance.js +298 -0
  175. package/dist/web-appearance.js.map +1 -0
  176. package/dist/web-asset-parts.d.ts +98 -0
  177. package/dist/web-asset-parts.js +133 -0
  178. package/dist/web-asset-parts.js.map +1 -0
  179. package/dist/web-auth.js +17 -1
  180. package/dist/web-auth.js.map +1 -1
  181. package/dist/web-card.d.ts +96 -0
  182. package/dist/web-card.js +232 -0
  183. package/dist/web-card.js.map +1 -0
  184. package/dist/web-chat-history.d.ts +13 -0
  185. package/dist/web-chat-history.js +23 -2
  186. package/dist/web-chat-history.js.map +1 -1
  187. package/dist/web-effect-bundle.d.ts +28 -0
  188. package/dist/web-effect-bundle.js +126 -0
  189. package/dist/web-effect-bundle.js.map +1 -0
  190. package/dist/web-effect-canonical.d.ts +8 -0
  191. package/dist/web-effect-canonical.js +27 -0
  192. package/dist/web-effect-canonical.js.map +1 -0
  193. package/dist/web-effect-library.d.ts +114 -0
  194. package/dist/web-effect-library.js +530 -0
  195. package/dist/web-effect-library.js.map +1 -0
  196. package/dist/web-effect-poster.d.ts +8 -0
  197. package/dist/web-effect-poster.js +52 -0
  198. package/dist/web-effect-poster.js.map +1 -0
  199. package/dist/web-effect-profiles.d.ts +118 -0
  200. package/dist/web-effect-profiles.js +180 -0
  201. package/dist/web-effect-profiles.js.map +1 -0
  202. package/dist/web-effect-service.d.ts +86 -0
  203. package/dist/web-effect-service.js +312 -0
  204. package/dist/web-effect-service.js.map +1 -0
  205. package/dist/web-effect-tools.d.ts +47 -0
  206. package/dist/web-effect-tools.js +126 -0
  207. package/dist/web-effect-tools.js.map +1 -0
  208. package/dist/web-effect-trials.d.ts +86 -0
  209. package/dist/web-effect-trials.js +204 -0
  210. package/dist/web-effect-trials.js.map +1 -0
  211. package/dist/web-effect-upload.d.ts +29 -0
  212. package/dist/web-effect-upload.js +190 -0
  213. package/dist/web-effect-upload.js.map +1 -0
  214. package/dist/web-effect-worker.d.ts +8 -0
  215. package/dist/web-effect-worker.js +170 -0
  216. package/dist/web-effect-worker.js.map +1 -0
  217. package/dist/web-effect.d.ts +89 -0
  218. package/dist/web-effect.js +132 -0
  219. package/dist/web-effect.js.map +1 -0
  220. package/dist/web-identity-color.d.ts +87 -0
  221. package/dist/web-identity-color.js +209 -0
  222. package/dist/web-identity-color.js.map +1 -0
  223. package/dist/web-identity-palette.d.ts +113 -0
  224. package/dist/web-identity-palette.js +84 -0
  225. package/dist/web-identity-palette.js.map +1 -0
  226. package/dist/web-image-meta.d.ts +25 -0
  227. package/dist/web-image-meta.js +200 -0
  228. package/dist/web-image-meta.js.map +1 -0
  229. package/dist/web-mod-agent.d.ts +50 -0
  230. package/dist/web-mod-agent.js +203 -0
  231. package/dist/web-mod-agent.js.map +1 -0
  232. package/dist/web-mod-bundle.d.ts +144 -0
  233. package/dist/web-mod-bundle.js +464 -0
  234. package/dist/web-mod-bundle.js.map +1 -0
  235. package/dist/web-mod-export.d.ts +59 -0
  236. package/dist/web-mod-export.js +79 -0
  237. package/dist/web-mod-export.js.map +1 -0
  238. package/dist/web-mod-store.d.ts +283 -0
  239. package/dist/web-mod-store.js +624 -0
  240. package/dist/web-mod-store.js.map +1 -0
  241. package/dist/web-org.d.ts +3 -3
  242. package/dist/web-org.js +11 -17
  243. package/dist/web-org.js.map +1 -1
  244. package/dist/web-owner-css-store.d.ts +180 -0
  245. package/dist/web-owner-css-store.js +384 -0
  246. package/dist/web-owner-css-store.js.map +1 -0
  247. package/dist/web-owner-css.d.ts +58 -0
  248. package/dist/web-owner-css.js +552 -0
  249. package/dist/web-owner-css.js.map +1 -0
  250. package/dist/web-prefs.d.ts +48 -0
  251. package/dist/web-prefs.js +209 -33
  252. package/dist/web-prefs.js.map +1 -1
  253. package/dist/web-preview.d.ts +9 -1
  254. package/dist/web-preview.js +24 -7
  255. package/dist/web-preview.js.map +1 -1
  256. package/dist/web-settings-patch.d.ts +18 -0
  257. package/dist/web-settings-patch.js +51 -0
  258. package/dist/web-settings-patch.js.map +1 -1
  259. package/dist/web-shell-routes.d.ts +1 -1
  260. package/dist/web-shell-routes.js +1 -1
  261. package/dist/web-shell-routes.js.map +1 -1
  262. package/dist/web-theme-store.d.ts +218 -0
  263. package/dist/web-theme-store.js +516 -0
  264. package/dist/web-theme-store.js.map +1 -0
  265. package/dist/web-theme.d.ts +121 -0
  266. package/dist/web-theme.js +407 -0
  267. package/dist/web-theme.js.map +1 -0
  268. package/dist/web-widget.d.ts +72 -0
  269. package/dist/web-widget.js +245 -0
  270. package/dist/web-widget.js.map +1 -0
  271. package/package.json +1 -1
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
  /**
@@ -28,8 +34,8 @@ import { dashboardOriginFor } from "./web-preview.js";
28
34
  import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
29
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";
32
- import { WEB_NAME_FALLBACK } from "./web-profile.js";
37
+ import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
38
+ import { WEB_NAME_FALLBACK, webPersonKey } from "./web-profile.js";
33
39
  import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
34
40
  import { parseMentionQuery } from "./web-mentions.js";
35
41
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
@@ -150,6 +156,701 @@ function stillAuthorized(req, res, url, ctx) {
150
156
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
151
157
  return false;
152
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
+ }
153
854
  /**
154
855
  * #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
155
856
  * no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
@@ -465,6 +1166,110 @@ export function handleWebRequest(req, res, url, ctx) {
465
1166
  res.end(got.bytes);
466
1167
  return true;
467
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
+ });
1271
+ return true;
1272
+ }
468
1273
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
469
1274
  const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
470
1275
  if (method === "GET" && imageMatch) {
@@ -679,15 +1484,19 @@ export function handleWebRequest(req, res, url, ctx) {
679
1484
  json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
680
1485
  return true;
681
1486
  }
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.
1487
+ // Host preferences preserve their scope; only five declared owner slots use the current human session bucket.
685
1488
  if (path === "/ui/prefs" && method === "GET") {
686
1489
  if (!ctx.webPrefs) {
687
1490
  json(res, 404, { error: "Preferences are not kept on this fleet" });
688
1491
  return true;
689
1492
  }
690
- 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));
691
1500
  return true;
692
1501
  }
693
1502
  if (path === "/ui/prefs" && method === "POST") {
@@ -700,17 +1509,9 @@ export function handleWebRequest(req, res, url, ctx) {
700
1509
  json(res, 403, { error: "prefs_not_over_public_link" });
701
1510
  return true;
702
1511
  }
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) {
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) {
714
1515
  refuse(first);
715
1516
  return true;
716
1517
  }
@@ -734,21 +1535,39 @@ export function handleWebRequest(req, res, url, ctx) {
734
1535
  json(res, 400, { error: parsed.error });
735
1536
  return;
736
1537
  }
737
- const now = writer();
738
- if (now !== 200) {
1538
+ const now = prefsViewer(req, url, ctx);
1539
+ if (!now?.canWrite) {
739
1540
  refuse(now);
740
1541
  return;
741
1542
  }
742
- const onlyIfUnset = body.onlyIfUnset === true;
743
- 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" });
744
1561
  if (!written.ok) {
745
1562
  json(res, written.status, { error: written.error });
746
1563
  return;
747
1564
  }
748
- // 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);
749
1568
  if (written.changed.length)
750
1569
  ctx.emitSseEvent("status", ctx.getUiStatus());
751
- json(res, 200, { ...written.view, changed: written.changed });
1570
+ json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
752
1571
  })().catch(err => {
753
1572
  ctx.logger.error({ err: err.message }, "Saving web preferences failed");
754
1573
  try {
@@ -758,6 +1577,24 @@ export function handleWebRequest(req, res, url, ctx) {
758
1577
  });
759
1578
  return true;
760
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);
761
1598
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
762
1599
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
763
1600
  if (method === "POST" && path === "/ui/needs/ack") {
@@ -1830,23 +2667,42 @@ function serveMediaRange(req, res, files, id, file) {
1830
2667
  res.writeHead(ranged ? 206 : 200);
1831
2668
  res.end(got.bytes);
1832
2669
  }
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) {
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) {
1841
2683
  if (mode !== "full" || !ctx.webPrefs)
1842
2684
  return '<html lang="en">';
1843
- const view = ctx.webPrefs.read(OPERATOR);
2685
+ const view = prefsView(ctx.webPrefs, viewer);
1844
2686
  const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
1845
2687
  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 }))}">`;
2688
+ return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
1847
2689
  }
1848
- function shellBodyTag(req, ctx, mode) {
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;
2696
+ }
2697
+ function shellBodyTag(req, ctx, mode, viewer) {
1849
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
+ }
1850
2706
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1851
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)}"`
1852
2708
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
@@ -1863,6 +2719,8 @@ function shellBodyTag(req, ctx, mode) {
1863
2719
  + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
1864
2720
  // #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
1865
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)}"` : ""}`
1866
2724
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
1867
2725
  + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
1868
2726
  }
@@ -1871,6 +2729,7 @@ function shellBodyTag(req, ctx, mode) {
1871
2729
  export function serveAppShell(req, res, ctx, mode) {
1872
2730
  try {
1873
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;
1874
2733
  const p = shellPreview(req, ctx, mode);
1875
2734
  // #1589: media plays only from this listener's own file route (path-scoped, like frame-src), at the address this page
1876
2735
  // was loaded from (a Host the host guard accepted); never on the public link, nor where no chat is shown.
@@ -1879,8 +2738,9 @@ export function serveAppShell(req, res, ctx, mode) {
1879
2738
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
1880
2739
  // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
1881
2740
  // 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 });
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 });
1884
2744
  }
1885
2745
  catch {
1886
2746
  json(res, 500, { error: "app.html not found" });