diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index a5be5e112e6..e9009160dce 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -431,4 +431,23 @@ describe("loadWebMedia", () => { code: "path-not-allowed", }); }); + + it("hydrates inbound media store URIs before allowed-root checks", async () => { + const id = `signal-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const filePath = path.join(stateDir, "media", "inbound", id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + try { + const result = await loadWebMedia(`media://inbound/${id}`, { + maxBytes: 1024 * 1024, + }); + + expect(result.kind).toBe("image"); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.fileName).toBe(id); + } finally { + await fs.rm(filePath, { force: true }); + } + }); }); diff --git a/src/media/web-media.ts b/src/media/web-media.ts index ee45336de7e..1691faefe5f 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -27,6 +27,7 @@ import { mimeTypeFromFilePath, normalizeMimeType, } from "./mime.js"; +import { resolveMediaBufferPath } from "./store.js"; export { getDefaultLocalRoots, LocalMediaAccessError }; export type { LocalMediaAccessErrorCode }; @@ -56,6 +57,46 @@ type WebMediaOptions = { hostReadCapability?: boolean; }; +async function resolveMediaStoreUriToPath(mediaUrl: string): Promise { + if (!mediaUrl.startsWith("media://")) { + return null; + } + let parsed: URL; + try { + parsed = new URL(mediaUrl); + } catch (err) { + throw new LocalMediaAccessError("invalid-path", `Invalid media URI: ${mediaUrl}`, { + cause: err, + }); + } + if (parsed.hostname !== "inbound") { + throw new LocalMediaAccessError( + "path-not-allowed", + `Unsupported media URI location: ${parsed.hostname || "(missing)"}`, + ); + } + let id: string; + try { + id = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); + } catch (err) { + throw new LocalMediaAccessError("invalid-path", `Invalid media URI: ${mediaUrl}`, { + cause: err, + }); + } + if (!id || id.includes("/")) { + throw new LocalMediaAccessError("invalid-path", `Invalid media URI: ${mediaUrl}`); + } + try { + return await resolveMediaBufferPath(id, "inbound"); + } catch (err) { + throw new LocalMediaAccessError( + "invalid-path", + err instanceof Error ? err.message : `Invalid media URI: ${mediaUrl}`, + { cause: err }, + ); + } +} + function resolveWebMediaOptions(params: { maxBytesOrOptions?: number | WebMediaOptions; options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" }; @@ -356,7 +397,10 @@ async function loadWebMediaInternal( } = options; // Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths. // Be lenient: LLM output may add extra whitespace (e.g. " MEDIA : /tmp/x.png"). - mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, ""); + if (!/^\s*media:\/\//i.test(mediaUrl)) { + mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, ""); + } + mediaUrl = (await resolveMediaStoreUriToPath(mediaUrl)) ?? mediaUrl; // Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.) if (mediaUrl.startsWith("file://")) { try {