feat(gateway): download inline artifacts over HTTPS (#154673)

* feat(gateway): download inline artifacts over HTTPS

* fix(ui): renew binary artifact downloads on click

* refactor(gateway): keep artifact read validation with session resolution

* fix(ui): reject proxy app pages during artifact downloads
This commit is contained in:
Peter Steinberger 2026-09-21 14:52:14 -07:00 • committed by GitHub
parent eba2d9f5da
commit 97dd4d233b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1798 additions and 374 deletions

View file

@ -1617,6 +1617,7 @@ public struct ArtifactsDownloadParams: Codable, Sendable {
public let agentid: String?
public let messagerole: String?
public let artifactid: String
public let transport: String?
public init(
sessionkey: String? = nil,
@ -1624,7 +1625,8 @@ public struct ArtifactsDownloadParams: Codable, Sendable {
taskid: String? = nil,
agentid: String? = nil,
messagerole: String? = nil,
artifactid: String)
artifactid: String,
transport: String? = nil)
{
self.sessionkey = sessionkey
self.runid = runid
@ -1632,6 +1634,7 @@ public struct ArtifactsDownloadParams: Codable, Sendable {
self.agentid = agentid
self.messagerole = messagerole
self.artifactid = artifactid
self.transport = transport
}
private enum CodingKeys: String, CodingKey {
@ -1641,6 +1644,7 @@ public struct ArtifactsDownloadParams: Codable, Sendable {
case agentid = "agentId"
case messagerole = "messageRole"
case artifactid = "artifactId"
case transport
}
}

View file

@ -227,6 +227,36 @@ Older image blocks without `artifactId` remain displayable by existing Control
UI clients, but native clients should show a readable attachment fallback rather
than forward a shared owner credential.
## Download inline artifacts over HTTPS
For artifacts embedded in transcripts, `artifacts.download` normally returns
`encoding: "base64"` and `data` over the existing connection. Clients that can
reach the Gateway's HTTPS routes can pass `transport: "http"` to receive a
Gateway-relative `url` and `expiresAt` instead, then fetch the original bytes
with `GET`. The route also supports `HEAD` and single byte ranges. Managed media
keeps its existing URL download path.
Use the active Gateway's HTTPS origin and configured Control UI base path. TLS
can terminate at the Gateway or its reverse proxy; the proxy must forward
`/api/artifacts/download/` as well as WebSocket upgrades. A `wss://` endpoint
alone does not establish HTTP reachability. WebSocket-only clients should omit
`transport`; a client whose HTTP route is unavailable can repeat the same RPC
without `transport`, preserving every session, run, task, and role filter.
Inline download links expire after five minutes and belong to the issuing live
connection and exact artifact. Reads recheck access, transcript membership,
session generation, and content identity. Expiry, disconnection, revocation, or
replacement makes the link unavailable. Reconnect and request a fresh download
instead of retaining links across connections. The links contain no reusable
Gateway credential, and responses disable caching and active document execution.
The Control UI opts in for image and text previews when served from the active
Gateway's HTTPS origin. If the HTTP fetch fails, it retries through the inline
RPC on that same connection. Other connections retain their existing transfer
behavior. Binary artifact previews keep a Download action rather than an
expiring link. Each click requests fresh download authority; changing the
connection or closing the preview cancels an in-flight download.
## Use history metadata and stable anchors
Rows returned by `chat.history` can carry an `__openclaw` metadata envelope:

View file

@ -80,6 +80,7 @@ providers ignore barge-in and end the session on an explicit output stop.
- `transcripts.list` (`operator.read`) lists durable meeting captures newest first. Optional `limit` accepts 1–200 (default 50); `providerId` filters the source. The `sessions` result includes selectors, provider/source locators, times, active state, utterance counts, participants, summary availability, optional model/heuristic provenance, and an overview preview capped at 280 characters. Source locators expose only `providerId`, `accountId`, `guildId`, `channelId`, and `meetingUrl`, never free-form metadata.
- `transcripts.get` (`operator.read`) accepts `selector` and optional `includeUtterances`. It returns the session and stored summary, including its canonical Markdown; requested utterances are sanitized and bounded by the capture limit of 2,000. Missing summaries omit `summary` rather than generating notes. Both transcript methods read across one trusted Gateway domain, like `agents.workspace.*`; separate domains are required for reader isolation. They do not export files or change capture state. See [Transcripts CLI](/cli/transcripts#gateway-and-control-ui-reads).
- `tasks.list`, `tasks.get`, and `tasks.cancel` expose the gateway task ledger to SDK and operator clients. See [Task ledger RPCs](/gateway/protocol/ledgers#task-ledger-rpcs).
- `artifacts.download` accepts optional `transport: "http"` for clients with a reachable Gateway HTTPS origin. Inline transcript artifacts then return a short-lived, connection-bound URL for raw-byte downloads; omitted `transport` preserves base64 over the existing connection. WebSocket availability alone does not establish HTTP reachability. See [HTTPS artifact downloads](/gateway/clients#download-inline-artifacts-over-https).
- `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side. Set `messageRole: "assistant"` on list, get, and download to select assistant-delivered artifacts, including delivery mirrors, and exclude uploaded inputs and raw tool observations. Omit the filter to keep all-role discovery. Combine it with `runId` for a particular run; a run ID alone does not identify generated output. Older Gateways reject the new filter; clients must surface that rejection instead of retrying without it.
- `artifacts.list` also accepts `type: "image"`, optional `limit` (1–4, default 4), and an opaque `cursor` for recent image discovery. Each page reads at most 32 transcript messages and 256 KiB, newest first; `nextCursor` continues into older messages even when a sparse page has no images. Image summaries include `image.url` from structured content, canonical uploaded media facts, or rendered Markdown references. Nonmanaged images use `preview_` ids, `source: "session-transcript-preview"`, and unsupported downloads: these are preview references, not identifiers for `artifacts.get` or `artifacts.download`. The Control UI uses the existing authenticated media routes and session media policy for local sources and the artifact download owner for managed media. Discovery never restores cold transcripts or rebuilds their projections; open the session to restore unavailable previews. `omittedOversized` reports skipped records; open the session to read them. Cursors expire after 15 minutes, belong to the originating connection, agent, session, and query, and reject transcript resets. Requests without `type` preserve the complete artifact listing; `limit` and `cursor` require the image filter.
- `environments.list` and `environments.status` (`operator.read`) remain available without cloud-worker profiles and preserve gateway-local and node environment discovery. `environments.list` also accepts an optional `runtimeId` from callers with `operator.write`. That request adds one Gateway-owned `requiredNodeCommand` result to each connected node when the runtime requires a node command. Its closed state is `invocable`, `pending-approval`, `undeclared`, or `unauthorized`; it never exposes the node's full pending declaration. Node environments include the durable `sessionHost` identity used to keep a known offline host visible, while current connected inventory is authoritative over that history. Missing identity means false. Exact bounded `{ total, available }` worker slots are live-only and omitted offline; worker-turn admission consumes a slot, while node-backed remote-exec does not. Configured profile summaries expose their bounded, canonically ordered `executionModes` array plus the existing singular `executionMode` primary/default display projection. Current clients select profiles only by membership in `executionModes`. Configured cloud workers and durable records left by earlier profiles add `worker` metadata with the configured `profileId`, `providerId`, optional `leaseId`, `state`, `ageMs`, optional `idleMs`, and `attachedSessionIds`. Worker lifecycle states are `requested`, `provisioning`, `bootstrapping`, `ready`, `attached`, `idle`, `draining`, `destroying`, `destroyed`, `failed`, and `orphaned`. A connected node may also include `workerBundle: { status: "installed", version }` or `workerBundle: { status: "missing" }`. This optional observation is reconnect-scoped and reports validation of one Gateway-retained bundle; it is not launch authority. The public result never exposes the bundle hash, Gateway namespace, node filesystem path, receipt, or protocol-feature details.

View file

@ -0,0 +1,2 @@
/** Exact-resource downloads authorized through artifacts.download. */
export const ARTIFACT_DOWNLOAD_PATH = "/api/artifacts/download/";

View file

@ -67,7 +67,12 @@ export const ArtifactsGetResultSchema = closedObject({
});
/** Download request payload for one artifact. */
export const ArtifactsDownloadParamsSchema = ArtifactGetParamsSchema;
export const ArtifactsDownloadParamsSchema = closedObject({
...ArtifactQueryParamsProperties,
artifactId: NonEmptyString,
/** Opt in only when the client can reach the Gateway's HTTP(S) media routes. */
transport: Type.Optional(Type.Literal("http")),
});
/** Download response, either inline base64 bytes, URL, or metadata for unsupported modes. */
export const ArtifactsDownloadResultSchema = closedObject({

View file

@ -0,0 +1,165 @@
import { createHash, randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { normalizeMimeType } from "@openclaw/media-core/mime";
import { ARTIFACT_DOWNLOAD_PATH } from "../../packages/gateway-protocol/src/artifact-download.js";
import { buildAssistantMediaContentDisposition } from "./assistant-media-content-disposition.js";
import { respondNotFound } from "./control-ui-http-utils.js";
import { resolveByteResponse, writeByteHeaders } from "./http-byte-range.js";
import { sendMethodNotAllowed } from "./http-common.js";
import type { ArtifactRecord } from "./server-methods/artifacts-content.js";
import type { GatewayClient } from "./server-methods/types.js";
const DOWNLOAD_TTL_MS = 5 * 60_000;
const MAX_DOWNLOADS_PER_CONNECTION = 128;
type Download = {
expiresAt: number;
digest: string;
assertCurrent: () => void;
read: () => Promise<ArtifactRecord | undefined>;
};
// Connection-owned grants retain only a reader, never artifact bytes or durable state.
const downloads = new WeakMap<GatewayClient, Map<string, Download>>();
function artifactContentDigest(artifact: ArtifactRecord): string {
return createHash("sha256")
.update(JSON.stringify([artifact.id, artifact.type, artifact.title, artifact.mimeType]))
.update("\0")
.update(artifact.data ?? "")
.digest("hex");
}
export function createArtifactDownload(params: {
client: GatewayClient | null;
artifact: ArtifactRecord;
assertCurrent: Download["assertCurrent"];
read: Download["read"];
}): { url: string; expiresAt: string } | undefined {
const { client } = params;
if (
!client?.connId ||
client.connectionSignal?.aborted !== false ||
client.invalidated ||
params.artifact.download.mode !== "bytes" ||
params.artifact.data === undefined
) {
return undefined;
}
params.assertCurrent();
let grants = downloads.get(client);
if (!grants) {
grants = new Map();
downloads.set(client, grants);
}
const now = Date.now();
for (const [ticket, grant] of grants) {
if (grant.expiresAt <= now) {
grants.delete(ticket);
}
}
while (grants.size >= MAX_DOWNLOADS_PER_CONNECTION) {
const oldest = grants.keys().next().value;
if (oldest !== undefined) {
grants.delete(oldest);
}
}
const ticket = randomBytes(32).toString("base64url");
const expiresAt = now + DOWNLOAD_TTL_MS;
grants.set(ticket, {
expiresAt,
digest: artifactContentDigest(params.artifact),
assertCurrent: params.assertCurrent,
read: params.read,
});
return {
url: `${ARTIFACT_DOWNLOAD_PATH}${encodeURIComponent(client.connId)}/${ticket}`,
expiresAt: new Date(expiresAt).toISOString(),
};
}
/** The RPC chooses and authorizes the resource; HTTP never accepts a replacement query. */
export async function handleArtifactDownloadHttpRequest(
req: IncomingMessage,
res: ServerResponse,
opts: { clients: ReadonlySet<GatewayClient>; basePath: string },
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const pathname =
opts.basePath && url.pathname.startsWith(`${opts.basePath}${ARTIFACT_DOWNLOAD_PATH}`)
? url.pathname.slice(opts.basePath.length)
: url.pathname;
if (!pathname.startsWith(ARTIFACT_DOWNLOAD_PATH)) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
sendMethodNotAllowed(res, "GET, HEAD");
return true;
}
const [connection, ticket, extra] = pathname.slice(ARTIFACT_DOWNLOAD_PATH.length).split("/");
const client = [...opts.clients].find(
(candidate) => candidate.connId && encodeURIComponent(candidate.connId) === connection,
);
const grants = client ? downloads.get(client) : undefined;
const grant = ticket && extra === undefined ? grants?.get(ticket) : undefined;
if (!client || !grant || !ticket) {
respondNotFound(res);
return true;
}
const assertCurrent = () => {
if (
!opts.clients.has(client) ||
client.connectionSignal?.aborted !== false ||
client.invalidated ||
grant.expiresAt <= Date.now() ||
grants?.get(ticket) !== grant
) {
throw new Error("Artifact download expired");
}
grant.assertCurrent();
};
let artifact: ArtifactRecord | undefined;
try {
assertCurrent();
artifact = await grant.read();
assertCurrent();
} catch {
respondNotFound(res);
return true;
}
if (
artifact?.download.mode !== "bytes" ||
artifact.data === undefined ||
artifactContentDigest(artifact) !== grant.digest
) {
respondNotFound(res);
return true;
}
const bytes = Buffer.from(artifact.data, "base64");
const mime = normalizeMimeType(artifact.mimeType);
const contentType =
mime && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mime)
? mime
: "application/octet-stream";
res.setHeader("content-type", contentType);
// Transcript content can contain active HTML or SVG. Never grant it the Gateway origin.
res.setHeader("content-disposition", buildAssistantMediaContentDisposition(artifact.title));
res.setHeader("content-security-policy", "default-src 'none'; sandbox");
res.setHeader("x-content-type-options", "nosniff");
res.setHeader("referrer-policy", "no-referrer");
res.setHeader("cache-control", "private, no-store");
const response = resolveByteResponse({
file: { size: bytes.length },
method: req.method,
request: req,
});
writeByteHeaders(res, response);
res.end(
req.method === "HEAD" || response.kind === "unsatisfiable" || response.kind === "not-modified"
? undefined
: response.kind === "partial"
? bytes.subarray(response.range.start, response.range.end + 1)
: bytes,
);
return true;
}

View file

@ -0,0 +1,304 @@
import { X509Certificate } from "node:crypto";
import { readFile } from "node:fs/promises";
import { Agent, fetch } from "undici";
import { expect, test, vi } from "vitest";
import type {
ArtifactsDownloadResult,
ArtifactsListResult,
} from "../../packages/gateway-protocol/src/schema/artifacts.js";
import { acquireGatewayTestClient } from "../../test/helpers/gateway-client.js";
import { runQaGatewayFixture } from "../../test/helpers/qa-gateway-cleanup.js";
import { replaceSessionEntry } from "../config/sessions/session-accessor.sqlite-entry.js";
import { replaceTranscriptEvents } from "../config/sessions/session-accessor.sqlite-transcript-write.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { acquireTestPortBlock } from "../test-utils/port-claims.js";
import type { GatewayClient } from "./client.js";
import { startGatewayServer } from "./server.js";
import { startClaimedGateway } from "./test-helpers.listener.js";
test("downloads inline artifacts over authenticated WSS and HTTPS with live ticket authority", async () => {
await withOpenClawTestState(
{
label: "artifact-https-download",
env: {
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
},
},
async (state) => {
const claim = await acquireTestPortBlock({ offsets: [0, 1, 2, 3, 4] });
const token = "synthetic-artifact-https-token";
const certPath = state.statePath("tls", "cert.pem");
const keyPath = state.statePath("tls", "key.pem");
const sessionKey = "agent:main:artifact-https";
const sessionId = "artifact-https-session";
const runId = "artifact-https-run";
const taskId = "artifact-https-task";
const scope = { agentId: "main", sessionKey, sessionId, env: state.env };
const binary = Buffer.from(Array.from({ length: 8192 }, (_, index) => index % 256));
const fixtures = [
{ title: "binary.bin", type: "file", mimeType: "application/octet-stream", bytes: binary },
{
title: "drawing.svg",
type: "image",
mimeType: "image/svg+xml",
bytes: Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><text>synthetic</text></svg>',
),
},
{
title: "empty.bin",
type: "file",
mimeType: "application/octet-stream",
bytes: Buffer.alloc(0),
},
];
const timestamp = new Date().toISOString();
const events = [
{ type: "session", version: 3, id: sessionId, timestamp, cwd: state.workspaceDir },
{
type: "message",
id: "inline-artifacts",
parentId: null,
timestamp,
message: {
role: "assistant",
content: fixtures.map((fixture) => ({
type: fixture.type,
title: fixture.title,
mimeType: fixture.mimeType,
...(fixture.type === "image"
? {
source: {
type: "base64",
media_type: fixture.mimeType,
data: fixture.bytes.toString("base64"),
},
}
: { data: fixture.bytes.toString("base64") }),
})),
__openclaw: { id: "inline-artifacts", runId, messageTaskId: taskId },
},
},
];
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
let dispatcher: Agent | undefined;
const clients: GatewayClient[] = [];
await runQaGatewayFixture(
async () => {
await state.writeConfig({
agents: { defaults: { workspace: state.workspaceDir } },
gateway: {
auth: { mode: "token", token },
controlUi: { enabled: false, basePath: "/gateway" },
tls: { enabled: true, certPath, keyPath },
},
});
await replaceSessionEntry(scope, { sessionId, updatedAt: Date.now() });
await replaceTranscriptEvents(scope, events);
server = await startClaimedGateway(claim, () =>
startGatewayServer(claim.port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
sidecarStartup: "defer",
}),
);
const cert = await readFile(certPath, "utf8");
dispatcher = new Agent({ connect: { ca: cert, servername: "openclaw-gateway" } });
const origin = `https://127.0.0.1:${claim.port}`;
const connect = async (label: string, scopes: string[]) => {
const client = await acquireGatewayTestClient(
{
url: `wss://127.0.0.1:${claim.port}`,
token,
tlsFingerprint: new X509Certificate(cert).fingerprint256,
clientName: "test",
mode: "test",
scopes,
deviceIdentity: loadOrCreateDeviceIdentity({
path: state.statePath(`${label}.sqlite`),
}),
},
{
timeoutMs: 10_000,
timeoutMessage: "TLS Gateway connect timed out",
closeMessage: "TLS Gateway closed",
},
);
clients.push(client);
return client;
};
const client = await connect("reader", ["operator.read"]);
const query = { sessionKey, runId, taskId, messageRole: "assistant" };
const listed = await client.request<ArtifactsListResult>("artifacts.list", query);
expect(listed.artifacts).toHaveLength(fixtures.length);
const request = async (
url: string,
init: { method?: string; headers?: Record<string, string> } = {},
) => {
const response = await fetch(new URL(url, origin), { ...init, dispatcher });
return {
status: response.status,
headers: response.headers,
bytes: Buffer.from(await response.arrayBuffer()),
};
};
const downloads: ArtifactsDownloadResult[] = [];
for (const fixture of fixtures) {
const artifact = listed.artifacts.find((entry) => entry.title === fixture.title);
expect(artifact).toBeDefined();
const params = { ...query, artifactId: artifact!.id };
const inline = await client.request<ArtifactsDownloadResult>(
"artifacts.download",
params,
);
expect(inline.artifact.download.mode).toBe("bytes");
expect(inline.encoding).toBe("base64");
expect(inline.url).toBeUndefined();
expect(Buffer.from(inline.data!, "base64")).toEqual(fixture.bytes);
const download = await client.request<ArtifactsDownloadResult>("artifacts.download", {
...params,
transport: "http",
});
expect(download.artifact.download.mode).toBe("url");
expect(download.data).toBeUndefined();
expect(download.encoding).toBeUndefined();
expect(download.url?.startsWith("/api/artifacts/download/")).toBe(true);
expect(Date.parse(download.expiresAt!)).toBeGreaterThan(Date.now());
downloads.push(download);
const body = await request(download.url!);
expect(body.status).toBe(200);
expect(body.headers.get("content-type")).toBe(fixture.mimeType);
expect(body.headers.get("content-length")).toBe(String(fixture.bytes.length));
expect(body.headers.get("content-disposition")).toContain("attachment;");
expect(body.headers.get("content-disposition")).toContain(fixture.title);
expect(body.headers.get("x-content-type-options")).toBe("nosniff");
expect(body.headers.get("content-security-policy")).toContain("sandbox");
expect(body.bytes).toEqual(fixture.bytes);
const head = await request(download.url!, { method: "HEAD" });
expect(head.status).toBe(200);
expect(head.headers.get("content-length")).toBe(String(fixture.bytes.length));
expect(head.bytes.length).toBe(0);
}
const download = downloads[0]!;
const url = download.url!;
const mounted = await request(`/gateway${url}`);
expect(mounted.status, "the artifact route works below the configured mount").toBe(200);
expect(mounted.bytes).toEqual(binary);
const ranged = await request(url, { headers: { Range: "bytes=253-258" } });
expect(ranged.status).toBe(206);
expect(ranged.headers.get("content-range")).toBe(`bytes 253-258/${binary.length}`);
expect(ranged.bytes).toEqual(binary.subarray(253, 259));
expect((await request(url, { headers: { Range: "bytes=8192-" } })).status).toBe(416);
expect((await request(url, { method: "POST" })).status).toBe(405);
const tamperedUrl = `${url.slice(0, -1)}${url.endsWith("a") ? "b" : "a"}`;
expect((await request(tamperedUrl)).status).toBe(404);
expect((await request("/api/artifacts/download/missing/missing")).status).toBe(404);
const underscoped = await connect("approver", ["operator.approvals"]);
await expect(
underscoped.request("artifacts.download", {
...query,
artifactId: download.artifact.id,
transport: "http",
}),
).rejects.toThrow("missing scope: operator.read");
await expect(
client.request("artifacts.download", {
...query,
runId: "unrelated-run",
artifactId: download.artifact.id,
transport: "http",
}),
).rejects.toMatchObject({ details: { type: "artifact_not_found" } });
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse(download.expiresAt!) + 1);
try {
expect((await request(url)).status).toBe(404);
} finally {
clock.mockRestore();
}
const renewed = await client.request<ArtifactsDownloadResult>("artifacts.download", {
...query,
artifactId: download.artifact.id,
transport: "http",
});
const replacement = Buffer.from(binary);
replacement[0] = 255;
const replacedEvents = events.map((event) =>
event.message
? {
...event,
message: {
...event.message,
content: event.message.content.map((block, index) =>
index === 0 ? { ...block, data: replacement.toString("base64") } : block,
),
},
}
: event,
);
await replaceTranscriptEvents(scope, replacedEvents);
expect(
(await request(renewed.url!)).status,
"an existing ticket cannot download replaced bytes under the same artifact ID",
).toBe(404);
const replaced = await client.request<ArtifactsDownloadResult>("artifacts.download", {
...query,
artifactId: download.artifact.id,
transport: "http",
});
expect(replaced.artifact.id).toBe(download.artifact.id);
const replacedBody = await request(replaced.url!);
expect(replacedBody.status).toBe(200);
expect(replacedBody.bytes).toEqual(replacement);
await replaceTranscriptEvents(scope, events);
const beforeRemoval = await client.request<ArtifactsDownloadResult>(
"artifacts.download",
{
...query,
artifactId: download.artifact.id,
transport: "http",
},
);
await replaceTranscriptEvents(scope, []);
expect(
(await request(beforeRemoval.url!)).status,
"removing the artifact from its transcript revokes its download",
).toBe(404);
await replaceTranscriptEvents(scope, events);
const beforeDisconnect = await client.request<ArtifactsDownloadResult>(
"artifacts.download",
{ ...query, artifactId: download.artifact.id, transport: "http" },
);
await client.stopAndWait();
expect((await request(beforeDisconnect.url!)).status).toBe(404);
},
async () => {
for (const client of clients) {
await client.stopAndWait();
}
},
async () => {
await dispatcher?.close();
},
async () => {
await server?.close({ reason: "artifact HTTPS proof complete" });
},
() => claim.release(),
);
},
);
}, 60_000);

