From 03c145cf3c496f1db901d7ac81d3a5f13d6f1b11 Mon Sep 17 00:00:00 2001 From: malin1997 Date: Sat, 25 Jul 2026 22:20:56 +0800 Subject: [PATCH] gui: decode percent-encoded non-ASCII artifact paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit micromark percent-encodes non-ASCII characters in link hrefs, so an artifact link to a Chinese/Japanese/Korean filename dispatched the encoded path and the backend 404ed on the literal %E6… name. Decode the path (and strip a leading slash so it stays workspace-relative) before opening. --- surfaces/gui/src/components/Markdown.test.tsx | 28 ++++++++++++++++++- surfaces/gui/src/components/Markdown.tsx | 26 +++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/surfaces/gui/src/components/Markdown.test.tsx b/surfaces/gui/src/components/Markdown.test.tsx index b2d4fee0..cfa09bbb 100644 --- a/surfaces/gui/src/components/Markdown.test.tsx +++ b/surfaces/gui/src/components/Markdown.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown"; +import { Markdown, OPEN_ARTIFACT_EVENT, normalizeArtifactPath } from "./Markdown"; afterEach(cleanup); @@ -35,4 +35,30 @@ describe("Markdown artifact links", () => { render(); expect(screen.getByTestId("artifact-chip").textContent).toContain("report.pdf"); }); + + // Chinese (and other non-ASCII) filenames: micromark percent-encodes the href; without + // decode the backend looks up the literal %E6… path and returns "not found". + it("decodes percent-encoded non-ASCII artifact paths before opening", () => { + const seen: string[] = []; + const listener = (e: Event) => seen.push((e as CustomEvent).detail.path); + window.addEventListener(OPEN_ARTIFACT_EVENT, listener); + + render(); + const chip = screen.getByTestId("artifact-chip"); + expect(chip.getAttribute("title")).toBe("reports/2026-04_月度ζŠ₯ε‘Š.md"); + fireEvent.click(chip); + expect(seen).toEqual(["reports/2026-04_月度ζŠ₯ε‘Š.md"]); + + window.removeEventListener(OPEN_ARTIFACT_EVENT, listener); + }); +}); + +describe("normalizeArtifactPath", () => { + it("decodes percent-encoded segments and strips a leading slash", () => { + expect(normalizeArtifactPath("reports/2026-04_%E6%9C%88%E5%BA%A6%E6%8A%A5%E5%91%8A.md")).toBe( + "reports/2026-04_月度ζŠ₯ε‘Š.md", + ); + expect(normalizeArtifactPath("/reports/foo.md")).toBe("reports/foo.md"); + expect(normalizeArtifactPath("reports/ascii.md")).toBe("reports/ascii.md"); + }); }); diff --git a/surfaces/gui/src/components/Markdown.tsx b/surfaces/gui/src/components/Markdown.tsx index b4bbc5c9..fa2a0c79 100644 --- a/surfaces/gui/src/components/Markdown.tsx +++ b/surfaces/gui/src/components/Markdown.tsx @@ -10,16 +10,36 @@ import { Icon } from "./Icon"; // the session's artifact list, App un-hides the rail. export const OPEN_ARTIFACT_EVENT = "ocw-open-artifact"; +/** Normalize an artifact: href path for the session workspace. + * + * react-markdown / micromark percent-encodes non-ASCII URL characters, so + * `artifact:reports/ζŠ₯ε‘Š.md` becomes `artifact:reports/%E6%8A%A5%E5%91%8A.md`. + * The backend looks up the literal filesystem path β€” encoded names 404 as "not found". + * Also strip a single leading `/` so `artifact:/reports/x.md` stays workspace-relative + * (Path(workspace) / "/abs" would otherwise escape the workspace root). + */ +export function normalizeArtifactPath(raw: string): string { + let path = raw; + try { + path = decodeURIComponent(raw); + } catch { + // malformed % sequences β€” keep raw + } + if (path.startsWith("/")) path = path.replace(/^\/+/, ""); + return path; +} + function ArtifactChip({ path, title }: { path: string; title: string }) { const { t } = useTranslation(); - const file = path.split("/").pop() || path; + const resolved = normalizeArtifactPath(path); + const file = resolved.split("/").pop() || resolved; return (