fix(artifacts): parse write-file URLs through the guarded helper (#7609)

`ArtifactFileDetail` and `loadArtifactContentFromToolCall` re-parsed
`write-file:` URLs with `new URL` + `decodeURIComponent` after
`parseWriteFileArtifact` had already rejected malformed values. The panel
restores its selection from session storage and only type-checks it as a
string, so a stale or hand-edited `write-file:%E0%A4%A` (or
`write-file://[`) threw `URIError`/`TypeError` during render.

Export the guarded parser and read `path` / `tool_call_id` from one parsed
result in both places; a rejected URL degrades to the raw string or
`undefined` instead of taking the panel down.
This commit is contained in:
一片一片 2026-10-28 07:35:01 +08:00 committed by GitHub
parent 54a09c467e
commit f0ba280365
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 92 additions and 17 deletions

View File

@ -47,6 +47,7 @@ import { useArtifactContent } from "@/core/artifacts/hooks";
import {
getArtifactViewState,
getTabularDelimiter,
parseWriteFileArtifact,
} from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
@ -106,13 +107,15 @@ export function ArtifactFileDetail({
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
const filepath = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepathFromProps);
return decodeURIComponent(url.pathname);
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
// `write-file:` URLs come from messages and from the panel's persisted
// selection, so parse them through the guarded helper instead of `new URL`
// + `decodeURIComponent` directly: a malformed value must degrade to the
// raw string, not throw during render.
const parsedWriteFile = useMemo(
() => (isWriteFile ? parseWriteFileArtifact(filepathFromProps) : undefined),
[filepathFromProps, isWriteFile],
);
const filepath = parsedWriteFile?.path ?? filepathFromProps;
// Keep these local because ChatBox replaces context artifacts with thread state.
const [openedPresentedFilepaths, setOpenedPresentedFilepaths] = useState<
string[]
@ -178,11 +181,7 @@ export function ArtifactFileDetail({
const isSupportPreview =
language === "html" || language === "markdown" || isTabular;
const toolResult = (() => {
if (!isWriteFile) {
return undefined;
}
const url = new URL(filepathFromProps);
const toolCallId = url.searchParams.get("tool_call_id");
const toolCallId = parsedWriteFile?.toolCallId;
if (!toolCallId) {
return undefined;
}

View File

@ -4,7 +4,7 @@ import { fetch } from "@/core/api/fetcher";
import type { AgentThreadState } from "../threads";
import { buildWriteFileDraftContent } from "./preview";
import { buildWriteFileDraftContent, parseWriteFileArtifact } from "./preview";
import { urlOfArtifact } from "./utils";
function fnv1aHash(value: string): string {
@ -122,6 +122,11 @@ export function loadArtifactContentFromToolCall({
url: string;
thread: BaseStream<AgentThreadState>;
}) {
const target = parseWriteFileArtifact(urlString);
if (!target) {
return undefined;
}
const draftContent = buildWriteFileDraftContent({
filepath: urlString,
messages: thread.messages,
@ -130,9 +135,7 @@ export function loadArtifactContentFromToolCall({
return draftContent;
}
const url = new URL(urlString);
const toolCallId = url.searchParams.get("tool_call_id");
const messageId = url.searchParams.get("message_id");
const { messageId, toolCallId } = target;
if (messageId && toolCallId) {
const message = thread.messages.find((message) => message.id === messageId);
if (message?.type === "ai" && message.tool_calls) {

View File

@ -68,7 +68,7 @@ function findToolResult(
return undefined;
}
function parseWriteFileArtifact(filepath: string) {
export function parseWriteFileArtifact(filepath: string) {
if (!isWriteFileArtifact(filepath)) {
return undefined;
}

View File

@ -167,3 +167,12 @@ describe("ArtifactFileDetail browser-preview iframe", () => {
);
});
});
describe("ArtifactFileDetail malformed write-file URLs", () => {
it.each(["write-file:%E0%A4%A", "write-file://["])(
"renders the raw value without throwing: %s",
(filepath) => {
expect(() => renderDetail(filepath)).not.toThrow();
},
);
});

View File

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core";
import {
ARTIFACT_PREVIEW_MAX_BYTES,
loadArtifactContent,
loadArtifactContentFromToolCall,
} from "@/core/artifacts/loader";
describe("loadArtifactContent", () => {
@ -190,3 +191,46 @@ describe("loadArtifactContent", () => {
expect(loaded.sha256).toHaveLength(8);
});
});
describe("loadArtifactContentFromToolCall", () => {
type ToolCallThread = Parameters<
typeof loadArtifactContentFromToolCall
>[0]["thread"];
const thread = {
messages: [
{
type: "ai",
id: "ai-1",
tool_calls: [
{
id: "call-1",
name: "str_replace",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "patched",
},
},
],
},
],
} as unknown as ToolCallThread;
it("returns undefined instead of throwing for a malformed write-file URL", () => {
expect(
loadArtifactContentFromToolCall({ url: "write-file://[", thread }),
).toBeUndefined();
expect(
loadArtifactContentFromToolCall({ url: "write-file:%E0%A4%A", thread }),
).toBeUndefined();
});
it("reads non-write_file tool output through the parsed coordinates", () => {
expect(
loadArtifactContentFromToolCall({
url: "write-file:/mnt/user-data/outputs/report.md?message_id=ai-1&tool_call_id=call-1",
thread,
}),
).toBe("patched");
});
});

View File

@ -5,6 +5,7 @@ import {
buildWriteFileDraftContent,
createHtmlPreviewScrollKey,
getArtifactViewState,
parseWriteFileArtifact,
} from "@/core/artifacts/preview";
const ARTIFACT_PATH = "/artifact-fixtures/report.html";
@ -283,3 +284,22 @@ test("scopes HTML scroll restoration without exposing the artifact path", () =>
expect(html).not.toContain("/artifact-fixtures/a");
expect(html).not.toContain("<script>alert");
});
test("parses a write-file artifact URL and its tool-call coordinates", () => {
expect(
parseWriteFileArtifact(
`write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
),
).toEqual({
path: ARTIFACT_PATH,
messageId: "ai-1",
toolCallId: "call-1",
});
});
test.each(["write-file:%E0%A4%A", "write-file://["])(
"rejects a malformed write-file URL: %s",
(filepath) => {
expect(parseWriteFileArtifact(filepath)).toBeUndefined();
},
);