View file

@ -0,0 +1,49 @@
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
export const getControlUiModule = createLazyRuntimeModule(() => import("./control-ui.js"));
export const getControlUiPluginAssetsModule = createLazyRuntimeModule(
() => import("./control-ui-plugin-assets.js"),
);
export const getCanvasServeModule = createLazyRuntimeModule(
() => import("../canvas/serve.runtime.js"),
);
export const getBoardHttpModule = createLazyRuntimeModule(() => import("./board-http.js"));
export const getEmbeddingsHttpModule = createLazyRuntimeModule(
() => import("./embeddings-http.js"),
);
export const getManagedMediaAttachmentsModule = createLazyRuntimeModule(
() => import("./managed-image-attachments.js"),
);
export const getArtifactDownloadsModule = createLazyRuntimeModule(
() => import("./artifact-downloads.js"),
);
export const getMcpAppStandaloneModule = createLazyRuntimeModule(
() => import("./mcp-app-standalone.js"),
);
export const getModelsHttpModule = createLazyRuntimeModule(() => import("./models-http.js"));
export const getOpenAiHttpModule = createLazyRuntimeModule(() => import("./openai-http.js"));
export const getOpenResponsesHttpModule = createLazyRuntimeModule(
() => import("./openresponses-http.js"),
);
export const getSessionHistoryHttpModule = createLazyRuntimeModule(
() => import("./sessions-history-http.js"),
);
export const getSessionKillHttpModule = createLazyRuntimeModule(
() => import("./session-kill-http.js"),
);
export const getToolsInvokeHttpModule = createLazyRuntimeModule(
() => import("./tools-invoke-http.js"),
);
export const getUserProfilesHttpModule = createLazyRuntimeModule(
() => import("./user-profiles-http.js"),
);
export const getDevicePairingJoinHttpModule = createLazyRuntimeModule(
() => import("./device-pairing-join-http.js"),
);
export const getPluginNodeCapabilityAuthModule = createLazyRuntimeModule(
() => import("./server/plugin-node-capability-auth.js"),
);
export const getHttpAuthUtilsModule = createLazyRuntimeModule(() => import("./http-auth-utils.js"));
export const getPluginRouteRuntimeScopesModule = createLazyRuntimeModule(
() => import("./server/plugin-route-runtime-scopes.js"),
);

