Skip to content

Commit 3073f7d

Browse files
steipetesweetcornna
andcommitted
fix(ui): proxy canonical inbound media previews
Co-authored-by: Cornna <[email protected]>
1 parent d6c880a commit 3073f7d

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

ui/src/e2e/chat-flow.e2e.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,70 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
346346
}
347347
});
348348

349+
it("renders a canonical inbound image through the ticketed media route", async () => {
350+
const context = await newBrowserContext({
351+
locale: "en-US",
352+
serviceWorkers: "block",
353+
viewport: { height: 900, width: 1280 },
354+
});
355+
const page = await context.newPage();
356+
const requestedMediaUrls: URL[] = [];
357+
await page.route("**/__openclaw__/assistant-media?**", async (route) => {
358+
const request = route.request();
359+
const url = new URL(request.url());
360+
requestedMediaUrls.push(url);
361+
expect(url.searchParams.get("source")).toBe("media://inbound/telegram-photo.png");
362+
if (url.searchParams.get("meta") === "1") {
363+
expect(request.headers().authorization).toBe("Bearer e2e-device-token");
364+
await route.fulfill({
365+
contentType: "application/json",
366+
body: JSON.stringify({
367+
available: true,
368+
mediaTicket: "ticket-inbound",
369+
mediaTicketExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
370+
}),
371+
});
372+
return;
373+
}
374+
expect(url.searchParams.get("mediaTicket")).toBe("ticket-inbound");
375+
await route.fulfill({
376+
contentType: "image/png",
377+
body: Buffer.from(
378+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=",
379+
"base64",
380+
),
381+
});
382+
});
383+
await installMockGateway(page, {
384+
historyMessages: [
385+
{
386+
id: "user-inbound-media-ref",
387+
role: "user",
388+
content: [{ type: "text", text: "🖼️ Attached image" }],
389+
MediaPath: "media://inbound/telegram-photo.png",
390+
MediaType: "image/png",
391+
timestamp: Date.now(),
392+
},
393+
],
394+
});
395+
396+
try {
397+
await page.goto(`${server.baseUrl}chat`);
398+
await expect.poll(() => requestedMediaUrls.length, { timeout: 10_000 }).toBe(2);
399+
const image = page.getByAltText("Attached image");
400+
await image.waitFor({ state: "visible", timeout: 10_000 });
401+
await expect
402+
.poll(() =>
403+
image.evaluate((element) =>
404+
element instanceof HTMLImageElement && element.complete ? element.naturalWidth : 0,
405+
),
406+
)
407+
.toBe(1);
408+
} finally {
409+
await closeBrowserContext(context);
410+
}
411+
});
412+
349413
it("opens current context and latest-run usage from the composer ring", async () => {
350414
const context = await newBrowserContext({
351415
locale: "en-US",

ui/src/pages/chat/components/chat-message.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2076,6 +2076,94 @@ describe("grouped chat rendering", () => {
20762076
vi.unstubAllGlobals();
20772077
});
20782078

2079+
it("renders canonical inbound transcript images through the authenticated media route", async () => {
2080+
resetAssistantAttachmentAvailabilityCacheForTest();
2081+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
2082+
const mediaUrl = new URL(url, "http://control.test");
2083+
expect(mediaUrl.pathname).toBe("/openclaw/__openclaw__/assistant-media");
2084+
expect([...mediaUrl.searchParams.keys()].toSorted()).toEqual(["meta", "source"]);
2085+
expect(mediaUrl.searchParams.get("meta")).toBe("1");
2086+
expect(mediaUrl.searchParams.get("source")).toBe("media://inbound/telegram-photo.png");
2087+
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token");
2088+
return {
2089+
ok: true,
2090+
json: async () => mediaTicketPayload("ticket-inbound"),
2091+
};
2092+
});
2093+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
2094+
2095+
const container = document.createElement("div");
2096+
const renderMessage = () =>
2097+
renderGroupedMessage(
2098+
container,
2099+
{
2100+
id: "user-inbound-media-ref",
2101+
role: "user",
2102+
content: "",
2103+
MediaPath: "media://inbound/telegram-photo.png",
2104+
MediaType: "image/png",
2105+
timestamp: Date.now(),
2106+
},
2107+
"user",
2108+
{
2109+
showToolCalls: false,
2110+
basePath: "/openclaw",
2111+
assistantAttachmentAuthToken: "session-token",
2112+
localMediaPreviewRoots: [],
2113+
onRequestUpdate: renderMessage,
2114+
},
2115+
);
2116+
2117+
renderMessage();
2118+
await flushAssistantAttachmentAvailabilityChecks();
2119+
2120+
expect(fetchMock).toHaveBeenCalledTimes(1);
2121+
expect(
2122+
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
2123+
).toBe(
2124+
"/openclaw/__openclaw__/assistant-media?source=media%3A%2F%2Finbound%2Ftelegram-photo.png&mediaTicket=ticket-inbound",
2125+
);
2126+
vi.unstubAllGlobals();
2127+
});
2128+
2129+
it.each([
2130+
"media://outbound/photo.png",
2131+
"media://inbound/",
2132+
"media://inbound/nested%2Fphoto.png",
2133+
"media://inbound/%00.png",
2134+
"media://inbound/nested/../photo.png",
2135+
"media://inbound/%2e%2e/photo.png",
2136+
"media://inbound/..",
2137+
"media://inbound/photo.png?raw=1",
2138+
"media://inbound/photo.png#preview",
2139+
])("does not proxy non-canonical inbound media ref %s", (source) => {
2140+
resetAssistantAttachmentAvailabilityCacheForTest();
2141+
const fetchMock = vi.fn();
2142+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
2143+
2144+
const container = document.createElement("div");
2145+
renderGroupedMessage(
2146+
container,
2147+
{
2148+
id: "user-invalid-inbound-media-ref",
2149+
role: "user",
2150+
content: "",
2151+
MediaPath: source,
2152+
MediaType: "image/png",
2153+
timestamp: Date.now(),
2154+
},
2155+
"user",
2156+
{
2157+
showToolCalls: false,
2158+
assistantAttachmentAuthToken: "session-token",
2159+
localMediaPreviewRoots: [],
2160+
},
2161+
);
2162+
2163+
expect(fetchMock).not.toHaveBeenCalled();
2164+
vi.unstubAllGlobals();
2165+
});
2166+
20792167
it("fetches managed chat images with auth and renders blob previews", async () => {
20802168
resetAssistantAttachmentAvailabilityCacheForTest();
20812169
const managedChatImageUrl =

ui/src/pages/chat/components/chat-message.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,18 +1228,38 @@ function isLocalAssistantAttachmentSource(source: string): boolean {
12281228
return false;
12291229
}
12301230
return (
1231+
isCanonicalInboundMediaSource(trimmed) ||
12311232
trimmed.startsWith("file://") ||
12321233
trimmed.startsWith("~") ||
12331234
trimmed.startsWith("/") ||
12341235
/^[a-zA-Z]:[\\/]/.test(trimmed)
12351236
);
12361237
}
12371238

1239+
function isCanonicalInboundMediaSource(source: string): boolean {
1240+
// Match the raw one-segment form first; URL parsing would erase dot segments.
1241+
const match = /^media:\/\/inbound\/([^/?#]+)$/i.exec(source.trim());
1242+
if (!match?.[1]) {
1243+
return false;
1244+
}
1245+
try {
1246+
const id = decodeURIComponent(match[1]);
1247+
return (
1248+
id !== "." && id !== ".." && !id.includes("/") && !id.includes("\\") && !id.includes("\0")
1249+
);
1250+
} catch {
1251+
return false;
1252+
}
1253+
}
1254+
12381255
function normalizeLocalAttachmentPath(source: string): string | null {
12391256
const trimmed = source.trim();
12401257
if (!isLocalAssistantAttachmentSource(trimmed)) {
12411258
return null;
12421259
}
1260+
if (isCanonicalInboundMediaSource(trimmed)) {
1261+
return null;
1262+
}
12431263
if (trimmed.startsWith("file://")) {
12441264
try {
12451265
const url = new URL(trimmed);
@@ -1290,6 +1310,9 @@ function isLocalAttachmentPreviewAllowed(
12901310
source: string,
12911311
localMediaPreviewRoots: readonly string[],
12921312
): boolean {
1313+
if (isCanonicalInboundMediaSource(source)) {
1314+
return true;
1315+
}
12931316
const normalizedSource = normalizeLocalAttachmentPath(source);
12941317
const comparableSources = normalizedSource
12951318
? [canonicalizeLocalPathForComparison(normalizedSource)]

0 commit comments

Comments
 (0)