mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -34,6 +34,10 @@ describe("fileUriToPath", () => {
|
|||||||
it("converts Windows drive-letter file URIs back to paths", () => {
|
it("converts Windows drive-letter file URIs back to paths", () => {
|
||||||
expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt");
|
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", () => {
|
describe("localFileSourceToPath", () => {
|
||||||
|
|||||||
@@ -162,7 +162,11 @@ export function fileUriToPath(uri: string): string {
|
|||||||
if (!uri.startsWith("file://")) {
|
if (!uri.startsWith("file://")) {
|
||||||
return uri;
|
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"));
|
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -3688,15 +3688,15 @@ describe("Codex app-server provider", () => {
|
|||||||
turnId: "test-turn",
|
turnId: "test-turn",
|
||||||
item: {
|
item: {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: "",
|
text: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["savedPath", { savedPath: "/tmp/generated-camel.png" }, "/tmp/generated-camel.png"],
|
["savedPath", { savedPath: "/tmp/generated-camel.png" }, "file:///tmp/generated-camel.png"],
|
||||||
["saved_path", { saved_path: "/tmp/generated-snake.png" }, "/tmp/generated-snake.png"],
|
["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"],
|
||||||
])(
|
])(
|
||||||
"emits imageGeneration thread items with %s as assistant markdown images",
|
"emits imageGeneration thread items with %s as assistant markdown images",
|
||||||
(_fieldName, imageFields, expectedPath) => {
|
(_fieldName, imageFields, expectedPath) => {
|
||||||
|
|||||||
@@ -49,6 +49,37 @@ describe("isProviderImageMarkdown", () => {
|
|||||||
expect(isProviderImageMarkdown(markdown)).toBe(true);
|
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(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encodes URI-significant characters in POSIX file paths", () => {
|
||||||
|
const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
|
||||||
|
|
||||||
|
expect(markdown).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves double-leading slashes in POSIX file paths", () => {
|
||||||
|
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
|
||||||
|
|
||||||
|
expect(markdown).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
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(``);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects user-authored markdown that is not a materialized attachment", () => {
|
test("rejects user-authored markdown that is not a materialized attachment", () => {
|
||||||
// No content hash — a hand-written path, not something the writer produced.
|
// No content hash — a hand-written path, not something the writer produced.
|
||||||
expect(isProviderImageMarkdown("")).toBe(false);
|
expect(isProviderImageMarkdown("")).toBe(false);
|
||||||
|
|||||||
@@ -128,9 +128,39 @@ function escapeMarkdownImageAlt(value: string): string {
|
|||||||
return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]");
|
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 {
|
function markdownImageSource(value: string): string {
|
||||||
if (/^[A-Za-z]:[\\/]/.test(value)) {
|
const windowsUri = windowsFileUri(value);
|
||||||
return `file:///${value.replace(/\\/g, "/")}`;
|
if (windowsUri) {
|
||||||
|
return windowsUri;
|
||||||
|
}
|
||||||
|
if (value.startsWith("/")) {
|
||||||
|
return `file://${encodeFilePath(value)}`;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user