@songsid/agend 2.3.3-beta.4 → 2.3.3-beta.5

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 (234) hide show
  1. package/dist/agent-cli.js +13 -112
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +0 -1
  4. package/dist/agent-endpoint.js +1 -9
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-whats-new.d.ts +3 -6
  7. package/dist/agent-whats-new.js +7 -18
  8. package/dist/agent-whats-new.js.map +1 -1
  9. package/dist/auth-api.d.ts +0 -7
  10. package/dist/auth-api.js +1 -15
  11. package/dist/auth-api.js.map +1 -1
  12. package/dist/backend/codex.d.ts +1 -0
  13. package/dist/backend/codex.js +106 -15
  14. package/dist/backend/codex.js.map +1 -1
  15. package/dist/backend/types.d.ts +17 -0
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/bundled-skills.d.ts +4 -6
  18. package/dist/bundled-skills.js +10 -14
  19. package/dist/bundled-skills.js.map +1 -1
  20. package/dist/channel/mcp-server.js +5 -5
  21. package/dist/channel/mcp-server.js.map +1 -1
  22. package/dist/channel/mcp-tools.d.ts +1 -3
  23. package/dist/channel/mcp-tools.js +4 -58
  24. package/dist/channel/mcp-tools.js.map +1 -1
  25. package/dist/cli.js +3 -1
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config-validator.js +6 -3
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/daemon.d.ts +11 -3
  30. package/dist/daemon.js +59 -107
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/dashboard-live.d.ts +1 -1
  33. package/dist/dashboard-live.js +3 -3
  34. package/dist/dashboard-live.js.map +1 -1
  35. package/dist/dashboard-permissions.d.ts +1 -1
  36. package/dist/dashboard-permissions.js +1 -3
  37. package/dist/dashboard-permissions.js.map +1 -1
  38. package/dist/fleet-manager.d.ts +26 -44
  39. package/dist/fleet-manager.js +70 -226
  40. package/dist/fleet-manager.js.map +1 -1
  41. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +11 -1
  42. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -0
  43. package/dist/general-knowledge/skills/web-appearance/SKILL.md +13 -204
  44. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +26 -63
  45. package/dist/instance-lifecycle.js +1 -1
  46. package/dist/instance-lifecycle.js.map +1 -1
  47. package/dist/locale.js +2 -0
  48. package/dist/locale.js.map +1 -1
  49. package/dist/outbound-handlers.js +19 -7
  50. package/dist/outbound-handlers.js.map +1 -1
  51. package/dist/outbound-schemas.d.ts +0 -62
  52. package/dist/outbound-schemas.js +2 -41
  53. package/dist/outbound-schemas.js.map +1 -1
  54. package/dist/public-web-gateway.js +0 -9
  55. package/dist/public-web-gateway.js.map +1 -1
  56. package/dist/public-web-link.d.ts +2 -0
  57. package/dist/public-web-link.js +5 -0
  58. package/dist/public-web-link.js.map +1 -1
  59. package/dist/search/search-docs.d.ts +1 -1
  60. package/dist/search/search-docs.js +2 -2
  61. package/dist/search/search-docs.js.map +1 -1
  62. package/dist/settings-api.d.ts +6 -0
  63. package/dist/settings-api.js +14 -2
  64. package/dist/settings-api.js.map +1 -1
  65. package/dist/settings-change.js +3 -1
  66. package/dist/settings-change.js.map +1 -1
  67. package/dist/tool-permissions.js +3 -10
  68. package/dist/tool-permissions.js.map +1 -1
  69. package/dist/topic-commands.d.ts +6 -2
  70. package/dist/topic-commands.js +2 -0
  71. package/dist/topic-commands.js.map +1 -1
  72. package/dist/types.d.ts +2 -1
  73. package/dist/ui/chat-ambient.js +4 -4
  74. package/dist/ui/chat-commands.js +3 -2
  75. package/dist/ui/chat-store.js +4 -1
  76. package/dist/ui/chat-strings.js +6 -42
  77. package/dist/ui/chat-thread.js +6 -44
  78. package/dist/ui/panel-chat.js +21 -50
  79. package/dist/ui/preview-panel.js +3 -15
  80. package/dist/ui/search-dialog.js +30 -14
  81. package/dist/ui/settings-appearance.js +1 -3
  82. package/dist/ui/settings-guests.js +13 -4
  83. package/dist/ui/settings-learn.js +1 -1
  84. package/dist/ui/settings-mods.js +5 -12
  85. package/dist/ui/settings-registry.js +0 -1
  86. package/dist/ui/settings-strings.js +6 -6
  87. package/dist/ui/shared/app-prefs.js +17 -17
  88. package/dist/ui/shared/app-shell.js +12 -8
  89. package/dist/ui/shared/app-store.js +6 -2
  90. package/dist/ui/shared/app.css +4 -36
  91. package/dist/ui/shared/app.js +5 -4
  92. package/dist/ui/shared/instance-nav.js +9 -9
  93. package/dist/ui/shared/instance-switch.js +8 -2
  94. package/dist/ui/shared/panel-view.js +22 -8
  95. package/dist/ui/shared/view-strings.js +6 -0
  96. package/dist/usage/usage-api.d.ts +3 -0
  97. package/dist/usage/usage-api.js +5 -1
  98. package/dist/usage/usage-api.js.map +1 -1
  99. package/dist/view-api.d.ts +2 -0
  100. package/dist/view-api.js +64 -0
  101. package/dist/view-api.js.map +1 -1
  102. package/dist/web-api.d.ts +9 -17
  103. package/dist/web-api.js +255 -371
  104. package/dist/web-api.js.map +1 -1
  105. package/dist/web-appearance-tools.d.ts +0 -2
  106. package/dist/web-appearance-tools.js +2 -5
  107. package/dist/web-appearance-tools.js.map +1 -1
  108. package/dist/web-appearance-view.d.ts +0 -6
  109. package/dist/web-appearance-view.js +0 -1
  110. package/dist/web-appearance-view.js.map +1 -1
  111. package/dist/web-appearance.d.ts +14 -7
  112. package/dist/web-appearance.js +5 -6
  113. package/dist/web-appearance.js.map +1 -1
  114. package/dist/web-asset-parts.d.ts +1 -17
  115. package/dist/web-asset-parts.js +1 -15
  116. package/dist/web-asset-parts.js.map +1 -1
  117. package/dist/web-auth.js +1 -7
  118. package/dist/web-auth.js.map +1 -1
  119. package/dist/web-chat-history.d.ts +3 -7
  120. package/dist/web-chat-history.js +10 -17
  121. package/dist/web-chat-history.js.map +1 -1
  122. package/dist/web-commands.d.ts +9 -1
  123. package/dist/web-commands.js +13 -0
  124. package/dist/web-commands.js.map +1 -1
  125. package/dist/web-experimental.d.ts +2 -11
  126. package/dist/web-experimental.js +8 -36
  127. package/dist/web-experimental.js.map +1 -1
  128. package/dist/web-guest-access.d.ts +40 -2
  129. package/dist/web-guest-access.js +50 -14
  130. package/dist/web-guest-access.js.map +1 -1
  131. package/dist/web-mod-bundle.d.ts +2 -45
  132. package/dist/web-mod-bundle.js +15 -63
  133. package/dist/web-mod-bundle.js.map +1 -1
  134. package/dist/web-mod-store.d.ts +3 -48
  135. package/dist/web-mod-store.js +30 -97
  136. package/dist/web-mod-store.js.map +1 -1
  137. package/dist/web-prefs.d.ts +13 -0
  138. package/dist/web-prefs.js +38 -0
  139. package/dist/web-prefs.js.map +1 -1
  140. package/dist/web-preview.d.ts +1 -11
  141. package/dist/web-preview.js +6 -20
  142. package/dist/web-preview.js.map +1 -1
  143. package/dist/web-session.d.ts +11 -3
  144. package/dist/web-session.js +22 -11
  145. package/dist/web-session.js.map +1 -1
  146. package/dist/web-settings-patch.d.ts +1 -0
  147. package/dist/web-settings-patch.js +6 -3
  148. package/dist/web-settings-patch.js.map +1 -1
  149. package/dist/web-settings-tools.d.ts +0 -13
  150. package/dist/web-settings-tools.js +12 -84
  151. package/dist/web-settings-tools.js.map +1 -1
  152. package/package.json +1 -1
  153. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +0 -376
  154. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +0 -672
  155. package/dist/ui/app-effects.js +0 -251
  156. package/dist/ui/chat-effect-posters.js +0 -42
  157. package/dist/ui/chat-effects.js +0 -167
  158. package/dist/ui/effect-trial-controller.js +0 -124
  159. package/dist/ui/effect-trial-offer.js +0 -2
  160. package/dist/ui/effect-trial-panel.js +0 -40
  161. package/dist/ui/effects-waker.js +0 -2
  162. package/dist/ui/settings-effects.js +0 -202
  163. package/dist/ui/settings-owner-effects.js +0 -82
  164. package/dist/ui/shared/effect-app-runtime.js +0 -299
  165. package/dist/ui/shared/effect-baseline.js +0 -64
  166. package/dist/ui/shared/effect-chat-runtime.js +0 -162
  167. package/dist/ui/shared/effect-cursor.js +0 -126
  168. package/dist/ui/shared/effect-display-metadata.js +0 -39
  169. package/dist/ui/shared/effect-frame.js +0 -146
  170. package/dist/ui/shared/effect-geometry.js +0 -137
  171. package/dist/ui/shared/effect-host-style.js +0 -10
  172. package/dist/ui/shared/effect-owner-regions.js +0 -74
  173. package/dist/ui/shared/effect-owner-source.js +0 -122
  174. package/dist/ui/shared/effect-policy.js +0 -151
  175. package/dist/ui/shared/effect-poster-reader.js +0 -98
  176. package/dist/ui/shared/effect-posters.js +0 -117
  177. package/dist/ui/shared/effect-profiler.js +0 -184
  178. package/dist/ui/shared/effect-profiles.js +0 -65
  179. package/dist/ui/shared/effect-registry.js +0 -545
  180. package/dist/ui/shared/effect-runtime-loader.js +0 -236
  181. package/dist/ui/shared/effect-safety.js +0 -153
  182. package/dist/ui/shared/effect-stock-snapshot.js +0 -43
  183. package/dist/ui/shared/effect-stock-trust.js +0 -217
  184. package/dist/ui/shared/effect-trial-shadow.js +0 -62
  185. package/dist/ui/shared/effect-trial-source.js +0 -103
  186. package/dist/ui/shared/effect-trial-stage.js +0 -170
  187. package/dist/ui/shared/effect-trial.css +0 -40
  188. package/dist/ui/shared/feature-flags.js +0 -127
  189. package/dist/ui/shared/owner-effect-prefs.js +0 -117
  190. package/dist/web-effect-bootstrap.d.ts +0 -12
  191. package/dist/web-effect-bootstrap.js +0 -49
  192. package/dist/web-effect-bootstrap.js.map +0 -1
  193. package/dist/web-effect-bundle.d.ts +0 -28
  194. package/dist/web-effect-bundle.js +0 -171
  195. package/dist/web-effect-bundle.js.map +0 -1
  196. package/dist/web-effect-canonical.d.ts +0 -8
  197. package/dist/web-effect-canonical.js +0 -27
  198. package/dist/web-effect-canonical.js.map +0 -1
  199. package/dist/web-effect-library.d.ts +0 -114
  200. package/dist/web-effect-library.js +0 -530
  201. package/dist/web-effect-library.js.map +0 -1
  202. package/dist/web-effect-playback.d.ts +0 -30
  203. package/dist/web-effect-playback.js +0 -71
  204. package/dist/web-effect-playback.js.map +0 -1
  205. package/dist/web-effect-poster.d.ts +0 -8
  206. package/dist/web-effect-poster.js +0 -52
  207. package/dist/web-effect-poster.js.map +0 -1
  208. package/dist/web-effect-profiles.d.ts +0 -118
  209. package/dist/web-effect-profiles.js +0 -181
  210. package/dist/web-effect-profiles.js.map +0 -1
  211. package/dist/web-effect-service.d.ts +0 -93
  212. package/dist/web-effect-service.js +0 -335
  213. package/dist/web-effect-service.js.map +0 -1
  214. package/dist/web-effect-tools.d.ts +0 -48
  215. package/dist/web-effect-tools.js +0 -129
  216. package/dist/web-effect-tools.js.map +0 -1
  217. package/dist/web-effect-trials.d.ts +0 -86
  218. package/dist/web-effect-trials.js +0 -204
  219. package/dist/web-effect-trials.js.map +0 -1
  220. package/dist/web-effect-upload.d.ts +0 -27
  221. package/dist/web-effect-upload.js +0 -185
  222. package/dist/web-effect-upload.js.map +0 -1
  223. package/dist/web-effect-worker.d.ts +0 -8
  224. package/dist/web-effect-worker.js +0 -170
  225. package/dist/web-effect-worker.js.map +0 -1
  226. package/dist/web-effect.d.ts +0 -103
  227. package/dist/web-effect.js +0 -153
  228. package/dist/web-effect.js.map +0 -1
  229. package/dist/web-experimental-http.d.ts +0 -2
  230. package/dist/web-experimental-http.js +0 -12
  231. package/dist/web-experimental-http.js.map +0 -1
  232. package/dist/web-mod-agent.d.ts +0 -50
  233. package/dist/web-mod-agent.js +0 -207
  234. package/dist/web-mod-agent.js.map +0 -1