View file

@ -8,6 +8,7 @@ import {
} from "node:http";
import { createServer as createHttpsServer } from "node:https";
import type { TlsOptions } from "node:tls";
import { ARTIFACT_DOWNLOAD_PATH } from "../../packages/gateway-protocol/src/artifact-download.js";
import { isCoreCanvasHostEnabled } from "../canvas/config.js";
import { isCanvasDocumentHttpPath } from "../canvas/constants.js";
import { getRuntimeConfig } from "../config/io.js";
@ -20,7 +21,6 @@ import {
import { runHttpConnectionRequest } from "../infra/http-request-lifecycle.js";
import { readTailscaleWhoisIdentity } from "../infra/tailscale.js";
import { parseDevicePairingJoinRequestPath } from "../pairing/join-code.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { resolveAssistantAgentId } from "./assistant-identity.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
@ -65,6 +65,27 @@ import {
handleProviderOAuthCallback,
PROVIDER_OAUTH_CALLBACK_PATH,
} from "./provider-browser-auth.js";
import {
getControlUiModule,
getControlUiPluginAssetsModule,
getCanvasServeModule,
getBoardHttpModule,
getEmbeddingsHttpModule,
getManagedMediaAttachmentsModule,
getArtifactDownloadsModule,
getMcpAppStandaloneModule,
getModelsHttpModule,
getOpenAiHttpModule,
getOpenResponsesHttpModule,
getSessionHistoryHttpModule,
getSessionKillHttpModule,
getToolsInvokeHttpModule,
getUserProfilesHttpModule,
getDevicePairingJoinHttpModule,
getPluginNodeCapabilityAuthModule,
getHttpAuthUtilsModule,
getPluginRouteRuntimeScopesModule,
} from "./server-http-modules.js";
import {
getCachedPluginGatewayAuthBypassPaths,
shouldEnforceDefaultPluginGatewayAuth,
@ -98,37 +119,6 @@ import {
type WatchNodeHttpRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
type McpOAuthCallbackHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
const getControlUiModule = createLazyRuntimeModule(() => import("./control-ui.js"));
const getControlUiPluginAssetsModule = createLazyRuntimeModule(
() => import("./control-ui-plugin-assets.js"),
);
const getCanvasServeModule = createLazyRuntimeModule(() => import("../canvas/serve.runtime.js"));
const getBoardHttpModule = createLazyRuntimeModule(() => import("./board-http.js"));
const getEmbeddingsHttpModule = createLazyRuntimeModule(() => import("./embeddings-http.js"));
const getManagedMediaAttachmentsModule = createLazyRuntimeModule(
() => import("./managed-image-attachments.js"),
);
const getMcpAppStandaloneModule = createLazyRuntimeModule(() => import("./mcp-app-standalone.js"));
const getModelsHttpModule = createLazyRuntimeModule(() => import("./models-http.js"));
const getOpenAiHttpModule = createLazyRuntimeModule(() => import("./openai-http.js"));
const getOpenResponsesHttpModule = createLazyRuntimeModule(() => import("./openresponses-http.js"));
const getSessionHistoryHttpModule = createLazyRuntimeModule(
() => import("./sessions-history-http.js"),
);
const getSessionKillHttpModule = createLazyRuntimeModule(() => import("./session-kill-http.js"));
const getToolsInvokeHttpModule = createLazyRuntimeModule(() => import("./tools-invoke-http.js"));
const getUserProfilesHttpModule = createLazyRuntimeModule(() => import("./user-profiles-http.js"));
const getDevicePairingJoinHttpModule = createLazyRuntimeModule(
() => import("./device-pairing-join-http.js"),
);
const getPluginNodeCapabilityAuthModule = createLazyRuntimeModule(
() => import("./server/plugin-node-capability-auth.js"),
);
const getHttpAuthUtilsModule = createLazyRuntimeModule(() => import("./http-auth-utils.js"));
const getPluginRouteRuntimeScopesModule = createLazyRuntimeModule(
() => import("./server/plugin-route-runtime-scopes.js"),
);
type GatewayHttpRequestStage = () => Promise<boolean> | boolean;
/** Creates the gateway HTTP/HTTPS server and ordered request-stage router. */
@ -463,6 +453,16 @@ export function createGatewayHttpServer(opts: {
addAdmittedStage(scopedRequestPath === PROVIDER_OAUTH_CALLBACK_PATH, () =>
handleProviderOAuthCallback(req, res),
);
addAdmittedStage(
scopedRequestPath.startsWith(ARTIFACT_DOWNLOAD_PATH) ||
(controlUiRouteBasePath.length > 0 &&
scopedRequestPath.startsWith(`${controlUiRouteBasePath}${ARTIFACT_DOWNLOAD_PATH}`)),
async () =>
(await getArtifactDownloadsModule()).handleArtifactDownloadHttpRequest(req, res, {
clients,
basePath: controlUiRouteBasePath,
}),
);
// Before hooks: an operator hooks.path of "/oauth" would otherwise claim
// this exact GET and 405 every provider redirect. The claim is exact-path
// and config-gated, so preceding hooks cannot shadow any hook route.

View file

@ -26,7 +26,8 @@ import {
resolveSessionStoreAgentId,
resolveStoredSessionKeyForAgentStore,
} from "../session-store-key.js";
import type { GatewayClient } from "./types.js";
import type { ArtifactLookup } from "./artifacts-content.js";
import type { GatewayClient, RespondFn } from "./types.js";
export type ArtifactQuery = ArtifactsListParams;
@ -161,6 +162,19 @@ export class ArtifactSessionResolutionError extends Error {
}
}
export function artifactResponseIsCurrent(found: ArtifactLookup, respond: RespondFn): boolean {
try {
found.assertCurrent?.();
return true;
} catch (error) {
if (!(error instanceof ArtifactSessionResolutionError)) {
throw error;
}
respond(false, undefined, error.shape);
return false;
}
}
export async function prepareArtifactSessionResolution(
input: ArtifactQuery,
): Promise<

View file

@ -324,7 +324,7 @@ async function exercise(
};
expect(readsBeforeRelease).toEqual(
secondPreparation
? { session: 1, sharing: 1, transcript: 1 }
? { session: 2, sharing: 2, transcript: 1 }
: { session: 0, sharing: 0, transcript: 0 },
);
changeAuthority();

View file

@ -24,6 +24,7 @@ import {
ASSISTANT_DISPLAY_CONTENT_FIELD,
readAssistantDisplayContent,
} from "../../shared/assistant-display-content.js";
import { createArtifactDownload } from "../artifact-downloads.js";
import {
parseManagedOutgoingArtifactId,
resolveManagedOutgoingMediaArtifactDownload,
@ -44,6 +45,7 @@ import {
import { readArtifactImagePage } from "./artifacts-image-page.js";
import {
ArtifactSessionResolutionError,
artifactResponseIsCurrent,
type ArtifactQuery,
prepareArtifactSessionResolution,
} from "./artifacts-session-resolution.js";
@ -297,6 +299,7 @@ async function loadArtifacts(
sessionKey?: string;
nextCursor?: string;
omittedOversized?: boolean;
assertCurrent?: () => void;
}> {
const resolveSession = await prepareArtifactSessionResolution(query);
const resolved = resolveSession(getRuntimeConfig(), client);
@ -312,6 +315,31 @@ async function loadArtifacts(
if (!sessionId || !storePath) {
return { sessionKey, artifacts: [] };
}
const lifecycleRevision = entry.lifecycleRevision;
const assertCurrent = () => {
const authorized = resolveSession(getRuntimeConfig(), client);
const current = loadGatewaySessionEntryReadOnly(
sessionKey,
unscopedAgentId ? { agentId: unscopedAgentId } : {},
);
if (
authorized?.sessionKey !== sessionKey ||
authorized.agentId !== resolved.agentId ||
current.storePath !== storePath ||
current.entry?.sessionId !== sessionId ||
current.entry.lifecycleRevision !== lifecycleRevision
) {
throw new ArtifactSessionResolutionError(
errorShape(
ErrorCodes.UNAVAILABLE,
"session changed while reading artifact; reload the conversation",
{
retryable: true,
},
),
);
}
};
const artifacts: ArtifactRecord[] = [];
const collection = { artifacts, count: 0 };
const scope = {
@ -353,7 +381,8 @@ async function loadArtifacts(
.toReversed();
},
});
return { ...page, sessionKey };
assertCurrent();
return { ...page, sessionKey, assertCurrent };
}
const downloadIds = opts.downloadArtifactId ? new Set([opts.downloadArtifactId]) : undefined;
const queuedKey =
@ -373,7 +402,9 @@ async function loadArtifacts(
const queued = queuedKey ? queuedArtifactDownloads.get(queuedKey) : undefined;
if (queued && opts.downloadArtifactId) {
queued.ids.add(opts.downloadArtifactId);
return queued.result;
const loaded = await queued.result;
assertCurrent();
return { ...loaded, assertCurrent };
}
const collect = async () => {
// Close before target resolution or snapshot acquisition, including empty/error reads.
@ -396,12 +427,14 @@ async function loadArtifacts(
});
return { sessionKey, artifacts };
};
if (!queuedKey || !downloadIds) {
return collect();
const result = queuedKey && downloadIds ? Promise.resolve().then(collect) : collect();
if (queuedKey && downloadIds) {
queuedArtifactDownloads.set(queuedKey, { ids: downloadIds, result });
}
const result = Promise.resolve().then(collect);
queuedArtifactDownloads.set(queuedKey, { ids: downloadIds, result });
return result;
// Queued scans share data; each caller retains its own disclosure authority.
const loaded = await result;
assertCurrent();
return { ...loaded, assertCurrent };
}
function requireQueryable(params: ArtifactQuery, respond: RespondFn): boolean {
@ -466,6 +499,7 @@ async function findArtifact(
return {
sessionKey: loaded.sessionKey,
artifact: loaded.artifacts.find((artifact) => artifact.id === params.artifactId),
assertCurrent: loaded.assertCurrent,
};
}
@ -474,19 +508,6 @@ function toSummary(artifact: ArtifactRecord): ArtifactSummary {
return summary;
}
function artifactResponseIsCurrent(found: ArtifactLookup, respond: RespondFn): boolean {
try {
found.assertCurrent?.();
return true;
} catch (error) {
if (!(error instanceof ArtifactSessionResolutionError)) {
throw error;
}
respond(false, undefined, error.shape);
return false;
}
}
async function respondManagedArtifactDownload(
query: ArtifactsGetParams,
getRuntimeConfig: () => OpenClawConfig | undefined,
@ -650,6 +671,34 @@ export const artifactsHandlers: GatewayRequestHandlers = {
);
return;
}
if (query.transport === "http") {
const assertArtifactCurrent = found.value.assertCurrent;
const download = createArtifactDownload({
client,
artifact,
assertCurrent: () => {
assertCurrent();
assertArtifactCurrent?.();
},
read: async () => {
const current = await findArtifact(
query,
getRuntimeConfig,
{ downloadArtifactId: query.artifactId },
client,
);
current.assertCurrent?.();
return current.artifact;
},
});
if (download) {
respond(true, {
artifact: { ...toSummary(artifact), download: { mode: "url" as const } },
...download,
});
return;
}
}
const managedUrl =
artifact.download.mode === "url" && artifact.url && artifact.sessionKey
? await resolveManagedOutgoingMediaUrlDownload({

View file

@ -0,0 +1,107 @@
import { ARTIFACT_DOWNLOAD_PATH } from "../../../packages/gateway-protocol/src/artifact-download.ts";
import { normalizeBasePath } from "../app-route-paths.ts";
import { fetchControlUiResource } from "../app/browser-http.ts";
import { isSameOriginGateway } from "../app/gateway-control-ui-reload.ts";
import type { GatewayBrowserClient } from "./gateway.ts";
import type { ArtifactDownloadResult } from "./types.ts";
type ArtifactDownloadHost = {
connected: boolean;
client?: GatewayBrowserClient | null;
connectionEpoch?: number;
resourceBasePath?: string;
};
export function isHttpArtifactDownloadUrl(url: string, resourceBasePath = ""): boolean {
try {
const parsed = new URL(url, globalThis.location.origin);
const prefix = `${normalizeBasePath(resourceBasePath)}${ARTIFACT_DOWNLOAD_PATH}`;
return (
parsed.origin === globalThis.location.origin &&
parsed.pathname.startsWith(prefix) &&
/^[^/]+\/[^/]+$/u.test(parsed.pathname.slice(prefix.length))
);
} catch {
return false;
}
}
export async function downloadArtifact(
state: ArtifactDownloadHost,
params: { sessionKey: string; agentId?: string; artifactId: string },
signal?: AbortSignal,
options?: { readBinary?: boolean },
): Promise<(ArtifactDownloadResult & { blob?: Blob }) | null> {
const client = state.client;
const connectionEpoch = state.connectionEpoch;
const resourceBasePath = state.resourceBasePath;
if (!state.connected || !client) {
return null;
}
// A loaded HTTPS Control UI proves this origin serves ordinary HTTP traffic.
// A remote wss endpoint alone may expose only WebSocket upgrades.
const useHttp =
!/^artifact_managed_(?:image|media)_/u.test(params.artifactId) &&
globalThis.location?.protocol === "https:" &&
isSameOriginGateway(client.gatewayUrl);
const isCurrent = () =>
!signal?.aborted &&
state.connected &&
state.client === client &&
state.connectionEpoch === connectionEpoch;
const request = async (http: boolean) => {
if (!isCurrent()) {
return null;
}
const result = await client.request<ArtifactDownloadResult | null>(
"artifacts.download",
{ ...params, ...(http ? { transport: "http" } : {}) },
{ timeoutMs: 30_000 },
);
return isCurrent() ? result : null;
};
const result = await request(useHttp);
if (!result) {
return null;
}
const url = result.url?.trim();
if (!useHttp || !url?.startsWith(ARTIFACT_DOWNLOAD_PATH)) {
return result;
}
const download = { ...result, url: `${normalizeBasePath(resourceBasePath ?? "")}${url}` };
const mimeType = result.artifact.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
if (!options?.readBinary && !/^(?:image\/|text\/|application\/json$)/u.test(mimeType)) {
return download;
}
try {
if (!isHttpArtifactDownloadUrl(download.url, resourceBasePath)) {
throw new Error("Invalid artifact download URL");
}
const deadline = AbortSignal.timeout(30_000);
const response = await fetchControlUiResource(download.url, {
credentials: "same-origin",
redirect: "error",
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
});
if (!response.ok) {
throw new Error(`Artifact download failed (${response.status})`);
}
if (
response.headers.get("content-disposition")?.split(";", 1)[0]?.trim().toLowerCase() !==
"attachment"
) {
throw new Error("Artifact download returned an unexpected content disposition");
}
const blob = await response.blob();
if (
blob.type.split(";", 1)[0]?.trim().toLowerCase() !== (mimeType || "application/octet-stream")
) {
throw new Error("Artifact download returned an unexpected content type");
}
return isCurrent() ? { ...download, blob } : null;
} catch {
// Some proxies serve the UI and WebSocket but omit media routes. Reauthorize
// inline delivery on the same connection instead of probing or retargeting.
return await request(false);
}
}

View file

@ -674,7 +674,8 @@ export class ChatPane extends ChatPaneLayoutRender {
chatMessageMaxWidth: state.settings.chatMessageMaxWidth,
branding: this.context?.theme.branding,
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never),
resolveArtifactDownload: (params) => resolveChatArtifactDownload(state, params),
resolveArtifactDownload: (params, signal) =>
resolveChatArtifactDownload(state, params, signal),
basePath: state.basePath,
sessionPublicOrigin: markdownSessionPublicOrigin(this.context),
resourceBasePath: state.resourceBasePath,

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../../test/helpers/promise.js";
import type { SessionsListResult } from "../../api/types.ts";
import { reconcileSessionHistory } from "../../lib/sessions/reconcile.ts";
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
@ -88,6 +89,150 @@ describe("applySelectedSessionProjection", () => {
});
describe("resolveChatArtifactDownload", () => {
afterEach(() => vi.unstubAllGlobals());
const artifact = {
id: "artifact-1",
type: "image",
title: "image",
mimeType: "image/png",
download: { mode: "bytes" },
};
const inline = { artifact, encoding: "base64", data: "cG5n" };
const ticket = "/api/artifacts/download/connection/ticket";
it.each([
{ page: "https://control.test", gateway: "wss://control.test", http: true },
{ page: "https://control.test", gateway: "wss://remote.test", http: false },
{ page: "http://control.test", gateway: "ws://control.test", http: false },
])("uses raw HTTP bytes only for $page with $gateway", async ({ page, gateway, http }) => {
vi.stubGlobal("location", new URL(page));
const blob = new Blob(["png"], { type: "image/png" });
const fetchMock = vi.fn(async () => ({
ok: true,
headers: new Headers({ "Content-Disposition": ' AtTaChMeNt ; filename="image.png" ' }),
blob: async () => blob,
}));
vi.stubGlobal("fetch", fetchMock);
const request = vi.fn().mockResolvedValue(http ? { artifact, url: ticket } : inline);
const result = await resolveChatArtifactDownload(
{
connected: true,
resourceBasePath: "/mount",
client: { gatewayUrl: gateway, request } as never,
},
{ sessionKey: "agent:main:main", artifactId: artifact.id },
);
expect(request).toHaveBeenCalledExactlyOnceWith(
"artifacts.download",
{
sessionKey: "agent:main:main",
artifactId: artifact.id,
...(http ? { transport: "http" } : {}),
},
{ timeoutMs: 30_000 },
);
if (http) {
expect(result).toEqual({ url: `/mount${ticket}`, blob });
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(`/mount${ticket}`, {
credentials: "same-origin",
redirect: "error",
signal: expect.any(AbortSignal),
});
} else {
expect(result).toEqual({ url: "data:image/png;base64,cG5n" });
expect(fetchMock).not.toHaveBeenCalled();
}
});
it.each(["network", "missing route", "SPA fallback"])(
"reauthorizes inline bytes when the HTTPS proxy returns %s",
async (failure) => {
vi.stubGlobal("location", new URL("https://control.test"));
vi.stubGlobal(
"fetch",
vi.fn(async () => {
if (failure === "network") {
throw new TypeError("Failed to fetch");
}
return {
ok: failure !== "missing route",
status: failure === "missing route" ? 404 : 200,
headers: new Headers(),
blob: async () => new Blob(["UI"], { type: "text/html" }),
};
}),
);
const request = vi
.fn()
.mockResolvedValueOnce({ artifact, url: ticket })
.mockResolvedValue(inline);
const result = await resolveChatArtifactDownload(
{ connected: true, client: { gatewayUrl: "wss://control.test", request } as never },
{ sessionKey: "agent:main:main", artifactId: artifact.id },
);
expect(result).toEqual({ url: "data:image/png;base64,cG5n" });
expect(request.mock.calls.map(([, params]) => params)).toEqual([
{ sessionKey: "agent:main:main", artifactId: artifact.id, transport: "http" },
{ sessionKey: "agent:main:main", artifactId: artifact.id },
]);
},
);
it.each([
{ mimeType: "image/svg+xml", type: "image" },
{ mimeType: "text/html", type: "image" },
{ mimeType: "image/png", type: "file" },
])("rejects $type HTTP blobs with $mimeType at the chat boundary", async ({ mimeType, type }) => {
vi.stubGlobal("location", new URL("https://control.test"));
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
headers: new Headers({ "Content-Disposition": 'attachment; filename="artifact"' }),
blob: async () => new Blob(["untrusted"], { type: mimeType }),
})),
);
const request = vi.fn().mockResolvedValue({
artifact: { ...artifact, mimeType, type },
url: ticket,
});
const result = await resolveChatArtifactDownload(
{ connected: true, client: { gatewayUrl: "wss://control.test", request } as never },
{ sessionKey: "agent:main:main", artifactId: artifact.id },
);
expect(result).toBeNull();
expect(request).toHaveBeenCalledOnce();
});
it("discards a failed transfer after reconnect without requesting inline bytes", async () => {
vi.stubGlobal("location", new URL("https://control.test"));
const transfer = createDeferred<Response>();
const started = createDeferred();
vi.stubGlobal(
"fetch",
vi.fn(() => {
started.resolve();
return transfer.promise;
}),
);
const request = vi.fn().mockResolvedValue({ artifact, url: ticket });
const state = {
connected: true,
connectionEpoch: 1,
client: { gatewayUrl: "wss://control.test", request } as never,
};
const pending = resolveChatArtifactDownload(state, {
sessionKey: "main",
artifactId: artifact.id,
});
await started.promise;
state.connectionEpoch += 1;
transfer.reject(new TypeError("Connection changed"));
expect(await pending).toBeNull();
expect(request).toHaveBeenCalledOnce();
});
it("returns a trimmed ticket without exposing a gateway bearer credential", async () => {
const requests: Array<{ method: string; params: unknown; options: unknown }> = [];
const result = await resolveChatArtifactDownload(

View file

@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ArtifactDownloadResult, GatewaySessionRow } from "../../api/types.ts";
import { downloadArtifact } from "../../api/artifact-download.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import { resolveControlUiAuthToken } from "../../app/control-ui-auth.ts";
import { t } from "../../i18n/index.ts";
import { getChatHistoryLoadState } from "./chat-history-state.ts";
@ -27,7 +27,7 @@ export function applySelectedSessionProjection(
}
const MAX_TRACKED_SESSION_ROWS = 256;
const CHAT_ARTIFACT_DOWNLOAD_TIMEOUT_MS = 30_000;
const CHAT_ARTIFACT_IMAGE_MIME = /^image\/(?:png|jpeg|gif|webp|avif)$/u;
export class SessionParticipationTracker {
private readonly lastBlocked = new Map<string, boolean>();
@ -91,21 +91,22 @@ export function resolveAssistantAttachmentAuthToken(state: {
}
export async function resolveChatArtifactDownload(
state: { connected: boolean; client?: GatewayBrowserClient | null },
state: Parameters<typeof downloadArtifact>[0],
params: { sessionKey: string; artifactId: string },
): Promise<{ url: string; expiresAt?: string } | null> {
if (!state.connected || !state.client) {
signal?: AbortSignal,
): Promise<{ url: string; expiresAt?: string; blob?: Blob } | null> {
const result = await downloadArtifact(state, params, signal);
if (
result?.blob &&
(result.artifact.type !== "image" ||
!CHAT_ARTIFACT_IMAGE_MIME.test(result.blob.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""))
) {
return null;
}
const result = await state.client.request<ArtifactDownloadResult | null>(
"artifacts.download",
params,
{ timeoutMs: CHAT_ARTIFACT_DOWNLOAD_TIMEOUT_MS },
);
if (
result?.encoding === "base64" &&
result.artifact.type === "image" &&
/^image\/(?:png|jpeg|gif|webp|avif)$/u.test(result.artifact.mimeType ?? "") &&
CHAT_ARTIFACT_IMAGE_MIME.test(result.artifact.mimeType ?? "") &&
result.data
) {
return { url: `data:${result.artifact.mimeType};base64,${result.data}` };
@ -115,7 +116,11 @@ export async function resolveChatArtifactDownload(
return null;
}
const expiresAt = typeof result?.expiresAt === "string" ? result.expiresAt.trim() : undefined;
return { url, ...(expiresAt ? { expiresAt } : {}) };
return {
url,
...(expiresAt ? { expiresAt } : {}),
...(result?.blob ? { blob: result.blob } : {}),
};
}
export function dismissChatError(state: {

View file

@ -24,6 +24,7 @@ export type AttachmentCardHeaderOptions = {
sizeBytes?: number;
downloadHref?: string;
downloadPending?: boolean;
onDownload?: () => void;
loading?: boolean;
expandLabel?: string;
onExpand?: () => void;
@ -166,20 +167,31 @@ export function renderAttachmentCardHeader(options: AttachmentCardHeaderOptions)
: null
}
${
options.downloadHref || options.downloadPending
? html`<a
options.onDownload
? html`<button
type="button"
class=${`${downloadClass} ${skeleton}`}
href=${options.downloadPending ? nothing : options.downloadHref}
aria-disabled=${options.downloadPending ? "true" : nothing}
role="link"
download=${options.label}
target="_blank"
rel="noreferrer"
?disabled=${options.downloadPending}
aria-label=${downloadTitle}
title=${downloadTitle}
>${icons.download}</a
>`
: null
@click=${options.onDownload}
>
${icons.download}
</button>`
: options.downloadHref || options.downloadPending
? html`<a
class=${`${downloadClass} ${skeleton}`}
href=${options.downloadPending ? nothing : options.downloadHref}
aria-disabled=${options.downloadPending ? "true" : nothing}
role="link"
download=${options.label}
target="_blank"
rel="noreferrer"
aria-label=${downloadTitle}
title=${downloadTitle}
>${icons.download}</a
>`
: null
}
${
hasOpenAction

View file

@ -0,0 +1,79 @@
import { t } from "../../../i18n/index.ts";
import { downloadBlobFile } from "../../../lib/download.ts";
import { formatUiError } from "../../../lib/format-error.ts";
import type {
AttachmentSidebarRuntime,
ChatDetailPanelContent,
} from "./chat-sidebar-content-types.ts";
export class AttachmentDownloadController {
error: string | null = null;
private request: AbortController | null = null;
constructor(
private readonly host: { readonly isConnected: boolean; requestUpdate(): void },
private readonly content: () => ChatDetailPanelContent | null,
private readonly runtime: () => AttachmentSidebarRuntime,
) {}
get pending(): boolean {
return this.request !== null;
}
cancel(): void {
if (!this.request && this.error === null) {
return;
}
this.request?.abort();
this.request = null;
this.error = null;
this.host.requestUpdate();
}
readonly onDownload = (): void => {
void this.run();
};
private async run(): Promise<void> {
const content = this.content();
if (content?.kind !== "attachment" || !content.download || this.request) {
return;
}
const request = new AbortController();
const { connectionEpoch, sessionKey, agentId } = this.runtime();
this.request = request;
this.error = null;
this.host.requestUpdate();
const current = () => {
const runtime = this.runtime();
return (
this.host.isConnected &&
this.content() === content &&
this.request === request &&
runtime.connectionEpoch === connectionEpoch &&
runtime.sessionKey === sessionKey &&
runtime.agentId === agentId &&
!request.signal.aborted
);
};
try {
const blob = await content.download(request.signal);
if (!current()) {
return;
}
if (!blob) {
throw new Error(t("chat.attachments.unavailable"));
}
downloadBlobFile(content.title, blob);
} catch (error) {
if (current()) {
this.error = formatUiError(error);
}
} finally {
if (this.request === request) {
this.request = null;
this.host.requestUpdate();
}
}
}
}

View file

@ -17,7 +17,7 @@ import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts";
import { type EditorId, openEditor } from "../../../lib/editor-links.ts";
import { formatUiError } from "../../../lib/format-error.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
import { getSafeLocalStorage } from "../../../local-storage.ts";
import { AttachmentDownloadController } from "./chat-attachment-download-controller.ts";
import { FileCopyController } from "./chat-file-copy-controller.ts";
import { readFileDraft, setFileDraft } from "./chat-file-drafts.ts";
import { FileHtmlPreviewController } from "./chat-html-preview.ts";
@ -34,29 +34,15 @@ import {
handleSidebarKeydown,
renderSidebarPanel,
} from "./chat-sidebar-content.ts";
import { computeFileMatches } from "./chat-sidebar-file-view.ts";
import {
computeFileMatches,
loadFileWrapPreference,
saveFileWrapPreference,
} from "./chat-sidebar-file-view.ts";
import type { FileEditorViewHandle } from "./file-editor-view.ts";
type FileSidebarContent = Extract<SidebarContent, { kind: "file" }>;
const FILE_WRAP_PREFERENCE_KEY = "openclaw.control.fileView.wrap.v1";
function loadFileWrapPreference(): boolean {
try {
return getSafeLocalStorage()?.getItem(FILE_WRAP_PREFERENCE_KEY) === "true";
} catch {
return false;
}
}
function saveFileWrapPreference(wrap: boolean): void {
try {
getSafeLocalStorage()?.setItem(FILE_WRAP_PREFERENCE_KEY, String(wrap));
} catch {
// Preference persistence is best effort.
}
}
class ChatDetailPanel extends OpenClawLightDomElement {
@property({ attribute: false }) content: ChatDetailPanelContent | null = null;
@property({ attribute: false }) fileNavigation: FileSidebarNavigation | null = null;
@ -109,6 +95,11 @@ class ChatDetailPanel extends OpenClawLightDomElement {
private fileSavedContent = "";
private fileHash = "";
private readonly requestAttachmentUpdate = () => this.requestUpdate();
private readonly attachmentDownload = new AttachmentDownloadController(
this,
() => (this.content === this.visibleContent ? this.content : null),
() => this.attachmentRuntime,
);
constructor() {
super();
@ -121,6 +112,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
}
override disconnectedCallback() {
this.attachmentDownload.cancel();
document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
if (this.fileDirty) {
this.fileDraftContent = this.currentFileText();
@ -139,6 +131,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
previousRuntime.connectionEpoch !== this.attachmentRuntime.connectionEpoch
) {
releaseChatMediaResourceSubscriber(this.requestAttachmentUpdate);
this.attachmentDownload.cancel();
}
if (changed.has("fileNavigation") && this.fileNavigation && this.content?.kind === "file") {
this.htmlPreview.showSource();
@ -160,6 +153,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
return;
}
releaseChatMediaResourceSubscriber(this.requestAttachmentUpdate);
this.attachmentDownload.cancel();
this.visibleContent = this.content;
this.error = null;
this.showingRawText = false;
@ -745,6 +739,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
onKeydown: this.handlePanelKeyDown,
onAttachmentUpdate: this.requestAttachmentUpdate,
attachmentRuntime: this.attachmentRuntime,
attachmentDownload: this.attachmentDownload,
});
}
}

View file

@ -157,15 +157,20 @@ async function fetchManagedImageBlob(
const artifactDownload =
requesterSessionKey && artifactId && opts?.resolveArtifactDownload
? await opts
.resolveArtifactDownload({ sessionKey: requesterSessionKey, artifactId })
.resolveArtifactDownload(
{ sessionKey: requesterSessionKey, artifactId },
controller.signal,
)
.catch(() => null)
: null;
if (controller.signal.aborted) {
return null;
}
if (artifactDownload?.blob) {
return artifactDownload.blob.type.startsWith("image/") ? artifactDownload.blob : null;
}
const imageSource = artifactDownload?.url ?? source;
if (
controller.signal.aborted ||
!imageSource ||
(!source && !imageSource.startsWith("data:image/"))
) {
if (!imageSource || (!source && !imageSource.startsWith("data:image/"))) {
return null;
}
const requestUrl = isManagedOutgoingMediaSource(imageSource)

View file

@ -131,9 +131,13 @@ function createAvailabilityPane(source: string, authToken: string, policyKey?: s
}
describe("chat media resource lifecycle", () => {
it.each([false, true])(
"discards late transcript images and reauthorizes changed sessions or connections with managed URL=%s",
async (withManagedUrl) => {
it.each([
{ withManagedUrl: false, http: false },
{ withManagedUrl: true, http: false },
{ withManagedUrl: false, http: true },
])(
"discards late transcript images and reauthorizes changed sessions or connections: $withManagedUrl/$http",
async ({ withManagedUrl, http }) => {
const artifactId = `transcript-image-${crypto.randomUUID()}`;
const mediaId = crypto.randomUUID();
const imageSource = "data:image/png;base64,cG5n";
@ -142,7 +146,14 @@ describe("chat media resource lifecycle", () => {
const resolveArtifactDownload = vi
.fn()
.mockReturnValueOnce(oldImage.promise)
.mockResolvedValue({ url: imageSource });
.mockResolvedValue(
http
? {
url: "/api/artifacts/download/connection/ticket",
blob: new Blob(["png"], { type: "image/png" }),
}
: { url: imageSource },
);
const fetchMock = vi.fn(async (_url: string) => imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
@ -170,8 +181,10 @@ describe("chat media resource lifecycle", () => {
expect(container.querySelector("img")?.getAttribute("src")).toBe(blobUrl);
oldImage.resolve({ url: "data:image/png;base64,b2xk" });
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(imageSource);
expect(fetchMock).toHaveBeenCalledTimes(http ? 0 : 1);
if (!http) {
expect(fetchMock.mock.calls[0]?.[0]).toBe(imageSource);
}
expect(container.querySelector("img")?.getAttribute("src")).toBe(blobUrl);
options.connectionEpoch = 2;
@ -182,8 +195,10 @@ describe("chat media resource lifecycle", () => {
{ sessionKey: "second-session", artifactId },
{ sessionKey: "second-session", artifactId },
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe(imageSource);
expect(fetchMock).toHaveBeenCalledTimes(http ? 0 : 2);
if (!http) {
expect(fetchMock.mock.calls[1]?.[0]).toBe(imageSource);
}
render(null, container);
},
);

View file

@ -24,10 +24,10 @@ export type ImageBlock = {
height?: number;
} & ({ url: string; artifactId?: string } | { url?: undefined; artifactId: string });
export type ArtifactDownloadResolver = (params: {
sessionKey: string;
artifactId: string;
}) => Promise<{ url: string; expiresAt?: string } | null>;
export type ArtifactDownloadResolver = (
params: { sessionKey: string; artifactId: string },
signal?: AbortSignal,
) => Promise<{ url: string; expiresAt?: string; blob?: Blob } | null>;
export type ImageRenderOptions = {
galleryImages?: readonly ImageBlock[];

View file

@ -5182,10 +5182,10 @@ describe("grouped chat rendering", () => {
);
await vi.waitFor(() => expect(container.querySelector(".chat-message-image")).not.toBeNull());
expect(resolveArtifactDownload).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
artifactId,
});
expect(resolveArtifactDownload).toHaveBeenCalledWith(
{ sessionKey: "agent:main:main", artifactId },
expect.any(AbortSignal),
);
expect(container.querySelector(".chat-message-image")).not.toBeNull();
expect(container.querySelector(".chat-assistant-attachment-card")).toBeNull();
});

View file

@ -0,0 +1,483 @@
import { Blob as NodeBlob } from "node:buffer";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { createDeferred } from "../../../../../test/helpers/promise.js";
import {
gatewayHello,
loadedSidebarContent,
createSidebarContentRecorder,
} from "./chat-session-workspace.test-support.ts";
import {
createSessionWorkspaceProps,
type SessionWorkspaceHost,
} from "./chat-session-workspace.ts";
import type { AttachmentSidebarRuntime, SidebarContent } from "./chat-sidebar-content-types.ts";
describe("session workspace artifacts", () => {
afterEach(() => {
if (vi.isFakeTimers()) {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function createArtifactHost(params: {
data: string;
mimeType: string;
title?: string;
http?: boolean;
}) {
const handleOpenSidebar = createSidebarContentRecorder();
const url = "/api/artifacts/download/connection/ticket";
const request = vi.fn(async (_method: string, query: { transport?: string }) => ({
artifact: {
id: "artifact-1",
mimeType: params.mimeType,
title: params.title ?? "Unicode artifact",
},
...(params.http && query.transport === "http"
? { url }
: { data: params.data, encoding: "base64" }),
}));
const fetchMock = vi.fn(async () => {
const bytes = Uint8Array.from(atob(params.data), (char) => char.charCodeAt(0));
return {
ok: true,
headers: new Headers({ "Content-Disposition": 'attachment; filename="artifact"' }),
blob: async () =>
params.mimeType.startsWith("image/")
? new Blob([bytes], { type: params.mimeType })
: {
type: params.mimeType.split(";", 1)[0]?.toLowerCase(),
text: async () => new TextDecoder().decode(bytes),
},
};
});
if (params.http) {
vi.stubGlobal("location", new URL("https://control.test"));
vi.stubGlobal("fetch", fetchMock);
}
const state = {
client: { request, gatewayUrl: "wss://control.test" },
connected: true,
resourceBasePath: "/mount",
handleOpenSidebar,
hello: gatewayHello([]),
sessionKey: "agent:main:current",
sidebarContent: null,
sessions: {},
} as unknown as SessionWorkspaceHost;
return { handleOpenSidebar, request, state, fetchMock, url: `/mount${url}` };
}
async function createBinaryArtifactPanel() {
// jsdom's Blob lacks arrayBuffer; binary download tests need the native byte contract.
vi.stubGlobal("Blob", NodeBlob);
const fixture = createArtifactHost({
data: "UEsAAQ==",
mimeType: "application/zip",
title: "archive.zip",
http: true,
});
fixture.state.connectionEpoch = 1;
const artifact = {
id: "artifact-1",
type: "file",
title: "archive.zip",
mimeType: "application/zip",
download: { mode: "url" },
};
let ticket = 0;
fixture.request.mockImplementation(async (_method, params) => ({
artifact,
...(params.transport === "http"
? {
url: `/api/artifacts/download/connection/ticket-${++ticket}`,
expiresAt: new Date(Date.now() + 300_000).toISOString(),
}
: { encoding: "base64", data: "UEsAAQ==" }),
}));
const props = createSessionWorkspaceProps(fixture.state);
props.onOpenArtifact("artifact-1");
const content = await loadedSidebarContent(fixture.state);
if (content.kind !== "attachment" || !content.download) {
throw new Error("Binary artifact must expose a download action");
}
const panel = document.createElement("openclaw-chat-detail-panel") as HTMLElement & {
content: SidebarContent;
attachmentRuntime: AttachmentSidebarRuntime;
updateComplete: Promise<unknown>;
};
panel.content = content;
panel.attachmentRuntime = { connectionEpoch: 1, sessionKey: fixture.state.sessionKey };
document.body.append(panel);
onTestFinished(() => panel.remove());
await panel.updateComplete;
const button = panel.querySelector<HTMLButtonElement>(
"button.chat-assistant-attachment-card__download",
);
if (!button) {
throw new Error("Binary artifact must render the attachment download action");
}
const saved = createDeferred<Blob>();
const NativeURL = URL;
const createObjectURL = vi.fn((blob: Blob) => {
saved.resolve(blob);
return "blob:artifact-download";
});
const revokeObjectURL = vi.fn();
vi.stubGlobal(
"URL",
class extends NativeURL {
static override createObjectURL = createObjectURL;
static override revokeObjectURL = revokeObjectURL;
},
);
const clicked: HTMLAnchorElement[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
function (this: HTMLAnchorElement) {
clicked.push(this);
},
);
return {
...fixture,
artifact,
props,
content,
panel,
button,
saved,
createObjectURL,
revokeObjectURL,
clicked,
};
}
it.each([false, true])(
"reauthorizes an expired cached binary download and saves its bytes (HTTP fails: %s)",
async (httpFails) => {
const fixture = await createBinaryArtifactPanel();
expect(fixture.fetchMock).not.toHaveBeenCalled();
const bytes = new Uint8Array([80, 75, 0, 1]);
const blob = new Blob([bytes], { type: "application/zip" });
fixture.fetchMock.mockImplementation(async () => {
if (httpFails) {
throw new TypeError("HTTP media is unreachable");
}
return {
ok: true,
headers: new Headers({ "Content-Disposition": 'attachment; filename="archive.zip"' }),
blob: async () => blob,
};
});
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 360_000);
fixture.props.onOpenArtifact("artifact-1");
expect(fixture.request).toHaveBeenCalledTimes(1);
fixture.button.click();
const downloaded = await fixture.saved.promise;
const actualBytes = new Uint8Array(await downloaded.arrayBuffer());
expect(actualBytes).toEqual(bytes);
expect(fixture.clicked[0]?.download).toBe("archive.zip");
expect(fixture.fetchMock).toHaveBeenCalledExactlyOnceWith(
"/mount/api/artifacts/download/connection/ticket-2",
{
credentials: "same-origin",
redirect: "error",
signal: expect.any(AbortSignal),
},
);
expect(fixture.request.mock.calls.map(([, params]) => params)).toEqual([
{
sessionKey: fixture.state.sessionKey,
agentId: "main",
artifactId: "artifact-1",
transport: "http",
},
{
sessionKey: fixture.state.sessionKey,
agentId: "main",
artifactId: "artifact-1",
transport: "http",
},
...(httpFails
? [{ sessionKey: fixture.state.sessionKey, agentId: "main", artifactId: "artifact-1" }]
: []),
]);
vi.runOnlyPendingTimers();
expect(fixture.revokeObjectURL).toHaveBeenCalledWith("blob:artifact-download");
},
);
it.each(["reconnect", "panel removal"])(
"does not save a binary download after %s",
async (retirement) => {
const fixture = await createBinaryArtifactPanel();
const transfer = createDeferred<{
ok: boolean;
headers: Headers;
blob: () => Promise<Blob>;
}>();
const started = createDeferred();
fixture.fetchMock.mockImplementation(() => {
started.resolve();
return transfer.promise;
});
const read = vi.spyOn(fixture.content, "download");
fixture.button.click();
await started.promise;
if (retirement === "reconnect") {
fixture.state.connectionEpoch = 2;
fixture.state.client = { request: vi.fn(), gatewayUrl: "wss://control.test" } as never;
fixture.panel.attachmentRuntime = {
connectionEpoch: 2,
sessionKey: fixture.state.sessionKey,
};
await fixture.panel.updateComplete;
} else {
fixture.panel.remove();
}
transfer.resolve({
ok: true,
headers: new Headers({ "Content-Disposition": 'attachment; filename="archive.zip"' }),
blob: async () => new Blob(["old"], { type: "application/zip" }),
});
await read.mock.results[0]?.value;
await fixture.panel.updateComplete;
expect(fixture.createObjectURL).not.toHaveBeenCalled();
expect(fixture.request).toHaveBeenCalledTimes(2);
},
);
it.each([true, false])(
"uses artifact titles without changing tab identity (listed: %s)",
async (listed) => {
const { state, request } = createArtifactHost({
data: "iVBORw0KGgo=",
mimeType: "image/png",
title: "resolved-image.png",
});
const props = createSessionWorkspaceProps(state);
const workspace = state.sessionWorkspaceState!;
if (listed) {
workspace.list = {
sessionKey: state.sessionKey,
files: [],
artifacts: [
{
id: "artifact-1",
title: "listed-image.png",
type: "image",
mimeType: "image/png",
download: { mode: "bytes" },
},
],
};
}
props.onOpenArtifact("artifact-1");
const preview = workspace.previews[0]!;
expect(preview.label).toBe(listed ? "listed-image.png" : "Artifacts");
await loadedSidebarContent(state);
expect(preview.label).toBe("resolved-image.png");
props.onOpenArtifact("artifact-1");
expect(workspace.previews).toEqual([preview]);
expect(preview.id).toBe("artifact:artifact-1");
expect(request).toHaveBeenCalledOnce();
},
);
it("keeps nested code literal in a decoded text artifact preview", async () => {
const source = [
"Résumé 東京 🦀",
"",
"```ts",
"const x = 1;",
"```",
"",
"**literal after**",
].join("\n");
const { state } = createArtifactHost({
data: btoa(String.fromCharCode(...new TextEncoder().encode(source))),
mimeType: "text/markdown",
title: "Source notes",
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
const content = await loadedSidebarContent(state);
expect(content).toMatchObject({ kind: "markdown", rawText: source });
const panel = document.createElement("openclaw-chat-detail-panel") as HTMLElement & {
content: SidebarContent;
updateComplete: Promise<unknown>;
};
panel.content = content;
document.body.append(panel);
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const schedule = vi.spyOn(globalThis, "setTimeout");
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
try {
await panel.updateComplete;
const reader = panel.querySelector(".sidebar-markdown-reader");
expect(reader?.querySelector("h1")?.textContent).toBe("Source notes");
expect.soft(reader?.querySelectorAll("pre code")).toHaveLength(1);
expect.soft(reader?.querySelector("pre code")?.textContent).toBe(`${source}\n`);
expect.soft(reader?.querySelector("strong")).toBeNull();
const copyButton = reader?.querySelector<HTMLButtonElement>(".code-block-copy");
expect(copyButton).toBeInstanceOf(HTMLButtonElement);
copyButton!.click();
await vi.waitFor(() => expect(copyButton!.getAttribute("aria-label")).toBe("Copied!"));
expect(writeText).toHaveBeenCalledWith(source);
} finally {
for (const [index, [, delay]] of schedule.mock.calls.entries()) {
if (delay === 1_500) {
globalThis.clearTimeout(schedule.mock.results[index]?.value);
}
}
schedule.mockRestore();
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
panel.remove();
}
});
it.each(
[
{
content: "Résumé 東京 🦀",
fence: "```",
mimeType: "text/plain",
},
{
content: "Résumé 東京 🦀",
fence: "```",
mimeType: "text/plain; charset=utf-8",
},
{
content: JSON.stringify({ message: "Résumé 東京 🦀" }),
fence: "```json",
mimeType: "application/json",
},
].flatMap((testCase) => [false, true].map((http) => Object.assign({ http }, testCase))),
)(
"decodes UTF-8 $mimeType artifacts without corrupting visible or raw text (HTTP: $http)",
async (testCase) => {
const data = btoa(String.fromCharCode(...new TextEncoder().encode(testCase.content)));
const { state, fetchMock, url } = createArtifactHost({
data,
mimeType: testCase.mimeType,
http: testCase.http,
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
expect(await loadedSidebarContent(state)).toEqual({
kind: "markdown",
content: `# Unicode artifact\n\n${testCase.fence}\n${testCase.content}\n\`\`\``,
rawText: testCase.content,
});
if (testCase.http) {
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(url, {
credentials: "same-origin",
redirect: "error",
signal: expect.any(AbortSignal),
});
}
},
);
it.each([false, true])(
"retains image artifact previews beyond ticket expiry (HTTP: %s)",
async (http) => {
const data = "iVBORw0KGgo=";
const { state, fetchMock, url } = createArtifactHost({
data,
mimeType: "image/png",
title: "preview.png",
http,
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
expect(await loadedSidebarContent(state)).toEqual({
kind: "image",
mimeType: "image/png",
rawText: http ? url : null,
src: `data:image/png;base64,${data}`,
title: "preview.png",
});
if (http) {
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(url, {
credentials: "same-origin",
redirect: "error",
signal: expect.any(AbortSignal),
});
}
},
);
it.each([undefined, 'inline; filename="index.html"'])(
"reauthorizes HTML artifact bytes when the proxy returns application HTML with disposition %s",
async (disposition) => {
const source = "<h1>Actual artifact</h1>";
const { state, request, fetchMock, url } = createArtifactHost({
data: btoa(source),
mimeType: "text/html",
title: "report.html",
http: true,
});
fetchMock.mockResolvedValueOnce(
new Response("<html><body>Control UI application</body></html>", {
headers: {
"Content-Type": "text/html",
...(disposition ? { "Content-Disposition": disposition } : {}),
},
}),
);
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
expect(await loadedSidebarContent(state)).toMatchObject({
kind: "markdown",
rawText: source,
});
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(url, {
credentials: "same-origin",
redirect: "error",
signal: expect.any(AbortSignal),
});
expect(request.mock.contexts).toEqual([state.client, state.client]);
expect(request.mock.calls.map(([, query]) => query)).toEqual([
{
sessionKey: state.sessionKey,
agentId: "main",
artifactId: "artifact-1",
transport: "http",
},
{ sessionKey: state.sessionKey, agentId: "main", artifactId: "artifact-1" },
]);
},
);
it("reports malformed base64 artifact data as a visible workspace error", async () => {
const { handleOpenSidebar, state } = createArtifactHost({
data: "not-base64!",
mimeType: "text/plain",
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
await vi.waitFor(() =>
expect(createSessionWorkspaceProps(state).error).toMatch(/InvalidCharacterError|invalid/i),
);
expect(handleOpenSidebar).toHaveBeenCalledOnce();
expect(state.sessionWorkspaceState?.previews.at(-1)?.content).toMatchObject({
kind: "unavailable",
});
});
});

View file

@ -78,6 +78,7 @@ export type SessionWorkspaceHost = {
connected: boolean;
connectionEpoch: number;
hello: GatewayHelloOk | null;
resourceBasePath?: string;
terminalAvailable?: boolean;
browserPanelAvailable?: boolean;
assistantAgentId?: string | null;

View file

@ -0,0 +1,32 @@
import { vi } from "vitest";
import type { SessionWorkspaceHost } from "./chat-session-workspace-types.ts";
import type { SidebarContent, SidebarSelection } from "./chat-sidebar-content-types.ts";
export function createSidebarContentRecorder() {
return vi.fn(function (this: SessionWorkspaceHost, content: SidebarSelection | null) {
if (!content?.fileTab) {
this.sidebarContent = content;
}
});
}
export function loadedSidebarContent(state: SessionWorkspaceHost): Promise<SidebarContent> {
return vi.waitFor(() => {
const content = state.sessionWorkspaceState?.previews.find(
(entry) => entry.id === state.sessionWorkspaceState?.activePreviewId,
)?.content;
if (!content || content.kind === "loading" || content.kind === "unavailable") {
throw new Error("Sidebar content is not loaded");
}
return content;
});
}
export function gatewayHello(methods: string[], scopes = ["operator.admin"]) {
return {
type: "hello-ok" as const,
protocol: 3,
auth: { role: "operator", scopes },
features: { methods },
};
}

View file

@ -5,6 +5,11 @@ import {
createGatewayBrowserClientFixture,
createSessionCapabilityFixture,
} from "../chat-pane.test-support.ts";
import {
gatewayHello,
loadedSidebarContent,
createSidebarContentRecorder,
} from "./chat-session-workspace.test-support.ts";
import {
createSessionWorkspaceProps,
openSessionWorkspaceFile,
@ -16,33 +21,6 @@ import {
} from "./chat-session-workspace.ts";
import type { SidebarContent, SidebarSelection } from "./chat-sidebar.ts";
function recordSidebarContent(this: SessionWorkspaceHost, content: SidebarSelection | null) {
if (!content?.fileTab) {
this.sidebarContent = content;
}
}
function loadedSidebarContent(state: SessionWorkspaceHost): Promise<SidebarContent> {
return vi.waitFor(() => {
const content = state.sessionWorkspaceState?.previews.find(
(entry) => entry.id === state.sessionWorkspaceState?.activePreviewId,
)?.content;
if (!content || content.kind === "loading" || content.kind === "unavailable") {
throw new Error("Sidebar content is not loaded");
}
return content;
});
}
function gatewayHello(methods: string[], scopes = ["operator.admin"]) {
return {
type: "hello-ok" as const,
protocol: 3,
auth: { role: "operator", scopes },
features: { methods },
};
}
describe("session workspace state", () => {
it("carries the saved bottom dock across session workspace state", () => {
const state = {
@ -428,193 +406,6 @@ describe("session workspace state", () => {
});
});
describe("session workspace artifacts", () => {
function createArtifactHost(params: { data: string; mimeType: string; title?: string }) {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const request = vi.fn().mockResolvedValue({
artifact: {
id: "artifact-1",
mimeType: params.mimeType,
title: params.title ?? "Unicode artifact",
},
data: params.data,
encoding: "base64",
});
const state = {
client: { request },
connected: true,
handleOpenSidebar,
hello: gatewayHello([]),
sessionKey: "agent:main:current",
sidebarContent: null,
sessions: {},
} as unknown as SessionWorkspaceHost;
return { handleOpenSidebar, request, state };
}
it.each([true, false])(
"uses artifact titles without changing tab identity (listed: %s)",
async (listed) => {
const { state, request } = createArtifactHost({
data: "iVBORw0KGgo=",
mimeType: "image/png",
title: "resolved-image.png",
});
const props = createSessionWorkspaceProps(state);
const workspace = state.sessionWorkspaceState!;
if (listed) {
workspace.list = {
sessionKey: state.sessionKey,
files: [],
artifacts: [
{
id: "artifact-1",
title: "listed-image.png",
type: "image",
mimeType: "image/png",
download: { mode: "bytes" },
},
],
};
}
props.onOpenArtifact("artifact-1");
const preview = workspace.previews[0]!;
expect(preview.label).toBe(listed ? "listed-image.png" : "Artifacts");
await loadedSidebarContent(state);
expect(preview.label).toBe("resolved-image.png");
props.onOpenArtifact("artifact-1");
expect(workspace.previews).toEqual([preview]);
expect(preview.id).toBe("artifact:artifact-1");
expect(request).toHaveBeenCalledOnce();
},
);
it("keeps nested code literal in a decoded text artifact preview", async () => {
const source = [
"Résumé 東京 🦀",
"",
"```ts",
"const x = 1;",
"```",
"",
"**literal after**",
].join("\n");
const { state } = createArtifactHost({
data: btoa(String.fromCharCode(...new TextEncoder().encode(source))),
mimeType: "text/markdown",
title: "Source notes",
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
const content = await loadedSidebarContent(state);
expect(content).toMatchObject({ kind: "markdown", rawText: source });
const panel = document.createElement("openclaw-chat-detail-panel") as HTMLElement & {
content: SidebarContent;
updateComplete: Promise<unknown>;
};
panel.content = content;
document.body.append(panel);
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const schedule = vi.spyOn(globalThis, "setTimeout");
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
try {
await panel.updateComplete;
const reader = panel.querySelector(".sidebar-markdown-reader");
expect(reader?.querySelector("h1")?.textContent).toBe("Source notes");
expect.soft(reader?.querySelectorAll("pre code")).toHaveLength(1);
expect.soft(reader?.querySelector("pre code")?.textContent).toBe(`${source}\n`);
expect.soft(reader?.querySelector("strong")).toBeNull();
const copyButton = reader?.querySelector<HTMLButtonElement>(".code-block-copy");
expect(copyButton).toBeInstanceOf(HTMLButtonElement);
copyButton!.click();
await vi.waitFor(() => expect(copyButton!.getAttribute("aria-label")).toBe("Copied!"));
expect(writeText).toHaveBeenCalledWith(source);
} finally {
for (const [index, [, delay]] of schedule.mock.calls.entries()) {
if (delay === 1_500) {
globalThis.clearTimeout(schedule.mock.results[index]?.value);
}
}
schedule.mockRestore();
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
panel.remove();
}
});
it.each([
{
content: "Résumé 東京 🦀",
fence: "```",
mimeType: "text/plain",
},
{
content: JSON.stringify({ message: "Résumé 東京 🦀" }),
fence: "```json",
mimeType: "application/json",
},
])(
"decodes UTF-8 $mimeType artifacts without corrupting visible or raw text",
async (testCase) => {
const data = btoa(String.fromCharCode(...new TextEncoder().encode(testCase.content)));
const { state } = createArtifactHost({
data,
mimeType: testCase.mimeType,
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
expect(await loadedSidebarContent(state)).toEqual({
kind: "markdown",
content: `# Unicode artifact\n\n${testCase.fence}\n${testCase.content}\n\`\`\``,
rawText: testCase.content,
});
},
);
it("preserves inline image artifacts as their original base64 data URLs", async () => {
const data = "iVBORw0KGgo=";
const { state } = createArtifactHost({
data,
mimeType: "image/png",
title: "preview.png",
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
expect(await loadedSidebarContent(state)).toEqual({
kind: "image",
mimeType: "image/png",
rawText: null,
src: `data:image/png;base64,${data}`,
title: "preview.png",
});
});
it("reports malformed base64 artifact data as a visible workspace error", async () => {
const { handleOpenSidebar, state } = createArtifactHost({
data: "not-base64!",
mimeType: "text/plain",
});
createSessionWorkspaceProps(state).onOpenArtifact("artifact-1");
await vi.waitFor(() =>
expect(createSessionWorkspaceProps(state).error).toMatch(/InvalidCharacterError|invalid/i),
);
expect(handleOpenSidebar).toHaveBeenCalledOnce();
expect(state.sessionWorkspaceState?.previews.at(-1)?.content).toMatchObject({
kind: "unavailable",
});
});
});
describe("openSessionWorkspaceFile", () => {
it.each([
{ client: null, connected: true, label: "no Gateway client exists" },
@ -648,7 +439,7 @@ describe("openSessionWorkspaceFile", () => {
});
it("opens Markdown with a canonical Gateway- and pane-scoped draft identity", async () => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const getFile = vi.fn().mockResolvedValue({
sessionKey: "agent:main:current",
root: "/workspace",
@ -694,7 +485,7 @@ describe("openSessionWorkspaceFile", () => {
scopes: ["operator.read"],
},
])("keeps Markdown read-only when $label", async ({ methods, scopes }) => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const state = {
client: {},
connected: true,
@ -838,7 +629,7 @@ describe("openSessionWorkspaceFile", () => {
);
it("opens base64 session images in the existing image sidebar", async () => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const state = {
client: {},
connected: true,
@ -877,7 +668,7 @@ describe("openSessionWorkspaceFile", () => {
{ label: "a non-allowlisted MIME", mimeType: "image/svg+xml", contentEncoding: "base64" },
{ label: "a non-base64 encoding", mimeType: "image/png", contentEncoding: "utf8" },
])("rejects image preview metadata with $label", async ({ mimeType, contentEncoding }) => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const state = {
client: {},
connected: true,
@ -917,7 +708,7 @@ describe("openSessionWorkspaceFile", () => {
});
it("does not render base64 content as text when the preview discriminator disagrees", async () => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const state = {
client: {},
connected: true,
@ -954,7 +745,7 @@ describe("openSessionWorkspaceFile", () => {
});
it("keeps a rejected file open as an unavailable file tab", async () => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const state: SessionWorkspaceHost = {
client: createGatewayBrowserClientFixture(),
connected: true,
@ -981,7 +772,7 @@ describe("openSessionWorkspaceFile", () => {
});
it("opens unsupported session files as metadata without treating bytes as text", async () => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const state = {
client: {},
connected: true,
@ -1018,7 +809,7 @@ describe("openSessionWorkspaceFile", () => {
});
it("keeps hostile unsupported filenames literal in metadata Markdown", async () => {
const handleOpenSidebar = vi.fn(recordSidebarContent);
const handleOpenSidebar = createSidebarContentRecorder();
const hostilePath = " build/`\n\n![remote](https://example.com/x) report~~old~~&amp;.db ";
const state = {
client: {},

View file

@ -1,5 +1,6 @@
import type { SessionsDiffResult } from "../../../../../packages/gateway-protocol/src/index.js";
import { formatFencedCodeBlock } from "../../../../../src/shared/markdown-code.js";
import { downloadArtifact, isHttpArtifactDownloadUrl } from "../../../api/artifact-download.ts";
import { GatewayRequestError } from "../../../api/gateway.ts";
import type { ArtifactDownloadResult, SessionWorkspaceGetResult } from "../../../api/types.ts";
import { hasOperatorAdminAccess } from "../../../app/operator-access.ts";
@ -12,6 +13,7 @@ import { openWorkspaceItem } from "./chat-session-workspace-preview.ts";
import {
clearWorkspaceTimer,
getSessionWorkspace,
isCurrentSessionWorkspace,
loadSessionWorkspace,
openSessionCheckoutSidebar,
refreshSessionWorkspaceState,
@ -121,29 +123,43 @@ function artifactSidebarContent(params: {
mimeType: string;
title: string;
url?: string;
text?: string;
imageSource?: string;
download?: (signal: AbortSignal) => Promise<Blob | null>;
}): SidebarContent {
const { data, encoding, mimeType, title, url } = params;
if (encoding === "base64" && data && mimeType.startsWith("image/")) {
const { data, encoding, mimeType, title, url, imageSource } = params;
let { text } = params;
if (imageSource || (encoding === "base64" && data && mimeType.startsWith("image/"))) {
return {
kind: "image",
title,
src: `data:${mimeType};base64,${data}`,
src: imageSource ?? `data:${mimeType};base64,${data}`,
mimeType,
rawText: url ?? null,
};
}
if (
encoding === "base64" &&
data &&
(mimeType === "application/json" || mimeType.startsWith("text/"))
) {
const bytes = Uint8Array.from(globalThis.atob(data), (char) => char.charCodeAt(0));
const decoded = new TextDecoder().decode(bytes);
if (mimeType === "application/json" || mimeType.startsWith("text/")) {
if (text === undefined && encoding === "base64" && data) {
text = new TextDecoder().decode(
Uint8Array.from(globalThis.atob(data), (char) => char.charCodeAt(0)),
);
}
}
if (text !== undefined) {
const language = mimeType === "application/json" ? "json" : "";
return {
kind: "markdown",
content: `# ${title}\n\n${formatFencedCodeBlock(decoded, language)}`,
rawText: decoded,
content: `# ${title}\n\n${formatFencedCodeBlock(text, language)}`,
rawText: text,
};
}
if (params.download) {
return {
kind: "attachment",
attachmentKind: "document",
title,
mimeType,
download: params.download,
};
}
if (url) {
@ -154,6 +170,55 @@ function artifactSidebarContent(params: {
return { kind: "markdown", content, rawText: content };
}
async function loadArtifactSidebarContent(
result: ArtifactDownloadResult & { blob?: Blob },
download: (signal: AbortSignal) => Promise<Blob | null>,
resourceBasePath?: string,
): Promise<SidebarContent> {
const params = {
data: result.data,
encoding: result.encoding,
mimeType: result.artifact.mimeType ?? "",
title: result.artifact.title,
url: result.url,
download:
result.encoding === "base64" ||
(result.url && isHttpArtifactDownloadUrl(result.url, resourceBasePath))
? download
: undefined,
};
if (!result.blob) {
return artifactSidebarContent(params);
}
if (!params.mimeType.startsWith("image/")) {
return artifactSidebarContent({ ...params, text: await result.blob.text() });
}
const blob = result.blob;
// Workspace previews outlive the ticket. Retain the fetched image in the
// preview's existing data URL representation without a second Blob URL owner.
const imageSource = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener(
"load",
() => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Artifact image could not be decoded"));
}
},
{ once: true },
);
reader.addEventListener(
"error",
() => reject(reader.error ?? new Error("Artifact image could not be decoded")),
{ once: true },
);
reader.readAsDataURL(blob);
});
return artifactSidebarContent({ ...params, imageSource });
}
export function refreshSessionWorkspace(state: SessionWorkspaceHost, refreshFiles: boolean) {
if (refreshSessionWorkspaceState(state, refreshFiles)) {
state.handleOpenSidebar(resolveSessionDiffSidebarContent(state));
@ -365,26 +430,57 @@ function openArtifact(
workspace: SessionWorkspaceState,
artifactId: string,
) {
const query = {
sessionKey: workspace.sessionKey,
artifactId,
...(workspace.agentId ? { agentId: workspace.agentId } : {}),
};
const readDownload = async (signal: AbortSignal): Promise<Blob | null> => {
const currentWorkspace = getSessionWorkspace(state);
if (
currentWorkspace.sessionKey !== query.sessionKey ||
currentWorkspace.agentId !== workspace.agentId
) {
return null;
}
// Cached preview actions bind a fresh connection on click; an in-flight
// transfer must never follow a reconnect to a replacement Gateway.
const client = state.client;
const connectionEpoch = state.connectionEpoch;
const result = await downloadArtifact(state, query, signal, { readBinary: true });
if (
signal.aborted ||
!state.connected ||
state.client !== client ||
state.connectionEpoch !== connectionEpoch ||
!isCurrentSessionWorkspace(state, currentWorkspace)
) {
return null;
}
if (result?.blob) {
return result.blob;
}
if (result?.encoding !== "base64" || result.data === undefined) {
return null;
}
return new Blob([Uint8Array.from(atob(result.data), (char) => char.charCodeAt(0))], {
type: result.artifact.mimeType ?? "application/octet-stream",
});
};
openWorkspaceItem(
state,
workspace,
`artifact:${artifactId}`,
() =>
state.client!.request<ArtifactDownloadResult | null>("artifacts.download", {
sessionKey: workspace.sessionKey,
artifactId,
...(workspace.agentId ? { agentId: workspace.agentId } : {}),
}),
(result) =>
!result.artifact
? null
: artifactSidebarContent({
data: result.data,
encoding: result.encoding,
mimeType: result.artifact.mimeType ?? "",
title: result.artifact.title,
url: result.url,
}),
async () => {
const result = await downloadArtifact(state, query);
return result?.artifact
? {
artifact: result.artifact,
content: await loadArtifactSidebarContent(result, readDownload, state.resourceBasePath),
}
: null;
},
(result) => result.content,
`Failed to load artifact ${artifactId}`,
{
label:

View file

@ -90,6 +90,8 @@ type AttachmentSidebarContent = {
voiceNote?: boolean;
plainText?: boolean;
renderActions?: () => TemplateResult;
/** Authorize and read fresh bytes for each explicit download. */
download?: (signal: AbortSignal) => Promise<Blob | null>;
resolveSource?: (
onRequestUpdate: () => void,
runtime: AttachmentSidebarRuntime,

View file

@ -59,7 +59,18 @@ function renderSidebarAttachment(
onRequestUpdate: () => void,
runtime: AttachmentSidebarRuntime,
embedSandboxMode: EmbedSandboxMode,
download?: { pending: boolean; error: string | null; onDownload: () => void },
) {
if (content.download && download) {
return html`${renderCompactAttachmentCard({
kind: "document",
label: content.title,
mimeType: content.mimeType ?? undefined,
sizeBytes: content.sizeBytes,
onDownload: download.onDownload,
downloadPending: download.pending,
})}${download.error ? html`<div role="alert">${download.error}</div>` : nothing}`;
}
const resolution = content.resolveSource?.(onRequestUpdate, runtime);
const source = resolution ? (resolution.status === "ready" ? resolution : null) : content;
const mimeType = content.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
@ -264,6 +275,7 @@ type MarkdownSidebarProps = {
embedded?: boolean;
onAttachmentUpdate: () => void;
attachmentRuntime: AttachmentSidebarRuntime;
attachmentDownload?: { pending: boolean; error: string | null; onDownload: () => void };
};
function renderMarkdownSidebar(props: MarkdownSidebarProps) {
@ -444,6 +456,7 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
props.onAttachmentUpdate,
props.attachmentRuntime,
props.embedSandboxMode ?? "scripts",
props.attachmentDownload,
)}
</div>`
: html`

View file

@ -7,6 +7,7 @@ import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import { registerCodeBlocksEnglish } from "../../../i18n/locales/en-code-blocks.ts";
import type { EditorId } from "../../../lib/editor-links.ts";
import { getSafeLocalStorage } from "../../../local-storage.ts";
import type { SidebarContent } from "./chat-sidebar-content-types.ts";
import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts";
import { detectLineSeparator } from "./file-line-separator.ts";
@ -15,6 +16,24 @@ registerCodeBlocksEnglish();
type FileSidebarContent = Extract<SidebarContent, { kind: "file" }>;
const FILE_WRAP_PREFERENCE_KEY = "openclaw.control.fileView.wrap.v1";
export function loadFileWrapPreference(): boolean {
try {
return getSafeLocalStorage()?.getItem(FILE_WRAP_PREFERENCE_KEY) === "true";
} catch {
return false;
}
}
export function saveFileWrapPreference(wrap: boolean): void {
try {
getSafeLocalStorage()?.setItem(FILE_WRAP_PREFERENCE_KEY, String(wrap));
} catch {
// Preference persistence is best effort.
}
}
export function hasUniformLineEndings(content: string): boolean {
const crlf = content.split("\r\n").length - 1;
const bareCr = (content.match(/\r(?!\n)/g) ?? []).length;