diff --git a/src/gateway/control-ui-assistant-media.e2e.test.ts b/src/gateway/control-ui-assistant-media.e2e.test.ts index 2feb66069b2e..c3c451e79dee 100644 --- a/src/gateway/control-ui-assistant-media.e2e.test.ts +++ b/src/gateway/control-ui-assistant-media.e2e.test.ts @@ -1,6 +1,7 @@ // Control UI assistant media e2e tests verify scoped media-ticket access through gateway HTTP routes. import fs from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, test } from "vitest"; import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js"; @@ -51,6 +52,26 @@ describe("Control UI assistant media e2e", () => { ); expect(await ticketed.text()).toBe("ticketed control ui media\n"); + const fileUrl = pathToFileURL(filePath).href; + for (const source of [ + fileUrl, + fileUrl.replace(/^file:/u, "FILE:"), + fileUrl.replace(/^file:\/\//u, "file:"), + fileUrl.replace(/^file:\/\//u, "FILE:"), + ]) { + const equivalent = await fetch( + `${route}?source=${encodeURIComponent(source)}&mediaTicket=${encodeURIComponent(payload.mediaTicket ?? "")}`, + ); + expect(equivalent.status, source).toBe(200); + expect(await equivalent.text()).toBe("ticketed control ui media\n"); + } + for (const source of ["file://evil-host/etc/hostname", "FILE://evil-host/etc/hostname"]) { + const remoteHost = await fetch(`${route}?source=${encodeURIComponent(source)}`, { + headers: { Authorization: `Bearer ${CONTROL_UI_E2E_TOKEN}` }, + }); + expect(remoteHost.status, source).toBe(404); + } + const ranged = await fetch( `${route}?source=${sourceParam}&mediaTicket=${encodeURIComponent(payload.mediaTicket ?? "")}`, { headers: { Range: "bytes=9-15" } }, diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index b586395abb79..4bce304af183 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -220,7 +220,7 @@ function normalizeAssistantMediaSource(source: string): string | null { if (!trimmed) { return null; } - if (trimmed.startsWith("file://")) { + if (/^file:/iu.test(trimmed)) { try { return safeFileURLToPath(trimmed); } catch { diff --git a/src/gateway/server-methods/chat-webchat-media.test.ts b/src/gateway/server-methods/chat-webchat-media.test.ts index dc60ba40f4d3..dd4f8173405e 100644 --- a/src/gateway/server-methods/chat-webchat-media.test.ts +++ b/src/gateway/server-methods/chat-webchat-media.test.ts @@ -159,34 +159,43 @@ describe("webchat audio blocks through assistant messages", () => { expect(blocks).toHaveLength(1); }); - it("embeds file:// URLs pointing at a local file within localRoots", async () => { - const { audioPath, localRoot } = writeAudioFixture([0x01]); + it.each(["file://", "FILE://", "FiLe://", "file:", "FILE:"])( + "embeds %s URLs pointing at a local file within localRoots", + async (scheme) => { + const { audioPath, localRoot } = writeAudioFixture([0x01]); - const fileUrl = pathToFileURL(audioPath).href; - const blocks = await buildWebchatAudioBlocks([{ mediaUrl: fileUrl, trustedLocalMedia: true }], { - localRoots: [localRoot], - }); + const fileUrl = pathToFileURL(audioPath).href.replace(/^file:\/\/\//, `${scheme}/`); + const blocks = await buildWebchatAudioBlocks( + [{ mediaUrl: fileUrl, trustedLocalMedia: true }], + { + localRoots: [localRoot], + }, + ); - expect(blocks).toHaveLength(1); - expect((blocks[0] as { type?: string }).type).toBe("attachment"); - }); + expect(blocks).toHaveLength(1); + expect((blocks[0] as { type?: string }).type).toBe("attachment"); + }, + ); - it("drops tool-result file:// URLs with remote hosts before touching the filesystem", async () => { - const openSpy = vi.spyOn(fsPromises, "open"); + it.each(["file://attacker/share/probe.mp3", "FILE://attacker/share/probe.mp3"])( + "drops tool-result %s URLs with remote hosts before touching the filesystem", + async (source) => { + const openSpy = vi.spyOn(fsPromises, "open"); - const blocks = await buildWebchatAudioBlocks([ - { - text: "MEDIA:file://attacker/share/probe.mp3", - mediaUrl: "file://attacker/share/probe.mp3", - trustedLocalMedia: true, - }, - ]); + const blocks = await buildWebchatAudioBlocks([ + { + text: `MEDIA:${source}`, + mediaUrl: source, + trustedLocalMedia: true, + }, + ]); - expect(blocks).toHaveLength(0); - expect(openSpy).not.toHaveBeenCalled(); + expect(blocks).toHaveLength(0); + expect(openSpy).not.toHaveBeenCalled(); - openSpy.mockRestore(); - }); + openSpy.mockRestore(); + }, + ); it("rejects a local audio file outside configured localRoots", async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); diff --git a/src/gateway/server-methods/chat-webchat-media.ts b/src/gateway/server-methods/chat-webchat-media.ts index bb673dcab935..92ca14ab3639 100644 --- a/src/gateway/server-methods/chat-webchat-media.ts +++ b/src/gateway/server-methods/chat-webchat-media.ts @@ -53,7 +53,7 @@ function resolveLocalMediaPathForEmbedding(raw: string): string | null { if (/^https?:/i.test(trimmed)) { return null; } - if (trimmed.startsWith("file:")) { + if (/^file:/iu.test(trimmed)) { try { const p = safeFileURLToPath(trimmed); if (!path.isAbsolute(p)) { diff --git a/ui/src/e2e/chat-flow.media-files.e2e.test.ts b/ui/src/e2e/chat-flow.media-files.e2e.test.ts index 029c35c12450..2cf65c645506 100644 --- a/ui/src/e2e/chat-flow.media-files.e2e.test.ts +++ b/ui/src/e2e/chat-flow.media-files.e2e.test.ts @@ -1,7 +1,8 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, it } from "vitest"; import { + buildLocalWebchatAudioMessage, captureUiProofEnabled, copiedViaExec, createChatFlowE2eSuite, @@ -85,9 +86,25 @@ suite.define(() => { source: "/home/node/.openclaw/media/outbound/bootstrap-image.png", ticket: "ticket-bootstrap-image", }, + { + kind: "image", + source: "FILE:///home/node/.openclaw/media/outbound/bootstrap-uppercase-image.png", + ticket: "ticket-bootstrap-uppercase-image", + }, + { + kind: "image", + source: "file:/home/node/.openclaw/media/outbound/bootstrap-authorityless-image.png", + ticket: "ticket-bootstrap-authorityless-image", + }, + { + kind: "audio", + source: `FILE:${path.join(managedImageCacheProofDir, "bootstrap-structured-audio.mp3")}`, + ticket: "ticket-bootstrap-structured-audio", + structured: true, + }, ] as const)( "renders local assistant $kind through server metadata before preview roots load", - async ({ kind, source, ticket }) => { + async ({ kind, source, ticket, ...options }) => { const context = await suite.newBrowserContext({ locale: "en-US", serviceWorkers: "block", @@ -95,12 +112,13 @@ suite.define(() => { }); const page = await context.newPage(); const requestedMediaUrls: URL[] = []; + const expectedSource = "structured" in options ? new URL(source).pathname : source; await page.route("**/__openclaw__/assistant-media?**", async (route) => { const request = route.request(); const url = new URL(request.url()); requestedMediaUrls.push(url); - expect(url.searchParams.get("source")).toBe(source); + expect(url.searchParams.get("source")).toBe(expectedSource); if (url.searchParams.get("meta") === "1") { expect(request.headers().authorization).toBe("Bearer e2e-device-token"); await route.fulfill({ @@ -120,10 +138,7 @@ suite.define(() => { kind === "image" ? { contentType: "image/png", - body: Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=", - "base64", - ), + body: await readFile(path.join(process.cwd(), "ui/public/apple-touch-icon.png")), } : { contentType: "audio/mpeg", @@ -144,7 +159,10 @@ suite.define(() => { : { id: "assistant-bootstrap-local-audio", role: "assistant", - content: [{ type: "text", text: `Your recording\nMEDIA:${source}` }], + content: + "structured" in options + ? (await buildLocalWebchatAudioMessage(source)).content + : [{ type: "text", text: `Your recording\nMEDIA:${source}` }], timestamp: Date.now(), }, ], @@ -172,7 +190,7 @@ suite.define(() => { element instanceof HTMLImageElement && element.complete ? element.naturalWidth : 0, ), ) - .toBe(1); + .toBe(180); } const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); @@ -180,7 +198,7 @@ suite.define(() => { await mkdir(artifactDir, { recursive: true }); await page.screenshot({ fullPage: true, - path: path.join(artifactDir, `bootstrap-local-${kind}.png`), + path: path.join(artifactDir, `bootstrap-local-${kind}-${ticket}.png`), }); } if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") { diff --git a/ui/src/e2e/chat-flow.test-support.ts b/ui/src/e2e/chat-flow.test-support.ts index 77e46fc1fa2c..615911a49199 100644 --- a/ui/src/e2e/chat-flow.test-support.ts +++ b/ui/src/e2e/chat-flow.test-support.ts @@ -52,6 +52,22 @@ export function createChatFlowE2eSuite() { }); } +export async function buildLocalWebchatAudioMessage(source: string) { + const { buildWebchatAssistantMessageFromReplyPayloads } = + await import("../../../src/gateway/server-methods/chat-webchat-media.ts"); + const audioPath = new URL(source).pathname; + const localRoot = path.dirname(audioPath); + await mkdir(localRoot, { recursive: true }); + await writeFile(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); + return expectDefined( + await buildWebchatAssistantMessageFromReplyPayloads( + [{ mediaUrl: source, trustedLocalMedia: true }], + { localRoots: [localRoot] }, + ), + "Gateway-produced WebChat audio message", + ); +} + export const requireRecord = createRequireRecord("record", "expected-object-value"); export function requireString(value: unknown, label: string): string { diff --git a/ui/src/pages/chat/components/chat-message-local-media.ts b/ui/src/pages/chat/components/chat-message-local-media.ts index 09bcf66deff7..e67f3b31995e 100644 --- a/ui/src/pages/chat/components/chat-message-local-media.ts +++ b/ui/src/pages/chat/components/chat-message-local-media.ts @@ -7,7 +7,7 @@ export function isLocalAssistantAttachmentSource(source: string): boolean { } return ( isCanonicalInboundMediaSource(trimmed) || - trimmed.startsWith("file://") || + /^file:/iu.test(trimmed) || trimmed.startsWith("~") || trimmed.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(trimmed) @@ -32,15 +32,15 @@ function isCanonicalInboundMediaSource(source: string): boolean { function normalizeLocalAttachmentPath(source: string): string | null { const trimmed = source.trim(); - if (!isLocalAssistantAttachmentSource(trimmed)) { + if (!isLocalAssistantAttachmentSource(trimmed) || isCanonicalInboundMediaSource(trimmed)) { return null; } - if (isCanonicalInboundMediaSource(trimmed)) { - return null; - } - if (trimmed.startsWith("file://")) { + if (/^file:/iu.test(trimmed)) { try { const url = new URL(trimmed); + if (url.hostname || /%2f|%5c/iu.test(url.pathname)) { + return null; + } const pathname = decodeURIComponent(url.pathname); if (/^\/[a-zA-Z]:\//.test(pathname)) { return pathname.slice(1);