package/dist/web-api.js CHANGED
@@ -1,8 +1,7 @@
1
- import { canvasWorkerEnabled, CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
2
- import { isCanvasWorkerHttpPath } from "./web-experimental-http.js";
1
+ import { CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
3
2
  import { webHumanOrigin } from "./human-delivery-receipts.js";
4
3
  import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
5
- import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
4
+ import { guestChatRefusal, guestCommandText, guestDeliveries, GUEST_PREF_MAX_BYTES, GUEST_SEARCHES, guestFrameData, guestHistory, guestMessages, guestOwnPrefs, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
6
5
  import { withSpawnHoldReply } from "./spawn-hold-reply.js";
7
6
  import { binaryProbe } from "./binary-probe.js";
8
7
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
@@ -14,7 +13,6 @@ import { buildModBundle } from "./web-mod-export.js";
14
13
  import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
15
14
  import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
16
15
  import { canonicalText } from "./web-theme.js";
17
- import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
18
16
  import { SKIN_WIRE_BYTES } from "./web-office-skins.js";
19
17
  import { gatewayRequestContext } from "./web-request-context.js";
20
18
  import { crossInstanceVisibility } from "./cross-instance-notice.js";
@@ -51,7 +49,6 @@ import { parseMentionQuery } from "./web-mentions.js";
51
49
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
52
50
  import { measureSyncWork } from "./sync-work-attribution.js";
53
51
  import { resolveTz } from "./tz-utils.js";
54
- import { filesPlayInChat } from "./web-effect.js";
55
52
  import { QUERY_MAX_CHARS, RESULT_MAX, SEARCH_SOURCES } from "./search/search-store.js";
56
53
  import { xmsgShown } from "./search/search-docs.js";
57
54
  import { WebSendDedupe } from "./web-send-dedupe.js";
@@ -319,6 +316,8 @@ export const SSE_HEARTBEAT_MS = 10_000;
319
316
  async function parseBody(req) {
320
317
  return JSON.parse((await readBoundedWebBody(req, 512 * 1024)).toString("utf8"));
321
318
  }
319
+ /** #2855: the guest accounts with a command in progress (one at a time each). */
320
+ const guestCommandsRunning = new Set();
322
321
  /** Send JSON response. */
323
322
  function json(res, status, data) {
324
323
  jsonText(res, status, JSON.stringify(data));
@@ -943,159 +942,6 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
943
942
  })().catch(fail);
944
943
  return true;
945
944
  }
946
- /** #1629: static current-placement posters, separate from the local human-only runtime API. */
947
- function handleEffectPosters(res, url, ctx, path, method) {
948
- res.setHeader("Cache-Control", "private, no-store");
949
- res.setHeader("X-Content-Type-Options", "nosniff");
950
- const image = /^\/ui\/effect-posters\/([0-9a-f]{32})\.png$/.exec(path);
951
- const names = [...url.searchParams.keys()], instance = url.searchParams.get("instance");
952
- if (method !== "GET" || path !== "/ui/effect-posters" && !image || names.length !== 1 || names[0] !== "instance" || !instance || instance.length > 128) {
953
- json(res, 404, { error: "not_found" });
954
- return true;
955
- }
956
- const service = ctx.effectService;
957
- if (!service) {
958
- json(res, 404, { error: "effects_unavailable" });
959
- return true;
960
- }
961
- if (image) {
962
- const r = service.placedPoster(image[1], instance);
963
- if (!r.ok)
964
- json(res, 404, { error: "not_found" });
965
- else {
966
- res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
967
- res.end(r.bytes);
968
- }
969
- }
970
- else {
971
- const result = service.posterPlacements(instance);
972
- json(res, result.error === "themes_unreadable" ? 503 : result.error ? 404 : 200, result);
973
- }
974
- return true;
975
- }
976
- /** #1629 Try: separate local-admin routes. These never borrow ordinary read-tier/placement authority. */
977
- function handleEffectTrials(req, res, url, ctx, path, method) {
978
- res.setHeader("Cache-Control", "private, no-store");
979
- res.setHeader("X-Content-Type-Options", "nosniff");
980
- const reading = method === "GET";
981
- if (gatewayRequestContext(req)) {
982
- json(res, reading ? 404 : 403, { error: "trials_not_over_public_link" });
983
- return true;
984
- }
985
- const who = () => {
986
- const sessions = ctx.webSessions;
987
- const v = evaluateWebRequest(req, url, ctx.webToken ?? null, sessions, { touch: false });
988
- if (v.kind !== "allow")
989
- return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
990
- if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
991
- return { error: "trials_need_a_local_admin", status: 403 };
992
- const person = webPersonKey(v.session.owner);
993
- let css = "unknown";
994
- try {
995
- const value = ctx.ownerCss?.effectState(person);
996
- if (value === "off" || value === "on")
997
- css = value;
998
- }
999
- catch { /* unavailable is never off */ }
1000
- // CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
1001
- const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
1002
- return { actor, record: v.session, sessions };
1003
- };
1004
- const first = who();
1005
- if ("error" in first) {
1006
- json(res, first.status, { error: first.error });
1007
- return true;
1008
- }
1009
- const service = ctx.effectService;
1010
- if (!service) {
1011
- json(res, 404, { error: "trials_unavailable" });
1012
- return true;
1013
- }
1014
- const start = path === "/ui/effect-trials/start";
1015
- const route = /^\/ui\/effect-trials\/([0-9a-f]{32})\/(resource|poster\.png|profile|stop)$/.exec(path);
1016
- const resource = reading && route && ["resource", "poster.png", "profile"].includes(route[2]);
1017
- const stop = method === "POST" && route?.[2] === "stop";
1018
- if (!(resource || stop || method === "POST" && start)) {
1019
- json(res, 404, { error: "not_found" });
1020
- return true;
1021
- }
1022
- const allowed = resource ? ["ownerEpoch", "instance"] : ["ownerEpoch"];
1023
- if ([...url.searchParams.keys()].some(k => !allowed.includes(k)) || allowed.some(k => url.searchParams.getAll(k).length !== 1)
1024
- || url.searchParams.get("ownerEpoch") !== first.actor.epoch) {
1025
- json(res, reading ? 404 : 409, { error: reading ? "not_found" : "trials_person_changed" });
1026
- return true;
1027
- }
1028
- const instance = url.searchParams.get("instance") ?? "";
1029
- const answer = (result) => json(res, result.error ? result.error === "not_found" ? 404
1030
- : ["profiles_unreadable", "profiles_busy", "profiles_write_failed"].includes(String(result.error)) ? 503 : 400 : 200, result);
1031
- if (resource) {
1032
- if (route[2] === "poster.png") {
1033
- const result = service.trialPoster(route[1], instance, first.actor);
1034
- if (!result.ok)
1035
- json(res, 404, { error: "not_found" });
1036
- else {
1037
- res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
1038
- res.end(result.bytes);
1039
- }
1040
- }
1041
- else
1042
- answer(route[2] === "profile" ? service.trialProfile(route[1], instance, first.actor) : service.trialResource(route[1], instance, first.actor));
1043
- return true;
1044
- }
1045
- (async () => {
1046
- let bytes;
1047
- try {
1048
- bytes = await readBoundedWebBody(req, 16 * 1024);
1049
- }
1050
- catch {
1051
- json(res, 413, { error: "trial_body_bound" });
1052
- return;
1053
- }
1054
- if (!permitWebContinuation(req, res, ctx))
1055
- return;
1056
- const now = who();
1057
- if ("error" in now) {
1058
- json(res, now.status, { error: now.error });
1059
- return;
1060
- }
1061
- if (!canvasWorkerEnabled(ctx.fleetConfig?.web)) {
1062
- json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
1063
- return;
1064
- }
1065
- if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
1066
- || now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
1067
- json(res, 409, { error: "trials_person_changed" });
1068
- return;
1069
- }
1070
- let body;
1071
- try {
1072
- body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
1073
- }
1074
- catch {
1075
- json(res, 400, { error: "trial_json" });
1076
- return;
1077
- }
1078
- if (start) {
1079
- const parsed = z.strictObject({ offer: z.string().regex(/^[0-9a-f]{32}$/), instance: z.string().min(1).max(128) }).safeParse(body);
1080
- if (!parsed.success) {
1081
- json(res, 400, { error: "trial_args" });
1082
- return;
1083
- }
1084
- answer(service.trialStart(parsed.data.offer, parsed.data.instance, now.actor));
1085
- }
1086
- else {
1087
- if (!z.strictObject({}).safeParse(body).success) {
1088
- json(res, 400, { error: "trial_args" });
1089
- return;
1090
- }
1091
- answer(service.trialStop(route[1], now.actor));
1092
- }
1093
- })().catch(() => { try {
1094
- json(res, 500, { error: "trial_request_failed" });
1095
- }
1096
- catch { /* response ended */ } });
1097
- return true;
1098
- }
1099
945
  /** #2599: the same closed browser controls as setup, for a current local human admin. */
