fix(artifacts): preserve literal percent sequences in file names (#5817)

This commit is contained in:
AochenShen99 2026-09-24 16:19:27 +08:00 committed by GitHub
parent 5804ee1a7e
commit ad58e8aa7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 68 additions and 3 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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(

View File

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