@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -24
- package/README.zh-TW.md +40 -24
- package/dist/agent-cli-instructions.md +6 -2
- package/dist/agent-cli.js +15 -7
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +3 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.js +16 -4
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +3 -1
- package/dist/auth-api.js +17 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.d.ts +33 -0
- package/dist/backend/antigravity.js +126 -18
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-carry.d.ts +22 -0
- package/dist/backend/claude-carry.js +170 -0
- package/dist/backend/claude-carry.js.map +1 -0
- package/dist/backend/claude-code.d.ts +65 -2
- package/dist/backend/claude-code.js +226 -33
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +33 -0
- package/dist/backend/codex-metadata.js +122 -18
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -0
- package/dist/backend/codex.js +17 -1
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +40 -3
- package/dist/backend/credential-profile.js +113 -5
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/effort-metadata.d.ts +37 -1
- package/dist/backend/effort-metadata.js +86 -15
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.d.ts +4 -0
- package/dist/backend/grok.js +14 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +27 -1
- package/dist/backend/kiro.js +63 -10
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.d.ts +61 -0
- package/dist/backend/launch-effort.js +170 -0
- package/dist/backend/launch-effort.js.map +1 -0
- package/dist/backend/muse.d.ts +84 -0
- package/dist/backend/muse.js +449 -53
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +48 -2
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-command.d.ts +91 -0
- package/dist/backend-command.js +122 -0
- package/dist/backend-command.js.map +1 -0
- package/dist/background-work-ended.d.ts +18 -0
- package/dist/background-work-ended.js +30 -0
- package/dist/background-work-ended.js.map +1 -0
- package/dist/cache-service.d.ts +2 -0
- package/dist/cache-service.js +2 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +26 -1
- package/dist/channel/adapters/discord.js +103 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +35 -3
- package/dist/channel/adapters/telegram.js +122 -23
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -3
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-edit-errors.d.ts +5 -0
- package/dist/channel/message-edit-errors.js +24 -0
- package/dist/channel/message-edit-errors.js.map +1 -0
- package/dist/channel/telegram-markdown.d.ts +6 -0
- package/dist/channel/telegram-markdown.js +104 -0
- package/dist/channel/telegram-markdown.js.map +1 -0
- package/dist/channel/tool-router.d.ts +2 -0
- package/dist/channel/tool-router.js +6 -5
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +46 -4
- package/dist/cli.js +184 -34
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +2 -2
- package/dist/command-table.js +5 -2
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.d.ts +86 -0
- package/dist/compact-watch.js +288 -0
- package/dist/compact-watch.js.map +1 -0
- package/dist/config-validator.js +55 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.js +2 -0
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +129 -1
- package/dist/daemon.js +754 -94
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +64 -8
- package/dist/delivery-outbox.js +315 -24
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +18 -1
- package/dist/delivery-reconciliation.js +67 -2
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/doctor.js +7 -0
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-context.d.ts +12 -1
- package/dist/fleet-manager.d.ts +392 -19
- package/dist/fleet-manager.js +2030 -275
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +4 -3
- package/dist/forged-envelope-scan.js +4 -3
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +84 -16
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +16 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
- package/dist/host-memory.d.ts +10 -0
- package/dist/host-memory.js +33 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/human-delivery-receipts.d.ts +83 -0
- package/dist/human-delivery-receipts.js +194 -0
- package/dist/human-delivery-receipts.js.map +1 -0
- package/dist/install-lock.d.ts +18 -1
- package/dist/install-lock.js +30 -3
- package/dist/install-lock.js.map +1 -1
- package/dist/instance-avatar.d.ts +5 -4
- package/dist/instance-avatar.js +12 -8
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instance-config-impact.js +1 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +25 -6
- package/dist/instance-lifecycle.js +76 -27
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +3 -3
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.d.ts +38 -0
- package/dist/kiro-compact-pane.js +57 -0
- package/dist/kiro-compact-pane.js.map +1 -0
- package/dist/kiro-log-gate.d.ts +42 -0
- package/dist/kiro-log-gate.js +39 -0
- package/dist/kiro-log-gate.js.map +1 -0
- package/dist/launchd-handoff.d.ts +43 -0
- package/dist/launchd-handoff.js +56 -0
- package/dist/launchd-handoff.js.map +1 -0
- package/dist/locale.js +268 -42
- package/dist/locale.js.map +1 -1
- package/dist/log-service.d.ts +99 -0
- package/dist/log-service.js +243 -0
- package/dist/log-service.js.map +1 -0
- package/dist/log-steps.d.ts +169 -0
- package/dist/log-steps.js +858 -0
- package/dist/log-steps.js.map +1 -0
- package/dist/login-controller.d.ts +20 -3
- package/dist/login-controller.js +71 -19
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +5 -2
- package/dist/login-flows.js +5 -4
- package/dist/login-flows.js.map +1 -1
- package/dist/login-shell.d.ts +21 -0
- package/dist/login-shell.js +98 -0
- package/dist/login-shell.js.map +1 -0
- package/dist/memory-pressure.d.ts +13 -1
- package/dist/memory-pressure.js +50 -10
- package/dist/memory-pressure.js.map +1 -1
- package/dist/needs-you-hub.js +2 -0
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +6 -0
- package/dist/needs-you-live.js +23 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/outbound-handlers.d.ts +100 -0
- package/dist/outbound-handlers.js +277 -93
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +30 -15
- package/dist/outbound-schemas.js +23 -5
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +29 -0
- package/dist/provider-probe.js +198 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/public-link-progress.d.ts +1 -1
- package/dist/public-link-progress.js +8 -2
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.d.ts +101 -0
- package/dist/public-preview.js +236 -0
- package/dist/public-preview.js.map +1 -0
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +20 -6
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +24 -1
- package/dist/public-web-link.js +63 -9
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +8 -6
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +12 -0
- package/dist/quickstart.js +36 -6
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +89 -5
- package/dist/reply-buttons-controller.js +273 -8
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-buttons.d.ts +48 -3
- package/dist/reply-buttons.js +105 -10
- package/dist/reply-buttons.js.map +1 -1
- package/dist/reply-polls.d.ts +85 -0
- package/dist/reply-polls.js +154 -0
- package/dist/reply-polls.js.map +1 -0
- package/dist/restart-guard.d.ts +6 -3
- package/dist/restart-guard.js +6 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +33 -2
- package/dist/restart-progress.js +30 -2
- package/dist/restart-progress.js.map +1 -1
- package/dist/sd-notify.d.ts +22 -1
- package/dist/sd-notify.js +63 -10
- package/dist/sd-notify.js.map +1 -1
- package/dist/service-activation.d.ts +15 -7
- package/dist/service-activation.js +42 -26
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +55 -0
- package/dist/service-installer.js +90 -3
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +11 -0
- package/dist/service-plan.js +23 -0
- package/dist/service-plan.js.map +1 -1
- package/dist/settings-api.js +14 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +4 -2
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +2 -0
- package/dist/settings-effect.js.map +1 -1
- package/dist/spawn-gate.js +5 -1
- package/dist/spawn-gate.js.map +1 -1
- package/dist/spawn-hold-reply.d.ts +12 -0
- package/dist/spawn-hold-reply.js +39 -0
- package/dist/spawn-hold-reply.js.map +1 -0
- package/dist/systemd-peer-bus.d.ts +95 -0
- package/dist/systemd-peer-bus.js +430 -0
- package/dist/systemd-peer-bus.js.map +1 -0
- package/dist/systemd-runtime.d.ts +27 -1
- package/dist/systemd-runtime.js +69 -7
- package/dist/systemd-runtime.js.map +1 -1
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/tool-permissions.js +3 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/tool-progress.js +3 -2
- package/dist/tool-progress.js.map +1 -1
- package/dist/topic-commands.d.ts +71 -4
- package/dist/topic-commands.js +167 -34
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +3 -3
- package/dist/transcript-delta-reader.js +8 -8
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-jsonl.d.ts +85 -3
- package/dist/transcript-jsonl.js +156 -13
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-monitor.d.ts +11 -0
- package/dist/transcript-monitor.js +61 -13
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +24 -0
- package/dist/transcript-sources.js +211 -56
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -15
- package/dist/tunnel/cloudflared.js +36 -1
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +5 -1
- package/dist/tunnel/purpose-lane.js +14 -7
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/tunnel/types.d.ts +5 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/turn-resume.d.ts +2 -0
- package/dist/turn-resume.js +1 -1
- package/dist/turn-resume.js.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/ui/chat-commands.js +1 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +80 -10
- package/dist/ui/chat-store.js +70 -6
- package/dist/ui/chat-strings.js +62 -32
- package/dist/ui/chat-thread.js +199 -24
- package/dist/ui/chat-time.js +80 -0
- package/dist/ui/chat-tour.js +17 -14
- package/dist/ui/panel-chat.js +45 -21
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +213 -58
- package/dist/ui/panel-log.js +428 -0
- package/dist/ui/panel-needs.js +14 -10
- package/dist/ui/panel-settings.js +82 -17
- package/dist/ui/preview-panel.js +55 -15
- package/dist/ui/preview-status.js +28 -0
- package/dist/ui/preview.js +44 -3
- package/dist/ui/settings-dialogs.js +11 -6
- package/dist/ui/settings-model.js +3 -1
- package/dist/ui/settings-strings.js +32 -12
- package/dist/ui/settings-token.js +8 -1
- package/dist/ui/settings-wizard.js +5 -3
- package/dist/ui/shared/app-i18n.js +41 -15
- package/dist/ui/shared/app-prefs.js +250 -0
- package/dist/ui/shared/app-route.js +6 -1
- package/dist/ui/shared/app-shell.js +165 -8
- package/dist/ui/shared/app-store.js +5 -1
- package/dist/ui/shared/app-stream.js +26 -5
- package/dist/ui/shared/app.css +170 -30
- package/dist/ui/shared/app.js +11 -1
- package/dist/ui/shared/fonts/README.md +1 -1
- package/dist/ui/shared/header-tools.js +83 -13
- package/dist/ui/shared/instance-nav.js +178 -51
- package/dist/ui/shared/instance-switch.js +8 -5
- package/dist/ui/shared/panel-view.js +21 -4
- package/dist/ui/shared/signin.js +15 -9
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/tokens.css +1 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/usage-dialog.js +23 -5
- package/dist/ui/shared/view-strings.js +40 -4
- package/dist/ui/signin.html +8 -3
- package/dist/unrecognised-prompt.d.ts +23 -0
- package/dist/unrecognised-prompt.js +74 -0
- package/dist/unrecognised-prompt.js.map +1 -0
- package/dist/update-check.d.ts +31 -0
- package/dist/update-check.js +47 -0
- package/dist/update-check.js.map +1 -1
- package/dist/update-install.d.ts +13 -1
- package/dist/update-install.js +16 -4
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +48 -1
- package/dist/update-launch.js +159 -5
- package/dist/update-launch.js.map +1 -1
- package/dist/update-marker.d.ts +51 -0
- package/dist/update-marker.js +61 -0
- package/dist/update-marker.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +62 -13
- package/dist/usage/providers.js +256 -68
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.d.ts +6 -2
- package/dist/usage/statusline-usage.js +10 -4
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/view-api.js +31 -9
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +82 -4
- package/dist/web-api.js +632 -88
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +5 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-chat-history.d.ts +26 -1
- package/dist/web-chat-history.js +47 -8
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +41 -5
- package/dist/web-commands.js +25 -7
- package/dist/web-commands.js.map +1 -1
- package/dist/web-login.d.ts +14 -0
- package/dist/web-login.js +4 -2
- package/dist/web-login.js.map +1 -1
- package/dist/web-mentions.d.ts +44 -0
- package/dist/web-mentions.js +75 -0
- package/dist/web-mentions.js.map +1 -0
- package/dist/web-prefs.d.ts +81 -0
- package/dist/web-prefs.js +297 -0
- package/dist/web-prefs.js.map +1 -0
- package/dist/web-preview.d.ts +14 -1
- package/dist/web-preview.js +35 -13
- package/dist/web-preview.js.map +1 -1
- package/dist/web-profile.d.ts +32 -0
- package/dist/web-profile.js +132 -0
- package/dist/web-profile.js.map +1 -0
- package/dist/web-reply-files.d.ts +45 -0
- package/dist/web-reply-files.js +223 -0
- package/dist/web-reply-files.js.map +1 -0
- package/dist/web-schedules.d.ts +12 -0
- package/dist/web-schedules.js +34 -0
- package/dist/web-schedules.js.map +1 -0
- package/dist/web-server-drain.d.ts +10 -0
- package/dist/web-server-drain.js +50 -0
- package/dist/web-server-drain.js.map +1 -0
- package/dist/web-session.d.ts +14 -0
- package/dist/web-session.js +11 -1
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +4 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -0
- package/dist/web-shell-routes.js +9 -0
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-upload.d.ts +135 -7
- package/dist/web-upload.js +315 -51
- package/dist/web-upload.js.map +1 -1
- package/launcher/install-admission.cjs +61 -6
- package/package.json +3 -3
- package/templates/launchd.plist.ejs +3 -1
- package/templates/systemd.service.ejs +2 -1
package/dist/web-api.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { webHumanOrigin } from "./human-delivery-receipts.js";
|
|
2
|
+
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
1
3
|
import { binaryProbe } from "./binary-probe.js";
|
|
2
4
|
import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
|
|
3
5
|
import { readBoundedWebBody } from "./web-body.js";
|
|
@@ -13,19 +15,26 @@ import { isSafeInstanceName } from "./web-shell-routes.js";
|
|
|
13
15
|
import { MAX_COMMAND_ARGS } from "./web-commands.js";
|
|
14
16
|
import { sendPanelHtml } from "./web-host-guard.js";
|
|
15
17
|
import { shellRoute } from "./web-shell-routes.js";
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
18
|
+
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
17
19
|
import { join, dirname } from "node:path";
|
|
18
20
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { CreateInstanceArgs, validateArgs } from "./outbound-schemas.js";
|
|
21
|
+
import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
|
|
22
|
+
import { scheduleView } from "./web-schedules.js";
|
|
23
|
+
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
20
24
|
import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
|
|
21
25
|
import { z } from "zod";
|
|
22
26
|
import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
23
27
|
import { dashboardOriginFor } from "./web-preview.js";
|
|
24
28
|
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
25
|
-
import { attachmentDelivery, displayName, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment,
|
|
29
|
+
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";
|
|
26
30
|
import { getAgendHome } from "./paths.js";
|
|
31
|
+
import { OPERATOR } from "./web-prefs.js";
|
|
32
|
+
import { WEB_NAME_FALLBACK } from "./web-profile.js";
|
|
33
|
+
import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
|
|
34
|
+
import { parseMentionQuery } from "./web-mentions.js";
|
|
27
35
|
import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
28
36
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
37
|
+
import { resolveTz } from "./tz-utils.js";
|
|
29
38
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
30
39
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
31
40
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -45,18 +54,17 @@ const TaskUpdateSchema = z.object({
|
|
|
45
54
|
description: z.string().max(MAX_TEXT).optional(),
|
|
46
55
|
priority: z.string().max(64).optional(),
|
|
47
56
|
}).strict();
|
|
48
|
-
const ScheduleCreateSchema = z.object({
|
|
49
|
-
cron: z.string().min(1).max(256),
|
|
50
|
-
message: z.string().min(1).max(MAX_TEXT),
|
|
51
|
-
target: z.string().min(1).max(256),
|
|
52
|
-
label: z.string().max(256).optional(),
|
|
53
|
-
timezone: z.string().max(128).optional(),
|
|
54
|
-
}).strict();
|
|
55
57
|
const TeamCreateSchema = z.object({
|
|
56
58
|
name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
|
|
57
59
|
members: z.array(z.string().min(1).max(256)).min(1).max(256),
|
|
58
60
|
description: z.string().max(MAX_TEXT).optional(),
|
|
59
61
|
}).strict();
|
|
62
|
+
// #1689: an edit of a team — its members as they should be, and its description ("" clears it). No name: a team is
|
|
63
|
+
// not renamed.
|
|
64
|
+
const TeamEditSchema = z.object({
|
|
65
|
+
members: z.array(z.string().min(1).max(256)).min(1).max(256),
|
|
66
|
+
description: z.string().max(MAX_TEXT).optional(),
|
|
67
|
+
}).strict();
|
|
60
68
|
const ConfigUpdateSchema = z.object({
|
|
61
69
|
channel: z.object({
|
|
62
70
|
group_id: z.union([z.number(), z.string()]).optional(),
|
|
@@ -142,6 +150,37 @@ function stillAuthorized(req, res, url, ctx) {
|
|
|
142
150
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
143
151
|
return false;
|
|
144
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
|
|
155
|
+
* no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
|
|
156
|
+
* session, the CLI's header token) none: the operator. For recognition only; nothing here authorizes anything.
|
|
157
|
+
*/
|
|
158
|
+
function webAccountOf(req, url, ctx) {
|
|
159
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
160
|
+
if (verdict.kind !== "allow")
|
|
161
|
+
return null;
|
|
162
|
+
return verdict.via === "session" && verdict.session?.owner ? { owner: verdict.session.owner } : {};
|
|
163
|
+
}
|
|
164
|
+
function webPersonOf(req, url, ctx) {
|
|
165
|
+
// No people kept here: everyone is "web-user", as before, and there is no identity to protect.
|
|
166
|
+
if (!ctx.webPerson)
|
|
167
|
+
return { key: OPERATOR, bound: false, userId: WEB_NAME_FALLBACK, voterKey: "web:operator", name: WEB_NAME_FALLBACK, set: false };
|
|
168
|
+
const who = webAccountOf(req, url, ctx);
|
|
169
|
+
return who ? ctx.webPerson(who.owner) : null;
|
|
170
|
+
}
|
|
171
|
+
/** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
|
|
172
|
+
function webPersonOr401(req, res, url, ctx) {
|
|
173
|
+
const p = webPersonOf(req, url, ctx);
|
|
174
|
+
if (!p)
|
|
175
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
/** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
|
|
179
|
+
function profileBody(p) {
|
|
180
|
+
return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
|
|
181
|
+
}
|
|
182
|
+
/** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
|
|
183
|
+
function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
|
|
145
184
|
/**
|
|
146
185
|
* Handle a Web UI request. Returns true if handled, false to pass through.
|
|
147
186
|
*/
|
|
@@ -254,6 +293,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
254
293
|
// #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
|
|
255
294
|
// records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
|
|
256
295
|
cursor: history ? history.pollCursor() : null,
|
|
296
|
+
// #1823 (#1830 review): the server's clock now, for the page's poll countdown (a poll's deadline is the server's).
|
|
297
|
+
now: Date.now(),
|
|
257
298
|
});
|
|
258
299
|
return true;
|
|
259
300
|
}
|
|
@@ -267,7 +308,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
267
308
|
"Cache-Control": "no-cache",
|
|
268
309
|
Connection: "keep-alive",
|
|
269
310
|
});
|
|
270
|
-
|
|
311
|
+
// #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
|
|
312
|
+
// poll down by (one write: the frames a connect sends are unchanged in number).
|
|
313
|
+
res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
|
|
271
314
|
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
272
315
|
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
273
316
|
if (lastSeen !== null && ctx.webChatHistory) {
|
|
@@ -303,7 +346,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
303
346
|
}
|
|
304
347
|
try {
|
|
305
348
|
// Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
|
|
306
|
-
res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
|
|
349
|
+
res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
|
|
307
350
|
}
|
|
308
351
|
catch {
|
|
309
352
|
cleanup();
|
|
@@ -340,6 +383,45 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
340
383
|
json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
|
|
341
384
|
return true;
|
|
342
385
|
}
|
|
386
|
+
// ── #1218: the Log tab ─────────────────────────────────
|
|
387
|
+
// An instance's steps, read on demand from its own transcript (log-service.ts). Read-only, and offered on the public
|
|
388
|
+
// link on purpose (the user's decision): the same masking applies to every viewer. Passive (web-auth.ts).
|
|
389
|
+
if (method === "GET" && path === "/ui/steps") {
|
|
390
|
+
const p = url.searchParams;
|
|
391
|
+
const instance = p.get("instance") ?? "";
|
|
392
|
+
const num = (k) => {
|
|
393
|
+
const v = p.get(k);
|
|
394
|
+
if (v === null)
|
|
395
|
+
return undefined;
|
|
396
|
+
const n = Number(v);
|
|
397
|
+
return /^\d{1,15}$/.test(v) && Number.isSafeInteger(n) ? n : null;
|
|
398
|
+
};
|
|
399
|
+
const after = num("after"), before = num("before"), ev = num("ev"), limit = num("limit");
|
|
400
|
+
const key = p.get("key");
|
|
401
|
+
if (!instance || instance.length > 128 || after === null || before === null || ev === null || limit === null
|
|
402
|
+
|| (limit !== undefined && (limit < 1 || limit > 200)) || (key !== null && !/^\d{1,20}-\d{1,20}$/.test(key))) {
|
|
403
|
+
json(res, 400, { error: "instance (1-128 chars); after, before, ev: offsets; limit 1-200; key: <dev>-<ino>" });
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
if (!ctx.logSteps) {
|
|
407
|
+
json(res, 404, { error: "not available" });
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
ctx.logSteps(instance, {
|
|
411
|
+
...(after !== undefined ? { after } : {}), ...(before !== undefined ? { before } : {}), ...(ev !== undefined ? { ev } : {}),
|
|
412
|
+
...(limit !== undefined ? { limit } : {}), ...(key !== null ? { key } : {}),
|
|
413
|
+
skip: p.get("skip") === "1", watch: p.get("watch") === "1",
|
|
414
|
+
}).then(answer => {
|
|
415
|
+
if (answer === null)
|
|
416
|
+
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
417
|
+
else
|
|
418
|
+
json(res, 200, answer);
|
|
419
|
+
}).catch(err => {
|
|
420
|
+
ctx.logger.error({ err: err.message, instance }, "Log steps read failed");
|
|
421
|
+
json(res, 500, { error: "could not read the steps" });
|
|
422
|
+
});
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
343
425
|
// ── Send message ───────────────────────────────────────
|
|
344
426
|
if (method === "POST" && path === "/ui/send") {
|
|
345
427
|
handleSendMessage(req, res, ctx);
|
|
@@ -360,12 +442,14 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
360
442
|
serveMediaRange(req, res, ctx.webFiles, id, media);
|
|
361
443
|
return true;
|
|
362
444
|
}
|
|
363
|
-
|
|
364
|
-
|
|
445
|
+
// #1937: a file over MEDIA_RANGE_MAX streams from the descriptor its checks were made on — never whole in memory.
|
|
446
|
+
const big = isFileId(id) && (ctx.webFiles?.servedFile?.(id)?.size ?? 0) > MEDIA_RANGE_MAX ? ctx.webFiles?.open?.(id) ?? null : null;
|
|
447
|
+
const got = big ? null : isFileId(id) ? ctx.webFiles?.read(id) : null;
|
|
448
|
+
const file = big?.file ?? got?.file;
|
|
449
|
+
if (!file) {
|
|
365
450
|
json(res, 404, { error: "No such file" });
|
|
366
451
|
return true;
|
|
367
452
|
}
|
|
368
|
-
const { file, bytes } = got;
|
|
369
453
|
const inline = INLINE_MIME.has(file.mime);
|
|
370
454
|
// Only the four image types are shown in the page; anything else is a download, never rendered.
|
|
371
455
|
res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
|
|
@@ -373,8 +457,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
373
457
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
374
458
|
res.setHeader("Cache-Control", "private, no-store");
|
|
375
459
|
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
460
|
+
if (big) {
|
|
461
|
+
streamWhole(res, big);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
376
464
|
res.writeHead(200);
|
|
377
|
-
res.end(bytes);
|
|
465
|
+
res.end(got.bytes);
|
|
378
466
|
return true;
|
|
379
467
|
}
|
|
380
468
|
// ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
|
|
@@ -419,6 +507,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
419
507
|
(ctx.webEmojiList ? ctx.webEmojiList() : Promise.resolve([])).then(answer, () => answer([]));
|
|
420
508
|
return true;
|
|
421
509
|
}
|
|
510
|
+
// #1682: the names of the mention ids a page found in what it rendered (at most MAX_MENTION_IDS, digits only) — only
|
|
511
|
+
// those, so no page (the public link included) ever gets a directory; from memory, never a request.
|
|
512
|
+
if (method === "GET" && path === "/ui/mentions") {
|
|
513
|
+
const parsed = parseMentionQuery(url.searchParams);
|
|
514
|
+
if (!parsed.ok) {
|
|
515
|
+
json(res, 400, { error: parsed.error });
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
let names = { u: {}, r: {}, c: {} };
|
|
519
|
+
try {
|
|
520
|
+
names = ctx.webMentionNames?.(parsed.query) ?? names;
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
ctx.logger.debug({ err: err.message }, "mention names unavailable");
|
|
524
|
+
}
|
|
525
|
+
json(res, 200, names);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
422
528
|
// ── Fleet prompts (C4): the hang / exit / interactive-prompt buttons, answerable here too ──
|
|
423
529
|
if (method === "GET" && path === "/ui/prompts") {
|
|
424
530
|
json(res, 200, { prompts: ctx.listWebPrompts?.() ?? [] });
|
|
@@ -463,6 +569,63 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
463
569
|
});
|
|
464
570
|
return true;
|
|
465
571
|
}
|
|
572
|
+
// #1827: the person's own name — the one agents and chats see for their web messages. Their own profile, so the public
|
|
573
|
+
// link may read and set it too (profile edits are allowed there); a read-only session may as well: it is about them.
|
|
574
|
+
if (path === "/ui/profile" && method === "GET") {
|
|
575
|
+
if (!ctx.webPerson) {
|
|
576
|
+
json(res, 404, { error: "Profiles are not kept on this fleet" });
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
const p = webPersonOr401(req, res, url, ctx);
|
|
580
|
+
if (p)
|
|
581
|
+
json(res, 200, profileBody(p));
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
if (path === "/ui/profile" && method === "POST") {
|
|
585
|
+
if (!ctx.setWebPersonName) {
|
|
586
|
+
json(res, 404, { error: "Profiles are not kept on this fleet" });
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
const set = ctx.setWebPersonName.bind(ctx);
|
|
590
|
+
(async () => {
|
|
591
|
+
let body;
|
|
592
|
+
try {
|
|
593
|
+
body = await parseBody(req);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
600
|
+
return;
|
|
601
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "name")) {
|
|
602
|
+
json(res, 400, { error: "name required (text, or null to clear)" });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
// Whose: asked once the body is in. A session revoked, expired or rotated away meanwhile writes nothing — it is
|
|
606
|
+
// not the operator (#1833 review).
|
|
607
|
+
const who = webAccountOf(req, url, ctx);
|
|
608
|
+
if (!who) {
|
|
609
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const r = set(who.owner, body.name);
|
|
613
|
+
if (!r.ok) {
|
|
614
|
+
json(res, r.status, { error: r.error });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// The person's other devices read it at their next status (the prefs rev moved).
|
|
618
|
+
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
619
|
+
json(res, 200, profileBody(r.person));
|
|
620
|
+
})().catch(err => {
|
|
621
|
+
ctx.logger.error({ err: err.message }, "Saving the web name failed");
|
|
622
|
+
try {
|
|
623
|
+
json(res, 500, { error: "Saving the name failed" });
|
|
624
|
+
}
|
|
625
|
+
catch { /* already answered */ }
|
|
626
|
+
});
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
466
629
|
// #1266: a click on an agent reply's button. The set id travels in the body; the public link may click too (a click
|
|
467
630
|
// is a message, and the public link may send messages).
|
|
468
631
|
if (method === "POST" && path === "/ui/reply-button") {
|
|
@@ -487,8 +650,14 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
487
650
|
json(res, 400, { error: "instance, id and index required" });
|
|
488
651
|
return;
|
|
489
652
|
}
|
|
490
|
-
|
|
491
|
-
|
|
653
|
+
// #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
|
|
654
|
+
// theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
|
|
655
|
+
// gate (a session revoked meanwhile) clicks nothing as anyone.
|
|
656
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
657
|
+
if (!person)
|
|
658
|
+
return;
|
|
659
|
+
const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
|
|
660
|
+
json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
492
661
|
})().catch(err => {
|
|
493
662
|
ctx.logger.error({ err: err.message }, "Web reply-button click failed");
|
|
494
663
|
try {
|
|
@@ -498,6 +667,97 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
498
667
|
});
|
|
499
668
|
return true;
|
|
500
669
|
}
|
|
670
|
+
// #1723: the public link page's preview, asked while it says "Preview is starting…" — its state and, once up, its
|
|
671
|
+
// origin and boot. Public link only: a local page's preview is decided by its own load.
|
|
672
|
+
if (path === "/ui/preview-status" && method === "GET") {
|
|
673
|
+
const gateway = gatewayRequestContext(req);
|
|
674
|
+
if (!gateway || !ctx.publicPreviewForUi) {
|
|
675
|
+
json(res, 404, { error: "not offered here" });
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
const p = ctx.publicPreviewForUi(gateway.exposureId, gateway.expectedOrigin);
|
|
679
|
+
json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
// #1665: the web preferences that follow the person between devices. Read by any page of the panel, the public link
|
|
683
|
+
// included (it reads what is in force); written only by a signed-in admin session or the CLI's header token — never
|
|
684
|
+
// over the public link (absent from its route manifest, and refused here too), never by a read-only session.
|
|
685
|
+
if (path === "/ui/prefs" && method === "GET") {
|
|
686
|
+
if (!ctx.webPrefs) {
|
|
687
|
+
json(res, 404, { error: "Preferences are not kept on this fleet" });
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
json(res, 200, ctx.webPrefs.read(OPERATOR));
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
if (path === "/ui/prefs" && method === "POST") {
|
|
694
|
+
const prefs = ctx.webPrefs;
|
|
695
|
+
if (!prefs) {
|
|
696
|
+
json(res, 404, { error: "Preferences are not kept on this fleet" });
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
if (gatewayRequestContext(req)) {
|
|
700
|
+
json(res, 403, { error: "prefs_not_over_public_link" });
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
// Who may write, asked now and again once the body is in: a session revoked or expired, or a token rotated, while
|
|
704
|
+
// the body was on its way writes nothing and announces nothing.
|
|
705
|
+
const writer = () => {
|
|
706
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
707
|
+
if (verdict.kind !== "allow")
|
|
708
|
+
return 401;
|
|
709
|
+
return verdict.via === "session" && verdict.session?.tier !== "admin" ? 403 : 200;
|
|
710
|
+
};
|
|
711
|
+
const refuse = (code) => json(res, code, code === 401 ? { error: WEB_TOKEN_INVALID_MESSAGE } : { error: "prefs_read_only" });
|
|
712
|
+
const first = writer();
|
|
713
|
+
if (first !== 200) {
|
|
714
|
+
refuse(first);
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
(async () => {
|
|
718
|
+
let body;
|
|
719
|
+
try {
|
|
720
|
+
body = await parseBody(req);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
727
|
+
return;
|
|
728
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
729
|
+
json(res, 400, { error: "set required" });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const parsed = prefs.parsePatch(body.set);
|
|
733
|
+
if (!parsed.ok) {
|
|
734
|
+
json(res, 400, { error: parsed.error });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const now = writer();
|
|
738
|
+
if (now !== 200) {
|
|
739
|
+
refuse(now);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const onlyIfUnset = body.onlyIfUnset === true;
|
|
743
|
+
const written = prefs.write(OPERATOR, parsed.patch, { onlyIfUnset });
|
|
744
|
+
if (!written.ok) {
|
|
745
|
+
json(res, written.status, { error: written.error });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Another device sees the new rev in the next status frame — at once on the live stream.
|
|
749
|
+
if (written.changed.length)
|
|
750
|
+
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
751
|
+
json(res, 200, { ...written.view, changed: written.changed });
|
|
752
|
+
})().catch(err => {
|
|
753
|
+
ctx.logger.error({ err: err.message }, "Saving web preferences failed");
|
|
754
|
+
try {
|
|
755
|
+
json(res, 500, { error: "Saving preferences failed" });
|
|
756
|
+
}
|
|
757
|
+
catch { /* already answered */ }
|
|
758
|
+
});
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
501
761
|
// #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
|
|
502
762
|
// token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
|
|
503
763
|
if (method === "POST" && path === "/ui/needs/ack") {
|
|
@@ -573,7 +833,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
573
833
|
json(res, 400, { error: "invalid confirmation" });
|
|
574
834
|
return;
|
|
575
835
|
}
|
|
576
|
-
const
|
|
836
|
+
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
837
|
+
if (!person)
|
|
838
|
+
return;
|
|
839
|
+
const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
|
|
577
840
|
json(res, r.status, r.body);
|
|
578
841
|
})().catch(err => {
|
|
579
842
|
ctx.logger.error({ err: err.message }, "Web command failed");
|
|
@@ -607,7 +870,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
607
870
|
json(res, 409, { error: `${name} is not running` });
|
|
608
871
|
return true;
|
|
609
872
|
}
|
|
610
|
-
ctx.eventLog?.logActivity("cancel",
|
|
873
|
+
ctx.eventLog?.logActivity("cancel", webPersonOf(req, url, ctx)?.name ?? WEB_NAME_FALLBACK, "stop the current reply", name);
|
|
611
874
|
json(res, 200, { cancelled: name });
|
|
612
875
|
return true;
|
|
613
876
|
}
|
|
@@ -640,13 +903,18 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
640
903
|
}
|
|
641
904
|
const topicMode = ctx.fleetConfig?.channel?.mode === "topic";
|
|
642
905
|
(async () => {
|
|
906
|
+
let held = false;
|
|
643
907
|
try {
|
|
644
|
-
await ctx.startInstance(name, config, topicMode ?? false);
|
|
908
|
+
await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false));
|
|
645
909
|
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
646
|
-
|
|
910
|
+
if (!held)
|
|
911
|
+
json(res, 200, { started: name });
|
|
647
912
|
}
|
|
648
913
|
catch (err) {
|
|
649
|
-
|
|
914
|
+
if (!held)
|
|
915
|
+
json(res, 500, { error: err.message });
|
|
916
|
+
else
|
|
917
|
+
ctx.logger.error({ err, name }, "Held instance action failed");
|
|
650
918
|
}
|
|
651
919
|
})();
|
|
652
920
|
return true;
|
|
@@ -759,14 +1027,19 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
759
1027
|
if (method === "POST" && restartMatch) {
|
|
760
1028
|
const name = decodeURIComponent(restartMatch[1]);
|
|
761
1029
|
(async () => {
|
|
1030
|
+
let held = false;
|
|
762
1031
|
try {
|
|
763
|
-
await ctx.restartSingleInstance(name, { explicit: true });
|
|
1032
|
+
await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.restartSingleInstance(name, { explicit: true }));
|
|
764
1033
|
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
765
|
-
|
|
1034
|
+
if (!held)
|
|
1035
|
+
json(res, 200, { restarted: name });
|
|
766
1036
|
}
|
|
767
1037
|
catch (err) {
|
|
768
1038
|
const status = err.message.includes("not found") ? 404 : 500;
|
|
769
|
-
|
|
1039
|
+
if (!held)
|
|
1040
|
+
json(res, status, { error: err.message });
|
|
1041
|
+
else
|
|
1042
|
+
ctx.logger.error({ err, name }, "Held instance action failed");
|
|
770
1043
|
}
|
|
771
1044
|
})();
|
|
772
1045
|
return true;
|
|
@@ -828,12 +1101,15 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
828
1101
|
const parsed = parseOrReject(TaskCreateSchema, body, res);
|
|
829
1102
|
if (!parsed)
|
|
830
1103
|
return;
|
|
1104
|
+
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
1105
|
+
if (!person)
|
|
1106
|
+
return;
|
|
831
1107
|
const task = ctx.scheduler.db.createTask({
|
|
832
1108
|
title: parsed.title,
|
|
833
1109
|
description: parsed.description,
|
|
834
1110
|
priority: parsed.priority,
|
|
835
1111
|
assignee: parsed.assignee,
|
|
836
|
-
created_by:
|
|
1112
|
+
created_by: person.name, // #1827
|
|
837
1113
|
});
|
|
838
1114
|
json(res, 200, task);
|
|
839
1115
|
}
|
|
@@ -860,7 +1136,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
860
1136
|
return;
|
|
861
1137
|
let result;
|
|
862
1138
|
if (parsed.action === "claim") {
|
|
863
|
-
|
|
1139
|
+
const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
|
|
1140
|
+
if (!parsed.assignee && !person)
|
|
1141
|
+
return;
|
|
1142
|
+
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
|
|
864
1143
|
}
|
|
865
1144
|
else if (parsed.action === "complete") {
|
|
866
1145
|
result = ctx.scheduler.db.completeTask(id, parsed.result);
|
|
@@ -884,10 +1163,28 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
884
1163
|
json(res, 200, { schedules: [] });
|
|
885
1164
|
return true;
|
|
886
1165
|
}
|
|
887
|
-
|
|
1166
|
+
// #1724: each row with its next run and one-shot time in the fleet's zone — worked out here, not on the page.
|
|
1167
|
+
const scheduler = ctx.scheduler;
|
|
1168
|
+
const now = Date.now();
|
|
1169
|
+
json(res, 200, {
|
|
1170
|
+
schedules: scheduler.list().map((s) => scheduleView(s, scheduler.getRetry?.(s.id) ?? null, now)),
|
|
1171
|
+
// The zone a new schedule (and a "Once" date and time typed without one) is read in.
|
|
1172
|
+
default_timezone: ctx.fleetConfig?.defaults?.scheduler?.default_timezone
|
|
1173
|
+
?? DEFAULT_SCHEDULER_CONFIG.default_timezone,
|
|
1174
|
+
});
|
|
888
1175
|
return true;
|
|
889
1176
|
}
|
|
1177
|
+
// #1724: who may write a schedule, asked again once the body is in (a session revoked meanwhile, a non-admin tier).
|
|
1178
|
+
const scheduleWriterGone = () => {
|
|
1179
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1180
|
+
return verdict.kind !== "allow" || (verdict.via === "session" && verdict.session?.tier !== "admin");
|
|
1181
|
+
};
|
|
890
1182
|
if (method === "POST" && path === "/ui/schedules") {
|
|
1183
|
+
// #1724: schedules are not created, edited or deleted over the public link (absent from its manifest too, #1689).
|
|
1184
|
+
if (gatewayRequestContext(req)) {
|
|
1185
|
+
json(res, 403, { error: "schedule_not_over_public_link" });
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
891
1188
|
if (!ctx.scheduler) {
|
|
892
1189
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
893
1190
|
return true;
|
|
@@ -897,10 +1194,23 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
897
1194
|
const body = await parseBody(req);
|
|
898
1195
|
if (!permitWebContinuation(req, res, ctx))
|
|
899
1196
|
return;
|
|
900
|
-
|
|
1197
|
+
if (scheduleWriterGone()) {
|
|
1198
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
// #1724: the create_schedule tool's own schema — cron or at (exactly one) — plus a target, which the web names.
|
|
1202
|
+
const parsed = parseOrReject(CreateScheduleArgs, body, res);
|
|
901
1203
|
if (!parsed)
|
|
902
1204
|
return;
|
|
903
|
-
|
|
1205
|
+
if (typeof parsed.target !== "string" || !parsed.target) {
|
|
1206
|
+
json(res, 400, { error: "target: an instance is required" });
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const schedule = ctx.scheduler.create({
|
|
1210
|
+
cron: parsed.cron, at: parsed.at, message: parsed.message, target: parsed.target, label: parsed.label, timezone: parsed.timezone,
|
|
1211
|
+
...(parsed.silent !== undefined ? { silent: parsed.silent } : {}),
|
|
1212
|
+
source: "web", reply_chat_id: "", reply_thread_id: null,
|
|
1213
|
+
});
|
|
904
1214
|
json(res, 200, schedule);
|
|
905
1215
|
}
|
|
906
1216
|
catch (err) {
|
|
@@ -910,7 +1220,48 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
910
1220
|
return true;
|
|
911
1221
|
}
|
|
912
1222
|
const schedDelMatch = path.match(/^\/ui\/schedules\/(.+)$/);
|
|
1223
|
+
// #1724: Edit — the update_schedule tool's validation (UpdateScheduleArgs) and its call (scheduler.update): cron and at
|
|
1224
|
+
// clear each other there; silent is not touched.
|
|
1225
|
+
if (method === "PATCH" && schedDelMatch) {
|
|
1226
|
+
if (gatewayRequestContext(req)) {
|
|
1227
|
+
json(res, 403, { error: "schedule_not_over_public_link" });
|
|
1228
|
+
return true;
|
|
1229
|
+
}
|
|
1230
|
+
if (!ctx.scheduler || typeof ctx.scheduler.update !== "function") {
|
|
1231
|
+
json(res, 500, { error: "Scheduler not initialized" });
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
const id = decodeURIComponent(schedDelMatch[1]);
|
|
1235
|
+
(async () => {
|
|
1236
|
+
try {
|
|
1237
|
+
const body = await parseBody(req);
|
|
1238
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1239
|
+
return;
|
|
1240
|
+
if (scheduleWriterGone()) {
|
|
1241
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || Object.hasOwn(body, "id")) {
|
|
1245
|
+
json(res, 400, { error: "expected the fields to change (the id is the path's)" });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const parsed = parseOrReject(UpdateScheduleArgs, { ...body, id }, res);
|
|
1249
|
+
if (!parsed)
|
|
1250
|
+
return;
|
|
1251
|
+
const { id: _id, ...changes } = parsed;
|
|
1252
|
+
json(res, 200, ctx.scheduler.update(id, changes));
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
json(res, 400, { error: err.message });
|
|
1256
|
+
}
|
|
1257
|
+
})();
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
913
1260
|
if (method === "DELETE" && schedDelMatch) {
|
|
1261
|
+
if (gatewayRequestContext(req)) {
|
|
1262
|
+
json(res, 403, { error: "schedule_not_over_public_link" });
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
914
1265
|
if (!ctx.scheduler) {
|
|
915
1266
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
916
1267
|
return true;
|
|
@@ -947,26 +1298,82 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
947
1298
|
return true;
|
|
948
1299
|
}
|
|
949
1300
|
if (method === "POST" && path === "/ui/teams") {
|
|
1301
|
+
// #1689: not over the public link (absent from its manifest too), like editing and deleting a team.
|
|
1302
|
+
if (gatewayRequestContext(req)) {
|
|
1303
|
+
json(res, 403, { error: "teams_not_over_public_link" });
|
|
1304
|
+
return true;
|
|
1305
|
+
}
|
|
950
1306
|
(async () => {
|
|
951
1307
|
try {
|
|
952
1308
|
const body = await parseBody(req);
|
|
953
1309
|
if (!permitWebContinuation(req, res, ctx))
|
|
954
1310
|
return;
|
|
1311
|
+
// #1692: who may write, asked again once the body is in (as the edit does).
|
|
1312
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1313
|
+
if (verdict.kind !== "allow" || (verdict.via === "session" && verdict.session?.tier !== "admin")) {
|
|
1314
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
955
1317
|
const parsed = parseOrReject(TeamCreateSchema, body, res);
|
|
956
1318
|
if (!parsed)
|
|
957
1319
|
return;
|
|
958
|
-
|
|
959
|
-
|
|
1320
|
+
// #1692: the create_team path — members checked, an existing team never overwritten, a failed save undone.
|
|
1321
|
+
const r = commitTeamCreate(ctx.fleetConfig, parsed, () => ctx.saveFleetConfig());
|
|
1322
|
+
if (!r.ok) {
|
|
1323
|
+
json(res, r.status, { error: r.error });
|
|
960
1324
|
return;
|
|
961
1325
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1326
|
+
json(res, 200, r.result);
|
|
1327
|
+
}
|
|
1328
|
+
catch (err) {
|
|
1329
|
+
json(res, 400, { error: err.message });
|
|
1330
|
+
}
|
|
1331
|
+
})();
|
|
1332
|
+
return true;
|
|
1333
|
+
}
|
|
1334
|
+
// #1689: edit a team — the same update as the update_team tool (applyTeamUpdate), from the members the page sends:
|
|
1335
|
+
// what to add and remove is worked out against the team as it is now. Never over the public link.
|
|
1336
|
+
const teamEditMatch = path.match(/^\/ui\/teams\/(.+)$/);
|
|
1337
|
+
if ((method === "PATCH" || method === "PUT") && teamEditMatch) {
|
|
1338
|
+
if (gatewayRequestContext(req)) {
|
|
1339
|
+
json(res, 403, { error: "teams_not_over_public_link" });
|
|
1340
|
+
return true;
|
|
1341
|
+
}
|
|
1342
|
+
const name = decodeURIComponent(teamEditMatch[1]);
|
|
1343
|
+
// Who may write, asked again once the body is in (#1693 review): a session revoked or a token rotated meanwhile
|
|
1344
|
+
// writes nothing.
|
|
1345
|
+
const writerNow = () => {
|
|
1346
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1347
|
+
return verdict.kind === "allow" && !(verdict.via === "session" && verdict.session?.tier !== "admin");
|
|
1348
|
+
};
|
|
1349
|
+
(async () => {
|
|
1350
|
+
try {
|
|
1351
|
+
const body = await parseBody(req);
|
|
1352
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1353
|
+
return;
|
|
1354
|
+
if (!writerNow()) {
|
|
1355
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const parsed = parseOrReject(TeamEditSchema, body, res);
|
|
1359
|
+
if (!parsed)
|
|
1360
|
+
return;
|
|
1361
|
+
const teams = ctx.fleetConfig?.teams;
|
|
1362
|
+
const current = teams && Object.hasOwn(teams, name) ? teams[name].members : null;
|
|
1363
|
+
if (!current) {
|
|
1364
|
+
json(res, 404, { error: `Team not found: ${name}` });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const want = [...new Set(parsed.members)];
|
|
1368
|
+
// The update and its save as one: a failed save leaves the team as it was (500).
|
|
1369
|
+
const r = commitTeamUpdate(ctx.fleetConfig, {
|
|
1370
|
+
name, add: want.filter(m => !current.includes(m)), remove: current.filter(m => !want.includes(m)), description: parsed.description,
|
|
1371
|
+
}, () => ctx.saveFleetConfig());
|
|
1372
|
+
if (!r.ok) {
|
|
1373
|
+
json(res, r.status, { error: r.error });
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
json(res, 200, { updated: r.result });
|
|
970
1377
|
}
|
|
971
1378
|
catch (err) {
|
|
972
1379
|
json(res, 400, { error: err.message });
|
|
@@ -976,8 +1383,13 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
976
1383
|
}
|
|
977
1384
|
const teamDelMatch = path.match(/^\/ui\/teams\/(.+)$/);
|
|
978
1385
|
if (method === "DELETE" && teamDelMatch) {
|
|
1386
|
+
if (gatewayRequestContext(req)) {
|
|
1387
|
+
json(res, 403, { error: "teams_not_over_public_link" });
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
979
1390
|
const name = decodeURIComponent(teamDelMatch[1]);
|
|
980
|
-
|
|
1391
|
+
// Own keys (#1689): a team is one fleet.yaml defines — "__proto__" or "constructor" is not found.
|
|
1392
|
+
if (!ctx.fleetConfig?.teams || !Object.hasOwn(ctx.fleetConfig.teams, name)) {
|
|
981
1393
|
json(res, 404, { error: `Team not found: ${name}` });
|
|
982
1394
|
return true;
|
|
983
1395
|
}
|
|
@@ -1116,6 +1528,14 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1116
1528
|
return;
|
|
1117
1529
|
}
|
|
1118
1530
|
const message = typed;
|
|
1531
|
+
// #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
|
|
1532
|
+
// body is in: a session revoked meanwhile sends nothing (not as the operator).
|
|
1533
|
+
const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
|
|
1534
|
+
if (!person) {
|
|
1535
|
+
ctx.webFiles?.release(files);
|
|
1536
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1119
1539
|
const ts = new Date().toISOString();
|
|
1120
1540
|
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
1121
1541
|
const messageId = newWebMessageId();
|
|
@@ -1134,7 +1554,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1134
1554
|
// opt-in resolved at send time inside sendClassicWebEcho.
|
|
1135
1555
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
1136
1556
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1137
|
-
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(
|
|
1557
|
+
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
|
|
1138
1558
|
}) : undefined;
|
|
1139
1559
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1140
1560
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -1142,18 +1562,21 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1142
1562
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
1143
1563
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
1144
1564
|
return;
|
|
1145
|
-
return syncAdapter.sendText(String(groupId), formatWebChannelEcho(
|
|
1565
|
+
return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
|
|
1146
1566
|
}) : undefined;
|
|
1147
1567
|
let delivered;
|
|
1148
1568
|
try {
|
|
1149
|
-
|
|
1569
|
+
const deliver = (name, payload) => ctx.deliverHumanToInstance
|
|
1570
|
+
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
|
|
1571
|
+
: ctx.deliverToInstance(name, payload);
|
|
1572
|
+
delivered = await deliver(instance, {
|
|
1150
1573
|
type: "fleet_inbound",
|
|
1151
1574
|
content: delivery.text,
|
|
1152
1575
|
targetSession: instance,
|
|
1153
1576
|
meta: {
|
|
1154
1577
|
chat_id: groupId ? String(groupId) : "",
|
|
1155
1578
|
message_id: messageId,
|
|
1156
|
-
user:
|
|
1579
|
+
user: person.name, user_id: person.userId,
|
|
1157
1580
|
ts,
|
|
1158
1581
|
thread_id: topicId != null ? String(topicId) : "",
|
|
1159
1582
|
source: "web",
|
|
@@ -1183,10 +1606,17 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1183
1606
|
}
|
|
1184
1607
|
settleEcho?.(true);
|
|
1185
1608
|
settleClassicEcho?.(true);
|
|
1609
|
+
// #1666: the same cancel/progress bubble a platform inbound starts, in the topic the echo went to — a second turn
|
|
1610
|
+
// on the echo's lane, so the topic reads "echo, then bubble" and a cancel in between drops it (the lane's epoch).
|
|
1611
|
+
// Not a copy (#1668): a slow bubble never drops the next message's echo, and a slow echo never drops the bubble.
|
|
1612
|
+
if (settleEcho && ctx.startWebChatProgress) {
|
|
1613
|
+
const startProgress = ctx.startWebChatProgress.bind(ctx);
|
|
1614
|
+
ctx.reserveWebChannelEcho?.(instance, () => startProgress(instance), { copy: false })(true);
|
|
1615
|
+
}
|
|
1186
1616
|
ctx.webFiles?.commit(files);
|
|
1187
|
-
ctx.lastInboundUser.set(instance,
|
|
1188
|
-
ctx.eventLog?.logActivity("message",
|
|
1189
|
-
ctx.emitSseEvent("message", { instance, sender:
|
|
1617
|
+
ctx.lastInboundUser.set(instance, person.name);
|
|
1618
|
+
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
1619
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
|
|
1190
1620
|
json(res, 200, { sent: true, messageId });
|
|
1191
1621
|
}
|
|
1192
1622
|
catch {
|
|
@@ -1198,6 +1628,11 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1198
1628
|
* One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
|
|
1199
1629
|
* read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
|
|
1200
1630
|
* workspace inbox under a name chosen here; answered with the id the message will name.
|
|
1631
|
+
*
|
|
1632
|
+
* #1937: streamed to a part file in that inbox (web-pending-….part) as it arrives — never held whole in memory — and
|
|
1633
|
+
* counted by the bytes that came, whatever Content-Length said. The limits are the ones in force for this request
|
|
1634
|
+
* (uploadLimits(): the config, and the public link's own cap). An upload that goes UPLOAD_STALL_MS without a byte, or
|
|
1635
|
+
* runs past UPLOAD_MAX_MS, is dropped; any upload that does not end in a stored file takes its part file with it.
|
|
1201
1636
|
*/
|
|
1202
1637
|
function handleUpload(req, res, url, ctx) {
|
|
1203
1638
|
const instance = url.searchParams.get("instance") ?? "";
|
|
@@ -1209,62 +1644,121 @@ function handleUpload(req, res, url, ctx) {
|
|
|
1209
1644
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1210
1645
|
return;
|
|
1211
1646
|
}
|
|
1647
|
+
const limits = uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined });
|
|
1648
|
+
const tooLarge = () => json(res, 413, { error: fileTooLargeText(limits), max_bytes: limits.maxFileBytes, ...(limits.publicLinkCapped ? { public_link: true } : {}) });
|
|
1212
1649
|
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
1213
|
-
if (Number.isFinite(declared) && declared >
|
|
1214
|
-
|
|
1650
|
+
if (Number.isFinite(declared) && declared > limits.maxFileBytes) {
|
|
1651
|
+
tooLarge();
|
|
1652
|
+
req.resume();
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
let part, fd, discard;
|
|
1656
|
+
try {
|
|
1657
|
+
({ path: part, fd, discard } = createPartFile(join(getAgendHome(), "workspaces", instance, "inbox")));
|
|
1658
|
+
}
|
|
1659
|
+
catch (err) {
|
|
1660
|
+
ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
|
|
1661
|
+
json(res, 500, { error: "the file could not be stored" });
|
|
1215
1662
|
req.resume();
|
|
1216
1663
|
return;
|
|
1217
1664
|
}
|
|
1218
|
-
const
|
|
1665
|
+
const out = createWriteStream(part, { fd, autoClose: true });
|
|
1666
|
+
const sniffer = new UploadSniffer();
|
|
1219
1667
|
let size = 0;
|
|
1220
|
-
let
|
|
1668
|
+
let over = false; // ended without a stored file: no further write, and the part file goes
|
|
1669
|
+
let stall;
|
|
1670
|
+
const clocks = () => { clearTimeout(stall); clearTimeout(deadline); };
|
|
1671
|
+
/** Stop here: answer (when the client is still there), remove the part file, and stop a body still arriving. */
|
|
1672
|
+
const drop = (answer) => {
|
|
1673
|
+
if (over)
|
|
1674
|
+
return;
|
|
1675
|
+
over = true;
|
|
1676
|
+
clocks();
|
|
1677
|
+
answer?.();
|
|
1678
|
+
out.destroy();
|
|
1679
|
+
discard();
|
|
1680
|
+
if (!req.complete)
|
|
1681
|
+
req.destroy();
|
|
1682
|
+
};
|
|
1683
|
+
const armStall = () => { clearTimeout(stall); stall = setTimeout(() => drop(() => json(res, 408, { error: "the upload stalled — attach the file again" })), UPLOAD_STALL_MS); stall.unref?.(); };
|
|
1684
|
+
const deadline = setTimeout(() => drop(() => json(res, 408, { error: "the upload took too long — attach the file again" })), UPLOAD_MAX_MS);
|
|
1685
|
+
deadline.unref?.();
|
|
1686
|
+
armStall();
|
|
1687
|
+
out.on("error", (err) => { ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored"); drop(() => json(res, 500, { error: "the file could not be stored" })); });
|
|
1688
|
+
out.on("drain", () => { if (!over)
|
|
1689
|
+
req.resume?.(); });
|
|
1221
1690
|
req.on("data", (c) => {
|
|
1222
|
-
if (
|
|
1691
|
+
if (over)
|
|
1223
1692
|
return;
|
|
1224
1693
|
size += c.length;
|
|
1225
|
-
if (size >
|
|
1226
|
-
|
|
1227
|
-
json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
|
|
1228
|
-
req.destroy();
|
|
1694
|
+
if (size > limits.maxFileBytes) {
|
|
1695
|
+
drop(tooLarge);
|
|
1229
1696
|
return;
|
|
1230
1697
|
}
|
|
1231
|
-
|
|
1698
|
+
armStall();
|
|
1699
|
+
sniffer.push(c);
|
|
1700
|
+
if (!out.write(c))
|
|
1701
|
+
req.pause?.();
|
|
1232
1702
|
});
|
|
1233
|
-
req.on("error", () =>
|
|
1703
|
+
req.on("error", () => drop()); // the client went away
|
|
1704
|
+
req.on("close", () => { if (!over && req.complete === false)
|
|
1705
|
+
drop(); });
|
|
1706
|
+
// The instance is checked again now the whole body is here: it may have been deleted (and its files
|
|
1707
|
+
// forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
|
|
1708
|
+
// Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
|
|
1709
|
+
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
|
|
1710
|
+
const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
|
|
1711
|
+
const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
|
|
1234
1712
|
req.on("end", () => {
|
|
1235
|
-
if (
|
|
1713
|
+
if (over)
|
|
1236
1714
|
return;
|
|
1237
|
-
|
|
1715
|
+
clocks();
|
|
1716
|
+
if (!permitWebContinuation(req, res, ctx)) {
|
|
1717
|
+
drop();
|
|
1238
1718
|
return;
|
|
1239
|
-
|
|
1719
|
+
}
|
|
1240
1720
|
const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
|
|
1241
|
-
const type =
|
|
1721
|
+
const type = sniffer.result(name);
|
|
1242
1722
|
if (!type) {
|
|
1243
|
-
json(res, 415, { error: "
|
|
1723
|
+
drop(() => json(res, 415, { error: "the file is empty" }));
|
|
1244
1724
|
return;
|
|
1245
1725
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
// Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
|
|
1249
|
-
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
|
|
1250
|
-
const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
|
|
1251
|
-
if (!ctx.instanceIpcClients.has(instance) || !registered) {
|
|
1252
|
-
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1726
|
+
if (!deliverable()) {
|
|
1727
|
+
gone();
|
|
1253
1728
|
return;
|
|
1254
1729
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1730
|
+
out.end(() => {
|
|
1731
|
+
if (over)
|
|
1732
|
+
return;
|
|
1733
|
+
// The write's finish is another wait (Prism #1941 P2-1): the session or the public link may have ended in it.
|
|
1734
|
+
if (!permitWebContinuation(req, res, ctx)) {
|
|
1735
|
+
drop();
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (!deliverable()) {
|
|
1739
|
+
gone();
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
over = true;
|
|
1743
|
+
try {
|
|
1744
|
+
const entry = ctx.webFiles.storeUploadedFile({ instance, partPath: part, name, type });
|
|
1745
|
+
json(res, 200, publicAttachment(entry));
|
|
1746
|
+
}
|
|
1747
|
+
catch (err) {
|
|
1748
|
+
discard();
|
|
1749
|
+
ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
|
|
1750
|
+
json(res, 500, { error: "the file could not be stored" });
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1263
1753
|
});
|
|
1264
1754
|
}
|
|
1265
1755
|
function shellPreview(req, ctx, mode) {
|
|
1266
|
-
if (mode !== "full"
|
|
1756
|
+
if (mode !== "full")
|
|
1267
1757
|
return null;
|
|
1758
|
+
// #1723: a page of the public link asks its own exposure's preview — never the local listener's Host-based choice.
|
|
1759
|
+
const gateway = gatewayRequestContext(req);
|
|
1760
|
+
if (gateway)
|
|
1761
|
+
return ctx.publicPreviewForUi?.(gateway.exposureId, gateway.expectedOrigin) ?? null;
|
|
1268
1762
|
return ctx.previewForUi?.(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) ?? null;
|
|
1269
1763
|
}
|
|
1270
1764
|
/**
|
|
@@ -1273,6 +1767,15 @@ function shellPreview(req, ctx, mode) {
|
|
|
1273
1767
|
* No Range, several, or one not in that form → 200 with the whole file, as every file is served (the Download link).
|
|
1274
1768
|
* Inline, nosniff, no caching, and the route's own sandbox policy, as for every file.
|
|
1275
1769
|
*/
|
|
1770
|
+
/** #1937: a whole file (headers already set) streamed from the descriptor its checks were made on: 200, its length. */
|
|
1771
|
+
function streamWhole(res, opened) {
|
|
1772
|
+
res.setHeader("Content-Length", String(opened.file.size));
|
|
1773
|
+
res.writeHead(200);
|
|
1774
|
+
const stream = createReadStream("", { fd: opened.fd, start: 0, end: Math.max(0, opened.file.size - 1), autoClose: true });
|
|
1775
|
+
stream.on("error", () => res.destroy());
|
|
1776
|
+
res.on("close", () => stream.destroy());
|
|
1777
|
+
stream.pipe(res);
|
|
1778
|
+
}
|
|
1276
1779
|
function serveMediaRange(req, res, files, id, file) {
|
|
1277
1780
|
const total = file.size;
|
|
1278
1781
|
const header = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
|
|
@@ -1296,23 +1799,52 @@ function serveMediaRange(req, res, files, id, file) {
|
|
|
1296
1799
|
}
|
|
1297
1800
|
}
|
|
1298
1801
|
end = ranged ? Math.min(end, total - 1, start + MEDIA_RANGE_MAX - 1) : total - 1;
|
|
1802
|
+
const mediaHeaders = () => {
|
|
1803
|
+
res.setHeader("Content-Type", file.mime);
|
|
1804
|
+
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
|
|
1805
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
1806
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1807
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
1808
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
1809
|
+
};
|
|
1810
|
+
// #1937 (Prism #1941 P3-2): the whole of a file over MEDIA_RANGE_MAX (no Range asked) streams, as /ui/file's do.
|
|
1811
|
+
if (!ranged && total > MEDIA_RANGE_MAX) {
|
|
1812
|
+
const opened = files.open(id);
|
|
1813
|
+
if (!opened) {
|
|
1814
|
+
json(res, 404, { error: "No such file" });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
mediaHeaders();
|
|
1818
|
+
streamWhole(res, opened);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1299
1821
|
const got = files.readSlice(id, start, end);
|
|
1300
1822
|
if (!got || (!ranged && got.bytes.length !== total)) {
|
|
1301
1823
|
json(res, 404, { error: "No such file" });
|
|
1302
1824
|
return;
|
|
1303
1825
|
}
|
|
1304
|
-
|
|
1305
|
-
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
|
|
1306
|
-
res.setHeader("Accept-Ranges", "bytes");
|
|
1826
|
+
mediaHeaders();
|
|
1307
1827
|
if (ranged)
|
|
1308
1828
|
res.setHeader("Content-Range", `bytes ${got.start}-${got.end}/${total}`);
|
|
1309
1829
|
res.setHeader("Content-Length", String(got.bytes.length));
|
|
1310
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1311
|
-
res.setHeader("Cache-Control", "private, no-store");
|
|
1312
|
-
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
1313
1830
|
res.writeHead(ranged ? 206 : 200);
|
|
1314
1831
|
res.end(got.bytes);
|
|
1315
1832
|
}
|
|
1833
|
+
/**
|
|
1834
|
+
* #1665: `<html>` carrying the host's preferences (data-prefs: `{"rev":n,"values":{…}}`, attribute-escaped). theme.js (a
|
|
1835
|
+
* blocking script in <head>) and the app's dictionary take the theme and language from it over the browser's cache,
|
|
1836
|
+
* and app-prefs.js copies every value into that cache before any panel reads it — so no device paints a stale theme
|
|
1837
|
+
* or replays a tour already seen, a new device included. Only for the signed-in app (the public link reads what is in
|
|
1838
|
+
* force too); the anonymous View reader and the sign-in page keep the browser's own.
|
|
1839
|
+
*/
|
|
1840
|
+
function shellHtmlTag(ctx, mode) {
|
|
1841
|
+
if (mode !== "full" || !ctx.webPrefs)
|
|
1842
|
+
return '<html lang="en">';
|
|
1843
|
+
const view = ctx.webPrefs.read(OPERATOR);
|
|
1844
|
+
const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
|
|
1845
|
+
const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1846
|
+
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify({ rev: view.rev, values: view.values }))}">`;
|
|
1847
|
+
}
|
|
1316
1848
|
function shellBodyTag(req, ctx, mode) {
|
|
1317
1849
|
const p = shellPreview(req, ctx, mode);
|
|
1318
1850
|
const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
@@ -1320,8 +1852,17 @@ function shellBodyTag(req, ctx, mode) {
|
|
|
1320
1852
|
+ ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
|
|
1321
1853
|
// #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
|
|
1322
1854
|
+ ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
|
|
1855
|
+
// #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
|
|
1856
|
+
+ `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
|
|
1323
1857
|
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔.
|
|
1324
1858
|
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
|
|
1859
|
+
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
1860
|
+
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
1861
|
+
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
1862
|
+
// checks before sending a file, and the numbers its words say. The server checks again on every upload.
|
|
1863
|
+
+ ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
|
|
1864
|
+
// #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
|
|
1865
|
+
+ ` data-fleet-tz="${attr(resolveTz())}"`
|
|
1325
1866
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|
|
1326
1867
|
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
|
|
1327
1868
|
}
|
|
@@ -1336,7 +1877,10 @@ export function serveAppShell(req, res, ctx, mode) {
|
|
|
1336
1877
|
const own = mode === "full" && !gatewayRequestContext(req)
|
|
1337
1878
|
? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) : null;
|
|
1338
1879
|
const mediaSrc = own ? `${own}/ui/file/` : "'none'";
|
|
1339
|
-
|
|
1880
|
+
// A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
|
|
1881
|
+
// the page may load); every other page frames exactly the origin this load chose (#1723).
|
|
1882
|
+
const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
|
|
1883
|
+
sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode)).replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
1340
1884
|
}
|
|
1341
1885
|
catch {
|
|
1342
1886
|
json(res, 500, { error: "app.html not found" });
|