fix(gateway): serve Control UI assistant media for uppercase file URLs (#121827)

* fix(gateway): normalize assistant media file URLs

Co-authored-by: masatohoshino <g515hoshino@gmail.com>

* fix(gateway): normalize webchat media at the producer

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Masato Hoshino
2026-08-26 06:14:52 +09:00
committed by GitHub
parent 18684f5cd1
commit 8c31fada1b
7 changed files with 104 additions and 40 deletions
@@ -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" } },
+1 -1
View File
@@ -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 {
@@ -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-"));
@@ -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)) {
+28 -10
View File
@@ -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") {
+16
View File
@@ -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 {
@@ -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);