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.
This commit is contained in:
Mohamed Boudra
2026-07-15 00:10:24 +02:00
committed by GitHub
parent d706c4339b
commit 9c40cb7637
5 changed files with 76 additions and 7 deletions

View File

@@ -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", () => {

View File

@@ -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"));
}

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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;
}