diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index f301f686bb97..6cdbb4835fe8 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -346,6 +346,70 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("renders a canonical inbound image through the ticketed media route", async () => { + const context = await newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const requestedMediaUrls: URL[] = []; + 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("media://inbound/telegram-photo.png"); + if (url.searchParams.get("meta") === "1") { + expect(request.headers().authorization).toBe("Bearer e2e-device-token"); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + available: true, + mediaTicket: "ticket-inbound", + mediaTicketExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + }), + }); + return; + } + expect(url.searchParams.get("mediaTicket")).toBe("ticket-inbound"); + await route.fulfill({ + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=", + "base64", + ), + }); + }); + await installMockGateway(page, { + historyMessages: [ + { + id: "user-inbound-media-ref", + role: "user", + content: [{ type: "text", text: "🖼️ Attached image" }], + MediaPath: "media://inbound/telegram-photo.png", + MediaType: "image/png", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await expect.poll(() => requestedMediaUrls.length, { timeout: 10_000 }).toBe(2); + const image = page.getByAltText("Attached image"); + await image.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(() => + image.evaluate((element) => + element instanceof HTMLImageElement && element.complete ? element.naturalWidth : 0, + ), + ) + .toBe(1); + } finally { + await closeBrowserContext(context); + } + }); + it("opens current context and latest-run usage from the composer ring", async () => { const context = await newBrowserContext({ locale: "en-US", diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index c4b97111ca85..79a886245441 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -2076,6 +2076,94 @@ describe("grouped chat rendering", () => { vi.unstubAllGlobals(); }); + it("renders canonical inbound transcript images through the authenticated media route", async () => { + resetAssistantAttachmentAvailabilityCacheForTest(); + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const mediaUrl = new URL(url, "http://control.test"); + expect(mediaUrl.pathname).toBe("/openclaw/__openclaw__/assistant-media"); + expect([...mediaUrl.searchParams.keys()].toSorted()).toEqual(["meta", "source"]); + expect(mediaUrl.searchParams.get("meta")).toBe("1"); + expect(mediaUrl.searchParams.get("source")).toBe("media://inbound/telegram-photo.png"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token"); + return { + ok: true, + json: async () => mediaTicketPayload("ticket-inbound"), + }; + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const container = document.createElement("div"); + const renderMessage = () => + renderGroupedMessage( + container, + { + id: "user-inbound-media-ref", + role: "user", + content: "", + MediaPath: "media://inbound/telegram-photo.png", + MediaType: "image/png", + timestamp: Date.now(), + }, + "user", + { + showToolCalls: false, + basePath: "/openclaw", + assistantAttachmentAuthToken: "session-token", + localMediaPreviewRoots: [], + onRequestUpdate: renderMessage, + }, + ); + + renderMessage(); + await flushAssistantAttachmentAvailabilityChecks(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect( + container.querySelector(".chat-message-image")?.getAttribute("src"), + ).toBe( + "/openclaw/__openclaw__/assistant-media?source=media%3A%2F%2Finbound%2Ftelegram-photo.png&mediaTicket=ticket-inbound", + ); + vi.unstubAllGlobals(); + }); + + it.each([ + "media://outbound/photo.png", + "media://inbound/", + "media://inbound/nested%2Fphoto.png", + "media://inbound/%00.png", + "media://inbound/nested/../photo.png", + "media://inbound/%2e%2e/photo.png", + "media://inbound/..", + "media://inbound/photo.png?raw=1", + "media://inbound/photo.png#preview", + ])("does not proxy non-canonical inbound media ref %s", (source) => { + resetAssistantAttachmentAvailabilityCacheForTest(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const container = document.createElement("div"); + renderGroupedMessage( + container, + { + id: "user-invalid-inbound-media-ref", + role: "user", + content: "", + MediaPath: source, + MediaType: "image/png", + timestamp: Date.now(), + }, + "user", + { + showToolCalls: false, + assistantAttachmentAuthToken: "session-token", + localMediaPreviewRoots: [], + }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + it("fetches managed chat images with auth and renders blob previews", async () => { resetAssistantAttachmentAvailabilityCacheForTest(); const managedChatImageUrl = diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index 24ec60bcaba0..6ec0f2624fe5 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -1228,6 +1228,7 @@ function isLocalAssistantAttachmentSource(source: string): boolean { return false; } return ( + isCanonicalInboundMediaSource(trimmed) || trimmed.startsWith("file://") || trimmed.startsWith("~") || trimmed.startsWith("/") || @@ -1235,11 +1236,30 @@ function isLocalAssistantAttachmentSource(source: string): boolean { ); } +function isCanonicalInboundMediaSource(source: string): boolean { + // Match the raw one-segment form first; URL parsing would erase dot segments. + const match = /^media:\/\/inbound\/([^/?#]+)$/i.exec(source.trim()); + if (!match?.[1]) { + return false; + } + try { + const id = decodeURIComponent(match[1]); + return ( + id !== "." && id !== ".." && !id.includes("/") && !id.includes("\\") && !id.includes("\0") + ); + } catch { + return false; + } +} + function normalizeLocalAttachmentPath(source: string): string | null { const trimmed = source.trim(); if (!isLocalAssistantAttachmentSource(trimmed)) { return null; } + if (isCanonicalInboundMediaSource(trimmed)) { + return null; + } if (trimmed.startsWith("file://")) { try { const url = new URL(trimmed); @@ -1290,6 +1310,9 @@ function isLocalAttachmentPreviewAllowed( source: string, localMediaPreviewRoots: readonly string[], ): boolean { + if (isCanonicalInboundMediaSource(source)) { + return true; + } const normalizedSource = normalizeLocalAttachmentPath(source); const comparableSources = normalizedSource ? [canonicalizeLocalPathForComparison(normalizedSource)]