@songsid/agend 2.1.12-beta.9 → 2.2.0-alpha.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 +2 -2
- package/README.zh-TW.md +2 -2
- package/dist/auth/one-time-code.d.ts +19 -0
- package/dist/auth/one-time-code.js +49 -0
- package/dist/auth/one-time-code.js.map +1 -0
- package/dist/auth-api.d.ts +52 -0
- package/dist/auth-api.js +346 -0
- package/dist/auth-api.js.map +1 -0
- package/dist/backend/claude-code.d.ts +4 -0
- package/dist/backend/claude-code.js +97 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +59 -30
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro-agent.d.ts +66 -0
- package/dist/backend/kiro-agent.js +302 -0
- package/dist/backend/kiro-agent.js.map +1 -0
- package/dist/backend/kiro-identity.d.ts +80 -0
- package/dist/backend/kiro-identity.js +537 -0
- package/dist/backend/kiro-identity.js.map +1 -0
- package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
- package/dist/backend/kiro-v2-store-worker.js +87 -0
- package/dist/backend/kiro-v2-store-worker.js.map +1 -0
- package/dist/backend/kiro-v2-store.d.ts +54 -0
- package/dist/backend/kiro-v2-store.js +131 -0
- package/dist/backend/kiro-v2-store.js.map +1 -0
- package/dist/backend/kiro-v3-identity.d.ts +5 -0
- package/dist/backend/kiro-v3-identity.js +22 -0
- package/dist/backend/kiro-v3-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +83 -7
- package/dist/backend/kiro.js +418 -91
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +45 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +1 -0
- package/dist/channel/adapters/discord.js +29 -10
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +1 -0
- package/dist/channel/adapters/telegram.js +13 -1
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +9 -1
- package/dist/classic-channel-manager.d.ts +18 -10
- package/dist/classic-channel-manager.js +45 -18
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +225 -127
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +5 -3
- package/dist/command-table.js +29 -27
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +46 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/connection-secrets.d.ts +6 -0
- package/dist/connection-secrets.js +4 -0
- package/dist/connection-secrets.js.map +1 -1
- package/dist/daemon.d.ts +89 -4
- package/dist/daemon.js +661 -77
- package/dist/daemon.js.map +1 -1
- package/dist/darwin-memory.d.ts +6 -1
- package/dist/darwin-memory.js +17 -6
- package/dist/darwin-memory.js.map +1 -1
- package/dist/delivery-consumed-watch.d.ts +85 -0
- package/dist/delivery-consumed-watch.js +244 -0
- package/dist/delivery-consumed-watch.js.map +1 -0
- package/dist/delivery-outbox.d.ts +61 -0
- package/dist/delivery-outbox.js +120 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +14 -5
- package/dist/delivery-reconciliation.js +27 -4
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/delivery-reconciliation.test.js +35 -1
- package/dist/delivery-reconciliation.test.js.map +1 -1
- package/dist/event-loop-watch.d.ts +28 -0
- package/dist/event-loop-watch.js +75 -3
- package/dist/event-loop-watch.js.map +1 -1
- package/dist/fleet-context.d.ts +22 -2
- package/dist/fleet-level-config.d.ts +2 -2
- package/dist/fleet-level-config.js +6 -1
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +293 -12
- package/dist/fleet-manager.js +2364 -449
- package/dist/fleet-manager.js.map +1 -1
- package/dist/host-memory.d.ts +4 -0
- package/dist/host-memory.js.map +1 -1
- package/dist/instance-config-impact.js +5 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +10 -5
- package/dist/instance-lifecycle.js +162 -53
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +150 -16
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +2 -0
- package/dist/login-controller.js +17 -5
- package/dist/login-controller.js.map +1 -1
- package/dist/memory-pressure.d.ts +7 -8
- package/dist/memory-pressure.js +76 -15
- package/dist/memory-pressure.js.map +1 -1
- package/dist/monotonic-budget.d.ts +2 -0
- package/dist/monotonic-budget.js +28 -0
- package/dist/monotonic-budget.js.map +1 -0
- package/dist/needs-you-hub.d.ts +114 -0
- package/dist/needs-you-hub.js +370 -0
- package/dist/needs-you-hub.js.map +1 -0
- package/dist/needs-you-live.d.ts +116 -0
- package/dist/needs-you-live.js +228 -0
- package/dist/needs-you-live.js.map +1 -0
- package/dist/needs-you.d.ts +132 -0
- package/dist/needs-you.js +164 -0
- package/dist/needs-you.js.map +1 -0
- package/dist/node-version-guard.d.ts +30 -0
- package/dist/node-version-guard.js +50 -0
- package/dist/node-version-guard.js.map +1 -0
- package/dist/operator-control-paths.d.ts +4 -0
- package/dist/operator-control-paths.js +24 -0
- package/dist/operator-control-paths.js.map +1 -0
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +9 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +5 -5
- package/dist/profile-control.js +4 -20
- package/dist/profile-control.js.map +1 -1
- package/dist/public-web-gateway.d.ts +18 -0
- package/dist/public-web-gateway.js +141 -0
- package/dist/public-web-gateway.js.map +1 -0
- package/dist/public-web-link.d.ts +53 -0
- package/dist/public-web-link.js +175 -0
- package/dist/public-web-link.js.map +1 -0
- package/dist/quickstart-api.d.ts +8 -1
- package/dist/quickstart-api.js +86 -48
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +1 -0
- package/dist/quickstart.js +6 -2
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.d.ts +7 -1
- package/dist/scheduler/db.js +38 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/retry-label.d.ts +11 -0
- package/dist/scheduler/retry-label.js +24 -0
- package/dist/scheduler/retry-label.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +55 -2
- package/dist/scheduler/scheduler.js +220 -8
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/types.d.ts +22 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/secret-store.d.ts +5 -0
- package/dist/secret-store.js +20 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/service-activation.d.ts +110 -0
- package/dist/service-activation.js +302 -0
- package/dist/service-activation.js.map +1 -0
- package/dist/service-installer.d.ts +7 -0
- package/dist/service-installer.js +7 -4
- package/dist/service-installer.js.map +1 -1
- package/dist/service-restart-selection.d.ts +6 -5
- package/dist/service-restart-selection.js +6 -5
- package/dist/service-restart-selection.js.map +1 -1
- package/dist/settings-api.d.ts +10 -2
- package/dist/settings-api.js +175 -72
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-baseline.d.ts +20 -0
- package/dist/settings-baseline.js +104 -0
- package/dist/settings-baseline.js.map +1 -0
- package/dist/settings-change.d.ts +21 -0
- package/dist/settings-change.js +133 -0
- package/dist/settings-change.js.map +1 -0
- package/dist/settings-confirmation.d.ts +105 -0
- package/dist/settings-confirmation.js +232 -0
- package/dist/settings-confirmation.js.map +1 -0
- package/dist/settings-control.d.ts +41 -0
- package/dist/settings-control.js +294 -0
- package/dist/settings-control.js.map +1 -0
- package/dist/settings-effect.d.ts +24 -0
- package/dist/settings-effect.js +169 -0
- package/dist/settings-effect.js.map +1 -0
- package/dist/settings-http-confirmation.d.ts +29 -0
- package/dist/settings-http-confirmation.js +123 -0
- package/dist/settings-http-confirmation.js.map +1 -0
- package/dist/settings-instance-patch.d.ts +4 -0
- package/dist/settings-instance-patch.js +26 -0
- package/dist/settings-instance-patch.js.map +1 -0
- package/dist/settings-request-capability.d.ts +16 -0
- package/dist/settings-request-capability.js +32 -0
- package/dist/settings-request-capability.js.map +1 -0
- package/dist/settings-transaction.d.ts +65 -0
- package/dist/settings-transaction.js +223 -0
- package/dist/settings-transaction.js.map +1 -0
- package/dist/setup-auth.d.ts +10 -18
- package/dist/setup-auth.js +14 -38
- package/dist/setup-auth.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +75 -18
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-guide.d.ts +13 -0
- package/dist/setup-guide.js +25 -0
- package/dist/setup-guide.js.map +1 -0
- package/dist/setup-host.d.ts +16 -0
- package/dist/setup-host.js +197 -32
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +3 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/slash-authz.d.ts +17 -3
- package/dist/slash-authz.js +5 -0
- package/dist/slash-authz.js.map +1 -1
- package/dist/spawn-gate.js +3 -3
- package/dist/spawn-gate.js.map +1 -1
- package/dist/statusline-watcher.d.ts +5 -0
- package/dist/statusline-watcher.js +2 -0
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/steer-capability.d.ts +6 -0
- package/dist/steer-capability.js +8 -0
- package/dist/steer-capability.js.map +1 -1
- package/dist/sync-work-attribution.d.ts +14 -0
- package/dist/sync-work-attribution.js +43 -8
- package/dist/sync-work-attribution.js.map +1 -1
- package/dist/tips.js +10 -5
- package/dist/tips.js.map +1 -1
- package/dist/tmux-control.d.ts +35 -5
- package/dist/tmux-control.js +291 -57
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +20 -6
- package/dist/tmux-manager.js +90 -47
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-read.d.ts +59 -0
- package/dist/tmux-read.js +213 -0
- package/dist/tmux-read.js.map +1 -0
- package/dist/topic-commands.d.ts +21 -10
- package/dist/topic-commands.js +191 -58
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.js +2 -1
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/tunnel/cloudflared-install.d.ts +2 -0
- package/dist/tunnel/cloudflared-install.js +1 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +14 -0
- package/dist/tunnel/purpose-lane.js +43 -0
- package/dist/tunnel/purpose-lane.js.map +1 -0
- package/dist/turn-reply-guard.d.ts +2 -0
- package/dist/turn-reply-guard.js +10 -1
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/ui/app.html +18 -0
- package/dist/ui/chat-render.js +478 -0
- package/dist/ui/chat-store.js +289 -0
- package/dist/ui/chat-strings.js +69 -0
- package/dist/ui/chat-thread.js +209 -0
- package/dist/ui/chat-tour.js +116 -0
- package/dist/ui/fleet-org.js +142 -0
- package/dist/ui/panel-chat.js +418 -0
- package/dist/ui/panel-fleet.js +413 -0
- package/dist/ui/panel-needs.js +202 -0
- package/dist/ui/panel-settings.js +750 -0
- package/dist/ui/preview.js +266 -0
- package/dist/ui/settings-apply.js +239 -0
- package/dist/ui/settings-confirm.js +186 -0
- package/dist/ui/settings-dialogs.js +546 -0
- package/dist/ui/settings-model.js +277 -0
- package/dist/ui/settings-strings.js +652 -0
- package/dist/ui/settings-wizard.js +140 -0
- package/dist/ui/shared/agend-auth.js +93 -0
- package/dist/ui/shared/app-ctx.js +80 -0
- package/dist/ui/shared/app-html.js +9 -0
- package/dist/ui/shared/app-i18n.js +95 -0
- package/dist/ui/shared/app-nav.js +73 -0
- package/dist/ui/shared/app-needs.js +114 -0
- package/dist/ui/shared/app-route.js +73 -0
- package/dist/ui/shared/app-session.js +66 -0
- package/dist/ui/shared/app-shell.js +344 -0
- package/dist/ui/shared/app-store.js +51 -0
- package/dist/ui/shared/app-stream.js +201 -0
- package/dist/ui/shared/app.css +676 -0
- package/dist/ui/shared/app.js +88 -0
- package/dist/ui/shared/fonts/OFL.txt +92 -0
- package/dist/ui/shared/fonts/README.md +21 -0
- package/dist/ui/shared/fonts/inter.woff2 +0 -0
- package/dist/ui/shared/htm.module.js +1 -0
- package/dist/ui/shared/panel-view.js +655 -0
- package/dist/ui/shared/preact-hooks.module.js +2 -0
- package/dist/ui/shared/preact.module.js +2 -0
- package/dist/ui/shared/signin.js +130 -0
- package/dist/ui/shared/theme.js +29 -0
- package/dist/ui/shared/tokens.css +153 -0
- package/dist/ui/shared/ui-confirm.js +43 -0
- package/dist/ui/shared/ui-dialog.js +45 -0
- package/dist/ui/shared/ui-icons.js +61 -0
- package/dist/ui/shared/ui-menu.js +39 -0
- package/dist/ui/shared/ui-states.js +20 -0
- package/dist/ui/shared/ui-toast.js +18 -0
- package/dist/ui/shared/vendor/htm.LICENSE +202 -0
- package/dist/ui/shared/vendor/preact.LICENSE +21 -0
- package/dist/ui/shared/vendor/vendor.json +39 -0
- package/dist/ui/shared/view-strings.js +309 -0
- package/dist/ui/signin.html +51 -0
- package/dist/update-check.d.ts +6 -2
- package/dist/update-check.js +12 -3
- package/dist/update-check.js.map +1 -1
- package/dist/update-install.d.ts +71 -0
- package/dist/update-install.js +162 -0
- package/dist/update-install.js.map +1 -0
- package/dist/upgrade-notices.d.ts +17 -0
- package/dist/upgrade-notices.js +93 -0
- package/dist/upgrade-notices.js.map +1 -0
- package/dist/usage/statusline-usage.d.ts +2 -0
- package/dist/usage/statusline-usage.js +14 -10
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/view-api.d.ts +6 -1
- package/dist/view-api.js +62 -51
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +71 -14
- package/dist/web-api.js +492 -77
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +71 -27
- package/dist/web-auth.js +169 -69
- package/dist/web-auth.js.map +1 -1
- package/dist/web-body.d.ts +3 -0
- package/dist/web-body.js +55 -0
- package/dist/web-body.js.map +1 -0
- package/dist/web-channel-echo.d.ts +9 -0
- package/dist/web-channel-echo.js +55 -0
- package/dist/web-channel-echo.js.map +1 -0
- package/dist/web-chat-history.d.ts +125 -0
- package/dist/web-chat-history.js +184 -0
- package/dist/web-chat-history.js.map +1 -0
- package/dist/web-continuation.d.ts +7 -0
- package/dist/web-continuation.js +22 -0
- package/dist/web-continuation.js.map +1 -0
- package/dist/web-host-guard.d.ts +34 -0
- package/dist/web-host-guard.js +57 -1
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-login.d.ts +72 -0
- package/dist/web-login.js +132 -0
- package/dist/web-login.js.map +1 -0
- package/dist/web-org.d.ts +55 -0
- package/dist/web-org.js +67 -0
- package/dist/web-org.js.map +1 -0
- package/dist/web-preview.d.ts +77 -0
- package/dist/web-preview.js +240 -0
- package/dist/web-preview.js.map +1 -0
- package/dist/web-request-context.d.ts +10 -0
- package/dist/web-request-context.js +13 -0
- package/dist/web-request-context.js.map +1 -0
- package/dist/web-session.d.ts +167 -0
- package/dist/web-session.js +389 -0
- package/dist/web-session.js.map +1 -0
- package/dist/web-shell-routes.d.ts +59 -0
- package/dist/web-shell-routes.js +96 -0
- package/dist/web-shell-routes.js.map +1 -0
- package/dist/web-upload.d.ts +183 -0
- package/dist/web-upload.js +468 -0
- package/dist/web-upload.js.map +1 -0
- package/dist/website-tips-renderer.d.ts +2 -2
- package/dist/website-tips-renderer.js +3 -3
- package/dist/website-tips-renderer.js.map +1 -1
- package/package.json +8 -5
- package/scripts/preinstall-guard.cjs +36 -0
- package/dist/ui/dashboard.html +0 -774
- package/dist/ui/settings.html +0 -2165
- package/dist/ui/view.html +0 -1139
package/dist/web-api.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
|
|
2
|
+
import { readBoundedWebBody } from "./web-body.js";
|
|
3
|
+
import { gatewayRequestContext } from "./web-request-context.js";
|
|
4
|
+
import { permitWebContinuation } from "./web-continuation.js";
|
|
5
|
+
/**
|
|
6
|
+
* Web UI HTTP API handler.
|
|
7
|
+
* All /ui/* routes are handled here, extracted from fleet-manager.ts.
|
|
8
|
+
*/
|
|
9
|
+
import { formatWebChannelEcho } from "./web-channel-echo.js";
|
|
10
|
+
import { t } from "./locale.js";
|
|
11
|
+
import { sendPanelHtml } from "./web-host-guard.js";
|
|
12
|
+
import { shellRoute } from "./web-shell-routes.js";
|
|
1
13
|
import { readFileSync } from "node:fs";
|
|
2
14
|
import { join, dirname } from "node:path";
|
|
3
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -5,8 +17,12 @@ import { execFileSync } from "node:child_process";
|
|
|
5
17
|
import { CreateInstanceArgs, validateArgs } from "./outbound-schemas.js";
|
|
6
18
|
import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
|
|
7
19
|
import { z } from "zod";
|
|
8
|
-
import { isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
20
|
+
import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
21
|
+
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
22
|
+
import { attachmentDelivery, displayName, INLINE_MIME, isFileId, publicAttachment, sniffUpload, UPLOAD_LIMITS, wellFormed } from "./web-upload.js";
|
|
23
|
+
import { getAgendHome } from "./paths.js";
|
|
9
24
|
import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
25
|
+
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
10
26
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
11
27
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
12
28
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -51,16 +67,20 @@ const ConfigUpdateSchema = z.object({
|
|
|
51
67
|
}).strict();
|
|
52
68
|
const SendMessageSchema = z.object({
|
|
53
69
|
instance: z.string().min(1).max(128),
|
|
54
|
-
message: z.string().
|
|
55
|
-
|
|
70
|
+
message: z.string().max(MAX_TEXT),
|
|
71
|
+
/** Ids from POST /ui/upload, for this instance, not yet sent. */
|
|
72
|
+
attachments: z.array(z.string().regex(/^[0-9a-f]{32}$/)).max(UPLOAD_LIMITS.maxFiles).optional(),
|
|
73
|
+
}).strict().refine(v => v.message.trim().length > 0 || (v.attachments?.length ?? 0) > 0, { message: "a message or at least one file", path: ["message"] });
|
|
74
|
+
/** An instance name that is safe as one path segment (no separator, no NUL, not a dot name). */
|
|
75
|
+
const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "." && name !== "..";
|
|
56
76
|
/**
|
|
57
77
|
* Push a single SSE frame to every client. If a client throws (closed socket
|
|
58
78
|
* after a network drop, etc.), evict it from the set and continue — without
|
|
59
79
|
* the eviction the dead entry leaks forever, and without the try/catch a
|
|
60
80
|
* single dead client breaks delivery to every client iterated after it.
|
|
61
81
|
*/
|
|
62
|
-
export function broadcastSseEvent(clients, event, data, onError) {
|
|
63
|
-
const payload =
|
|
82
|
+
export function broadcastSseEvent(clients, event, data, onError, id) {
|
|
83
|
+
const payload = sseFrame(event, data, id);
|
|
64
84
|
const dead = [];
|
|
65
85
|
for (const client of clients) {
|
|
66
86
|
try {
|
|
@@ -79,6 +99,10 @@ export function broadcastSseEvent(clients, event, data, onError) {
|
|
|
79
99
|
catch { /* socket already gone */ }
|
|
80
100
|
}
|
|
81
101
|
}
|
|
102
|
+
/** One SSE frame; `id` (a chat message's) lets a reconnecting EventSource say what it last saw. */
|
|
103
|
+
export function sseFrame(event, data, id) {
|
|
104
|
+
return `${id !== undefined ? `id: ${id}\n` : ""}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
105
|
+
}
|
|
82
106
|
function parseOrReject(schema, data, res) {
|
|
83
107
|
const r = schema.safeParse(data);
|
|
84
108
|
if (!r.success) {
|
|
@@ -91,20 +115,11 @@ function parseOrReject(schema, data, res) {
|
|
|
91
115
|
}
|
|
92
116
|
const __filename = fileURLToPath(import.meta.url);
|
|
93
117
|
const __dirname = dirname(__filename);
|
|
118
|
+
/** How often a stream is refreshed, and how often it re-checks that its session still stands. */
|
|
119
|
+
export const SSE_HEARTBEAT_MS = 10_000;
|
|
94
120
|
/** Parse JSON body from request. */
|
|
95
|
-
function parseBody(req) {
|
|
96
|
-
return
|
|
97
|
-
let body = "";
|
|
98
|
-
req.on("data", (chunk) => { body += chunk.toString(); });
|
|
99
|
-
req.on("end", () => {
|
|
100
|
-
try {
|
|
101
|
-
resolve(JSON.parse(body));
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
reject(new Error("Invalid JSON"));
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
});
|
|
121
|
+
async function parseBody(req) {
|
|
122
|
+
return JSON.parse((await readBoundedWebBody(req, 512 * 1024)).toString("utf8"));
|
|
108
123
|
}
|
|
109
124
|
/** Send JSON response. */
|
|
110
125
|
function json(res, status, data) {
|
|
@@ -119,11 +134,22 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
119
134
|
const path = url.pathname;
|
|
120
135
|
const method = req.method ?? "GET";
|
|
121
136
|
// Auth check for all /ui routes. Defence in depth behind the fleet-manager
|
|
122
|
-
// gate
|
|
123
|
-
//
|
|
124
|
-
// panel instead of comparing null against a missing credential.
|
|
137
|
+
// gate: the same session cookie or header token, and an unset token closes
|
|
138
|
+
// the panel instead of comparing null against a missing credential.
|
|
125
139
|
if (path.startsWith("/ui")) {
|
|
126
|
-
|
|
140
|
+
const approved = isSettingsReplay(req) && method === "POST"
|
|
141
|
+
&& (path === "/ui/config" || path === "/ui/instances" || /^\/ui\/instances\/[^/]+\/delete$/.test(path))
|
|
142
|
+
? settingsRequestExecution(req) : undefined;
|
|
143
|
+
if (approved) {
|
|
144
|
+
try {
|
|
145
|
+
approved.assert();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
json(res, 409, { error: "settings_execution_stale" });
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: !isPassiveWebRead(method, path) })) {
|
|
127
153
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
128
154
|
return true;
|
|
129
155
|
}
|
|
@@ -132,16 +158,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
132
158
|
return false;
|
|
133
159
|
}
|
|
134
160
|
// ── Static files ───────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
161
|
+
// #1408: the app shell, for exactly the paths the classifier names (src/web-shell-routes.ts). One page for every
|
|
162
|
+
// route; the client router mounts the panel. A malformed instance name is a 400, an unknown one is the shell (the
|
|
163
|
+
// panel says "not found"), so the response never tells which names exist.
|
|
164
|
+
const shell = shellRoute(method, path);
|
|
165
|
+
if (shell?.kind === "malformed") {
|
|
166
|
+
json(res, 400, { error: "invalid instance name" });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (shell?.kind === "shell") {
|
|
170
|
+
serveAppShell(req, res, ctx, "full");
|
|
145
171
|
return true;
|
|
146
172
|
}
|
|
147
173
|
// Serve JS modules
|
|
@@ -190,22 +216,83 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
190
216
|
return true;
|
|
191
217
|
}
|
|
192
218
|
// ── SSE ────────────────────────────────────────────────
|
|
219
|
+
// The same data as the stream, over plain requests, for a path that cannot carry SSE (Cloudflare Quick
|
|
220
|
+
// Tunnels do not; a buffering proxy looks like a mute server). `after` is the same `<boot>-<id>` cursor
|
|
221
|
+
// as the stream's Last-Event-ID, so polling and the stream can take turns without a message twice or
|
|
222
|
+
// a gap — and a cursor from before a fleet restart gets the new process's backlog. No cursor yet (the page
|
|
223
|
+
// has not seen a single stream message): everything still retained, like a restart's backlog, so a message
|
|
224
|
+
// that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
|
|
225
|
+
// review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
|
|
226
|
+
if (method === "GET" && path === "/ui/poll") {
|
|
227
|
+
const history = ctx.webChatHistory;
|
|
228
|
+
const after = url.searchParams.get("after");
|
|
229
|
+
const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
|
|
230
|
+
json(res, 200, {
|
|
231
|
+
status: ctx.getUiStatus(),
|
|
232
|
+
messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
|
|
233
|
+
// The ticks too, so polling never has to re-read a chat's history — that read would count as the person's
|
|
234
|
+
// activity; this poll does not (isPassiveWebRead).
|
|
235
|
+
deliveries: history ? history.deliveries() : [],
|
|
236
|
+
// And the fleet prompts open on the dashboard (C4): prompt events are stream-only too.
|
|
237
|
+
prompts: ctx.listWebPrompts?.() ?? [],
|
|
238
|
+
// #1386: "Needs you" rides the passive channels only — no endpoint of its own to poll.
|
|
239
|
+
needs: ctx.needsYouItems?.() ?? [],
|
|
240
|
+
cursor: history ? `${history.boot}-${history.lastId}` : null,
|
|
241
|
+
});
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
193
244
|
if (method === "GET" && path === "/ui/events") {
|
|
245
|
+
if (gatewayRequestContext(req)) {
|
|
246
|
+
json(res, 404, { error: "Use polling on the public link" });
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
194
249
|
res.writeHead(200, {
|
|
195
250
|
"Content-Type": "text/event-stream",
|
|
196
251
|
"Cache-Control": "no-cache",
|
|
197
252
|
Connection: "keep-alive",
|
|
198
253
|
});
|
|
199
254
|
res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`);
|
|
255
|
+
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
256
|
+
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
257
|
+
if (lastSeen !== null && ctx.webChatHistory) {
|
|
258
|
+
const history = ctx.webChatHistory;
|
|
259
|
+
for (const m of history.replayFor(lastSeen))
|
|
260
|
+
res.write(sseFrame("message", m, history.cursorOf(m)));
|
|
261
|
+
}
|
|
262
|
+
// Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
|
|
263
|
+
// report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
|
|
264
|
+
if (ctx.webChatHistory)
|
|
265
|
+
res.write(`event: deliveries\ndata: ${JSON.stringify(ctx.webChatHistory.deliveries())}\n\n`);
|
|
266
|
+
// The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
|
|
267
|
+
if (ctx.listWebPrompts)
|
|
268
|
+
res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
|
|
269
|
+
// #1386: and "Needs you" as it is now; changes follow as `needs` events.
|
|
270
|
+
if (ctx.needsYouItems)
|
|
271
|
+
res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
|
|
200
272
|
ctx.sseClients.add(res);
|
|
201
273
|
const interval = setInterval(() => {
|
|
274
|
+
// A stream authorized once must not outlive the authorization. Re-checked
|
|
275
|
+
// without counting as activity, or an open tab would keep an idle session
|
|
276
|
+
// alive forever; a revoked, expired or rotated-away session ends here.
|
|
277
|
+
if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false })) {
|
|
278
|
+
// Close the connection too, not just the response: an ended response leaves the socket idle in
|
|
279
|
+
// keep-alive, still held open by whoever's session just ended.
|
|
280
|
+
const socket = req.socket;
|
|
281
|
+
try {
|
|
282
|
+
res.end(() => socket?.destroy());
|
|
283
|
+
}
|
|
284
|
+
catch { /* already closed */ }
|
|
285
|
+
cleanup();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
202
288
|
try {
|
|
203
|
-
|
|
289
|
+
// Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
|
|
290
|
+
res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
|
|
204
291
|
}
|
|
205
292
|
catch {
|
|
206
293
|
cleanup();
|
|
207
294
|
}
|
|
208
|
-
},
|
|
295
|
+
}, ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
|
|
209
296
|
let cleanedUp = false;
|
|
210
297
|
const cleanup = () => {
|
|
211
298
|
if (cleanedUp)
|
|
@@ -214,20 +301,171 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
214
301
|
ctx.sseClients.delete(res);
|
|
215
302
|
clearInterval(interval);
|
|
216
303
|
};
|
|
217
|
-
// `close`
|
|
218
|
-
//
|
|
304
|
+
// The *response's* `close` is the one that means the stream is over: it fires when the connection
|
|
305
|
+
// goes away or the response is finished. The request's `close` means "the request has been read"
|
|
306
|
+
// — on newer Node that can be as soon as it is consumed, which would drop a live stream from
|
|
307
|
+
// sseClients and stop its session re-check while the response is still open. `error` covers
|
|
308
|
+
// network resets that never deliver a clean FIN. Without these, dead clients accumulate in
|
|
219
309
|
// sseClients and the heartbeat interval keeps firing forever.
|
|
220
|
-
|
|
310
|
+
res.on("close", cleanup);
|
|
221
311
|
req.on("error", cleanup);
|
|
222
312
|
res.on("error", cleanup);
|
|
223
313
|
return true;
|
|
224
314
|
}
|
|
315
|
+
// ── Chat history ───────────────────────────────────────
|
|
316
|
+
if (method === "GET" && path === "/ui/history") {
|
|
317
|
+
const instance = url.searchParams.get("instance") ?? "";
|
|
318
|
+
const limitRaw = url.searchParams.get("limit");
|
|
319
|
+
const limit = limitRaw === null ? 200 : Number(limitRaw);
|
|
320
|
+
if (instance.length === 0 || instance.length > 128 || !Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
321
|
+
json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
225
327
|
// ── Send message ───────────────────────────────────────
|
|
226
328
|
if (method === "POST" && path === "/ui/send") {
|
|
227
329
|
handleSendMessage(req, res, ctx);
|
|
228
330
|
return true;
|
|
229
331
|
}
|
|
332
|
+
// ── Files (C2): upload one file for an instance; fetch a file by the id the fleet issued for it ──
|
|
333
|
+
if (method === "POST" && path === "/ui/upload") {
|
|
334
|
+
handleUpload(req, res, url, ctx);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
|
|
338
|
+
if (method === "GET" && fileMatch) {
|
|
339
|
+
const id = fileMatch[1];
|
|
340
|
+
const got = isFileId(id) ? ctx.webFiles?.read(id) : null;
|
|
341
|
+
if (!got) {
|
|
342
|
+
json(res, 404, { error: "No such file" });
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
const { file, bytes } = got;
|
|
346
|
+
const inline = INLINE_MIME.has(file.mime);
|
|
347
|
+
// Only the four image types are shown in the page; anything else is a download, never rendered.
|
|
348
|
+
res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
|
|
349
|
+
res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
|
|
350
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
351
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
352
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
353
|
+
res.writeHead(200);
|
|
354
|
+
res.end(bytes);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
// ── Fleet prompts (C4): the hang / exit / interactive-prompt buttons, answerable here too ──
|
|
358
|
+
if (method === "GET" && path === "/ui/prompts") {
|
|
359
|
+
json(res, 200, { prompts: ctx.listWebPrompts?.() ?? [] });
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
// The nonce is the capability, so it travels in the body: never in a URL a log or a history could keep.
|
|
363
|
+
if (method === "POST" && path === "/ui/prompt") {
|
|
364
|
+
if (!ctx.clickWebPrompt) {
|
|
365
|
+
json(res, 404, { error: "No prompts here" });
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
const click = ctx.clickWebPrompt.bind(ctx);
|
|
369
|
+
(async () => {
|
|
370
|
+
let body;
|
|
371
|
+
try {
|
|
372
|
+
body = await parseBody(req);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
379
|
+
return;
|
|
380
|
+
if (!body || typeof body !== "object") {
|
|
381
|
+
json(res, 400, { error: "instance, nonce and action required" });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const { instance, nonce, action } = body;
|
|
385
|
+
if (typeof instance !== "string" || typeof nonce !== "string" || typeof action !== "string") {
|
|
386
|
+
json(res, 400, { error: "instance, nonce and action required" });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const r = await click(instance, nonce, action);
|
|
390
|
+
// 409: answered elsewhere first, expired, or never open — the page drops the buttons on `gone`.
|
|
391
|
+
json(res, r.status, r.status === 200 ? { answered: true } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
392
|
+
})().catch(err => {
|
|
393
|
+
ctx.logger.error({ err: err.message }, "Web prompt answer failed");
|
|
394
|
+
try {
|
|
395
|
+
json(res, 500, { error: "Prompt answer failed" });
|
|
396
|
+
}
|
|
397
|
+
catch { /* already answered */ }
|
|
398
|
+
});
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
// #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
|
|
402
|
+
// token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
|
|
403
|
+
if (method === "POST" && path === "/ui/needs/ack") {
|
|
404
|
+
if (!ctx.acknowledgeNeedsItem) {
|
|
405
|
+
json(res, 404, { error: "Nothing to acknowledge here" });
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
const acknowledge = ctx.acknowledgeNeedsItem.bind(ctx);
|
|
409
|
+
(async () => {
|
|
410
|
+
let body;
|
|
411
|
+
try {
|
|
412
|
+
body = await parseBody(req);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
419
|
+
return;
|
|
420
|
+
const id = body && typeof body === "object" ? body.id : undefined;
|
|
421
|
+
if (typeof id !== "string") {
|
|
422
|
+
json(res, 400, { error: "id required" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
426
|
+
if (verdict.kind !== "allow") {
|
|
427
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const principal = verdict.via === "session" && verdict.session ? `web:${verdict.session.handle}` : "cli";
|
|
431
|
+
const r = acknowledge(id, principal);
|
|
432
|
+
json(res, r.status, r.status === 200 ? { acknowledged: true, message: r.message } : { error: r.message });
|
|
433
|
+
})().catch(err => {
|
|
434
|
+
ctx.logger.error({ err: err.message }, "Needs you acknowledge failed");
|
|
435
|
+
try {
|
|
436
|
+
json(res, 500, { error: "Acknowledge failed" });
|
|
437
|
+
}
|
|
438
|
+
catch { /* already answered */ }
|
|
439
|
+
});
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
230
442
|
// ── Instance operations ────────────────────────────────
|
|
443
|
+
// Stop the agent's current reply — what Telegram's cancel button and /cancel do: Esc into the CLI, and the
|
|
444
|
+
// messages still waiting for it are dropped. Not /ui/stop, which stops the instance's process.
|
|
445
|
+
const cancelMatch = path.match(/^\/ui\/cancel\/([^/]+)$/);
|
|
446
|
+
if (method === "POST" && cancelMatch) {
|
|
447
|
+
let name;
|
|
448
|
+
try {
|
|
449
|
+
name = decodeURIComponent(cancelMatch[1]);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
json(res, 400, { error: "Bad instance name" });
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
// Own keys only: "constructor" is in every object and names no instance.
|
|
456
|
+
const known = (ctx.fleetConfig ? Object.hasOwn(ctx.fleetConfig.instances, name) : false) || ctx.daemons.has(name);
|
|
457
|
+
if (!ctx.cancelInstance || !known) {
|
|
458
|
+
json(res, 404, { error: `Instance not found: ${name}` });
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
if (!ctx.cancelInstance(name)) {
|
|
462
|
+
json(res, 409, { error: `${name} is not running` });
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
ctx.eventLog?.logActivity("cancel", "web-user", "stop the current reply", name);
|
|
466
|
+
json(res, 200, { cancelled: name });
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
231
469
|
const stopMatch = path.match(/^\/ui\/stop\/(.+)$/);
|
|
232
470
|
if (method === "POST" && stopMatch) {
|
|
233
471
|
const name = decodeURIComponent(stopMatch[1]);
|
|
@@ -278,11 +516,17 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
278
516
|
(async () => {
|
|
279
517
|
try {
|
|
280
518
|
const body = await parseBody(req);
|
|
519
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
520
|
+
return;
|
|
281
521
|
if (body.confirm !== `delete ${name}`) {
|
|
282
522
|
json(res, 400, { error: `Confirmation required: { "confirm": "delete ${name}" }` });
|
|
283
523
|
return;
|
|
284
524
|
}
|
|
285
|
-
|
|
525
|
+
const execution = settingsRequestExecution(req);
|
|
526
|
+
if (execution)
|
|
527
|
+
await ctx.removeInstance(name, authorizeExplicitInstanceRemoval("dashboard-confirmed"), execution);
|
|
528
|
+
else
|
|
529
|
+
await ctx.removeInstance(name, authorizeExplicitInstanceRemoval("dashboard-confirmed"));
|
|
286
530
|
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
287
531
|
json(res, 200, { deleted: name });
|
|
288
532
|
}
|
|
@@ -364,6 +608,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
364
608
|
(async () => {
|
|
365
609
|
try {
|
|
366
610
|
const body = await parseBody(req);
|
|
611
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
612
|
+
return;
|
|
367
613
|
const v = validateArgs(CreateInstanceArgs, body, "create_instance");
|
|
368
614
|
if (!v.ok) {
|
|
369
615
|
json(res, 400, { error: v.error });
|
|
@@ -371,7 +617,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
371
617
|
}
|
|
372
618
|
let result = null;
|
|
373
619
|
let error;
|
|
374
|
-
|
|
620
|
+
const respond = (r, e) => { result = r; error = e; };
|
|
621
|
+
const execution = settingsRequestExecution(req);
|
|
622
|
+
if (execution)
|
|
623
|
+
await ctx.lifecycle.handleCreate(v.data, respond, undefined, execution);
|
|
624
|
+
else
|
|
625
|
+
await ctx.lifecycle.handleCreate(v.data, respond);
|
|
375
626
|
if (error) {
|
|
376
627
|
json(res, 400, { error });
|
|
377
628
|
}
|
|
@@ -404,6 +655,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
404
655
|
(async () => {
|
|
405
656
|
try {
|
|
406
657
|
const body = await parseBody(req);
|
|
658
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
659
|
+
return;
|
|
407
660
|
const parsed = parseOrReject(TaskCreateSchema, body, res);
|
|
408
661
|
if (!parsed)
|
|
409
662
|
return;
|
|
@@ -432,6 +685,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
432
685
|
(async () => {
|
|
433
686
|
try {
|
|
434
687
|
const body = await parseBody(req);
|
|
688
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
689
|
+
return;
|
|
435
690
|
const parsed = parseOrReject(TaskUpdateSchema, body, res);
|
|
436
691
|
if (!parsed)
|
|
437
692
|
return;
|
|
@@ -472,6 +727,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
472
727
|
(async () => {
|
|
473
728
|
try {
|
|
474
729
|
const body = await parseBody(req);
|
|
730
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
731
|
+
return;
|
|
475
732
|
const parsed = parseOrReject(ScheduleCreateSchema, body, res);
|
|
476
733
|
if (!parsed)
|
|
477
734
|
return;
|
|
@@ -500,6 +757,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
500
757
|
return true;
|
|
501
758
|
}
|
|
502
759
|
// ── Teams ──────────────────────────────────────────────
|
|
760
|
+
// #1389: the org chart. Opening it is a person's navigation (it counts as use, like a Fleet tab); nothing re-reads
|
|
761
|
+
// it on a timer — the live state comes over the stream the page already has.
|
|
762
|
+
if (method === "GET" && path === "/ui/org") {
|
|
763
|
+
json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
503
766
|
if (method === "GET" && path === "/ui/teams") {
|
|
504
767
|
const teams = ctx.fleetConfig?.teams ?? {};
|
|
505
768
|
json(res, 200, { teams });
|
|
@@ -509,6 +772,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
509
772
|
(async () => {
|
|
510
773
|
try {
|
|
511
774
|
const body = await parseBody(req);
|
|
775
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
776
|
+
return;
|
|
512
777
|
const parsed = parseOrReject(TeamCreateSchema, body, res);
|
|
513
778
|
if (!parsed)
|
|
514
779
|
return;
|
|
@@ -569,6 +834,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
569
834
|
(async () => {
|
|
570
835
|
try {
|
|
571
836
|
const body = await parseBody(req);
|
|
837
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
838
|
+
return;
|
|
572
839
|
const parsed = parseOrReject(ConfigUpdateSchema, body, res);
|
|
573
840
|
if (!parsed)
|
|
574
841
|
return;
|
|
@@ -578,26 +845,28 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
578
845
|
return;
|
|
579
846
|
}
|
|
580
847
|
const ch = config.channel;
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
if (parsed.channel
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
848
|
+
settingsWrite(req, () => {
|
|
849
|
+
// Update channel settings
|
|
850
|
+
if (parsed.channel && ch) {
|
|
851
|
+
if (parsed.channel.group_id != null)
|
|
852
|
+
config.channel.group_id = parsed.channel.group_id;
|
|
853
|
+
if (parsed.channel.access)
|
|
854
|
+
config.channel.access = parsed.channel.access;
|
|
855
|
+
}
|
|
856
|
+
// Update defaults
|
|
857
|
+
if (parsed.defaults) {
|
|
858
|
+
const d = config.defaults;
|
|
859
|
+
if (parsed.defaults.backend)
|
|
860
|
+
d.backend = parsed.defaults.backend;
|
|
861
|
+
if (parsed.defaults.model)
|
|
862
|
+
d.model = parsed.defaults.model;
|
|
863
|
+
}
|
|
864
|
+
// Update project_roots
|
|
865
|
+
if (parsed.project_roots) {
|
|
866
|
+
config.project_roots = parsed.project_roots;
|
|
867
|
+
}
|
|
868
|
+
ctx.saveFleetConfig();
|
|
869
|
+
});
|
|
601
870
|
const needsRestart = parsed.channel?.group_id != null;
|
|
602
871
|
json(res, 200, { saved: true, needs_restart: needsRestart });
|
|
603
872
|
}
|
|
@@ -611,6 +880,14 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
611
880
|
json(res, 404, { error: "not found" });
|
|
612
881
|
return true;
|
|
613
882
|
}
|
|
883
|
+
/** Keep the echo in a single platform message, including its attachment names. */
|
|
884
|
+
function webEchoPreview(message, names) {
|
|
885
|
+
const text = wellFormed(message.slice(0, 500));
|
|
886
|
+
const files = names.length ? `[📎 ${names.map(name => displayName(name, "attachment")).join(", ")}]` : "";
|
|
887
|
+
const preview = [text, files].filter(Boolean).join(" ");
|
|
888
|
+
const truncated = message.length > 500 || preview.length > 1400;
|
|
889
|
+
return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
|
|
890
|
+
}
|
|
614
891
|
/** Handle POST /ui/send — extracted for readability. */
|
|
615
892
|
function handleSendMessage(req, res, ctx) {
|
|
616
893
|
let body = "";
|
|
@@ -625,6 +902,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
625
902
|
body += chunk.toString();
|
|
626
903
|
});
|
|
627
904
|
req.on("end", async () => {
|
|
905
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
906
|
+
return;
|
|
628
907
|
try {
|
|
629
908
|
const raw = JSON.parse(body);
|
|
630
909
|
const parsed = SendMessageSchema.safeParse(raw);
|
|
@@ -633,55 +912,191 @@ function handleSendMessage(req, res, ctx) {
|
|
|
633
912
|
json(res, 400, { error: `${issue.path.join(".") || "body"}: ${issue.message}` });
|
|
634
913
|
return;
|
|
635
914
|
}
|
|
636
|
-
const { instance, message } = parsed.data;
|
|
915
|
+
const { instance, message: typed, attachments = [] } = parsed.data;
|
|
637
916
|
const ipc = ctx.instanceIpcClients.get(instance);
|
|
638
917
|
if (!ipc) {
|
|
639
918
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
640
919
|
return;
|
|
641
920
|
}
|
|
921
|
+
let files = [];
|
|
922
|
+
if (attachments.length > 0) {
|
|
923
|
+
const taken = ctx.webFiles?.takeForMessage(instance, attachments);
|
|
924
|
+
if (!taken || !taken.ok) {
|
|
925
|
+
json(res, 400, { error: taken && !taken.ok ? taken.error : "files are not available here" });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
files = taken.entries;
|
|
929
|
+
}
|
|
930
|
+
// The same tags and meta a Telegram photo/document produces, so the agent needs nothing new.
|
|
931
|
+
const delivery = attachmentDelivery(typed, files);
|
|
932
|
+
const message = typed;
|
|
642
933
|
const ts = new Date().toISOString();
|
|
934
|
+
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
935
|
+
const messageId = newWebMessageId();
|
|
643
936
|
// Use real Telegram context so daemon's lastChatId/lastThreadId are set,
|
|
644
937
|
// enabling reply tool even when first message comes from Web UI.
|
|
645
|
-
// Pure Web UI mode (no channel config) leaves these empty
|
|
646
|
-
//
|
|
647
|
-
const
|
|
938
|
+
// Pure Web UI mode (no channel config) leaves these empty; the agent's reply then comes back to the
|
|
939
|
+
// web chat alone (FleetManager's web-only reply sink).
|
|
940
|
+
const syncAdapter = ctx.getAdapterForInstance ? ctx.getAdapterForInstance(instance) : ctx.adapter;
|
|
941
|
+
const groupId = ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "");
|
|
648
942
|
const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
|
|
943
|
+
const canEcho = ctx.fleetConfig?.web?.echo_to_channel !== false
|
|
944
|
+
&& !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
|
|
945
|
+
// Add no runtime credentials or local attachment paths to the display copy.
|
|
946
|
+
const preview = webEchoPreview(message, files.map(f => f.name));
|
|
947
|
+
// ClassicBot echo (#1320 part B): same ordering lane, per-entry
|
|
948
|
+
// opt-in resolved at send time inside sendClassicWebEcho.
|
|
949
|
+
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
950
|
+
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
951
|
+
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho("web-user", preview, t("web.echo_full_text")));
|
|
952
|
+
}) : undefined;
|
|
953
|
+
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
954
|
+
// A replacement adapter or edited binding is not the route we reserved.
|
|
955
|
+
if ((ctx.getAdapterForInstance ? ctx.getAdapterForInstance(instance) : ctx.adapter) !== syncAdapter
|
|
956
|
+
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
957
|
+
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
958
|
+
return;
|
|
959
|
+
return syncAdapter.sendText(String(groupId), formatWebChannelEcho("web-user", preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
|
|
960
|
+
}) : undefined;
|
|
961
|
+
let delivered;
|
|
649
962
|
try {
|
|
650
|
-
await ctx.deliverToInstance(instance, {
|
|
963
|
+
delivered = await ctx.deliverToInstance(instance, {
|
|
651
964
|
type: "fleet_inbound",
|
|
652
|
-
content:
|
|
965
|
+
content: delivery.text,
|
|
653
966
|
targetSession: instance,
|
|
654
967
|
meta: {
|
|
655
968
|
chat_id: groupId ? String(groupId) : "",
|
|
656
|
-
message_id:
|
|
969
|
+
message_id: messageId,
|
|
657
970
|
user: "web-user", user_id: "web-user",
|
|
658
971
|
ts,
|
|
659
972
|
thread_id: topicId != null ? String(topicId) : "",
|
|
660
973
|
source: "web",
|
|
974
|
+
adapter_id: syncAdapter?.id,
|
|
975
|
+
...delivery.meta,
|
|
661
976
|
},
|
|
662
977
|
});
|
|
663
978
|
}
|
|
664
979
|
catch (err) {
|
|
980
|
+
settleEcho?.(false);
|
|
981
|
+
// A throw must release the Classic ordering lane too: otherwise the
|
|
982
|
+
// next accepted echo queues behind a reservation that never settles
|
|
983
|
+
// and is dropped by expiry (#1330 R2).
|
|
984
|
+
settleClassicEcho?.(false);
|
|
985
|
+
ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
|
|
665
986
|
ctx.logger.error({ err, instance }, "Web message delivery failed");
|
|
666
987
|
json(res, 503, { error: "Instance delivery failed" });
|
|
667
988
|
return;
|
|
668
989
|
}
|
|
990
|
+
if (delivered === false) {
|
|
991
|
+
settleEcho?.(false);
|
|
992
|
+
settleClassicEcho?.(false);
|
|
993
|
+
ctx.webFiles?.release(files);
|
|
994
|
+
ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
|
|
995
|
+
json(res, 503, { error: "Instance delivery failed" });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
settleEcho?.(true);
|
|
999
|
+
settleClassicEcho?.(true);
|
|
1000
|
+
ctx.webFiles?.commit(files);
|
|
669
1001
|
ctx.lastInboundUser.set(instance, "web-user");
|
|
670
|
-
ctx.eventLog?.logActivity("message", "web-user", message.slice(0, 200), instance);
|
|
671
|
-
ctx.emitSseEvent("message", { instance, sender: "web-user", text: message, ts });
|
|
672
|
-
|
|
673
|
-
const syncAdapter = ctx.getAdapterForInstance?.(instance) ?? ctx.adapter;
|
|
674
|
-
const syncGroupId = ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "");
|
|
675
|
-
if (syncAdapter && syncGroupId) {
|
|
676
|
-
const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
|
|
677
|
-
const preview = message.length > 500 ? message.slice(0, 500) + " [...]" : message;
|
|
678
|
-
syncAdapter.sendText(syncGroupId, `🌐 web-user: ${preview}`, { threadId: topicId != null ? String(topicId) : undefined }).catch(() => ctx.logger.debug({}, "Web→Channel sync failed"));
|
|
679
|
-
}
|
|
680
|
-
json(res, 200, { sent: true });
|
|
1002
|
+
ctx.eventLog?.logActivity("message", "web-user", (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
1003
|
+
ctx.emitSseEvent("message", { instance, sender: "web-user", role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
|
|
1004
|
+
json(res, 200, { sent: true, messageId });
|
|
681
1005
|
}
|
|
682
1006
|
catch {
|
|
683
1007
|
json(res, 400, { error: "Invalid JSON" });
|
|
684
1008
|
}
|
|
685
1009
|
});
|
|
686
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
|
|
1013
|
+
* read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
|
|
1014
|
+
* workspace inbox under a name chosen here; answered with the id the message will name.
|
|
1015
|
+
*/
|
|
1016
|
+
function handleUpload(req, res, url, ctx) {
|
|
1017
|
+
const instance = url.searchParams.get("instance") ?? "";
|
|
1018
|
+
if (!ctx.webFiles) {
|
|
1019
|
+
json(res, 404, { error: "uploads are not available" });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
|
|
1023
|
+
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
1027
|
+
if (Number.isFinite(declared) && declared > UPLOAD_LIMITS.maxFileBytes) {
|
|
1028
|
+
json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
|
|
1029
|
+
req.resume();
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const chunks = [];
|
|
1033
|
+
let size = 0;
|
|
1034
|
+
let refused = false;
|
|
1035
|
+
req.on("data", (c) => {
|
|
1036
|
+
if (refused)
|
|
1037
|
+
return;
|
|
1038
|
+
size += c.length;
|
|
1039
|
+
if (size > UPLOAD_LIMITS.maxFileBytes) {
|
|
1040
|
+
refused = true;
|
|
1041
|
+
json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
|
|
1042
|
+
req.destroy();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
chunks.push(c);
|
|
1046
|
+
});
|
|
1047
|
+
req.on("error", () => { });
|
|
1048
|
+
req.on("end", () => {
|
|
1049
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1050
|
+
return;
|
|
1051
|
+
if (refused)
|
|
1052
|
+
return;
|
|
1053
|
+
const bytes = Buffer.concat(chunks);
|
|
1054
|
+
const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
|
|
1055
|
+
const type = sniffUpload(bytes, name);
|
|
1056
|
+
if (!type) {
|
|
1057
|
+
json(res, 415, { error: "unsupported file type — images (PNG, JPEG, GIF, WebP), PDF and text files only" });
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
// The instance is checked again now the whole body is here: it may have been deleted (and its files
|
|
1061
|
+
// forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
|
|
1062
|
+
// Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
|
|
1063
|
+
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
|
|
1064
|
+
const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
|
|
1065
|
+
if (!ctx.instanceIpcClients.has(instance) || !registered) {
|
|
1066
|
+
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
try {
|
|
1070
|
+
const entry = ctx.webFiles.storeUpload({ instance, inboxDir: join(getAgendHome(), "workspaces", instance, "inbox"), bytes, name, type });
|
|
1071
|
+
json(res, 200, publicAttachment(entry));
|
|
1072
|
+
}
|
|
1073
|
+
catch (err) {
|
|
1074
|
+
ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
|
|
1075
|
+
json(res, 500, { error: "the file could not be stored" });
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
function shellPreview(req, ctx, mode) {
|
|
1080
|
+
if (mode !== "full" || gatewayRequestContext(req))
|
|
1081
|
+
return null;
|
|
1082
|
+
return ctx.previewForUi?.(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) ?? null;
|
|
1083
|
+
}
|
|
1084
|
+
function shellBodyTag(req, ctx, mode) {
|
|
1085
|
+
const p = shellPreview(req, ctx, mode);
|
|
1086
|
+
const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1087
|
+
return `<body data-mode="${mode}"${mode === "full" && gatewayRequestContext(req) ? ' data-web-transport="poll"' : ""} data-dashboard-origin="${attr(p?.dashboardOrigin)}" data-preview-origin="${attr(p?.previewOrigin)}"`
|
|
1088
|
+
+ ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}">`;
|
|
1089
|
+
}
|
|
1090
|
+
/** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
|
|
1091
|
+
* when this load chose one. */
|
|
1092
|
+
export function serveAppShell(req, res, ctx, mode) {
|
|
1093
|
+
try {
|
|
1094
|
+
const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
|
|
1095
|
+
const p = shellPreview(req, ctx, mode);
|
|
1096
|
+
sendPanelHtml(res, html.replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, p?.previewOrigin ? { frameSrc: `${p.previewOrigin}/frame` } : {});
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
json(res, 500, { error: "app.html not found" });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
687
1102
|
//# sourceMappingURL=web-api.js.map
|