@songsid/agend 2.2.0-beta.9 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -24
- package/README.zh-TW.md +40 -24
- package/dist/agent-endpoint.js +3 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.js +7 -1
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +3 -1
- package/dist/auth-api.js +15 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.d.ts +33 -0
- package/dist/backend/antigravity.js +126 -18
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +3 -0
- package/dist/backend/claude-code.js +9 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +33 -0
- package/dist/backend/codex-metadata.js +122 -18
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -0
- package/dist/backend/codex.js +17 -1
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/effort-metadata.d.ts +37 -1
- package/dist/backend/effort-metadata.js +86 -15
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.d.ts +3 -0
- package/dist/backend/grok.js +9 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +3 -1
- package/dist/backend/kiro.js +7 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.d.ts +61 -0
- package/dist/backend/launch-effort.js +170 -0
- package/dist/backend/launch-effort.js.map +1 -0
- package/dist/backend/types.d.ts +24 -0
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-command.d.ts +91 -0
- package/dist/backend-command.js +122 -0
- package/dist/backend-command.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +12 -0
- package/dist/channel/adapters/discord.js +49 -2
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +18 -0
- package/dist/channel/adapters/telegram.js +24 -3
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.d.ts +2 -0
- package/dist/channel/tool-router.js +1 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +16 -0
- package/dist/cli.js +124 -24
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +2 -2
- package/dist/command-table.js +5 -2
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.d.ts +86 -0
- package/dist/compact-watch.js +288 -0
- package/dist/compact-watch.js.map +1 -0
- package/dist/config-validator.js +39 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.js +150 -9
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +8 -1
- package/dist/delivery-outbox.js +6 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.js +7 -0
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-context.d.ts +7 -0
- package/dist/fleet-manager.d.ts +158 -9
- package/dist/fleet-manager.js +827 -118
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +4 -3
- package/dist/forged-envelope-scan.js +4 -3
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
- package/dist/install-lock.d.ts +18 -1
- package/dist/install-lock.js +30 -3
- package/dist/install-lock.js.map +1 -1
- package/dist/instance-avatar.d.ts +5 -4
- package/dist/instance-avatar.js +12 -8
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instructions.js +2 -2
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.d.ts +38 -0
- package/dist/kiro-compact-pane.js +57 -0
- package/dist/kiro-compact-pane.js.map +1 -0
- package/dist/launchd-handoff.d.ts +43 -0
- package/dist/launchd-handoff.js +56 -0
- package/dist/launchd-handoff.js.map +1 -0
- package/dist/locale.js +202 -30
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +10 -2
- package/dist/log-service.js.map +1 -1
- package/dist/login-shell.d.ts +21 -0
- package/dist/login-shell.js +98 -0
- package/dist/login-shell.js.map +1 -0
- package/dist/outbound-handlers.d.ts +27 -0
- package/dist/outbound-handlers.js +120 -36
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +12 -0
- package/dist/outbound-schemas.js +15 -4
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +29 -0
- package/dist/provider-probe.js +198 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +11 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +8 -6
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +12 -0
- package/dist/quickstart.js +36 -6
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +86 -5
- package/dist/reply-buttons-controller.js +271 -7
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-buttons.d.ts +48 -3
- package/dist/reply-buttons.js +105 -10
- package/dist/reply-buttons.js.map +1 -1
- package/dist/reply-polls.d.ts +85 -0
- package/dist/reply-polls.js +154 -0
- package/dist/reply-polls.js.map +1 -0
- package/dist/restart-guard.d.ts +6 -3
- package/dist/restart-guard.js +6 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +0 -2
- package/dist/restart-progress.js +0 -2
- package/dist/restart-progress.js.map +1 -1
- package/dist/sd-notify.d.ts +22 -1
- package/dist/sd-notify.js +63 -10
- package/dist/sd-notify.js.map +1 -1
- package/dist/service-activation.d.ts +15 -7
- package/dist/service-activation.js +42 -26
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +24 -0
- package/dist/service-installer.js +49 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +11 -0
- package/dist/service-plan.js +23 -0
- package/dist/service-plan.js.map +1 -1
- package/dist/systemd-peer-bus.d.ts +95 -0
- package/dist/systemd-peer-bus.js +430 -0
- package/dist/systemd-peer-bus.js.map +1 -0
- package/dist/systemd-runtime.d.ts +27 -1
- package/dist/systemd-runtime.js +69 -7
- package/dist/systemd-runtime.js.map +1 -1
- package/dist/tool-permissions.js +3 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +68 -2
- package/dist/topic-commands.js +141 -27
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-jsonl.d.ts +7 -2
- package/dist/transcript-jsonl.js +13 -7
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-sources.js +6 -1
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/ui/chat-commands.js +1 -0
- package/dist/ui/chat-render.js +55 -10
- package/dist/ui/chat-store.js +69 -5
- package/dist/ui/chat-strings.js +28 -16
- package/dist/ui/chat-thread.js +132 -14
- package/dist/ui/chat-tour.js +17 -14
- package/dist/ui/panel-chat.js +16 -10
- package/dist/ui/panel-fleet.js +15 -2
- package/dist/ui/panel-log.js +4 -4
- package/dist/ui/panel-needs.js +10 -7
- package/dist/ui/panel-settings.js +58 -7
- package/dist/ui/settings-dialogs.js +11 -6
- package/dist/ui/settings-model.js +3 -1
- package/dist/ui/settings-strings.js +24 -10
- package/dist/ui/settings-token.js +7 -0
- package/dist/ui/settings-wizard.js +5 -3
- package/dist/ui/shared/app-i18n.js +1 -1
- package/dist/ui/shared/app-stream.js +4 -1
- package/dist/ui/shared/app.css +52 -10
- package/dist/ui/shared/header-tools.js +34 -14
- package/dist/ui/shared/instance-switch.js +5 -2
- package/dist/ui/shared/panel-view.js +15 -4
- package/dist/ui/shared/signin.js +15 -9
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/view-strings.js +10 -2
- package/dist/ui/signin.html +8 -3
- package/dist/update-check.d.ts +31 -0
- package/dist/update-check.js +47 -0
- package/dist/update-check.js.map +1 -1
- package/dist/update-launch.d.ts +48 -1
- package/dist/update-launch.js +159 -5
- package/dist/update-launch.js.map +1 -1
- package/dist/usage/providers.d.ts +28 -8
- package/dist/usage/providers.js +96 -41
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.js +26 -9
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +44 -2
- package/dist/web-api.js +265 -57
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +26 -1
- package/dist/web-chat-history.js +47 -8
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +39 -3
- package/dist/web-commands.js +22 -5
- package/dist/web-commands.js.map +1 -1
- package/dist/web-login.d.ts +14 -0
- package/dist/web-login.js +4 -2
- package/dist/web-login.js.map +1 -1
- package/dist/web-prefs.d.ts +18 -0
- package/dist/web-prefs.js +36 -4
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-profile.d.ts +32 -0
- package/dist/web-profile.js +132 -0
- package/dist/web-profile.js.map +1 -0
- package/dist/web-session.d.ts +14 -0
- package/dist/web-session.js +10 -0
- package/dist/web-session.js.map +1 -1
- package/dist/web-upload.d.ts +119 -4
- package/dist/web-upload.js +252 -39
- package/dist/web-upload.js.map +1 -1
- package/launcher/install-admission.cjs +61 -6
- package/package.json +3 -3
package/dist/web-api.js
CHANGED
|
@@ -15,7 +15,7 @@ import { isSafeInstanceName } from "./web-shell-routes.js";
|
|
|
15
15
|
import { MAX_COMMAND_ARGS } from "./web-commands.js";
|
|
16
16
|
import { sendPanelHtml } from "./web-host-guard.js";
|
|
17
17
|
import { shellRoute } from "./web-shell-routes.js";
|
|
18
|
-
import { readFileSync } from "node:fs";
|
|
18
|
+
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
19
19
|
import { join, dirname } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
|
|
@@ -26,9 +26,10 @@ import { z } from "zod";
|
|
|
26
26
|
import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
27
27
|
import { dashboardOriginFor } from "./web-preview.js";
|
|
28
28
|
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
29
|
-
import { attachmentDelivery, displayName, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment,
|
|
29
|
+
import { attachmentDelivery, createPartFile, displayName, fileTooLargeText, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, UPLOAD_LIMITS, UPLOAD_MAX_MS, UPLOAD_STALL_MS, UploadSniffer, uploadLimits, wellFormed } from "./web-upload.js";
|
|
30
30
|
import { getAgendHome } from "./paths.js";
|
|
31
31
|
import { OPERATOR } from "./web-prefs.js";
|
|
32
|
+
import { WEB_NAME_FALLBACK } from "./web-profile.js";
|
|
32
33
|
import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
|
|
33
34
|
import { parseMentionQuery } from "./web-mentions.js";
|
|
34
35
|
import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
@@ -149,6 +150,37 @@ function stillAuthorized(req, res, url, ctx) {
|
|
|
149
150
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
150
151
|
return false;
|
|
151
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
|
|
155
|
+
* no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
|
|
156
|
+
* session, the CLI's header token) none: the operator. For recognition only; nothing here authorizes anything.
|
|
157
|
+
*/
|
|
158
|
+
function webAccountOf(req, url, ctx) {
|
|
159
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
160
|
+
if (verdict.kind !== "allow")
|
|
161
|
+
return null;
|
|
162
|
+
return verdict.via === "session" && verdict.session?.owner ? { owner: verdict.session.owner } : {};
|
|
163
|
+
}
|
|
164
|
+
function webPersonOf(req, url, ctx) {
|
|
165
|
+
// No people kept here: everyone is "web-user", as before, and there is no identity to protect.
|
|
166
|
+
if (!ctx.webPerson)
|
|
167
|
+
return { key: OPERATOR, bound: false, userId: WEB_NAME_FALLBACK, voterKey: "web:operator", name: WEB_NAME_FALLBACK, set: false };
|
|
168
|
+
const who = webAccountOf(req, url, ctx);
|
|
169
|
+
return who ? ctx.webPerson(who.owner) : null;
|
|
170
|
+
}
|
|
171
|
+
/** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
|
|
172
|
+
function webPersonOr401(req, res, url, ctx) {
|
|
173
|
+
const p = webPersonOf(req, url, ctx);
|
|
174
|
+
if (!p)
|
|
175
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
/** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
|
|
179
|
+
function profileBody(p) {
|
|
180
|
+
return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
|
|
181
|
+
}
|
|
182
|
+
/** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
|
|
183
|
+
function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
|
|
152
184
|
/**
|
|
153
185
|
* Handle a Web UI request. Returns true if handled, false to pass through.
|
|
154
186
|
*/
|
|
@@ -261,6 +293,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
261
293
|
// #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
|
|
262
294
|
// records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
|
|
263
295
|
cursor: history ? history.pollCursor() : null,
|
|
296
|
+
// #1823 (#1830 review): the server's clock now, for the page's poll countdown (a poll's deadline is the server's).
|
|
297
|
+
now: Date.now(),
|
|
264
298
|
});
|
|
265
299
|
return true;
|
|
266
300
|
}
|
|
@@ -274,7 +308,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
274
308
|
"Cache-Control": "no-cache",
|
|
275
309
|
Connection: "keep-alive",
|
|
276
310
|
});
|
|
277
|
-
|
|
311
|
+
// #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
|
|
312
|
+
// poll down by (one write: the frames a connect sends are unchanged in number).
|
|
313
|
+
res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
|
|
278
314
|
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
279
315
|
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
280
316
|
if (lastSeen !== null && ctx.webChatHistory) {
|
|
@@ -310,7 +346,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
310
346
|
}
|
|
311
347
|
try {
|
|
312
348
|
// Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
|
|
313
|
-
res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
|
|
349
|
+
res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
|
|
314
350
|
}
|
|
315
351
|
catch {
|
|
316
352
|
cleanup();
|
|
@@ -406,12 +442,14 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
406
442
|
serveMediaRange(req, res, ctx.webFiles, id, media);
|
|
407
443
|
return true;
|
|
408
444
|
}
|
|
409
|
-
|
|
410
|
-
|
|
445
|
+
// #1937: a file over MEDIA_RANGE_MAX streams from the descriptor its checks were made on — never whole in memory.
|
|
446
|
+
const big = isFileId(id) && (ctx.webFiles?.servedFile?.(id)?.size ?? 0) > MEDIA_RANGE_MAX ? ctx.webFiles?.open?.(id) ?? null : null;
|
|
447
|
+
const got = big ? null : isFileId(id) ? ctx.webFiles?.read(id) : null;
|
|
448
|
+
const file = big?.file ?? got?.file;
|
|
449
|
+
if (!file) {
|
|
411
450
|
json(res, 404, { error: "No such file" });
|
|
412
451
|
return true;
|
|
413
452
|
}
|
|
414
|
-
const { file, bytes } = got;
|
|
415
453
|
const inline = INLINE_MIME.has(file.mime);
|
|
416
454
|
// Only the four image types are shown in the page; anything else is a download, never rendered.
|
|
417
455
|
res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
|
|
@@ -419,8 +457,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
419
457
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
420
458
|
res.setHeader("Cache-Control", "private, no-store");
|
|
421
459
|
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
460
|
+
if (big) {
|
|
461
|
+
streamWhole(res, big);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
422
464
|
res.writeHead(200);
|
|
423
|
-
res.end(bytes);
|
|
465
|
+
res.end(got.bytes);
|
|
424
466
|
return true;
|
|
425
467
|
}
|
|
426
468
|
// ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
|
|
@@ -527,6 +569,63 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
527
569
|
});
|
|
528
570
|
return true;
|
|
529
571
|
}
|
|
572
|
+
// #1827: the person's own name — the one agents and chats see for their web messages. Their own profile, so the public
|
|
573
|
+
// link may read and set it too (profile edits are allowed there); a read-only session may as well: it is about them.
|
|
574
|
+
if (path === "/ui/profile" && method === "GET") {
|
|
575
|
+
if (!ctx.webPerson) {
|
|
576
|
+
json(res, 404, { error: "Profiles are not kept on this fleet" });
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
const p = webPersonOr401(req, res, url, ctx);
|
|
580
|
+
if (p)
|
|
581
|
+
json(res, 200, profileBody(p));
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
if (path === "/ui/profile" && method === "POST") {
|
|
585
|
+
if (!ctx.setWebPersonName) {
|
|
586
|
+
json(res, 404, { error: "Profiles are not kept on this fleet" });
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
const set = ctx.setWebPersonName.bind(ctx);
|
|
590
|
+
(async () => {
|
|
591
|
+
let body;
|
|
592
|
+
try {
|
|
593
|
+
body = await parseBody(req);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
600
|
+
return;
|
|
601
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "name")) {
|
|
602
|
+
json(res, 400, { error: "name required (text, or null to clear)" });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
// Whose: asked once the body is in. A session revoked, expired or rotated away meanwhile writes nothing — it is
|
|
606
|
+
// not the operator (#1833 review).
|
|
607
|
+
const who = webAccountOf(req, url, ctx);
|
|
608
|
+
if (!who) {
|
|
609
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const r = set(who.owner, body.name);
|
|
613
|
+
if (!r.ok) {
|
|
614
|
+
json(res, r.status, { error: r.error });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// The person's other devices read it at their next status (the prefs rev moved).
|
|
618
|
+
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
619
|
+
json(res, 200, profileBody(r.person));
|
|
620
|
+
})().catch(err => {
|
|
621
|
+
ctx.logger.error({ err: err.message }, "Saving the web name failed");
|
|
622
|
+
try {
|
|
623
|
+
json(res, 500, { error: "Saving the name failed" });
|
|
624
|
+
}
|
|
625
|
+
catch { /* already answered */ }
|
|
626
|
+
});
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
530
629
|
// #1266: a click on an agent reply's button. The set id travels in the body; the public link may click too (a click
|
|
531
630
|
// is a message, and the public link may send messages).
|
|
532
631
|
if (method === "POST" && path === "/ui/reply-button") {
|
|
@@ -551,8 +650,14 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
551
650
|
json(res, 400, { error: "instance, id and index required" });
|
|
552
651
|
return;
|
|
553
652
|
}
|
|
554
|
-
|
|
555
|
-
|
|
653
|
+
// #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
|
|
654
|
+
// theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
|
|
655
|
+
// gate (a session revoked meanwhile) clicks nothing as anyone.
|
|
656
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
657
|
+
if (!person)
|
|
658
|
+
return;
|
|
659
|
+
const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
|
|
660
|
+
json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
556
661
|
})().catch(err => {
|
|
557
662
|
ctx.logger.error({ err: err.message }, "Web reply-button click failed");
|
|
558
663
|
try {
|
|
@@ -728,7 +833,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
728
833
|
json(res, 400, { error: "invalid confirmation" });
|
|
729
834
|
return;
|
|
730
835
|
}
|
|
731
|
-
const
|
|
836
|
+
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
837
|
+
if (!person)
|
|
838
|
+
return;
|
|
839
|
+
const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
|
|
732
840
|
json(res, r.status, r.body);
|
|
733
841
|
})().catch(err => {
|
|
734
842
|
ctx.logger.error({ err: err.message }, "Web command failed");
|
|
@@ -762,7 +870,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
762
870
|
json(res, 409, { error: `${name} is not running` });
|
|
763
871
|
return true;
|
|
764
872
|
}
|
|
765
|
-
ctx.eventLog?.logActivity("cancel",
|
|
873
|
+
ctx.eventLog?.logActivity("cancel", webPersonOf(req, url, ctx)?.name ?? WEB_NAME_FALLBACK, "stop the current reply", name);
|
|
766
874
|
json(res, 200, { cancelled: name });
|
|
767
875
|
return true;
|
|
768
876
|
}
|
|
@@ -993,12 +1101,15 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
993
1101
|
const parsed = parseOrReject(TaskCreateSchema, body, res);
|
|
994
1102
|
if (!parsed)
|
|
995
1103
|
return;
|
|
1104
|
+
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
1105
|
+
if (!person)
|
|
1106
|
+
return;
|
|
996
1107
|
const task = ctx.scheduler.db.createTask({
|
|
997
1108
|
title: parsed.title,
|
|
998
1109
|
description: parsed.description,
|
|
999
1110
|
priority: parsed.priority,
|
|
1000
1111
|
assignee: parsed.assignee,
|
|
1001
|
-
created_by:
|
|
1112
|
+
created_by: person.name, // #1827
|
|
1002
1113
|
});
|
|
1003
1114
|
json(res, 200, task);
|
|
1004
1115
|
}
|
|
@@ -1025,7 +1136,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1025
1136
|
return;
|
|
1026
1137
|
let result;
|
|
1027
1138
|
if (parsed.action === "claim") {
|
|
1028
|
-
|
|
1139
|
+
const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
|
|
1140
|
+
if (!parsed.assignee && !person)
|
|
1141
|
+
return;
|
|
1142
|
+
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
|
|
1029
1143
|
}
|
|
1030
1144
|
else if (parsed.action === "complete") {
|
|
1031
1145
|
result = ctx.scheduler.db.completeTask(id, parsed.result);
|
|
@@ -1414,6 +1528,14 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1414
1528
|
return;
|
|
1415
1529
|
}
|
|
1416
1530
|
const message = typed;
|
|
1531
|
+
// #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
|
|
1532
|
+
// body is in: a session revoked meanwhile sends nothing (not as the operator).
|
|
1533
|
+
const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
|
|
1534
|
+
if (!person) {
|
|
1535
|
+
ctx.webFiles?.release(files);
|
|
1536
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1417
1539
|
const ts = new Date().toISOString();
|
|
1418
1540
|
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
1419
1541
|
const messageId = newWebMessageId();
|
|
@@ -1432,7 +1554,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1432
1554
|
// opt-in resolved at send time inside sendClassicWebEcho.
|
|
1433
1555
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
1434
1556
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1435
|
-
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(
|
|
1557
|
+
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
|
|
1436
1558
|
}) : undefined;
|
|
1437
1559
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1438
1560
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -1440,7 +1562,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1440
1562
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
1441
1563
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
1442
1564
|
return;
|
|
1443
|
-
return syncAdapter.sendText(String(groupId), formatWebChannelEcho(
|
|
1565
|
+
return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
|
|
1444
1566
|
}) : undefined;
|
|
1445
1567
|
let delivered;
|
|
1446
1568
|
try {
|
|
@@ -1454,7 +1576,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1454
1576
|
meta: {
|
|
1455
1577
|
chat_id: groupId ? String(groupId) : "",
|
|
1456
1578
|
message_id: messageId,
|
|
1457
|
-
user:
|
|
1579
|
+
user: person.name, user_id: person.userId,
|
|
1458
1580
|
ts,
|
|
1459
1581
|
thread_id: topicId != null ? String(topicId) : "",
|
|
1460
1582
|
source: "web",
|
|
@@ -1492,9 +1614,9 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1492
1614
|
ctx.reserveWebChannelEcho?.(instance, () => startProgress(instance), { copy: false })(true);
|
|
1493
1615
|
}
|
|
1494
1616
|
ctx.webFiles?.commit(files);
|
|
1495
|
-
ctx.lastInboundUser.set(instance,
|
|
1496
|
-
ctx.eventLog?.logActivity("message",
|
|
1497
|
-
ctx.emitSseEvent("message", { instance, sender:
|
|
1617
|
+
ctx.lastInboundUser.set(instance, person.name);
|
|
1618
|
+
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
1619
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
|
|
1498
1620
|
json(res, 200, { sent: true, messageId });
|
|
1499
1621
|
}
|
|
1500
1622
|
catch {
|
|
@@ -1506,6 +1628,11 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1506
1628
|
* One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
|
|
1507
1629
|
* read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
|
|
1508
1630
|
* workspace inbox under a name chosen here; answered with the id the message will name.
|
|
1631
|
+
*
|
|
1632
|
+
* #1937: streamed to a part file in that inbox (web-pending-….part) as it arrives — never held whole in memory — and
|
|
1633
|
+
* counted by the bytes that came, whatever Content-Length said. The limits are the ones in force for this request
|
|
1634
|
+
* (uploadLimits(): the config, and the public link's own cap). An upload that goes UPLOAD_STALL_MS without a byte, or
|
|
1635
|
+
* runs past UPLOAD_MAX_MS, is dropped; any upload that does not end in a stored file takes its part file with it.
|
|
1509
1636
|
*/
|
|
1510
1637
|
function handleUpload(req, res, url, ctx) {
|
|
1511
1638
|
const instance = url.searchParams.get("instance") ?? "";
|
|
@@ -1517,57 +1644,112 @@ function handleUpload(req, res, url, ctx) {
|
|
|
1517
1644
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1518
1645
|
return;
|
|
1519
1646
|
}
|
|
1647
|
+
const limits = uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined });
|
|
1648
|
+
const tooLarge = () => json(res, 413, { error: fileTooLargeText(limits), max_bytes: limits.maxFileBytes, ...(limits.publicLinkCapped ? { public_link: true } : {}) });
|
|
1520
1649
|
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
1521
|
-
if (Number.isFinite(declared) && declared >
|
|
1522
|
-
|
|
1650
|
+
if (Number.isFinite(declared) && declared > limits.maxFileBytes) {
|
|
1651
|
+
tooLarge();
|
|
1652
|
+
req.resume();
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
let part, fd, discard;
|
|
1656
|
+
try {
|
|
1657
|
+
({ path: part, fd, discard } = createPartFile(join(getAgendHome(), "workspaces", instance, "inbox")));
|
|
1658
|
+
}
|
|
1659
|
+
catch (err) {
|
|
1660
|
+
ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
|
|
1661
|
+
json(res, 500, { error: "the file could not be stored" });
|
|
1523
1662
|
req.resume();
|
|
1524
1663
|
return;
|
|
1525
1664
|
}
|
|
1526
|
-
const
|
|
1665
|
+
const out = createWriteStream(part, { fd, autoClose: true });
|
|
1666
|
+
const sniffer = new UploadSniffer();
|
|
1527
1667
|
let size = 0;
|
|
1528
|
-
let
|
|
1668
|
+
let over = false; // ended without a stored file: no further write, and the part file goes
|
|
1669
|
+
let stall;
|
|
1670
|
+
const clocks = () => { clearTimeout(stall); clearTimeout(deadline); };
|
|
1671
|
+
/** Stop here: answer (when the client is still there), remove the part file, and stop a body still arriving. */
|
|
1672
|
+
const drop = (answer) => {
|
|
1673
|
+
if (over)
|
|
1674
|
+
return;
|
|
1675
|
+
over = true;
|
|
1676
|
+
clocks();
|
|
1677
|
+
answer?.();
|
|
1678
|
+
out.destroy();
|
|
1679
|
+
discard();
|
|
1680
|
+
if (!req.complete)
|
|
1681
|
+
req.destroy();
|
|
1682
|
+
};
|
|
1683
|
+
const armStall = () => { clearTimeout(stall); stall = setTimeout(() => drop(() => json(res, 408, { error: "the upload stalled — attach the file again" })), UPLOAD_STALL_MS); stall.unref?.(); };
|
|
1684
|
+
const deadline = setTimeout(() => drop(() => json(res, 408, { error: "the upload took too long — attach the file again" })), UPLOAD_MAX_MS);
|
|
1685
|
+
deadline.unref?.();
|
|
1686
|
+
armStall();
|
|
1687
|
+
out.on("error", (err) => { ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored"); drop(() => json(res, 500, { error: "the file could not be stored" })); });
|
|
1688
|
+
out.on("drain", () => { if (!over)
|
|
1689
|
+
req.resume?.(); });
|
|
1529
1690
|
req.on("data", (c) => {
|
|
1530
|
-
if (
|
|
1691
|
+
if (over)
|
|
1531
1692
|
return;
|
|
1532
1693
|
size += c.length;
|
|
1533
|
-
if (size >
|
|
1534
|
-
|
|
1535
|
-
json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
|
|
1536
|
-
req.destroy();
|
|
1694
|
+
if (size > limits.maxFileBytes) {
|
|
1695
|
+
drop(tooLarge);
|
|
1537
1696
|
return;
|
|
1538
1697
|
}
|
|
1539
|
-
|
|
1698
|
+
armStall();
|
|
1699
|
+
sniffer.push(c);
|
|
1700
|
+
if (!out.write(c))
|
|
1701
|
+
req.pause?.();
|
|
1540
1702
|
});
|
|
1541
|
-
req.on("error", () =>
|
|
1703
|
+
req.on("error", () => drop()); // the client went away
|
|
1704
|
+
req.on("close", () => { if (!over && req.complete === false)
|
|
1705
|
+
drop(); });
|
|
1706
|
+
// The instance is checked again now the whole body is here: it may have been deleted (and its files
|
|
1707
|
+
// forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
|
|
1708
|
+
// Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
|
|
1709
|
+
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
|
|
1710
|
+
const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
|
|
1711
|
+
const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
|
|
1542
1712
|
req.on("end", () => {
|
|
1543
|
-
if (
|
|
1713
|
+
if (over)
|
|
1544
1714
|
return;
|
|
1545
|
-
|
|
1715
|
+
clocks();
|
|
1716
|
+
if (!permitWebContinuation(req, res, ctx)) {
|
|
1717
|
+
drop();
|
|
1546
1718
|
return;
|
|
1547
|
-
|
|
1719
|
+
}
|
|
1548
1720
|
const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
|
|
1549
|
-
const type =
|
|
1721
|
+
const type = sniffer.result(name);
|
|
1550
1722
|
if (!type) {
|
|
1551
|
-
json(res, 415, { error: "
|
|
1723
|
+
drop(() => json(res, 415, { error: "the file is empty" }));
|
|
1552
1724
|
return;
|
|
1553
1725
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
// Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
|
|
1557
|
-
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
|
|
1558
|
-
const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
|
|
1559
|
-
if (!ctx.instanceIpcClients.has(instance) || !registered) {
|
|
1560
|
-
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1726
|
+
if (!deliverable()) {
|
|
1727
|
+
gone();
|
|
1561
1728
|
return;
|
|
1562
1729
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1730
|
+
out.end(() => {
|
|
1731
|
+
if (over)
|
|
1732
|
+
return;
|
|
1733
|
+
// The write's finish is another wait (Prism #1941 P2-1): the session or the public link may have ended in it.
|
|
1734
|
+
if (!permitWebContinuation(req, res, ctx)) {
|
|
1735
|
+
drop();
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (!deliverable()) {
|
|
1739
|
+
gone();
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
over = true;
|
|
1743
|
+
try {
|
|
1744
|
+
const entry = ctx.webFiles.storeUploadedFile({ instance, partPath: part, name, type });
|
|
1745
|
+
json(res, 200, publicAttachment(entry));
|
|
1746
|
+
}
|
|
1747
|
+
catch (err) {
|
|
1748
|
+
discard();
|
|
1749
|
+
ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
|
|
1750
|
+
json(res, 500, { error: "the file could not be stored" });
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1571
1753
|
});
|
|
1572
1754
|
}
|
|
1573
1755
|
function shellPreview(req, ctx, mode) {
|
|
@@ -1585,6 +1767,15 @@ function shellPreview(req, ctx, mode) {
|
|
|
1585
1767
|
* No Range, several, or one not in that form → 200 with the whole file, as every file is served (the Download link).
|
|
1586
1768
|
* Inline, nosniff, no caching, and the route's own sandbox policy, as for every file.
|
|
1587
1769
|
*/
|
|
1770
|
+
/** #1937: a whole file (headers already set) streamed from the descriptor its checks were made on: 200, its length. */
|
|
1771
|
+
function streamWhole(res, opened) {
|
|
1772
|
+
res.setHeader("Content-Length", String(opened.file.size));
|
|
1773
|
+
res.writeHead(200);
|
|
1774
|
+
const stream = createReadStream("", { fd: opened.fd, start: 0, end: Math.max(0, opened.file.size - 1), autoClose: true });
|
|
1775
|
+
stream.on("error", () => res.destroy());
|
|
1776
|
+
res.on("close", () => stream.destroy());
|
|
1777
|
+
stream.pipe(res);
|
|
1778
|
+
}
|
|
1588
1779
|
function serveMediaRange(req, res, files, id, file) {
|
|
1589
1780
|
const total = file.size;
|
|
1590
1781
|
const header = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
|
|
@@ -1608,20 +1799,34 @@ function serveMediaRange(req, res, files, id, file) {
|
|
|
1608
1799
|
}
|
|
1609
1800
|
}
|
|
1610
1801
|
end = ranged ? Math.min(end, total - 1, start + MEDIA_RANGE_MAX - 1) : total - 1;
|
|
1802
|
+
const mediaHeaders = () => {
|
|
1803
|
+
res.setHeader("Content-Type", file.mime);
|
|
1804
|
+
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
|
|
1805
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
1806
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1807
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
1808
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
1809
|
+
};
|
|
1810
|
+
// #1937 (Prism #1941 P3-2): the whole of a file over MEDIA_RANGE_MAX (no Range asked) streams, as /ui/file's do.
|
|
1811
|
+
if (!ranged && total > MEDIA_RANGE_MAX) {
|
|
1812
|
+
const opened = files.open(id);
|
|
1813
|
+
if (!opened) {
|
|
1814
|
+
json(res, 404, { error: "No such file" });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
mediaHeaders();
|
|
1818
|
+
streamWhole(res, opened);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1611
1821
|
const got = files.readSlice(id, start, end);
|
|
1612
1822
|
if (!got || (!ranged && got.bytes.length !== total)) {
|
|
1613
1823
|
json(res, 404, { error: "No such file" });
|
|
1614
1824
|
return;
|
|
1615
1825
|
}
|
|
1616
|
-
|
|
1617
|
-
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
|
|
1618
|
-
res.setHeader("Accept-Ranges", "bytes");
|
|
1826
|
+
mediaHeaders();
|
|
1619
1827
|
if (ranged)
|
|
1620
1828
|
res.setHeader("Content-Range", `bytes ${got.start}-${got.end}/${total}`);
|
|
1621
1829
|
res.setHeader("Content-Length", String(got.bytes.length));
|
|
1622
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1623
|
-
res.setHeader("Cache-Control", "private, no-store");
|
|
1624
|
-
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
1625
1830
|
res.writeHead(ranged ? 206 : 200);
|
|
1626
1831
|
res.end(got.bytes);
|
|
1627
1832
|
}
|
|
@@ -1653,6 +1858,9 @@ function shellBodyTag(req, ctx, mode) {
|
|
|
1653
1858
|
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
|
|
1654
1859
|
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
1655
1860
|
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
1861
|
+
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
1862
|
+
// checks before sending a file, and the numbers its words say. The server checks again on every upload.
|
|
1863
|
+
+ ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
|
|
1656
1864
|
// #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
|
|
1657
1865
|
+ ` data-fleet-tz="${attr(resolveTz())}"`
|
|
1658
1866
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|