1100
946
  function handleBrowserBackendSignIns(req, res, url, ctx) {
1101
947
  res.setHeader("Cache-Control", "private, no-store");
@@ -1291,148 +1137,6 @@ function handleBackendLogin(req, res, url, ctx, path, method) {
1291
1137
  catch { /* response ended */ } });
1292
1138
  return true;
1293
1139
  }
1294
- /** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
1295
- function handleEffects(req, res, url, ctx, path, method) {
1296
- res.setHeader("Cache-Control", "private, no-store");
1297
- res.setHeader("X-Content-Type-Options", "nosniff");
1298
- const reading = method === "GET";
1299
- // Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
1300
- const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
1301
- const gateway = gatewayRequestContext(req), guest = guestRequestOf(req);
1302
- const instanceQuery = url.searchParams.get("instance"), keys = [...url.searchParams.keys()];
1303
- const chatRead = reading && (path === "/ui/effects/placements" || /^\/ui\/effects\/resource\/[0-9a-f]{32}$/.test(path))
1304
- && keys.length === 2 && keys.includes("instance") && keys.includes("ownerEpoch") && !!instanceQuery && instanceQuery.length <= 128;
1305
- // Public viewing grants current Chat reads, never ordinary library/owner/Trial access or writes.
1306
- if (gateway && (!chatRead || !gateway.isCurrent())) {
1307
- json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
1308
- return true;
1309
- }
1310
- const who = () => {
1311
- const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1312
- if (verdict.kind !== "allow")
1313
- return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
1314
- if (verdict.via !== "session" || !verdict.session)
1315
- return { status: 403, error: "effects_need_a_signed_in_person" };
1316
- if (!reading && !profilePost && verdict.session.tier !== "admin")
1317
- return { status: 403, error: "effects_read_only" };
1318
- if (verdict.session.tier === "guest" && (!chatRead || !guest || !guest.current() || !guest.scope().has(instanceQuery)))
1319
- return { status: 404, error: "not_found" };
1320
- return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle, guest: verdict.session.tier === "guest" };
1321
- };
1322
- const first = who();
1323
- if ("status" in first) {
1324
- json(res, first.status, { error: first.error });
1325
- return true;
1326
- }
1327
- const service = ctx.effectService;
1328
- if (!service) {
1329
- json(res, 404, { error: "effects_unavailable" });
1330
- return true;
1331
- }
1332
- const unavailable = new Set(["themes_unreadable", "profiles_unreadable", "library_unreadable", "references_unreadable", "library_busy", "profiles_busy", "profiles_write_failed", "library_write_failed", "prefs_unreadable"]);
1333
- 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);
1334
- const idPath = /^\/ui\/effects\/(resource|profile)\/([0-9a-f]{32})$/.exec(path);
1335
- const instance = url.searchParams.get("instance") ?? undefined, slot = url.searchParams.get("slot");
1336
- if (slot !== null && instance !== undefined) {
1337
- json(res, 404, { error: "not_found" });
1338
- return true;
1339
- }
1340
- const runtimeRead = reading && (slot !== null || path === "/ui/effects/owner" || path === "/ui/effects/placements" || idPath?.[1] === "resource" || instance !== undefined && /^\/ui\/effects\/image\//.test(path));
1341
- if ((runtimeRead || profilePost) && (url.searchParams.getAll("ownerEpoch").length !== 1 || url.searchParams.get("ownerEpoch") !== first.epoch)) {
1342
- const privateResource = reading && (slot !== null || idPath?.[1] === "resource" || /^\/ui\/effects\/image\//.test(path));
1343
- json(res, privateResource ? 404 : 409, { error: privateResource ? "not_found" : "effects_person_changed" });
1344
- return true;
1345
- }
1346
- if (reading) {
1347
- const image = /^\/ui\/effects\/image\/([0-9a-f]{32})\/(poster|cursor)\.png$/.exec(path);
1348
- if (image) {
1349
- const r = slot === null ? service.image(first.person, image[1], image[2], instance) : service.ownerImage(first.person, image[1], image[2], slot);
1350
- if (!r.ok)
1351
- json(res, 404, { error: "not_found" });
1352
- else {
1353
- res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
1354
- res.end(r.bytes);
1355
- }
1356
- return true;
1357
- }
1358
- if (path === "/ui/effects")
1359
- answer({ ...service.list(first.person), canWrite: first.canWrite });
1360
- else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
1361
- answer(service.ownerSelections(first.person));
1362
- else if (path === "/ui/effects/placements" && instance !== undefined)
1363
- answer(service.placements(first.person, instance, !!gateway || first.guest));
1364
- else if (idPath?.[1] === "resource")
1365
- answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
1366
- else if (idPath?.[1] === "profile" && instance === undefined)
1367
- answer(service.profile(first.person, idPath[2]));
1368
- else
1369
- json(res, 404, { error: "not_found" });
1370
- return true;
1371
- }
1372
- const deletion = /^\/ui\/effects\/([0-9a-f]{32})$/.exec(path);
1373
- if (method === "DELETE" && deletion) {
1374
- answer(service.delete(first.person, deletion[1]));
1375
- return true;
1376
- }
1377
- if (method !== "POST" || !["/ui/effects/upload", "/ui/effects/apply", "/ui/effects/remove", "/ui/effects/share"].includes(path) && idPath?.[1] !== "profile") {
1378
- json(res, 404, { error: "not_found" });
1379
- return true;
1380
- }
1381
- (async () => {
1382
- let bytes;
1383
- try {
1384
- bytes = await readBoundedWebBody(req, path === "/ui/effects/upload" ? EFFECT_WIRE_LIMIT : 16 * 1024);
1385
- }
1386
- catch {
1387
- json(res, 413, { error: "effect_body_bound" });
1388
- return;
1389
- }
1390
- if (!permitWebContinuation(req, res, ctx))
1391
- return;
1392
- const now = who();
1393
- if ("status" in now) {
1394
- json(res, now.status, { error: now.error });
1395
- return;
1396
- }
1397
- if (!canvasWorkerEnabled(ctx.fleetConfig?.web)) {
1398
- json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
1399
- return;
1400
- }
1401
- if (now.person !== first.person || now.epoch !== first.epoch) {
1402
- json(res, 409, { error: "effects_person_changed" });
1403
- return;
1404
- }
1405
- if (path === "/ui/effects/upload") {
1406
- answer(service.upload(now.person, bytes));
1407
- return;
1408
- }
1409
- let body;
1410
- try {
1411
- body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
1412
- }
1413
- catch {
1414
- json(res, 400, { error: "effect_json" });
1415
- return;
1416
- }
1417
- if (path === "/ui/effects/apply")
1418
- answer(service.apply(now.person, body));
1419
- else if (path === "/ui/effects/remove")
1420
- answer(service.remove(now.person, body));
1421
- else if (path === "/ui/effects/share")
1422
- answer(service.share(now.person, body));
1423
- else {
1424
- if (body && typeof body === "object" && body.kind === "playback" && (url.searchParams.getAll("instance").length !== 1 || slot !== null)) {
1425
- json(res, 400, { error: "playback_report" });
1426
- return;
1427
- }
1428
- answer(service.report(now.person, idPath[2], body, instance, now.epoch));
1429
- }
1430
- })().catch(() => { try {
1431
- json(res, 500, { error: "effect_request_failed" });
1432
- }
1433
- catch { /* response ended */ } });
1434
- return true;
1435
- }
1436
1140
  /**
1437
1141
  * #1629 r4 §4 (r4-5): mods — a person's import (stage, then commit), uninstall and export. Every route is a signed-in
1438
1142
  * session's, writes an admin's: never the CLI's header token (the way an agent could reach it) and never the public link
@@ -1515,18 +1219,13 @@ function handleMods(req, res, url, ctx, path, method) {
1515
1219
  json(res, 404, { error: "no_such_mod" });
1516
1220
  return true;
1517
1221
  }
1518
- // The file is the installed bundle, effects and all — also effects its import left out, which nobody installed.
1519
- // So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
1520
- // read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
1222
+ // The file is the installed bundle, effects and all. #2799: a file that carries Canvas Worker effects (or can't be
1223
+ // read to tell) is not handed out; one without is any admin's.
1521
1224
  const fx = mods.bundleEffects(fileMatch[1]);
1522
- if (!canvasWorkerEnabled(ctx.fleetConfig?.web) && (fx === "unknown" || (fx?.length ?? 0) > 0)) {
1225
+ if (fx === "unknown" || (fx?.length ?? 0) > 0) {
1523
1226
  json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
1524
1227
  return true;
1525
1228
  }
1526
- if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
1527
- 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" });
1528
- return true;
1529
- }
1530
1229
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
1531
1230
  "Content-Disposition": `attachment; filename="${rec.name}-${rec.version}.agendmod.json"` });
1532
1231
  res.end(text);
@@ -1650,14 +1349,9 @@ export function exportMod(ctx, person, body) {
1650
1349
  const store = ctx.themeStore;
1651
1350
  const rec = store.get(name);
1652
1351
  if (inc.theme === true) {
1653
- // The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
1654
- // Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
1655
- // #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
1656
- const effects = (canvasWorkerEnabled(ctx.fleetConfig?.web) ? rec.placements.effects : []).flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
1657
- omitted++;
1658
- return [];
1659
- } return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
1660
- 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 };
1352
+ // #2799: Canvas Worker effects were removed; an older placement travels nowhere (it is counted as left out).
1353
+ omitted += rec.placements.effects.length;
1354
+ 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: [] };
1661
1355
  }
1662
1356
  if (inc.identity === true)
1663
1357
  parts.identity = ctx.modCardText?.(name) ?? {};
@@ -1730,6 +1424,109 @@ export function webPeerOf(req) {
1730
1424
  const gateway = gatewayRequestContext(req);
1731
1425
  return gateway ? (gateway.named === true ? "named_link" : "quick_link") : classifyWebPeer(req);
1732
1426
  }
1427
+ /** #2855: the guest accounts with a search running, and how many guest searches run in all (GUEST_SEARCHES). */
1428
+ const guestSearchesRunning = new Set();
1429
+ let guestSearchesInFlight = 0;
1430
+ /**
1431
+ * #2855: a trusted guest's search — chat messages of its in-scope agents that it can see now (guestSeesMessage, on the
1432
+ * history's own row: one no longer kept is not shown, nothing else is), at most GUEST_SEARCHES.maxResults. Per account
1433
+ * GUEST_SEARCHES a minute and one at a time; at most GUEST_SEARCHES.global guest searches running in all, so the
1434
+ * index's queue always has room for the admins'. Its context: the same rows only.
1435
+ */
1436
+ function guestSearch(req, res, url, ctx, guest, path) {
1437
+ const refusal = () => {
1438
+ if (!guest.current())
1439
+ return [401, WEB_TOKEN_INVALID_MESSAGE];
1440
+ if (!guest.trusted?.())
1441
+ return [403, "guest_not_trusted"];
1442
+ if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.search?.public_link === false)
1443
+ return [404, "search_not_over_public_link"];
1444
+ if (ctx.fleetConfig?.web?.search?.enabled === false || !ctx.searchIndex || ctx.searchIndex.status() === "unavailable")
1445
+ return [404, "search_unavailable"];
1446
+ return null;
1447
+ };
1448
+ const first = refusal();
1449
+ if (first) {
1450
+ json(res, first[0], { error: first[1] });
1451
+ return;
1452
+ }
1453
+ const account = `${guest.owner.adapterId}:${guest.owner.userId}`;
1454
+ const limit = ctx.guestSearches?.check(account);
1455
+ if (limit && !limit.ok) {
1456
+ res.setHeader("Retry-After", String(limit.retry_after_s));
1457
+ json(res, 429, { error: "guest_search_rate_limited", retry_after_s: limit.retry_after_s });
1458
+ return;
1459
+ }
1460
+ if (guestSearchesRunning.has(account)) {
1461
+ json(res, 429, { error: "guest_search_running" });
1462
+ return;
1463
+ }
1464
+ if (guestSearchesInFlight >= GUEST_SEARCHES.global) {
1465
+ res.setHeader("Retry-After", "2");
1466
+ json(res, 503, { error: "search_busy" });
1467
+ return;
1468
+ }
1469
+ const index = ctx.searchIndex, history = ctx.webChatHistory;
1470
+ // What this guest may see of one indexed chat row: its history row, now, by the guest rule; else nothing.
1471
+ const visible = (hit) => {
1472
+ if (hit.source !== "chat" || !guest.scope().has(hit.instance) || !history)
1473
+ return false;
1474
+ const row = history.find(hit.instance, String(hit.ref.boot ?? ""), Number(hit.ref.id));
1475
+ return !!row && guestSeesMessage(row, guest, guest.scope());
1476
+ };
1477
+ const finish = (status, body) => { const now = refusal(); if (now)
1478
+ json(res, now[0], { error: now[1] });
1479
+ else
1480
+ json(res, status, body); };
1481
+ const failed = (reason) => finish(reason === "timeout" || reason === "busy" ? 503 : 404, { error: `search_${reason}` });
1482
+ let run;
1483
+ if (path === "/ui/search/context") {
1484
+ const key = url.searchParams.get("key") ?? "";
1485
+ if (!key || key.length > 300) {
1486
+ json(res, 400, { error: "key required" });
1487
+ return;
1488
+ }
1489
+ run = index.context(key).then(r => {
1490
+ if (!r.ok)
1491
+ return failed(r.reason);
1492
+ const value = r.value;
1493
+ if (!value?.doc || !visible(value.doc))
1494
+ return finish(404, { error: "not_found" });
1495
+ finish(200, { doc: value.doc, before: value.before.filter(visible), after: value.after.filter(visible), building: r.building });
1496
+ }, () => failed("unavailable"));
1497
+ }
1498
+ else {
1499
+ // Never a team (whose members, and whether it exists, are the admin's) or "only mine" (an admin's own messages).
1500
+ const own = new URL(url.href);
1501
+ own.searchParams.delete("team");
1502
+ own.searchParams.delete("mine");
1503
+ const parsed = parseSearchQuery(own, ctx);
1504
+ if ("error" in parsed) {
1505
+ json(res, 400, { error: parsed.error });
1506
+ return;
1507
+ }
1508
+ const scope = guest.scope();
1509
+ const asked = parsed.filters.instances?.filter(n => scope.has(n)) ?? [...scope];
1510
+ if (!asked.length) {
1511
+ json(res, 200, { hits: [], limited: false });
1512
+ return;
1513
+ }
1514
+ // Chat only, its scope only; more asked of the index than shown, since rows it can't see are left out after.
1515
+ run = index.query({ q: parsed.filters.q, instances: asked, sources: ["chat"], limit: SEARCH_LIMIT_DEFAULT,
1516
+ ...(parsed.filters.since !== undefined ? { since: parsed.filters.since } : {}), ...(parsed.filters.until !== undefined ? { until: parsed.filters.until } : {}) }).then(r => {
1517
+ if (!r.ok)
1518
+ return failed(r.reason);
1519
+ const hits = r.value.hits.filter(visible);
1520
+ finish(200, { hits: hits.slice(0, GUEST_SEARCHES.maxResults).map(h => ({ ...h, live: true })), limited: r.value.limited || hits.length > GUEST_SEARCHES.maxResults, building: r.building });
1521
+ }, () => failed("unavailable"));
1522
+ }
1523
+ ctx.guestSearches?.record(account);
1524
+ guestSearchesRunning.add(account);
1525
+ guestSearchesInFlight++;
1526
+ void run.catch(() => { if (!res.headersSent)
1527
+ json(res, 500, { error: "search_failed" }); })
1528
+ .finally(() => { guestSearchesRunning.delete(account); guestSearchesInFlight--; });
1529
+ }
1733
1530
  /**
1734
1531
  * #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
1735
1532
  * header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
@@ -1913,10 +1710,6 @@ function statusTick(ctx, t) {
1913
1710
  export function handleWebRequest(req, res, url, ctx) {
1914
1711
  const path = url.pathname;
1915
1712
  const method = req.method ?? "GET";
1916
- if (!canvasWorkerEnabled(ctx.fleetConfig?.web) && isCanvasWorkerHttpPath(path)) {
1917
- json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
1918
- return true;
1919
- }
1920
1713
  // Auth check for all /ui routes. Defence in depth behind the fleet-manager
1921
1714
  // gate: the same session cookie or header token, and an unset token closes
1922
1715
  // the panel instead of comparing null against a missing credential.
@@ -2192,7 +1985,10 @@ export function handleWebRequest(req, res, url, ctx) {
2192
1985
  const guest = guestRequestOf(req);
2193
1986
  const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope())
2194
1987
  : visibleMessages(ctx, !!gatewayRequestContext(req), ctx.webChatHistory.list(instance, limit));
2195
- json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1988
+ // #2848: the chat has messages, none of them a guest's to see (all between agents, or kept before they were marked):
1989
+ // said, so its empty chat is explained rather than blank. Only that there are some — never how many, or what.
1990
+ const allHidden = !!guest && messages.length === 0 && (ctx.webChatHistory?.list(instance, 1).length ?? 0) > 0;
1991
+ json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0, ...(allHidden ? { allHidden: true } : {}) });
2196
1992
  return true;
2197
1993
  }
2198
1994
  // ── #1218: the Log tab ─────────────────────────────────
@@ -2856,6 +2652,13 @@ export function handleWebRequest(req, res, url, ctx) {
2856
2652
  json(res, 404, { error: "Preferences are not kept on this fleet" });
2857
2653
  return true;
2858
2654
  }
2655
+ // #2855: a guest saves its own preferences (GUEST_OWN_PREF_KEYS) into its own account's bucket — on any listener,
2656
+ // the public link too, since nothing of the host's is written. At most GUEST_PREF_WRITES a minute per account.
2657
+ const guest = guestRequestOf(req);
2658
+ if (guest) {
2659
+ saveGuestPrefs(req, res, ctx, prefs, guest);
2660
+ return true;
2661
+ }
2859
2662
  if (gatewayRequestContext(req)) {
2860
2663
  json(res, 403, { error: "prefs_not_over_public_link" });
2861
2664
  return true;
@@ -2896,30 +2699,16 @@ export function handleWebRequest(req, res, url, ctx) {
2896
2699
  return;
2897
2700
  }
2898
2701
  const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
2899
- if (personal && !canvasWorkerEnabled(ctx.fleetConfig?.web)) {
2702
+ // #2799: a person's own effect selections were Canvas Worker effects' (removed): refused, as before.
2703
+ if (personal) {
2900
2704
  json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
2901
2705
  return;
2902
2706
  }
2903
- if (personal && now.person === null) {
2904
- json(res, 403, { error: "prefs_need_a_signed_in_person" });
2905
- return;
2906
- }
2907
- // A scope marker rejects a queued old page after a different login, including the same person's new session.
2908
- // It supplies neither identity nor authority; the current verified session still chooses the bucket.
2909
- if (personal && body.ownerEpoch !== now.epoch) {
2910
- json(res, 409, { error: "prefs_person_changed" });
2911
- return;
2912
- }
2913
- const before = prefs.readForViewer(now.person);
2914
- const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true,
2915
- validateEffect: (person, key, id) => ctx.effectService ? ctx.effectService.validateOwnerSelection(person, key, id) : "effects_unavailable" });
2707
+ const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true, validateEffect: () => "effects_unavailable" });
2916
2708
  if (!written.ok) {
2917
2709
  json(res, written.status, { error: written.error });
2918
2710
  return;
2919
2711
  }
2920
- const dropped = written.changed.filter(isOwnerEffectKey).flatMap(key => typeof before.values[key] === "string" && before.values[key] !== written.view.values[key] ? [before.values[key]] : []);
2921
- if (dropped.length)
2922
- ctx.effectService?.collectOwnerReplaced(dropped);
2923
2712
  if (written.changed.length)
2924
2713
  ctx.emitSseEvent("status", ctx.getUiStatus());
2925
2714
  json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
@@ -2945,19 +2734,11 @@ export function handleWebRequest(req, res, url, ctx) {
2945
2734
  // #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
2946
2735
  if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
2947
2736
  return handleMods(req, res, url, ctx, path, method);
2948
- // Posters remain behind the common session gate, with a closed GET-only public manifest.
2949
- if (path === "/ui/effect-posters" || path.startsWith("/ui/effect-posters/"))
2950
- return handleEffectPosters(res, url, ctx, path, method);
2951
- // Canvas effects are absent from the public gateway manifest and independently refused above.
2952
- if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
2953
- return handleEffectTrials(req, res, url, ctx, path, method);
2954
2737
  // #2233: backend sign-in — absent from the public manifest and the guest table, and refused here too.
2955
2738
  if (path === "/ui/browser-backend-signins")
2956
2739
  return handleBrowserBackendSignIns(req, res, url, ctx);
2957
2740
  if (path === "/ui/backend-login" || path.startsWith("/ui/backend-login/"))
2958
2741
  return handleBackendLogin(req, res, url, ctx, path, method);
2959
- if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
2960
- return handleEffects(req, res, url, ctx, path, method);
2961
2742
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
2962
2743
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
2963
2744
  if (method === "POST" && path === "/ui/needs/ack") {
@@ -3052,6 +2833,21 @@ export function handleWebRequest(req, res, url, ctx) {
3052
2833
  return true;
3053
2834
  }
3054
2835
  const run = ctx.webCommand.bind(ctx);
2836
+ // #2855: a guest — an in-scope agent, while guests may chat, the table's "anyone" commands only (runWebCommand), at
2837
+ // most GUEST_COMMAND_RUNS a minute per account and one at a time.
2838
+ const guest = guestRequestOf(req), account = guest ? `${guest.owner.adapterId}:${guest.owner.userId}` : "";
2839
+ if (guest) {
2840
+ const limit = ctx.guestCommandRuns?.check(account);
2841
+ if (limit && !limit.ok) {
2842
+ res.setHeader("Retry-After", String(limit.retry_after_s));
2843
+ json(res, 429, { error: "guest_commands_rate_limited", retry_after_s: limit.retry_after_s });
2844
+ return true;
2845
+ }
2846
+ if (guestCommandsRunning.has(account)) {
2847
+ json(res, 429, { error: "guest_command_running" });
2848
+ return true;
2849
+ }
2850
+ }
3055
2851
  const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
3056
2852
  (async () => {
3057
2853
  let body;
@@ -3081,9 +2877,39 @@ export function handleWebRequest(req, res, url, ctx) {
3081
2877
  const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
3082
2878
  if (!person)
3083
2879
  return;
3084
- const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
3085
- const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority });
3086
- json(res, r.status, r.body);
2880
+ if (guest) {
2881
+ if (!guest.current()) {
2882
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2883
+ return;
2884
+ }
2885
+ const refused = guestChatRefusal(guest, instance);
2886
+ if (refused) {
2887
+ json(res, refused.status, { error: refused.error });
2888
+ return;
2889
+ }
2890
+ if (guestCommandsRunning.has(account)) {
2891
+ json(res, 429, { error: "guest_command_running" });
2892
+ return;
2893
+ }
2894
+ // The budget again, now (#2857 review): commands that ran while this body was on its way have counted.
2895
+ const budget = ctx.guestCommandRuns?.check(account);
2896
+ if (budget && !budget.ok) {
2897
+ res.setHeader("Retry-After", String(budget.retry_after_s));
2898
+ json(res, 429, { error: "guest_commands_rate_limited", retry_after_s: budget.retry_after_s });
2899
+ return;
2900
+ }
2901
+ guestCommandsRunning.add(account);
2902
+ ctx.guestCommandRuns?.record(account);
2903
+ }
2904
+ try {
2905
+ const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
2906
+ const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority, ...(guest ? { guest: true } : {}) });
2907
+ json(res, r.status, r.body);
2908
+ }
2909
+ finally {
2910
+ if (guest)
2911
+ guestCommandsRunning.delete(account);
2912
+ }
3087
2913
  })().catch(err => {
3088
2914
  ctx.logger.error({ err: err.message }, "Web command failed");
3089
2915
  try {
@@ -3708,11 +3534,15 @@ export function handleWebRequest(req, res, url, ctx) {
3708
3534
  // #2478 (the user's request): over the public link too, unless web.search.public_link is false. Asked on arrival and
3709
3535
  // again before the answer is sent.
3710
3536
  if (method === "GET" && (path === "/ui/search" || path === "/ui/search/context")) {
3537
+ // #2855: a trusted guest searches only what it can see (guestSearch).
3538
+ const searchGuest = guestRequestOf(req);
3539
+ if (searchGuest) {
3540
+ guestSearch(req, res, url, ctx, searchGuest, path);
3541
+ return true;
3542
+ }
3711
3543
  const refusal = () => {
3712
3544
  if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.search?.public_link === false)
3713
3545
  return [404, "search_not_over_public_link"];
3714
- if (guestRequestOf(req))
3715
- return [403, "guest_forbidden"];
3716
3546
  if (ctx.fleetConfig?.web?.search?.enabled === false || !ctx.searchIndex || ctx.searchIndex.status() === "unavailable")
3717
3547
  return [404, "search_unavailable"];
3718
3548
  return null;
@@ -4123,7 +3953,7 @@ function handleSendMessage(req, res, ctx) {
4123
3953
  let quoted;
4124
3954
  if (replyTo) {
4125
3955
  quoted = ctx.webChatHistory?.find(instance, replyTo.boot, replyTo.id);
4126
- if (!quoted || quoted.effectTrial || (guest && !guestSeesMessage(quoted, guest, guest.scope()))) {
3956
+ if (!quoted || (guest && !guestSeesMessage(quoted, guest, guest.scope()))) {
4127
3957
  json(res, 409, { error: t("web.reply_gone"), reason: "reply_gone" });
4128
3958
  return;
4129
3959
  }
@@ -4535,6 +4365,64 @@ function serveMediaRange(req, res, files, id, file) {
4535
4365
  res.writeHead(ranged ? 206 : 200);
4536
4366
  res.end(got.bytes);
4537
4367
  }
4368
+ /** #2855: a guest's own preferences — its own keys, its own bucket, favourites only in its scope. */
4369
+ function saveGuestPrefs(req, res, ctx, prefs, guest) {
4370
+ const person = webPersonKey(guest.owner), account = `${guest.owner.adapterId}:${guest.owner.userId}`;
4371
+ const limit = ctx.guestPrefWrites?.check(account);
4372
+ if (limit && !limit.ok) {
4373
+ res.setHeader("Retry-After", String(limit.retry_after_s));
4374
+ json(res, 429, { error: "guest_prefs_rate_limited", retry_after_s: limit.retry_after_s });
4375
+ return;
4376
+ }
4377
+ // Too large by its own declared length: answered before a byte is read (the body is drained, not cut off).
4378
+ if (Number(req.headers["content-length"]) > GUEST_PREF_MAX_BYTES) {
4379
+ req.resume();
4380
+ json(res, 413, { error: "guest_prefs_too_large", detail: "at most 16 KB" });
4381
+ return;
4382
+ }
4383
+ (async () => {
4384
+ let body;
4385
+ try {
4386
+ body = JSON.parse((await readBoundedWebBody(req, GUEST_PREF_MAX_BYTES)).toString("utf8"));
4387
+ }
4388
+ catch {
4389
+ json(res, 400, { error: "Invalid JSON, or over 16 KB" });
4390
+ return;
4391
+ }
4392
+ if (!permitWebContinuation(req, res, ctx))
4393
+ return;
4394
+ if (!guest.current()) {
4395
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
4396
+ return;
4397
+ }
4398
+ if (!body || typeof body !== "object" || Array.isArray(body) || !body.set || typeof body.set !== "object" || Array.isArray(body.set)) {
4399
+ json(res, 400, { error: "set required" });
4400
+ return;
4401
+ }
4402
+ const set = body.set, scope = guest.scope();
4403
+ const favourites = set["nav.favorites"];
4404
+ if (Array.isArray(favourites)) {
4405
+ const outside = favourites.find((n) => typeof n !== "string" || !scope.has(n));
4406
+ if (outside !== undefined) {
4407
+ json(res, 400, { error: `not_in_scope: ${String(outside).slice(0, 64)}` });
4408
+ return;
4409
+ }
4410
+ }
4411
+ const written = prefs.writeForGuest(person, new Map(Object.entries(set)), { onlyIfUnset: body.onlyIfUnset === true });
4412
+ if (!written.ok) {
4413
+ json(res, written.status, { error: written.error });
4414
+ return;
4415
+ }
4416
+ ctx.guestPrefWrites?.record(account);
4417
+ json(res, 200, { ...guestOwnPrefs(prefs.readForViewer(person), prefs.readGuestOwn(person), scope), ownerScope: "person", ownerEpoch: null, changed: written.changed });
4418
+ })().catch(err => {
4419
+ ctx.logger.error({ err: err.message }, "Saving a guest's web preferences failed");
4420
+ try {
4421
+ json(res, 500, { error: "Saving preferences failed" });
4422
+ }
4423
+ catch { /* already answered */ }
4424
+ });
4425
+ }
4538
4426
  function prefsViewer(req, url, ctx) {
4539
4427
  const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
4540
4428
  if (v.kind !== "allow")
@@ -4543,6 +4431,12 @@ function prefsViewer(req, url, ctx) {
4543
4431
  // it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
4544
4432
  // scope. Its account (phase 3) names it on its page.
4545
4433
  const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true, ...(v.session.owner ? { owner: v.session.owner } : {}) } : {};
4434
+ // #2855: a guest reads its own preferences (its account's bucket) on every listener, the public link too.
4435
+ if (v.via === "session" && v.session?.tier === "guest" && v.session.owner) {
4436
+ const g = guestRequestOf(req);
4437
+ return { person: webPersonKey(v.session.owner), epoch: v.session.handle, scope: "person", canWrite: false, via: "session", ...guest,
4438
+ guestScope: () => g?.scope() ?? new Set() };
4439
+ }
4546
4440
  if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
4547
4441
  const person = webPersonKey(v.session.owner);
4548
4442
  return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
@@ -4553,7 +4447,10 @@ const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoc
4553
4447
  // #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
4554
4448
  const prefsView = (prefs, v) => {
4555
4449
  const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
4556
- return v?.guest ? guestPrefs(view) : view;
4450
+ if (!v?.guest)
4451
+ return view;
4452
+ // #2855: the host's look, then the guest's own preferences (only on a guest with an account: its bucket).
4453
+ return v.person ? guestOwnPrefs(view, prefs.readGuestOwn(v.person), v.guestScope?.() ?? new Set()) : guestPrefs(view);
4557
4454
  };
4558
4455
  function shellHtmlTag(ctx, mode, viewer) {
4559
4456
  if (mode !== "full" || !ctx.webPrefs)
@@ -4572,20 +4469,12 @@ function shellOwnerCss(ctx, mode, viewer) {
4572
4469
  }
4573
4470
  function guestBodyAttrs(ctx, owner, attr) {
4574
4471
  const badge = ctx.guestBadge?.(owner);
4575
- return ` data-tier="guest"${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
4576
- }
4577
- /** A separate session fence for Chat playback: public/guest viewers acquire no owner-pref identity. */
4578
- function shellEffect(req, ctx, mode) {
4579
- if (mode !== "full" || typeof ctx.effectBoot !== "string" || !/^[0-9a-f]{32}$/.test(ctx.effectBoot))
4580
- return null;
4581
- const v = evaluateWebRequest(req, new URL(req.url ?? "/", "http://localhost"), ctx.webToken ?? null, ctx.webSessions, { touch: false });
4582
- if (v.kind !== "allow" || v.via !== "session" || !v.session || !/^[a-f0-9]{16}$/.test(v.session.handle))
4583
- return null;
4584
- return { boot: ctx.effectBoot, epoch: v.session.handle, scope: gatewayRequestContext(req) || v.session.tier === "guest" ? "chat" : "owner" };
4472
+ const trusted = ctx.guestTrusted?.(owner) ? ' data-guest-level="trusted"' : "";
4473
+ return ` data-tier="guest"${trusted}${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
4585
4474
  }
4586
4475
  function shellBodyTag(req, ctx, mode, viewer) {
4587
4476
  const p = shellPreview(req, ctx, mode);
4588
- const ownerCss = shellOwnerCss(ctx, mode, viewer), effect = shellEffect(req, ctx, mode);
4477
+ const ownerCss = shellOwnerCss(ctx, mode, viewer);
4589
4478
  let ownerCssState = mode === "full" && (gatewayRequestContext(req) || viewer?.guest) ? "off" : "unknown";
4590
4479
  if (mode === "full" && !viewer?.guest && viewer?.person != null && ctx.ownerCss) {
4591
4480
  ownerCssState = ctx.ownerCss.effectState(viewer.person);
@@ -4594,17 +4483,15 @@ function shellBodyTag(req, ctx, mode, viewer) {
4594
4483
  }
4595
4484
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
4596
4485
  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)}"`
4597
- + `${effect ? ` data-effect-boot="${effect.boot}" data-effect-epoch="${effect.epoch}" data-effect-scope="${effect.scope}"` : ""}`
4598
4486
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
4599
4487
  // #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
4600
4488
  + ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
4601
4489
  // #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
4602
4490
  + `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
4603
4491
  // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔ — never to a guest (#1920).
4604
- + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
4492
+ + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"` // #2855: guests' too
4605
4493
  // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
4606
4494
  + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
4607
- + ` data-canvas-worker="${canvasWorkerEnabled(ctx.fleetConfig?.web) ? "1" : "0"}"`
4608
4495
  // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
4609
4496
  // checks before sending a file, and the numbers its words say. The server checks again on every upload.
4610
4497
  + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
@@ -4634,10 +4521,7 @@ export function serveAppShell(req, res, ctx, mode) {
4634
4521
  // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
4635
4522
  // the page may load); every other page frames exactly the origin this load chose (#1723).
4636
4523
  const previewFrames = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
4637
- // This fixed trusted document is independent of HTML preview. Its own response confines all Worker execution.
4638
- const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
4639
- const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
4640
- const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
4524
+ const frameSrc = previewFrames ?? "";
4641
4525
  sendPanelHtml(res, withAppIconLinks(html).replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
4642
4526
  }
4643
4527
  catch {