@songsid/agend 2.3.0-beta.9 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/README.zh-TW.md +16 -8
- package/dist/adapter-world.js.map +1 -1
- package/dist/agend-home.d.ts +5 -0
- package/dist/agend-home.js +7 -0
- package/dist/agend-home.js.map +1 -0
- package/dist/agent-cli-instructions.md +7 -1
- package/dist/agent-cli.js +41 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +9 -0
- package/dist/agent-endpoint.js +23 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.js +1 -1
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.d.ts +16 -0
- package/dist/agent-whats-new.js +53 -12
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/apply-job.js.map +1 -1
- package/dist/atomic-write.js.map +1 -1
- package/dist/auth-api.d.ts +12 -0
- package/dist/auth-api.js +77 -22
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +17 -8
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +16 -8
- package/dist/authority/web-authority.js +19 -5
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/antigravity.d.ts +2 -2
- package/dist/backend/antigravity.js +2 -1
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +13 -4
- 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-package.d.ts +30 -0
- package/dist/backend/codex-package.js +133 -0
- package/dist/backend/codex-package.js.map +1 -0
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +2 -0
- package/dist/backend/codex.js +25 -5
- 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/effort-metadata.js.map +1 -1
- package/dist/backend/grok.js +58 -3
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.js.map +1 -1
- package/dist/backend/kiro-kas-store.js.map +1 -1
- package/dist/backend/kiro-v2-store.js.map +1 -1
- package/dist/backend/kiro-v3-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -1
- package/dist/backend/kiro.js +102 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.js.map +1 -1
- package/dist/backend/mock.js.map +1 -1
- package/dist/backend/muse.d.ts +20 -2
- package/dist/backend/muse.js +154 -16
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/opencode-permission.js +1 -1
- package/dist/backend/opencode-permission.js.map +1 -1
- package/dist/backend/opencode.js +2 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/session-signals.js.map +1 -1
- package/dist/backend-availability.js.map +1 -1
- package/dist/backend-command.d.ts +2 -0
- package/dist/backend-command.js +3 -1
- package/dist/backend-command.js.map +1 -1
- package/dist/backend-outage.js.map +1 -1
- package/dist/binary-probe.js +1 -1
- package/dist/binary-probe.js.map +1 -1
- package/dist/bug-report-cli.d.ts +30 -0
- package/dist/bug-report-cli.js +139 -0
- package/dist/bug-report-cli.js.map +1 -0
- package/dist/bug-report.d.ts +47 -0
- package/dist/bug-report.js +137 -0
- package/dist/bug-report.js.map +1 -0
- package/dist/bundled-skills.d.ts +18 -0
- package/dist/bundled-skills.js +38 -0
- package/dist/bundled-skills.js.map +1 -0
- package/dist/cache-service.js +1 -1
- 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 +4 -0
- package/dist/channel/adapters/discord.js +57 -12
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +7 -1
- package/dist/channel/adapters/telegram.js +57 -49
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-server.js +21 -4
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +3 -1
- package/dist/channel/mcp-tools.js +41 -4
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/sticker-download.js.map +1 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +54 -0
- package/dist/child-process.js.map +1 -0
- package/dist/classic-channel-manager.js +3 -2
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli-entry.js.map +1 -1
- package/dist/cli-flags.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +448 -313
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +1 -1
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.js.map +1 -1
- package/dist/completion-install.js.map +1 -1
- package/dist/completion.js.map +1 -1
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +81 -8
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +15 -3
- package/dist/config.js +41 -10
- package/dist/config.js.map +1 -1
- package/dist/connection-secrets.js.map +1 -1
- package/dist/context-guardian.js.map +1 -1
- package/dist/coredump-filter.js.map +1 -1
- package/dist/cpu-profile.js.map +1 -1
- package/dist/cross-instance-envelope.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 +52 -1
- package/dist/daemon.js +385 -27
- package/dist/daemon.js.map +1 -1
- package/dist/daily-summary.js.map +1 -1
- package/dist/darwin-memory.js +1 -1
- package/dist/darwin-memory.js.map +1 -1
- package/dist/dashboard-live.d.ts +8 -0
- package/dist/dashboard-live.js +35 -0
- package/dist/dashboard-live.js.map +1 -0
- package/dist/dashboard-permissions.d.ts +69 -0
- package/dist/dashboard-permissions.js +138 -0
- package/dist/dashboard-permissions.js.map +1 -0
- package/dist/decision-list.js.map +1 -1
- package/dist/delivery-consumed-watch.js.map +1 -1
- package/dist/delivery-outbox.d.ts +11 -0
- package/dist/delivery-outbox.js +52 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-outbox.test.js.map +1 -1
- package/dist/delivery-reconciliation.test.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/doctor.d.ts +8 -0
- package/dist/doctor.js +52 -0
- package/dist/doctor.js.map +1 -1
- package/dist/e2e-isolation.js.map +1 -1
- package/dist/event-log.js.map +1 -1
- package/dist/export-import.js +7 -5
- package/dist/export-import.js.map +1 -1
- package/dist/fleet-context.d.ts +1 -1
- package/dist/fleet-control-audit.js.map +1 -1
- package/dist/fleet-label.js.map +1 -1
- package/dist/fleet-level-config.d.ts +1 -1
- package/dist/fleet-level-config.js +2 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-lock.d.ts +6 -0
- package/dist/fleet-lock.js +22 -2
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +283 -15
- package/dist/fleet-manager.js +1825 -331
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +18 -0
- package/dist/fleet-process-handlers.js +70 -0
- package/dist/fleet-process-handlers.js.map +1 -0
- package/dist/fleet-startup.d.ts +14 -0
- package/dist/fleet-startup.js +30 -0
- package/dist/fleet-startup.js.map +1 -0
- package/dist/fleet-traffic.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +0 -20
- package/dist/forged-envelope-scan.js +3 -2
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/full-restart.d.ts +5 -5
- package/dist/full-restart.js +5 -5
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +52 -11
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +5 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -1
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +8 -9
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +11 -0
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
- 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.canvas-worker-off.md +321 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -4
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +554 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +156 -32
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/general-knowledge/steering/core-rules.md +1 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/idle-gate-log.d.ts +19 -0
- package/dist/idle-gate-log.js +15 -0
- package/dist/idle-gate-log.js.map +1 -0
- package/dist/instance-config-impact.js +25 -4
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +11 -1
- package/dist/instance-lifecycle.js +97 -25
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -6
- package/dist/instructions.js +19 -10
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.js.map +1 -1
- package/dist/kiro-db-reader.js.map +1 -1
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/kiro-log-gate.js.map +1 -1
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/locale.js +450 -20
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +1 -0
- package/dist/log-service.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/logger.js +3 -2
- package/dist/logger.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/login-flows.js +1 -1
- package/dist/login-manager.js.map +1 -1
- package/dist/login-shell.js +3 -1
- package/dist/login-shell.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 +83 -4
- package/dist/ls-rows.js.map +1 -1
- package/dist/memory-pressure.js.map +1 -1
- package/dist/muse-usage-relay.js.map +1 -1
- package/dist/needs-you-hub.d.ts +98 -1
- package/dist/needs-you-hub.js +313 -44
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +5 -2
- package/dist/needs-you-live.js +6 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/needs-you.d.ts +68 -3
- package/dist/needs-you.js +39 -4
- package/dist/needs-you.js.map +1 -1
- package/dist/network-family.js.map +1 -1
- package/dist/operator-control-paths.js +3 -4
- package/dist/operator-control-paths.js.map +1 -1
- package/dist/outbound-handlers.d.ts +5 -2
- package/dist/outbound-handlers.js +81 -24
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +106 -60
- package/dist/outbound-schemas.js +33 -10
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/outbox-open-error.js.map +1 -1
- package/dist/pane-context-cache.js.map +1 -1
- package/dist/paths.js.map +1 -1
- package/dist/private-dir.js.map +1 -1
- package/dist/probe-worker-pool.js.map +1 -1
- package/dist/profile-control.js.map +1 -1
- package/dist/prompt-file-ref.js.map +1 -1
- package/dist/provider-probe.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +5 -3
- package/dist/provider-secret-registry.js +8 -2
- package/dist/provider-secret-registry.js.map +1 -1
- package/dist/public-link-auto-open.d.ts +32 -0
- package/dist/public-link-auto-open.js +86 -0
- package/dist/public-link-auto-open.js.map +1 -0
- package/dist/public-link-owner.d.ts +10 -0
- package/dist/public-link-owner.js +36 -0
- package/dist/public-link-owner.js.map +1 -0
- package/dist/public-link-progress.d.ts +12 -1
- package/dist/public-link-progress.js +5 -10
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.js.map +1 -1
- package/dist/public-tunnel-config.d.ts +11 -0
- package/dist/public-tunnel-config.js +68 -0
- package/dist/public-tunnel-config.js.map +1 -0
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +19 -6
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +48 -3
- package/dist/public-web-link.js +177 -51
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +9 -0
- package/dist/quickstart-api.js +82 -0
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +5 -0
- package/dist/quickstart.js +40 -25
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-dedup.js.map +1 -1
- package/dist/resource-report.js +1 -1
- package/dist/resource-report.js.map +1 -1
- package/dist/restart-guard.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.js.map +1 -1
- package/dist/runtime-cpu-profile.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/scheduler.js.map +1 -1
- package/dist/scheduler/task-board-text.d.ts +26 -0
- package/dist/scheduler/task-board-text.js +140 -0
- package/dist/scheduler/task-board-text.js.map +1 -0
- package/dist/scheduler/task-board.d.ts +730 -0
- package/dist/scheduler/task-board.js +2179 -0
- package/dist/scheduler/task-board.js.map +1 -0
- package/dist/scheduler/types.d.ts +6 -1
- package/dist/sd-notify.js +2 -1
- package/dist/sd-notify.js.map +1 -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 +149 -0
- package/dist/search/search-index.js +453 -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/secret-file.js.map +1 -1
- package/dist/secret-store.d.ts +2 -0
- package/dist/secret-store.js +21 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/service-installer.d.ts +98 -1
- package/dist/service-installer.js +246 -11
- package/dist/service-installer.js.map +1 -1
- package/dist/service-owner.d.ts +44 -0
- package/dist/service-owner.js +48 -0
- package/dist/service-owner.js.map +1 -0
- package/dist/service-plan.d.ts +39 -2
- package/dist/service-plan.js +188 -6
- package/dist/service-plan.js.map +1 -1
- package/dist/session-oom-stop.d.ts +45 -0
- package/dist/session-oom-stop.js +259 -0
- package/dist/session-oom-stop.js.map +1 -0
- package/dist/session-oom.d.ts +34 -0
- package/dist/session-oom.js +251 -0
- package/dist/session-oom.js.map +1 -0
- package/dist/settings-api.d.ts +14 -1
- package/dist/settings-api.js +74 -13
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-baseline.js.map +1 -1
- package/dist/settings-change.d.ts +5 -0
- package/dist/settings-change.js +167 -6
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +9 -1
- package/dist/settings-confirmation.js +18 -8
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-control.js +2 -1
- package/dist/settings-control.js.map +1 -1
- package/dist/settings-effect.js +29 -4
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +13 -0
- package/dist/settings-http-confirmation.js +15 -3
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-instance-patch.js +13 -1
- package/dist/settings-instance-patch.js.map +1 -1
- package/dist/settings-request-capability.d.ts +4 -1
- package/dist/settings-request-capability.js +11 -3
- package/dist/settings-request-capability.js.map +1 -1
- package/dist/settings-transaction.js.map +1 -1
- package/dist/setup-auth.d.ts +1 -0
- package/dist/setup-auth.js +2 -1
- package/dist/setup-auth.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/setup-host.d.ts +6 -1
- package/dist/setup-host.js +15 -4
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +6 -3
- package/dist/setup-wizard.js.map +1 -1
- package/dist/spawn-gate.d.ts +7 -0
- package/dist/spawn-gate.js +45 -12
- package/dist/spawn-gate.js.map +1 -1
- package/dist/start-attempt.d.ts +11 -0
- package/dist/start-attempt.js +29 -0
- package/dist/start-attempt.js.map +1 -0
- package/dist/start-failure-chat.d.ts +37 -0
- package/dist/start-failure-chat.js +142 -0
- package/dist/start-failure-chat.js.map +1 -0
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +20 -0
- package/dist/start-failure-notice.js.map +1 -0
- package/dist/start-failure.d.ts +171 -0
- package/dist/start-failure.js +348 -0
- package/dist/start-failure.js.map +1 -0
- package/dist/startup-memory-deadline.d.ts +23 -0
- package/dist/startup-memory-deadline.js +55 -0
- package/dist/startup-memory-deadline.js.map +1 -0
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/storm-window.js.map +1 -1
- package/dist/systemd-peer-bus.js.map +1 -1
- package/dist/target-queue-worker.js.map +1 -1
- package/dist/task-board-summary.d.ts +101 -0
- package/dist/task-board-summary.js +177 -0
- package/dist/task-board-summary.js.map +1 -0
- package/dist/task-board-tool.d.ts +25 -0
- package/dist/task-board-tool.js +155 -0
- package/dist/task-board-tool.js.map +1 -0
- package/dist/task-board-waker.d.ts +51 -0
- package/dist/task-board-waker.js +141 -0
- package/dist/task-board-waker.js.map +1 -0
- package/dist/telegram-api-root.d.ts +1 -0
- package/dist/telegram-api-root.js +40 -0
- package/dist/telegram-api-root.js.map +1 -0
- package/dist/tips.js.map +1 -1
- package/dist/tmux-control.js +1 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +104 -0
- package/dist/tmux-exec-helper.js +412 -0
- package/dist/tmux-exec-helper.js.map +1 -0
- package/dist/tmux-manager.d.ts +1 -0
- package/dist/tmux-manager.js +29 -13
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-read.js +2 -2
- package/dist/tmux-read.js.map +1 -1
- package/dist/tool-permissions.js.map +1 -1
- package/dist/tool-progress.js.map +1 -1
- package/dist/topic-archiver.js.map +1 -1
- package/dist/topic-commands.d.ts +13 -0
- package/dist/topic-commands.js +56 -33
- package/dist/topic-commands.js.map +1 -1
- package/dist/topic-link.js.map +1 -1
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-monitor.js +5 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +2 -0
- package/dist/transcript-sources.js +4 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared-install.js +3 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -16
- package/dist/tunnel/cloudflared.js +100 -13
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/lease.js +3 -2
- package/dist/tunnel/lease.js.map +1 -1
- package/dist/tunnel/manager.js.map +1 -1
- package/dist/tunnel/named-diagnostics.d.ts +13 -0
- package/dist/tunnel/named-diagnostics.js +52 -0
- package/dist/tunnel/named-diagnostics.js.map +1 -0
- package/dist/tunnel/public-fetch.js.map +1 -1
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/tunnel/types.d.ts +4 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +56 -2
- package/dist/ui/app-effects.js +23 -5
- package/dist/ui/app.html +2 -0
- package/dist/ui/backend-signin.js +297 -0
- package/dist/ui/chat-effects.js +10 -3
- package/dist/ui/chat-msg-actions.js +176 -0
- package/dist/ui/chat-render.js +62 -18
- package/dist/ui/chat-store.js +218 -48
- package/dist/ui/chat-strings.js +40 -8
- package/dist/ui/chat-thread.js +119 -10
- package/dist/ui/effect-trial-offer.js +2 -31
- package/dist/ui/effects-waker.js +2 -0
- package/dist/ui/fleet-canvas-layout.js +13 -4
- package/dist/ui/fleet-canvas.js +69 -28
- package/dist/ui/fleet-office-editor.js +149 -0
- package/dist/ui/fleet-office-model.js +358 -0
- package/dist/ui/fleet-office-pack.js +199 -0
- package/dist/ui/fleet-office-skin.js +474 -0
- package/dist/ui/fleet-office-store.js +49 -0
- package/dist/ui/fleet-office-zones.js +177 -0
- package/dist/ui/fleet-office.js +434 -0
- package/dist/ui/fleet-org.js +18 -5
- package/dist/ui/fleet-route-scene.js +272 -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 +482 -0
- package/dist/ui/fleet-task-work.js +107 -0
- package/dist/ui/fleet-tasks.js +569 -0
- package/dist/ui/fleet-traffic.js +81 -18
- package/dist/ui/fleet-usage.js +36 -2
- package/dist/ui/guest-refused.html +41 -0
- package/dist/ui/icons/icon-192.png +0 -0
- package/dist/ui/icons/icon-512.png +0 -0
- package/dist/ui/image-lightbox.js +45 -1
- package/dist/ui/image-zoom.js +242 -0
- package/dist/ui/panel-chat.js +214 -57
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +32 -71
- package/dist/ui/panel-log.js +3 -3
- package/dist/ui/panel-needs.js +23 -6
- package/dist/ui/panel-settings.js +166 -33
- package/dist/ui/preview-panel.js +2 -1
- package/dist/ui/search-dialog.js +350 -0
- package/dist/ui/settings-appearance.js +170 -10
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-dialogs.js +46 -21
- package/dist/ui/settings-discord.js +6 -2
- package/dist/ui/settings-guests.js +2 -1
- package/dist/ui/settings-learn.js +1 -0
- package/dist/ui/settings-model.js +51 -18
- package/dist/ui/settings-mods.js +8 -3
- package/dist/ui/settings-owner-effects.js +6 -4
- package/dist/ui/settings-strings.js +186 -6
- package/dist/ui/settings-token.js +3 -1
- package/dist/ui/settings-web-authority.js +13 -5
- package/dist/ui/settings-wizard.js +13 -7
- package/dist/ui/shared/agend-auth.js +2 -0
- package/dist/ui/shared/app-i18n.js +12 -5
- package/dist/ui/shared/app-needs.js +2 -2
- package/dist/ui/shared/app-prefs.js +15 -0
- package/dist/ui/shared/app-route.js +18 -0
- package/dist/ui/shared/app-shell.js +189 -28
- package/dist/ui/shared/app-store.js +3 -2
- package/dist/ui/shared/app-stream.js +5 -1
- package/dist/ui/shared/app-themes.js +116 -1
- package/dist/ui/shared/app.css +668 -48
- package/dist/ui/shared/app.js +9 -1
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/effect-app-runtime.js +7 -5
- package/dist/ui/shared/effect-chat-runtime.js +2 -7
- package/dist/ui/shared/effect-cursor.js +3 -2
- package/dist/ui/shared/effect-display-metadata.js +39 -0
- package/dist/ui/shared/effect-owner-source.js +8 -1
- package/dist/ui/shared/effect-registry.js +14 -4
- package/dist/ui/shared/effect-runtime-loader.js +3 -1
- package/dist/ui/shared/effect-stock-trust.js +5 -5
- package/dist/ui/shared/feature-flags.js +127 -0
- package/dist/ui/shared/guest-refused.js +35 -0
- package/dist/ui/shared/header-tools.js +2 -2
- package/dist/ui/shared/identity-card.js +26 -8
- package/dist/ui/shared/identity-color.js +17 -10
- package/dist/ui/shared/instance-nav.js +183 -28
- package/dist/ui/shared/owner-css.js +30 -1
- package/dist/ui/shared/panel-view.js +14 -29
- package/dist/ui/shared/text-undo.js +210 -0
- package/dist/ui/shared/tokens.css +4 -0
- package/dist/ui/shared/ui-dialog.js +5 -4
- package/dist/ui/shared/ui-icons.js +4 -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 +216 -16
- package/dist/ui/shared/view-strings.js +70 -4
- package/dist/ui/shared/web-authorize.js +50 -1
- package/dist/ui/signin.html +2 -0
- package/dist/ui/web-terminal/terminal.html +10 -10
- package/dist/ui/web-terminal/terminal.js +58 -12
- package/dist/update-check.js.map +1 -1
- package/dist/update-dispatch.js +1 -1
- package/dist/update-dispatch.js.map +1 -1
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +10 -1
- package/dist/update-launch.js +39 -14
- package/dist/update-launch.js.map +1 -1
- package/dist/update-marker.js.map +1 -1
- package/dist/update-preflight.js +1 -1
- package/dist/update-preflight.js.map +1 -1
- package/dist/update-progress.d.ts +3 -1
- package/dist/update-progress.js +3 -1
- package/dist/update-progress.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 +89 -2
- package/dist/usage/providers.js +247 -41
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/usage/usage-api.d.ts +5 -1
- package/dist/usage/usage-api.js +185 -58
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/usage-report.js.map +1 -1
- package/dist/usage-rollup.js +1 -1
- package/dist/usage-rollup.js.map +1 -1
- 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 +7 -0
- package/dist/view-api.js +16 -4
- package/dist/view-api.js.map +1 -1
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-ambient.js.map +1 -1
- package/dist/web-api.d.ts +58 -11
- package/dist/web-api.js +1104 -71
- package/dist/web-api.js.map +1 -1
- package/dist/web-app-icon-worker.d.ts +1 -0
- package/dist/web-app-icon-worker.js +16 -0
- package/dist/web-app-icon-worker.js.map +1 -0
- package/dist/web-app-icon.d.ts +109 -0
- package/dist/web-app-icon.js +257 -0
- package/dist/web-app-icon.js.map +1 -0
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +1 -0
- package/dist/web-appearance-tools.js +7 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +4 -10
- package/dist/web-appearance.js +7 -4
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-auth.d.ts +8 -4
- package/dist/web-auth.js +33 -21
- 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.js.map +1 -1
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +118 -8
- package/dist/web-chat-history.js +220 -25
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +5 -1
- package/dist/web-commands.js +1 -1
- package/dist/web-commands.js.map +1 -1
- package/dist/web-cookie-names.d.ts +5 -0
- package/dist/web-cookie-names.js +12 -0
- package/dist/web-cookie-names.js.map +1 -0
- package/dist/web-effect-bundle.d.ts +2 -2
- package/dist/web-effect-library.js.map +1 -1
- package/dist/web-effect-playback.js.map +1 -1
- package/dist/web-effect-profiles.d.ts +16 -16
- package/dist/web-effect-profiles.js +3 -2
- package/dist/web-effect-profiles.js.map +1 -1
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-effect.d.ts +6 -6
- package/dist/web-effect.js.map +1 -1
- package/dist/web-experimental-http.d.ts +2 -0
- package/dist/web-experimental-http.js +12 -0
- package/dist/web-experimental-http.js.map +1 -0
- package/dist/web-experimental.d.ts +11 -0
- package/dist/web-experimental.js +49 -0
- package/dist/web-experimental.js.map +1 -0
- package/dist/web-guest-access.d.ts +4 -2
- package/dist/web-guest-access.js +22 -10
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-guest-rooms.js.map +1 -1
- 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-icons.d.ts +33 -1
- package/dist/web-icons.js +83 -11
- package/dist/web-icons.js.map +1 -1
- package/dist/web-identity-color.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-images.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.js.map +1 -1
- package/dist/web-mentions.js.map +1 -1
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-store.d.ts +2 -0
- package/dist/web-mod-store.js +10 -0
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-office-store.d.ts +104 -0
- package/dist/web-office-store.js +236 -0
- package/dist/web-office-store.js.map +1 -0
- 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-owner-css.js.map +1 -1
- package/dist/web-png.d.ts +33 -0
- package/dist/web-png.js +329 -0
- package/dist/web-png.js.map +1 -0
- package/dist/web-prefs.d.ts +6 -0
- package/dist/web-prefs.js +29 -3
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +2 -0
- package/dist/web-preview.js +2 -0
- package/dist/web-preview.js.map +1 -1
- package/dist/web-profile.js.map +1 -1
- package/dist/web-reactions.d.ts +35 -0
- package/dist/web-reactions.js +47 -0
- package/dist/web-reactions.js.map +1 -0
- package/dist/web-request-context.d.ts +5 -0
- package/dist/web-request-context.js.map +1 -1
- package/dist/web-returning-device.d.ts +4 -2
- package/dist/web-returning-device.js +13 -6
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-schedules.js.map +1 -1
- package/dist/web-send-dedupe.d.ts +36 -0
- package/dist/web-send-dedupe.js +69 -0
- package/dist/web-send-dedupe.js.map +1 -0
- package/dist/web-session-authorization.js.map +1 -1
- package/dist/web-session.js +1 -1
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +11 -5
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +6 -0
- package/dist/web-settings-tools.js +52 -17
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-terminal-http.js +15 -2
- package/dist/web-terminal-http.js.map +1 -1
- package/dist/web-terminal.js +1 -1
- package/dist/web-terminal.js.map +1 -1
- package/dist/web-theme-store.d.ts +2 -2
- package/dist/web-theme-store.js +6 -3
- package/dist/web-theme-store.js.map +1 -1
- package/dist/web-theme.d.ts +29 -0
- package/dist/web-theme.js +89 -1
- package/dist/web-theme.js.map +1 -1
- package/dist/web-upload.d.ts +18 -1
- package/dist/web-upload.js +45 -7
- package/dist/web-upload.js.map +1 -1
- package/dist/web-widget.js.map +1 -1
- package/dist/webhook-emitter.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 +3 -0
- package/dist/working-state-journal.d.ts +68 -0
- package/dist/working-state-journal.js +93 -0
- package/dist/working-state-journal.js.map +1 -0
- package/dist/ws-server.js.map +1 -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 +828 -0
- package/launcher/install-admission.cjs +4 -39
- package/launcher/postinstall.cjs +14 -12
- package/launcher/private-home.cjs +43 -0
- package/package.json +16 -13
- 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,3 +1,5 @@
|
|
|
1
|
+
import { canvasWorkerEnabled, CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
|
|
2
|
+
import { isCanvasWorkerHttpPath } from "./web-experimental-http.js";
|
|
1
3
|
import { webHumanOrigin } from "./human-delivery-receipts.js";
|
|
2
4
|
import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
|
|
3
5
|
import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
|
|
@@ -5,6 +7,8 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
|
5
7
|
import { binaryProbe } from "./binary-probe.js";
|
|
6
8
|
import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
|
|
7
9
|
import { readBoundedWebBody } from "./web-body.js";
|
|
10
|
+
import { APP_ICON_MAX_BYTES } from "./web-app-icon.js";
|
|
11
|
+
import { withAppIconLinks } from "./web-icons.js";
|
|
8
12
|
import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
|
|
9
13
|
import { buildModBundle } from "./web-mod-export.js";
|
|
10
14
|
import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
|
|
@@ -12,6 +16,7 @@ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
|
|
|
12
16
|
import { canonicalText } from "./web-theme.js";
|
|
13
17
|
import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
|
|
14
18
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
19
|
+
import { crossInstanceVisibility } from "./cross-instance-notice.js";
|
|
15
20
|
import { permitWebContinuation } from "./web-continuation.js";
|
|
16
21
|
/**
|
|
17
22
|
* Web UI HTTP API handler.
|
|
@@ -21,7 +26,8 @@ import { formatWebChannelEcho } from "./web-channel-echo.js";
|
|
|
21
26
|
import { t } from "./locale.js";
|
|
22
27
|
import { isSafeInstanceName } from "./web-shell-routes.js";
|
|
23
28
|
import { MAX_COMMAND_ARGS } from "./web-commands.js";
|
|
24
|
-
import { sendPanelHtml } from "./web-host-guard.js";
|
|
29
|
+
import { ROUTE_WORKER_CSP, ROUTE_WORKER_PATH, sendPanelHtml } from "./web-host-guard.js";
|
|
30
|
+
import { libavoidFile } from "./web-libavoid.js";
|
|
25
31
|
import { shellRoute } from "./web-shell-routes.js";
|
|
26
32
|
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
27
33
|
import { join, dirname } from "node:path";
|
|
@@ -29,11 +35,12 @@ import { fileURLToPath } from "node:url";
|
|
|
29
35
|
import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
|
|
30
36
|
import { scheduleView } from "./web-schedules.js";
|
|
31
37
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
38
|
+
import { CARD_PRIORITIES, CARD_SIZES, CARD_STATUSES, TaskBoardError } from "./scheduler/task-board.js";
|
|
32
39
|
import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
|
|
33
40
|
import { z } from "zod";
|
|
34
41
|
import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
35
42
|
import { dashboardOriginFor } from "./web-preview.js";
|
|
36
|
-
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
43
|
+
import { WEB_CHAT_CARDS_OVERFLOW, newWebMessageId, parseLastEventId, webChatReplyRef, webChatReplyText } from "./web-chat-history.js";
|
|
37
44
|
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";
|
|
38
45
|
import { getAgendHome } from "./paths.js";
|
|
39
46
|
import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
|
|
@@ -44,6 +51,9 @@ import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
|
44
51
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
45
52
|
import { resolveTz } from "./tz-utils.js";
|
|
46
53
|
import { filesPlayInChat } from "./web-effect.js";
|
|
54
|
+
import { QUERY_MAX_CHARS, RESULT_MAX, SEARCH_SOURCES } from "./search/search-store.js";
|
|
55
|
+
import { xmsgShown } from "./search/search-docs.js";
|
|
56
|
+
import { WebSendDedupe } from "./web-send-dedupe.js";
|
|
47
57
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
48
58
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
49
59
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -58,11 +68,79 @@ const TaskUpdateSchema = z.object({
|
|
|
58
68
|
action: z.enum(["claim", "complete", "update"]).optional(),
|
|
59
69
|
assignee: z.string().max(128).optional(),
|
|
60
70
|
result: z.string().max(MAX_TEXT).optional(),
|
|
61
|
-
status:
|
|
71
|
+
// A status the task board knows: each carries its rules now (scheduler/task-board.ts).
|
|
72
|
+
status: z.enum(CARD_STATUSES).optional(),
|
|
62
73
|
title: z.string().max(512).optional(),
|
|
63
74
|
description: z.string().max(MAX_TEXT).optional(),
|
|
64
75
|
priority: z.string().max(64).optional(),
|
|
65
76
|
}).strict();
|
|
77
|
+
// The task board (scheduler/task-board.ts). Coarse bounds here; the board checks each field's own limit.
|
|
78
|
+
const BOARD_TEXT = 8_192;
|
|
79
|
+
const CardRef = z.string().min(1).max(64);
|
|
80
|
+
/** Who handed a card out: an instance, the person writing ({ kind: "person" }), or nobody. */
|
|
81
|
+
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();
|
|
82
|
+
const CardCreateSchema = z.object({
|
|
83
|
+
kind: z.enum(["main", "sub"]).optional(),
|
|
84
|
+
project: z.union([z.number().int(), z.string().max(256)]).nullable().optional(),
|
|
85
|
+
parent: CardRef.nullable().optional(),
|
|
86
|
+
title: z.string().max(512),
|
|
87
|
+
goal: z.string().max(BOARD_TEXT).optional(),
|
|
88
|
+
scope: z.string().max(BOARD_TEXT).optional(),
|
|
89
|
+
acceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
90
|
+
context: z.string().max(BOARD_TEXT).optional(),
|
|
91
|
+
size: z.enum(CARD_SIZES).nullable().optional(),
|
|
92
|
+
priority: z.enum(CARD_PRIORITIES).optional(),
|
|
93
|
+
dependsOn: z.array(CardRef).max(50).optional(),
|
|
94
|
+
assignee: z.string().max(128).nullable().optional(),
|
|
95
|
+
dispatcher: DispatcherSchema.optional(),
|
|
96
|
+
needsReview: z.boolean().optional(),
|
|
97
|
+
}).strict();
|
|
98
|
+
const CardPatchSchema = z.object({
|
|
99
|
+
version: z.number().int().optional(),
|
|
100
|
+
title: z.string().max(512).optional(),
|
|
101
|
+
goal: z.string().max(BOARD_TEXT).optional(),
|
|
102
|
+
scope: z.string().max(BOARD_TEXT).optional(),
|
|
103
|
+
context: z.string().max(BOARD_TEXT).optional(),
|
|
104
|
+
state: z.string().max(BOARD_TEXT).optional(),
|
|
105
|
+
result: z.string().max(BOARD_TEXT).optional(),
|
|
106
|
+
blockedReason: z.string().max(BOARD_TEXT).optional(),
|
|
107
|
+
size: z.enum(CARD_SIZES).nullable().optional(),
|
|
108
|
+
priority: z.enum(CARD_PRIORITIES).optional(),
|
|
109
|
+
assignee: z.string().max(128).nullable().optional(),
|
|
110
|
+
dispatcher: DispatcherSchema.optional(),
|
|
111
|
+
needsReview: z.boolean().optional(),
|
|
112
|
+
dependsOn: z.array(CardRef).max(50).optional(),
|
|
113
|
+
acceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
114
|
+
addAcceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
115
|
+
check: z.array(z.number().int()).max(50).optional(),
|
|
116
|
+
uncheck: z.array(z.number().int()).max(50).optional(),
|
|
117
|
+
projectId: z.number().int().nullable().optional(),
|
|
118
|
+
log: z.string().max(BOARD_TEXT).optional(),
|
|
119
|
+
comment: z.string().max(MAX_TEXT).optional(),
|
|
120
|
+
}).strict();
|
|
121
|
+
const CardMoveSchema = z.object({
|
|
122
|
+
to: z.enum(CARD_STATUSES),
|
|
123
|
+
reason: z.string().max(BOARD_TEXT).optional(),
|
|
124
|
+
result: z.string().max(BOARD_TEXT).optional(),
|
|
125
|
+
comment: z.string().max(MAX_TEXT).optional(),
|
|
126
|
+
version: z.number().int().optional(),
|
|
127
|
+
}).strict();
|
|
128
|
+
const CardReorderSchema = z.object({ after: CardRef.nullable().optional(), before: CardRef.nullable().optional() }).strict();
|
|
129
|
+
const ProjectCreateSchema = z.object({ name: z.string().max(256), description: z.string().max(BOARD_TEXT).optional(), leadInstance: z.string().max(256).nullable().optional() }).strict();
|
|
130
|
+
/** #2352: a project's lead must be one of the fleet's instances now (the board keeps any name, as for an assignee). */
|
|
131
|
+
function checkLeadIsInstance(ctx, lead) {
|
|
132
|
+
const name = lead?.trim();
|
|
133
|
+
if (name && !(ctx.fleetConfig?.instances && Object.hasOwn(ctx.fleetConfig.instances, name))) {
|
|
134
|
+
throw new TaskBoardError("invalid", `"${name.slice(0, 64)}" is not one of the fleet's instances`, "lead_not_instance");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const ProjectPatchSchema = z.object({
|
|
138
|
+
name: z.string().max(256).optional(),
|
|
139
|
+
description: z.string().max(BOARD_TEXT).optional(),
|
|
140
|
+
archived: z.boolean().optional(),
|
|
141
|
+
/** #2352: the instance whose channel lists the project's waiting cards (null or "": General). */
|
|
142
|
+
leadInstance: z.string().max(256).nullable().optional(),
|
|
143
|
+
}).strict();
|
|
66
144
|
const TeamCreateSchema = z.object({
|
|
67
145
|
name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
|
|
68
146
|
members: z.array(z.string().min(1).max(256)).min(1).max(256),
|
|
@@ -90,6 +168,11 @@ const SendMessageSchema = z.object({
|
|
|
90
168
|
message: z.string().max(MAX_TEXT),
|
|
91
169
|
/** Ids from POST /ui/upload, for this instance, not yet sent. */
|
|
92
170
|
attachments: z.array(z.string().regex(/^[0-9a-f]{32}$/)).max(UPLOAD_LIMITS.maxFiles).optional(),
|
|
171
|
+
/** #2272: the message of this chat this one replies to — its row only; the server reads what it quotes. */
|
|
172
|
+
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(),
|
|
173
|
+
/** The sending page's own id for its pending bubble: echoed on the live event only (never recorded), so that page
|
|
174
|
+
* swaps its "sending" bubble for the real one even when the stream beats this answer. */
|
|
175
|
+
clientId: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/).optional(),
|
|
93
176
|
}).strict().refine(v => v.message.trim().length > 0 || (v.attachments?.length ?? 0) > 0, { message: "a message or at least one file", path: ["message"] });
|
|
94
177
|
/** An instance name that is safe as one path segment (no separator, no NUL, not a dot name). */
|
|
95
178
|
const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "." && name !== "..";
|
|
@@ -101,6 +184,68 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
|
|
|
101
184
|
*/
|
|
102
185
|
/** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
|
|
103
186
|
const streamGuests = new WeakMap();
|
|
187
|
+
/**
|
|
188
|
+
* The task board's rule over the public link: an instance kept out of sight (cross_instance_visibility: hidden) is not
|
|
189
|
+
* shown, nor a card that names one as its assignee, creator or dispatcher. Off the link nothing is hidden. Read when
|
|
190
|
+
* asked: the fleet's config as it is now.
|
|
191
|
+
*/
|
|
192
|
+
function boardHiding(ctx, gateway) {
|
|
193
|
+
const fleet = ctx.fleetConfig;
|
|
194
|
+
const hiddenName = (n) => gateway && typeof n === "string" && !!n && !!ctx.fleetConfig?.instances?.[n] && crossInstanceVisibility(fleet, n) === "hidden";
|
|
195
|
+
const hidden = (c) => [c.assignee, c.createdBy, c.dispatcher?.name].some(hiddenName);
|
|
196
|
+
return { hiddenName, hidden };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Chat history as the public link may see it (#2341 review): a status line that names board cards (the daily summary)
|
|
200
|
+
* is withheld while one of them is hidden there now — or no longer on the board — read when asked, so a card hidden
|
|
201
|
+
* after the line was written is withheld too. Off the link, as is. The cards are read in one query per answer.
|
|
202
|
+
*/
|
|
203
|
+
function visibleMessages(ctx, gateway, messages) {
|
|
204
|
+
if (!gateway)
|
|
205
|
+
return messages;
|
|
206
|
+
// #2341 review r2: a line that named too many cards to keep them all is withheld whole.
|
|
207
|
+
messages = messages.filter((m) => !(m.cards ?? []).includes(WEB_CHAT_CARDS_OVERFLOW));
|
|
208
|
+
const named = [...new Set(messages.flatMap((m) => m.cards ?? []))];
|
|
209
|
+
if (!named.length)
|
|
210
|
+
return messages;
|
|
211
|
+
const board = ctx.scheduler?.db.board;
|
|
212
|
+
const { hidden } = boardHiding(ctx, true);
|
|
213
|
+
let found;
|
|
214
|
+
try {
|
|
215
|
+
found = new Map((board ? board.cardsByCode(named) : []).map((c) => [c.code, c]));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
found = new Map();
|
|
219
|
+
}
|
|
220
|
+
const withheld = new Set(named.filter((code) => { const c = found.get(code); return !c || hidden(c); }));
|
|
221
|
+
return messages.filter((m) => !(m.cards ?? []).some((code) => withheld.has(code)));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Needs you as the public link may see it (#2306 review): a task item whose card the board hides there — or a card
|
|
225
|
+
* the board no longer has — is left out, with its title and question; everything else as listed. Off the link, as is.
|
|
226
|
+
* The link reads Needs you by /ui/poll only: its page has no stream (/ui/events answers it 404).
|
|
227
|
+
*/
|
|
228
|
+
function visibleNeeds(ctx, gateway, items) {
|
|
229
|
+
if (!gateway)
|
|
230
|
+
return items;
|
|
231
|
+
const board = ctx.scheduler?.db.board;
|
|
232
|
+
const { hidden } = boardHiding(ctx, true);
|
|
233
|
+
return items.filter((i) => {
|
|
234
|
+
const item = i;
|
|
235
|
+
if (item.type !== "task")
|
|
236
|
+
return true;
|
|
237
|
+
const code = item.card?.code;
|
|
238
|
+
if (!board || typeof code !== "string")
|
|
239
|
+
return false;
|
|
240
|
+
try {
|
|
241
|
+
const card = board.findCard(code);
|
|
242
|
+
return !!card && !hidden(card);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
104
249
|
/**
|
|
105
250
|
* #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
|
|
106
251
|
* its channel saw (an admin's page already has it, unchanged).
|
|
@@ -173,10 +318,21 @@ async function parseBody(req) {
|
|
|
173
318
|
}
|
|
174
319
|
/** Send JSON response. */
|
|
175
320
|
function json(res, status, data) {
|
|
321
|
+
jsonText(res, status, JSON.stringify(data));
|
|
322
|
+
}
|
|
323
|
+
/** A JSON answer already serialized (a body kept for reuse). */
|
|
324
|
+
function jsonText(res, status, body) {
|
|
176
325
|
res.setHeader("Content-Type", "application/json");
|
|
177
326
|
res.writeHead(status);
|
|
178
|
-
res.end(
|
|
327
|
+
res.end(body);
|
|
179
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* #2296: the last dashboard board read, serialized, per board: its key (revision, done=all, minute) and body. One per
|
|
331
|
+
* board (the newest revision replaces the previous), so it holds one board's JSON at most.
|
|
332
|
+
*/
|
|
333
|
+
const boardBodies = new WeakMap();
|
|
334
|
+
/** #2316 review: the same for who works on what (GET /ui/tasks/work), per (revision, names asked for). */
|
|
335
|
+
const workBodies = new WeakMap();
|
|
180
336
|
/**
|
|
181
337
|
* #1630 (#1635 review): an answer that waited on something (a list, a download) is sent only if the request is still
|
|
182
338
|
* allowed — the session not revoked meanwhile, a public link's exposure still current. Otherwise 401, and nothing.
|
|
@@ -189,6 +345,108 @@ function stillAuthorized(req, res, url, ctx) {
|
|
|
189
345
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
190
346
|
return false;
|
|
191
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* #2308: the app icon. GET: its state, for the settings card (a signed-in person's session). PUT (one PNG, at most
|
|
350
|
+
* 5 MiB) and DELETE (back to the bundled icon): a signed-in admin's session only — never the CLI's token, a read-only
|
|
351
|
+
* session, a guest or the public link; checked again once the body has arrived.
|
|
352
|
+
*/
|
|
353
|
+
/** An app icon refusal as the page reads it: its code, the English detail and, when named, the detail's key (#2390). */
|
|
354
|
+
function appIconRefusal(r) {
|
|
355
|
+
return { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.detail_key ? { detail_key: r.detail_key } : {}), ...(r.detail_args ? { detail_args: r.detail_args } : {}) };
|
|
356
|
+
}
|
|
357
|
+
function handleAppIcon(req, res, url, ctx, method) {
|
|
358
|
+
const store = ctx.appIcon;
|
|
359
|
+
if (!store) {
|
|
360
|
+
json(res, 404, { error: "app_icon_not_kept", detail: "The app icon is not kept on this fleet" });
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
const reading = method === "GET" || method === "HEAD";
|
|
364
|
+
if (!reading && method !== "PUT" && method !== "DELETE") {
|
|
365
|
+
res.setHeader("Allow", "GET, PUT, DELETE");
|
|
366
|
+
json(res, 405, { error: "method not allowed" });
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
if (gatewayRequestContext(req)) {
|
|
370
|
+
json(res, reading ? 404 : 403, { error: "app_icon_not_over_public_link" });
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
const who = () => {
|
|
374
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
375
|
+
if (verdict.kind !== "allow")
|
|
376
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
377
|
+
if (verdict.via !== "session" || !verdict.session || verdict.session.tier === "guest")
|
|
378
|
+
return { status: 403, error: "app_icon_needs_a_signed_in_person" };
|
|
379
|
+
if (!reading && verdict.session.tier !== "admin")
|
|
380
|
+
return { status: 403, error: "app_icon_admin_only" };
|
|
381
|
+
return { person: webPersonKey(verdict.session.owner) };
|
|
382
|
+
};
|
|
383
|
+
const first = who();
|
|
384
|
+
if ("status" in first) {
|
|
385
|
+
json(res, first.status, { error: first.error });
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
const state = () => { const s = store.current(); return s ? { custom: true, version: s.version, updatedAt: s.updatedAt } : { custom: false, version: "default", updatedAt: null }; };
|
|
389
|
+
if (reading) {
|
|
390
|
+
json(res, 200, state());
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
if (method === "DELETE") {
|
|
394
|
+
const reset = store.reset();
|
|
395
|
+
if (!reset.ok) {
|
|
396
|
+
json(res, reset.status, appIconRefusal(reset));
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
json(res, 200, state());
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
// Still allowed, still answering, the store still open: asked after each await (the body; the conversion).
|
|
403
|
+
const again = () => {
|
|
404
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
405
|
+
return null;
|
|
406
|
+
if (res.destroyed)
|
|
407
|
+
return null;
|
|
408
|
+
const now = who();
|
|
409
|
+
if ("status" in now) {
|
|
410
|
+
json(res, now.status, { error: now.error });
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
return now.person;
|
|
414
|
+
};
|
|
415
|
+
(async () => {
|
|
416
|
+
let bytes;
|
|
417
|
+
try {
|
|
418
|
+
bytes = await readBoundedWebBody(req, APP_ICON_MAX_BYTES);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
json(res, 413, { error: "app_icon_too_large", detail: "at most 5 MiB", detail_key: "max_size", detail_args: [5] });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!again())
|
|
425
|
+
return;
|
|
426
|
+
// #2308 review: decoding and making the sizes is seconds of CPU at worst — a worker thread's, not the fleet loop's.
|
|
427
|
+
const prepared = await store.prepare(bytes);
|
|
428
|
+
if (!prepared.ok) {
|
|
429
|
+
json(res, prepared.status, appIconRefusal(prepared));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const person = again();
|
|
433
|
+
if (!person)
|
|
434
|
+
return;
|
|
435
|
+
const saved = store.commit(prepared.prepared, person);
|
|
436
|
+
if (!saved.ok) {
|
|
437
|
+
json(res, saved.status, appIconRefusal(saved));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
json(res, 200, state());
|
|
441
|
+
})().catch((err) => {
|
|
442
|
+
ctx.logger.error({ err: err.message }, "App icon request failed");
|
|
443
|
+
try {
|
|
444
|
+
json(res, 500, { error: "app_icon_failed", detail: "App icon request failed" });
|
|
445
|
+
}
|
|
446
|
+
catch { /* already answered */ }
|
|
447
|
+
});
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
192
450
|
/**
|
|
193
451
|
* #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
|
|
194
452
|
* signed-in human session is, or the status to answer.
|
|
@@ -280,7 +538,7 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
|
|
|
280
538
|
json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
|
|
281
539
|
return true;
|
|
282
540
|
}
|
|
283
|
-
if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
|
|
541
|
+
if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/shown", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
|
|
284
542
|
json(res, 404, { error: "not found" });
|
|
285
543
|
return true;
|
|
286
544
|
}
|
|
@@ -329,6 +587,17 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
|
|
|
329
587
|
announce();
|
|
330
588
|
json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
|
|
331
589
|
}
|
|
590
|
+
else if (path === "/ui/owner-css/shown") {
|
|
591
|
+
// #2198: this page shows the trial (its stylesheet loaded): an agent's waiting trial starts its keep window now.
|
|
592
|
+
const shown = store.shown(person, body.trial);
|
|
593
|
+
if (!shown.ok) {
|
|
594
|
+
json(res, shown.status, { error: shown.error });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (shown.started)
|
|
598
|
+
announce(); // the person's other devices take the new deadline
|
|
599
|
+
json(res, 200, { remaining_ms: shown.remaining_ms });
|
|
600
|
+
}
|
|
332
601
|
else if (path === "/ui/owner-css/keep") {
|
|
333
602
|
const kept = store.keep(person, body.trial);
|
|
334
603
|
if (!kept.ok) {
|
|
@@ -475,6 +744,10 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
|
475
744
|
json(res, now.status, { error: now.error });
|
|
476
745
|
return;
|
|
477
746
|
}
|
|
747
|
+
if (!canvasWorkerEnabled(ctx.fleetConfig?.web)) {
|
|
748
|
+
json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
478
751
|
if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
|
|
479
752
|
|| now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
|
|
480
753
|
json(res, 409, { error: "trials_person_changed" });
|
|
@@ -509,6 +782,104 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
|
509
782
|
catch { /* response ended */ } });
|
|
510
783
|
return true;
|
|
511
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* #2233: signing a backend in from the dashboard (web-backend-login.ts). A signed-in admin session on the local surface
|
|
787
|
+
* only — never the CLI's header token (an agent's), a guest, a read session or the public link (refused before the body
|
|
788
|
+
* is read, and again after it). Every answer is no-store: the starter's view carries the terminal's access code.
|
|
789
|
+
*/
|
|
790
|
+
function handleBackendLogin(req, res, url, ctx, path, method) {
|
|
791
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
792
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
793
|
+
const reading = method === "GET";
|
|
794
|
+
if (gatewayRequestContext(req)) {
|
|
795
|
+
json(res, reading ? 404 : 403, { error: "sign_in_not_over_public_link" });
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
const who = () => {
|
|
799
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
800
|
+
if (v.kind !== "allow")
|
|
801
|
+
return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
|
|
802
|
+
if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
|
|
803
|
+
return { error: "sign_in_needs_a_local_admin", status: 403 };
|
|
804
|
+
return { handle: v.session.handle, label: v.session.label };
|
|
805
|
+
};
|
|
806
|
+
const first = who();
|
|
807
|
+
if ("error" in first) {
|
|
808
|
+
json(res, first.status, { error: first.error });
|
|
809
|
+
return true;
|
|
810
|
+
}
|
|
811
|
+
const logins = ctx.webBackendLogins;
|
|
812
|
+
if (!logins) {
|
|
813
|
+
json(res, 404, { error: "sign_in_unavailable" });
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
const action = /^\/ui\/backend-login\/(check|start|cancel)$/.exec(path)?.[1];
|
|
817
|
+
if (reading && path === "/ui/backend-login" && [...url.searchParams.keys()].length === 0) {
|
|
818
|
+
json(res, 200, { login: logins.view(first.handle), targets: ctx.backendSignInTargets?.() ?? [] });
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
if (method !== "POST" || !action) {
|
|
822
|
+
json(res, 404, { error: "not_found" });
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
(async () => {
|
|
826
|
+
let bytes;
|
|
827
|
+
try {
|
|
828
|
+
bytes = await readBoundedWebBody(req, 4 * 1024);
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
json(res, 413, { error: "sign_in_body_bound" });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
835
|
+
return;
|
|
836
|
+
const now = who();
|
|
837
|
+
if ("error" in now) {
|
|
838
|
+
json(res, now.status, { error: now.error });
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (now.handle !== first.handle) {
|
|
842
|
+
json(res, 409, { error: "sign_in_session_changed" });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
let body;
|
|
846
|
+
try {
|
|
847
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
json(res, 400, { error: "sign_in_json" });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (action === "check") {
|
|
854
|
+
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);
|
|
855
|
+
if (!parsed.success) {
|
|
856
|
+
json(res, 400, { error: "sign_in_args" });
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
json(res, 200, await logins.check(now, parsed.data.backend, parsed.data.profile));
|
|
860
|
+
}
|
|
861
|
+
else if (action === "start") {
|
|
862
|
+
const parsed = z.strictObject({ confirm: z.string().regex(/^[0-9a-f]{32}$/), tunnel: z.boolean().optional() }).safeParse(body);
|
|
863
|
+
if (!parsed.success) {
|
|
864
|
+
json(res, 400, { error: "sign_in_args" });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const result = await logins.begin(now, parsed.data.confirm, parsed.data.tunnel === true);
|
|
868
|
+
json(res, "error" in result ? 409 : 200, result);
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
if (!z.strictObject({}).safeParse(body).success) {
|
|
872
|
+
json(res, 400, { error: "sign_in_args" });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
json(res, 200, { message: await logins.cancel() });
|
|
876
|
+
}
|
|
877
|
+
})().catch(() => { try {
|
|
878
|
+
json(res, 500, { error: "sign_in_request_failed" });
|
|
879
|
+
}
|
|
880
|
+
catch { /* response ended */ } });
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
512
883
|
/** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
|
|
513
884
|
function handleEffects(req, res, url, ctx, path, method) {
|
|
514
885
|
res.setHeader("Cache-Control", "private, no-store");
|
|
@@ -612,6 +983,10 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
612
983
|
json(res, now.status, { error: now.error });
|
|
613
984
|
return;
|
|
614
985
|
}
|
|
986
|
+
if (!canvasWorkerEnabled(ctx.fleetConfig?.web)) {
|
|
987
|
+
json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
615
990
|
if (now.person !== first.person || now.epoch !== first.epoch) {
|
|
616
991
|
json(res, 409, { error: "effects_person_changed" });
|
|
617
992
|
return;
|
|
@@ -733,6 +1108,10 @@ function handleMods(req, res, url, ctx, path, method) {
|
|
|
733
1108
|
// So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
|
|
734
1109
|
// read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
|
|
735
1110
|
const fx = mods.bundleEffects(fileMatch[1]);
|
|
1111
|
+
if (!canvasWorkerEnabled(ctx.fleetConfig?.web) && (fx === "unknown" || (fx?.length ?? 0) > 0)) {
|
|
1112
|
+
json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
736
1115
|
if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
|
|
737
1116
|
json(res, 403, { error: "mod_effects_not_yours", detail: "this mod's file carries effects that are not yours to share: export your own look instead" });
|
|
738
1117
|
return true;
|
|
@@ -863,7 +1242,7 @@ export function exportMod(ctx, person, body) {
|
|
|
863
1242
|
// The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
|
|
864
1243
|
// Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
|
|
865
1244
|
// #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
|
|
866
|
-
const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
|
|
1245
|
+
const effects = (canvasWorkerEnabled(ctx.fleetConfig?.web) ? rec.placements.effects : []).flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
|
|
867
1246
|
omitted++;
|
|
868
1247
|
return [];
|
|
869
1248
|
} return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
|
|
@@ -929,13 +1308,71 @@ function webAuthorityOf(req, url, ctx, instance) {
|
|
|
929
1308
|
return { authority: noAuthority(0), refusal: "not_session" };
|
|
930
1309
|
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
931
1310
|
return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
|
|
932
|
-
peer:
|
|
1311
|
+
peer: webPeerOf(req), ...(instance !== undefined ? { instance } : {}) });
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* #1939 §3.4 / #2453 §0.2: where this request came from — the admin's named link by the gateway's own provenance (its
|
|
1315
|
+
* exact hostname), else what the TCP peer and Host say (a quick tunnel's request is `lan`, and refused as public_link).
|
|
1316
|
+
*/
|
|
1317
|
+
export function webPeerOf(req) {
|
|
1318
|
+
return gatewayRequestContext(req)?.named === true ? "named_link" : classifyWebPeer(req);
|
|
933
1319
|
}
|
|
934
1320
|
/**
|
|
935
1321
|
* #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
|
|
936
1322
|
* header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
|
|
937
1323
|
* one). Equal before and after an await: the same admission still holds. Never touches the session.
|
|
938
1324
|
*/
|
|
1325
|
+
/** #2293: an admin's admission — the header token or an admin session — else null (a guest's or a read-only session's). */
|
|
1326
|
+
function adminAdmissionOf(req, url, ctx) {
|
|
1327
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1328
|
+
if (v.kind !== "allow" || (v.via === "session" && v.session.tier !== "admin"))
|
|
1329
|
+
return null;
|
|
1330
|
+
return admissionOf(req, url, ctx);
|
|
1331
|
+
}
|
|
1332
|
+
const SEARCH_LIMIT_DEFAULT = 50;
|
|
1333
|
+
/**
|
|
1334
|
+
* #2293: GET /ui/search's parameters: q (1–QUERY_MAX_CHARS), instance (repeated), team (its members join `instance`),
|
|
1335
|
+
* source (repeated: chat, xmsg, task), since / until (epoch ms), mine=1, limit (1–RESULT_MAX).
|
|
1336
|
+
*/
|
|
1337
|
+
function parseSearchQuery(url, ctx) {
|
|
1338
|
+
const p = url.searchParams;
|
|
1339
|
+
const q = (p.get("q") ?? "").trim();
|
|
1340
|
+
if (!q || q.length > QUERY_MAX_CHARS)
|
|
1341
|
+
return { error: `q required (1-${QUERY_MAX_CHARS} chars)` };
|
|
1342
|
+
const instances = new Set(p.getAll("instance").filter(n => n.length > 0 && n.length <= 128));
|
|
1343
|
+
const team = p.get("team");
|
|
1344
|
+
if (team) {
|
|
1345
|
+
const def = ctx.fleetConfig?.teams && Object.hasOwn(ctx.fleetConfig.teams, team) ? ctx.fleetConfig.teams[team] : undefined;
|
|
1346
|
+
if (!def)
|
|
1347
|
+
return { error: "unknown team" };
|
|
1348
|
+
for (const member of def.members ?? [])
|
|
1349
|
+
instances.add(member);
|
|
1350
|
+
if (!instances.size)
|
|
1351
|
+
return { error: "team has no members" };
|
|
1352
|
+
}
|
|
1353
|
+
const sources = p.getAll("source");
|
|
1354
|
+
if (sources.some(s => !SEARCH_SOURCES.includes(s)))
|
|
1355
|
+
return { error: `source must be one of: ${SEARCH_SOURCES.join(", ")}` };
|
|
1356
|
+
const time = (name) => {
|
|
1357
|
+
const raw = p.get(name);
|
|
1358
|
+
if (raw === null || raw === "")
|
|
1359
|
+
return undefined;
|
|
1360
|
+
const n = Number(raw);
|
|
1361
|
+
return Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
1362
|
+
};
|
|
1363
|
+
const since = time("since"), until = time("until");
|
|
1364
|
+
if (since === null || until === null)
|
|
1365
|
+
return { error: "since/until must be epoch milliseconds" };
|
|
1366
|
+
const limitRaw = p.get("limit");
|
|
1367
|
+
const limit = limitRaw === null ? SEARCH_LIMIT_DEFAULT : Number(limitRaw);
|
|
1368
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > RESULT_MAX)
|
|
1369
|
+
return { error: `limit must be 1-${RESULT_MAX}` };
|
|
1370
|
+
return {
|
|
1371
|
+
filters: { q, limit, ...(instances.size ? { instances: [...instances] } : {}), ...(sources.length ? { sources: sources } : {}),
|
|
1372
|
+
...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}) },
|
|
1373
|
+
mine: p.get("mine") === "1",
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
939
1376
|
function admissionOf(req, url, ctx) {
|
|
940
1377
|
const gateway = gatewayRequestContext(req);
|
|
941
1378
|
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
@@ -951,18 +1388,122 @@ function webPersonOr401(req, res, url, ctx) {
|
|
|
951
1388
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
952
1389
|
return p;
|
|
953
1390
|
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Who a dashboard request acts as on the task board. The person only through a signed-in local session: the header
|
|
1393
|
+
* token is the CLI's or a script's (any agent on this machine can read the token file), and the temporary public link
|
|
1394
|
+
* carries no verified authority (docs/web-dashboard.md) — either is a script, held to the rules an instance is held to,
|
|
1395
|
+
* so neither confirms work waiting for the person.
|
|
1396
|
+
*/
|
|
1397
|
+
function boardActor(req, url, ctx, p) {
|
|
1398
|
+
if (gatewayRequestContext(req))
|
|
1399
|
+
return { kind: "script", name: "public-link" };
|
|
1400
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1401
|
+
return v.kind === "allow" && v.via === "session" ? { kind: "person", name: p.name } : { kind: "script", name: "web-token" };
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* A board refusal as the dashboard reads it: the board's sentence, its code and the status that fits, and — when the
|
|
1405
|
+
* person can meet it — the key and values the page says it with in its own language.
|
|
1406
|
+
*/
|
|
1407
|
+
function boardError(res, err) {
|
|
1408
|
+
if (res.headersSent)
|
|
1409
|
+
return;
|
|
1410
|
+
if (err instanceof TaskBoardError) {
|
|
1411
|
+
const status = err.code === "not_found" ? 404 : err.code === "forbidden" ? 403 : err.code === "conflict" ? 409 : 400;
|
|
1412
|
+
json(res, status, { error: err.message, code: err.code, ...(err.key ? { key: err.key, args: err.args } : {}) });
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
json(res, 400, { error: err.message });
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* One board write from the dashboard: the body read and checked, the request still allowed, the person asked for
|
|
1419
|
+
* again now the body is in (#1827), then the write as that person. The board's own routes take a signed-in session
|
|
1420
|
+
* only (instances use their task tool, scripts the old /ui/tasks routes).
|
|
1421
|
+
*/
|
|
1422
|
+
function boardWrite(req, res, url, ctx, board, schema, write) {
|
|
1423
|
+
void (async () => {
|
|
1424
|
+
try {
|
|
1425
|
+
const body = await parseBody(req);
|
|
1426
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1427
|
+
return;
|
|
1428
|
+
const parsed = parseOrReject(schema, body, res);
|
|
1429
|
+
if (!parsed)
|
|
1430
|
+
return;
|
|
1431
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
1432
|
+
if (!person)
|
|
1433
|
+
return;
|
|
1434
|
+
const actor = boardActor(req, url, ctx, person);
|
|
1435
|
+
if (actor.kind !== "person") {
|
|
1436
|
+
json(res, 403, { error: "The task board takes changes from a signed-in dashboard", code: "forbidden", key: "signed_in_only" });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
json(res, 200, write(parsed, actor));
|
|
1440
|
+
}
|
|
1441
|
+
catch (err) {
|
|
1442
|
+
boardError(res, err);
|
|
1443
|
+
}
|
|
1444
|
+
})();
|
|
1445
|
+
}
|
|
954
1446
|
/** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
|
|
955
1447
|
function profileBody(p) {
|
|
956
1448
|
return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
|
|
957
1449
|
}
|
|
958
1450
|
/** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
|
|
959
1451
|
function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1452
|
+
const statusTicks = new WeakMap();
|
|
1453
|
+
function subscribeStatusTick(ctx, stream) {
|
|
1454
|
+
let tick = statusTicks.get(ctx);
|
|
1455
|
+
if (!tick)
|
|
1456
|
+
statusTicks.set(ctx, tick = { streams: new Set(), timer: null });
|
|
1457
|
+
const t = tick;
|
|
1458
|
+
t.streams.add(stream);
|
|
1459
|
+
t.timer ??= setInterval(() => statusTick(ctx, t), ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
|
|
1460
|
+
return () => {
|
|
1461
|
+
t.streams.delete(stream);
|
|
1462
|
+
if (!t.streams.size && t.timer) {
|
|
1463
|
+
clearInterval(t.timer);
|
|
1464
|
+
t.timer = null;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
function statusTick(ctx, t) {
|
|
1469
|
+
const live = [...t.streams].filter((s) => s.alive());
|
|
1470
|
+
if (!live.length)
|
|
1471
|
+
return;
|
|
1472
|
+
let status, admin = null;
|
|
1473
|
+
const guests = new Map();
|
|
1474
|
+
try {
|
|
1475
|
+
status = measureSyncWork("web.sseStatus", () => ctx.getUiStatus());
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
return;
|
|
1479
|
+
} // nothing to send this tick; the next one tries again
|
|
1480
|
+
const frameFor = (s) => {
|
|
1481
|
+
if (!s.scope)
|
|
1482
|
+
return admin ??= `event: status\ndata: ${JSON.stringify(status)}\n\n`;
|
|
1483
|
+
const scope = s.scope(), key = [...scope].sort().join("\u0000");
|
|
1484
|
+
let frame = guests.get(key);
|
|
1485
|
+
if (frame === undefined)
|
|
1486
|
+
guests.set(key, frame = `event: status\ndata: ${JSON.stringify(guestStatus(status, scope))}\n\n`);
|
|
1487
|
+
return frame;
|
|
1488
|
+
};
|
|
1489
|
+
for (const s of live) {
|
|
1490
|
+
let frame;
|
|
1491
|
+
try {
|
|
1492
|
+
frame = frameFor(s);
|
|
1493
|
+
}
|
|
1494
|
+
catch {
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
s.write(frame);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
963
1500
|
export function handleWebRequest(req, res, url, ctx) {
|
|
964
1501
|
const path = url.pathname;
|
|
965
1502
|
const method = req.method ?? "GET";
|
|
1503
|
+
if (!canvasWorkerEnabled(ctx.fleetConfig?.web) && isCanvasWorkerHttpPath(path)) {
|
|
1504
|
+
json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
|
|
1505
|
+
return true;
|
|
1506
|
+
}
|
|
966
1507
|
// Auth check for all /ui routes. Defence in depth behind the fleet-manager
|
|
967
1508
|
// gate: the same session cookie or header token, and an unset token closes
|
|
968
1509
|
// the panel instead of comparing null against a missing credential.
|
|
@@ -1010,6 +1551,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1010
1551
|
try {
|
|
1011
1552
|
const js = readFileSync(join(__dirname, "ui", fileName), "utf-8");
|
|
1012
1553
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
1554
|
+
// #2182: the route worker's own, narrower-reaching policy (ROUTE_WORKER_CSP); every other file keeps the page's.
|
|
1555
|
+
if (path === ROUTE_WORKER_PATH)
|
|
1556
|
+
res.setHeader("Content-Security-Policy", ROUTE_WORKER_CSP);
|
|
1013
1557
|
res.writeHead(200);
|
|
1014
1558
|
res.end(js);
|
|
1015
1559
|
}
|
|
@@ -1018,6 +1562,19 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1018
1562
|
}
|
|
1019
1563
|
return true;
|
|
1020
1564
|
}
|
|
1565
|
+
// #2182: libavoid (libavoid-js, LGPL-2.1-or-later) — the npm package's own two files, unmodified, at a versioned path.
|
|
1566
|
+
const vendor = method === "GET" ? libavoidFile(path) : null;
|
|
1567
|
+
if (vendor) {
|
|
1568
|
+
try {
|
|
1569
|
+
const body = readFileSync(vendor.file);
|
|
1570
|
+
res.writeHead(200, { "Content-Type": vendor.type, "Cache-Control": "private, max-age=31536000, immutable" });
|
|
1571
|
+
res.end(body);
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
json(res, 404, { error: "File not found" });
|
|
1575
|
+
}
|
|
1576
|
+
return true;
|
|
1577
|
+
}
|
|
1021
1578
|
// ── Backend detection ─────────────────────────────────
|
|
1022
1579
|
// #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
|
|
1023
1580
|
if (method === "GET" && path === "/ui/models") {
|
|
@@ -1080,30 +1637,31 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1080
1637
|
const after = url.searchParams.get("after");
|
|
1081
1638
|
const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
|
|
1082
1639
|
if (guest) {
|
|
1083
|
-
// #2085 review:
|
|
1640
|
+
// #2085 review: with what became the channel's after its cursor (an older row, its copy sent since), once each —
|
|
1641
|
+
// replayFor counts a row where it was seen (seenSeq), as for every reader (#1774).
|
|
1084
1642
|
json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
|
|
1085
1643
|
if (!history)
|
|
1086
1644
|
return [];
|
|
1087
|
-
|
|
1088
|
-
return history.after(0);
|
|
1089
|
-
const cursor = parseLastEventId(after);
|
|
1090
|
-
const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
|
|
1091
|
-
return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
|
|
1645
|
+
return fresh ? history.after(0) : history.replayFor(parseLastEventId(after));
|
|
1092
1646
|
}));
|
|
1093
1647
|
return true;
|
|
1094
1648
|
}
|
|
1095
1649
|
json(res, 200, {
|
|
1096
1650
|
status: ctx.getUiStatus(),
|
|
1097
|
-
|
|
1651
|
+
// #2341 review: over the public link, without a summary line that names a card hidden there.
|
|
1652
|
+
messages: history ? visibleMessages(ctx, !!gatewayRequestContext(req), fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
|
|
1098
1653
|
// The ticks too, so polling never has to re-read a chat's history — that read would count as the person's
|
|
1099
1654
|
// activity; this poll does not (isPassiveWebRead).
|
|
1100
1655
|
deliveries: history ? history.deliveries() : [],
|
|
1101
1656
|
// #1266: a reply's buttons that ended after the page saw the reply (the message itself is not sent again).
|
|
1102
1657
|
reply_buttons: history ? history.buttonStates() : [],
|
|
1658
|
+
// #2305: the instance's reactions, likewise (a reaction is no new message).
|
|
1659
|
+
reactions: history ? history.reactionStates() : [],
|
|
1103
1660
|
// And the fleet prompts open on the dashboard (C4): prompt events are stream-only too.
|
|
1104
1661
|
prompts: ctx.listWebPrompts?.() ?? [],
|
|
1105
|
-
// #1386: "Needs you" rides the passive channels only — no endpoint of its own to poll.
|
|
1106
|
-
|
|
1662
|
+
// #1386: "Needs you" rides the passive channels only — no endpoint of its own to poll. Over the public link,
|
|
1663
|
+
// without the task board's hidden cards (#2306 review).
|
|
1664
|
+
needs: visibleNeeds(ctx, !!gatewayRequestContext(req), ctx.needsYouItems?.() ?? []),
|
|
1107
1665
|
// #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
|
|
1108
1666
|
// records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
|
|
1109
1667
|
cursor: history ? history.pollCursor() : null,
|
|
@@ -1160,13 +1718,17 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1160
1718
|
if (!guest && ctx.needsYouItems)
|
|
1161
1719
|
res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
|
|
1162
1720
|
ctx.sseClients.add(res);
|
|
1163
|
-
|
|
1721
|
+
// #1956: the heartbeat's status comes from the one build every open stream shares each tick (statusTick), with
|
|
1722
|
+
// this stream's own projection; this stream's checks stay its own.
|
|
1723
|
+
const unsubscribe = subscribeStatusTick(ctx, {
|
|
1164
1724
|
// A stream authorized once must not outlive the authorization. Re-checked
|
|
1165
1725
|
// without counting as activity, or an open tab would keep an idle session
|
|
1166
1726
|
// alive forever; a revoked, expired or rotated-away session ends here.
|
|
1167
1727
|
// #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
|
|
1168
1728
|
// still authenticating does not keep a revoked device's stream (with its scope) alive.
|
|
1169
|
-
|
|
1729
|
+
alive: () => {
|
|
1730
|
+
if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) && (!guest || guest.current()))
|
|
1731
|
+
return true;
|
|
1170
1732
|
// Close the connection too, not just the response: an ended response leaves the socket idle in
|
|
1171
1733
|
// keep-alive, still held open by whoever's session just ended.
|
|
1172
1734
|
const socket = req.socket;
|
|
@@ -1175,23 +1737,23 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1175
1737
|
}
|
|
1176
1738
|
catch { /* already closed */ }
|
|
1177
1739
|
cleanup();
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
res.write(
|
|
1740
|
+
return false;
|
|
1741
|
+
},
|
|
1742
|
+
scope: guest ? () => guest.scope() : null,
|
|
1743
|
+
write: (frame) => { try {
|
|
1744
|
+
res.write(frame + sseClock());
|
|
1183
1745
|
}
|
|
1184
1746
|
catch {
|
|
1185
1747
|
cleanup();
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1748
|
+
} },
|
|
1749
|
+
});
|
|
1188
1750
|
let cleanedUp = false;
|
|
1189
1751
|
const cleanup = () => {
|
|
1190
1752
|
if (cleanedUp)
|
|
1191
1753
|
return;
|
|
1192
1754
|
cleanedUp = true;
|
|
1193
1755
|
ctx.sseClients.delete(res);
|
|
1194
|
-
|
|
1756
|
+
unsubscribe();
|
|
1195
1757
|
};
|
|
1196
1758
|
// The *response's* `close` is the one that means the stream is over: it fires when the connection
|
|
1197
1759
|
// goes away or the response is finished. The request's `close` means "the request has been read"
|
|
@@ -1215,7 +1777,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1215
1777
|
}
|
|
1216
1778
|
// #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
|
|
1217
1779
|
const guest = guestRequestOf(req);
|
|
1218
|
-
const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope())
|
|
1780
|
+
const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope())
|
|
1781
|
+
: visibleMessages(ctx, !!gatewayRequestContext(req), ctx.webChatHistory.list(instance, limit));
|
|
1219
1782
|
json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
|
|
1220
1783
|
return true;
|
|
1221
1784
|
}
|
|
@@ -1417,6 +1980,100 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1417
1980
|
});
|
|
1418
1981
|
return true;
|
|
1419
1982
|
}
|
|
1983
|
+
// ── Fleet → Org chart → Office: the offices people made (web-offices.json, web-office-store.ts) ──
|
|
1984
|
+
// GET /ui/offices: { rev, offices, readable } for whoever may open the org chart (the public link too: it reads the
|
|
1985
|
+
// chart; a guest's routes do not include it). PUT /ui/offices { offices, rev? }: a person's change, as a theme's reset
|
|
1986
|
+
// or lock is — an admin session only, never the CLI token, never the public link (absent from its manifest, and
|
|
1987
|
+
// refused here too); asked again once the body is in. Every page hears `offices` on the stream and reads again.
|
|
1988
|
+
if (method === "GET" && path === "/ui/offices") {
|
|
1989
|
+
const store = ctx.officeStore;
|
|
1990
|
+
if (!store) {
|
|
1991
|
+
json(res, 404, { error: "Offices are not kept on this fleet" });
|
|
1992
|
+
return true;
|
|
1993
|
+
}
|
|
1994
|
+
json(res, 200, store.list());
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
if (method === "PUT" && path === "/ui/offices") {
|
|
1998
|
+
const store = ctx.officeStore;
|
|
1999
|
+
if (!store) {
|
|
2000
|
+
json(res, 404, { error: "Offices are not kept on this fleet" });
|
|
2001
|
+
return true;
|
|
2002
|
+
}
|
|
2003
|
+
if (gatewayRequestContext(req)) {
|
|
2004
|
+
json(res, 403, { error: "offices_not_over_public_link" });
|
|
2005
|
+
return true;
|
|
2006
|
+
}
|
|
2007
|
+
// The person (an admin session's account), or the status that refuses the request.
|
|
2008
|
+
const who = () => {
|
|
2009
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
2010
|
+
if (verdict.kind !== "allow")
|
|
2011
|
+
return { status: 401 };
|
|
2012
|
+
return verdict.via === "session" && verdict.session?.tier === "admin" ? { person: verdict.session.owner ? webPersonKey(verdict.session.owner) : "admin-session" } : { status: 403 };
|
|
2013
|
+
};
|
|
2014
|
+
const refuse = (status) => json(res, status, { error: status === 401 ? WEB_TOKEN_INVALID_MESSAGE : "offices_need_an_admin_session" });
|
|
2015
|
+
const first = who();
|
|
2016
|
+
if ("status" in first) {
|
|
2017
|
+
refuse(first.status);
|
|
2018
|
+
return true;
|
|
2019
|
+
}
|
|
2020
|
+
(async () => {
|
|
2021
|
+
let body;
|
|
2022
|
+
try {
|
|
2023
|
+
body = await parseBody(req);
|
|
2024
|
+
}
|
|
2025
|
+
catch {
|
|
2026
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
2030
|
+
return;
|
|
2031
|
+
const now = who();
|
|
2032
|
+
if ("status" in now) {
|
|
2033
|
+
refuse(now.status);
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
2037
|
+
json(res, 400, { error: "offices required" });
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
const extra = Object.keys(body).find(k => k !== "offices" && k !== "rev");
|
|
2041
|
+
if (extra !== undefined) {
|
|
2042
|
+
json(res, 400, { error: "offices_invalid", detail: `unknown key "${extra}"` });
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
// Office review: the rev the page read is required — a save without one would overwrite another person's unseen.
|
|
2046
|
+
if (body.rev === undefined) {
|
|
2047
|
+
json(res, 428, { error: "offices_rev_required", detail: "send the rev you read (GET /ui/offices)" });
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
if (!Number.isSafeInteger(body.rev)) {
|
|
2051
|
+
json(res, 400, { error: "offices_invalid", detail: "rev: an integer" });
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
const limiter = ctx.officeWrites, allowed = limiter ? limiter.check(now.person) : { ok: true };
|
|
2055
|
+
if (!allowed.ok) {
|
|
2056
|
+
res.setHeader("Retry-After", String(allowed.retry_after_s));
|
|
2057
|
+
json(res, 429, { error: "offices_rate_limited", retry_after_s: allowed.retry_after_s });
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
const r = store.replace(body.offices, body.rev);
|
|
2061
|
+
if (!r.ok) {
|
|
2062
|
+
json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
limiter?.record(now.person);
|
|
2066
|
+
ctx.emitSseEvent("offices", { rev: r.rev });
|
|
2067
|
+
json(res, 200, { rev: r.rev, offices: r.offices });
|
|
2068
|
+
})().catch(err => {
|
|
2069
|
+
ctx.logger.error({ err: err.message }, "Saving offices failed");
|
|
2070
|
+
try {
|
|
2071
|
+
json(res, 500, { error: "Saving offices failed" });
|
|
2072
|
+
}
|
|
2073
|
+
catch { /* already answered */ }
|
|
2074
|
+
});
|
|
2075
|
+
return true;
|
|
2076
|
+
}
|
|
1420
2077
|
// ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
|
|
1421
2078
|
const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
|
|
1422
2079
|
if (method === "GET" && imageMatch) {
|
|
@@ -1709,6 +2366,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1709
2366
|
return;
|
|
1710
2367
|
}
|
|
1711
2368
|
const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
|
|
2369
|
+
if (personal && !canvasWorkerEnabled(ctx.fleetConfig?.web)) {
|
|
2370
|
+
json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
1712
2373
|
if (personal && now.person === null) {
|
|
1713
2374
|
json(res, 403, { error: "prefs_need_a_signed_in_person" });
|
|
1714
2375
|
return;
|
|
@@ -1748,6 +2409,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1748
2409
|
if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
|
|
1749
2410
|
return handleOwnerCss(req, res, url, ctx, path, method);
|
|
1750
2411
|
}
|
|
2412
|
+
// #2308: the app icon (handleAppIcon: its state for a signed-in person; changing it, an admin's session only).
|
|
2413
|
+
if (path === "/ui/app-icon")
|
|
2414
|
+
return handleAppIcon(req, res, url, ctx, method);
|
|
1751
2415
|
// #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
|
|
1752
2416
|
if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
|
|
1753
2417
|
return handleMods(req, res, url, ctx, path, method);
|
|
@@ -1757,6 +2421,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1757
2421
|
// Canvas effects are absent from the public gateway manifest and independently refused above.
|
|
1758
2422
|
if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
|
|
1759
2423
|
return handleEffectTrials(req, res, url, ctx, path, method);
|
|
2424
|
+
// #2233: backend sign-in — absent from the public manifest and the guest table, and refused here too.
|
|
2425
|
+
if (path === "/ui/backend-login" || path.startsWith("/ui/backend-login/"))
|
|
2426
|
+
return handleBackendLogin(req, res, url, ctx, path, method);
|
|
1760
2427
|
if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
|
|
1761
2428
|
return handleEffects(req, res, url, ctx, path, method);
|
|
1762
2429
|
// #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
|
|
@@ -1808,7 +2475,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1808
2475
|
if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
|
|
1809
2476
|
const judge = () => {
|
|
1810
2477
|
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1811
|
-
return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer:
|
|
2478
|
+
return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: webPeerOf(req) };
|
|
1812
2479
|
};
|
|
1813
2480
|
res.setHeader("Cache-Control", "no-store");
|
|
1814
2481
|
if (method === "GET") {
|
|
@@ -1908,7 +2575,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1908
2575
|
return true;
|
|
1909
2576
|
}
|
|
1910
2577
|
// Own keys only: "constructor" is in every object and names no instance.
|
|
1911
|
-
const known = (ctx
|
|
2578
|
+
const known = registeredChatInstance(ctx, name) || ctx.daemons.has(name);
|
|
1912
2579
|
if (!ctx.cancelInstance || !known) {
|
|
1913
2580
|
json(res, 404, { error: `Instance not found: ${name}` });
|
|
1914
2581
|
return true;
|
|
@@ -1958,7 +2625,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1958
2625
|
(async () => {
|
|
1959
2626
|
let held = false;
|
|
1960
2627
|
try {
|
|
1961
|
-
await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false));
|
|
2628
|
+
await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
|
|
1962
2629
|
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
1963
2630
|
if (!held)
|
|
1964
2631
|
json(res, 200, { started: name });
|
|
@@ -2141,6 +2808,11 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2141
2808
|
json(res, 200, { tasks });
|
|
2142
2809
|
return true;
|
|
2143
2810
|
}
|
|
2811
|
+
// #2246: the public link writes no task — the old list's create and update are board writes now (see below).
|
|
2812
|
+
if (method === "POST" && (path === "/ui/tasks" || /^\/ui\/tasks\/[^/]+$/.test(path)) && gatewayRequestContext(req)) {
|
|
2813
|
+
json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
|
|
2814
|
+
return true;
|
|
2815
|
+
}
|
|
2144
2816
|
if (method === "POST" && path === "/ui/tasks") {
|
|
2145
2817
|
if (!ctx.scheduler) {
|
|
2146
2818
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
@@ -2163,7 +2835,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2163
2835
|
priority: parsed.priority,
|
|
2164
2836
|
assignee: parsed.assignee,
|
|
2165
2837
|
created_by: person.name, // #1827
|
|
2166
|
-
});
|
|
2838
|
+
}, boardActor(req, url, ctx, person));
|
|
2167
2839
|
json(res, 200, task);
|
|
2168
2840
|
}
|
|
2169
2841
|
catch (err) {
|
|
@@ -2172,15 +2844,183 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2172
2844
|
})();
|
|
2173
2845
|
return true;
|
|
2174
2846
|
}
|
|
2847
|
+
// ── Task board (scheduler/task-board.ts) ──────────────
|
|
2848
|
+
// Reads are the session's; every write is the signed-in person's, through the same rules an instance's tools use.
|
|
2849
|
+
if (path === "/ui/tasks/board" || path === "/ui/tasks/search" || path === "/ui/tasks/work" || path.startsWith("/ui/tasks/cards") || path.startsWith("/ui/tasks/projects")) {
|
|
2850
|
+
const board = ctx.scheduler?.db.board;
|
|
2851
|
+
if (!board) {
|
|
2852
|
+
json(res, 503, { error: "The task board is not available", code: "unavailable", key: "unavailable" });
|
|
2853
|
+
return true;
|
|
2854
|
+
}
|
|
2855
|
+
// The temporary public link reads the board and changes nothing on it: confirming, sending back, cancelling and
|
|
2856
|
+
// assigning are the person's authority, which the public link never carries (docs/web-dashboard.md).
|
|
2857
|
+
const gateway = gatewayRequestContext(req);
|
|
2858
|
+
if (gateway && method !== "GET") {
|
|
2859
|
+
json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
|
|
2860
|
+
return true;
|
|
2861
|
+
}
|
|
2862
|
+
// …nor shows the cards of instances kept out of sight (cross_instance_visibility: hidden).
|
|
2863
|
+
const { hiddenName, hidden } = boardHiding(ctx, !!gateway);
|
|
2864
|
+
// #2303 review: what a visible card shows of the cards around it follows the same rule — its children, its main
|
|
2865
|
+
// task (a search hit's too) — and so do the words and the history a hidden instance left on it.
|
|
2866
|
+
// …and a hidden instance's words on a visible card are not counted, named or found there (#2304 review).
|
|
2867
|
+
const scope = { leaveOutAuthors: gateway ? Object.keys(ctx.fleetConfig?.instances ?? {}).filter(hiddenName) : [] };
|
|
2868
|
+
const hiddenRef = (ref) => { const c = ref ? board.findCard(ref) : undefined; return !!c && hidden(c); };
|
|
2869
|
+
const mentionsHidden = (v) => (typeof v === "string" ? hiddenName(v) : !!v && typeof v === "object" && Object.values(v).some(mentionsHidden));
|
|
2870
|
+
const shown = (d) => !gateway ? d : {
|
|
2871
|
+
...d,
|
|
2872
|
+
parent: d.card.parentId && hiddenRef(d.card.parentId) ? null : d.parent,
|
|
2873
|
+
children: d.children.filter((c) => !hidden(c)),
|
|
2874
|
+
comments: d.comments.filter((e) => !hiddenName(e.author)),
|
|
2875
|
+
logs: d.logs.filter((e) => !hiddenName(e.author)),
|
|
2876
|
+
history: d.history.filter((e) => !hiddenName(e.actor) && !mentionsHidden(e.data)),
|
|
2877
|
+
};
|
|
2878
|
+
const card = /^\/ui\/tasks\/cards\/([^/]+)(?:\/(move|reorder))?$/.exec(path);
|
|
2879
|
+
const project = /^\/ui\/tasks\/projects\/(\d+)$/.exec(path);
|
|
2880
|
+
try {
|
|
2881
|
+
const ref = card ? decodeURIComponent(card[1]) : "";
|
|
2882
|
+
if (method === "GET" && path === "/ui/tasks/board") {
|
|
2883
|
+
// A page that has this revision already is told so, not sent the board again.
|
|
2884
|
+
const rev = board.revision();
|
|
2885
|
+
if (url.searchParams.get("rev") === String(rev)) {
|
|
2886
|
+
json(res, 200, { rev, unchanged: true });
|
|
2887
|
+
return true;
|
|
2888
|
+
}
|
|
2889
|
+
const allDone = url.searchParams.get("done") === "all";
|
|
2890
|
+
// #2296: every open Tasks tab reads the board again after each change; on the dashboard they all get the same
|
|
2891
|
+
// board for a revision, so it is built once per (revision, done=all, minute) — the minute for the 14-day
|
|
2892
|
+
// window. The public link's reads are filtered per request (hidden instances) and are built each time.
|
|
2893
|
+
const key = gateway ? null : `${rev}:${allDone ? 1 : 0}:${Math.floor(Date.now() / 60_000)}`;
|
|
2894
|
+
const kept = key ? boardBodies.get(board) : undefined;
|
|
2895
|
+
if (kept && kept.key === key) {
|
|
2896
|
+
jsonText(res, 200, kept.body);
|
|
2897
|
+
return true;
|
|
2898
|
+
}
|
|
2899
|
+
const cards = board.boardCards(undefined, allDone).filter((c) => !hidden(c));
|
|
2900
|
+
// #2352: over the public link a hidden lead is not named (the project reads as having none).
|
|
2901
|
+
const projects = board.listProjects(true).map((p) => (p.leadInstance && hiddenName(p.leadInstance) ? { ...p, leadInstance: null } : p));
|
|
2902
|
+
// #2355 review: the names a project's lead may be — the fleet's instances (a Classic-only room is not one); over the
|
|
2903
|
+
// public link, not a hidden one.
|
|
2904
|
+
const leads = Object.keys(ctx.fleetConfig?.instances ?? {}).filter((n) => !hiddenName(n)).sort();
|
|
2905
|
+
const body = JSON.stringify({ rev, cards: board.views(cards, scope), projects, leads, stats: board.projectStats() });
|
|
2906
|
+
if (key)
|
|
2907
|
+
boardBodies.set(board, { key, body });
|
|
2908
|
+
jsonText(res, 200, body);
|
|
2909
|
+
return true;
|
|
2910
|
+
}
|
|
2911
|
+
if (method === "GET" && path === "/ui/tasks/work") {
|
|
2912
|
+
// #2246 step 3: each instance's work on the board — what its `task mine` reads (myWork) — for the board's
|
|
2913
|
+
// Instance list and an instance's Details page (`?instance=`): the fleet's instances and anyone else a live
|
|
2914
|
+
// card is assigned to. Over the public link, not those kept out of sight, nor a card that names one. Every
|
|
2915
|
+
// name's work is one workOf() read and the cards one views() read, whatever the number of instances (#2316
|
|
2916
|
+
// review); on the dashboard the answer is built once per board revision and the names asked for, and shared
|
|
2917
|
+
// by every open drawer (the public link's is filtered per request, as the board's).
|
|
2918
|
+
const only = url.searchParams.get("instance");
|
|
2919
|
+
const names = (only ? [only] : [...new Set([...Object.keys(ctx.fleetConfig?.instances ?? {}), ...board.liveAssignees()])]).filter((n) => !hiddenName(n));
|
|
2920
|
+
const key = gateway ? null : `${board.revision()}:${only === null ? "*" : `=${only}`}:${names.join("\u0000")}`;
|
|
2921
|
+
const kept = key ? workBodies.get(board) : undefined;
|
|
2922
|
+
if (kept && kept.key === key) {
|
|
2923
|
+
jsonText(res, 200, kept.body);
|
|
2924
|
+
return true;
|
|
2925
|
+
}
|
|
2926
|
+
const byName = board.workOf(names);
|
|
2927
|
+
const works = names.map((name) => ({ name, w: byName.get(name) }));
|
|
2928
|
+
const all = new Map();
|
|
2929
|
+
for (const { w } of works)
|
|
2930
|
+
for (const c of [...w.doing, ...w.blocked, ...w.review, ...w.ready, ...w.waiting.map((x) => x.card), ...w.mains.map((x) => x.card)])
|
|
2931
|
+
if (!hidden(c))
|
|
2932
|
+
all.set(c.uuid, c);
|
|
2933
|
+
const viewOf = new Map(board.views([...all.values()], scope).map((v) => [v.uuid, v]));
|
|
2934
|
+
const drawn = (cards) => cards.flatMap((c) => (viewOf.has(c.uuid) ? [viewOf.get(c.uuid)] : []));
|
|
2935
|
+
const codes = (cards) => cards.filter((x) => all.has(x.card.uuid)).map((x) => x.card.code);
|
|
2936
|
+
const body = JSON.stringify({ instances: works.map(({ name, w }) => ({
|
|
2937
|
+
name,
|
|
2938
|
+
doing: drawn(w.doing), blocked: drawn(w.blocked), review: drawn(w.review), ready: drawn(w.ready),
|
|
2939
|
+
waiting: drawn(w.waiting.map((x) => x.card)), mains: drawn(w.mains.map((x) => x.card)),
|
|
2940
|
+
sentBack: codes(w.sentBack), newComments: codes(w.newComments),
|
|
2941
|
+
})) });
|
|
2942
|
+
if (key)
|
|
2943
|
+
workBodies.set(board, { key, body });
|
|
2944
|
+
jsonText(res, 200, body);
|
|
2945
|
+
return true;
|
|
2946
|
+
}
|
|
2947
|
+
if (method === "GET" && path === "/ui/tasks/search") {
|
|
2948
|
+
const names = new Map(board.listProjects(true).map((p) => [p.id, p.name]));
|
|
2949
|
+
const hits = board.search(url.searchParams.get("q") ?? "", Number(url.searchParams.get("limit")) || 20, scope).filter((h) => !hidden(h.card));
|
|
2950
|
+
const views = board.views(hits.map((h) => h.card), scope);
|
|
2951
|
+
const results = hits.map(({ card: c, match }, i) => ({
|
|
2952
|
+
card: views[i],
|
|
2953
|
+
match,
|
|
2954
|
+
project: c.projectId === null ? null : names.get(c.projectId) ?? null,
|
|
2955
|
+
parentTitle: c.parent && !hiddenRef(c.parent) ? board.findCard(c.parent)?.title ?? null : null,
|
|
2956
|
+
}));
|
|
2957
|
+
json(res, 200, { results });
|
|
2958
|
+
return true;
|
|
2959
|
+
}
|
|
2960
|
+
if (method === "GET" && card && !card[2]) {
|
|
2961
|
+
const found = board.getCard(ref);
|
|
2962
|
+
if (hidden(found)) {
|
|
2963
|
+
json(res, 404, { error: "no such card", code: "not_found" });
|
|
2964
|
+
return true;
|
|
2965
|
+
}
|
|
2966
|
+
json(res, 200, shown(board.detail(ref, scope)));
|
|
2967
|
+
return true;
|
|
2968
|
+
}
|
|
2969
|
+
if (method === "POST" && path === "/ui/tasks/cards") {
|
|
2970
|
+
boardWrite(req, res, url, ctx, board, CardCreateSchema, (spec, person) => {
|
|
2971
|
+
const { cards, said } = board.createCards(person, { cards: [spec] });
|
|
2972
|
+
return { card: board.view(cards[0]), notes: said };
|
|
2973
|
+
});
|
|
2974
|
+
return true;
|
|
2975
|
+
}
|
|
2976
|
+
if (method === "POST" && card) {
|
|
2977
|
+
if (card[2] === "move") {
|
|
2978
|
+
boardWrite(req, res, url, ctx, board, CardMoveSchema, ({ to, ...move }, person) => {
|
|
2979
|
+
const { card: moved, said } = board.moveCard(person, ref, to, move);
|
|
2980
|
+
return { card: board.view(moved), notes: said };
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
else if (card[2] === "reorder") {
|
|
2984
|
+
boardWrite(req, res, url, ctx, board, CardReorderSchema, ({ after, before }, person) => ({ card: board.view(board.reorder(person, ref, after ?? null, before ?? null)) }));
|
|
2985
|
+
}
|
|
2986
|
+
else {
|
|
2987
|
+
boardWrite(req, res, url, ctx, board, CardPatchSchema, (patch, person) => {
|
|
2988
|
+
const { card: updated, said } = board.updateCard(person, ref, patch);
|
|
2989
|
+
return { card: board.view(updated), notes: said };
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2992
|
+
return true;
|
|
2993
|
+
}
|
|
2994
|
+
if (method === "POST" && path === "/ui/tasks/projects") {
|
|
2995
|
+
boardWrite(req, res, url, ctx, board, ProjectCreateSchema, (input, person) => { checkLeadIsInstance(ctx, input.leadInstance); return { project: board.createProject(person, input) }; });
|
|
2996
|
+
return true;
|
|
2997
|
+
}
|
|
2998
|
+
if (method === "POST" && project) {
|
|
2999
|
+
boardWrite(req, res, url, ctx, board, ProjectPatchSchema, (patch, person) => { checkLeadIsInstance(ctx, patch.leadInstance); return { project: board.updateProject(person, Number(project[1]), patch) }; });
|
|
3000
|
+
return true;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
catch (err) {
|
|
3004
|
+
// A malformed %-escape in the card's address is the caller's mistake, not the server's.
|
|
3005
|
+
if (err instanceof URIError) {
|
|
3006
|
+
json(res, 400, { error: "bad card reference", code: "invalid" });
|
|
3007
|
+
return true;
|
|
3008
|
+
}
|
|
3009
|
+
boardError(res, err);
|
|
3010
|
+
return true;
|
|
3011
|
+
}
|
|
3012
|
+
json(res, 404, { error: "Not found" });
|
|
3013
|
+
return true;
|
|
3014
|
+
}
|
|
2175
3015
|
const taskMatch = path.match(/^\/ui\/tasks\/(.+)$/);
|
|
2176
3016
|
if (method === "POST" && taskMatch) {
|
|
2177
3017
|
if (!ctx.scheduler) {
|
|
2178
3018
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
2179
3019
|
return true;
|
|
2180
3020
|
}
|
|
2181
|
-
const id = decodeURIComponent(taskMatch[1]);
|
|
2182
3021
|
(async () => {
|
|
2183
3022
|
try {
|
|
3023
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
2184
3024
|
const body = await parseBody(req);
|
|
2185
3025
|
if (!permitWebContinuation(req, res, ctx))
|
|
2186
3026
|
return;
|
|
@@ -2188,19 +3028,22 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2188
3028
|
if (!parsed)
|
|
2189
3029
|
return;
|
|
2190
3030
|
let result;
|
|
3031
|
+
// #1827: the person, who is also the default assignee of a claim; the task board's review gate needs to know
|
|
3032
|
+
// a person (not an instance) made the move.
|
|
3033
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
3034
|
+
if (!person)
|
|
3035
|
+
return;
|
|
3036
|
+
const by = boardActor(req, url, ctx, person);
|
|
2191
3037
|
if (parsed.action === "claim") {
|
|
2192
|
-
|
|
2193
|
-
if (!parsed.assignee && !person)
|
|
2194
|
-
return;
|
|
2195
|
-
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
|
|
3038
|
+
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name, by);
|
|
2196
3039
|
}
|
|
2197
3040
|
else if (parsed.action === "complete") {
|
|
2198
|
-
result = ctx.scheduler.db.completeTask(id, parsed.result);
|
|
3041
|
+
result = ctx.scheduler.db.completeTask(id, parsed.result, by);
|
|
2199
3042
|
}
|
|
2200
3043
|
else {
|
|
2201
3044
|
// Strip action before passing remaining fields to updateTask
|
|
2202
3045
|
const { action: _a, ...rest } = parsed;
|
|
2203
|
-
result = ctx.scheduler.db.updateTask(id, rest);
|
|
3046
|
+
result = ctx.scheduler.db.updateTask(id, rest, by);
|
|
2204
3047
|
}
|
|
2205
3048
|
json(res, 200, result);
|
|
2206
3049
|
}
|
|
@@ -2328,6 +3171,90 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2328
3171
|
}
|
|
2329
3172
|
return true;
|
|
2330
3173
|
}
|
|
3174
|
+
// #2293: global search — an admin's only: the header token or an admin session. Never a guest (not in GUEST_ROUTES).
|
|
3175
|
+
// #2478 (the user's request): over the public link too, unless web.search.public_link is false. Asked on arrival and
|
|
3176
|
+
// again before the answer is sent.
|
|
3177
|
+
if (method === "GET" && (path === "/ui/search" || path === "/ui/search/context")) {
|
|
3178
|
+
const refusal = () => {
|
|
3179
|
+
if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.search?.public_link === false)
|
|
3180
|
+
return [404, "search_not_over_public_link"];
|
|
3181
|
+
if (guestRequestOf(req))
|
|
3182
|
+
return [403, "guest_forbidden"];
|
|
3183
|
+
if (ctx.fleetConfig?.web?.search?.enabled === false || !ctx.searchIndex || ctx.searchIndex.status() === "unavailable")
|
|
3184
|
+
return [404, "search_unavailable"];
|
|
3185
|
+
return null;
|
|
3186
|
+
};
|
|
3187
|
+
const first = refusal();
|
|
3188
|
+
if (first) {
|
|
3189
|
+
json(res, first[0], { error: first[1] });
|
|
3190
|
+
return true;
|
|
3191
|
+
}
|
|
3192
|
+
const admitted = adminAdmissionOf(req, url, ctx);
|
|
3193
|
+
if (!admitted) {
|
|
3194
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
3195
|
+
return true;
|
|
3196
|
+
}
|
|
3197
|
+
const index = ctx.searchIndex;
|
|
3198
|
+
// cross_instance_visibility "hidden" (fleet-traffic's rule): a message to or from such a name is not found.
|
|
3199
|
+
const visibility = () => ctx.searchVisibility?.() ?? { hidden: [] };
|
|
3200
|
+
const shown = (doc) => xmsgShown(doc, visibility());
|
|
3201
|
+
const finish = (status, body) => {
|
|
3202
|
+
const now = refusal();
|
|
3203
|
+
if (now)
|
|
3204
|
+
return json(res, now[0], { error: now[1] });
|
|
3205
|
+
if (adminAdmissionOf(req, url, ctx) !== admitted)
|
|
3206
|
+
return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
3207
|
+
json(res, status, body);
|
|
3208
|
+
};
|
|
3209
|
+
const failed = (reason) => finish(reason === "timeout" || reason === "busy" ? 503 : 404, { error: `search_${reason}` });
|
|
3210
|
+
if (path === "/ui/search/context") {
|
|
3211
|
+
const key = url.searchParams.get("key") ?? "";
|
|
3212
|
+
if (!key || key.length > 300) {
|
|
3213
|
+
json(res, 400, { error: "key required" });
|
|
3214
|
+
return true;
|
|
3215
|
+
}
|
|
3216
|
+
void index.context(key).then(r => {
|
|
3217
|
+
if (!r.ok)
|
|
3218
|
+
return failed(r.reason);
|
|
3219
|
+
const doc = r.value?.doc;
|
|
3220
|
+
// A message to or from an instance that is hidden now is not shown, as in the search itself.
|
|
3221
|
+
if (!doc || !shown(doc))
|
|
3222
|
+
return finish(404, { error: "not_found" });
|
|
3223
|
+
finish(200, { ...r.value, building: r.building });
|
|
3224
|
+
}, () => failed("unavailable"));
|
|
3225
|
+
return true;
|
|
3226
|
+
}
|
|
3227
|
+
const parsed = parseSearchQuery(url, ctx);
|
|
3228
|
+
if ("error" in parsed) {
|
|
3229
|
+
json(res, 400, { error: parsed.error });
|
|
3230
|
+
return true;
|
|
3231
|
+
}
|
|
3232
|
+
if (parsed.mine) {
|
|
3233
|
+
const person = webPersonOf(req, url, ctx);
|
|
3234
|
+
if (!person) {
|
|
3235
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
3236
|
+
return true;
|
|
3237
|
+
}
|
|
3238
|
+
parsed.filters.mine = [person.voterKey];
|
|
3239
|
+
}
|
|
3240
|
+
const v = visibility();
|
|
3241
|
+
if ("hidden" in v)
|
|
3242
|
+
parsed.filters.hidden = v.hidden;
|
|
3243
|
+
else
|
|
3244
|
+
parsed.filters.xmsgAmong = v.among;
|
|
3245
|
+
void index.query(parsed.filters).then(r => {
|
|
3246
|
+
if (!r.ok)
|
|
3247
|
+
return failed(r.reason);
|
|
3248
|
+
// A chat row still in the history opens in place (#m=); one that has left it opens as the index's copy.
|
|
3249
|
+
const history = ctx.webChatHistory;
|
|
3250
|
+
// The visibility as it is now (#2295 review): a name hidden while the query ran is left out too.
|
|
3251
|
+
const now = visibility();
|
|
3252
|
+
const hits = r.value.hits.filter(h => xmsgShown(h, now)).map(h => h.source !== "chat" ? h
|
|
3253
|
+
: { ...h, live: !!history?.find(h.instance, String(h.ref.boot ?? ""), Number(h.ref.id)) });
|
|
3254
|
+
finish(200, { hits, limited: r.value.limited, building: r.building });
|
|
3255
|
+
}, () => failed("unavailable"));
|
|
3256
|
+
return true;
|
|
3257
|
+
}
|
|
2331
3258
|
// #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
|
|
2332
3259
|
// usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
|
|
2333
3260
|
// from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
|
|
@@ -2570,6 +3497,17 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2570
3497
|
json(res, 404, { error: "not found" });
|
|
2571
3498
|
return true;
|
|
2572
3499
|
}
|
|
3500
|
+
/**
|
|
3501
|
+
* #2272: the line a web reply's channel copy opens with — the quoted sender and first words when that message reached
|
|
3502
|
+
* the channel (its members saw it); a web-only one is only named, never copied out to the channel.
|
|
3503
|
+
*/
|
|
3504
|
+
export function webReplyEchoLine(quoted) {
|
|
3505
|
+
if (!quoted.channel)
|
|
3506
|
+
return t("web.echo_reply_web_only");
|
|
3507
|
+
const ref = webChatReplyRef(quoted);
|
|
3508
|
+
const words = ref.text.slice(0, 120) || (ref.media === "photo" ? "[photo]" : ref.media ? "[file]" : "");
|
|
3509
|
+
return t("web.echo_reply", quoted.sender.slice(0, 80), words);
|
|
3510
|
+
}
|
|
2573
3511
|
/** Keep the echo in a single platform message, including its attachment names. */
|
|
2574
3512
|
function webEchoPreview(message, names) {
|
|
2575
3513
|
const text = wellFormed(message.slice(0, 500));
|
|
@@ -2578,7 +3516,36 @@ function webEchoPreview(message, names) {
|
|
|
2578
3516
|
const truncated = message.length > 500 || preview.length > 1400;
|
|
2579
3517
|
return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
|
|
2580
3518
|
}
|
|
3519
|
+
/** Registration and liveness are different: a paused CLI may have neither daemon nor IPC. */
|
|
3520
|
+
function registeredChatInstance(ctx, name) {
|
|
3521
|
+
return (!!ctx.fleetConfig && Object.hasOwn(ctx.fleetConfig.instances, name)) || ctx.isClassicInstance?.(name) === true;
|
|
3522
|
+
}
|
|
3523
|
+
function uploadRecipient(ctx, name) {
|
|
3524
|
+
return registeredChatInstance(ctx, name)
|
|
3525
|
+
&& (ctx.instanceIpcClients.has(name) || ctx.getInstanceStatus?.(name) === "paused");
|
|
3526
|
+
}
|
|
3527
|
+
/** Closed, path-free explanations; arbitrary launch errors stay in the host log. */
|
|
3528
|
+
function webDeliveryFailure(err) {
|
|
3529
|
+
const message = err instanceof Error ? err.message : "";
|
|
3530
|
+
if (/No warm slot is free/.test(message))
|
|
3531
|
+
return { reason: "wake_capacity", error: "No slot is free to wake this instance. Retry when another instance is idle." };
|
|
3532
|
+
if (/login|log in|sign in|\bauth(?:entication|orization)?\b|credential|api.?key/i.test(message))
|
|
3533
|
+
return { reason: "wake_credentials", error: "The instance could not sign in. Check its account on the host, then retry." };
|
|
3534
|
+
if (/timed? out|timeout/i.test(message))
|
|
3535
|
+
return { reason: "wake_timeout", error: "The instance did not become ready in time. Retry, or check its status on the host." };
|
|
3536
|
+
if (/Cannot wake stopped|no longer configured/.test(message))
|
|
3537
|
+
return { reason: "instance_stopped", error: "This instance was stopped or removed. Start it on the host before retrying." };
|
|
3538
|
+
return { reason: "delivery_unavailable", error: "The instance could not receive this message. Check its status on the host, then retry." };
|
|
3539
|
+
}
|
|
2581
3540
|
/** Handle POST /ui/send — extracted for readability. */
|
|
3541
|
+
/** Each web server's own memory of the sends it accepted by clientId (web-send-dedupe.ts). */
|
|
3542
|
+
const webSendDedupes = new WeakMap();
|
|
3543
|
+
function webSendDedupeOf(ctx) {
|
|
3544
|
+
let d = webSendDedupes.get(ctx);
|
|
3545
|
+
if (!d)
|
|
3546
|
+
webSendDedupes.set(ctx, d = new WebSendDedupe());
|
|
3547
|
+
return d;
|
|
3548
|
+
}
|
|
2582
3549
|
function handleSendMessage(req, res, ctx) {
|
|
2583
3550
|
const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
|
|
2584
3551
|
let body = "";
|
|
@@ -2595,6 +3562,9 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2595
3562
|
req.on("end", async () => {
|
|
2596
3563
|
if (!permitWebContinuation(req, res, ctx))
|
|
2597
3564
|
return;
|
|
3565
|
+
// A claimed clientId is settled however this ends: with the messageId once accepted, else forgotten.
|
|
3566
|
+
let claim;
|
|
3567
|
+
let acceptedId = null;
|
|
2598
3568
|
try {
|
|
2599
3569
|
const raw = JSON.parse(body);
|
|
2600
3570
|
const parsed = SendMessageSchema.safeParse(raw);
|
|
@@ -2603,7 +3573,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2603
3573
|
json(res, 400, { error: `${issue.path.join(".") || "body"}: ${issue.message}` });
|
|
2604
3574
|
return;
|
|
2605
3575
|
}
|
|
2606
|
-
const { instance, message: typed, attachments = [] } = parsed.data;
|
|
3576
|
+
const { instance, message: typed, attachments = [], replyTo, clientId } = parsed.data;
|
|
2607
3577
|
// #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
|
|
2608
3578
|
const guest = guestRequestOf(req);
|
|
2609
3579
|
const refused = guestChatRefusal(guest, instance);
|
|
@@ -2615,11 +3585,48 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2615
3585
|
json(res, 403, { error: "guest_no_commands" });
|
|
2616
3586
|
return;
|
|
2617
3587
|
}
|
|
2618
|
-
|
|
2619
|
-
|
|
3588
|
+
// #2272: a reply names a message this chat still keeps — and, for a guest, one it may see. What it quotes is read
|
|
3589
|
+
// from that row here, never taken from the page.
|
|
3590
|
+
let quoted;
|
|
3591
|
+
if (replyTo) {
|
|
3592
|
+
quoted = ctx.webChatHistory?.find(instance, replyTo.boot, replyTo.id);
|
|
3593
|
+
if (!quoted || quoted.effectTrial || (guest && !guestSeesMessage(quoted, guest, guest.scope()))) {
|
|
3594
|
+
json(res, 409, { error: t("web.reply_gone"), reason: "reply_gone" });
|
|
3595
|
+
return;
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
const registered = registeredChatInstance(ctx, instance);
|
|
3599
|
+
if (!registered && !ctx.instanceIpcClients.has(instance)) {
|
|
2620
3600
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
2621
3601
|
return;
|
|
2622
3602
|
}
|
|
3603
|
+
// #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
|
|
3604
|
+
// body is in: a session revoked meanwhile sends nothing (not as the operator).
|
|
3605
|
+
const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
|
|
3606
|
+
if (!person) {
|
|
3607
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
// #2438 follow-up: the page's Retry of a send that already went (its answer and the live copy lost) is that send:
|
|
3611
|
+
// answered with its messageId, delivered once. One still going is waited for; one that failed is sent anew.
|
|
3612
|
+
if (clientId) {
|
|
3613
|
+
const key = WebSendDedupe.key(person.key, instance, clientId);
|
|
3614
|
+
for (;;) {
|
|
3615
|
+
const seen = webSendDedupeOf(ctx).claim(key);
|
|
3616
|
+
if (seen.kind === "going") {
|
|
3617
|
+
await seen.settled;
|
|
3618
|
+
if (res.destroyed)
|
|
3619
|
+
return;
|
|
3620
|
+
continue;
|
|
3621
|
+
}
|
|
3622
|
+
if (seen.kind === "accepted") {
|
|
3623
|
+
json(res, 200, { sent: true, messageId: seen.messageId, repeated: true });
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
claim = seen;
|
|
3627
|
+
break;
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
2623
3630
|
let files = [];
|
|
2624
3631
|
if (attachments.length > 0) {
|
|
2625
3632
|
const taken = ctx.webFiles?.takeForMessage(instance, attachments);
|
|
@@ -2639,15 +3646,13 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2639
3646
|
json(res, 403, { error: t("web.raw_public_refused") });
|
|
2640
3647
|
return;
|
|
2641
3648
|
}
|
|
3649
|
+
// Recheck the same admission all the way through a wake, before the facade's actual IPC handoff.
|
|
3650
|
+
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
3651
|
+
const admission = admissionOf(req, requestUrl, ctx);
|
|
3652
|
+
const stillCurrent = () => !res.destroyed && admission !== null && admissionOf(req, requestUrl, ctx) === admission
|
|
3653
|
+
&& (!guest || (guest.current() && !guestChatRefusal(guest, instance)))
|
|
3654
|
+
&& (registered ? registeredChatInstance(ctx, instance) : ctx.instanceIpcClients.has(instance));
|
|
2642
3655
|
const message = typed;
|
|
2643
|
-
// #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
|
|
2644
|
-
// body is in: a session revoked meanwhile sends nothing (not as the operator).
|
|
2645
|
-
const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
|
|
2646
|
-
if (!person) {
|
|
2647
|
-
ctx.webFiles?.release(files);
|
|
2648
|
-
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2649
|
-
return;
|
|
2650
|
-
}
|
|
2651
3656
|
// #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
|
|
2652
3657
|
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
|
|
2653
3658
|
const ts = new Date().toISOString();
|
|
@@ -2662,15 +3667,23 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2662
3667
|
const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
|
|
2663
3668
|
const canEcho = ctx.fleetConfig?.web?.echo_to_channel !== false
|
|
2664
3669
|
&& !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
|
|
2665
|
-
// Add no runtime credentials or local attachment paths to the display copy.
|
|
2666
|
-
|
|
3670
|
+
// Add no runtime credentials or local attachment paths to the display copy. A reply opens with the line it
|
|
3671
|
+
// answers (#2272) — quoted only if that message reached the channel; a web-only one is named, never copied out.
|
|
3672
|
+
const quoteLine = quoted ? webReplyEchoLine(quoted) : "";
|
|
3673
|
+
const plainPreview = webEchoPreview(message, files.map(f => f.name));
|
|
3674
|
+
const preview = (quoteLine ? `${quoteLine}\n` : "") + plainPreview;
|
|
3675
|
+
// #2281: where the quoted message is on a platform. An echo to the same place is a native reply to it there, and
|
|
3676
|
+
// then needs no quoted line; anywhere else (or a message with no known post) keeps the line, as before.
|
|
3677
|
+
const quotedPosts = quoted ? ctx.webChatHistory?.postsOf(quoted) ?? [] : [];
|
|
3678
|
+
const echoText = (p) => formatWebChannelEcho(person.name, p, t("web.echo_full_text"), { guest: !!guest });
|
|
2667
3679
|
// ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
|
|
2668
3680
|
// the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
|
|
2669
3681
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
2670
3682
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
2671
|
-
const
|
|
3683
|
+
const copies = [];
|
|
3684
|
+
const posted = await ctx.sendClassicWebEcho(instance, echoText(preview), person.name, copies, quotedPosts.length ? { posts: quotedPosts, text: echoText(plainPreview) } : undefined);
|
|
2672
3685
|
if (posted > 0)
|
|
2673
|
-
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
|
|
3686
|
+
ctx.webMessageInChannel?.(instance, messageId, copies); // #1920 §5.2: the room saw it (#2305: its copies)
|
|
2674
3687
|
}) : undefined;
|
|
2675
3688
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
2676
3689
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -2678,15 +3691,20 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2678
3691
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
2679
3692
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
2680
3693
|
return;
|
|
2681
|
-
const
|
|
2682
|
-
|
|
3694
|
+
const native = quotedPosts.find(p => p.adapterId === syncAdapter.id && p.chatId === String(groupId) && (p.threadId ?? "") === String(topicId));
|
|
3695
|
+
const sent = await syncAdapter.sendText(String(groupId), echoText(native ? plainPreview : preview), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] }, ...(native ? { replyTo: native.messageId } : {}) });
|
|
3696
|
+
const copyId = sent && typeof sent === "object" && typeof sent.messageId === "string" ? sent.messageId : "";
|
|
3697
|
+
// #1920 §5.2: the topic saw it. #2305: and where it is, for a reaction to the web message (#2281: and a reply).
|
|
3698
|
+
const reacting = syncAdapter;
|
|
3699
|
+
ctx.webMessageInChannel?.(instance, messageId, copyId
|
|
3700
|
+
? [{ adapter: reacting, adapterId: syncAdapter.id, chatId: String(groupId), threadId: String(topicId), messageId: copyId }] : []);
|
|
2683
3701
|
return sent;
|
|
2684
3702
|
}) : undefined;
|
|
2685
3703
|
let delivered;
|
|
2686
3704
|
try {
|
|
2687
3705
|
const deliver = (name, payload) => ctx.deliverHumanToInstance
|
|
2688
|
-
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed)
|
|
2689
|
-
: ctx.deliverToInstance(name, payload);
|
|
3706
|
+
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed, { stillCurrent })
|
|
3707
|
+
: ctx.deliverToInstance(name, payload, { stillCurrent });
|
|
2690
3708
|
delivered = await deliver(instance, {
|
|
2691
3709
|
type: "fleet_inbound",
|
|
2692
3710
|
content: delivery.text,
|
|
@@ -2701,6 +3719,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2701
3719
|
...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
|
|
2702
3720
|
adapter_id: syncAdapter?.id,
|
|
2703
3721
|
...delivery.meta,
|
|
3722
|
+
// #2272: `(reply_to: "…")`, as a Discord or Telegram reply gives the agent.
|
|
3723
|
+
...(quoted ? { reply_to_text: webChatReplyText(quoted) } : {}),
|
|
2704
3724
|
},
|
|
2705
3725
|
});
|
|
2706
3726
|
}
|
|
@@ -2712,7 +3732,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2712
3732
|
settleClassicEcho?.(false);
|
|
2713
3733
|
ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
|
|
2714
3734
|
ctx.logger.error({ err, instance }, "Web message delivery failed");
|
|
2715
|
-
json(res, 503,
|
|
3735
|
+
json(res, 503, webDeliveryFailure(err));
|
|
2716
3736
|
return;
|
|
2717
3737
|
}
|
|
2718
3738
|
if (delivered === false) {
|
|
@@ -2720,9 +3740,12 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2720
3740
|
settleClassicEcho?.(false);
|
|
2721
3741
|
ctx.webFiles?.release(files);
|
|
2722
3742
|
ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
|
|
2723
|
-
json(res, 503, { error: "
|
|
3743
|
+
json(res, 503, { reason: "delivery_changed", error: "The session or instance changed before this message was sent. Sign in again if needed, then retry." });
|
|
2724
3744
|
return;
|
|
2725
3745
|
}
|
|
3746
|
+
// The agent has it: from here on a repeat of this clientId is this send, whatever fails after (the echo, the
|
|
3747
|
+
// stream's copy) — a Retry then gets this messageId, never a second delivery.
|
|
3748
|
+
acceptedId = messageId;
|
|
2726
3749
|
settleEcho?.(true);
|
|
2727
3750
|
settleClassicEcho?.(true);
|
|
2728
3751
|
// #1666: the same cancel/progress bubble a platform inbound starts, in the topic the echo went to — a second turn
|
|
@@ -2736,11 +3759,20 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2736
3759
|
ctx.lastInboundUser.set(instance, person.name);
|
|
2737
3760
|
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
2738
3761
|
// #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
|
|
2739
|
-
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {})
|
|
3762
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}),
|
|
3763
|
+
...(quoted ? { replyTo: webChatReplyRef(quoted) } : {}), ...(clientId ? { clientId } : {}) });
|
|
2740
3764
|
json(res, 200, { sent: true, messageId });
|
|
2741
3765
|
}
|
|
2742
3766
|
catch {
|
|
2743
|
-
|
|
3767
|
+
// A failure after the agent has it is no failed send: the page is told it went (a Retry would only find it).
|
|
3768
|
+
if (res.headersSent) { /* answered already: the failure was in or after the answer */ }
|
|
3769
|
+
else if (acceptedId)
|
|
3770
|
+
json(res, 200, { sent: true, messageId: acceptedId });
|
|
3771
|
+
else
|
|
3772
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
3773
|
+
}
|
|
3774
|
+
finally {
|
|
3775
|
+
claim?.settle(acceptedId);
|
|
2744
3776
|
}
|
|
2745
3777
|
});
|
|
2746
3778
|
}
|
|
@@ -2767,7 +3799,7 @@ function handleUpload(req, res, url, ctx) {
|
|
|
2767
3799
|
json(res, guestRefused.status, { error: guestRefused.error });
|
|
2768
3800
|
return;
|
|
2769
3801
|
}
|
|
2770
|
-
if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx
|
|
3802
|
+
if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !uploadRecipient(ctx, instance)) {
|
|
2771
3803
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
2772
3804
|
return;
|
|
2773
3805
|
}
|
|
@@ -2830,11 +3862,9 @@ function handleUpload(req, res, url, ctx) {
|
|
|
2830
3862
|
req.on("error", () => drop()); // the client went away
|
|
2831
3863
|
req.on("close", () => { if (!over && req.complete === false)
|
|
2832
3864
|
drop(); });
|
|
2833
|
-
//
|
|
2834
|
-
//
|
|
2835
|
-
|
|
2836
|
-
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
|
|
2837
|
-
const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
|
|
3865
|
+
// Recheck registration and delivery readiness after both waits. A paused recipient may have no IPC;
|
|
3866
|
+
// staging a file never wakes it. A stopped or deleted recipient must not regain an upload id.
|
|
3867
|
+
const deliverable = () => uploadRecipient(ctx, instance);
|
|
2838
3868
|
const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
|
|
2839
3869
|
req.on("end", () => {
|
|
2840
3870
|
if (over)
|
|
@@ -3026,6 +4056,7 @@ function shellBodyTag(req, ctx, mode, viewer) {
|
|
|
3026
4056
|
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
|
|
3027
4057
|
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
3028
4058
|
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
4059
|
+
+ ` data-canvas-worker="${canvasWorkerEnabled(ctx.fleetConfig?.web) ? "1" : "0"}"`
|
|
3029
4060
|
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
3030
4061
|
// checks before sending a file, and the numbers its words say. The server checks again on every upload.
|
|
3031
4062
|
+ ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
|
|
@@ -3035,6 +4066,8 @@ function shellBodyTag(req, ctx, mode, viewer) {
|
|
|
3035
4066
|
+ ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
|
|
3036
4067
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|
|
3037
4068
|
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
|
|
4069
|
+
// #2478: search is offered over the public link unless web.search.public_link is false (the page then shows no entry).
|
|
4070
|
+
+ `${gatewayRequestContext(req) && ctx.fleetConfig?.web?.search?.public_link === false ? ' data-search-link="0"' : ""}`
|
|
3038
4071
|
// #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
|
|
3039
4072
|
+ `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
|
|
3040
4073
|
}
|
|
@@ -3057,7 +4090,7 @@ export function serveAppShell(req, res, ctx, mode) {
|
|
|
3057
4090
|
const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
|
|
3058
4091
|
const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
|
|
3059
4092
|
const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
|
|
3060
|
-
sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
4093
|
+
sendPanelHtml(res, withAppIconLinks(html).replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
3061
4094
|
}
|
|
3062
4095
|
catch {
|
|
3063
4096
|
json(res, 500, { error: "app.html not found" });
|