From ad58e8aa7d2f9299fd82fe7ef73c1fa785049706 Mon Sep 17 00:00:00 2001 From: AochenShen99 Date: Thu, 24 Sep 2026 16:19:27 +0800 Subject: [PATCH] fix(artifacts): preserve literal percent sequences in file names (#5817) --- README.md | 4 ++ frontend/src/AGENTS.md | 6 +++ frontend/src/core/artifacts/utils.ts | 8 ++- .../tests/unit/core/artifacts/utils.test.ts | 53 ++++++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 48298036c1..35ed26d7d9 100644 --- a/README.md +++ b/README.md @@ -1749,6 +1749,10 @@ an explicit **Load full file** action before fetching the remainder or mounting the full code editor. Active HTML, XHTML, and SVG artifacts remain forced downloads at the Gateway boundary. +Artifact previews and downloads preserve literal percent sequences in file names: +`report%20final.md` and `report final.md` remain distinct files. Markdown links +continue to accept URL-encoded paths. + The `write_file` guidance reflects the active model's output token limit, including custom-agent and thinking-mode overrides. For longer documents, the agent is guided to write sections with `append=True`; models without a known limit receive no numeric budget hint. With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes, so host-native CLI launchers retain their normal MSYS compatibility. When the local sandbox falls back to PowerShell, it captures output as UTF-8 so CJK text does not depend on the Gateway host locale. diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 8f35c824cc..904f2fa381 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -1,5 +1,11 @@ ### Data Flow +Artifact state, upload metadata, and workspace changes carry raw filesystem +paths. `urlOfArtifact` and `resolveArtifactURL` encode those paths without +decoding literal percent sequences. Only Markdown destinations are URL inputs: +`resolveMarkdownArtifactURL` decodes once at that boundary, and relative image +resolution matches the decoded name against raw artifact paths before encoding. + 1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission, and `core/voice-input` can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming 2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream. File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed `write_file` or `str_replace` updates. diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts index f948c1ace6..e0fc803b59 100644 --- a/frontend/src/core/artifacts/utils.ts +++ b/frontend/src/core/artifacts/utils.ts @@ -21,9 +21,11 @@ function splitPathSuffix(src: string) { } function encodeArtifactPath(filepath: string) { + // This is a filesystem path, not a URL: a literal "%20" must stay part + // of the filename after the server decodes the request path once. return filepath .split("/") - .map((segment) => encodeURIComponent(decodePathSegment(segment))) + .map((segment) => encodeURIComponent(segment)) .join("/"); } @@ -98,7 +100,9 @@ export function resolveArtifactURL(absolutePath: string, threadId: string) { export function resolveMarkdownArtifactURL(src: string, threadId: string) { const { path, suffix } = splitPathSuffix(src); - return `${resolveArtifactURL(path, threadId)}${suffix}`; + // Markdown destinations already carry URL escapes; decode only at this + // boundary before handing the raw path to the artifact URL builder. + return `${resolveArtifactURL(decodeRelativeArtifactPath(path), threadId)}${suffix}`; } export function resolveMessageImageURL( diff --git a/frontend/tests/unit/core/artifacts/utils.test.ts b/frontend/tests/unit/core/artifacts/utils.test.ts index edb8b35dd8..fd6f034d09 100644 --- a/frontend/tests/unit/core/artifacts/utils.test.ts +++ b/frontend/tests/unit/core/artifacts/utils.test.ts @@ -104,10 +104,61 @@ describe("artifact URL helpers", () => { expect( resolveArtifactURL("/mnt/user-data/outputs/a%23b%3F.txt", "thread-1"), ).toBe( - "/api/threads/thread-1/artifacts/mnt/user-data/outputs/a%23b%3F.txt", + "/api/threads/thread-1/artifacts/mnt/user-data/outputs/a%2523b%253F.txt", ); }); + test.each(["report%20final.md", "a%2Fb.txt", "a%2520b.png"])( + "preserves literal percent escapes in the raw artifact path %s", + async (filename) => { + const { resolveArtifactURL, urlOfArtifact } = + await loadFreshArtifactUtils(); + const filepath = `/mnt/user-data/outputs/${filename}`; + for (const url of [ + urlOfArtifact({ filepath, threadId: "thread-1" }), + urlOfArtifact({ filepath, threadId: "thread-1", download: true }), + urlOfArtifact({ filepath, threadId: "thread-1", isMock: true }), + resolveArtifactURL(filepath, "thread-1"), + ]) { + const pathname = new URL(url, "http://localhost").pathname; + expect(decodeURIComponent(pathname.split("/artifacts")[1]!)).toBe( + filepath, + ); + } + }, + ); + + test("decodes Markdown URLs once before resolving the raw artifact path", async () => { + const { resolveMarkdownArtifactURL, resolveMessageImageURL } = + await loadFreshArtifactUtils(); + const rawPath = "/mnt/user-data/outputs/report%20final.png"; + const expected = + "/api/threads/thread-1/artifacts/mnt/user-data/outputs/report%2520final.png?v=2#detail"; + + expect( + resolveMarkdownArtifactURL( + "/mnt/user-data/outputs/report%2520final.png?v=2#detail", + "thread-1", + ), + ).toBe(expected); + expect( + resolveMessageImageURL("report%2520final.png?v=2#detail", "thread-1", [ + rawPath, + ]), + ).toBe(expected); + }); + + test("preserves percent escapes in static-demo file names", async () => { + setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "true"); + const { urlOfArtifact } = await loadFreshArtifactUtils(); + expect( + urlOfArtifact({ + filepath: "/mnt/user-data/outputs/report%20final.md", + threadId: "thread-1", + }), + ).toBe("/demo/threads/thread-1/user-data/outputs/report%2520final.md"); + }); + test("preserves markdown query and fragment suffixes on artifact URLs", async () => { const { resolveMarkdownArtifactURL, resolveMessageImageURL } = await loadFreshArtifactUtils();