From 9c40cb76371247c2249e20fc708b4e02cd3360c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 00:10:24 +0200 Subject: [PATCH] Render provider images from paths with spaces (#2074) * fix(server): render image paths with spaces Raw spaces in local Markdown image destinations made the renderer treat provider images as text. Emit encoded file URIs for spaced paths so the client parses and resolves them as images. * fix(server): encode all absolute image paths Provider image paths without spaces could still contain URI-significant characters such as fragment or query markers. Route every absolute local path through the encoded file URI boundary. * fix(server): encode network image paths Windows network-share paths bypassed the absolute local path handling. Normalize UNC and extended UNC paths before encoding them as file URIs. * fix(app): preserve network image paths Host-based file URIs were decoded as relative paths. Restore the UNC prefix so provider images on network shares resolve through the file RPC correctly. * fix(server): preserve double-slash POSIX image paths Classify only backslash-prefixed paths as Windows network paths so POSIX double-leading-slash paths retain their filesystem semantics. --- packages/app/src/attachments/utils.test.ts | 4 +++ packages/app/src/attachments/utils.ts | 6 +++- .../providers/codex-app-server-agent.test.ts | 8 ++--- .../providers/provider-image-output.test.ts | 31 +++++++++++++++++ .../agent/providers/provider-image-output.ts | 34 +++++++++++++++++-- 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index 6c50ce17c..319f247d2 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -34,6 +34,10 @@ describe("fileUriToPath", () => { it("converts Windows drive-letter file URIs back to paths", () => { expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt"); }); + + it("converts host-based file URIs back to UNC paths", () => { + expect(fileUriToPath("file://server/share/shot%231.png")).toBe("\\\\server\\share\\shot#1.png"); + }); }); describe("localFileSourceToPath", () => { diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index e1422a7f4..bb1903f9f 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -162,7 +162,11 @@ export function fileUriToPath(uri: string): string { if (!uri.startsWith("file://")) { return uri; } - const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, "")); + const fileSource = uri.slice("file://".length); + const decodedPath = decodeFilePathSource(fileSource); + if (!fileSource.startsWith("/")) { + return `\\\\${decodedPath.replace(/\//g, "\\")}`; + } return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 4db6c748c..228f67124 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -3668,7 +3668,7 @@ describe("Codex app-server provider", () => { }); }); - test("emits imageView thread items as assistant markdown images using the path", () => { + test("emits imageView paths with spaces as valid assistant markdown images", () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -3688,15 +3688,15 @@ describe("Codex app-server provider", () => { turnId: "test-turn", item: { type: "assistant_message", - text: "![Image](/tmp/paseo image.png)", + text: "![Image](file:///tmp/paseo%20image.png)", }, }, ]); }); test.each([ - ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "/tmp/generated-camel.png"], - ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "/tmp/generated-snake.png"], + ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "file:///tmp/generated-camel.png"], + ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"], ])( "emits imageGeneration thread items with %s as assistant markdown images", (_fieldName, imageFields, expectedPath) => { diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index 49ffcf94b..0c32af04f 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -49,6 +49,37 @@ describe("isProviderImageMarkdown", () => { expect(isProviderImageMarkdown(markdown)).toBe(true); }); + test("emits POSIX file paths with spaces as valid file URI markdown", () => { + const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png"); + + expect(markdown).toBe( + "![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)", + ); + }); + + test("encodes URI-significant characters in POSIX file paths", () => { + const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png"); + + expect(markdown).toBe("![Image](file:///tmp/screenshot%231%3Fdraft.png)"); + }); + + test("preserves double-leading slashes in POSIX file paths", () => { + const markdown = renderImageMarkdown("//tmp/screenshot#1.png"); + + expect(markdown).toBe("![Image](file:////tmp/screenshot%231.png)"); + }); + + test.each([ + ["UNC", "\\\\server\\share\\shot#1.png", "file://server/share/shot%231.png"], + [ + "extended UNC", + "\\\\?\\UNC\\server\\share\\shot?draft.png", + "file://server/share/shot%3Fdraft.png", + ], + ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => { + expect(renderImageMarkdown(imagePath)).toBe(`![Image](${expectedSource})`); + }); + test("rejects user-authored markdown that is not a materialized attachment", () => { // No content hash — a hand-written path, not something the writer produced. expect(isProviderImageMarkdown("![diagram](./paseo-attachments/notes.png)")).toBe(false); diff --git a/packages/server/src/server/agent/providers/provider-image-output.ts b/packages/server/src/server/agent/providers/provider-image-output.ts index 649e5bdfe..387158b32 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.ts @@ -128,9 +128,39 @@ function escapeMarkdownImageAlt(value: string): string { return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]"); } +function encodeFilePath(value: string): string { + return value + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function windowsFileUri(value: string): string | null { + const isWindowsNetworkPath = value.startsWith("\\\\"); + let normalizedPath = value.replace(/\\/g, "/"); + if (/^\/\/\?\/UNC\//i.test(normalizedPath)) { + normalizedPath = `//${normalizedPath.slice(8)}`; + } else if (/^\/\/\?\/[A-Za-z]:\//.test(normalizedPath)) { + normalizedPath = normalizedPath.slice(4); + } + + if (/^[A-Za-z]:\//.test(normalizedPath)) { + const drive = normalizedPath.slice(0, 2); + return `file:///${drive}${encodeFilePath(normalizedPath.slice(2))}`; + } + if (isWindowsNetworkPath && normalizedPath.startsWith("//")) { + return `file:${encodeFilePath(normalizedPath)}`; + } + return null; +} + function markdownImageSource(value: string): string { - if (/^[A-Za-z]:[\\/]/.test(value)) { - return `file:///${value.replace(/\\/g, "/")}`; + const windowsUri = windowsFileUri(value); + if (windowsUri) { + return windowsUri; + } + if (value.startsWith("/")) { + return `file://${encodeFilePath(value)}`; } return value; }