From 8a2e5c786e085dde2f88b3f3c88854c73fb14bb7 Mon Sep 17 00:00:00 2001 From: ElliotWu <112760141+Elliotwu-7@users.noreply.github.com> Date: Mon, 25 May 2026 18:05:00 +0800 Subject: [PATCH] fix(app): swallow URIError in assistant file link parser on bare '%' decodeURIComponent throws on '%' not followed by two hex digits. The two calls inside parseAssistantFileLink and normalizeFileUrlPath had no try/catch, so any timeline message containing strings like '100% packet loss' (ping output), '%PATH%' or '0% off' could escape into the React tree and unmount the agent route, leaving the renderer blank. Wrap both calls in a local safeDecodeURIComponent that returns the input unchanged when decoding fails, mirroring the helper already used in host-routes.ts. Closes #1148 --- .../src/assistant-file-links/parse.test.ts | 19 +++++++++++++++++++ .../app/src/assistant-file-links/parse.ts | 12 ++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/parse.test.ts index 9410f3da4..d3dc823cb 100644 --- a/packages/app/src/assistant-file-links/parse.test.ts +++ b/packages/app/src/assistant-file-links/parse.test.ts @@ -360,6 +360,25 @@ describe("parseAssistantFileLink", () => { }), ).toBeNull(); }); + + it("does not throw when the input contains a literal '%' that is not a valid percent-escape", () => { + // Regressions for tool output strings like ping's "100% packet loss", + // Windows "%PATH%" references, and percentages such as "0% off". + // decodeURIComponent throws URIError on these; the parser must swallow + // it and return null rather than crash the renderer. + const cases = [ + "/tmp/100% packet loss", + "/Users/test/project/0% off", + "/var/log/%PATH%/x.log", + "file:///tmp/100% packet loss", + ]; + for (const value of cases) { + expect(() => + parseAssistantFileLink(value, { workspaceRoot: "/Users/test/project" }), + ).not.toThrow(); + } + expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow(); + }); }); describe("normalizeInlinePathTarget", () => { diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index c46a67de1..bfcf31842 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -1,5 +1,13 @@ import { isAbsolutePath } from "@/utils/path"; +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + export interface InlinePathTarget { raw: string; path: string; @@ -350,7 +358,7 @@ export function parseAssistantFileLink( return null; } - const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname)); + const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname)); if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } @@ -659,7 +667,7 @@ function normalizeFileUrlPath(pathname: string): string | null { return null; } - const decoded = decodeURIComponent(pathname).replace(/\\/g, "/"); + const decoded = safeDecodeURIComponent(pathname).replace(/\\/g, "/"); if (!decoded) { return null; }