mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
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:
@@ -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" } },
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user