@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/README.zh-TW.md +7 -1
- package/dist/agent-cli-instructions.md +9 -2
- package/dist/agent-cli.js +63 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +4 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +4 -1
- package/dist/agent-file-read.js +189 -16
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.d.ts +11 -0
- package/dist/agent-whats-new.js +29 -5
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +45 -13
- 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/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +18 -8
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex.js +15 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +2 -0
- package/dist/backend/credential-profile.js +2 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/types.d.ts +6 -0
- package/dist/backend/types.js +15 -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 +23 -3
- package/dist/backend-command.js +25 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/bug-report-cli.d.ts +30 -0
- package/dist/bug-report-cli.js +138 -0
- package/dist/bug-report-cli.js.map +1 -0
- package/dist/bug-report.d.ts +47 -0
- package/dist/bug-report.js +136 -0
- package/dist/bug-report.js.map +1 -0
- 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-ledger.d.ts +16 -3
- package/dist/cache-ledger.js +33 -13
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-service.d.ts +55 -3
- package/dist/cache-service.js +152 -16
- 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 +9 -0
- package/dist/channel/adapters/discord.js +49 -4
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +22 -11
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +6 -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 +224 -111
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +129 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -5
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-notice.d.ts +7 -0
- package/dist/cross-instance-notice.js +10 -0
- package/dist/cross-instance-notice.js.map +1 -1
- package/dist/daemon.d.ts +24 -1
- package/dist/daemon.js +151 -19
- 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 +17 -1
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/diagnostic-redaction.d.ts +10 -0
- package/dist/diagnostic-redaction.js +167 -0
- package/dist/diagnostic-redaction.js.map +1 -0
- package/dist/fleet-context.d.ts +10 -0
- package/dist/fleet-manager.d.ts +505 -15
- package/dist/fleet-manager.js +1870 -204
- 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/bug-report/SKILL.md +31 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +17 -6
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
- 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/scheduling/SKILL.md +4 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +190 -47
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +545 -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-config-impact.js +7 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +10 -1
- package/dist/instance-lifecycle.js +79 -18
- 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 +173 -12
- package/dist/locale.js.map +1 -1
- package/dist/log-steps.d.ts +1 -0
- package/dist/log-steps.js +15 -0
- package/dist/log-steps.js.map +1 -1
- package/dist/login-controller.d.ts +45 -0
- package/dist/login-controller.js +101 -5
- package/dist/login-controller.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +96 -0
- package/dist/ls-attach-resolve.js +180 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- package/dist/ls-rows.d.ts +12 -0
- package/dist/ls-rows.js +82 -3
- package/dist/ls-rows.js.map +1 -1
- 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.d.ts +10 -0
- package/dist/needs-you.js +2 -1
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.d.ts +11 -0
- package/dist/outbound-handlers.js +139 -11
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +200 -4
- package/dist/outbound-schemas.js +124 -11
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +17 -4
- 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 +19 -1
- package/dist/quickstart-api.js +94 -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/restart-progress.d.ts +80 -1
- package/dist/restart-progress.js +306 -21
- 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/db.d.ts +40 -13
- package/dist/scheduler/db.js +123 -89
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/task-board-text.d.ts +26 -0
- package/dist/scheduler/task-board-text.js +135 -0
- package/dist/scheduler/task-board-text.js.map +1 -0
- package/dist/scheduler/task-board.d.ts +650 -0
- package/dist/scheduler/task-board.js +1938 -0
- package/dist/scheduler/task-board.js.map +1 -0
- package/dist/scheduler/types.d.ts +6 -1
- package/dist/search/search-docs.d.ts +58 -0
- package/dist/search/search-docs.js +63 -0
- package/dist/search/search-docs.js.map +1 -0
- package/dist/search/search-index.d.ts +141 -0
- package/dist/search/search-index.js +440 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search/search-store.d.ts +122 -0
- package/dist/search/search-store.js +236 -0
- package/dist/search/search-store.js.map +1 -0
- package/dist/search/search-worker.d.ts +1 -0
- package/dist/search/search-worker.js +163 -0
- package/dist/search/search-worker.js.map +1 -0
- package/dist/service-installer.js +12 -0
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +23 -0
- package/dist/settings-api.js +164 -3
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +104 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +3 -1
- package/dist/settings-confirmation.js +2 -0
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +34 -3
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +11 -0
- package/dist/settings-http-confirmation.js +10 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +23 -7
- package/dist/setup-form.js.map +1 -1
- package/dist/spawn-gate.d.ts +1 -0
- package/dist/spawn-gate.js +13 -3
- package/dist/spawn-gate.js.map +1 -1
- package/dist/task-board-tool.d.ts +25 -0
- package/dist/task-board-tool.js +154 -0
- package/dist/task-board-tool.js.map +1 -0
- package/dist/task-board-waker.d.ts +44 -0
- package/dist/task-board-waker.js +128 -0
- package/dist/task-board-waker.js.map +1 -0
- package/dist/tmux-exec-helper.d.ts +94 -0
- package/dist/tmux-exec-helper.js +407 -0
- package/dist/tmux-exec-helper.js.map +1 -0
- package/dist/tmux-manager.d.ts +5 -0
- package/dist/tmux-manager.js +42 -11
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +0 -7
- package/dist/tool-permissions.js +44 -5
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +17 -1
- package/dist/topic-commands.js +45 -19
- package/dist/topic-commands.js.map +1 -1
- package/dist/tunnel/cloudflared.js +37 -2
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/types.d.ts +3 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +63 -1
- package/dist/ui/app-effects.js +90 -32
- package/dist/ui/backend-signin.js +297 -0
- package/dist/ui/chat-ambient.js +307 -0
- package/dist/ui/chat-effect-posters.js +8 -8
- package/dist/ui/chat-effects.js +110 -15
- package/dist/ui/chat-msg-actions.js +176 -0
- package/dist/ui/chat-render.js +21 -3
- package/dist/ui/chat-store.js +64 -12
- package/dist/ui/chat-strings.js +85 -8
- package/dist/ui/chat-thread.js +204 -21
- package/dist/ui/effect-trial-controller.js +13 -8
- package/dist/ui/effect-trial-panel.js +19 -16
- package/dist/ui/effects-waker.js +70 -0
- package/dist/ui/fleet-canvas-layout.js +117 -0
- package/dist/ui/fleet-canvas.js +555 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-route-scene.js +207 -0
- package/dist/ui/fleet-route-worker.js +21 -0
- package/dist/ui/fleet-route.js +88 -0
- package/dist/ui/fleet-task-card.js +436 -0
- package/dist/ui/fleet-task-common.js +448 -0
- package/dist/ui/fleet-tasks.js +535 -0
- package/dist/ui/fleet-traffic.js +359 -0
- package/dist/ui/fleet-usage.js +435 -0
- package/dist/ui/panel-chat.js +264 -66
- package/dist/ui/panel-details.js +2 -1
- package/dist/ui/panel-fleet.js +65 -83
- package/dist/ui/panel-needs.js +4 -2
- package/dist/ui/panel-settings.js +56 -11
- package/dist/ui/preview.js +3 -2
- package/dist/ui/search-dialog.js +349 -0
- package/dist/ui/settings-appearance.js +44 -19
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-catalog.js +92 -0
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-dialogs.js +40 -12
- package/dist/ui/settings-effects.js +4 -3
- 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 +23 -9
- package/dist/ui/settings-owner-effects.js +15 -13
- package/dist/ui/settings-strings.js +122 -42
- package/dist/ui/settings-web-authority.js +91 -0
- package/dist/ui/shared/agend-auth.js +1 -1
- package/dist/ui/shared/app-i18n.js +11 -2
- package/dist/ui/shared/app-prefs.js +17 -4
- package/dist/ui/shared/app-route.js +19 -1
- package/dist/ui/shared/app-session.js +2 -0
- package/dist/ui/shared/app-shell.js +174 -30
- package/dist/ui/shared/app-store.js +26 -1
- package/dist/ui/shared/app-stream.js +5 -1
- package/dist/ui/shared/app-themes.js +140 -7
- package/dist/ui/shared/app.css +704 -20
- package/dist/ui/shared/app.js +26 -15
- package/dist/ui/shared/card-strings.js +8 -0
- package/dist/ui/shared/effect-app-runtime.js +68 -19
- package/dist/ui/shared/effect-chat-runtime.js +16 -3
- package/dist/ui/shared/effect-frame.js +26 -7
- package/dist/ui/shared/effect-geometry.js +24 -9
- package/dist/ui/shared/effect-owner-source.js +8 -1
- package/dist/ui/shared/effect-policy.js +21 -7
- package/dist/ui/shared/effect-profiler.js +31 -9
- package/dist/ui/shared/effect-registry.js +97 -50
- package/dist/ui/shared/effect-stock-snapshot.js +9 -0
- package/dist/ui/shared/effect-stock-trust.js +62 -10
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-source.js +1 -1
- package/dist/ui/shared/effect-trial-stage.js +54 -31
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/fx-motion.js +67 -0
- package/dist/ui/shared/identity-card.js +100 -11
- package/dist/ui/shared/identity-color.js +14 -10
- package/dist/ui/shared/instance-nav.js +179 -23
- package/dist/ui/shared/owner-css.js +30 -1
- package/dist/ui/shared/panel-view.js +24 -4
- package/dist/ui/shared/tokens.css +9 -3
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/ui-menu.js +20 -7
- package/dist/ui/shared/ui-toast.js +16 -3
- package/dist/ui/shared/usage-dialog.js +34 -3
- package/dist/ui/shared/view-strings.js +72 -0
- package/dist/ui/shared/web-authorize.js +198 -0
- package/dist/ui/web-terminal/terminal.css +4 -4
- package/dist/ui/web-terminal/terminal.html +1 -1
- package/dist/update-launch.d.ts +9 -0
- package/dist/update-launch.js +38 -12
- package/dist/update-launch.js.map +1 -1
- package/dist/usage/format-rich.js +6 -3
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +2 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/i18n.d.ts +13 -1
- package/dist/usage/i18n.js +29 -0
- package/dist/usage/i18n.js.map +1 -1
- package/dist/usage/providers.d.ts +81 -2
- package/dist/usage/providers.js +240 -38
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +5 -1
- package/dist/usage/usage-api.js +184 -58
- package/dist/usage/usage-api.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/user-systemd.d.ts +24 -0
- package/dist/user-systemd.js +75 -0
- package/dist/user-systemd.js.map +1 -0
- package/dist/view-api.d.ts +39 -0
- package/dist/view-api.js +69 -6
- 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 +98 -14
- package/dist/web-api.js +1027 -83
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-policy.d.ts +4 -6
- package/dist/web-appearance-policy.js +5 -12
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +3 -0
- package/dist/web-appearance-tools.js +22 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.d.ts +27 -2
- package/dist/web-appearance-view.js +129 -14
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +17 -3
- package/dist/web-appearance.js +85 -8
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-asset-parts.d.ts +2 -2
- package/dist/web-asset-parts.js +3 -3
- package/dist/web-asset-parts.js.map +1 -1
- package/dist/web-auth.js +3 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-backend-login.d.ts +130 -0
- package/dist/web-backend-login.js +225 -0
- package/dist/web-backend-login.js.map +1 -0
- package/dist/web-card.d.ts +14 -0
- package/dist/web-card.js +40 -6
- package/dist/web-card.js.map +1 -1
- 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 +83 -0
- package/dist/web-chat-history.js +137 -5
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +6 -1
- package/dist/web-commands.js +1 -1
- package/dist/web-commands.js.map +1 -1
- 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.js +46 -1
- package/dist/web-effect-bundle.js.map +1 -1
- 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-profiles.d.ts +4 -4
- package/dist/web-effect-service.d.ts +11 -4
- package/dist/web-effect-service.js +25 -4
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.d.ts +1 -0
- package/dist/web-effect-tools.js +4 -4
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.d.ts +2 -2
- package/dist/web-effect-trials.js +3 -3
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-effect-upload.js +1 -1
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-effect-worker.d.ts +1 -1
- package/dist/web-effect-worker.js +2 -2
- package/dist/web-effect-worker.js.map +1 -1
- package/dist/web-effect.d.ts +1 -1
- package/dist/web-guest-access.d.ts +126 -0
- package/dist/web-guest-access.js +238 -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-host-guard.d.ts +11 -0
- package/dist/web-host-guard.js +11 -0
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-identity-color.d.ts +1 -1
- package/dist/web-identity-color.js +2 -2
- package/dist/web-identity-color.js.map +1 -1
- package/dist/web-identity-palette.d.ts +1 -1
- package/dist/web-identity-palette.js +2 -2
- package/dist/web-identity-palette.js.map +1 -1
- package/dist/web-image-meta.d.ts +13 -0
- package/dist/web-image-meta.js +62 -0
- package/dist/web-image-meta.js.map +1 -1
- package/dist/web-libavoid.d.ts +14 -0
- package/dist/web-libavoid.js +46 -0
- package/dist/web-libavoid.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 +1 -1
- package/dist/web-mod-agent.js +7 -5
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-bundle.d.ts +9 -1
- package/dist/web-mod-bundle.js +62 -3
- package/dist/web-mod-bundle.js.map +1 -1
- package/dist/web-mod-export.d.ts +9 -0
- package/dist/web-mod-export.js +21 -0
- package/dist/web-mod-export.js.map +1 -1
- package/dist/web-mod-store.d.ts +7 -1
- package/dist/web-mod-store.js +19 -6
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-owner-css-store.d.ts +44 -1
- package/dist/web-owner-css-store.js +70 -9
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-prefs.d.ts +14 -0
- package/dist/web-prefs.js +37 -4
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +6 -2
- package/dist/web-preview.js +4 -2
- 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 +49 -2
- package/dist/web-settings-patch.js +145 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +94 -0
- package/dist/web-settings-tools.js +301 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-terminal-http.js +15 -2
- package/dist/web-terminal-http.js.map +1 -1
- package/dist/web-theme-store.d.ts +39 -0
- package/dist/web-theme-store.js +122 -11
- package/dist/web-theme-store.js.map +1 -1
- package/dist/web-theme.d.ts +31 -0
- package/dist/web-theme.js +99 -2
- package/dist/web-theme.js.map +1 -1
- 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/windows-shortcuts.d.ts +78 -0
- package/dist/windows-shortcuts.js +203 -0
- package/dist/windows-shortcuts.js.map +1 -0
- package/dist/workflow-templates/default.md +4 -1
- package/dist/yaml-shared-nodes.d.ts +25 -0
- package/dist/yaml-shared-nodes.js +185 -0
- package/dist/yaml-shared-nodes.js.map +1 -0
- package/launcher/agend-install.cjs +553 -0
- package/launcher/install-admission.cjs +4 -39
- package/launcher/postinstall.cjs +14 -12
- package/package.json +4 -2
- package/scripts/preinstall-guard.cjs +2 -5
- package/templates/windows-shortcuts.ps1 +181 -0
- package/launcher/old-updater-note.cjs +0 -48
package/dist/web-api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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";
|
|
@@ -10,6 +12,7 @@ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
|
|
|
10
12
|
import { canonicalText } from "./web-theme.js";
|
|
11
13
|
import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
|
|
12
14
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
15
|
+
import { crossInstanceVisibility } from "./cross-instance-notice.js";
|
|
13
16
|
import { permitWebContinuation } from "./web-continuation.js";
|
|
14
17
|
/**
|
|
15
18
|
* Web UI HTTP API handler.
|
|
@@ -19,7 +22,8 @@ import { formatWebChannelEcho } from "./web-channel-echo.js";
|
|
|
19
22
|
import { t } from "./locale.js";
|
|
20
23
|
import { isSafeInstanceName } from "./web-shell-routes.js";
|
|
21
24
|
import { MAX_COMMAND_ARGS } from "./web-commands.js";
|
|
22
|
-
import { sendPanelHtml } from "./web-host-guard.js";
|
|
25
|
+
import { ROUTE_WORKER_CSP, ROUTE_WORKER_PATH, sendPanelHtml } from "./web-host-guard.js";
|
|
26
|
+
import { libavoidFile } from "./web-libavoid.js";
|
|
23
27
|
import { shellRoute } from "./web-shell-routes.js";
|
|
24
28
|
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
25
29
|
import { join, dirname } from "node:path";
|
|
@@ -27,11 +31,12 @@ import { fileURLToPath } from "node:url";
|
|
|
27
31
|
import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
|
|
28
32
|
import { scheduleView } from "./web-schedules.js";
|
|
29
33
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
34
|
+
import { CARD_PRIORITIES, CARD_SIZES, CARD_STATUSES, TaskBoardError } from "./scheduler/task-board.js";
|
|
30
35
|
import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
|
|
31
36
|
import { z } from "zod";
|
|
32
37
|
import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
33
38
|
import { dashboardOriginFor } from "./web-preview.js";
|
|
34
|
-
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
39
|
+
import { newWebMessageId, parseLastEventId, webChatReplyRef, webChatReplyText } from "./web-chat-history.js";
|
|
35
40
|
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";
|
|
36
41
|
import { getAgendHome } from "./paths.js";
|
|
37
42
|
import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
|
|
@@ -42,6 +47,8 @@ import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
|
42
47
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
43
48
|
import { resolveTz } from "./tz-utils.js";
|
|
44
49
|
import { filesPlayInChat } from "./web-effect.js";
|
|
50
|
+
import { QUERY_MAX_CHARS, RESULT_MAX, SEARCH_SOURCES } from "./search/search-store.js";
|
|
51
|
+
import { xmsgShown } from "./search/search-docs.js";
|
|
45
52
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
46
53
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
47
54
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -56,11 +63,70 @@ const TaskUpdateSchema = z.object({
|
|
|
56
63
|
action: z.enum(["claim", "complete", "update"]).optional(),
|
|
57
64
|
assignee: z.string().max(128).optional(),
|
|
58
65
|
result: z.string().max(MAX_TEXT).optional(),
|
|
59
|
-
status:
|
|
66
|
+
// A status the task board knows: each carries its rules now (scheduler/task-board.ts).
|
|
67
|
+
status: z.enum(CARD_STATUSES).optional(),
|
|
60
68
|
title: z.string().max(512).optional(),
|
|
61
69
|
description: z.string().max(MAX_TEXT).optional(),
|
|
62
70
|
priority: z.string().max(64).optional(),
|
|
63
71
|
}).strict();
|
|
72
|
+
// The task board (scheduler/task-board.ts). Coarse bounds here; the board checks each field's own limit.
|
|
73
|
+
const BOARD_TEXT = 8_192;
|
|
74
|
+
const CardRef = z.string().min(1).max(64);
|
|
75
|
+
/** Who handed a card out: an instance, the person writing ({ kind: "person" }), or nobody. */
|
|
76
|
+
const DispatcherSchema = z.union([z.object({ kind: z.literal("instance"), name: z.string().min(1).max(128) }).strict(), z.object({ kind: z.literal("person") }).strict()]).nullable();
|
|
77
|
+
const CardCreateSchema = z.object({
|
|
78
|
+
kind: z.enum(["main", "sub"]).optional(),
|
|
79
|
+
project: z.union([z.number().int(), z.string().max(256)]).nullable().optional(),
|
|
80
|
+
parent: CardRef.nullable().optional(),
|
|
81
|
+
title: z.string().max(512),
|
|
82
|
+
goal: z.string().max(BOARD_TEXT).optional(),
|
|
83
|
+
scope: z.string().max(BOARD_TEXT).optional(),
|
|
84
|
+
acceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
85
|
+
context: z.string().max(BOARD_TEXT).optional(),
|
|
86
|
+
size: z.enum(CARD_SIZES).nullable().optional(),
|
|
87
|
+
priority: z.enum(CARD_PRIORITIES).optional(),
|
|
88
|
+
dependsOn: z.array(CardRef).max(50).optional(),
|
|
89
|
+
assignee: z.string().max(128).nullable().optional(),
|
|
90
|
+
dispatcher: DispatcherSchema.optional(),
|
|
91
|
+
needsReview: z.boolean().optional(),
|
|
92
|
+
}).strict();
|
|
93
|
+
const CardPatchSchema = z.object({
|
|
94
|
+
version: z.number().int().optional(),
|
|
95
|
+
title: z.string().max(512).optional(),
|
|
96
|
+
goal: z.string().max(BOARD_TEXT).optional(),
|
|
97
|
+
scope: z.string().max(BOARD_TEXT).optional(),
|
|
98
|
+
context: z.string().max(BOARD_TEXT).optional(),
|
|
99
|
+
state: z.string().max(BOARD_TEXT).optional(),
|
|
100
|
+
result: z.string().max(BOARD_TEXT).optional(),
|
|
101
|
+
blockedReason: z.string().max(BOARD_TEXT).optional(),
|
|
102
|
+
size: z.enum(CARD_SIZES).nullable().optional(),
|
|
103
|
+
priority: z.enum(CARD_PRIORITIES).optional(),
|
|
104
|
+
assignee: z.string().max(128).nullable().optional(),
|
|
105
|
+
dispatcher: DispatcherSchema.optional(),
|
|
106
|
+
needsReview: z.boolean().optional(),
|
|
107
|
+
dependsOn: z.array(CardRef).max(50).optional(),
|
|
108
|
+
acceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
109
|
+
addAcceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
110
|
+
check: z.array(z.number().int()).max(50).optional(),
|
|
111
|
+
uncheck: z.array(z.number().int()).max(50).optional(),
|
|
112
|
+
projectId: z.number().int().nullable().optional(),
|
|
113
|
+
log: z.string().max(BOARD_TEXT).optional(),
|
|
114
|
+
comment: z.string().max(MAX_TEXT).optional(),
|
|
115
|
+
}).strict();
|
|
116
|
+
const CardMoveSchema = z.object({
|
|
117
|
+
to: z.enum(CARD_STATUSES),
|
|
118
|
+
reason: z.string().max(BOARD_TEXT).optional(),
|
|
119
|
+
result: z.string().max(BOARD_TEXT).optional(),
|
|
120
|
+
comment: z.string().max(MAX_TEXT).optional(),
|
|
121
|
+
version: z.number().int().optional(),
|
|
122
|
+
}).strict();
|
|
123
|
+
const CardReorderSchema = z.object({ after: CardRef.nullable().optional(), before: CardRef.nullable().optional() }).strict();
|
|
124
|
+
const ProjectCreateSchema = z.object({ name: z.string().max(256), description: z.string().max(BOARD_TEXT).optional() }).strict();
|
|
125
|
+
const ProjectPatchSchema = z.object({
|
|
126
|
+
name: z.string().max(256).optional(),
|
|
127
|
+
description: z.string().max(BOARD_TEXT).optional(),
|
|
128
|
+
archived: z.boolean().optional(),
|
|
129
|
+
}).strict();
|
|
64
130
|
const TeamCreateSchema = z.object({
|
|
65
131
|
name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
|
|
66
132
|
members: z.array(z.string().min(1).max(256)).min(1).max(256),
|
|
@@ -88,6 +154,8 @@ const SendMessageSchema = z.object({
|
|
|
88
154
|
message: z.string().max(MAX_TEXT),
|
|
89
155
|
/** Ids from POST /ui/upload, for this instance, not yet sent. */
|
|
90
156
|
attachments: z.array(z.string().regex(/^[0-9a-f]{32}$/)).max(UPLOAD_LIMITS.maxFiles).optional(),
|
|
157
|
+
/** #2272: the message of this chat this one replies to — its row only; the server reads what it quotes. */
|
|
158
|
+
replyTo: z.object({ boot: z.string().regex(/^[0-9a-f]{1,32}$/), id: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER) }).strict().optional(),
|
|
91
159
|
}).strict().refine(v => v.message.trim().length > 0 || (v.attachments?.length ?? 0) > 0, { message: "a message or at least one file", path: ["message"] });
|
|
92
160
|
/** An instance name that is safe as one path segment (no separator, no NUL, not a dot name). */
|
|
93
161
|
const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "." && name !== "..";
|
|
@@ -97,11 +165,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
|
|
|
97
165
|
* the eviction the dead entry leaks forever, and without the try/catch a
|
|
98
166
|
* single dead client breaks delivery to every client iterated after it.
|
|
99
167
|
*/
|
|
168
|
+
/** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
|
|
169
|
+
const streamGuests = new WeakMap();
|
|
170
|
+
/**
|
|
171
|
+
* #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
|
|
172
|
+
* its channel saw (an admin's page already has it, unchanged).
|
|
173
|
+
*/
|
|
174
|
+
export function broadcastToGuests(clients, event, data, onError) {
|
|
175
|
+
const guests = [...clients].filter((c) => streamGuests.has(c));
|
|
176
|
+
if (!guests.length)
|
|
177
|
+
return;
|
|
178
|
+
const live = new Set(guests);
|
|
179
|
+
broadcastSseEvent(live, event, data, onError);
|
|
180
|
+
for (const c of guests)
|
|
181
|
+
if (!live.has(c))
|
|
182
|
+
clients.delete(c); // the ones it ended (their session went)
|
|
183
|
+
}
|
|
100
184
|
export function broadcastSseEvent(clients, event, data, onError, id) {
|
|
101
185
|
const payload = sseFrame(event, data, id);
|
|
102
186
|
const dead = [];
|
|
103
187
|
for (const client of clients) {
|
|
104
188
|
try {
|
|
189
|
+
// An admin's stream gets these bytes; a guest's, the event as projected for its scope now, or nothing (no id: it
|
|
190
|
+
// never replays chat, so a cursor of the admin's stream is not its to hold).
|
|
191
|
+
const guest = streamGuests.get(client);
|
|
192
|
+
if (guest) {
|
|
193
|
+
// The exact session that opened it ended (revoked, expired, rotated away): nothing more, and the stream goes.
|
|
194
|
+
if (!guest.current()) {
|
|
195
|
+
dead.push(client);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const shown = guestFrameData(event, data, guest.scope(), guest);
|
|
199
|
+
if (shown !== undefined)
|
|
200
|
+
client.write(sseFrame(event, shown));
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
105
203
|
client.write(payload);
|
|
106
204
|
}
|
|
107
205
|
catch (err) {
|
|
@@ -141,10 +239,19 @@ async function parseBody(req) {
|
|
|
141
239
|
}
|
|
142
240
|
/** Send JSON response. */
|
|
143
241
|
function json(res, status, data) {
|
|
242
|
+
jsonText(res, status, JSON.stringify(data));
|
|
243
|
+
}
|
|
244
|
+
/** A JSON answer already serialized (a body kept for reuse). */
|
|
245
|
+
function jsonText(res, status, body) {
|
|
144
246
|
res.setHeader("Content-Type", "application/json");
|
|
145
247
|
res.writeHead(status);
|
|
146
|
-
res.end(
|
|
248
|
+
res.end(body);
|
|
147
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* #2296: the last dashboard board read, serialized, per board: its key (revision, done=all, minute) and body. One per
|
|
252
|
+
* board (the newest revision replaces the previous), so it holds one board's JSON at most.
|
|
253
|
+
*/
|
|
254
|
+
const boardBodies = new WeakMap();
|
|
148
255
|
/**
|
|
149
256
|
* #1630 (#1635 review): an answer that waited on something (a list, a download) is sent only if the request is still
|
|
150
257
|
* allowed — the session not revoked meanwhile, a public link's exposure still current. Otherwise 401, and nothing.
|
|
@@ -248,7 +355,7 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
|
|
|
248
355
|
json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
|
|
249
356
|
return true;
|
|
250
357
|
}
|
|
251
|
-
if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
|
|
358
|
+
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)) {
|
|
252
359
|
json(res, 404, { error: "not found" });
|
|
253
360
|
return true;
|
|
254
361
|
}
|
|
@@ -297,6 +404,17 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
|
|
|
297
404
|
announce();
|
|
298
405
|
json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
|
|
299
406
|
}
|
|
407
|
+
else if (path === "/ui/owner-css/shown") {
|
|
408
|
+
// #2198: this page shows the trial (its stylesheet loaded): an agent's waiting trial starts its keep window now.
|
|
409
|
+
const shown = store.shown(person, body.trial);
|
|
410
|
+
if (!shown.ok) {
|
|
411
|
+
json(res, shown.status, { error: shown.error });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (shown.started)
|
|
415
|
+
announce(); // the person's other devices take the new deadline
|
|
416
|
+
json(res, 200, { remaining_ms: shown.remaining_ms });
|
|
417
|
+
}
|
|
300
418
|
else if (path === "/ui/owner-css/keep") {
|
|
301
419
|
const kept = store.keep(person, body.trial);
|
|
302
420
|
if (!kept.ok) {
|
|
@@ -382,7 +500,7 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
|
382
500
|
css = value;
|
|
383
501
|
}
|
|
384
502
|
catch { /* unavailable is never off */ }
|
|
385
|
-
// CSS
|
|
503
|
+
// CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
|
|
386
504
|
const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
|
|
387
505
|
return { actor, record: v.session, sessions };
|
|
388
506
|
};
|
|
@@ -477,6 +595,104 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
|
477
595
|
catch { /* response ended */ } });
|
|
478
596
|
return true;
|
|
479
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* #2233: signing a backend in from the dashboard (web-backend-login.ts). A signed-in admin session on the local surface
|
|
600
|
+
* only — never the CLI's header token (an agent's), a guest, a read session or the public link (refused before the body
|
|
601
|
+
* is read, and again after it). Every answer is no-store: the starter's view carries the terminal's access code.
|
|
602
|
+
*/
|
|
603
|
+
function handleBackendLogin(req, res, url, ctx, path, method) {
|
|
604
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
605
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
606
|
+
const reading = method === "GET";
|
|
607
|
+
if (gatewayRequestContext(req)) {
|
|
608
|
+
json(res, reading ? 404 : 403, { error: "sign_in_not_over_public_link" });
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
const who = () => {
|
|
612
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
613
|
+
if (v.kind !== "allow")
|
|
614
|
+
return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
|
|
615
|
+
if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
|
|
616
|
+
return { error: "sign_in_needs_a_local_admin", status: 403 };
|
|
617
|
+
return { handle: v.session.handle, label: v.session.label };
|
|
618
|
+
};
|
|
619
|
+
const first = who();
|
|
620
|
+
if ("error" in first) {
|
|
621
|
+
json(res, first.status, { error: first.error });
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
const logins = ctx.webBackendLogins;
|
|
625
|
+
if (!logins) {
|
|
626
|
+
json(res, 404, { error: "sign_in_unavailable" });
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
const action = /^\/ui\/backend-login\/(check|start|cancel)$/.exec(path)?.[1];
|
|
630
|
+
if (reading && path === "/ui/backend-login" && [...url.searchParams.keys()].length === 0) {
|
|
631
|
+
json(res, 200, { login: logins.view(first.handle), targets: ctx.backendSignInTargets?.() ?? [] });
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
if (method !== "POST" || !action) {
|
|
635
|
+
json(res, 404, { error: "not_found" });
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
(async () => {
|
|
639
|
+
let bytes;
|
|
640
|
+
try {
|
|
641
|
+
bytes = await readBoundedWebBody(req, 4 * 1024);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
json(res, 413, { error: "sign_in_body_bound" });
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
648
|
+
return;
|
|
649
|
+
const now = who();
|
|
650
|
+
if ("error" in now) {
|
|
651
|
+
json(res, now.status, { error: now.error });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (now.handle !== first.handle) {
|
|
655
|
+
json(res, 409, { error: "sign_in_session_changed" });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
let body;
|
|
659
|
+
try {
|
|
660
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
json(res, 400, { error: "sign_in_json" });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (action === "check") {
|
|
667
|
+
const parsed = z.strictObject({ backend: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/i), profile: z.string().min(1).max(64).optional() }).safeParse(body);
|
|
668
|
+
if (!parsed.success) {
|
|
669
|
+
json(res, 400, { error: "sign_in_args" });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
json(res, 200, await logins.check(now, parsed.data.backend, parsed.data.profile));
|
|
673
|
+
}
|
|
674
|
+
else if (action === "start") {
|
|
675
|
+
const parsed = z.strictObject({ confirm: z.string().regex(/^[0-9a-f]{32}$/), tunnel: z.boolean().optional() }).safeParse(body);
|
|
676
|
+
if (!parsed.success) {
|
|
677
|
+
json(res, 400, { error: "sign_in_args" });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const result = await logins.begin(now, parsed.data.confirm, parsed.data.tunnel === true);
|
|
681
|
+
json(res, "error" in result ? 409 : 200, result);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
if (!z.strictObject({}).safeParse(body).success) {
|
|
685
|
+
json(res, 400, { error: "sign_in_args" });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
json(res, 200, { message: await logins.cancel() });
|
|
689
|
+
}
|
|
690
|
+
})().catch(() => { try {
|
|
691
|
+
json(res, 500, { error: "sign_in_request_failed" });
|
|
692
|
+
}
|
|
693
|
+
catch { /* response ended */ } });
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
480
696
|
/** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
|
|
481
697
|
function handleEffects(req, res, url, ctx, path, method) {
|
|
482
698
|
res.setHeader("Cache-Control", "private, no-store");
|
|
@@ -484,7 +700,12 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
484
700
|
const reading = method === "GET";
|
|
485
701
|
// Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
|
|
486
702
|
const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
|
|
487
|
-
|
|
703
|
+
const gateway = gatewayRequestContext(req), guest = guestRequestOf(req);
|
|
704
|
+
const instanceQuery = url.searchParams.get("instance"), keys = [...url.searchParams.keys()];
|
|
705
|
+
const chatRead = reading && (path === "/ui/effects/placements" || /^\/ui\/effects\/resource\/[0-9a-f]{32}$/.test(path))
|
|
706
|
+
&& keys.length === 2 && keys.includes("instance") && keys.includes("ownerEpoch") && !!instanceQuery && instanceQuery.length <= 128;
|
|
707
|
+
// Public viewing grants current Chat reads, never ordinary library/owner/Trial access or writes.
|
|
708
|
+
if (gateway && (!chatRead || !gateway.isCurrent())) {
|
|
488
709
|
json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
|
|
489
710
|
return true;
|
|
490
711
|
}
|
|
@@ -496,7 +717,9 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
496
717
|
return { status: 403, error: "effects_need_a_signed_in_person" };
|
|
497
718
|
if (!reading && !profilePost && verdict.session.tier !== "admin")
|
|
498
719
|
return { status: 403, error: "effects_read_only" };
|
|
499
|
-
|
|
720
|
+
if (verdict.session.tier === "guest" && (!chatRead || !guest || !guest.current() || !guest.scope().has(instanceQuery)))
|
|
721
|
+
return { status: 404, error: "not_found" };
|
|
722
|
+
return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle, guest: verdict.session.tier === "guest" };
|
|
500
723
|
};
|
|
501
724
|
const first = who();
|
|
502
725
|
if ("status" in first) {
|
|
@@ -539,9 +762,9 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
539
762
|
else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
|
|
540
763
|
answer(service.ownerSelections(first.person));
|
|
541
764
|
else if (path === "/ui/effects/placements" && instance !== undefined)
|
|
542
|
-
answer(service.placements(first.person, instance));
|
|
765
|
+
answer(service.placements(first.person, instance, !!gateway || first.guest));
|
|
543
766
|
else if (idPath?.[1] === "resource")
|
|
544
|
-
answer(slot === null ? service.resource(first.person, idPath[2], instance) : service.ownerResource(first.person, idPath[2], slot));
|
|
767
|
+
answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
|
|
545
768
|
else if (idPath?.[1] === "profile" && instance === undefined)
|
|
546
769
|
answer(service.profile(first.person, idPath[2]));
|
|
547
770
|
else
|
|
@@ -595,8 +818,13 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
595
818
|
answer(service.remove(now.person, body));
|
|
596
819
|
else if (path === "/ui/effects/share")
|
|
597
820
|
answer(service.share(now.person, body));
|
|
598
|
-
else
|
|
599
|
-
|
|
821
|
+
else {
|
|
822
|
+
if (body && typeof body === "object" && body.kind === "playback" && (url.searchParams.getAll("instance").length !== 1 || slot !== null)) {
|
|
823
|
+
json(res, 400, { error: "playback_report" });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
answer(service.report(now.person, idPath[2], body, instance, now.epoch));
|
|
827
|
+
}
|
|
600
828
|
})().catch(() => { try {
|
|
601
829
|
json(res, 500, { error: "effect_request_failed" });
|
|
602
830
|
}
|
|
@@ -805,7 +1033,7 @@ export function exportMod(ctx, person, body) {
|
|
|
805
1033
|
return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
|
|
806
1034
|
const parts = {};
|
|
807
1035
|
let omitted = 0;
|
|
808
|
-
const wantsInstance = inc.theme === true || inc.identity === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
|
|
1036
|
+
const wantsInstance = inc.theme === true || inc.identity === true || inc.ambient === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
|
|
809
1037
|
let name = null;
|
|
810
1038
|
if (wantsInstance) {
|
|
811
1039
|
if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
|
|
@@ -832,6 +1060,11 @@ export function exportMod(ctx, person, body) {
|
|
|
832
1060
|
if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
|
|
833
1061
|
(parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
|
|
834
1062
|
}
|
|
1063
|
+
// #2117: its ambients, whoever set them (a person exports what is on the chat).
|
|
1064
|
+
if (inc.ambient === true) {
|
|
1065
|
+
const specs = Object.fromEntries(Object.entries(rec.placements.ambient).flatMap(([k, a]) => a ? [[k, a.spec]] : []));
|
|
1066
|
+
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; } };
|
|
1067
|
+
}
|
|
835
1068
|
}
|
|
836
1069
|
if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
|
|
837
1070
|
const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
|
|
@@ -871,6 +1104,81 @@ function webPersonOf(req, url, ctx) {
|
|
|
871
1104
|
const who = webAccountOf(req, url, ctx);
|
|
872
1105
|
return who ? ctx.webPerson(who.owner) : null;
|
|
873
1106
|
}
|
|
1107
|
+
/**
|
|
1108
|
+
* #1939 S1: whether this request carries `web-session` authority, asked as it stands now — on arrival without an instance,
|
|
1109
|
+
* after the body with the one it names (then confirmAfterBody joins the two). Never touches the session.
|
|
1110
|
+
*/
|
|
1111
|
+
function webAuthorityOf(req, url, ctx, instance) {
|
|
1112
|
+
if (!ctx.webAuthority)
|
|
1113
|
+
return { authority: noAuthority(0), refusal: "not_session" };
|
|
1114
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1115
|
+
return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
|
|
1116
|
+
peer: classifyWebPeer(req), ...(instance !== undefined ? { instance } : {}) });
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
|
|
1120
|
+
* header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
|
|
1121
|
+
* one). Equal before and after an await: the same admission still holds. Never touches the session.
|
|
1122
|
+
*/
|
|
1123
|
+
/** #2293: an admin's admission — the header token or an admin session — else null (a guest's or a read-only session's). */
|
|
1124
|
+
function adminAdmissionOf(req, url, ctx) {
|
|
1125
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1126
|
+
if (v.kind !== "allow" || (v.via === "session" && v.session.tier !== "admin"))
|
|
1127
|
+
return null;
|
|
1128
|
+
return admissionOf(req, url, ctx);
|
|
1129
|
+
}
|
|
1130
|
+
const SEARCH_LIMIT_DEFAULT = 50;
|
|
1131
|
+
/**
|
|
1132
|
+
* #2293: GET /ui/search's parameters: q (1–QUERY_MAX_CHARS), instance (repeated), team (its members join `instance`),
|
|
1133
|
+
* source (repeated: chat, xmsg, task), since / until (epoch ms), mine=1, limit (1–RESULT_MAX).
|
|
1134
|
+
*/
|
|
1135
|
+
function parseSearchQuery(url, ctx) {
|
|
1136
|
+
const p = url.searchParams;
|
|
1137
|
+
const q = (p.get("q") ?? "").trim();
|
|
1138
|
+
if (!q || q.length > QUERY_MAX_CHARS)
|
|
1139
|
+
return { error: `q required (1-${QUERY_MAX_CHARS} chars)` };
|
|
1140
|
+
const instances = new Set(p.getAll("instance").filter(n => n.length > 0 && n.length <= 128));
|
|
1141
|
+
const team = p.get("team");
|
|
1142
|
+
if (team) {
|
|
1143
|
+
const def = ctx.fleetConfig?.teams && Object.hasOwn(ctx.fleetConfig.teams, team) ? ctx.fleetConfig.teams[team] : undefined;
|
|
1144
|
+
if (!def)
|
|
1145
|
+
return { error: "unknown team" };
|
|
1146
|
+
for (const member of def.members ?? [])
|
|
1147
|
+
instances.add(member);
|
|
1148
|
+
if (!instances.size)
|
|
1149
|
+
return { error: "team has no members" };
|
|
1150
|
+
}
|
|
1151
|
+
const sources = p.getAll("source");
|
|
1152
|
+
if (sources.some(s => !SEARCH_SOURCES.includes(s)))
|
|
1153
|
+
return { error: `source must be one of: ${SEARCH_SOURCES.join(", ")}` };
|
|
1154
|
+
const time = (name) => {
|
|
1155
|
+
const raw = p.get(name);
|
|
1156
|
+
if (raw === null || raw === "")
|
|
1157
|
+
return undefined;
|
|
1158
|
+
const n = Number(raw);
|
|
1159
|
+
return Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
1160
|
+
};
|
|
1161
|
+
const since = time("since"), until = time("until");
|
|
1162
|
+
if (since === null || until === null)
|
|
1163
|
+
return { error: "since/until must be epoch milliseconds" };
|
|
1164
|
+
const limitRaw = p.get("limit");
|
|
1165
|
+
const limit = limitRaw === null ? SEARCH_LIMIT_DEFAULT : Number(limitRaw);
|
|
1166
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > RESULT_MAX)
|
|
1167
|
+
return { error: `limit must be 1-${RESULT_MAX}` };
|
|
1168
|
+
return {
|
|
1169
|
+
filters: { q, limit, ...(instances.size ? { instances: [...instances] } : {}), ...(sources.length ? { sources: sources } : {}),
|
|
1170
|
+
...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}) },
|
|
1171
|
+
mine: p.get("mine") === "1",
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
function admissionOf(req, url, ctx) {
|
|
1175
|
+
const gateway = gatewayRequestContext(req);
|
|
1176
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1177
|
+
if (v.kind !== "allow")
|
|
1178
|
+
return null;
|
|
1179
|
+
const via = v.via === "session" ? `session:${v.session.handle}:${v.session.tokenEpoch}` : "token";
|
|
1180
|
+
return gateway ? `${via}@${gateway.exposureId}` : via;
|
|
1181
|
+
}
|
|
874
1182
|
/** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
|
|
875
1183
|
function webPersonOr401(req, res, url, ctx) {
|
|
876
1184
|
const p = webPersonOf(req, url, ctx);
|
|
@@ -878,15 +1186,115 @@ function webPersonOr401(req, res, url, ctx) {
|
|
|
878
1186
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
879
1187
|
return p;
|
|
880
1188
|
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Who a dashboard request acts as on the task board. The person only through a signed-in local session: the header
|
|
1191
|
+
* token is the CLI's or a script's (any agent on this machine can read the token file), and the temporary public link
|
|
1192
|
+
* carries no verified authority (docs/web-dashboard.md) — either is a script, held to the rules an instance is held to,
|
|
1193
|
+
* so neither confirms work waiting for the person.
|
|
1194
|
+
*/
|
|
1195
|
+
function boardActor(req, url, ctx, p) {
|
|
1196
|
+
if (gatewayRequestContext(req))
|
|
1197
|
+
return { kind: "script", name: "public-link" };
|
|
1198
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1199
|
+
return v.kind === "allow" && v.via === "session" ? { kind: "person", name: p.name } : { kind: "script", name: "web-token" };
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* A board refusal as the dashboard reads it: the board's sentence, its code and the status that fits, and — when the
|
|
1203
|
+
* person can meet it — the key and values the page says it with in its own language.
|
|
1204
|
+
*/
|
|
1205
|
+
function boardError(res, err) {
|
|
1206
|
+
if (res.headersSent)
|
|
1207
|
+
return;
|
|
1208
|
+
if (err instanceof TaskBoardError) {
|
|
1209
|
+
const status = err.code === "not_found" ? 404 : err.code === "forbidden" ? 403 : err.code === "conflict" ? 409 : 400;
|
|
1210
|
+
json(res, status, { error: err.message, code: err.code, ...(err.key ? { key: err.key, args: err.args } : {}) });
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
json(res, 400, { error: err.message });
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* One board write from the dashboard: the body read and checked, the request still allowed, the person asked for
|
|
1217
|
+
* again now the body is in (#1827), then the write as that person. The board's own routes take a signed-in session
|
|
1218
|
+
* only (instances use their task tool, scripts the old /ui/tasks routes).
|
|
1219
|
+
*/
|
|
1220
|
+
function boardWrite(req, res, url, ctx, board, schema, write) {
|
|
1221
|
+
void (async () => {
|
|
1222
|
+
try {
|
|
1223
|
+
const body = await parseBody(req);
|
|
1224
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1225
|
+
return;
|
|
1226
|
+
const parsed = parseOrReject(schema, body, res);
|
|
1227
|
+
if (!parsed)
|
|
1228
|
+
return;
|
|
1229
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
1230
|
+
if (!person)
|
|
1231
|
+
return;
|
|
1232
|
+
const actor = boardActor(req, url, ctx, person);
|
|
1233
|
+
if (actor.kind !== "person") {
|
|
1234
|
+
json(res, 403, { error: "The task board takes changes from a signed-in dashboard", code: "forbidden", key: "signed_in_only" });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
json(res, 200, write(parsed, actor));
|
|
1238
|
+
}
|
|
1239
|
+
catch (err) {
|
|
1240
|
+
boardError(res, err);
|
|
1241
|
+
}
|
|
1242
|
+
})();
|
|
1243
|
+
}
|
|
881
1244
|
/** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
|
|
882
1245
|
function profileBody(p) {
|
|
883
1246
|
return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
|
|
884
1247
|
}
|
|
885
1248
|
/** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
|
|
886
1249
|
function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1250
|
+
const statusTicks = new WeakMap();
|
|
1251
|
+
function subscribeStatusTick(ctx, stream) {
|
|
1252
|
+
let tick = statusTicks.get(ctx);
|
|
1253
|
+
if (!tick)
|
|
1254
|
+
statusTicks.set(ctx, tick = { streams: new Set(), timer: null });
|
|
1255
|
+
const t = tick;
|
|
1256
|
+
t.streams.add(stream);
|
|
1257
|
+
t.timer ??= setInterval(() => statusTick(ctx, t), ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
|
|
1258
|
+
return () => {
|
|
1259
|
+
t.streams.delete(stream);
|
|
1260
|
+
if (!t.streams.size && t.timer) {
|
|
1261
|
+
clearInterval(t.timer);
|
|
1262
|
+
t.timer = null;
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
function statusTick(ctx, t) {
|
|
1267
|
+
const live = [...t.streams].filter((s) => s.alive());
|
|
1268
|
+
if (!live.length)
|
|
1269
|
+
return;
|
|
1270
|
+
let status, admin = null;
|
|
1271
|
+
const guests = new Map();
|
|
1272
|
+
try {
|
|
1273
|
+
status = measureSyncWork("web.sseStatus", () => ctx.getUiStatus());
|
|
1274
|
+
}
|
|
1275
|
+
catch {
|
|
1276
|
+
return;
|
|
1277
|
+
} // nothing to send this tick; the next one tries again
|
|
1278
|
+
const frameFor = (s) => {
|
|
1279
|
+
if (!s.scope)
|
|
1280
|
+
return admin ??= `event: status\ndata: ${JSON.stringify(status)}\n\n`;
|
|
1281
|
+
const scope = s.scope(), key = [...scope].sort().join("\u0000");
|
|
1282
|
+
let frame = guests.get(key);
|
|
1283
|
+
if (frame === undefined)
|
|
1284
|
+
guests.set(key, frame = `event: status\ndata: ${JSON.stringify(guestStatus(status, scope))}\n\n`);
|
|
1285
|
+
return frame;
|
|
1286
|
+
};
|
|
1287
|
+
for (const s of live) {
|
|
1288
|
+
let frame;
|
|
1289
|
+
try {
|
|
1290
|
+
frame = frameFor(s);
|
|
1291
|
+
}
|
|
1292
|
+
catch {
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
s.write(frame);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
890
1298
|
export function handleWebRequest(req, res, url, ctx) {
|
|
891
1299
|
const path = url.pathname;
|
|
892
1300
|
const method = req.method ?? "GET";
|
|
@@ -937,6 +1345,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
937
1345
|
try {
|
|
938
1346
|
const js = readFileSync(join(__dirname, "ui", fileName), "utf-8");
|
|
939
1347
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
1348
|
+
// #2182: the route worker's own, narrower-reaching policy (ROUTE_WORKER_CSP); every other file keeps the page's.
|
|
1349
|
+
if (path === ROUTE_WORKER_PATH)
|
|
1350
|
+
res.setHeader("Content-Security-Policy", ROUTE_WORKER_CSP);
|
|
940
1351
|
res.writeHead(200);
|
|
941
1352
|
res.end(js);
|
|
942
1353
|
}
|
|
@@ -945,8 +1356,34 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
945
1356
|
}
|
|
946
1357
|
return true;
|
|
947
1358
|
}
|
|
1359
|
+
// #2182: libavoid (libavoid-js, LGPL-2.1-or-later) — the npm package's own two files, unmodified, at a versioned path.
|
|
1360
|
+
const vendor = method === "GET" ? libavoidFile(path) : null;
|
|
1361
|
+
if (vendor) {
|
|
1362
|
+
try {
|
|
1363
|
+
const body = readFileSync(vendor.file);
|
|
1364
|
+
res.writeHead(200, { "Content-Type": vendor.type, "Cache-Control": "private, max-age=31536000, immutable" });
|
|
1365
|
+
res.end(body);
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
json(res, 404, { error: "File not found" });
|
|
1369
|
+
}
|
|
1370
|
+
return true;
|
|
1371
|
+
}
|
|
948
1372
|
// ── Backend detection ─────────────────────────────────
|
|
1373
|
+
// #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
|
|
1374
|
+
if (method === "GET" && path === "/ui/models") {
|
|
1375
|
+
const backend = url.searchParams.get("backend") ?? "";
|
|
1376
|
+
const instance = url.searchParams.get("instance");
|
|
1377
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(backend) || (instance !== null && (instance.length < 1 || instance.length > 256))) {
|
|
1378
|
+
json(res, 400, { error: "backend (and optional instance) required" });
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
json(res, 200, { backend, models: ctx.modelCatalogFor?.(backend, instance) ?? [] });
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
949
1384
|
if (method === "GET" && path === "/ui/backends") {
|
|
1385
|
+
// #2072 (#2098 review P3-3): the backend an agent created with "fleet default" runs, so its model list can be shown.
|
|
1386
|
+
const defaultBackend = ctx.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
950
1387
|
const BACKENDS = [
|
|
951
1388
|
{ name: "claude-code", binary: "claude" },
|
|
952
1389
|
{ name: "codex", binary: "codex" },
|
|
@@ -956,6 +1393,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
956
1393
|
{ name: "grok", binary: "grok" },
|
|
957
1394
|
{ name: "muse", binary: "muse" },
|
|
958
1395
|
];
|
|
1396
|
+
// #2072: the published availability snapshot — installed, signed in, new enough — when the fleet has one.
|
|
1397
|
+
if (ctx.backendAvailabilityRows) {
|
|
1398
|
+
const binaryOf = new Map(BACKENDS.map(b => [b.name, b]));
|
|
1399
|
+
const backends = ctx.backendAvailabilityRows().filter(r => binaryOf.has(r.backend)).map(r => ({
|
|
1400
|
+
name: r.backend, binary: binaryOf.get(r.backend).binary, installed: r.state !== "not_installed" && r.path !== null, path: r.path ?? "",
|
|
1401
|
+
deprecated: binaryOf.get(r.backend).deprecated ?? false, state: r.state, minVersion: r.minVersion, ...(r.state === "unknown" ? { unknown: true } : {}),
|
|
1402
|
+
}));
|
|
1403
|
+
json(res, 200, { backends, defaultBackend });
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
959
1406
|
// #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
|
|
960
1407
|
// runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
|
|
961
1408
|
// time (shown as not installed, as the old timeout was).
|
|
@@ -965,7 +1412,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
965
1412
|
return { name: b.name, binary: b.binary, installed: path !== null, path: path ?? "", deprecated: b.deprecated ?? false,
|
|
966
1413
|
...(found.known ? {} : { unknown: true }) };
|
|
967
1414
|
})).then((backends) => { if (!res.headersSent)
|
|
968
|
-
json(res, 200, { backends }); }, () => { if (!res.headersSent)
|
|
1415
|
+
json(res, 200, { backends, defaultBackend }); }, () => { if (!res.headersSent)
|
|
969
1416
|
json(res, 500, { error: "backend probe failed" }); });
|
|
970
1417
|
return true;
|
|
971
1418
|
}
|
|
@@ -978,9 +1425,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
978
1425
|
// that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
|
|
979
1426
|
// review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
|
|
980
1427
|
if (method === "GET" && path === "/ui/poll") {
|
|
1428
|
+
// #1920: a guest's poll is projected for its scope (web-guest-access.ts) — the same rule as its stream.
|
|
1429
|
+
const guest = guestRequestOf(req);
|
|
981
1430
|
const history = ctx.webChatHistory;
|
|
982
1431
|
const after = url.searchParams.get("after");
|
|
983
1432
|
const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
|
|
1433
|
+
if (guest) {
|
|
1434
|
+
// #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
|
|
1435
|
+
json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
|
|
1436
|
+
if (!history)
|
|
1437
|
+
return [];
|
|
1438
|
+
if (fresh)
|
|
1439
|
+
return history.after(0);
|
|
1440
|
+
const cursor = parseLastEventId(after);
|
|
1441
|
+
const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
|
|
1442
|
+
return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
|
|
1443
|
+
}));
|
|
1444
|
+
return true;
|
|
1445
|
+
}
|
|
984
1446
|
json(res, 200, {
|
|
985
1447
|
status: ctx.getUiStatus(),
|
|
986
1448
|
messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
|
|
@@ -1013,30 +1475,53 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1013
1475
|
});
|
|
1014
1476
|
// #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
|
|
1015
1477
|
// poll down by (one write: the frames a connect sends are unchanged in number).
|
|
1016
|
-
|
|
1478
|
+
// #1920: a guest's stream: its projected status now and with each heartbeat, its replay and ticks as it may see them
|
|
1479
|
+
// (§5.2), then only what guestFrameData lets through — no prompts or Needs you.
|
|
1480
|
+
const guest = guestRequestOf(req);
|
|
1481
|
+
if (guest) {
|
|
1482
|
+
streamGuests.set(res, guest);
|
|
1483
|
+
guest.streamOpened?.();
|
|
1484
|
+
}
|
|
1485
|
+
const statusFrame = () => `event: status\ndata: ${JSON.stringify(guest ? guestStatus(ctx.getUiStatus(), guest.scope()) : ctx.getUiStatus())}\n\n`;
|
|
1486
|
+
res.write(`${statusFrame()}${sseClock()}`);
|
|
1017
1487
|
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
1018
1488
|
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
1019
1489
|
if (lastSeen !== null && ctx.webChatHistory) {
|
|
1020
1490
|
const history = ctx.webChatHistory;
|
|
1021
|
-
|
|
1022
|
-
|
|
1491
|
+
if (guest) {
|
|
1492
|
+
// Its own projection, with no cursor: an admin's ids are not a guest's to hold (it reloads its history instead).
|
|
1493
|
+
for (const m of guestMessages(history.replayFor(lastSeen), guest, guest.scope()))
|
|
1494
|
+
res.write(sseFrame("message", m));
|
|
1495
|
+
}
|
|
1496
|
+
else {
|
|
1497
|
+
for (const m of history.replayFor(lastSeen))
|
|
1498
|
+
res.write(sseFrame("message", m, history.cursorOf(m)));
|
|
1499
|
+
}
|
|
1023
1500
|
}
|
|
1024
1501
|
// Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
|
|
1025
1502
|
// report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
|
|
1026
|
-
if (ctx.webChatHistory)
|
|
1027
|
-
|
|
1503
|
+
if (ctx.webChatHistory) {
|
|
1504
|
+
const ticks = guest ? guestDeliveries(ctx.webChatHistory, guest, guest.scope()) : ctx.webChatHistory.deliveries();
|
|
1505
|
+
res.write(`event: deliveries\ndata: ${JSON.stringify(ticks)}\n\n`);
|
|
1506
|
+
}
|
|
1028
1507
|
// The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
|
|
1029
|
-
if (ctx.listWebPrompts)
|
|
1508
|
+
if (!guest && ctx.listWebPrompts)
|
|
1030
1509
|
res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
|
|
1031
1510
|
// #1386: and "Needs you" as it is now; changes follow as `needs` events.
|
|
1032
|
-
if (ctx.needsYouItems)
|
|
1511
|
+
if (!guest && ctx.needsYouItems)
|
|
1033
1512
|
res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
|
|
1034
1513
|
ctx.sseClients.add(res);
|
|
1035
|
-
|
|
1514
|
+
// #1956: the heartbeat's status comes from the one build every open stream shares each tick (statusTick), with
|
|
1515
|
+
// this stream's own projection; this stream's checks stay its own.
|
|
1516
|
+
const unsubscribe = subscribeStatusTick(ctx, {
|
|
1036
1517
|
// A stream authorized once must not outlive the authorization. Re-checked
|
|
1037
1518
|
// without counting as activity, or an open tab would keep an idle session
|
|
1038
1519
|
// alive forever; a revoked, expired or rotated-away session ends here.
|
|
1039
|
-
|
|
1520
|
+
// #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
|
|
1521
|
+
// still authenticating does not keep a revoked device's stream (with its scope) alive.
|
|
1522
|
+
alive: () => {
|
|
1523
|
+
if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) && (!guest || guest.current()))
|
|
1524
|
+
return true;
|
|
1040
1525
|
// Close the connection too, not just the response: an ended response leaves the socket idle in
|
|
1041
1526
|
// keep-alive, still held open by whoever's session just ended.
|
|
1042
1527
|
const socket = req.socket;
|
|
@@ -1045,23 +1530,23 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1045
1530
|
}
|
|
1046
1531
|
catch { /* already closed */ }
|
|
1047
1532
|
cleanup();
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
res.write(
|
|
1533
|
+
return false;
|
|
1534
|
+
},
|
|
1535
|
+
scope: guest ? () => guest.scope() : null,
|
|
1536
|
+
write: (frame) => { try {
|
|
1537
|
+
res.write(frame + sseClock());
|
|
1053
1538
|
}
|
|
1054
1539
|
catch {
|
|
1055
1540
|
cleanup();
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1541
|
+
} },
|
|
1542
|
+
});
|
|
1058
1543
|
let cleanedUp = false;
|
|
1059
1544
|
const cleanup = () => {
|
|
1060
1545
|
if (cleanedUp)
|
|
1061
1546
|
return;
|
|
1062
1547
|
cleanedUp = true;
|
|
1063
1548
|
ctx.sseClients.delete(res);
|
|
1064
|
-
|
|
1549
|
+
unsubscribe();
|
|
1065
1550
|
};
|
|
1066
1551
|
// The *response's* `close` is the one that means the stream is over: it fires when the connection
|
|
1067
1552
|
// goes away or the response is finished. The request's `close` means "the request has been read"
|
|
@@ -1083,7 +1568,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1083
1568
|
json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
|
|
1084
1569
|
return true;
|
|
1085
1570
|
}
|
|
1086
|
-
|
|
1571
|
+
// #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
|
|
1572
|
+
const guest = guestRequestOf(req);
|
|
1573
|
+
const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
|
|
1574
|
+
json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
|
|
1087
1575
|
return true;
|
|
1088
1576
|
}
|
|
1089
1577
|
// ── #1218: the Log tab ─────────────────────────────────
|
|
@@ -1138,6 +1626,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1138
1626
|
const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
|
|
1139
1627
|
if (method === "GET" && fileMatch) {
|
|
1140
1628
|
const id = fileMatch[1];
|
|
1629
|
+
// #1920: a guest gets a file only when its stored instance is in scope — "no such file" otherwise, like an unknown id
|
|
1630
|
+
// (an id from another instance's chat is useless).
|
|
1631
|
+
const guest = guestRequestOf(req);
|
|
1632
|
+
if (guest) {
|
|
1633
|
+
const served = isFileId(id) ? ctx.webFiles?.servedFile?.(id) ?? null : null;
|
|
1634
|
+
if (!served || !guest.scope().has(served.instance)) {
|
|
1635
|
+
json(res, 404, { error: "No such file" });
|
|
1636
|
+
return true;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1141
1639
|
// #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
|
|
1142
1640
|
// memory. Every other type is answered exactly as before.
|
|
1143
1641
|
const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
|
|
@@ -1169,9 +1667,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1169
1667
|
return true;
|
|
1170
1668
|
}
|
|
1171
1669
|
// ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
|
|
1172
|
-
// GET /ui/themes: { rev, themes, locked, widgets } when the status frame's themes_rev moves (passive, web-auth.ts).
|
|
1670
|
+
// GET /ui/themes: { rev, themes, locked, widgets, ambient (#2117) } when the status frame's themes_rev moves (passive, web-auth.ts).
|
|
1173
1671
|
// GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
|
|
1174
|
-
// POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out)
|
|
1672
|
+
// POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out) | ambient (#2117: { spec } sets one,
|
|
1673
|
+
// { which? } alone clears that kind or both): a person's, from an admin session — never the CLI token, never the public
|
|
1175
1674
|
// link (absent from its manifest, and refused here too).
|
|
1176
1675
|
if (method === "GET" && path === "/ui/themes") {
|
|
1177
1676
|
const store = ctx.themeStore;
|
|
@@ -1179,7 +1678,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1179
1678
|
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1180
1679
|
return true;
|
|
1181
1680
|
}
|
|
1182
|
-
json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets() });
|
|
1681
|
+
json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets(), ambient: store.ambients() });
|
|
1183
1682
|
return true;
|
|
1184
1683
|
}
|
|
1185
1684
|
const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
|
|
@@ -1205,7 +1704,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1205
1704
|
res.end(image.bytes);
|
|
1206
1705
|
return true;
|
|
1207
1706
|
}
|
|
1208
|
-
const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget)$/);
|
|
1707
|
+
const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget|ambient)$/);
|
|
1209
1708
|
if (method === "POST" && themeWrite) {
|
|
1210
1709
|
const service = ctx.webAppearance;
|
|
1211
1710
|
if (!service) {
|
|
@@ -1257,7 +1756,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1257
1756
|
}
|
|
1258
1757
|
const r = themeWrite[2] === "reset" ? service.humanReset(name)
|
|
1259
1758
|
: themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
|
|
1260
|
-
:
|
|
1759
|
+
: themeWrite[2] === "ambient" ? service.humanSetAmbient(name, body.spec ?? null, body.which)
|
|
1760
|
+
: typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
|
|
1261
1761
|
if (typeof r.error === "string") {
|
|
1262
1762
|
json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
|
|
1263
1763
|
return;
|
|
@@ -1441,6 +1941,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1441
1941
|
return true;
|
|
1442
1942
|
}
|
|
1443
1943
|
const click = ctx.clickWebReplyButton.bind(ctx);
|
|
1944
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
1444
1945
|
(async () => {
|
|
1445
1946
|
let body;
|
|
1446
1947
|
try {
|
|
@@ -1457,13 +1958,29 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1457
1958
|
json(res, 400, { error: "instance, id and index required" });
|
|
1458
1959
|
return;
|
|
1459
1960
|
}
|
|
1961
|
+
// #1920 step 2b: a guest clicks only on a reply it may see (§5.2), of an in-scope instance, while guests may chat.
|
|
1962
|
+
const guest = guestRequestOf(req);
|
|
1963
|
+
const refused = guestChatRefusal(guest, instance);
|
|
1964
|
+
if (refused) {
|
|
1965
|
+
json(res, refused.status, { error: refused.error });
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
if (guest) {
|
|
1969
|
+
const row = guest.history?.()?.findByButtons(instance, id);
|
|
1970
|
+
if (!row || !guestSeesMessage(row, guest, guest.scope())) {
|
|
1971
|
+
json(res, 404, { error: "not found" });
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1460
1975
|
// #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
|
|
1461
1976
|
// theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
|
|
1462
1977
|
// gate (a session revoked meanwhile) clicks nothing as anyone.
|
|
1463
1978
|
const person = webPersonOr401(req, res, url, ctx);
|
|
1464
1979
|
if (!person)
|
|
1465
1980
|
return;
|
|
1466
|
-
|
|
1981
|
+
// #1920 review: a guest's choice reaches the agent marked as a guest's, as its typed messages do.
|
|
1982
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
1983
|
+
const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name, ...(guest ? { guest: true } : {}), authority });
|
|
1467
1984
|
json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
1468
1985
|
})().catch(err => {
|
|
1469
1986
|
ctx.logger.error({ err: err.message }, "Web reply-button click failed");
|
|
@@ -1595,6 +2112,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1595
2112
|
// Canvas effects are absent from the public gateway manifest and independently refused above.
|
|
1596
2113
|
if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
|
|
1597
2114
|
return handleEffectTrials(req, res, url, ctx, path, method);
|
|
2115
|
+
// #2233: backend sign-in — absent from the public manifest and the guest table, and refused here too.
|
|
2116
|
+
if (path === "/ui/backend-login" || path.startsWith("/ui/backend-login/"))
|
|
2117
|
+
return handleBackendLogin(req, res, url, ctx, path, method);
|
|
1598
2118
|
if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
|
|
1599
2119
|
return handleEffects(req, res, url, ctx, path, method);
|
|
1600
2120
|
// #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
|
|
@@ -1641,12 +2161,57 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1641
2161
|
// #1269: an instance's chat command (/ctx, /compact, /clear, /model, …) — a session write (CSRF, checked by the
|
|
1642
2162
|
// gate), run through the platforms' own handlers and command table. The public link is told it is one (/save is
|
|
1643
2163
|
// refused there).
|
|
2164
|
+
// ── #1939 S2b: this browser's authorization — what the app shows (GET), and "Authorize this browser" (POST: posts the
|
|
2165
|
+
// Confirm card in the account's DM). Judged on this request (§3), never touching the session.
|
|
2166
|
+
if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
|
|
2167
|
+
const judge = () => {
|
|
2168
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
2169
|
+
return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: classifyWebPeer(req) };
|
|
2170
|
+
};
|
|
2171
|
+
res.setHeader("Cache-Control", "no-store");
|
|
2172
|
+
if (method === "GET") {
|
|
2173
|
+
if (!ctx.webSessionAuthorization) {
|
|
2174
|
+
json(res, 404, { error: "not available" });
|
|
2175
|
+
return true;
|
|
2176
|
+
}
|
|
2177
|
+
json(res, 200, ctx.webSessionAuthorization(judge()));
|
|
2178
|
+
return true;
|
|
2179
|
+
}
|
|
2180
|
+
if (!ctx.requestSessionAuthorization) {
|
|
2181
|
+
json(res, 404, { error: "not available" });
|
|
2182
|
+
return true;
|
|
2183
|
+
}
|
|
2184
|
+
const ask = ctx.requestSessionAuthorization.bind(ctx);
|
|
2185
|
+
(async () => {
|
|
2186
|
+
try {
|
|
2187
|
+
await parseBody(req);
|
|
2188
|
+
}
|
|
2189
|
+
catch {
|
|
2190
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
2194
|
+
return;
|
|
2195
|
+
const r = await ask(judge()); // asked again now the body is in
|
|
2196
|
+
// A refusal is an answer the page shows (pending, three this hour, why not), not a failed request: 200 with
|
|
2197
|
+
// ok: false — a 4xx would be logged as an error in the browser's console every time.
|
|
2198
|
+
json(res, 200, r);
|
|
2199
|
+
})().catch(err => {
|
|
2200
|
+
ctx.logger.error({ err: err.message }, "Web authorization request failed");
|
|
2201
|
+
try {
|
|
2202
|
+
json(res, 500, { error: "Request failed" });
|
|
2203
|
+
}
|
|
2204
|
+
catch { /* already answered */ }
|
|
2205
|
+
});
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
1644
2208
|
if (method === "POST" && path === "/ui/command") {
|
|
1645
2209
|
if (!ctx.webCommand) {
|
|
1646
2210
|
json(res, 404, { error: "Not available" });
|
|
1647
2211
|
return true;
|
|
1648
2212
|
}
|
|
1649
2213
|
const run = ctx.webCommand.bind(ctx);
|
|
2214
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
1650
2215
|
(async () => {
|
|
1651
2216
|
let body;
|
|
1652
2217
|
try {
|
|
@@ -1675,7 +2240,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1675
2240
|
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
1676
2241
|
if (!person)
|
|
1677
2242
|
return;
|
|
1678
|
-
const
|
|
2243
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
2244
|
+
const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority });
|
|
1679
2245
|
json(res, r.status, r.body);
|
|
1680
2246
|
})().catch(err => {
|
|
1681
2247
|
ctx.logger.error({ err: err.message }, "Web command failed");
|
|
@@ -1700,11 +2266,17 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1700
2266
|
return true;
|
|
1701
2267
|
}
|
|
1702
2268
|
// Own keys only: "constructor" is in every object and names no instance.
|
|
1703
|
-
const known = (ctx
|
|
2269
|
+
const known = registeredChatInstance(ctx, name) || ctx.daemons.has(name);
|
|
1704
2270
|
if (!ctx.cancelInstance || !known) {
|
|
1705
2271
|
json(res, 404, { error: `Instance not found: ${name}` });
|
|
1706
2272
|
return true;
|
|
1707
2273
|
}
|
|
2274
|
+
// #1920 step 2b: a guest's Cancel is the platform button's (anyone allowed there), while guests may chat.
|
|
2275
|
+
const cancelRefused = guestChatRefusal(guestRequestOf(req), name);
|
|
2276
|
+
if (cancelRefused) {
|
|
2277
|
+
json(res, cancelRefused.status, { error: cancelRefused.error });
|
|
2278
|
+
return true;
|
|
2279
|
+
}
|
|
1708
2280
|
if (!ctx.cancelInstance(name)) {
|
|
1709
2281
|
json(res, 409, { error: `${name} is not running` });
|
|
1710
2282
|
return true;
|
|
@@ -1927,6 +2499,11 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1927
2499
|
json(res, 200, { tasks });
|
|
1928
2500
|
return true;
|
|
1929
2501
|
}
|
|
2502
|
+
// #2246: the public link writes no task — the old list's create and update are board writes now (see below).
|
|
2503
|
+
if (method === "POST" && (path === "/ui/tasks" || /^\/ui\/tasks\/[^/]+$/.test(path)) && gatewayRequestContext(req)) {
|
|
2504
|
+
json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
|
|
2505
|
+
return true;
|
|
2506
|
+
}
|
|
1930
2507
|
if (method === "POST" && path === "/ui/tasks") {
|
|
1931
2508
|
if (!ctx.scheduler) {
|
|
1932
2509
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
@@ -1949,7 +2526,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1949
2526
|
priority: parsed.priority,
|
|
1950
2527
|
assignee: parsed.assignee,
|
|
1951
2528
|
created_by: person.name, // #1827
|
|
1952
|
-
});
|
|
2529
|
+
}, boardActor(req, url, ctx, person));
|
|
1953
2530
|
json(res, 200, task);
|
|
1954
2531
|
}
|
|
1955
2532
|
catch (err) {
|
|
@@ -1958,15 +2535,144 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1958
2535
|
})();
|
|
1959
2536
|
return true;
|
|
1960
2537
|
}
|
|
2538
|
+
// ── Task board (scheduler/task-board.ts) ──────────────
|
|
2539
|
+
// Reads are the session's; every write is the signed-in person's, through the same rules an instance's tools use.
|
|
2540
|
+
if (path === "/ui/tasks/board" || path === "/ui/tasks/search" || path.startsWith("/ui/tasks/cards") || path.startsWith("/ui/tasks/projects")) {
|
|
2541
|
+
const board = ctx.scheduler?.db.board;
|
|
2542
|
+
if (!board) {
|
|
2543
|
+
json(res, 503, { error: "The task board is not available" });
|
|
2544
|
+
return true;
|
|
2545
|
+
}
|
|
2546
|
+
// The temporary public link reads the board and changes nothing on it: confirming, sending back, cancelling and
|
|
2547
|
+
// assigning are the person's authority, which the public link never carries (docs/web-dashboard.md).
|
|
2548
|
+
const gateway = gatewayRequestContext(req);
|
|
2549
|
+
if (gateway && method !== "GET") {
|
|
2550
|
+
json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
|
|
2551
|
+
return true;
|
|
2552
|
+
}
|
|
2553
|
+
// …nor shows the cards of instances kept out of sight (cross_instance_visibility: hidden).
|
|
2554
|
+
const fleet = ctx.fleetConfig;
|
|
2555
|
+
const hiddenName = (n) => !!gateway && typeof n === "string" && !!n && !!ctx.fleetConfig?.instances?.[n] && crossInstanceVisibility(fleet, n) === "hidden";
|
|
2556
|
+
const hidden = (c) => [c.assignee, c.createdBy, c.dispatcher?.name].some(hiddenName);
|
|
2557
|
+
// #2303 review: what a visible card shows of the cards around it follows the same rule — its children, its main
|
|
2558
|
+
// task (a search hit's too) — and so do the words and the history a hidden instance left on it.
|
|
2559
|
+
// …and a hidden instance's words on a visible card are not counted, named or found there (#2304 review).
|
|
2560
|
+
const scope = { leaveOutAuthors: gateway ? Object.keys(ctx.fleetConfig?.instances ?? {}).filter(hiddenName) : [] };
|
|
2561
|
+
const hiddenRef = (ref) => { const c = ref ? board.findCard(ref) : undefined; return !!c && hidden(c); };
|
|
2562
|
+
const mentionsHidden = (v) => (typeof v === "string" ? hiddenName(v) : !!v && typeof v === "object" && Object.values(v).some(mentionsHidden));
|
|
2563
|
+
const shown = (d) => !gateway ? d : {
|
|
2564
|
+
...d,
|
|
2565
|
+
parent: d.card.parentId && hiddenRef(d.card.parentId) ? null : d.parent,
|
|
2566
|
+
children: d.children.filter((c) => !hidden(c)),
|
|
2567
|
+
comments: d.comments.filter((e) => !hiddenName(e.author)),
|
|
2568
|
+
logs: d.logs.filter((e) => !hiddenName(e.author)),
|
|
2569
|
+
history: d.history.filter((e) => !hiddenName(e.actor) && !mentionsHidden(e.data)),
|
|
2570
|
+
};
|
|
2571
|
+
const card = /^\/ui\/tasks\/cards\/([^/]+)(?:\/(move|reorder))?$/.exec(path);
|
|
2572
|
+
const project = /^\/ui\/tasks\/projects\/(\d+)$/.exec(path);
|
|
2573
|
+
try {
|
|
2574
|
+
const ref = card ? decodeURIComponent(card[1]) : "";
|
|
2575
|
+
if (method === "GET" && path === "/ui/tasks/board") {
|
|
2576
|
+
// A page that has this revision already is told so, not sent the board again.
|
|
2577
|
+
const rev = board.revision();
|
|
2578
|
+
if (url.searchParams.get("rev") === String(rev)) {
|
|
2579
|
+
json(res, 200, { rev, unchanged: true });
|
|
2580
|
+
return true;
|
|
2581
|
+
}
|
|
2582
|
+
const allDone = url.searchParams.get("done") === "all";
|
|
2583
|
+
// #2296: every open Tasks tab reads the board again after each change; on the dashboard they all get the same
|
|
2584
|
+
// board for a revision, so it is built once per (revision, done=all, minute) — the minute for the 14-day
|
|
2585
|
+
// window. The public link's reads are filtered per request (hidden instances) and are built each time.
|
|
2586
|
+
const key = gateway ? null : `${rev}:${allDone ? 1 : 0}:${Math.floor(Date.now() / 60_000)}`;
|
|
2587
|
+
const kept = key ? boardBodies.get(board) : undefined;
|
|
2588
|
+
if (kept && kept.key === key) {
|
|
2589
|
+
jsonText(res, 200, kept.body);
|
|
2590
|
+
return true;
|
|
2591
|
+
}
|
|
2592
|
+
const cards = board.boardCards(undefined, allDone).filter((c) => !hidden(c));
|
|
2593
|
+
const body = JSON.stringify({ rev, cards: board.views(cards, scope), projects: board.listProjects(true), stats: board.projectStats() });
|
|
2594
|
+
if (key)
|
|
2595
|
+
boardBodies.set(board, { key, body });
|
|
2596
|
+
jsonText(res, 200, body);
|
|
2597
|
+
return true;
|
|
2598
|
+
}
|
|
2599
|
+
if (method === "GET" && path === "/ui/tasks/search") {
|
|
2600
|
+
const names = new Map(board.listProjects(true).map((p) => [p.id, p.name]));
|
|
2601
|
+
const hits = board.search(url.searchParams.get("q") ?? "", Number(url.searchParams.get("limit")) || 20, scope).filter((h) => !hidden(h.card));
|
|
2602
|
+
const views = board.views(hits.map((h) => h.card), scope);
|
|
2603
|
+
const results = hits.map(({ card: c, match }, i) => ({
|
|
2604
|
+
card: views[i],
|
|
2605
|
+
match,
|
|
2606
|
+
project: c.projectId === null ? null : names.get(c.projectId) ?? null,
|
|
2607
|
+
parentTitle: c.parent && !hiddenRef(c.parent) ? board.findCard(c.parent)?.title ?? null : null,
|
|
2608
|
+
}));
|
|
2609
|
+
json(res, 200, { results });
|
|
2610
|
+
return true;
|
|
2611
|
+
}
|
|
2612
|
+
if (method === "GET" && card && !card[2]) {
|
|
2613
|
+
const found = board.getCard(ref);
|
|
2614
|
+
if (hidden(found)) {
|
|
2615
|
+
json(res, 404, { error: "no such card", code: "not_found" });
|
|
2616
|
+
return true;
|
|
2617
|
+
}
|
|
2618
|
+
json(res, 200, shown(board.detail(ref, scope)));
|
|
2619
|
+
return true;
|
|
2620
|
+
}
|
|
2621
|
+
if (method === "POST" && path === "/ui/tasks/cards") {
|
|
2622
|
+
boardWrite(req, res, url, ctx, board, CardCreateSchema, (spec, person) => {
|
|
2623
|
+
const { cards, said } = board.createCards(person, { cards: [spec] });
|
|
2624
|
+
return { card: board.view(cards[0]), notes: said };
|
|
2625
|
+
});
|
|
2626
|
+
return true;
|
|
2627
|
+
}
|
|
2628
|
+
if (method === "POST" && card) {
|
|
2629
|
+
if (card[2] === "move") {
|
|
2630
|
+
boardWrite(req, res, url, ctx, board, CardMoveSchema, ({ to, ...move }, person) => {
|
|
2631
|
+
const { card: moved, said } = board.moveCard(person, ref, to, move);
|
|
2632
|
+
return { card: board.view(moved), notes: said };
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
else if (card[2] === "reorder") {
|
|
2636
|
+
boardWrite(req, res, url, ctx, board, CardReorderSchema, ({ after, before }, person) => ({ card: board.view(board.reorder(person, ref, after ?? null, before ?? null)) }));
|
|
2637
|
+
}
|
|
2638
|
+
else {
|
|
2639
|
+
boardWrite(req, res, url, ctx, board, CardPatchSchema, (patch, person) => {
|
|
2640
|
+
const { card: updated, said } = board.updateCard(person, ref, patch);
|
|
2641
|
+
return { card: board.view(updated), notes: said };
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
return true;
|
|
2645
|
+
}
|
|
2646
|
+
if (method === "POST" && path === "/ui/tasks/projects") {
|
|
2647
|
+
boardWrite(req, res, url, ctx, board, ProjectCreateSchema, (input, person) => ({ project: board.createProject(person, input) }));
|
|
2648
|
+
return true;
|
|
2649
|
+
}
|
|
2650
|
+
if (method === "POST" && project) {
|
|
2651
|
+
boardWrite(req, res, url, ctx, board, ProjectPatchSchema, (patch, person) => ({ project: board.updateProject(person, Number(project[1]), patch) }));
|
|
2652
|
+
return true;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
catch (err) {
|
|
2656
|
+
// A malformed %-escape in the card's address is the caller's mistake, not the server's.
|
|
2657
|
+
if (err instanceof URIError) {
|
|
2658
|
+
json(res, 400, { error: "bad card reference", code: "invalid" });
|
|
2659
|
+
return true;
|
|
2660
|
+
}
|
|
2661
|
+
boardError(res, err);
|
|
2662
|
+
return true;
|
|
2663
|
+
}
|
|
2664
|
+
json(res, 404, { error: "Not found" });
|
|
2665
|
+
return true;
|
|
2666
|
+
}
|
|
1961
2667
|
const taskMatch = path.match(/^\/ui\/tasks\/(.+)$/);
|
|
1962
2668
|
if (method === "POST" && taskMatch) {
|
|
1963
2669
|
if (!ctx.scheduler) {
|
|
1964
2670
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
1965
2671
|
return true;
|
|
1966
2672
|
}
|
|
1967
|
-
const id = decodeURIComponent(taskMatch[1]);
|
|
1968
2673
|
(async () => {
|
|
1969
2674
|
try {
|
|
2675
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
1970
2676
|
const body = await parseBody(req);
|
|
1971
2677
|
if (!permitWebContinuation(req, res, ctx))
|
|
1972
2678
|
return;
|
|
@@ -1974,19 +2680,22 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1974
2680
|
if (!parsed)
|
|
1975
2681
|
return;
|
|
1976
2682
|
let result;
|
|
2683
|
+
// #1827: the person, who is also the default assignee of a claim; the task board's review gate needs to know
|
|
2684
|
+
// a person (not an instance) made the move.
|
|
2685
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
2686
|
+
if (!person)
|
|
2687
|
+
return;
|
|
2688
|
+
const by = boardActor(req, url, ctx, person);
|
|
1977
2689
|
if (parsed.action === "claim") {
|
|
1978
|
-
|
|
1979
|
-
if (!parsed.assignee && !person)
|
|
1980
|
-
return;
|
|
1981
|
-
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
|
|
2690
|
+
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name, by);
|
|
1982
2691
|
}
|
|
1983
2692
|
else if (parsed.action === "complete") {
|
|
1984
|
-
result = ctx.scheduler.db.completeTask(id, parsed.result);
|
|
2693
|
+
result = ctx.scheduler.db.completeTask(id, parsed.result, by);
|
|
1985
2694
|
}
|
|
1986
2695
|
else {
|
|
1987
2696
|
// Strip action before passing remaining fields to updateTask
|
|
1988
2697
|
const { action: _a, ...rest } = parsed;
|
|
1989
|
-
result = ctx.scheduler.db.updateTask(id, rest);
|
|
2698
|
+
result = ctx.scheduler.db.updateTask(id, rest, by);
|
|
1990
2699
|
}
|
|
1991
2700
|
json(res, 200, result);
|
|
1992
2701
|
}
|
|
@@ -2114,6 +2823,130 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2114
2823
|
}
|
|
2115
2824
|
return true;
|
|
2116
2825
|
}
|
|
2826
|
+
// #2293: global search — an admin's only: the header token or an admin session. Never over the public link (the gateway
|
|
2827
|
+
// does not list it either), never a guest (not in GUEST_ROUTES). Asked on arrival and again before the answer is sent.
|
|
2828
|
+
if (method === "GET" && (path === "/ui/search" || path === "/ui/search/context")) {
|
|
2829
|
+
const refusal = () => {
|
|
2830
|
+
if (gatewayRequestContext(req))
|
|
2831
|
+
return [404, "search_not_over_public_link"];
|
|
2832
|
+
if (guestRequestOf(req))
|
|
2833
|
+
return [403, "guest_forbidden"];
|
|
2834
|
+
if (ctx.fleetConfig?.web?.search?.enabled === false || !ctx.searchIndex || ctx.searchIndex.status() === "unavailable")
|
|
2835
|
+
return [404, "search_unavailable"];
|
|
2836
|
+
return null;
|
|
2837
|
+
};
|
|
2838
|
+
const first = refusal();
|
|
2839
|
+
if (first) {
|
|
2840
|
+
json(res, first[0], { error: first[1] });
|
|
2841
|
+
return true;
|
|
2842
|
+
}
|
|
2843
|
+
const admitted = adminAdmissionOf(req, url, ctx);
|
|
2844
|
+
if (!admitted) {
|
|
2845
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2846
|
+
return true;
|
|
2847
|
+
}
|
|
2848
|
+
const index = ctx.searchIndex;
|
|
2849
|
+
// cross_instance_visibility "hidden" (fleet-traffic's rule): a message to or from such a name is not found.
|
|
2850
|
+
const visibility = () => ctx.searchVisibility?.() ?? { hidden: [] };
|
|
2851
|
+
const shown = (doc) => xmsgShown(doc, visibility());
|
|
2852
|
+
const finish = (status, body) => {
|
|
2853
|
+
const now = refusal();
|
|
2854
|
+
if (now)
|
|
2855
|
+
return json(res, now[0], { error: now[1] });
|
|
2856
|
+
if (adminAdmissionOf(req, url, ctx) !== admitted)
|
|
2857
|
+
return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2858
|
+
json(res, status, body);
|
|
2859
|
+
};
|
|
2860
|
+
const failed = (reason) => finish(reason === "timeout" || reason === "busy" ? 503 : 404, { error: `search_${reason}` });
|
|
2861
|
+
if (path === "/ui/search/context") {
|
|
2862
|
+
const key = url.searchParams.get("key") ?? "";
|
|
2863
|
+
if (!key || key.length > 300) {
|
|
2864
|
+
json(res, 400, { error: "key required" });
|
|
2865
|
+
return true;
|
|
2866
|
+
}
|
|
2867
|
+
void index.context(key).then(r => {
|
|
2868
|
+
if (!r.ok)
|
|
2869
|
+
return failed(r.reason);
|
|
2870
|
+
const doc = r.value?.doc;
|
|
2871
|
+
// A message to or from an instance that is hidden now is not shown, as in the search itself.
|
|
2872
|
+
if (!doc || !shown(doc))
|
|
2873
|
+
return finish(404, { error: "not_found" });
|
|
2874
|
+
finish(200, { ...r.value, building: r.building });
|
|
2875
|
+
}, () => failed("unavailable"));
|
|
2876
|
+
return true;
|
|
2877
|
+
}
|
|
2878
|
+
const parsed = parseSearchQuery(url, ctx);
|
|
2879
|
+
if ("error" in parsed) {
|
|
2880
|
+
json(res, 400, { error: parsed.error });
|
|
2881
|
+
return true;
|
|
2882
|
+
}
|
|
2883
|
+
if (parsed.mine) {
|
|
2884
|
+
const person = webPersonOf(req, url, ctx);
|
|
2885
|
+
if (!person) {
|
|
2886
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2887
|
+
return true;
|
|
2888
|
+
}
|
|
2889
|
+
parsed.filters.mine = [person.voterKey];
|
|
2890
|
+
}
|
|
2891
|
+
const v = visibility();
|
|
2892
|
+
if ("hidden" in v)
|
|
2893
|
+
parsed.filters.hidden = v.hidden;
|
|
2894
|
+
else
|
|
2895
|
+
parsed.filters.xmsgAmong = v.among;
|
|
2896
|
+
void index.query(parsed.filters).then(r => {
|
|
2897
|
+
if (!r.ok)
|
|
2898
|
+
return failed(r.reason);
|
|
2899
|
+
// A chat row still in the history opens in place (#m=); one that has left it opens as the index's copy.
|
|
2900
|
+
const history = ctx.webChatHistory;
|
|
2901
|
+
// The visibility as it is now (#2295 review): a name hidden while the query ran is left out too.
|
|
2902
|
+
const now = visibility();
|
|
2903
|
+
const hits = r.value.hits.filter(h => xmsgShown(h, now)).map(h => h.source !== "chat" ? h
|
|
2904
|
+
: { ...h, live: !!history?.find(h.instance, String(h.ref.boot ?? ""), Number(h.ref.id)) });
|
|
2905
|
+
finish(200, { hits, limited: r.value.limited, building: r.building });
|
|
2906
|
+
}, () => failed("unavailable"));
|
|
2907
|
+
return true;
|
|
2908
|
+
}
|
|
2909
|
+
// #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
|
|
2910
|
+
// usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
|
|
2911
|
+
// from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
|
|
2912
|
+
if (method === "GET" && path === "/ui/usage-report") {
|
|
2913
|
+
// Who may have it, asked on arrival and again before it is sent (the report is awaited: a switch turned off, a
|
|
2914
|
+
// session signed out or a public link ended meanwhile gets nothing). The same boundary both times, no new gate.
|
|
2915
|
+
const refusal = () => {
|
|
2916
|
+
if (!ctx.usageReport || ctx.fleetConfig?.web?.usage_panel === false)
|
|
2917
|
+
return [404, "Not available"];
|
|
2918
|
+
if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.usage_report_on_public_link !== true)
|
|
2919
|
+
return [403, "usage_report_not_over_public_link"];
|
|
2920
|
+
return null;
|
|
2921
|
+
};
|
|
2922
|
+
// Over the public link, where to turn it on: Settings → Web on the dashboard reached directly (this computer's own
|
|
2923
|
+
// address — the public link's Settings only reads). Never the public origin's own Settings, which cannot change it.
|
|
2924
|
+
const answer = ([status, error]) => json(res, status, {
|
|
2925
|
+
error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web` } : {})
|
|
2926
|
+
});
|
|
2927
|
+
const first = refusal();
|
|
2928
|
+
if (first) {
|
|
2929
|
+
answer(first);
|
|
2930
|
+
return true;
|
|
2931
|
+
}
|
|
2932
|
+
const admitted = admissionOf(req, url, ctx);
|
|
2933
|
+
if (!admitted) {
|
|
2934
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2935
|
+
return true;
|
|
2936
|
+
}
|
|
2937
|
+
void import("./usage-report.js").then(({ usageRange, usageGroup }) => ctx.usageReport({
|
|
2938
|
+
range: usageRange(url.searchParams.get("range"), url.searchParams.get("from") ?? undefined, url.searchParams.get("to") ?? undefined),
|
|
2939
|
+
group: usageGroup(url.searchParams.get("group")),
|
|
2940
|
+
})).then((report) => {
|
|
2941
|
+
const now = refusal();
|
|
2942
|
+
if (now)
|
|
2943
|
+
return answer(now);
|
|
2944
|
+
if (admissionOf(req, url, ctx) !== admitted)
|
|
2945
|
+
return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2946
|
+
json(res, 200, report);
|
|
2947
|
+
}, (err) => { ctx.logger.error({ err }, "usage report failed"); json(res, 500, { error: "Usage report failed" }); });
|
|
2948
|
+
return true;
|
|
2949
|
+
}
|
|
2117
2950
|
// #1468: the prompt-cache expiry analysis. Opening it is a person's navigation, like any Fleet tab (it counts as
|
|
2118
2951
|
// use); the page never re-reads it on a timer. Answered from the ledgers as they are; a catch-up runs behind.
|
|
2119
2952
|
if (method === "GET" && path === "/ui/cache") {
|
|
@@ -2131,6 +2964,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2131
2964
|
json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
|
|
2132
2965
|
return true;
|
|
2133
2966
|
}
|
|
2967
|
+
// #1918: the org canvas's traffic on opening, and the public link's (which has no stream) on its poll cadence. The
|
|
2968
|
+
// fleet works it out at most once every two seconds and caps it, whoever asks how often.
|
|
2969
|
+
if (method === "GET" && path === "/ui/traffic") {
|
|
2970
|
+
json(res, 200, ctx.trafficSnapshot?.() ?? { now: new Date().toISOString(), items: [], delegations: [] });
|
|
2971
|
+
return true;
|
|
2972
|
+
}
|
|
2134
2973
|
if (method === "GET" && path === "/ui/teams") {
|
|
2135
2974
|
const teams = ctx.fleetConfig?.teams ?? {};
|
|
2136
2975
|
json(res, 200, { teams });
|
|
@@ -2309,6 +3148,17 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2309
3148
|
json(res, 404, { error: "not found" });
|
|
2310
3149
|
return true;
|
|
2311
3150
|
}
|
|
3151
|
+
/**
|
|
3152
|
+
* #2272: the line a web reply's channel copy opens with — the quoted sender and first words when that message reached
|
|
3153
|
+
* the channel (its members saw it); a web-only one is only named, never copied out to the channel.
|
|
3154
|
+
*/
|
|
3155
|
+
export function webReplyEchoLine(quoted) {
|
|
3156
|
+
if (!quoted.channel)
|
|
3157
|
+
return t("web.echo_reply_web_only");
|
|
3158
|
+
const ref = webChatReplyRef(quoted);
|
|
3159
|
+
const words = ref.text.slice(0, 120) || (ref.media === "photo" ? "[photo]" : ref.media ? "[file]" : "");
|
|
3160
|
+
return t("web.echo_reply", quoted.sender.slice(0, 80), words);
|
|
3161
|
+
}
|
|
2312
3162
|
/** Keep the echo in a single platform message, including its attachment names. */
|
|
2313
3163
|
function webEchoPreview(message, names) {
|
|
2314
3164
|
const text = wellFormed(message.slice(0, 500));
|
|
@@ -2317,8 +3167,30 @@ function webEchoPreview(message, names) {
|
|
|
2317
3167
|
const truncated = message.length > 500 || preview.length > 1400;
|
|
2318
3168
|
return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
|
|
2319
3169
|
}
|
|
3170
|
+
/** Registration and liveness are different: a paused CLI may have neither daemon nor IPC. */
|
|
3171
|
+
function registeredChatInstance(ctx, name) {
|
|
3172
|
+
return (!!ctx.fleetConfig && Object.hasOwn(ctx.fleetConfig.instances, name)) || ctx.isClassicInstance?.(name) === true;
|
|
3173
|
+
}
|
|
3174
|
+
function uploadRecipient(ctx, name) {
|
|
3175
|
+
return registeredChatInstance(ctx, name)
|
|
3176
|
+
&& (ctx.instanceIpcClients.has(name) || ctx.getInstanceStatus?.(name) === "paused");
|
|
3177
|
+
}
|
|
3178
|
+
/** Closed, path-free explanations; arbitrary launch errors stay in the host log. */
|
|
3179
|
+
function webDeliveryFailure(err) {
|
|
3180
|
+
const message = err instanceof Error ? err.message : "";
|
|
3181
|
+
if (/No warm slot is free/.test(message))
|
|
3182
|
+
return { reason: "wake_capacity", error: "No slot is free to wake this instance. Retry when another instance is idle." };
|
|
3183
|
+
if (/login|log in|sign in|\bauth(?:entication|orization)?\b|credential|api.?key/i.test(message))
|
|
3184
|
+
return { reason: "wake_credentials", error: "The instance could not sign in. Check its account on the host, then retry." };
|
|
3185
|
+
if (/timed? out|timeout/i.test(message))
|
|
3186
|
+
return { reason: "wake_timeout", error: "The instance did not become ready in time. Retry, or check its status on the host." };
|
|
3187
|
+
if (/Cannot wake stopped|no longer configured/.test(message))
|
|
3188
|
+
return { reason: "instance_stopped", error: "This instance was stopped or removed. Start it on the host before retrying." };
|
|
3189
|
+
return { reason: "delivery_unavailable", error: "The instance could not receive this message. Check its status on the host, then retry." };
|
|
3190
|
+
}
|
|
2320
3191
|
/** Handle POST /ui/send — extracted for readability. */
|
|
2321
3192
|
function handleSendMessage(req, res, ctx) {
|
|
3193
|
+
const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
|
|
2322
3194
|
let body = "";
|
|
2323
3195
|
let size = 0;
|
|
2324
3196
|
req.on("data", (chunk) => {
|
|
@@ -2341,9 +3213,30 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2341
3213
|
json(res, 400, { error: `${issue.path.join(".") || "body"}: ${issue.message}` });
|
|
2342
3214
|
return;
|
|
2343
3215
|
}
|
|
2344
|
-
const { instance, message: typed, attachments = [] } = parsed.data;
|
|
2345
|
-
|
|
2346
|
-
|
|
3216
|
+
const { instance, message: typed, attachments = [], replyTo } = parsed.data;
|
|
3217
|
+
// #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
|
|
3218
|
+
const guest = guestRequestOf(req);
|
|
3219
|
+
const refused = guestChatRefusal(guest, instance);
|
|
3220
|
+
if (refused) {
|
|
3221
|
+
json(res, refused.status, { error: refused.error });
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
if (guest && guestCommandText(typed)) {
|
|
3225
|
+
json(res, 403, { error: "guest_no_commands" });
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
// #2272: a reply names a message this chat still keeps — and, for a guest, one it may see. What it quotes is read
|
|
3229
|
+
// from that row here, never taken from the page.
|
|
3230
|
+
let quoted;
|
|
3231
|
+
if (replyTo) {
|
|
3232
|
+
quoted = ctx.webChatHistory?.find(instance, replyTo.boot, replyTo.id);
|
|
3233
|
+
if (!quoted || quoted.effectTrial || (guest && !guestSeesMessage(quoted, guest, guest.scope()))) {
|
|
3234
|
+
json(res, 409, { error: t("web.reply_gone"), reason: "reply_gone" });
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
const registered = registeredChatInstance(ctx, instance);
|
|
3239
|
+
if (!registered && !ctx.instanceIpcClients.has(instance)) {
|
|
2347
3240
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
2348
3241
|
return;
|
|
2349
3242
|
}
|
|
@@ -2366,6 +3259,12 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2366
3259
|
json(res, 403, { error: t("web.raw_public_refused") });
|
|
2367
3260
|
return;
|
|
2368
3261
|
}
|
|
3262
|
+
// Recheck the same admission all the way through a wake, before the facade's actual IPC handoff.
|
|
3263
|
+
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
3264
|
+
const admission = admissionOf(req, requestUrl, ctx);
|
|
3265
|
+
const stillCurrent = () => !res.destroyed && admission !== null && admissionOf(req, requestUrl, ctx) === admission
|
|
3266
|
+
&& (!guest || (guest.current() && !guestChatRefusal(guest, instance)))
|
|
3267
|
+
&& (registered ? registeredChatInstance(ctx, instance) : ctx.instanceIpcClients.has(instance));
|
|
2369
3268
|
const message = typed;
|
|
2370
3269
|
// #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
|
|
2371
3270
|
// body is in: a session revoked meanwhile sends nothing (not as the operator).
|
|
@@ -2375,6 +3274,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2375
3274
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2376
3275
|
return;
|
|
2377
3276
|
}
|
|
3277
|
+
// #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
|
|
3278
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
|
|
2378
3279
|
const ts = new Date().toISOString();
|
|
2379
3280
|
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
2380
3281
|
const messageId = newWebMessageId();
|
|
@@ -2387,13 +3288,17 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2387
3288
|
const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
|
|
2388
3289
|
const canEcho = ctx.fleetConfig?.web?.echo_to_channel !== false
|
|
2389
3290
|
&& !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
|
|
2390
|
-
// Add no runtime credentials or local attachment paths to the display copy.
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
3291
|
+
// Add no runtime credentials or local attachment paths to the display copy. A reply opens with the line it
|
|
3292
|
+
// answers (#2272) — quoted only if that message reached the channel; a web-only one is named, never copied out.
|
|
3293
|
+
const quoteLine = quoted ? webReplyEchoLine(quoted) : "";
|
|
3294
|
+
const preview = (quoteLine ? `${quoteLine}\n` : "") + webEchoPreview(message, files.map(f => f.name));
|
|
3295
|
+
// ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
|
|
3296
|
+
// the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
|
|
2394
3297
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
2395
3298
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
2396
|
-
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
|
|
3299
|
+
const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
|
|
3300
|
+
if (posted > 0)
|
|
3301
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
|
|
2397
3302
|
}) : undefined;
|
|
2398
3303
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
2399
3304
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -2401,13 +3306,15 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2401
3306
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
2402
3307
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
2403
3308
|
return;
|
|
2404
|
-
|
|
3309
|
+
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: [] } });
|
|
3310
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
|
|
3311
|
+
return sent;
|
|
2405
3312
|
}) : undefined;
|
|
2406
3313
|
let delivered;
|
|
2407
3314
|
try {
|
|
2408
3315
|
const deliver = (name, payload) => ctx.deliverHumanToInstance
|
|
2409
|
-
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
|
|
2410
|
-
: ctx.deliverToInstance(name, payload);
|
|
3316
|
+
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed, { stillCurrent })
|
|
3317
|
+
: ctx.deliverToInstance(name, payload, { stillCurrent });
|
|
2411
3318
|
delivered = await deliver(instance, {
|
|
2412
3319
|
type: "fleet_inbound",
|
|
2413
3320
|
content: delivery.text,
|
|
@@ -2419,8 +3326,11 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2419
3326
|
ts,
|
|
2420
3327
|
thread_id: topicId != null ? String(topicId) : "",
|
|
2421
3328
|
source: "web",
|
|
3329
|
+
...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
|
|
2422
3330
|
adapter_id: syncAdapter?.id,
|
|
2423
3331
|
...delivery.meta,
|
|
3332
|
+
// #2272: `(reply_to: "…")`, as a Discord or Telegram reply gives the agent.
|
|
3333
|
+
...(quoted ? { reply_to_text: webChatReplyText(quoted) } : {}),
|
|
2424
3334
|
},
|
|
2425
3335
|
});
|
|
2426
3336
|
}
|
|
@@ -2432,7 +3342,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2432
3342
|
settleClassicEcho?.(false);
|
|
2433
3343
|
ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
|
|
2434
3344
|
ctx.logger.error({ err, instance }, "Web message delivery failed");
|
|
2435
|
-
json(res, 503,
|
|
3345
|
+
json(res, 503, webDeliveryFailure(err));
|
|
2436
3346
|
return;
|
|
2437
3347
|
}
|
|
2438
3348
|
if (delivered === false) {
|
|
@@ -2440,7 +3350,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2440
3350
|
settleClassicEcho?.(false);
|
|
2441
3351
|
ctx.webFiles?.release(files);
|
|
2442
3352
|
ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
|
|
2443
|
-
json(res, 503, { error: "
|
|
3353
|
+
json(res, 503, { reason: "delivery_changed", error: "The session or instance changed before this message was sent. Sign in again if needed, then retry." });
|
|
2444
3354
|
return;
|
|
2445
3355
|
}
|
|
2446
3356
|
settleEcho?.(true);
|
|
@@ -2455,7 +3365,9 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2455
3365
|
ctx.webFiles?.commit(files);
|
|
2456
3366
|
ctx.lastInboundUser.set(instance, person.name);
|
|
2457
3367
|
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
2458
|
-
|
|
3368
|
+
// #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
|
|
3369
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}),
|
|
3370
|
+
...(quoted ? { replyTo: webChatReplyRef(quoted) } : {}) });
|
|
2459
3371
|
json(res, 200, { sent: true, messageId });
|
|
2460
3372
|
}
|
|
2461
3373
|
catch {
|
|
@@ -2479,7 +3391,14 @@ function handleUpload(req, res, url, ctx) {
|
|
|
2479
3391
|
json(res, 404, { error: "uploads are not available" });
|
|
2480
3392
|
return;
|
|
2481
3393
|
}
|
|
2482
|
-
|
|
3394
|
+
// #1920 step 2b: a guest's upload is for an in-scope instance's message, while guests may chat.
|
|
3395
|
+
const guestRefused = guestChatRefusal(guestRequestOf(req), instance);
|
|
3396
|
+
if (guestRefused) {
|
|
3397
|
+
req.resume();
|
|
3398
|
+
json(res, guestRefused.status, { error: guestRefused.error });
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !uploadRecipient(ctx, instance)) {
|
|
2483
3402
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
2484
3403
|
return;
|
|
2485
3404
|
}
|
|
@@ -2542,11 +3461,9 @@ function handleUpload(req, res, url, ctx) {
|
|
|
2542
3461
|
req.on("error", () => drop()); // the client went away
|
|
2543
3462
|
req.on("close", () => { if (!over && req.complete === false)
|
|
2544
3463
|
drop(); });
|
|
2545
|
-
//
|
|
2546
|
-
//
|
|
2547
|
-
|
|
2548
|
-
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
|
|
2549
|
-
const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
|
|
3464
|
+
// Recheck registration and delivery readiness after both waits. A paused recipient may have no IPC;
|
|
3465
|
+
// staging a file never wakes it. A stopped or deleted recipient must not regain an upload id.
|
|
3466
|
+
const deliverable = () => uploadRecipient(ctx, instance);
|
|
2550
3467
|
const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
|
|
2551
3468
|
req.on("end", () => {
|
|
2552
3469
|
if (over)
|
|
@@ -2673,14 +3590,22 @@ function prefsViewer(req, url, ctx) {
|
|
|
2673
3590
|
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
2674
3591
|
if (v.kind !== "allow")
|
|
2675
3592
|
return null;
|
|
3593
|
+
// #1920: a guest is a guest on every listener. The public link's page and reads too — before, a guest signed in through
|
|
3594
|
+
// it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
|
|
3595
|
+
// scope. Its account (phase 3) names it on its page.
|
|
3596
|
+
const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true, ...(v.session.owner ? { owner: v.session.owner } : {}) } : {};
|
|
2676
3597
|
if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
|
|
2677
3598
|
const person = webPersonKey(v.session.owner);
|
|
2678
|
-
return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
|
|
3599
|
+
return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
|
|
2679
3600
|
}
|
|
2680
|
-
return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
|
|
3601
|
+
return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
|
|
2681
3602
|
}
|
|
2682
3603
|
const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
|
|
2683
|
-
|
|
3604
|
+
// #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
|
|
3605
|
+
const prefsView = (prefs, v) => {
|
|
3606
|
+
const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
|
|
3607
|
+
return v?.guest ? guestPrefs(view) : view;
|
|
3608
|
+
};
|
|
2684
3609
|
function shellHtmlTag(ctx, mode, viewer) {
|
|
2685
3610
|
if (mode !== "full" || !ctx.webPrefs)
|
|
2686
3611
|
return '<html lang="en">';
|
|
@@ -2694,26 +3619,40 @@ function shellHtmlTag(ctx, mode, viewer) {
|
|
|
2694
3619
|
* the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
|
|
2695
3620
|
*/
|
|
2696
3621
|
function shellOwnerCss(ctx, mode, viewer) {
|
|
2697
|
-
return mode === "full" && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
|
|
3622
|
+
return mode === "full" && !viewer?.guest && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
|
|
3623
|
+
}
|
|
3624
|
+
function guestBodyAttrs(ctx, owner, attr) {
|
|
3625
|
+
const badge = ctx.guestBadge?.(owner);
|
|
3626
|
+
return ` data-tier="guest"${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
|
|
3627
|
+
}
|
|
3628
|
+
/** A separate session fence for Chat playback: public/guest viewers acquire no owner-pref identity. */
|
|
3629
|
+
function shellEffect(req, ctx, mode) {
|
|
3630
|
+
if (mode !== "full" || typeof ctx.effectBoot !== "string" || !/^[0-9a-f]{32}$/.test(ctx.effectBoot))
|
|
3631
|
+
return null;
|
|
3632
|
+
const v = evaluateWebRequest(req, new URL(req.url ?? "/", "http://localhost"), ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
3633
|
+
if (v.kind !== "allow" || v.via !== "session" || !v.session || !/^[a-f0-9]{16}$/.test(v.session.handle))
|
|
3634
|
+
return null;
|
|
3635
|
+
return { boot: ctx.effectBoot, epoch: v.session.handle, scope: gatewayRequestContext(req) || v.session.tier === "guest" ? "chat" : "owner" };
|
|
2698
3636
|
}
|
|
2699
3637
|
function shellBodyTag(req, ctx, mode, viewer) {
|
|
2700
3638
|
const p = shellPreview(req, ctx, mode);
|
|
2701
|
-
const ownerCss = shellOwnerCss(ctx, mode, viewer);
|
|
2702
|
-
let ownerCssState = "unknown";
|
|
2703
|
-
if (mode === "full" && viewer?.person != null && ctx.ownerCss) {
|
|
3639
|
+
const ownerCss = shellOwnerCss(ctx, mode, viewer), effect = shellEffect(req, ctx, mode);
|
|
3640
|
+
let ownerCssState = mode === "full" && (gatewayRequestContext(req) || viewer?.guest) ? "off" : "unknown";
|
|
3641
|
+
if (mode === "full" && !viewer?.guest && viewer?.person != null && ctx.ownerCss) {
|
|
2704
3642
|
ownerCssState = ctx.ownerCss.effectState(viewer.person);
|
|
2705
3643
|
if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
|
|
2706
3644
|
ownerCssState = "unknown";
|
|
2707
3645
|
}
|
|
2708
3646
|
const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
2709
3647
|
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)}"`
|
|
3648
|
+
+ `${effect ? ` data-effect-boot="${effect.boot}" data-effect-epoch="${effect.epoch}" data-effect-scope="${effect.scope}"` : ""}`
|
|
2710
3649
|
+ ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
|
|
2711
3650
|
// #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
|
|
2712
3651
|
+ ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
|
|
2713
3652
|
// #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
|
|
2714
3653
|
+ `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
|
|
2715
|
-
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's
|
|
2716
|
-
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
|
|
3654
|
+
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔ — never to a guest (#1920).
|
|
3655
|
+
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
|
|
2717
3656
|
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
2718
3657
|
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
2719
3658
|
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
@@ -2724,7 +3663,9 @@ function shellBodyTag(req, ctx, mode, viewer) {
|
|
|
2724
3663
|
// #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
|
|
2725
3664
|
+ ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
|
|
2726
3665
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|
|
2727
|
-
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}
|
|
3666
|
+
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
|
|
3667
|
+
// #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
|
|
3668
|
+
+ `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
|
|
2728
3669
|
}
|
|
2729
3670
|
/** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
|
|
2730
3671
|
* when this load chose one. */
|
|
@@ -2740,8 +3681,11 @@ export function serveAppShell(req, res, ctx, mode) {
|
|
|
2740
3681
|
const mediaSrc = own ? `${own}/ui/file/` : "'none'";
|
|
2741
3682
|
// A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
|
|
2742
3683
|
// the page may load); every other page frames exactly the origin this load chose (#1723).
|
|
2743
|
-
const
|
|
2744
|
-
|
|
3684
|
+
const previewFrames = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
|
|
3685
|
+
// This fixed trusted document is independent of HTML preview. Its own response confines all Worker execution.
|
|
3686
|
+
const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
|
|
3687
|
+
const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
|
|
3688
|
+
const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
|
|
2745
3689
|
sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
2746
3690
|
}
|
|
2747
3691
|
catch {
|