@songsid/agend 2.2.1 → 2.2.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.
- package/dist/agent-cli-instructions.md +5 -2
- package/dist/agent-cli.js +284 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +29 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +38 -0
- package/dist/agent-file-read.js +317 -0
- package/dist/agent-file-read.js.map +1 -0
- package/dist/agent-whats-new.d.ts +11 -0
- package/dist/agent-whats-new.js +34 -2
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/atomic-write.js +9 -1
- package/dist/atomic-write.js.map +1 -1
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +61 -12
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +38 -0
- package/dist/authority/authority-state.js +114 -0
- package/dist/authority/authority-state.js.map +1 -0
- package/dist/authority/session-authorizations.d.ts +43 -0
- package/dist/authority/session-authorizations.js +102 -0
- package/dist/authority/session-authorizations.js.map +1 -0
- package/dist/authority/web-authority.d.ts +120 -0
- package/dist/authority/web-authority.js +157 -0
- package/dist/authority/web-authority.js.map +1 -0
- package/dist/backend/antigravity.js +3 -3
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +10 -5
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/launch-effort.js +1 -1
- package/dist/backend/launch-effort.js.map +1 -1
- package/dist/backend/muse.js +10 -2
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +14 -0
- package/dist/backend/types.js +25 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +120 -0
- package/dist/backend-availability.js +396 -0
- package/dist/backend-availability.js.map +1 -0
- package/dist/backend-command.d.ts +21 -3
- package/dist/backend-command.js +23 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/bundled-skills.d.ts +14 -0
- package/dist/bundled-skills.js +29 -0
- package/dist/bundled-skills.js.map +1 -0
- package/dist/cache-analysis.d.ts +47 -1
- package/dist/cache-analysis.js +68 -8
- package/dist/cache-analysis.js.map +1 -1
- package/dist/cache-ledger.d.ts +16 -3
- package/dist/cache-ledger.js +33 -13
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-report-tool.d.ts +17 -0
- package/dist/cache-report-tool.js +40 -0
- package/dist/cache-report-tool.js.map +1 -0
- package/dist/cache-service.d.ts +70 -3
- package/dist/cache-service.js +178 -17
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
- package/dist/channel/adapters/discord-client-retirement.js +57 -0
- package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +11 -0
- package/dist/channel/adapters/discord.js +66 -6
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +8 -1
- package/dist/channel/adapters/telegram.js +74 -4
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +48 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +21 -0
- package/dist/classic-channel-manager.d.ts +10 -5
- package/dist/classic-channel-manager.js +12 -9
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +142 -110
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +119 -4
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +8 -0
- package/dist/daemon.js +298 -22
- package/dist/daemon.js.map +1 -1
- package/dist/decision-list.d.ts +25 -0
- package/dist/decision-list.js +30 -0
- package/dist/decision-list.js.map +1 -0
- package/dist/delivery-outbox.d.ts +10 -0
- package/dist/delivery-outbox.js +12 -1
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +15 -0
- package/dist/fleet-manager.d.ts +514 -12
- package/dist/fleet-manager.js +1885 -163
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +18 -0
- package/dist/fleet-process-handlers.js +69 -0
- package/dist/fleet-process-handlers.js.map +1 -0
- package/dist/fleet-traffic.d.ts +74 -0
- package/dist/fleet-traffic.js +196 -0
- package/dist/fleet-traffic.js.map +1 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +492 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +482 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/human-delivery-receipts.d.ts +24 -1
- package/dist/human-delivery-receipts.js +64 -11
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/instance-avatar.js +3 -0
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instance-config-impact.js +6 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +2 -0
- package/dist/instance-lifecycle.js +20 -10
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instance-names.d.ts +4 -0
- package/dist/instance-names.js +12 -0
- package/dist/instance-names.js.map +1 -0
- package/dist/instructions.d.ts +12 -1
- package/dist/instructions.js +13 -2
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +100 -8
- package/dist/locale.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +95 -0
- package/dist/ls-attach-resolve.js +174 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- package/dist/memory-hogs.d.ts +27 -0
- package/dist/memory-hogs.js +124 -0
- package/dist/memory-hogs.js.map +1 -0
- package/dist/needs-you-hub.d.ts +4 -0
- package/dist/needs-you-hub.js +31 -6
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +1 -13
- package/dist/needs-you.js +3 -37
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.d.ts +15 -0
- package/dist/outbound-handlers.js +152 -11
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +228 -4
- package/dist/outbound-schemas.js +147 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +21 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -0
- package/dist/public-web-link.js +8 -0
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +10 -1
- package/dist/quickstart-api.js +12 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +4 -0
- package/dist/reply-buttons-controller.js +2 -2
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/repo-worktree-guard.d.ts +36 -0
- package/dist/repo-worktree-guard.js +143 -0
- package/dist/repo-worktree-guard.js.map +1 -0
- package/dist/restart-progress.d.ts +65 -1
- package/dist/restart-progress.js +236 -10
- package/dist/restart-progress.js.map +1 -1
- package/dist/rollout-index.d.ts +5 -0
- package/dist/rollout-index.js +26 -5
- package/dist/rollout-index.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +70 -1
- package/dist/scheduler/scheduler.js +256 -17
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/service-installer.d.ts +2 -1
- package/dist/service-installer.js +18 -3
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +23 -0
- package/dist/settings-api.js +233 -2
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +112 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +32 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +5 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/tmux-manager.d.ts +5 -0
- package/dist/tmux-manager.js +16 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +4 -6
- package/dist/tool-permissions.js +100 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +17 -7
- package/dist/topic-commands.js +39 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/topic-link.d.ts +128 -0
- package/dist/topic-link.js +269 -0
- package/dist/topic-link.js.map +1 -0
- package/dist/types.d.ts +61 -1
- package/dist/ui/app-effects.js +245 -0
- package/dist/ui/chat-ambient.js +307 -0
- package/dist/ui/chat-effect-posters.js +42 -0
- package/dist/ui/chat-effects.js +160 -0
- package/dist/ui/chat-live-replies.js +58 -0
- package/dist/ui/chat-render.js +25 -7
- package/dist/ui/chat-store.js +41 -13
- package/dist/ui/chat-strings.js +86 -2
- package/dist/ui/chat-thread.js +204 -37
- package/dist/ui/chat-tour.js +1 -1
- package/dist/ui/effect-trial-controller.js +124 -0
- package/dist/ui/effect-trial-offer.js +31 -0
- package/dist/ui/effect-trial-panel.js +40 -0
- package/dist/ui/fleet-cache.js +23 -1
- package/dist/ui/fleet-canvas-layout.js +111 -0
- package/dist/ui/fleet-canvas.js +525 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-traffic.js +343 -0
- package/dist/ui/fleet-usage.js +401 -0
- package/dist/ui/image-lightbox.js +1 -1
- package/dist/ui/panel-chat.js +252 -58
- package/dist/ui/panel-details.js +20 -6
- package/dist/ui/panel-fleet.js +45 -19
- package/dist/ui/panel-log.js +6 -6
- package/dist/ui/panel-needs.js +3 -3
- package/dist/ui/panel-settings.js +58 -13
- package/dist/ui/preview-panel.js +25 -9
- package/dist/ui/preview.js +3 -2
- package/dist/ui/settings-appearance.js +349 -0
- package/dist/ui/settings-catalog.js +92 -0
- package/dist/ui/settings-dialogs.js +40 -12
- package/dist/ui/settings-effects.js +202 -0
- package/dist/ui/settings-guests.js +117 -0
- package/dist/ui/settings-learn.js +25 -0
- package/dist/ui/settings-model.js +15 -6
- package/dist/ui/settings-mods.js +271 -0
- package/dist/ui/settings-owner-effects.js +80 -0
- package/dist/ui/settings-strings.js +335 -10
- package/dist/ui/settings-web-authority.js +91 -0
- package/dist/ui/shared/agend-auth.js +28 -4
- package/dist/ui/shared/agent-widget.js +248 -0
- package/dist/ui/shared/app-i18n.js +6 -2
- package/dist/ui/shared/app-prefs.js +34 -13
- package/dist/ui/shared/app-route.js +42 -2
- package/dist/ui/shared/app-session.js +3 -1
- package/dist/ui/shared/app-shell.js +119 -41
- package/dist/ui/shared/app-store.js +28 -1
- package/dist/ui/shared/app-stream.js +14 -7
- package/dist/ui/shared/app-themes.js +206 -0
- package/dist/ui/shared/app.css +566 -5
- package/dist/ui/shared/app.js +23 -10
- package/dist/ui/shared/card-strings.js +32 -0
- package/dist/ui/shared/effect-app-runtime.js +297 -0
- package/dist/ui/shared/effect-baseline.js +64 -0
- package/dist/ui/shared/effect-chat-runtime.js +167 -0
- package/dist/ui/shared/effect-cursor.js +125 -0
- package/dist/ui/shared/effect-frame.js +146 -0
- package/dist/ui/shared/effect-geometry.js +137 -0
- package/dist/ui/shared/effect-host-style.js +10 -0
- package/dist/ui/shared/effect-owner-regions.js +74 -0
- package/dist/ui/shared/effect-owner-source.js +122 -0
- package/dist/ui/shared/effect-policy.js +151 -0
- package/dist/ui/shared/effect-poster-reader.js +98 -0
- package/dist/ui/shared/effect-posters.js +117 -0
- package/dist/ui/shared/effect-profiler.js +184 -0
- package/dist/ui/shared/effect-profiles.js +65 -0
- package/dist/ui/shared/effect-registry.js +543 -0
- package/dist/ui/shared/effect-runtime-loader.js +234 -0
- package/dist/ui/shared/effect-safety.js +153 -0
- package/dist/ui/shared/effect-stock-snapshot.js +43 -0
- package/dist/ui/shared/effect-stock-trust.js +217 -0
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-source.js +103 -0
- package/dist/ui/shared/effect-trial-stage.js +170 -0
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/fx-motion.js +67 -0
- package/dist/ui/shared/header-tools.js +1 -1
- package/dist/ui/shared/identity-card.js +273 -0
- package/dist/ui/shared/identity-color.js +261 -0
- package/dist/ui/shared/instance-nav.js +215 -41
- package/dist/ui/shared/owner-css.js +250 -0
- package/dist/ui/shared/owner-effect-prefs.js +117 -0
- package/dist/ui/shared/panel-view.js +103 -10
- package/dist/ui/shared/signin.js +2 -0
- package/dist/ui/shared/tokens.css +9 -3
- package/dist/ui/shared/ui-dialog.js +1 -1
- package/dist/ui/shared/ui-icons.js +3 -0
- package/dist/ui/shared/ui-menu.js +1 -1
- package/dist/ui/shared/ui-toast.js +1 -1
- package/dist/ui/shared/usage-dialog.js +22 -2
- package/dist/ui/shared/view-strings.js +98 -2
- package/dist/ui/shared/web-authorize.js +151 -0
- package/dist/ui/signin.html +1 -0
- package/dist/ui/web-terminal/terminal.css +4 -4
- package/dist/usage/providers.d.ts +28 -0
- package/dist/usage/providers.js +126 -36
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage-report.d.ts +158 -0
- package/dist/usage-report.js +226 -0
- package/dist/usage-report.js.map +1 -0
- package/dist/usage-rollup.d.ts +51 -0
- package/dist/usage-rollup.js +168 -0
- package/dist/usage-rollup.js.map +1 -0
- package/dist/view-api.d.ts +68 -0
- package/dist/view-api.js +249 -8
- package/dist/view-api.js.map +1 -1
- package/dist/web-ambient.d.ts +72 -0
- package/dist/web-ambient.js +161 -0
- package/dist/web-ambient.js.map +1 -0
- package/dist/web-api.d.ts +117 -7
- package/dist/web-api.js +1283 -70
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-limits.d.ts +82 -0
- package/dist/web-appearance-limits.js +83 -0
- package/dist/web-appearance-limits.js.map +1 -0
- package/dist/web-appearance-policy.d.ts +64 -0
- package/dist/web-appearance-policy.js +87 -0
- package/dist/web-appearance-policy.js.map +1 -0
- package/dist/web-appearance-tools.d.ts +53 -0
- package/dist/web-appearance-tools.js +124 -0
- package/dist/web-appearance-tools.js.map +1 -0
- package/dist/web-appearance-view.d.ts +124 -0
- package/dist/web-appearance-view.js +278 -0
- package/dist/web-appearance-view.js.map +1 -0
- package/dist/web-appearance.d.ts +93 -0
- package/dist/web-appearance.js +373 -0
- package/dist/web-appearance.js.map +1 -0
- package/dist/web-asset-parts.d.ts +93 -0
- package/dist/web-asset-parts.js +133 -0
- package/dist/web-asset-parts.js.map +1 -0
- package/dist/web-auth.js +20 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-card.d.ts +110 -0
- package/dist/web-card.js +266 -0
- package/dist/web-card.js.map +1 -0
- package/dist/web-channel-echo.d.ts +7 -1
- package/dist/web-channel-echo.js +6 -2
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +63 -0
- package/dist/web-chat-history.js +109 -6
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +1 -0
- package/dist/web-effect-bootstrap.d.ts +12 -0
- package/dist/web-effect-bootstrap.js +49 -0
- package/dist/web-effect-bootstrap.js.map +1 -0
- package/dist/web-effect-bundle.d.ts +28 -0
- package/dist/web-effect-bundle.js +171 -0
- package/dist/web-effect-bundle.js.map +1 -0
- package/dist/web-effect-canonical.d.ts +8 -0
- package/dist/web-effect-canonical.js +27 -0
- package/dist/web-effect-canonical.js.map +1 -0
- package/dist/web-effect-library.d.ts +114 -0
- package/dist/web-effect-library.js +530 -0
- package/dist/web-effect-library.js.map +1 -0
- package/dist/web-effect-playback.d.ts +30 -0
- package/dist/web-effect-playback.js +71 -0
- package/dist/web-effect-playback.js.map +1 -0
- package/dist/web-effect-poster.d.ts +8 -0
- package/dist/web-effect-poster.js +52 -0
- package/dist/web-effect-poster.js.map +1 -0
- package/dist/web-effect-profiles.d.ts +118 -0
- package/dist/web-effect-profiles.js +180 -0
- package/dist/web-effect-profiles.js.map +1 -0
- package/dist/web-effect-service.d.ts +93 -0
- package/dist/web-effect-service.js +335 -0
- package/dist/web-effect-service.js.map +1 -0
- package/dist/web-effect-tools.d.ts +48 -0
- package/dist/web-effect-tools.js +129 -0
- package/dist/web-effect-tools.js.map +1 -0
- package/dist/web-effect-trials.d.ts +86 -0
- package/dist/web-effect-trials.js +204 -0
- package/dist/web-effect-trials.js.map +1 -0
- package/dist/web-effect-upload.d.ts +27 -0
- package/dist/web-effect-upload.js +185 -0
- package/dist/web-effect-upload.js.map +1 -0
- package/dist/web-effect-worker.d.ts +8 -0
- package/dist/web-effect-worker.js +170 -0
- package/dist/web-effect-worker.js.map +1 -0
- package/dist/web-effect.d.ts +103 -0
- package/dist/web-effect.js +153 -0
- package/dist/web-effect.js.map +1 -0
- package/dist/web-guest-access.d.ts +125 -0
- package/dist/web-guest-access.js +234 -0
- package/dist/web-guest-access.js.map +1 -0
- package/dist/web-guest-rooms.d.ts +82 -0
- package/dist/web-guest-rooms.js +135 -0
- package/dist/web-guest-rooms.js.map +1 -0
- package/dist/web-identity-color.d.ts +87 -0
- package/dist/web-identity-color.js +209 -0
- package/dist/web-identity-color.js.map +1 -0
- package/dist/web-identity-palette.d.ts +113 -0
- package/dist/web-identity-palette.js +84 -0
- package/dist/web-identity-palette.js.map +1 -0
- package/dist/web-image-meta.d.ts +38 -0
- package/dist/web-image-meta.js +262 -0
- package/dist/web-image-meta.js.map +1 -0
- package/dist/web-login.d.ts +47 -0
- package/dist/web-login.js +115 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mod-agent.d.ts +50 -0
- package/dist/web-mod-agent.js +207 -0
- package/dist/web-mod-agent.js.map +1 -0
- package/dist/web-mod-bundle.d.ts +157 -0
- package/dist/web-mod-bundle.js +529 -0
- package/dist/web-mod-bundle.js.map +1 -0
- package/dist/web-mod-export.d.ts +68 -0
- package/dist/web-mod-export.js +100 -0
- package/dist/web-mod-export.js.map +1 -0
- package/dist/web-mod-store.d.ts +289 -0
- package/dist/web-mod-store.js +638 -0
- package/dist/web-mod-store.js.map +1 -0
- package/dist/web-org.d.ts +3 -3
- package/dist/web-org.js +11 -17
- package/dist/web-org.js.map +1 -1
- package/dist/web-owner-css-store.d.ts +222 -0
- package/dist/web-owner-css-store.js +440 -0
- package/dist/web-owner-css-store.js.map +1 -0
- package/dist/web-owner-css.d.ts +58 -0
- package/dist/web-owner-css.js +552 -0
- package/dist/web-owner-css.js.map +1 -0
- package/dist/web-prefs.d.ts +62 -0
- package/dist/web-prefs.js +232 -36
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +15 -3
- package/dist/web-preview.js +28 -9
- package/dist/web-preview.js.map +1 -1
- package/dist/web-returning-device.d.ts +41 -0
- package/dist/web-returning-device.js +57 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-session-authorization.d.ts +132 -0
- package/dist/web-session-authorization.js +193 -0
- package/dist/web-session-authorization.js.map +1 -0
- package/dist/web-session.d.ts +70 -10
- package/dist/web-session.js +137 -22
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +67 -2
- package/dist/web-settings-patch.js +196 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +89 -0
- package/dist/web-settings-tools.js +273 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/dist/web-shell-routes.d.ts +2 -2
- package/dist/web-shell-routes.js +2 -2
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-theme-store.d.ts +257 -0
- package/dist/web-theme-store.js +627 -0
- package/dist/web-theme-store.js.map +1 -0
- package/dist/web-theme.d.ts +123 -0
- package/dist/web-theme.js +416 -0
- package/dist/web-theme.js.map +1 -0
- package/dist/web-upload.d.ts +13 -0
- package/dist/web-upload.js +39 -3
- package/dist/web-upload.js.map +1 -1
- package/dist/web-widget.d.ts +72 -0
- package/dist/web-widget.js +245 -0
- package/dist/web-widget.js.map +1 -0
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +8 -0
package/dist/web-api.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { webHumanOrigin } from "./human-delivery-receipts.js";
|
|
2
|
+
import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
|
|
3
|
+
import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
|
|
2
4
|
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
3
5
|
import { binaryProbe } from "./binary-probe.js";
|
|
4
6
|
import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
|
|
5
7
|
import { readBoundedWebBody } from "./web-body.js";
|
|
8
|
+
import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
|
|
9
|
+
import { buildModBundle } from "./web-mod-export.js";
|
|
10
|
+
import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
|
|
11
|
+
import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
|
|
12
|
+
import { canonicalText } from "./web-theme.js";
|
|
13
|
+
import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
|
|
6
14
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
7
15
|
import { permitWebContinuation } from "./web-continuation.js";
|
|
8
16
|
/**
|
|
@@ -28,13 +36,14 @@ import { dashboardOriginFor } from "./web-preview.js";
|
|
|
28
36
|
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
29
37
|
import { attachmentDelivery, createPartFile, displayName, fileTooLargeText, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, UPLOAD_LIMITS, UPLOAD_MAX_MS, UPLOAD_STALL_MS, UploadSniffer, uploadLimits, wellFormed } from "./web-upload.js";
|
|
30
38
|
import { getAgendHome } from "./paths.js";
|
|
31
|
-
import { OPERATOR } from "./web-prefs.js";
|
|
32
|
-
import { WEB_NAME_FALLBACK } from "./web-profile.js";
|
|
39
|
+
import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
|
|
40
|
+
import { WEB_NAME_FALLBACK, webPersonKey } from "./web-profile.js";
|
|
33
41
|
import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
|
|
34
42
|
import { parseMentionQuery } from "./web-mentions.js";
|
|
35
43
|
import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
36
44
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
37
45
|
import { resolveTz } from "./tz-utils.js";
|
|
46
|
+
import { filesPlayInChat } from "./web-effect.js";
|
|
38
47
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
39
48
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
40
49
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -90,11 +99,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
|
|
|
90
99
|
* the eviction the dead entry leaks forever, and without the try/catch a
|
|
91
100
|
* single dead client breaks delivery to every client iterated after it.
|
|
92
101
|
*/
|
|
102
|
+
/** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
|
|
103
|
+
const streamGuests = new WeakMap();
|
|
104
|
+
/**
|
|
105
|
+
* #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
|
|
106
|
+
* its channel saw (an admin's page already has it, unchanged).
|
|
107
|
+
*/
|
|
108
|
+
export function broadcastToGuests(clients, event, data, onError) {
|
|
109
|
+
const guests = [...clients].filter((c) => streamGuests.has(c));
|
|
110
|
+
if (!guests.length)
|
|
111
|
+
return;
|
|
112
|
+
const live = new Set(guests);
|
|
113
|
+
broadcastSseEvent(live, event, data, onError);
|
|
114
|
+
for (const c of guests)
|
|
115
|
+
if (!live.has(c))
|
|
116
|
+
clients.delete(c); // the ones it ended (their session went)
|
|
117
|
+
}
|
|
93
118
|
export function broadcastSseEvent(clients, event, data, onError, id) {
|
|
94
119
|
const payload = sseFrame(event, data, id);
|
|
95
120
|
const dead = [];
|
|
96
121
|
for (const client of clients) {
|
|
97
122
|
try {
|
|
123
|
+
// An admin's stream gets these bytes; a guest's, the event as projected for its scope now, or nothing (no id: it
|
|
124
|
+
// never replays chat, so a cursor of the admin's stream is not its to hold).
|
|
125
|
+
const guest = streamGuests.get(client);
|
|
126
|
+
if (guest) {
|
|
127
|
+
// The exact session that opened it ended (revoked, expired, rotated away): nothing more, and the stream goes.
|
|
128
|
+
if (!guest.current()) {
|
|
129
|
+
dead.push(client);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const shown = guestFrameData(event, data, guest.scope(), guest);
|
|
133
|
+
if (shown !== undefined)
|
|
134
|
+
client.write(sseFrame(event, shown));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
98
137
|
client.write(payload);
|
|
99
138
|
}
|
|
100
139
|
catch (err) {
|
|
@@ -150,6 +189,730 @@ function stillAuthorized(req, res, url, ctx) {
|
|
|
150
189
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
151
190
|
return false;
|
|
152
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
|
|
194
|
+
* signed-in human session is, or the status to answer.
|
|
195
|
+
*/
|
|
196
|
+
function handleOwnerCss(req, res, url, ctx, path, method) {
|
|
197
|
+
const store = ctx.ownerCss;
|
|
198
|
+
if (!store) {
|
|
199
|
+
json(res, 404, { error: "Owner CSS is not kept on this fleet" });
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
const reading = method === "GET" || method === "HEAD";
|
|
203
|
+
if (gatewayRequestContext(req)) {
|
|
204
|
+
json(res, reading ? 404 : 403, { error: "owner_css_not_over_public_link" });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
const who = () => {
|
|
208
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
209
|
+
if (verdict.kind !== "allow")
|
|
210
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
211
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
212
|
+
return { status: 403, error: "owner_css_needs_a_signed_in_person" };
|
|
213
|
+
if (!reading && verdict.session.tier !== "admin")
|
|
214
|
+
return { status: 403, error: "owner_css_read_only" };
|
|
215
|
+
return { person: webPersonKey(verdict.session.owner) };
|
|
216
|
+
};
|
|
217
|
+
const first = who();
|
|
218
|
+
if ("status" in first) {
|
|
219
|
+
json(res, first.status, { error: first.error });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const again = () => {
|
|
223
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
224
|
+
return null;
|
|
225
|
+
const now = who();
|
|
226
|
+
if ("status" in now) {
|
|
227
|
+
json(res, now.status, { error: now.error });
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (now.person !== first.person) {
|
|
231
|
+
json(res, 409, { error: "owner_css_person_changed" });
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return now.person;
|
|
235
|
+
};
|
|
236
|
+
const announce = () => ctx.emitSseEvent("status", ctx.getUiStatus()); // their other devices see the new prefs rev
|
|
237
|
+
const fail = (err) => {
|
|
238
|
+
ctx.logger.error({ err: err.message }, "Owner CSS request failed");
|
|
239
|
+
try {
|
|
240
|
+
json(res, 500, { error: "Owner CSS request failed" });
|
|
241
|
+
}
|
|
242
|
+
catch { /* already answered */ }
|
|
243
|
+
};
|
|
244
|
+
if (path === "/ui/owner-css.css" && method === "GET") {
|
|
245
|
+
const v = url.searchParams.get("v");
|
|
246
|
+
const sha = v ?? store.state(first.person).active;
|
|
247
|
+
const text = sha ? store.served(first.person, sha) : null;
|
|
248
|
+
if (text === null) {
|
|
249
|
+
json(res, 404, { error: "no owner CSS" });
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
res.writeHead(200, {
|
|
253
|
+
"Content-Type": "text/css; charset=utf-8", "X-Content-Type-Options": "nosniff",
|
|
254
|
+
// Keyed by its sha (?v): a version's text never changes. Without ?v it is whatever is in force now.
|
|
255
|
+
"Cache-Control": v ? "private, max-age=31536000, immutable" : "private, no-store",
|
|
256
|
+
});
|
|
257
|
+
res.end(text);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
const assetPath = /^\/ui\/owner-css\/asset\/([0-9a-f]{32})$/.exec(path);
|
|
261
|
+
if (assetPath && method === "GET") {
|
|
262
|
+
const asset = store.asset(first.person, assetPath[1]);
|
|
263
|
+
if (!asset) {
|
|
264
|
+
json(res, 404, { error: "no such asset" });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
res.writeHead(200, {
|
|
268
|
+
"Content-Type": asset.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable",
|
|
269
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
270
|
+
});
|
|
271
|
+
res.end(asset.bytes);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (path === "/ui/owner-css/state" && method === "GET") {
|
|
275
|
+
json(res, 200, store.state(first.person));
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (assetPath && method === "DELETE") {
|
|
279
|
+
const removed = store.removeAsset(first.person, assetPath[1]);
|
|
280
|
+
json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/shown", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
|
|
284
|
+
json(res, 404, { error: "not found" });
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
(async () => {
|
|
288
|
+
if (path === "/ui/owner-css/asset") {
|
|
289
|
+
let bytes;
|
|
290
|
+
try {
|
|
291
|
+
bytes = await readBoundedWebBody(req, 1024 * 1024);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
json(res, 413, { error: "asset_quota", detail: "at most 1 MiB" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const person = again();
|
|
298
|
+
if (!person)
|
|
299
|
+
return;
|
|
300
|
+
const added = store.addAsset(person, bytes);
|
|
301
|
+
if (!added.ok) {
|
|
302
|
+
json(res, added.status, { error: added.error, ...(added.detail ? { detail: added.detail } : {}) });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
json(res, 200, { id: added.id, kind: added.kind, bytes: added.bytes });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
let body;
|
|
309
|
+
try {
|
|
310
|
+
body = await parseBody(req);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const person = again();
|
|
317
|
+
if (!person)
|
|
318
|
+
return;
|
|
319
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
320
|
+
json(res, 400, { error: "an object" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (path === "/ui/owner-css") {
|
|
324
|
+
const saved = store.save(person, body.text);
|
|
325
|
+
if (!saved.ok) {
|
|
326
|
+
json(res, saved.status, { error: saved.error, ...(saved.detail ? { detail: saved.detail } : {}) });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
announce();
|
|
330
|
+
json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
|
|
331
|
+
}
|
|
332
|
+
else if (path === "/ui/owner-css/shown") {
|
|
333
|
+
// #2198: this page shows the trial (its stylesheet loaded): an agent's waiting trial starts its keep window now.
|
|
334
|
+
const shown = store.shown(person, body.trial);
|
|
335
|
+
if (!shown.ok) {
|
|
336
|
+
json(res, shown.status, { error: shown.error });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (shown.started)
|
|
340
|
+
announce(); // the person's other devices take the new deadline
|
|
341
|
+
json(res, 200, { remaining_ms: shown.remaining_ms });
|
|
342
|
+
}
|
|
343
|
+
else if (path === "/ui/owner-css/keep") {
|
|
344
|
+
const kept = store.keep(person, body.trial);
|
|
345
|
+
if (!kept.ok) {
|
|
346
|
+
json(res, kept.status, { error: kept.error });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
announce(); // the person's other devices drop their bar
|
|
350
|
+
json(res, 200, { active: kept.active });
|
|
351
|
+
}
|
|
352
|
+
else if (path === "/ui/owner-css/revert") {
|
|
353
|
+
// Exactly this trial, while it still is one: a trial kept (or ended) on another device meanwhile stays (409).
|
|
354
|
+
const reverted = store.revertTrial(person, body.trial);
|
|
355
|
+
if (!reverted.ok) {
|
|
356
|
+
json(res, reverted.status, { error: reverted.error });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
announce();
|
|
360
|
+
json(res, 200, { active: reverted.active });
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
const restored = store.restore(person, body.sha);
|
|
364
|
+
if (!restored.ok) {
|
|
365
|
+
json(res, restored.status, { error: restored.error });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
announce();
|
|
369
|
+
json(res, 200, { active: restored.active });
|
|
370
|
+
}
|
|
371
|
+
})().catch(fail);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
/** #1629: static current-placement posters, separate from the local human-only runtime API. */
|
|
375
|
+
function handleEffectPosters(res, url, ctx, path, method) {
|
|
376
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
377
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
378
|
+
const image = /^\/ui\/effect-posters\/([0-9a-f]{32})\.png$/.exec(path);
|
|
379
|
+
const names = [...url.searchParams.keys()], instance = url.searchParams.get("instance");
|
|
380
|
+
if (method !== "GET" || path !== "/ui/effect-posters" && !image || names.length !== 1 || names[0] !== "instance" || !instance || instance.length > 128) {
|
|
381
|
+
json(res, 404, { error: "not_found" });
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
const service = ctx.effectService;
|
|
385
|
+
if (!service) {
|
|
386
|
+
json(res, 404, { error: "effects_unavailable" });
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (image) {
|
|
390
|
+
const r = service.placedPoster(image[1], instance);
|
|
391
|
+
if (!r.ok)
|
|
392
|
+
json(res, 404, { error: "not_found" });
|
|
393
|
+
else {
|
|
394
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
395
|
+
res.end(r.bytes);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
const result = service.posterPlacements(instance);
|
|
400
|
+
json(res, result.error === "themes_unreadable" ? 503 : result.error ? 404 : 200, result);
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
/** #1629 Try: separate local-admin routes. These never borrow ordinary read-tier/placement authority. */
|
|
405
|
+
function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
406
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
407
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
408
|
+
const reading = method === "GET";
|
|
409
|
+
if (gatewayRequestContext(req)) {
|
|
410
|
+
json(res, reading ? 404 : 403, { error: "trials_not_over_public_link" });
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
const who = () => {
|
|
414
|
+
const sessions = ctx.webSessions;
|
|
415
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, sessions, { touch: false });
|
|
416
|
+
if (v.kind !== "allow")
|
|
417
|
+
return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
|
|
418
|
+
if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
|
|
419
|
+
return { error: "trials_need_a_local_admin", status: 403 };
|
|
420
|
+
const person = webPersonKey(v.session.owner);
|
|
421
|
+
let css = "unknown";
|
|
422
|
+
try {
|
|
423
|
+
const value = ctx.ownerCss?.effectState(person);
|
|
424
|
+
if (value === "off" || value === "on")
|
|
425
|
+
css = value;
|
|
426
|
+
}
|
|
427
|
+
catch { /* unavailable is never off */ }
|
|
428
|
+
// CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
|
|
429
|
+
const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
|
|
430
|
+
return { actor, record: v.session, sessions };
|
|
431
|
+
};
|
|
432
|
+
const first = who();
|
|
433
|
+
if ("error" in first) {
|
|
434
|
+
json(res, first.status, { error: first.error });
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
const service = ctx.effectService;
|
|
438
|
+
if (!service) {
|
|
439
|
+
json(res, 404, { error: "trials_unavailable" });
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
const start = path === "/ui/effect-trials/start";
|
|
443
|
+
const route = /^\/ui\/effect-trials\/([0-9a-f]{32})\/(resource|poster\.png|profile|stop)$/.exec(path);
|
|
444
|
+
const resource = reading && route && ["resource", "poster.png", "profile"].includes(route[2]);
|
|
445
|
+
const stop = method === "POST" && route?.[2] === "stop";
|
|
446
|
+
if (!(resource || stop || method === "POST" && start)) {
|
|
447
|
+
json(res, 404, { error: "not_found" });
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
const allowed = resource ? ["ownerEpoch", "instance"] : ["ownerEpoch"];
|
|
451
|
+
if ([...url.searchParams.keys()].some(k => !allowed.includes(k)) || allowed.some(k => url.searchParams.getAll(k).length !== 1)
|
|
452
|
+
|| url.searchParams.get("ownerEpoch") !== first.actor.epoch) {
|
|
453
|
+
json(res, reading ? 404 : 409, { error: reading ? "not_found" : "trials_person_changed" });
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
const instance = url.searchParams.get("instance") ?? "";
|
|
457
|
+
const answer = (result) => json(res, result.error ? result.error === "not_found" ? 404
|
|
458
|
+
: ["profiles_unreadable", "profiles_busy", "profiles_write_failed"].includes(String(result.error)) ? 503 : 400 : 200, result);
|
|
459
|
+
if (resource) {
|
|
460
|
+
if (route[2] === "poster.png") {
|
|
461
|
+
const result = service.trialPoster(route[1], instance, first.actor);
|
|
462
|
+
if (!result.ok)
|
|
463
|
+
json(res, 404, { error: "not_found" });
|
|
464
|
+
else {
|
|
465
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
466
|
+
res.end(result.bytes);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
else
|
|
470
|
+
answer(route[2] === "profile" ? service.trialProfile(route[1], instance, first.actor) : service.trialResource(route[1], instance, first.actor));
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
(async () => {
|
|
474
|
+
let bytes;
|
|
475
|
+
try {
|
|
476
|
+
bytes = await readBoundedWebBody(req, 16 * 1024);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
json(res, 413, { error: "trial_body_bound" });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
483
|
+
return;
|
|
484
|
+
const now = who();
|
|
485
|
+
if ("error" in now) {
|
|
486
|
+
json(res, now.status, { error: now.error });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
|
|
490
|
+
|| now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
|
|
491
|
+
json(res, 409, { error: "trials_person_changed" });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
let body;
|
|
495
|
+
try {
|
|
496
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
json(res, 400, { error: "trial_json" });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (start) {
|
|
503
|
+
const parsed = z.strictObject({ offer: z.string().regex(/^[0-9a-f]{32}$/), instance: z.string().min(1).max(128) }).safeParse(body);
|
|
504
|
+
if (!parsed.success) {
|
|
505
|
+
json(res, 400, { error: "trial_args" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
answer(service.trialStart(parsed.data.offer, parsed.data.instance, now.actor));
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
if (!z.strictObject({}).safeParse(body).success) {
|
|
512
|
+
json(res, 400, { error: "trial_args" });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
answer(service.trialStop(route[1], now.actor));
|
|
516
|
+
}
|
|
517
|
+
})().catch(() => { try {
|
|
518
|
+
json(res, 500, { error: "trial_request_failed" });
|
|
519
|
+
}
|
|
520
|
+
catch { /* response ended */ } });
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
/** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
|
|
524
|
+
function handleEffects(req, res, url, ctx, path, method) {
|
|
525
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
526
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
527
|
+
const reading = method === "GET";
|
|
528
|
+
// Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
|
|
529
|
+
const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
|
|
530
|
+
const gateway = gatewayRequestContext(req), guest = guestRequestOf(req);
|
|
531
|
+
const instanceQuery = url.searchParams.get("instance"), keys = [...url.searchParams.keys()];
|
|
532
|
+
const chatRead = reading && (path === "/ui/effects/placements" || /^\/ui\/effects\/resource\/[0-9a-f]{32}$/.test(path))
|
|
533
|
+
&& keys.length === 2 && keys.includes("instance") && keys.includes("ownerEpoch") && !!instanceQuery && instanceQuery.length <= 128;
|
|
534
|
+
// Public viewing grants current Chat reads, never ordinary library/owner/Trial access or writes.
|
|
535
|
+
if (gateway && (!chatRead || !gateway.isCurrent())) {
|
|
536
|
+
json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
const who = () => {
|
|
540
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
541
|
+
if (verdict.kind !== "allow")
|
|
542
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
543
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
544
|
+
return { status: 403, error: "effects_need_a_signed_in_person" };
|
|
545
|
+
if (!reading && !profilePost && verdict.session.tier !== "admin")
|
|
546
|
+
return { status: 403, error: "effects_read_only" };
|
|
547
|
+
if (verdict.session.tier === "guest" && (!chatRead || !guest || !guest.current() || !guest.scope().has(instanceQuery)))
|
|
548
|
+
return { status: 404, error: "not_found" };
|
|
549
|
+
return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle, guest: verdict.session.tier === "guest" };
|
|
550
|
+
};
|
|
551
|
+
const first = who();
|
|
552
|
+
if ("status" in first) {
|
|
553
|
+
json(res, first.status, { error: first.error });
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
const service = ctx.effectService;
|
|
557
|
+
if (!service) {
|
|
558
|
+
json(res, 404, { error: "effects_unavailable" });
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
const unavailable = new Set(["themes_unreadable", "profiles_unreadable", "library_unreadable", "references_unreadable", "library_busy", "profiles_busy", "profiles_write_failed", "library_write_failed", "prefs_unreadable"]);
|
|
562
|
+
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);
|
|
563
|
+
const idPath = /^\/ui\/effects\/(resource|profile)\/([0-9a-f]{32})$/.exec(path);
|
|
564
|
+
const instance = url.searchParams.get("instance") ?? undefined, slot = url.searchParams.get("slot");
|
|
565
|
+
if (slot !== null && instance !== undefined) {
|
|
566
|
+
json(res, 404, { error: "not_found" });
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
const runtimeRead = reading && (slot !== null || path === "/ui/effects/owner" || path === "/ui/effects/placements" || idPath?.[1] === "resource" || instance !== undefined && /^\/ui\/effects\/image\//.test(path));
|
|
570
|
+
if ((runtimeRead || profilePost) && (url.searchParams.getAll("ownerEpoch").length !== 1 || url.searchParams.get("ownerEpoch") !== first.epoch)) {
|
|
571
|
+
const privateResource = reading && (slot !== null || idPath?.[1] === "resource" || /^\/ui\/effects\/image\//.test(path));
|
|
572
|
+
json(res, privateResource ? 404 : 409, { error: privateResource ? "not_found" : "effects_person_changed" });
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
if (reading) {
|
|
576
|
+
const image = /^\/ui\/effects\/image\/([0-9a-f]{32})\/(poster|cursor)\.png$/.exec(path);
|
|
577
|
+
if (image) {
|
|
578
|
+
const r = slot === null ? service.image(first.person, image[1], image[2], instance) : service.ownerImage(first.person, image[1], image[2], slot);
|
|
579
|
+
if (!r.ok)
|
|
580
|
+
json(res, 404, { error: "not_found" });
|
|
581
|
+
else {
|
|
582
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
583
|
+
res.end(r.bytes);
|
|
584
|
+
}
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
if (path === "/ui/effects")
|
|
588
|
+
answer({ ...service.list(first.person), canWrite: first.canWrite });
|
|
589
|
+
else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
|
|
590
|
+
answer(service.ownerSelections(first.person));
|
|
591
|
+
else if (path === "/ui/effects/placements" && instance !== undefined)
|
|
592
|
+
answer(service.placements(first.person, instance, !!gateway || first.guest));
|
|
593
|
+
else if (idPath?.[1] === "resource")
|
|
594
|
+
answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
|
|
595
|
+
else if (idPath?.[1] === "profile" && instance === undefined)
|
|
596
|
+
answer(service.profile(first.person, idPath[2]));
|
|
597
|
+
else
|
|
598
|
+
json(res, 404, { error: "not_found" });
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
const deletion = /^\/ui\/effects\/([0-9a-f]{32})$/.exec(path);
|
|
602
|
+
if (method === "DELETE" && deletion) {
|
|
603
|
+
answer(service.delete(first.person, deletion[1]));
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
if (method !== "POST" || !["/ui/effects/upload", "/ui/effects/apply", "/ui/effects/remove", "/ui/effects/share"].includes(path) && idPath?.[1] !== "profile") {
|
|
607
|
+
json(res, 404, { error: "not_found" });
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
(async () => {
|
|
611
|
+
let bytes;
|
|
612
|
+
try {
|
|
613
|
+
bytes = await readBoundedWebBody(req, path === "/ui/effects/upload" ? EFFECT_WIRE_LIMIT : 16 * 1024);
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
json(res, 413, { error: "effect_body_bound" });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
620
|
+
return;
|
|
621
|
+
const now = who();
|
|
622
|
+
if ("status" in now) {
|
|
623
|
+
json(res, now.status, { error: now.error });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (now.person !== first.person || now.epoch !== first.epoch) {
|
|
627
|
+
json(res, 409, { error: "effects_person_changed" });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (path === "/ui/effects/upload") {
|
|
631
|
+
answer(service.upload(now.person, bytes));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
let body;
|
|
635
|
+
try {
|
|
636
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
json(res, 400, { error: "effect_json" });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (path === "/ui/effects/apply")
|
|
643
|
+
answer(service.apply(now.person, body));
|
|
644
|
+
else if (path === "/ui/effects/remove")
|
|
645
|
+
answer(service.remove(now.person, body));
|
|
646
|
+
else if (path === "/ui/effects/share")
|
|
647
|
+
answer(service.share(now.person, body));
|
|
648
|
+
else {
|
|
649
|
+
if (body && typeof body === "object" && body.kind === "playback" && (url.searchParams.getAll("instance").length !== 1 || slot !== null)) {
|
|
650
|
+
json(res, 400, { error: "playback_report" });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
answer(service.report(now.person, idPath[2], body, instance, now.epoch));
|
|
654
|
+
}
|
|
655
|
+
})().catch(() => { try {
|
|
656
|
+
json(res, 500, { error: "effect_request_failed" });
|
|
657
|
+
}
|
|
658
|
+
catch { /* response ended */ } });
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* #1629 r4 §4 (r4-5): mods — a person's import (stage, then commit), uninstall and export. Every route is a signed-in
|
|
663
|
+
* session's, writes an admin's: never the CLI's header token (the way an agent could reach it) and never the public link
|
|
664
|
+
* (absent from its manifest, and refused here too). `who()` is asked first and again once a body is in.
|
|
665
|
+
*/
|
|
666
|
+
function handleMods(req, res, url, ctx, path, method) {
|
|
667
|
+
const mods = ctx.mods;
|
|
668
|
+
if (!mods) {
|
|
669
|
+
json(res, 404, { error: "Mods are not kept on this fleet" });
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
const reading = method === "GET";
|
|
673
|
+
if (gatewayRequestContext(req)) {
|
|
674
|
+
json(res, reading ? 404 : 403, { error: "mods_not_over_public_link" });
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
const who = () => {
|
|
678
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
679
|
+
if (verdict.kind !== "allow")
|
|
680
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
681
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
682
|
+
return { status: 403, error: "mods_need_a_signed_in_session" };
|
|
683
|
+
if (!reading && verdict.session.tier !== "admin")
|
|
684
|
+
return { status: 403, error: "mods_need_an_admin_session" };
|
|
685
|
+
return { person: webPersonKey(verdict.session.owner) };
|
|
686
|
+
};
|
|
687
|
+
const first = who();
|
|
688
|
+
if ("status" in first) {
|
|
689
|
+
json(res, first.status, { error: first.error });
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
const again = () => {
|
|
693
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
694
|
+
return null;
|
|
695
|
+
const now = who();
|
|
696
|
+
if ("status" in now) {
|
|
697
|
+
json(res, now.status, { error: now.error });
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
if (now.person !== first.person) {
|
|
701
|
+
json(res, 409, { error: "mods_person_changed" });
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
return now.person;
|
|
705
|
+
};
|
|
706
|
+
const answer = (r, body) => {
|
|
707
|
+
if (r.ok)
|
|
708
|
+
json(res, 200, body);
|
|
709
|
+
else
|
|
710
|
+
json(res, r.status ?? 500, { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.problems ? { problems: r.problems } : {}) });
|
|
711
|
+
};
|
|
712
|
+
const fail = (err) => {
|
|
713
|
+
ctx.logger.error({ err: err.message }, "Mod request failed");
|
|
714
|
+
try {
|
|
715
|
+
json(res, 500, { error: "Mod request failed" });
|
|
716
|
+
}
|
|
717
|
+
catch { /* already answered */ }
|
|
718
|
+
};
|
|
719
|
+
if (path === "/ui/mods" && method === "GET") {
|
|
720
|
+
json(res, 200, { ...mods.list(), staged: mods.staged(first.person), proposals: mods.proposals() });
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
// A staged mod's picture (this session's person's stage only): the plan shows the actual pictures before install.
|
|
724
|
+
const stagedPic = /^\/ui\/mods\/staged\/([0-9a-f]{32})$/.exec(path);
|
|
725
|
+
if (stagedPic && method === "GET") {
|
|
726
|
+
const pic = mods.stagedPicture(first.person, stagedPic[1]);
|
|
727
|
+
if (!pic) {
|
|
728
|
+
json(res, 404, { error: "No such image" });
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
res.writeHead(200, { "Content-Type": pic.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
732
|
+
res.end(Buffer.from(pic.bytes));
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
const fileMatch = /^\/ui\/mods\/file\/([0-9a-f]{32})$/.exec(path);
|
|
736
|
+
if (fileMatch && method === "GET") {
|
|
737
|
+
const text = mods.bundleText(fileMatch[1]);
|
|
738
|
+
const rec = mods.list().mods.find(m => m.id === fileMatch[1]);
|
|
739
|
+
if (text === null || !rec) {
|
|
740
|
+
json(res, 404, { error: "no_such_mod" });
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
// The file is the installed bundle, effects and all — also effects its import left out, which nobody installed.
|
|
744
|
+
// So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
|
|
745
|
+
// read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
|
|
746
|
+
const fx = mods.bundleEffects(fileMatch[1]);
|
|
747
|
+
if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
|
|
748
|
+
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" });
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
|
|
752
|
+
"Content-Disposition": `attachment; filename="${rec.name}-${rec.version}.agendmod.json"` });
|
|
753
|
+
res.end(text);
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
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)) {
|
|
757
|
+
json(res, 404, { error: "not found" });
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
(async () => {
|
|
761
|
+
if (path === "/ui/mods/stage") {
|
|
762
|
+
// Too large by its own declared length: answered before a byte is read (the body is drained, not cut off).
|
|
763
|
+
if (Number(req.headers["content-length"]) > MOD_MAX_BYTES) {
|
|
764
|
+
req.resume();
|
|
765
|
+
json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
let raw;
|
|
769
|
+
try {
|
|
770
|
+
raw = await readBoundedWebBody(req, MOD_MAX_BYTES);
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const person = again();
|
|
777
|
+
if (!person)
|
|
778
|
+
return;
|
|
779
|
+
let text;
|
|
780
|
+
try {
|
|
781
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
json(res, 400, { error: "mod_invalid", problems: [{ path: "", problem: "not UTF-8" }] });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const r = mods.stage(person, text);
|
|
788
|
+
answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
let body;
|
|
792
|
+
try {
|
|
793
|
+
body = await parseBody(req);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const person = again();
|
|
800
|
+
if (!person)
|
|
801
|
+
return;
|
|
802
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
803
|
+
json(res, 400, { error: "an object" });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (path === "/ui/mods/abort") {
|
|
807
|
+
const r = mods.abort(person, body.stage);
|
|
808
|
+
answer(r, { ok: true });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
// r4-5b: an agent's proposal, opened into the usual review (staged as this person's), or dismissed.
|
|
812
|
+
if (path === "/ui/mods/proposal/stage") {
|
|
813
|
+
const r = mods.stageProposal(person, body.instance);
|
|
814
|
+
answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (path === "/ui/mods/proposal/dismiss") {
|
|
818
|
+
const r = mods.dismiss(body.instance);
|
|
819
|
+
answer(r, { ok: true });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (path === "/ui/mods/commit") {
|
|
823
|
+
const r = mods.commit(person, body);
|
|
824
|
+
if (r.ok)
|
|
825
|
+
ctx.emitSseEvent("status", ctx.getUiStatus()); // themes_rev and prefs_rev moved: every page re-reads
|
|
826
|
+
answer(r, r.ok ? { id: r.id, applied: r.applied, ...(r.css ? { css: r.css } : {}) } : null);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (path === "/ui/mods/uninstall") {
|
|
830
|
+
const r = mods.uninstall(body.id);
|
|
831
|
+
answer(r, r.ok ? { ok: true, released: r.released } : null);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// Export: what is in force now, as a bundle file.
|
|
835
|
+
const r = exportMod(ctx, person, body);
|
|
836
|
+
if (!r.ok) {
|
|
837
|
+
json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
|
|
841
|
+
"Content-Disposition": `attachment; filename="${r.filename}"`, "X-AgEnD-Effects-Omitted": String(r.omitted) });
|
|
842
|
+
res.end(r.text);
|
|
843
|
+
})().catch(fail);
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
/** A person's export: an instance's look (theme, identity, its widgets), their sidebar widget and their own CSS. */
|
|
847
|
+
export function exportMod(ctx, person, body) {
|
|
848
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
849
|
+
const inc = isObj(body.include) ? body.include : null;
|
|
850
|
+
if (!inc)
|
|
851
|
+
return { ok: false, status: 400, error: "include" };
|
|
852
|
+
const meta = { name: body.name, version: body.version, title: body.title };
|
|
853
|
+
if (typeof meta.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(meta.name))
|
|
854
|
+
return { ok: false, status: 400, error: "name", detail: "a-z, 0-9 and - (1..64)" };
|
|
855
|
+
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))
|
|
856
|
+
return { ok: false, status: 400, error: "version", detail: "MAJOR.MINOR.PATCH" };
|
|
857
|
+
// The importer's own rule (#1897 review): what is exported always imports.
|
|
858
|
+
const title = canonicalText(meta.title, 1, 60);
|
|
859
|
+
if (title === null)
|
|
860
|
+
return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
|
|
861
|
+
const parts = {};
|
|
862
|
+
let omitted = 0;
|
|
863
|
+
const wantsInstance = inc.theme === true || inc.identity === true || inc.ambient === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
|
|
864
|
+
let name = null;
|
|
865
|
+
if (wantsInstance) {
|
|
866
|
+
if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
|
|
867
|
+
return { ok: false, status: 400, error: "instance" };
|
|
868
|
+
name = body.instance;
|
|
869
|
+
}
|
|
870
|
+
if (name && ctx.themeStore) {
|
|
871
|
+
const store = ctx.themeStore;
|
|
872
|
+
const rec = store.get(name);
|
|
873
|
+
if (inc.theme === true) {
|
|
874
|
+
// The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
|
|
875
|
+
// Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
|
|
876
|
+
// #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
|
|
877
|
+
const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
|
|
878
|
+
omitted++;
|
|
879
|
+
return [];
|
|
880
|
+
} return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
|
|
881
|
+
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 };
|
|
882
|
+
}
|
|
883
|
+
if (inc.identity === true)
|
|
884
|
+
parts.identity = ctx.modCardText?.(name) ?? {};
|
|
885
|
+
for (const slot of AGENT_WIDGET_SLOTS) {
|
|
886
|
+
const w = rec.placements.widgets[slot];
|
|
887
|
+
if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
|
|
888
|
+
(parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
|
|
889
|
+
}
|
|
890
|
+
// #2117: its ambients, whoever set them (a person exports what is on the chat).
|
|
891
|
+
if (inc.ambient === true) {
|
|
892
|
+
const specs = Object.fromEntries(Object.entries(rec.placements.ambient).flatMap(([k, a]) => a ? [[k, a.spec]] : []));
|
|
893
|
+
parts.ambient = { specs, picture: id => { const a = rec.assets.find(x => x.id === id); const b = store.asset(name, id); return a && b ? { bytes: b.bytes, kind: a.kind } : null; } };
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
|
|
897
|
+
const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
|
|
898
|
+
const spec = v ? checkWidgetSpec(v.spec, "sidebar") : null;
|
|
899
|
+
const data = spec?.ok ? checkWidgetData(v.data, spec.value) : null;
|
|
900
|
+
if (spec?.ok && data?.ok)
|
|
901
|
+
(parts.widgets ??= {}).sidebar = { spec: spec.value, data: { ...data.value } };
|
|
902
|
+
}
|
|
903
|
+
if (inc.ownerCss === true && ctx.ownerCss) {
|
|
904
|
+
const state = ctx.ownerCss.state(person);
|
|
905
|
+
const checked = state.text ? checkOwnerCss(state.text, new Set(state.assets.map(a => a.id))) : null;
|
|
906
|
+
if (state.text && checked?.ok) {
|
|
907
|
+
// Only the assets this text names travel (read from its tokens: url("\\61sset:<id>") names one too).
|
|
908
|
+
const named = ownerCssAssetRefs(checked.tokens);
|
|
909
|
+
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 }] : []; });
|
|
910
|
+
parts.ownerCss = { text: state.text, assets };
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const built = buildModBundle({ name: meta.name, version: meta.version, title }, parts);
|
|
914
|
+
return built.ok ? { ...built, omitted } : { ok: false, status: built.error === "nothing_to_export" ? 400 : 409, error: built.error };
|
|
915
|
+
}
|
|
153
916
|
/**
|
|
154
917
|
* #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
|
|
155
918
|
* no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
|
|
@@ -168,6 +931,30 @@ function webPersonOf(req, url, ctx) {
|
|
|
168
931
|
const who = webAccountOf(req, url, ctx);
|
|
169
932
|
return who ? ctx.webPerson(who.owner) : null;
|
|
170
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* #1939 S1: whether this request carries `web-session` authority, asked as it stands now — on arrival without an instance,
|
|
936
|
+
* after the body with the one it names (then confirmAfterBody joins the two). Never touches the session.
|
|
937
|
+
*/
|
|
938
|
+
function webAuthorityOf(req, url, ctx, instance) {
|
|
939
|
+
if (!ctx.webAuthority)
|
|
940
|
+
return { authority: noAuthority(0), refusal: "not_session" };
|
|
941
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
942
|
+
return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
|
|
943
|
+
peer: classifyWebPeer(req), ...(instance !== undefined ? { instance } : {}) });
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
|
|
947
|
+
* header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
|
|
948
|
+
* one). Equal before and after an await: the same admission still holds. Never touches the session.
|
|
949
|
+
*/
|
|
950
|
+
function admissionOf(req, url, ctx) {
|
|
951
|
+
const gateway = gatewayRequestContext(req);
|
|
952
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
953
|
+
if (v.kind !== "allow")
|
|
954
|
+
return null;
|
|
955
|
+
const via = v.via === "session" ? `session:${v.session.handle}:${v.session.tokenEpoch}` : "token";
|
|
956
|
+
return gateway ? `${via}@${gateway.exposureId}` : via;
|
|
957
|
+
}
|
|
171
958
|
/** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
|
|
172
959
|
function webPersonOr401(req, res, url, ctx) {
|
|
173
960
|
const p = webPersonOf(req, url, ctx);
|
|
@@ -243,7 +1030,20 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
243
1030
|
return true;
|
|
244
1031
|
}
|
|
245
1032
|
// ── Backend detection ─────────────────────────────────
|
|
1033
|
+
// #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
|
|
1034
|
+
if (method === "GET" && path === "/ui/models") {
|
|
1035
|
+
const backend = url.searchParams.get("backend") ?? "";
|
|
1036
|
+
const instance = url.searchParams.get("instance");
|
|
1037
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(backend) || (instance !== null && (instance.length < 1 || instance.length > 256))) {
|
|
1038
|
+
json(res, 400, { error: "backend (and optional instance) required" });
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
1041
|
+
json(res, 200, { backend, models: ctx.modelCatalogFor?.(backend, instance) ?? [] });
|
|
1042
|
+
return true;
|
|
1043
|
+
}
|
|
246
1044
|
if (method === "GET" && path === "/ui/backends") {
|
|
1045
|
+
// #2072 (#2098 review P3-3): the backend an agent created with "fleet default" runs, so its model list can be shown.
|
|
1046
|
+
const defaultBackend = ctx.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
247
1047
|
const BACKENDS = [
|
|
248
1048
|
{ name: "claude-code", binary: "claude" },
|
|
249
1049
|
{ name: "codex", binary: "codex" },
|
|
@@ -253,6 +1053,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
253
1053
|
{ name: "grok", binary: "grok" },
|
|
254
1054
|
{ name: "muse", binary: "muse" },
|
|
255
1055
|
];
|
|
1056
|
+
// #2072: the published availability snapshot — installed, signed in, new enough — when the fleet has one.
|
|
1057
|
+
if (ctx.backendAvailabilityRows) {
|
|
1058
|
+
const binaryOf = new Map(BACKENDS.map(b => [b.name, b]));
|
|
1059
|
+
const backends = ctx.backendAvailabilityRows().filter(r => binaryOf.has(r.backend)).map(r => ({
|
|
1060
|
+
name: r.backend, binary: binaryOf.get(r.backend).binary, installed: r.state !== "not_installed" && r.path !== null, path: r.path ?? "",
|
|
1061
|
+
deprecated: binaryOf.get(r.backend).deprecated ?? false, state: r.state, minVersion: r.minVersion, ...(r.state === "unknown" ? { unknown: true } : {}),
|
|
1062
|
+
}));
|
|
1063
|
+
json(res, 200, { backends, defaultBackend });
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
256
1066
|
// #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
|
|
257
1067
|
// runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
|
|
258
1068
|
// time (shown as not installed, as the old timeout was).
|
|
@@ -262,7 +1072,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
262
1072
|
return { name: b.name, binary: b.binary, installed: path !== null, path: path ?? "", deprecated: b.deprecated ?? false,
|
|
263
1073
|
...(found.known ? {} : { unknown: true }) };
|
|
264
1074
|
})).then((backends) => { if (!res.headersSent)
|
|
265
|
-
json(res, 200, { backends }); }, () => { if (!res.headersSent)
|
|
1075
|
+
json(res, 200, { backends, defaultBackend }); }, () => { if (!res.headersSent)
|
|
266
1076
|
json(res, 500, { error: "backend probe failed" }); });
|
|
267
1077
|
return true;
|
|
268
1078
|
}
|
|
@@ -275,9 +1085,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
275
1085
|
// that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
|
|
276
1086
|
// review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
|
|
277
1087
|
if (method === "GET" && path === "/ui/poll") {
|
|
1088
|
+
// #1920: a guest's poll is projected for its scope (web-guest-access.ts) — the same rule as its stream.
|
|
1089
|
+
const guest = guestRequestOf(req);
|
|
278
1090
|
const history = ctx.webChatHistory;
|
|
279
1091
|
const after = url.searchParams.get("after");
|
|
280
1092
|
const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
|
|
1093
|
+
if (guest) {
|
|
1094
|
+
// #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
|
|
1095
|
+
json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
|
|
1096
|
+
if (!history)
|
|
1097
|
+
return [];
|
|
1098
|
+
if (fresh)
|
|
1099
|
+
return history.after(0);
|
|
1100
|
+
const cursor = parseLastEventId(after);
|
|
1101
|
+
const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
|
|
1102
|
+
return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
|
|
1103
|
+
}));
|
|
1104
|
+
return true;
|
|
1105
|
+
}
|
|
281
1106
|
json(res, 200, {
|
|
282
1107
|
status: ctx.getUiStatus(),
|
|
283
1108
|
messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
|
|
@@ -310,30 +1135,49 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
310
1135
|
});
|
|
311
1136
|
// #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
|
|
312
1137
|
// poll down by (one write: the frames a connect sends are unchanged in number).
|
|
313
|
-
|
|
1138
|
+
// #1920: a guest's stream: its projected status now and with each heartbeat, its replay and ticks as it may see them
|
|
1139
|
+
// (§5.2), then only what guestFrameData lets through — no prompts or Needs you.
|
|
1140
|
+
const guest = guestRequestOf(req);
|
|
1141
|
+
if (guest) {
|
|
1142
|
+
streamGuests.set(res, guest);
|
|
1143
|
+
guest.streamOpened?.();
|
|
1144
|
+
}
|
|
1145
|
+
const statusFrame = () => `event: status\ndata: ${JSON.stringify(guest ? guestStatus(ctx.getUiStatus(), guest.scope()) : ctx.getUiStatus())}\n\n`;
|
|
1146
|
+
res.write(`${statusFrame()}${sseClock()}`);
|
|
314
1147
|
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
315
1148
|
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
316
1149
|
if (lastSeen !== null && ctx.webChatHistory) {
|
|
317
1150
|
const history = ctx.webChatHistory;
|
|
318
|
-
|
|
319
|
-
|
|
1151
|
+
if (guest) {
|
|
1152
|
+
// Its own projection, with no cursor: an admin's ids are not a guest's to hold (it reloads its history instead).
|
|
1153
|
+
for (const m of guestMessages(history.replayFor(lastSeen), guest, guest.scope()))
|
|
1154
|
+
res.write(sseFrame("message", m));
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
for (const m of history.replayFor(lastSeen))
|
|
1158
|
+
res.write(sseFrame("message", m, history.cursorOf(m)));
|
|
1159
|
+
}
|
|
320
1160
|
}
|
|
321
1161
|
// Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
|
|
322
1162
|
// report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
|
|
323
|
-
if (ctx.webChatHistory)
|
|
324
|
-
|
|
1163
|
+
if (ctx.webChatHistory) {
|
|
1164
|
+
const ticks = guest ? guestDeliveries(ctx.webChatHistory, guest, guest.scope()) : ctx.webChatHistory.deliveries();
|
|
1165
|
+
res.write(`event: deliveries\ndata: ${JSON.stringify(ticks)}\n\n`);
|
|
1166
|
+
}
|
|
325
1167
|
// The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
|
|
326
|
-
if (ctx.listWebPrompts)
|
|
1168
|
+
if (!guest && ctx.listWebPrompts)
|
|
327
1169
|
res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
|
|
328
1170
|
// #1386: and "Needs you" as it is now; changes follow as `needs` events.
|
|
329
|
-
if (ctx.needsYouItems)
|
|
1171
|
+
if (!guest && ctx.needsYouItems)
|
|
330
1172
|
res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
|
|
331
1173
|
ctx.sseClients.add(res);
|
|
332
1174
|
const interval = setInterval(() => {
|
|
333
1175
|
// A stream authorized once must not outlive the authorization. Re-checked
|
|
334
1176
|
// without counting as activity, or an open tab would keep an idle session
|
|
335
1177
|
// alive forever; a revoked, expired or rotated-away session ends here.
|
|
336
|
-
|
|
1178
|
+
// #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
|
|
1179
|
+
// still authenticating does not keep a revoked device's stream (with its scope) alive.
|
|
1180
|
+
if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
|
|
337
1181
|
// Close the connection too, not just the response: an ended response leaves the socket idle in
|
|
338
1182
|
// keep-alive, still held open by whoever's session just ended.
|
|
339
1183
|
const socket = req.socket;
|
|
@@ -346,7 +1190,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
346
1190
|
}
|
|
347
1191
|
try {
|
|
348
1192
|
// Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
|
|
349
|
-
res.write(measureSyncWork("web.sseStatus",
|
|
1193
|
+
res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
|
|
350
1194
|
}
|
|
351
1195
|
catch {
|
|
352
1196
|
cleanup();
|
|
@@ -380,7 +1224,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
380
1224
|
json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
|
|
381
1225
|
return true;
|
|
382
1226
|
}
|
|
383
|
-
|
|
1227
|
+
// #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
|
|
1228
|
+
const guest = guestRequestOf(req);
|
|
1229
|
+
const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
|
|
1230
|
+
json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
|
|
384
1231
|
return true;
|
|
385
1232
|
}
|
|
386
1233
|
// ── #1218: the Log tab ─────────────────────────────────
|
|
@@ -435,6 +1282,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
435
1282
|
const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
|
|
436
1283
|
if (method === "GET" && fileMatch) {
|
|
437
1284
|
const id = fileMatch[1];
|
|
1285
|
+
// #1920: a guest gets a file only when its stored instance is in scope — "no such file" otherwise, like an unknown id
|
|
1286
|
+
// (an id from another instance's chat is useless).
|
|
1287
|
+
const guest = guestRequestOf(req);
|
|
1288
|
+
if (guest) {
|
|
1289
|
+
const served = isFileId(id) ? ctx.webFiles?.servedFile?.(id) ?? null : null;
|
|
1290
|
+
if (!served || !guest.scope().has(served.instance)) {
|
|
1291
|
+
json(res, 404, { error: "No such file" });
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
438
1295
|
// #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
|
|
439
1296
|
// memory. Every other type is answered exactly as before.
|
|
440
1297
|
const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
|
|
@@ -465,6 +1322,112 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
465
1322
|
res.end(got.bytes);
|
|
466
1323
|
return true;
|
|
467
1324
|
}
|
|
1325
|
+
// ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
|
|
1326
|
+
// GET /ui/themes: { rev, themes, locked, widgets, ambient (#2117) } when the status frame's themes_rev moves (passive, web-auth.ts).
|
|
1327
|
+
// GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
|
|
1328
|
+
// POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out) | ambient (#2117: { spec } sets one,
|
|
1329
|
+
// { which? } alone clears that kind or both): a person's, from an admin session — never the CLI token, never the public
|
|
1330
|
+
// link (absent from its manifest, and refused here too).
|
|
1331
|
+
if (method === "GET" && path === "/ui/themes") {
|
|
1332
|
+
const store = ctx.themeStore;
|
|
1333
|
+
if (!store) {
|
|
1334
|
+
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets(), ambient: store.ambients() });
|
|
1338
|
+
return true;
|
|
1339
|
+
}
|
|
1340
|
+
const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
|
|
1341
|
+
if (method === "GET" && themeImage) {
|
|
1342
|
+
let name;
|
|
1343
|
+
try {
|
|
1344
|
+
name = decodeURIComponent(themeImage[1]);
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
json(res, 404, { error: "No such image" });
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
const image = ctx.themeStore?.asset(name, themeImage[2]) ?? null;
|
|
1351
|
+
if (!image) {
|
|
1352
|
+
json(res, 404, { error: "No such image" });
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
res.setHeader("Content-Type", image.mime);
|
|
1356
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1357
|
+
res.setHeader("Cache-Control", "private, max-age=86400");
|
|
1358
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
1359
|
+
res.writeHead(200);
|
|
1360
|
+
res.end(image.bytes);
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget|ambient)$/);
|
|
1364
|
+
if (method === "POST" && themeWrite) {
|
|
1365
|
+
const service = ctx.webAppearance;
|
|
1366
|
+
if (!service) {
|
|
1367
|
+
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
if (gatewayRequestContext(req)) {
|
|
1371
|
+
json(res, 403, { error: "theme_not_over_public_link" });
|
|
1372
|
+
return true;
|
|
1373
|
+
}
|
|
1374
|
+
let name;
|
|
1375
|
+
try {
|
|
1376
|
+
name = decodeURIComponent(themeWrite[1]);
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
json(res, 400, { error: "instance" });
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
const who = () => {
|
|
1383
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1384
|
+
if (verdict.kind !== "allow")
|
|
1385
|
+
return 401;
|
|
1386
|
+
return verdict.via === "session" && verdict.session?.tier === "admin" ? 200 : 403;
|
|
1387
|
+
};
|
|
1388
|
+
const first = who();
|
|
1389
|
+
if (first !== 200) {
|
|
1390
|
+
json(res, first, { error: first === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
|
|
1391
|
+
return true;
|
|
1392
|
+
}
|
|
1393
|
+
(async () => {
|
|
1394
|
+
let body = {};
|
|
1395
|
+
try {
|
|
1396
|
+
body = await parseBody(req);
|
|
1397
|
+
}
|
|
1398
|
+
catch {
|
|
1399
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1403
|
+
return;
|
|
1404
|
+
const now = who();
|
|
1405
|
+
if (now !== 200) {
|
|
1406
|
+
json(res, now, { error: now === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
if (!ctx.isKnownInstance?.(name)) {
|
|
1410
|
+
json(res, 404, { error: "unknown instance" });
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const r = themeWrite[2] === "reset" ? service.humanReset(name)
|
|
1414
|
+
: themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
|
|
1415
|
+
: themeWrite[2] === "ambient" ? service.humanSetAmbient(name, body.spec ?? null, body.which)
|
|
1416
|
+
: typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
|
|
1417
|
+
if (typeof r.error === "string") {
|
|
1418
|
+
json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
json(res, 200, r);
|
|
1422
|
+
})().catch(err => {
|
|
1423
|
+
ctx.logger.error({ err: err.message }, "Theme reset/lock failed");
|
|
1424
|
+
try {
|
|
1425
|
+
json(res, 500, { error: "Theme change failed" });
|
|
1426
|
+
}
|
|
1427
|
+
catch { /* already answered */ }
|
|
1428
|
+
});
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
468
1431
|
// ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
|
|
469
1432
|
const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
|
|
470
1433
|
if (method === "GET" && imageMatch) {
|
|
@@ -634,6 +1597,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
634
1597
|
return true;
|
|
635
1598
|
}
|
|
636
1599
|
const click = ctx.clickWebReplyButton.bind(ctx);
|
|
1600
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
637
1601
|
(async () => {
|
|
638
1602
|
let body;
|
|
639
1603
|
try {
|
|
@@ -650,13 +1614,29 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
650
1614
|
json(res, 400, { error: "instance, id and index required" });
|
|
651
1615
|
return;
|
|
652
1616
|
}
|
|
1617
|
+
// #1920 step 2b: a guest clicks only on a reply it may see (§5.2), of an in-scope instance, while guests may chat.
|
|
1618
|
+
const guest = guestRequestOf(req);
|
|
1619
|
+
const refused = guestChatRefusal(guest, instance);
|
|
1620
|
+
if (refused) {
|
|
1621
|
+
json(res, refused.status, { error: refused.error });
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (guest) {
|
|
1625
|
+
const row = guest.history?.()?.findByButtons(instance, id);
|
|
1626
|
+
if (!row || !guestSeesMessage(row, guest, guest.scope())) {
|
|
1627
|
+
json(res, 404, { error: "not found" });
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
653
1631
|
// #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
|
|
654
1632
|
// theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
|
|
655
1633
|
// gate (a session revoked meanwhile) clicks nothing as anyone.
|
|
656
1634
|
const person = webPersonOr401(req, res, url, ctx);
|
|
657
1635
|
if (!person)
|
|
658
1636
|
return;
|
|
659
|
-
|
|
1637
|
+
// #1920 review: a guest's choice reaches the agent marked as a guest's, as its typed messages do.
|
|
1638
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
1639
|
+
const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name, ...(guest ? { guest: true } : {}), authority });
|
|
660
1640
|
json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
661
1641
|
})().catch(err => {
|
|
662
1642
|
ctx.logger.error({ err: err.message }, "Web reply-button click failed");
|
|
@@ -679,15 +1659,19 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
679
1659
|
json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
|
|
680
1660
|
return true;
|
|
681
1661
|
}
|
|
682
|
-
//
|
|
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.
|
|
1662
|
+
// Host preferences preserve their scope; only five declared owner slots use the current human session bucket.
|
|
685
1663
|
if (path === "/ui/prefs" && method === "GET") {
|
|
686
1664
|
if (!ctx.webPrefs) {
|
|
687
1665
|
json(res, 404, { error: "Preferences are not kept on this fleet" });
|
|
688
1666
|
return true;
|
|
689
1667
|
}
|
|
690
|
-
|
|
1668
|
+
const viewer = prefsViewer(req, url, ctx);
|
|
1669
|
+
if (!viewer) {
|
|
1670
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1671
|
+
return true;
|
|
1672
|
+
}
|
|
1673
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
1674
|
+
json(res, 200, prefsView(ctx.webPrefs, viewer));
|
|
691
1675
|
return true;
|
|
692
1676
|
}
|
|
693
1677
|
if (path === "/ui/prefs" && method === "POST") {
|
|
@@ -700,17 +1684,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
700
1684
|
json(res, 403, { error: "prefs_not_over_public_link" });
|
|
701
1685
|
return true;
|
|
702
1686
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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) {
|
|
1687
|
+
const refuse = (viewer) => json(res, viewer ? 403 : 401, { error: viewer ? "prefs_read_only" : WEB_TOKEN_INVALID_MESSAGE });
|
|
1688
|
+
const first = prefsViewer(req, url, ctx);
|
|
1689
|
+
if (!first?.canWrite) {
|
|
714
1690
|
refuse(first);
|
|
715
1691
|
return true;
|
|
716
1692
|
}
|
|
@@ -734,21 +1710,39 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
734
1710
|
json(res, 400, { error: parsed.error });
|
|
735
1711
|
return;
|
|
736
1712
|
}
|
|
737
|
-
const now =
|
|
738
|
-
if (now
|
|
1713
|
+
const now = prefsViewer(req, url, ctx);
|
|
1714
|
+
if (!now?.canWrite) {
|
|
739
1715
|
refuse(now);
|
|
740
1716
|
return;
|
|
741
1717
|
}
|
|
742
|
-
|
|
743
|
-
|
|
1718
|
+
if (now.person !== first.person || now.epoch !== first.epoch || now.via !== first.via) {
|
|
1719
|
+
json(res, 409, { error: "prefs_person_changed" });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
|
|
1723
|
+
if (personal && now.person === null) {
|
|
1724
|
+
json(res, 403, { error: "prefs_need_a_signed_in_person" });
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
// A scope marker rejects a queued old page after a different login, including the same person's new session.
|
|
1728
|
+
// It supplies neither identity nor authority; the current verified session still chooses the bucket.
|
|
1729
|
+
if (personal && body.ownerEpoch !== now.epoch) {
|
|
1730
|
+
json(res, 409, { error: "prefs_person_changed" });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const before = prefs.readForViewer(now.person);
|
|
1734
|
+
const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true,
|
|
1735
|
+
validateEffect: (person, key, id) => ctx.effectService ? ctx.effectService.validateOwnerSelection(person, key, id) : "effects_unavailable" });
|
|
744
1736
|
if (!written.ok) {
|
|
745
1737
|
json(res, written.status, { error: written.error });
|
|
746
1738
|
return;
|
|
747
1739
|
}
|
|
748
|
-
|
|
1740
|
+
const dropped = written.changed.filter(isOwnerEffectKey).flatMap(key => typeof before.values[key] === "string" && before.values[key] !== written.view.values[key] ? [before.values[key]] : []);
|
|
1741
|
+
if (dropped.length)
|
|
1742
|
+
ctx.effectService?.collectOwnerReplaced(dropped);
|
|
749
1743
|
if (written.changed.length)
|
|
750
1744
|
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
751
|
-
json(res, 200, { ...written.view, changed: written.changed });
|
|
1745
|
+
json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
|
|
752
1746
|
})().catch(err => {
|
|
753
1747
|
ctx.logger.error({ err: err.message }, "Saving web preferences failed");
|
|
754
1748
|
try {
|
|
@@ -758,6 +1752,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
758
1752
|
});
|
|
759
1753
|
return true;
|
|
760
1754
|
}
|
|
1755
|
+
// #1629 r4 §3: owner CSS — one person's own stylesheet, for their own screens. Every route is a signed-in human's
|
|
1756
|
+
// session: never the CLI's header token (no person, and the way an agent could reach it), never the public link
|
|
1757
|
+
// (absent from its manifest, and refused here too), reads included. The stylesheet and its assets are that session's
|
|
1758
|
+
// person's only: no name or identity in any URL.
|
|
1759
|
+
if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
|
|
1760
|
+
return handleOwnerCss(req, res, url, ctx, path, method);
|
|
1761
|
+
}
|
|
1762
|
+
// #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
|
|
1763
|
+
if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
|
|
1764
|
+
return handleMods(req, res, url, ctx, path, method);
|
|
1765
|
+
// Posters remain behind the common session gate, with a closed GET-only public manifest.
|
|
1766
|
+
if (path === "/ui/effect-posters" || path.startsWith("/ui/effect-posters/"))
|
|
1767
|
+
return handleEffectPosters(res, url, ctx, path, method);
|
|
1768
|
+
// Canvas effects are absent from the public gateway manifest and independently refused above.
|
|
1769
|
+
if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
|
|
1770
|
+
return handleEffectTrials(req, res, url, ctx, path, method);
|
|
1771
|
+
if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
|
|
1772
|
+
return handleEffects(req, res, url, ctx, path, method);
|
|
761
1773
|
// #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
|
|
762
1774
|
// token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
|
|
763
1775
|
if (method === "POST" && path === "/ui/needs/ack") {
|
|
@@ -802,12 +1814,57 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
802
1814
|
// #1269: an instance's chat command (/ctx, /compact, /clear, /model, …) — a session write (CSRF, checked by the
|
|
803
1815
|
// gate), run through the platforms' own handlers and command table. The public link is told it is one (/save is
|
|
804
1816
|
// refused there).
|
|
1817
|
+
// ── #1939 S2b: this browser's authorization — what the app shows (GET), and "Authorize this browser" (POST: posts the
|
|
1818
|
+
// Confirm card in the account's DM). Judged on this request (§3), never touching the session.
|
|
1819
|
+
if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
|
|
1820
|
+
const judge = () => {
|
|
1821
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1822
|
+
return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: classifyWebPeer(req) };
|
|
1823
|
+
};
|
|
1824
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1825
|
+
if (method === "GET") {
|
|
1826
|
+
if (!ctx.webSessionAuthorization) {
|
|
1827
|
+
json(res, 404, { error: "not available" });
|
|
1828
|
+
return true;
|
|
1829
|
+
}
|
|
1830
|
+
json(res, 200, ctx.webSessionAuthorization(judge()));
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1833
|
+
if (!ctx.requestSessionAuthorization) {
|
|
1834
|
+
json(res, 404, { error: "not available" });
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
const ask = ctx.requestSessionAuthorization.bind(ctx);
|
|
1838
|
+
(async () => {
|
|
1839
|
+
try {
|
|
1840
|
+
await parseBody(req);
|
|
1841
|
+
}
|
|
1842
|
+
catch {
|
|
1843
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1847
|
+
return;
|
|
1848
|
+
const r = await ask(judge()); // asked again now the body is in
|
|
1849
|
+
// A refusal is an answer the page shows (pending, three this hour, why not), not a failed request: 200 with
|
|
1850
|
+
// ok: false — a 4xx would be logged as an error in the browser's console every time.
|
|
1851
|
+
json(res, 200, r);
|
|
1852
|
+
})().catch(err => {
|
|
1853
|
+
ctx.logger.error({ err: err.message }, "Web authorization request failed");
|
|
1854
|
+
try {
|
|
1855
|
+
json(res, 500, { error: "Request failed" });
|
|
1856
|
+
}
|
|
1857
|
+
catch { /* already answered */ }
|
|
1858
|
+
});
|
|
1859
|
+
return true;
|
|
1860
|
+
}
|
|
805
1861
|
if (method === "POST" && path === "/ui/command") {
|
|
806
1862
|
if (!ctx.webCommand) {
|
|
807
1863
|
json(res, 404, { error: "Not available" });
|
|
808
1864
|
return true;
|
|
809
1865
|
}
|
|
810
1866
|
const run = ctx.webCommand.bind(ctx);
|
|
1867
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
811
1868
|
(async () => {
|
|
812
1869
|
let body;
|
|
813
1870
|
try {
|
|
@@ -836,7 +1893,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
836
1893
|
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
837
1894
|
if (!person)
|
|
838
1895
|
return;
|
|
839
|
-
const
|
|
1896
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
1897
|
+
const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority });
|
|
840
1898
|
json(res, r.status, r.body);
|
|
841
1899
|
})().catch(err => {
|
|
842
1900
|
ctx.logger.error({ err: err.message }, "Web command failed");
|
|
@@ -861,11 +1919,17 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
861
1919
|
return true;
|
|
862
1920
|
}
|
|
863
1921
|
// Own keys only: "constructor" is in every object and names no instance.
|
|
864
|
-
const known = (ctx
|
|
1922
|
+
const known = registeredChatInstance(ctx, name) || ctx.daemons.has(name);
|
|
865
1923
|
if (!ctx.cancelInstance || !known) {
|
|
866
1924
|
json(res, 404, { error: `Instance not found: ${name}` });
|
|
867
1925
|
return true;
|
|
868
1926
|
}
|
|
1927
|
+
// #1920 step 2b: a guest's Cancel is the platform button's (anyone allowed there), while guests may chat.
|
|
1928
|
+
const cancelRefused = guestChatRefusal(guestRequestOf(req), name);
|
|
1929
|
+
if (cancelRefused) {
|
|
1930
|
+
json(res, cancelRefused.status, { error: cancelRefused.error });
|
|
1931
|
+
return true;
|
|
1932
|
+
}
|
|
869
1933
|
if (!ctx.cancelInstance(name)) {
|
|
870
1934
|
json(res, 409, { error: `${name} is not running` });
|
|
871
1935
|
return true;
|
|
@@ -1275,6 +2339,47 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1275
2339
|
}
|
|
1276
2340
|
return true;
|
|
1277
2341
|
}
|
|
2342
|
+
// #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
|
|
2343
|
+
// usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
|
|
2344
|
+
// from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
|
|
2345
|
+
if (method === "GET" && path === "/ui/usage-report") {
|
|
2346
|
+
// Who may have it, asked on arrival and again before it is sent (the report is awaited: a switch turned off, a
|
|
2347
|
+
// session signed out or a public link ended meanwhile gets nothing). The same boundary both times, no new gate.
|
|
2348
|
+
const refusal = () => {
|
|
2349
|
+
if (!ctx.usageReport || ctx.fleetConfig?.web?.usage_panel === false)
|
|
2350
|
+
return [404, "Not available"];
|
|
2351
|
+
if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.usage_report_on_public_link !== true)
|
|
2352
|
+
return [403, "usage_report_not_over_public_link"];
|
|
2353
|
+
return null;
|
|
2354
|
+
};
|
|
2355
|
+
// Over the public link, where to turn it on: Settings → Web on the dashboard reached directly (this computer's own
|
|
2356
|
+
// address — the public link's Settings only reads). Never the public origin's own Settings, which cannot change it.
|
|
2357
|
+
const answer = ([status, error]) => json(res, status, {
|
|
2358
|
+
error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web` } : {})
|
|
2359
|
+
});
|
|
2360
|
+
const first = refusal();
|
|
2361
|
+
if (first) {
|
|
2362
|
+
answer(first);
|
|
2363
|
+
return true;
|
|
2364
|
+
}
|
|
2365
|
+
const admitted = admissionOf(req, url, ctx);
|
|
2366
|
+
if (!admitted) {
|
|
2367
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2368
|
+
return true;
|
|
2369
|
+
}
|
|
2370
|
+
void import("./usage-report.js").then(({ usageRange, usageGroup }) => ctx.usageReport({
|
|
2371
|
+
range: usageRange(url.searchParams.get("range"), url.searchParams.get("from") ?? undefined, url.searchParams.get("to") ?? undefined),
|
|
2372
|
+
group: usageGroup(url.searchParams.get("group")),
|
|
2373
|
+
})).then((report) => {
|
|
2374
|
+
const now = refusal();
|
|
2375
|
+
if (now)
|
|
2376
|
+
return answer(now);
|
|
2377
|
+
if (admissionOf(req, url, ctx) !== admitted)
|
|
2378
|
+
return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2379
|
+
json(res, 200, report);
|
|
2380
|
+
}, (err) => { ctx.logger.error({ err }, "usage report failed"); json(res, 500, { error: "Usage report failed" }); });
|
|
2381
|
+
return true;
|
|
2382
|
+
}
|
|
1278
2383
|
// #1468: the prompt-cache expiry analysis. Opening it is a person's navigation, like any Fleet tab (it counts as
|
|
1279
2384
|
// use); the page never re-reads it on a timer. Answered from the ledgers as they are; a catch-up runs behind.
|
|
1280
2385
|
if (method === "GET" && path === "/ui/cache") {
|
|
@@ -1292,6 +2397,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1292
2397
|
json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
|
|
1293
2398
|
return true;
|
|
1294
2399
|
}
|
|
2400
|
+
// #1918: the org canvas's traffic on opening, and the public link's (which has no stream) on its poll cadence. The
|
|
2401
|
+
// fleet works it out at most once every two seconds and caps it, whoever asks how often.
|
|
2402
|
+
if (method === "GET" && path === "/ui/traffic") {
|
|
2403
|
+
json(res, 200, ctx.trafficSnapshot?.() ?? { now: new Date().toISOString(), items: [], delegations: [] });
|
|
2404
|
+
return true;
|
|
2405
|
+
}
|
|
1295
2406
|
if (method === "GET" && path === "/ui/teams") {
|
|
1296
2407
|
const teams = ctx.fleetConfig?.teams ?? {};
|
|
1297
2408
|
json(res, 200, { teams });
|
|
@@ -1478,8 +2589,30 @@ function webEchoPreview(message, names) {
|
|
|
1478
2589
|
const truncated = message.length > 500 || preview.length > 1400;
|
|
1479
2590
|
return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
|
|
1480
2591
|
}
|
|
2592
|
+
/** Registration and liveness are different: a paused CLI may have neither daemon nor IPC. */
|
|
2593
|
+
function registeredChatInstance(ctx, name) {
|
|
2594
|
+
return (!!ctx.fleetConfig && Object.hasOwn(ctx.fleetConfig.instances, name)) || ctx.isClassicInstance?.(name) === true;
|
|
2595
|
+
}
|
|
2596
|
+
function uploadRecipient(ctx, name) {
|
|
2597
|
+
return registeredChatInstance(ctx, name)
|
|
2598
|
+
&& (ctx.instanceIpcClients.has(name) || ctx.getInstanceStatus?.(name) === "paused");
|
|
2599
|
+
}
|
|
2600
|
+
/** Closed, path-free explanations; arbitrary launch errors stay in the host log. */
|
|
2601
|
+
function webDeliveryFailure(err) {
|
|
2602
|
+
const message = err instanceof Error ? err.message : "";
|
|
2603
|
+
if (/No warm slot is free/.test(message))
|
|
2604
|
+
return { reason: "wake_capacity", error: "No slot is free to wake this instance. Retry when another instance is idle." };
|
|
2605
|
+
if (/login|log in|sign in|\bauth(?:entication|orization)?\b|credential|api.?key/i.test(message))
|
|
2606
|
+
return { reason: "wake_credentials", error: "The instance could not sign in. Check its account on the host, then retry." };
|
|
2607
|
+
if (/timed? out|timeout/i.test(message))
|
|
2608
|
+
return { reason: "wake_timeout", error: "The instance did not become ready in time. Retry, or check its status on the host." };
|
|
2609
|
+
if (/Cannot wake stopped|no longer configured/.test(message))
|
|
2610
|
+
return { reason: "instance_stopped", error: "This instance was stopped or removed. Start it on the host before retrying." };
|
|
2611
|
+
return { reason: "delivery_unavailable", error: "The instance could not receive this message. Check its status on the host, then retry." };
|
|
2612
|
+
}
|
|
1481
2613
|
/** Handle POST /ui/send — extracted for readability. */
|
|
1482
2614
|
function handleSendMessage(req, res, ctx) {
|
|
2615
|
+
const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
|
|
1483
2616
|
let body = "";
|
|
1484
2617
|
let size = 0;
|
|
1485
2618
|
req.on("data", (chunk) => {
|
|
@@ -1503,8 +2636,19 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1503
2636
|
return;
|
|
1504
2637
|
}
|
|
1505
2638
|
const { instance, message: typed, attachments = [] } = parsed.data;
|
|
1506
|
-
|
|
1507
|
-
|
|
2639
|
+
// #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
|
|
2640
|
+
const guest = guestRequestOf(req);
|
|
2641
|
+
const refused = guestChatRefusal(guest, instance);
|
|
2642
|
+
if (refused) {
|
|
2643
|
+
json(res, refused.status, { error: refused.error });
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
if (guest && guestCommandText(typed)) {
|
|
2647
|
+
json(res, 403, { error: "guest_no_commands" });
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
const registered = registeredChatInstance(ctx, instance);
|
|
2651
|
+
if (!registered && !ctx.instanceIpcClients.has(instance)) {
|
|
1508
2652
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1509
2653
|
return;
|
|
1510
2654
|
}
|
|
@@ -1527,6 +2671,12 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1527
2671
|
json(res, 403, { error: t("web.raw_public_refused") });
|
|
1528
2672
|
return;
|
|
1529
2673
|
}
|
|
2674
|
+
// Recheck the same admission all the way through a wake, before the facade's actual IPC handoff.
|
|
2675
|
+
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
2676
|
+
const admission = admissionOf(req, requestUrl, ctx);
|
|
2677
|
+
const stillCurrent = () => !res.destroyed && admission !== null && admissionOf(req, requestUrl, ctx) === admission
|
|
2678
|
+
&& (!guest || (guest.current() && !guestChatRefusal(guest, instance)))
|
|
2679
|
+
&& (registered ? registeredChatInstance(ctx, instance) : ctx.instanceIpcClients.has(instance));
|
|
1530
2680
|
const message = typed;
|
|
1531
2681
|
// #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
|
|
1532
2682
|
// body is in: a session revoked meanwhile sends nothing (not as the operator).
|
|
@@ -1536,6 +2686,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1536
2686
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1537
2687
|
return;
|
|
1538
2688
|
}
|
|
2689
|
+
// #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
|
|
2690
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
|
|
1539
2691
|
const ts = new Date().toISOString();
|
|
1540
2692
|
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
1541
2693
|
const messageId = newWebMessageId();
|
|
@@ -1550,11 +2702,13 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1550
2702
|
&& !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
|
|
1551
2703
|
// Add no runtime credentials or local attachment paths to the display copy.
|
|
1552
2704
|
const preview = webEchoPreview(message, files.map(f => f.name));
|
|
1553
|
-
// ClassicBot echo (#1320 part B): same ordering lane,
|
|
1554
|
-
//
|
|
2705
|
+
// ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
|
|
2706
|
+
// the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
|
|
1555
2707
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
1556
2708
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1557
|
-
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
|
|
2709
|
+
const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
|
|
2710
|
+
if (posted > 0)
|
|
2711
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
|
|
1558
2712
|
}) : undefined;
|
|
1559
2713
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1560
2714
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -1562,13 +2716,15 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1562
2716
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
1563
2717
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
1564
2718
|
return;
|
|
1565
|
-
|
|
2719
|
+
const sent = await syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
|
|
2720
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
|
|
2721
|
+
return sent;
|
|
1566
2722
|
}) : undefined;
|
|
1567
2723
|
let delivered;
|
|
1568
2724
|
try {
|
|
1569
2725
|
const deliver = (name, payload) => ctx.deliverHumanToInstance
|
|
1570
|
-
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
|
|
1571
|
-
: ctx.deliverToInstance(name, payload);
|
|
2726
|
+
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed, { stillCurrent })
|
|
2727
|
+
: ctx.deliverToInstance(name, payload, { stillCurrent });
|
|
1572
2728
|
delivered = await deliver(instance, {
|
|
1573
2729
|
type: "fleet_inbound",
|
|
1574
2730
|
content: delivery.text,
|
|
@@ -1580,6 +2736,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1580
2736
|
ts,
|
|
1581
2737
|
thread_id: topicId != null ? String(topicId) : "",
|
|
1582
2738
|
source: "web",
|
|
2739
|
+
...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
|
|
1583
2740
|
adapter_id: syncAdapter?.id,
|
|
1584
2741
|
...delivery.meta,
|
|
1585
2742
|
},
|
|
@@ -1593,7 +2750,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1593
2750
|
settleClassicEcho?.(false);
|
|
1594
2751
|
ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
|
|
1595
2752
|
ctx.logger.error({ err, instance }, "Web message delivery failed");
|
|
1596
|
-
json(res, 503,
|
|
2753
|
+
json(res, 503, webDeliveryFailure(err));
|
|
1597
2754
|
return;
|
|
1598
2755
|
}
|
|
1599
2756
|
if (delivered === false) {
|
|
@@ -1601,7 +2758,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1601
2758
|
settleClassicEcho?.(false);
|
|
1602
2759
|
ctx.webFiles?.release(files);
|
|
1603
2760
|
ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
|
|
1604
|
-
json(res, 503, { error: "
|
|
2761
|
+
json(res, 503, { reason: "delivery_changed", error: "The session or instance changed before this message was sent. Sign in again if needed, then retry." });
|
|
1605
2762
|
return;
|
|
1606
2763
|
}
|
|
1607
2764
|
settleEcho?.(true);
|
|
@@ -1616,7 +2773,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1616
2773
|
ctx.webFiles?.commit(files);
|
|
1617
2774
|
ctx.lastInboundUser.set(instance, person.name);
|
|
1618
2775
|
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
1619
|
-
|
|
2776
|
+
// #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
|
|
2777
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}) });
|
|
1620
2778
|
json(res, 200, { sent: true, messageId });
|
|
1621
2779
|
}
|
|
1622
2780
|
catch {
|
|
@@ -1640,7 +2798,14 @@ function handleUpload(req, res, url, ctx) {
|
|
|
1640
2798
|
json(res, 404, { error: "uploads are not available" });
|
|
1641
2799
|
return;
|
|
1642
2800
|
}
|
|
1643
|
-
|
|
2801
|
+
// #1920 step 2b: a guest's upload is for an in-scope instance's message, while guests may chat.
|
|
2802
|
+
const guestRefused = guestChatRefusal(guestRequestOf(req), instance);
|
|
2803
|
+
if (guestRefused) {
|
|
2804
|
+
req.resume();
|
|
2805
|
+
json(res, guestRefused.status, { error: guestRefused.error });
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !uploadRecipient(ctx, instance)) {
|
|
1644
2809
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1645
2810
|
return;
|
|
1646
2811
|
}
|
|
@@ -1703,11 +2868,9 @@ function handleUpload(req, res, url, ctx) {
|
|
|
1703
2868
|
req.on("error", () => drop()); // the client went away
|
|
1704
2869
|
req.on("close", () => { if (!over && req.complete === false)
|
|
1705
2870
|
drop(); });
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
|
|
1709
|
-
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
|
|
1710
|
-
const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
|
|
2871
|
+
// Recheck registration and delivery readiness after both waits. A paused recipient may have no IPC;
|
|
2872
|
+
// staging a file never wakes it. A stopped or deleted recipient must not regain an upload id.
|
|
2873
|
+
const deliverable = () => uploadRecipient(ctx, instance);
|
|
1711
2874
|
const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
|
|
1712
2875
|
req.on("end", () => {
|
|
1713
2876
|
if (over)
|
|
@@ -1830,32 +2993,73 @@ function serveMediaRange(req, res, files, id, file) {
|
|
|
1830
2993
|
res.writeHead(ranged ? 206 : 200);
|
|
1831
2994
|
res.end(got.bytes);
|
|
1832
2995
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2996
|
+
function prefsViewer(req, url, ctx) {
|
|
2997
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
2998
|
+
if (v.kind !== "allow")
|
|
2999
|
+
return null;
|
|
3000
|
+
// #1920: a guest is a guest on every listener. The public link's page and reads too — before, a guest signed in through
|
|
3001
|
+
// it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
|
|
3002
|
+
// scope. Its account (phase 3) names it on its page.
|
|
3003
|
+
const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true, ...(v.session.owner ? { owner: v.session.owner } : {}) } : {};
|
|
3004
|
+
if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
|
|
3005
|
+
const person = webPersonKey(v.session.owner);
|
|
3006
|
+
return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
|
|
3007
|
+
}
|
|
3008
|
+
return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
|
|
3009
|
+
}
|
|
3010
|
+
const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
|
|
3011
|
+
// #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
|
|
3012
|
+
const prefsView = (prefs, v) => {
|
|
3013
|
+
const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
|
|
3014
|
+
return v?.guest ? guestPrefs(view) : view;
|
|
3015
|
+
};
|
|
3016
|
+
function shellHtmlTag(ctx, mode, viewer) {
|
|
1841
3017
|
if (mode !== "full" || !ctx.webPrefs)
|
|
1842
3018
|
return '<html lang="en">';
|
|
1843
|
-
const view = ctx.webPrefs
|
|
3019
|
+
const view = prefsView(ctx.webPrefs, viewer);
|
|
1844
3020
|
const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
|
|
1845
3021
|
const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1846
|
-
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(
|
|
3022
|
+
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
|
|
1847
3023
|
}
|
|
1848
|
-
|
|
3024
|
+
/**
|
|
3025
|
+
* #1629 r4 §3: the owner CSS this load links (its sha), or null. Only the full app of a signed-in human session — never
|
|
3026
|
+
* the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
|
|
3027
|
+
*/
|
|
3028
|
+
function shellOwnerCss(ctx, mode, viewer) {
|
|
3029
|
+
return mode === "full" && !viewer?.guest && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
|
|
3030
|
+
}
|
|
3031
|
+
function guestBodyAttrs(ctx, owner, attr) {
|
|
3032
|
+
const badge = ctx.guestBadge?.(owner);
|
|
3033
|
+
return ` data-tier="guest"${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
|
|
3034
|
+
}
|
|
3035
|
+
/** A separate session fence for Chat playback: public/guest viewers acquire no owner-pref identity. */
|
|
3036
|
+
function shellEffect(req, ctx, mode) {
|
|
3037
|
+
if (mode !== "full" || typeof ctx.effectBoot !== "string" || !/^[0-9a-f]{32}$/.test(ctx.effectBoot))
|
|
3038
|
+
return null;
|
|
3039
|
+
const v = evaluateWebRequest(req, new URL(req.url ?? "/", "http://localhost"), ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
3040
|
+
if (v.kind !== "allow" || v.via !== "session" || !v.session || !/^[a-f0-9]{16}$/.test(v.session.handle))
|
|
3041
|
+
return null;
|
|
3042
|
+
return { boot: ctx.effectBoot, epoch: v.session.handle, scope: gatewayRequestContext(req) || v.session.tier === "guest" ? "chat" : "owner" };
|
|
3043
|
+
}
|
|
3044
|
+
function shellBodyTag(req, ctx, mode, viewer) {
|
|
1849
3045
|
const p = shellPreview(req, ctx, mode);
|
|
3046
|
+
const ownerCss = shellOwnerCss(ctx, mode, viewer), effect = shellEffect(req, ctx, mode);
|
|
3047
|
+
let ownerCssState = mode === "full" && (gatewayRequestContext(req) || viewer?.guest) ? "off" : "unknown";
|
|
3048
|
+
if (mode === "full" && !viewer?.guest && viewer?.person != null && ctx.ownerCss) {
|
|
3049
|
+
ownerCssState = ctx.ownerCss.effectState(viewer.person);
|
|
3050
|
+
if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
|
|
3051
|
+
ownerCssState = "unknown";
|
|
3052
|
+
}
|
|
1850
3053
|
const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1851
3054
|
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)}"`
|
|
3055
|
+
+ `${effect ? ` data-effect-boot="${effect.boot}" data-effect-epoch="${effect.epoch}" data-effect-scope="${effect.scope}"` : ""}`
|
|
1852
3056
|
+ ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
|
|
1853
3057
|
// #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
|
|
1854
3058
|
+ ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
|
|
1855
3059
|
// #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
|
|
1856
3060
|
+ `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
|
|
1857
|
-
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's
|
|
1858
|
-
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
|
|
3061
|
+
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔ — never to a guest (#1920).
|
|
3062
|
+
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
|
|
1859
3063
|
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
1860
3064
|
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
1861
3065
|
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
@@ -1863,14 +3067,19 @@ function shellBodyTag(req, ctx, mode) {
|
|
|
1863
3067
|
+ ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
|
|
1864
3068
|
// #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
|
|
1865
3069
|
+ ` data-fleet-tz="${attr(resolveTz())}"`
|
|
3070
|
+
// #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
|
|
3071
|
+
+ ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
|
|
1866
3072
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|
|
1867
|
-
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}
|
|
3073
|
+
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
|
|
3074
|
+
// #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
|
|
3075
|
+
+ `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
|
|
1868
3076
|
}
|
|
1869
3077
|
/** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
|
|
1870
3078
|
* when this load chose one. */
|
|
1871
3079
|
export function serveAppShell(req, res, ctx, mode) {
|
|
1872
3080
|
try {
|
|
1873
3081
|
const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
|
|
3082
|
+
const viewer = mode === "full" ? prefsViewer(req, new URL(req.url ?? "/", "http://localhost"), ctx) : null;
|
|
1874
3083
|
const p = shellPreview(req, ctx, mode);
|
|
1875
3084
|
// #1589: media plays only from this listener's own file route (path-scoped, like frame-src), at the address this page
|
|
1876
3085
|
// was loaded from (a Host the host guard accepted); never on the public link, nor where no chat is shown.
|
|
@@ -1879,8 +3088,12 @@ export function serveAppShell(req, res, ctx, mode) {
|
|
|
1879
3088
|
const mediaSrc = own ? `${own}/ui/file/` : "'none'";
|
|
1880
3089
|
// A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
|
|
1881
3090
|
// the page may load); every other page frames exactly the origin this load chose (#1723).
|
|
1882
|
-
const
|
|
1883
|
-
|
|
3091
|
+
const previewFrames = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
|
|
3092
|
+
// This fixed trusted document is independent of HTML preview. Its own response confines all Worker execution.
|
|
3093
|
+
const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
|
|
3094
|
+
const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
|
|
3095
|
+
const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
|
|
3096
|
+
sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
1884
3097
|
}
|
|
1885
3098
|
catch {
|
|
1886
3099
|
json(res, 500, { error: "app.html not found" });
|