@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-cli-instructions.md +5 -2
- package/dist/agent-cli.js +43 -7
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +4 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +14 -6
- package/dist/agent-file-read.js +207 -19
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.d.ts +11 -0
- package/dist/agent-whats-new.js +29 -15
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +45 -13
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +38 -0
- package/dist/authority/authority-state.js +114 -0
- package/dist/authority/authority-state.js.map +1 -0
- package/dist/authority/session-authorizations.d.ts +43 -0
- package/dist/authority/session-authorizations.js +102 -0
- package/dist/authority/session-authorizations.js.map +1 -0
- package/dist/authority/web-authority.d.ts +120 -0
- package/dist/authority/web-authority.js +157 -0
- package/dist/authority/web-authority.js.map +1 -0
- package/dist/backend/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +10 -5
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +14 -0
- package/dist/backend/types.js +25 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +120 -0
- package/dist/backend-availability.js +396 -0
- package/dist/backend-availability.js.map +1 -0
- package/dist/backend-command.d.ts +21 -3
- package/dist/backend-command.js +23 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/bundled-skills.d.ts +14 -0
- package/dist/bundled-skills.js +29 -0
- package/dist/bundled-skills.js.map +1 -0
- package/dist/cache-analysis.d.ts +47 -1
- package/dist/cache-analysis.js +68 -8
- package/dist/cache-analysis.js.map +1 -1
- package/dist/cache-ledger.d.ts +16 -3
- package/dist/cache-ledger.js +33 -13
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-report-tool.d.ts +17 -0
- package/dist/cache-report-tool.js +40 -0
- package/dist/cache-report-tool.js.map +1 -0
- package/dist/cache-service.d.ts +70 -3
- package/dist/cache-service.js +178 -17
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +7 -0
- package/dist/channel/adapters/discord.js +35 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +22 -10
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +6 -0
- package/dist/classic-channel-manager.d.ts +10 -5
- package/dist/classic-channel-manager.js +12 -9
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +130 -31
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +103 -4
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +63 -16
- package/dist/daemon.js.map +1 -1
- package/dist/decision-list.d.ts +25 -0
- package/dist/decision-list.js +30 -0
- package/dist/decision-list.js.map +1 -0
- package/dist/delivery-outbox.d.ts +10 -0
- package/dist/delivery-outbox.js +12 -1
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -0
- package/dist/fleet-manager.d.ts +417 -11
- package/dist/fleet-manager.js +1387 -109
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-traffic.d.ts +74 -0
- package/dist/fleet-traffic.js +196 -0
- package/dist/fleet-traffic.js.map +1 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +257 -23
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +478 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/human-delivery-receipts.d.ts +24 -1
- package/dist/human-delivery-receipts.js +64 -11
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/instance-config-impact.js +3 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-names.d.ts +4 -0
- package/dist/instance-names.js +12 -0
- package/dist/instance-names.js.map +1 -0
- package/dist/instructions.d.ts +12 -1
- package/dist/instructions.js +13 -2
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +100 -8
- package/dist/locale.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +95 -0
- package/dist/ls-attach-resolve.js +174 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- package/dist/memory-hogs.d.ts +27 -0
- package/dist/memory-hogs.js +124 -0
- package/dist/memory-hogs.js.map +1 -0
- package/dist/outbound-handlers.d.ts +10 -0
- package/dist/outbound-handlers.js +122 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +164 -4
- package/dist/outbound-schemas.js +99 -3
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +8 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -0
- package/dist/public-web-link.js +8 -0
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +10 -1
- package/dist/quickstart-api.js +12 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +4 -0
- package/dist/reply-buttons-controller.js +2 -2
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/restart-progress.d.ts +65 -1
- package/dist/restart-progress.js +236 -10
- package/dist/restart-progress.js.map +1 -1
- package/dist/rollout-index.d.ts +5 -0
- package/dist/rollout-index.js +26 -5
- package/dist/rollout-index.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +12 -0
- package/dist/scheduler/scheduler.js +83 -10
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/service-installer.d.ts +2 -1
- package/dist/service-installer.js +18 -3
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +19 -0
- package/dist/settings-api.js +137 -2
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +104 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +17 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/tmux-manager.d.ts +5 -0
- package/dist/tmux-manager.js +16 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +4 -6
- package/dist/tool-permissions.js +53 -5
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +7 -1
- package/dist/topic-commands.js +18 -4
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +48 -1
- package/dist/ui/app-effects.js +77 -31
- package/dist/ui/chat-ambient.js +307 -0
- package/dist/ui/chat-effect-posters.js +8 -8
- package/dist/ui/chat-effects.js +101 -13
- package/dist/ui/chat-render.js +21 -3
- package/dist/ui/chat-strings.js +59 -6
- package/dist/ui/chat-thread.js +159 -21
- package/dist/ui/effect-trial-controller.js +13 -8
- package/dist/ui/effect-trial-panel.js +19 -16
- package/dist/ui/fleet-cache.js +23 -1
- package/dist/ui/fleet-canvas-layout.js +111 -0
- package/dist/ui/fleet-canvas.js +525 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-traffic.js +343 -0
- package/dist/ui/fleet-usage.js +401 -0
- package/dist/ui/panel-chat.js +154 -49
- package/dist/ui/panel-details.js +2 -1
- package/dist/ui/panel-fleet.js +43 -17
- package/dist/ui/panel-settings.js +26 -8
- package/dist/ui/preview.js +3 -2
- package/dist/ui/settings-appearance.js +42 -18
- package/dist/ui/settings-catalog.js +92 -0
- package/dist/ui/settings-dialogs.js +40 -12
- package/dist/ui/settings-effects.js +4 -3
- package/dist/ui/settings-guests.js +117 -0
- package/dist/ui/settings-learn.js +25 -0
- package/dist/ui/settings-model.js +15 -6
- package/dist/ui/settings-mods.js +23 -9
- package/dist/ui/settings-owner-effects.js +9 -9
- package/dist/ui/settings-strings.js +120 -42
- package/dist/ui/settings-web-authority.js +91 -0
- package/dist/ui/shared/agend-auth.js +1 -1
- package/dist/ui/shared/app-i18n.js +4 -2
- package/dist/ui/shared/app-prefs.js +5 -4
- package/dist/ui/shared/app-route.js +19 -1
- package/dist/ui/shared/app-session.js +2 -0
- package/dist/ui/shared/app-shell.js +92 -27
- package/dist/ui/shared/app-store.js +26 -1
- package/dist/ui/shared/app-stream.js +3 -1
- package/dist/ui/shared/app-themes.js +24 -6
- package/dist/ui/shared/app.css +365 -17
- package/dist/ui/shared/app.js +21 -14
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/effect-app-runtime.js +68 -19
- package/dist/ui/shared/effect-chat-runtime.js +16 -3
- package/dist/ui/shared/effect-frame.js +26 -7
- package/dist/ui/shared/effect-geometry.js +24 -9
- package/dist/ui/shared/effect-policy.js +21 -7
- package/dist/ui/shared/effect-profiler.js +31 -9
- package/dist/ui/shared/effect-registry.js +87 -48
- package/dist/ui/shared/effect-stock-snapshot.js +9 -0
- package/dist/ui/shared/effect-stock-trust.js +62 -10
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-source.js +1 -1
- package/dist/ui/shared/effect-trial-stage.js +54 -31
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/fx-motion.js +67 -0
- package/dist/ui/shared/identity-card.js +75 -4
- package/dist/ui/shared/identity-color.js +3 -3
- package/dist/ui/shared/instance-nav.js +194 -28
- package/dist/ui/shared/panel-view.js +24 -4
- package/dist/ui/shared/tokens.css +9 -3
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +22 -2
- package/dist/ui/shared/view-strings.js +68 -2
- package/dist/ui/shared/web-authorize.js +151 -0
- package/dist/ui/signin.html +1 -1
- package/dist/ui/web-terminal/terminal.css +4 -4
- package/dist/usage/providers.d.ts +28 -0
- package/dist/usage/providers.js +126 -36
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage-report.d.ts +158 -0
- package/dist/usage-report.js +226 -0
- package/dist/usage-report.js.map +1 -0
- package/dist/usage-rollup.d.ts +51 -0
- package/dist/usage-rollup.js +168 -0
- package/dist/usage-rollup.js.map +1 -0
- package/dist/view-api.d.ts +39 -0
- package/dist/view-api.js +65 -4
- package/dist/view-api.js.map +1 -1
- package/dist/web-ambient.d.ts +72 -0
- package/dist/web-ambient.js +161 -0
- package/dist/web-ambient.js.map +1 -0
- package/dist/web-api.d.ts +69 -5
- package/dist/web-api.js +362 -45
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-policy.d.ts +4 -6
- package/dist/web-appearance-policy.js +5 -12
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +3 -0
- package/dist/web-appearance-tools.js +22 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.d.ts +27 -2
- package/dist/web-appearance-view.js +129 -14
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +17 -3
- package/dist/web-appearance.js +82 -7
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-asset-parts.d.ts +5 -10
- package/dist/web-asset-parts.js +3 -3
- package/dist/web-asset-parts.js.map +1 -1
- package/dist/web-auth.js +3 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-card.d.ts +14 -0
- package/dist/web-card.js +40 -6
- package/dist/web-card.js.map +1 -1
- package/dist/web-channel-echo.d.ts +7 -1
- package/dist/web-channel-echo.js +6 -2
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +50 -0
- package/dist/web-chat-history.js +87 -5
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +1 -0
- package/dist/web-effect-bootstrap.d.ts +12 -0
- package/dist/web-effect-bootstrap.js +49 -0
- package/dist/web-effect-bootstrap.js.map +1 -0
- package/dist/web-effect-bundle.js +46 -1
- package/dist/web-effect-bundle.js.map +1 -1
- package/dist/web-effect-playback.d.ts +30 -0
- package/dist/web-effect-playback.js +71 -0
- package/dist/web-effect-playback.js.map +1 -0
- package/dist/web-effect-profiles.d.ts +4 -4
- package/dist/web-effect-service.d.ts +11 -4
- package/dist/web-effect-service.js +28 -5
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.d.ts +1 -0
- package/dist/web-effect-tools.js +8 -5
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.d.ts +4 -4
- package/dist/web-effect-trials.js +6 -6
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-effect-upload.d.ts +2 -4
- package/dist/web-effect-upload.js +3 -8
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-effect-worker.d.ts +1 -1
- package/dist/web-effect-worker.js +2 -2
- package/dist/web-effect-worker.js.map +1 -1
- package/dist/web-effect.d.ts +14 -0
- package/dist/web-effect.js +21 -0
- package/dist/web-effect.js.map +1 -1
- package/dist/web-guest-access.d.ts +125 -0
- package/dist/web-guest-access.js +234 -0
- package/dist/web-guest-access.js.map +1 -0
- package/dist/web-guest-rooms.d.ts +82 -0
- package/dist/web-guest-rooms.js +135 -0
- package/dist/web-guest-rooms.js.map +1 -0
- package/dist/web-identity-color.d.ts +1 -1
- package/dist/web-identity-color.js +2 -2
- package/dist/web-identity-color.js.map +1 -1
- package/dist/web-identity-palette.d.ts +1 -1
- package/dist/web-identity-palette.js +2 -2
- package/dist/web-identity-palette.js.map +1 -1
- package/dist/web-image-meta.d.ts +13 -0
- package/dist/web-image-meta.js +62 -0
- package/dist/web-image-meta.js.map +1 -1
- package/dist/web-login.d.ts +47 -0
- package/dist/web-login.js +115 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mod-agent.d.ts +1 -1
- package/dist/web-mod-agent.js +12 -8
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-bundle.d.ts +14 -1
- package/dist/web-mod-bundle.js +68 -3
- package/dist/web-mod-bundle.js.map +1 -1
- package/dist/web-mod-export.d.ts +9 -0
- package/dist/web-mod-export.js +21 -0
- package/dist/web-mod-export.js.map +1 -1
- package/dist/web-mod-store.d.ts +7 -1
- package/dist/web-mod-store.js +21 -7
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-prefs.d.ts +14 -0
- package/dist/web-prefs.js +24 -4
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +6 -2
- package/dist/web-preview.js +4 -2
- package/dist/web-preview.js.map +1 -1
- package/dist/web-returning-device.d.ts +41 -0
- package/dist/web-returning-device.js +57 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-session-authorization.d.ts +132 -0
- package/dist/web-session-authorization.js +193 -0
- package/dist/web-session-authorization.js.map +1 -0
- package/dist/web-session.d.ts +70 -10
- package/dist/web-session.js +137 -22
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +49 -2
- package/dist/web-settings-patch.js +145 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +89 -0
- package/dist/web-settings-tools.js +270 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-theme-store.d.ts +39 -0
- package/dist/web-theme-store.js +122 -11
- package/dist/web-theme-store.js.map +1 -1
- package/dist/web-theme.d.ts +2 -0
- package/dist/web-theme.js +10 -1
- package/dist/web-theme.js.map +1 -1
- package/dist/web-upload.d.ts +13 -0
- package/dist/web-upload.js +39 -3
- package/dist/web-upload.js.map +1 -1
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +8 -0
package/dist/web-api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { webHumanOrigin } from "./human-delivery-receipts.js";
|
|
2
|
+
import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
|
|
3
|
+
import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
|
|
2
4
|
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
3
5
|
import { binaryProbe } from "./binary-probe.js";
|
|
4
6
|
import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
|
|
@@ -41,6 +43,7 @@ import { parseMentionQuery } from "./web-mentions.js";
|
|
|
41
43
|
import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
42
44
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
43
45
|
import { resolveTz } from "./tz-utils.js";
|
|
46
|
+
import { filesPlayInChat } from "./web-effect.js";
|
|
44
47
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
45
48
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
46
49
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -96,11 +99,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
|
|
|
96
99
|
* the eviction the dead entry leaks forever, and without the try/catch a
|
|
97
100
|
* single dead client breaks delivery to every client iterated after it.
|
|
98
101
|
*/
|
|
102
|
+
/** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
|
|
103
|
+
const streamGuests = new WeakMap();
|
|
104
|
+
/**
|
|
105
|
+
* #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
|
|
106
|
+
* its channel saw (an admin's page already has it, unchanged).
|
|
107
|
+
*/
|
|
108
|
+
export function broadcastToGuests(clients, event, data, onError) {
|
|
109
|
+
const guests = [...clients].filter((c) => streamGuests.has(c));
|
|
110
|
+
if (!guests.length)
|
|
111
|
+
return;
|
|
112
|
+
const live = new Set(guests);
|
|
113
|
+
broadcastSseEvent(live, event, data, onError);
|
|
114
|
+
for (const c of guests)
|
|
115
|
+
if (!live.has(c))
|
|
116
|
+
clients.delete(c); // the ones it ended (their session went)
|
|
117
|
+
}
|
|
99
118
|
export function broadcastSseEvent(clients, event, data, onError, id) {
|
|
100
119
|
const payload = sseFrame(event, data, id);
|
|
101
120
|
const dead = [];
|
|
102
121
|
for (const client of clients) {
|
|
103
122
|
try {
|
|
123
|
+
// An admin's stream gets these bytes; a guest's, the event as projected for its scope now, or nothing (no id: it
|
|
124
|
+
// never replays chat, so a cursor of the admin's stream is not its to hold).
|
|
125
|
+
const guest = streamGuests.get(client);
|
|
126
|
+
if (guest) {
|
|
127
|
+
// The exact session that opened it ended (revoked, expired, rotated away): nothing more, and the stream goes.
|
|
128
|
+
if (!guest.current()) {
|
|
129
|
+
dead.push(client);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const shown = guestFrameData(event, data, guest.scope(), guest);
|
|
133
|
+
if (shown !== undefined)
|
|
134
|
+
client.write(sseFrame(event, shown));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
104
137
|
client.write(payload);
|
|
105
138
|
}
|
|
106
139
|
catch (err) {
|
|
@@ -381,7 +414,7 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
|
381
414
|
css = value;
|
|
382
415
|
}
|
|
383
416
|
catch { /* unavailable is never off */ }
|
|
384
|
-
// CSS
|
|
417
|
+
// CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
|
|
385
418
|
const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
|
|
386
419
|
return { actor, record: v.session, sessions };
|
|
387
420
|
};
|
|
@@ -483,7 +516,12 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
483
516
|
const reading = method === "GET";
|
|
484
517
|
// Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
|
|
485
518
|
const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
|
|
486
|
-
|
|
519
|
+
const gateway = gatewayRequestContext(req), guest = guestRequestOf(req);
|
|
520
|
+
const instanceQuery = url.searchParams.get("instance"), keys = [...url.searchParams.keys()];
|
|
521
|
+
const chatRead = reading && (path === "/ui/effects/placements" || /^\/ui\/effects\/resource\/[0-9a-f]{32}$/.test(path))
|
|
522
|
+
&& keys.length === 2 && keys.includes("instance") && keys.includes("ownerEpoch") && !!instanceQuery && instanceQuery.length <= 128;
|
|
523
|
+
// Public viewing grants current Chat reads, never ordinary library/owner/Trial access or writes.
|
|
524
|
+
if (gateway && (!chatRead || !gateway.isCurrent())) {
|
|
487
525
|
json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
|
|
488
526
|
return true;
|
|
489
527
|
}
|
|
@@ -495,7 +533,9 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
495
533
|
return { status: 403, error: "effects_need_a_signed_in_person" };
|
|
496
534
|
if (!reading && !profilePost && verdict.session.tier !== "admin")
|
|
497
535
|
return { status: 403, error: "effects_read_only" };
|
|
498
|
-
|
|
536
|
+
if (verdict.session.tier === "guest" && (!chatRead || !guest || !guest.current() || !guest.scope().has(instanceQuery)))
|
|
537
|
+
return { status: 404, error: "not_found" };
|
|
538
|
+
return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle, guest: verdict.session.tier === "guest" };
|
|
499
539
|
};
|
|
500
540
|
const first = who();
|
|
501
541
|
if ("status" in first) {
|
|
@@ -538,9 +578,9 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
538
578
|
else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
|
|
539
579
|
answer(service.ownerSelections(first.person));
|
|
540
580
|
else if (path === "/ui/effects/placements" && instance !== undefined)
|
|
541
|
-
answer(service.placements(first.person, instance));
|
|
581
|
+
answer(service.placements(first.person, instance, !!gateway || first.guest));
|
|
542
582
|
else if (idPath?.[1] === "resource")
|
|
543
|
-
answer(slot === null ? service.resource(first.person, idPath[2], instance) : service.ownerResource(first.person, idPath[2], slot));
|
|
583
|
+
answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
|
|
544
584
|
else if (idPath?.[1] === "profile" && instance === undefined)
|
|
545
585
|
answer(service.profile(first.person, idPath[2]));
|
|
546
586
|
else
|
|
@@ -594,8 +634,13 @@ function handleEffects(req, res, url, ctx, path, method) {
|
|
|
594
634
|
answer(service.remove(now.person, body));
|
|
595
635
|
else if (path === "/ui/effects/share")
|
|
596
636
|
answer(service.share(now.person, body));
|
|
597
|
-
else
|
|
598
|
-
|
|
637
|
+
else {
|
|
638
|
+
if (body && typeof body === "object" && body.kind === "playback" && (url.searchParams.getAll("instance").length !== 1 || slot !== null)) {
|
|
639
|
+
json(res, 400, { error: "playback_report" });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
answer(service.report(now.person, idPath[2], body, instance, now.epoch));
|
|
643
|
+
}
|
|
599
644
|
})().catch(() => { try {
|
|
600
645
|
json(res, 500, { error: "effect_request_failed" });
|
|
601
646
|
}
|
|
@@ -804,7 +849,7 @@ export function exportMod(ctx, person, body) {
|
|
|
804
849
|
return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
|
|
805
850
|
const parts = {};
|
|
806
851
|
let omitted = 0;
|
|
807
|
-
const wantsInstance = inc.theme === true || inc.identity === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
|
|
852
|
+
const wantsInstance = inc.theme === true || inc.identity === true || inc.ambient === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
|
|
808
853
|
let name = null;
|
|
809
854
|
if (wantsInstance) {
|
|
810
855
|
if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
|
|
@@ -817,7 +862,8 @@ export function exportMod(ctx, person, body) {
|
|
|
817
862
|
if (inc.theme === true) {
|
|
818
863
|
// The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
|
|
819
864
|
// Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
|
|
820
|
-
|
|
865
|
+
// #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
|
|
866
|
+
const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
|
|
821
867
|
omitted++;
|
|
822
868
|
return [];
|
|
823
869
|
} return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
|
|
@@ -830,6 +876,11 @@ export function exportMod(ctx, person, body) {
|
|
|
830
876
|
if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
|
|
831
877
|
(parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
|
|
832
878
|
}
|
|
879
|
+
// #2117: its ambients, whoever set them (a person exports what is on the chat).
|
|
880
|
+
if (inc.ambient === true) {
|
|
881
|
+
const specs = Object.fromEntries(Object.entries(rec.placements.ambient).flatMap(([k, a]) => a ? [[k, a.spec]] : []));
|
|
882
|
+
parts.ambient = { specs, picture: id => { const a = rec.assets.find(x => x.id === id); const b = store.asset(name, id); return a && b ? { bytes: b.bytes, kind: a.kind } : null; } };
|
|
883
|
+
}
|
|
833
884
|
}
|
|
834
885
|
if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
|
|
835
886
|
const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
|
|
@@ -869,6 +920,30 @@ function webPersonOf(req, url, ctx) {
|
|
|
869
920
|
const who = webAccountOf(req, url, ctx);
|
|
870
921
|
return who ? ctx.webPerson(who.owner) : null;
|
|
871
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* #1939 S1: whether this request carries `web-session` authority, asked as it stands now — on arrival without an instance,
|
|
925
|
+
* after the body with the one it names (then confirmAfterBody joins the two). Never touches the session.
|
|
926
|
+
*/
|
|
927
|
+
function webAuthorityOf(req, url, ctx, instance) {
|
|
928
|
+
if (!ctx.webAuthority)
|
|
929
|
+
return { authority: noAuthority(0), refusal: "not_session" };
|
|
930
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
931
|
+
return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
|
|
932
|
+
peer: classifyWebPeer(req), ...(instance !== undefined ? { instance } : {}) });
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
|
|
936
|
+
* header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
|
|
937
|
+
* one). Equal before and after an await: the same admission still holds. Never touches the session.
|
|
938
|
+
*/
|
|
939
|
+
function admissionOf(req, url, ctx) {
|
|
940
|
+
const gateway = gatewayRequestContext(req);
|
|
941
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
942
|
+
if (v.kind !== "allow")
|
|
943
|
+
return null;
|
|
944
|
+
const via = v.via === "session" ? `session:${v.session.handle}:${v.session.tokenEpoch}` : "token";
|
|
945
|
+
return gateway ? `${via}@${gateway.exposureId}` : via;
|
|
946
|
+
}
|
|
872
947
|
/** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
|
|
873
948
|
function webPersonOr401(req, res, url, ctx) {
|
|
874
949
|
const p = webPersonOf(req, url, ctx);
|
|
@@ -944,7 +1019,20 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
944
1019
|
return true;
|
|
945
1020
|
}
|
|
946
1021
|
// ── Backend detection ─────────────────────────────────
|
|
1022
|
+
// #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
|
|
1023
|
+
if (method === "GET" && path === "/ui/models") {
|
|
1024
|
+
const backend = url.searchParams.get("backend") ?? "";
|
|
1025
|
+
const instance = url.searchParams.get("instance");
|
|
1026
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(backend) || (instance !== null && (instance.length < 1 || instance.length > 256))) {
|
|
1027
|
+
json(res, 400, { error: "backend (and optional instance) required" });
|
|
1028
|
+
return true;
|
|
1029
|
+
}
|
|
1030
|
+
json(res, 200, { backend, models: ctx.modelCatalogFor?.(backend, instance) ?? [] });
|
|
1031
|
+
return true;
|
|
1032
|
+
}
|
|
947
1033
|
if (method === "GET" && path === "/ui/backends") {
|
|
1034
|
+
// #2072 (#2098 review P3-3): the backend an agent created with "fleet default" runs, so its model list can be shown.
|
|
1035
|
+
const defaultBackend = ctx.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
948
1036
|
const BACKENDS = [
|
|
949
1037
|
{ name: "claude-code", binary: "claude" },
|
|
950
1038
|
{ name: "codex", binary: "codex" },
|
|
@@ -954,6 +1042,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
954
1042
|
{ name: "grok", binary: "grok" },
|
|
955
1043
|
{ name: "muse", binary: "muse" },
|
|
956
1044
|
];
|
|
1045
|
+
// #2072: the published availability snapshot — installed, signed in, new enough — when the fleet has one.
|
|
1046
|
+
if (ctx.backendAvailabilityRows) {
|
|
1047
|
+
const binaryOf = new Map(BACKENDS.map(b => [b.name, b]));
|
|
1048
|
+
const backends = ctx.backendAvailabilityRows().filter(r => binaryOf.has(r.backend)).map(r => ({
|
|
1049
|
+
name: r.backend, binary: binaryOf.get(r.backend).binary, installed: r.state !== "not_installed" && r.path !== null, path: r.path ?? "",
|
|
1050
|
+
deprecated: binaryOf.get(r.backend).deprecated ?? false, state: r.state, minVersion: r.minVersion, ...(r.state === "unknown" ? { unknown: true } : {}),
|
|
1051
|
+
}));
|
|
1052
|
+
json(res, 200, { backends, defaultBackend });
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
957
1055
|
// #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
|
|
958
1056
|
// runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
|
|
959
1057
|
// time (shown as not installed, as the old timeout was).
|
|
@@ -963,7 +1061,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
963
1061
|
return { name: b.name, binary: b.binary, installed: path !== null, path: path ?? "", deprecated: b.deprecated ?? false,
|
|
964
1062
|
...(found.known ? {} : { unknown: true }) };
|
|
965
1063
|
})).then((backends) => { if (!res.headersSent)
|
|
966
|
-
json(res, 200, { backends }); }, () => { if (!res.headersSent)
|
|
1064
|
+
json(res, 200, { backends, defaultBackend }); }, () => { if (!res.headersSent)
|
|
967
1065
|
json(res, 500, { error: "backend probe failed" }); });
|
|
968
1066
|
return true;
|
|
969
1067
|
}
|
|
@@ -976,9 +1074,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
976
1074
|
// that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
|
|
977
1075
|
// review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
|
|
978
1076
|
if (method === "GET" && path === "/ui/poll") {
|
|
1077
|
+
// #1920: a guest's poll is projected for its scope (web-guest-access.ts) — the same rule as its stream.
|
|
1078
|
+
const guest = guestRequestOf(req);
|
|
979
1079
|
const history = ctx.webChatHistory;
|
|
980
1080
|
const after = url.searchParams.get("after");
|
|
981
1081
|
const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
|
|
1082
|
+
if (guest) {
|
|
1083
|
+
// #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
|
|
1084
|
+
json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
|
|
1085
|
+
if (!history)
|
|
1086
|
+
return [];
|
|
1087
|
+
if (fresh)
|
|
1088
|
+
return history.after(0);
|
|
1089
|
+
const cursor = parseLastEventId(after);
|
|
1090
|
+
const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
|
|
1091
|
+
return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
|
|
1092
|
+
}));
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
982
1095
|
json(res, 200, {
|
|
983
1096
|
status: ctx.getUiStatus(),
|
|
984
1097
|
messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
|
|
@@ -1011,30 +1124,49 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1011
1124
|
});
|
|
1012
1125
|
// #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
|
|
1013
1126
|
// poll down by (one write: the frames a connect sends are unchanged in number).
|
|
1014
|
-
|
|
1127
|
+
// #1920: a guest's stream: its projected status now and with each heartbeat, its replay and ticks as it may see them
|
|
1128
|
+
// (§5.2), then only what guestFrameData lets through — no prompts or Needs you.
|
|
1129
|
+
const guest = guestRequestOf(req);
|
|
1130
|
+
if (guest) {
|
|
1131
|
+
streamGuests.set(res, guest);
|
|
1132
|
+
guest.streamOpened?.();
|
|
1133
|
+
}
|
|
1134
|
+
const statusFrame = () => `event: status\ndata: ${JSON.stringify(guest ? guestStatus(ctx.getUiStatus(), guest.scope()) : ctx.getUiStatus())}\n\n`;
|
|
1135
|
+
res.write(`${statusFrame()}${sseClock()}`);
|
|
1015
1136
|
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
1016
1137
|
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
1017
1138
|
if (lastSeen !== null && ctx.webChatHistory) {
|
|
1018
1139
|
const history = ctx.webChatHistory;
|
|
1019
|
-
|
|
1020
|
-
|
|
1140
|
+
if (guest) {
|
|
1141
|
+
// Its own projection, with no cursor: an admin's ids are not a guest's to hold (it reloads its history instead).
|
|
1142
|
+
for (const m of guestMessages(history.replayFor(lastSeen), guest, guest.scope()))
|
|
1143
|
+
res.write(sseFrame("message", m));
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
for (const m of history.replayFor(lastSeen))
|
|
1147
|
+
res.write(sseFrame("message", m, history.cursorOf(m)));
|
|
1148
|
+
}
|
|
1021
1149
|
}
|
|
1022
1150
|
// Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
|
|
1023
1151
|
// report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
|
|
1024
|
-
if (ctx.webChatHistory)
|
|
1025
|
-
|
|
1152
|
+
if (ctx.webChatHistory) {
|
|
1153
|
+
const ticks = guest ? guestDeliveries(ctx.webChatHistory, guest, guest.scope()) : ctx.webChatHistory.deliveries();
|
|
1154
|
+
res.write(`event: deliveries\ndata: ${JSON.stringify(ticks)}\n\n`);
|
|
1155
|
+
}
|
|
1026
1156
|
// The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
|
|
1027
|
-
if (ctx.listWebPrompts)
|
|
1157
|
+
if (!guest && ctx.listWebPrompts)
|
|
1028
1158
|
res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
|
|
1029
1159
|
// #1386: and "Needs you" as it is now; changes follow as `needs` events.
|
|
1030
|
-
if (ctx.needsYouItems)
|
|
1160
|
+
if (!guest && ctx.needsYouItems)
|
|
1031
1161
|
res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
|
|
1032
1162
|
ctx.sseClients.add(res);
|
|
1033
1163
|
const interval = setInterval(() => {
|
|
1034
1164
|
// A stream authorized once must not outlive the authorization. Re-checked
|
|
1035
1165
|
// without counting as activity, or an open tab would keep an idle session
|
|
1036
1166
|
// alive forever; a revoked, expired or rotated-away session ends here.
|
|
1037
|
-
|
|
1167
|
+
// #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
|
|
1168
|
+
// still authenticating does not keep a revoked device's stream (with its scope) alive.
|
|
1169
|
+
if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
|
|
1038
1170
|
// Close the connection too, not just the response: an ended response leaves the socket idle in
|
|
1039
1171
|
// keep-alive, still held open by whoever's session just ended.
|
|
1040
1172
|
const socket = req.socket;
|
|
@@ -1047,7 +1179,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1047
1179
|
}
|
|
1048
1180
|
try {
|
|
1049
1181
|
// Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
|
|
1050
|
-
res.write(measureSyncWork("web.sseStatus",
|
|
1182
|
+
res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
|
|
1051
1183
|
}
|
|
1052
1184
|
catch {
|
|
1053
1185
|
cleanup();
|
|
@@ -1081,7 +1213,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1081
1213
|
json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
|
|
1082
1214
|
return true;
|
|
1083
1215
|
}
|
|
1084
|
-
|
|
1216
|
+
// #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
|
|
1217
|
+
const guest = guestRequestOf(req);
|
|
1218
|
+
const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
|
|
1219
|
+
json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
|
|
1085
1220
|
return true;
|
|
1086
1221
|
}
|
|
1087
1222
|
// ── #1218: the Log tab ─────────────────────────────────
|
|
@@ -1136,6 +1271,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1136
1271
|
const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
|
|
1137
1272
|
if (method === "GET" && fileMatch) {
|
|
1138
1273
|
const id = fileMatch[1];
|
|
1274
|
+
// #1920: a guest gets a file only when its stored instance is in scope — "no such file" otherwise, like an unknown id
|
|
1275
|
+
// (an id from another instance's chat is useless).
|
|
1276
|
+
const guest = guestRequestOf(req);
|
|
1277
|
+
if (guest) {
|
|
1278
|
+
const served = isFileId(id) ? ctx.webFiles?.servedFile?.(id) ?? null : null;
|
|
1279
|
+
if (!served || !guest.scope().has(served.instance)) {
|
|
1280
|
+
json(res, 404, { error: "No such file" });
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1139
1284
|
// #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
|
|
1140
1285
|
// memory. Every other type is answered exactly as before.
|
|
1141
1286
|
const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
|
|
@@ -1167,9 +1312,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1167
1312
|
return true;
|
|
1168
1313
|
}
|
|
1169
1314
|
// ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
|
|
1170
|
-
// GET /ui/themes: { rev, themes, locked, widgets } when the status frame's themes_rev moves (passive, web-auth.ts).
|
|
1315
|
+
// GET /ui/themes: { rev, themes, locked, widgets, ambient (#2117) } when the status frame's themes_rev moves (passive, web-auth.ts).
|
|
1171
1316
|
// GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
|
|
1172
|
-
// POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out)
|
|
1317
|
+
// POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out) | ambient (#2117: { spec } sets one,
|
|
1318
|
+
// { which? } alone clears that kind or both): a person's, from an admin session — never the CLI token, never the public
|
|
1173
1319
|
// link (absent from its manifest, and refused here too).
|
|
1174
1320
|
if (method === "GET" && path === "/ui/themes") {
|
|
1175
1321
|
const store = ctx.themeStore;
|
|
@@ -1177,7 +1323,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1177
1323
|
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1178
1324
|
return true;
|
|
1179
1325
|
}
|
|
1180
|
-
json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets() });
|
|
1326
|
+
json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets(), ambient: store.ambients() });
|
|
1181
1327
|
return true;
|
|
1182
1328
|
}
|
|
1183
1329
|
const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
|
|
@@ -1203,7 +1349,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1203
1349
|
res.end(image.bytes);
|
|
1204
1350
|
return true;
|
|
1205
1351
|
}
|
|
1206
|
-
const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget)$/);
|
|
1352
|
+
const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget|ambient)$/);
|
|
1207
1353
|
if (method === "POST" && themeWrite) {
|
|
1208
1354
|
const service = ctx.webAppearance;
|
|
1209
1355
|
if (!service) {
|
|
@@ -1255,7 +1401,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1255
1401
|
}
|
|
1256
1402
|
const r = themeWrite[2] === "reset" ? service.humanReset(name)
|
|
1257
1403
|
: themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
|
|
1258
|
-
:
|
|
1404
|
+
: themeWrite[2] === "ambient" ? service.humanSetAmbient(name, body.spec ?? null, body.which)
|
|
1405
|
+
: typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
|
|
1259
1406
|
if (typeof r.error === "string") {
|
|
1260
1407
|
json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
|
|
1261
1408
|
return;
|
|
@@ -1439,6 +1586,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1439
1586
|
return true;
|
|
1440
1587
|
}
|
|
1441
1588
|
const click = ctx.clickWebReplyButton.bind(ctx);
|
|
1589
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
1442
1590
|
(async () => {
|
|
1443
1591
|
let body;
|
|
1444
1592
|
try {
|
|
@@ -1455,13 +1603,29 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1455
1603
|
json(res, 400, { error: "instance, id and index required" });
|
|
1456
1604
|
return;
|
|
1457
1605
|
}
|
|
1606
|
+
// #1920 step 2b: a guest clicks only on a reply it may see (§5.2), of an in-scope instance, while guests may chat.
|
|
1607
|
+
const guest = guestRequestOf(req);
|
|
1608
|
+
const refused = guestChatRefusal(guest, instance);
|
|
1609
|
+
if (refused) {
|
|
1610
|
+
json(res, refused.status, { error: refused.error });
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (guest) {
|
|
1614
|
+
const row = guest.history?.()?.findByButtons(instance, id);
|
|
1615
|
+
if (!row || !guestSeesMessage(row, guest, guest.scope())) {
|
|
1616
|
+
json(res, 404, { error: "not found" });
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1458
1620
|
// #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
|
|
1459
1621
|
// theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
|
|
1460
1622
|
// gate (a session revoked meanwhile) clicks nothing as anyone.
|
|
1461
1623
|
const person = webPersonOr401(req, res, url, ctx);
|
|
1462
1624
|
if (!person)
|
|
1463
1625
|
return;
|
|
1464
|
-
|
|
1626
|
+
// #1920 review: a guest's choice reaches the agent marked as a guest's, as its typed messages do.
|
|
1627
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
1628
|
+
const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name, ...(guest ? { guest: true } : {}), authority });
|
|
1465
1629
|
json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
1466
1630
|
})().catch(err => {
|
|
1467
1631
|
ctx.logger.error({ err: err.message }, "Web reply-button click failed");
|
|
@@ -1639,12 +1803,57 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1639
1803
|
// #1269: an instance's chat command (/ctx, /compact, /clear, /model, …) — a session write (CSRF, checked by the
|
|
1640
1804
|
// gate), run through the platforms' own handlers and command table. The public link is told it is one (/save is
|
|
1641
1805
|
// refused there).
|
|
1806
|
+
// ── #1939 S2b: this browser's authorization — what the app shows (GET), and "Authorize this browser" (POST: posts the
|
|
1807
|
+
// Confirm card in the account's DM). Judged on this request (§3), never touching the session.
|
|
1808
|
+
if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
|
|
1809
|
+
const judge = () => {
|
|
1810
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1811
|
+
return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: classifyWebPeer(req) };
|
|
1812
|
+
};
|
|
1813
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1814
|
+
if (method === "GET") {
|
|
1815
|
+
if (!ctx.webSessionAuthorization) {
|
|
1816
|
+
json(res, 404, { error: "not available" });
|
|
1817
|
+
return true;
|
|
1818
|
+
}
|
|
1819
|
+
json(res, 200, ctx.webSessionAuthorization(judge()));
|
|
1820
|
+
return true;
|
|
1821
|
+
}
|
|
1822
|
+
if (!ctx.requestSessionAuthorization) {
|
|
1823
|
+
json(res, 404, { error: "not available" });
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
const ask = ctx.requestSessionAuthorization.bind(ctx);
|
|
1827
|
+
(async () => {
|
|
1828
|
+
try {
|
|
1829
|
+
await parseBody(req);
|
|
1830
|
+
}
|
|
1831
|
+
catch {
|
|
1832
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1836
|
+
return;
|
|
1837
|
+
const r = await ask(judge()); // asked again now the body is in
|
|
1838
|
+
// A refusal is an answer the page shows (pending, three this hour, why not), not a failed request: 200 with
|
|
1839
|
+
// ok: false — a 4xx would be logged as an error in the browser's console every time.
|
|
1840
|
+
json(res, 200, r);
|
|
1841
|
+
})().catch(err => {
|
|
1842
|
+
ctx.logger.error({ err: err.message }, "Web authorization request failed");
|
|
1843
|
+
try {
|
|
1844
|
+
json(res, 500, { error: "Request failed" });
|
|
1845
|
+
}
|
|
1846
|
+
catch { /* already answered */ }
|
|
1847
|
+
});
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
1642
1850
|
if (method === "POST" && path === "/ui/command") {
|
|
1643
1851
|
if (!ctx.webCommand) {
|
|
1644
1852
|
json(res, 404, { error: "Not available" });
|
|
1645
1853
|
return true;
|
|
1646
1854
|
}
|
|
1647
1855
|
const run = ctx.webCommand.bind(ctx);
|
|
1856
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
1648
1857
|
(async () => {
|
|
1649
1858
|
let body;
|
|
1650
1859
|
try {
|
|
@@ -1673,7 +1882,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1673
1882
|
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
1674
1883
|
if (!person)
|
|
1675
1884
|
return;
|
|
1676
|
-
const
|
|
1885
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
1886
|
+
const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority });
|
|
1677
1887
|
json(res, r.status, r.body);
|
|
1678
1888
|
})().catch(err => {
|
|
1679
1889
|
ctx.logger.error({ err: err.message }, "Web command failed");
|
|
@@ -1703,6 +1913,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1703
1913
|
json(res, 404, { error: `Instance not found: ${name}` });
|
|
1704
1914
|
return true;
|
|
1705
1915
|
}
|
|
1916
|
+
// #1920 step 2b: a guest's Cancel is the platform button's (anyone allowed there), while guests may chat.
|
|
1917
|
+
const cancelRefused = guestChatRefusal(guestRequestOf(req), name);
|
|
1918
|
+
if (cancelRefused) {
|
|
1919
|
+
json(res, cancelRefused.status, { error: cancelRefused.error });
|
|
1920
|
+
return true;
|
|
1921
|
+
}
|
|
1706
1922
|
if (!ctx.cancelInstance(name)) {
|
|
1707
1923
|
json(res, 409, { error: `${name} is not running` });
|
|
1708
1924
|
return true;
|
|
@@ -2112,6 +2328,47 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2112
2328
|
}
|
|
2113
2329
|
return true;
|
|
2114
2330
|
}
|
|
2331
|
+
// #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
|
|
2332
|
+
// usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
|
|
2333
|
+
// from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
|
|
2334
|
+
if (method === "GET" && path === "/ui/usage-report") {
|
|
2335
|
+
// Who may have it, asked on arrival and again before it is sent (the report is awaited: a switch turned off, a
|
|
2336
|
+
// session signed out or a public link ended meanwhile gets nothing). The same boundary both times, no new gate.
|
|
2337
|
+
const refusal = () => {
|
|
2338
|
+
if (!ctx.usageReport || ctx.fleetConfig?.web?.usage_panel === false)
|
|
2339
|
+
return [404, "Not available"];
|
|
2340
|
+
if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.usage_report_on_public_link !== true)
|
|
2341
|
+
return [403, "usage_report_not_over_public_link"];
|
|
2342
|
+
return null;
|
|
2343
|
+
};
|
|
2344
|
+
// Over the public link, where to turn it on: Settings → Web on the dashboard reached directly (this computer's own
|
|
2345
|
+
// address — the public link's Settings only reads). Never the public origin's own Settings, which cannot change it.
|
|
2346
|
+
const answer = ([status, error]) => json(res, status, {
|
|
2347
|
+
error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web` } : {})
|
|
2348
|
+
});
|
|
2349
|
+
const first = refusal();
|
|
2350
|
+
if (first) {
|
|
2351
|
+
answer(first);
|
|
2352
|
+
return true;
|
|
2353
|
+
}
|
|
2354
|
+
const admitted = admissionOf(req, url, ctx);
|
|
2355
|
+
if (!admitted) {
|
|
2356
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2357
|
+
return true;
|
|
2358
|
+
}
|
|
2359
|
+
void import("./usage-report.js").then(({ usageRange, usageGroup }) => ctx.usageReport({
|
|
2360
|
+
range: usageRange(url.searchParams.get("range"), url.searchParams.get("from") ?? undefined, url.searchParams.get("to") ?? undefined),
|
|
2361
|
+
group: usageGroup(url.searchParams.get("group")),
|
|
2362
|
+
})).then((report) => {
|
|
2363
|
+
const now = refusal();
|
|
2364
|
+
if (now)
|
|
2365
|
+
return answer(now);
|
|
2366
|
+
if (admissionOf(req, url, ctx) !== admitted)
|
|
2367
|
+
return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2368
|
+
json(res, 200, report);
|
|
2369
|
+
}, (err) => { ctx.logger.error({ err }, "usage report failed"); json(res, 500, { error: "Usage report failed" }); });
|
|
2370
|
+
return true;
|
|
2371
|
+
}
|
|
2115
2372
|
// #1468: the prompt-cache expiry analysis. Opening it is a person's navigation, like any Fleet tab (it counts as
|
|
2116
2373
|
// use); the page never re-reads it on a timer. Answered from the ledgers as they are; a catch-up runs behind.
|
|
2117
2374
|
if (method === "GET" && path === "/ui/cache") {
|
|
@@ -2129,6 +2386,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2129
2386
|
json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
|
|
2130
2387
|
return true;
|
|
2131
2388
|
}
|
|
2389
|
+
// #1918: the org canvas's traffic on opening, and the public link's (which has no stream) on its poll cadence. The
|
|
2390
|
+
// fleet works it out at most once every two seconds and caps it, whoever asks how often.
|
|
2391
|
+
if (method === "GET" && path === "/ui/traffic") {
|
|
2392
|
+
json(res, 200, ctx.trafficSnapshot?.() ?? { now: new Date().toISOString(), items: [], delegations: [] });
|
|
2393
|
+
return true;
|
|
2394
|
+
}
|
|
2132
2395
|
if (method === "GET" && path === "/ui/teams") {
|
|
2133
2396
|
const teams = ctx.fleetConfig?.teams ?? {};
|
|
2134
2397
|
json(res, 200, { teams });
|
|
@@ -2317,6 +2580,7 @@ function webEchoPreview(message, names) {
|
|
|
2317
2580
|
}
|
|
2318
2581
|
/** Handle POST /ui/send — extracted for readability. */
|
|
2319
2582
|
function handleSendMessage(req, res, ctx) {
|
|
2583
|
+
const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
|
|
2320
2584
|
let body = "";
|
|
2321
2585
|
let size = 0;
|
|
2322
2586
|
req.on("data", (chunk) => {
|
|
@@ -2340,6 +2604,17 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2340
2604
|
return;
|
|
2341
2605
|
}
|
|
2342
2606
|
const { instance, message: typed, attachments = [] } = parsed.data;
|
|
2607
|
+
// #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
|
|
2608
|
+
const guest = guestRequestOf(req);
|
|
2609
|
+
const refused = guestChatRefusal(guest, instance);
|
|
2610
|
+
if (refused) {
|
|
2611
|
+
json(res, refused.status, { error: refused.error });
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
if (guest && guestCommandText(typed)) {
|
|
2615
|
+
json(res, 403, { error: "guest_no_commands" });
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2343
2618
|
const ipc = ctx.instanceIpcClients.get(instance);
|
|
2344
2619
|
if (!ipc) {
|
|
2345
2620
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
@@ -2373,6 +2648,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2373
2648
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2374
2649
|
return;
|
|
2375
2650
|
}
|
|
2651
|
+
// #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
|
|
2652
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
|
|
2376
2653
|
const ts = new Date().toISOString();
|
|
2377
2654
|
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
2378
2655
|
const messageId = newWebMessageId();
|
|
@@ -2387,11 +2664,13 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2387
2664
|
&& !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
|
|
2388
2665
|
// Add no runtime credentials or local attachment paths to the display copy.
|
|
2389
2666
|
const preview = webEchoPreview(message, files.map(f => f.name));
|
|
2390
|
-
// ClassicBot echo (#1320 part B): same ordering lane,
|
|
2391
|
-
//
|
|
2667
|
+
// ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
|
|
2668
|
+
// the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
|
|
2392
2669
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
2393
2670
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
2394
|
-
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
|
|
2671
|
+
const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
|
|
2672
|
+
if (posted > 0)
|
|
2673
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
|
|
2395
2674
|
}) : undefined;
|
|
2396
2675
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
2397
2676
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -2399,12 +2678,14 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2399
2678
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
2400
2679
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
2401
2680
|
return;
|
|
2402
|
-
|
|
2681
|
+
const sent = await syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
|
|
2682
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
|
|
2683
|
+
return sent;
|
|
2403
2684
|
}) : undefined;
|
|
2404
2685
|
let delivered;
|
|
2405
2686
|
try {
|
|
2406
2687
|
const deliver = (name, payload) => ctx.deliverHumanToInstance
|
|
2407
|
-
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
|
|
2688
|
+
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed)
|
|
2408
2689
|
: ctx.deliverToInstance(name, payload);
|
|
2409
2690
|
delivered = await deliver(instance, {
|
|
2410
2691
|
type: "fleet_inbound",
|
|
@@ -2417,6 +2698,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2417
2698
|
ts,
|
|
2418
2699
|
thread_id: topicId != null ? String(topicId) : "",
|
|
2419
2700
|
source: "web",
|
|
2701
|
+
...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
|
|
2420
2702
|
adapter_id: syncAdapter?.id,
|
|
2421
2703
|
...delivery.meta,
|
|
2422
2704
|
},
|
|
@@ -2453,7 +2735,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
2453
2735
|
ctx.webFiles?.commit(files);
|
|
2454
2736
|
ctx.lastInboundUser.set(instance, person.name);
|
|
2455
2737
|
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
2456
|
-
|
|
2738
|
+
// #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
|
|
2739
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}) });
|
|
2457
2740
|
json(res, 200, { sent: true, messageId });
|
|
2458
2741
|
}
|
|
2459
2742
|
catch {
|
|
@@ -2477,6 +2760,13 @@ function handleUpload(req, res, url, ctx) {
|
|
|
2477
2760
|
json(res, 404, { error: "uploads are not available" });
|
|
2478
2761
|
return;
|
|
2479
2762
|
}
|
|
2763
|
+
// #1920 step 2b: a guest's upload is for an in-scope instance's message, while guests may chat.
|
|
2764
|
+
const guestRefused = guestChatRefusal(guestRequestOf(req), instance);
|
|
2765
|
+
if (guestRefused) {
|
|
2766
|
+
req.resume();
|
|
2767
|
+
json(res, guestRefused.status, { error: guestRefused.error });
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2480
2770
|
if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
|
|
2481
2771
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
2482
2772
|
return;
|
|
@@ -2671,14 +2961,22 @@ function prefsViewer(req, url, ctx) {
|
|
|
2671
2961
|
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
2672
2962
|
if (v.kind !== "allow")
|
|
2673
2963
|
return null;
|
|
2964
|
+
// #1920: a guest is a guest on every listener. The public link's page and reads too — before, a guest signed in through
|
|
2965
|
+
// it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
|
|
2966
|
+
// scope. Its account (phase 3) names it on its page.
|
|
2967
|
+
const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true, ...(v.session.owner ? { owner: v.session.owner } : {}) } : {};
|
|
2674
2968
|
if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
|
|
2675
2969
|
const person = webPersonKey(v.session.owner);
|
|
2676
|
-
return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
|
|
2970
|
+
return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
|
|
2677
2971
|
}
|
|
2678
|
-
return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
|
|
2972
|
+
return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
|
|
2679
2973
|
}
|
|
2680
2974
|
const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
|
|
2681
|
-
|
|
2975
|
+
// #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
|
|
2976
|
+
const prefsView = (prefs, v) => {
|
|
2977
|
+
const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
|
|
2978
|
+
return v?.guest ? guestPrefs(view) : view;
|
|
2979
|
+
};
|
|
2682
2980
|
function shellHtmlTag(ctx, mode, viewer) {
|
|
2683
2981
|
if (mode !== "full" || !ctx.webPrefs)
|
|
2684
2982
|
return '<html lang="en">';
|
|
@@ -2692,26 +2990,40 @@ function shellHtmlTag(ctx, mode, viewer) {
|
|
|
2692
2990
|
* the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
|
|
2693
2991
|
*/
|
|
2694
2992
|
function shellOwnerCss(ctx, mode, viewer) {
|
|
2695
|
-
return mode === "full" && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
|
|
2993
|
+
return mode === "full" && !viewer?.guest && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
|
|
2994
|
+
}
|
|
2995
|
+
function guestBodyAttrs(ctx, owner, attr) {
|
|
2996
|
+
const badge = ctx.guestBadge?.(owner);
|
|
2997
|
+
return ` data-tier="guest"${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
|
|
2998
|
+
}
|
|
2999
|
+
/** A separate session fence for Chat playback: public/guest viewers acquire no owner-pref identity. */
|
|
3000
|
+
function shellEffect(req, ctx, mode) {
|
|
3001
|
+
if (mode !== "full" || typeof ctx.effectBoot !== "string" || !/^[0-9a-f]{32}$/.test(ctx.effectBoot))
|
|
3002
|
+
return null;
|
|
3003
|
+
const v = evaluateWebRequest(req, new URL(req.url ?? "/", "http://localhost"), ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
3004
|
+
if (v.kind !== "allow" || v.via !== "session" || !v.session || !/^[a-f0-9]{16}$/.test(v.session.handle))
|
|
3005
|
+
return null;
|
|
3006
|
+
return { boot: ctx.effectBoot, epoch: v.session.handle, scope: gatewayRequestContext(req) || v.session.tier === "guest" ? "chat" : "owner" };
|
|
2696
3007
|
}
|
|
2697
3008
|
function shellBodyTag(req, ctx, mode, viewer) {
|
|
2698
3009
|
const p = shellPreview(req, ctx, mode);
|
|
2699
|
-
const ownerCss = shellOwnerCss(ctx, mode, viewer);
|
|
2700
|
-
let ownerCssState = "unknown";
|
|
2701
|
-
if (mode === "full" && viewer?.person != null && ctx.ownerCss) {
|
|
3010
|
+
const ownerCss = shellOwnerCss(ctx, mode, viewer), effect = shellEffect(req, ctx, mode);
|
|
3011
|
+
let ownerCssState = mode === "full" && (gatewayRequestContext(req) || viewer?.guest) ? "off" : "unknown";
|
|
3012
|
+
if (mode === "full" && !viewer?.guest && viewer?.person != null && ctx.ownerCss) {
|
|
2702
3013
|
ownerCssState = ctx.ownerCss.effectState(viewer.person);
|
|
2703
3014
|
if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
|
|
2704
3015
|
ownerCssState = "unknown";
|
|
2705
3016
|
}
|
|
2706
3017
|
const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
2707
3018
|
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)}"`
|
|
3019
|
+
+ `${effect ? ` data-effect-boot="${effect.boot}" data-effect-epoch="${effect.epoch}" data-effect-scope="${effect.scope}"` : ""}`
|
|
2708
3020
|
+ ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
|
|
2709
3021
|
// #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
|
|
2710
3022
|
+ ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
|
|
2711
3023
|
// #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
|
|
2712
3024
|
+ `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
|
|
2713
|
-
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's
|
|
2714
|
-
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
|
|
3025
|
+
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔ — never to a guest (#1920).
|
|
3026
|
+
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
|
|
2715
3027
|
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
2716
3028
|
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
2717
3029
|
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
@@ -2722,7 +3034,9 @@ function shellBodyTag(req, ctx, mode, viewer) {
|
|
|
2722
3034
|
// #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
|
|
2723
3035
|
+ ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
|
|
2724
3036
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|
|
2725
|
-
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}
|
|
3037
|
+
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
|
|
3038
|
+
// #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
|
|
3039
|
+
+ `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
|
|
2726
3040
|
}
|
|
2727
3041
|
/** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
|
|
2728
3042
|
* when this load chose one. */
|
|
@@ -2738,8 +3052,11 @@ export function serveAppShell(req, res, ctx, mode) {
|
|
|
2738
3052
|
const mediaSrc = own ? `${own}/ui/file/` : "'none'";
|
|
2739
3053
|
// A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
|
|
2740
3054
|
// the page may load); every other page frames exactly the origin this load chose (#1723).
|
|
2741
|
-
const
|
|
2742
|
-
|
|
3055
|
+
const previewFrames = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
|
|
3056
|
+
// This fixed trusted document is independent of HTML preview. Its own response confines all Worker execution.
|
|
3057
|
+
const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
|
|
3058
|
+
const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
|
|
3059
|
+
const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
|
|
2743
3060
|
sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
2744
3061
|
}
|
|
2745
3062
|
catch {
|