From fe1e95818c38426a675cef26479e89598e3b5f4e Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 25 Feb 2026 15:19:07 +0200 Subject: [PATCH 1/3] fix(messages): harden file link routing in markdown --- src/features/messages/components/Markdown.tsx | 179 +++++++++++++++++- .../messages/components/Messages.test.tsx | 79 ++++++++ 2 files changed, 256 insertions(+), 2 deletions(-) diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index ec450531d..37e74ebbd 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -180,6 +180,136 @@ function normalizeUrlLine(line: string) { return withoutBullet; } +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function safeDecodeFileLink(url: string) { + try { + return decodeFileLink(url); + } catch { + return null; + } +} + +const FILE_LINE_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/opt/", + "/etc/", + "/private/", + "/Volumes/", + "/mnt/", +]; + +function stripPathLineSuffix(value: string) { + return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); +} + +function hasLikelyFileName(path: string) { + const normalizedPath = stripPathLineSuffix(path).replace(/[\\/]+$/, ""); + const lastSegment = normalizedPath.split(/[\\/]/).pop() ?? ""; + if (!lastSegment || lastSegment === "." || lastSegment === "..") { + return false; + } + if (lastSegment.startsWith(".") && lastSegment.length > 1) { + return true; + } + return lastSegment.includes("."); +} + +function hasLikelyLocalAbsolutePrefix(path: string) { + const normalizedPath = path.replace(/\\/g, "/"); + return LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES.some((prefix) => + normalizedPath.startsWith(prefix), + ); +} + +function isLikelyFileHref(url: string) { + const trimmed = url.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("file://")) { + return true; + } + if ( + trimmed.startsWith("http://") || + trimmed.startsWith("https://") || + trimmed.startsWith("mailto:") + ) { + return false; + } + if (trimmed.startsWith("thread://") || trimmed.startsWith("/thread/")) { + return false; + } + if (trimmed.startsWith("#")) { + return false; + } + if (/[?#]/.test(trimmed)) { + return false; + } + if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) { + return true; + } + if (trimmed.startsWith("/")) { + return hasLikelyLocalAbsolutePrefix(trimmed); + } + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (hasLikelyFileName(trimmed)) { + return true; + } + if ( + trimmed.startsWith("~/") || + trimmed.startsWith("./") || + trimmed.startsWith("../") + ) { + return true; + } + return false; +} + +function toPathFromFileUrl(url: string) { + if (!url.toLowerCase().startsWith("file://")) { + return null; + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") { + return null; + } + + const decodedPath = safeDecodeURIComponent(parsed.pathname) ?? parsed.pathname; + let path = decodedPath; + if (parsed.host && parsed.host !== "localhost") { + const normalizedPath = decodedPath.startsWith("/") + ? decodedPath + : `/${decodedPath}`; + path = `//${parsed.host}${normalizedPath}`; + } + if (/^\/[A-Za-z]:\//.test(path)) { + path = path.slice(1); + } + return path; + } catch { + const manualPath = url.slice("file://".length).trim(); + if (!manualPath) { + return null; + } + return safeDecodeURIComponent(manualPath) ?? manualPath; + } +} + function extractUrlLines(value: string) { const lines = value.split(/\r?\n/); const urls = lines @@ -474,9 +604,29 @@ export function Markdown({ } return trimmed; }; + const resolveHrefFilePath = (url: string) => { + if (isLikelyFileHref(url)) { + const directPath = getLinkablePath(url); + if (directPath) { + return directPath; + } + } + const decodedUrl = safeDecodeURIComponent(url); + if (decodedUrl && isLikelyFileHref(decodedUrl)) { + const decodedPath = getLinkablePath(decodedUrl); + if (decodedPath) { + return decodedPath; + } + } + const fileUrlPath = toPathFromFileUrl(url); + if (!fileUrlPath) { + return null; + } + return getLinkablePath(fileUrlPath); + }; const components: Components = { a: ({ href, children }) => { - const url = href ?? ""; + const url = (href ?? "").trim(); const threadId = url.startsWith("thread://") ? url.slice("thread://".length).trim() : url.startsWith("/thread/") @@ -497,7 +647,20 @@ export function Markdown({ ); } if (isFileLinkUrl(url)) { - const path = decodeFileLink(url); + const path = safeDecodeFileLink(url); + if (!path) { + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + > + {children} + + ); + } return ( ); } + const hrefFilePath = resolveHrefFilePath(url); + if (hrefFilePath) { + return ( + handleFileLinkClick(event, hrefFilePath)} + onContextMenu={(event) => handleFileLinkContextMenu(event, hrefFilePath)} + > + {children} + + ); + } const isExternal = url.startsWith("http://") || url.startsWith("https://") || diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index efd3990d2..5ab716547 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -234,6 +234,85 @@ describe("Messages", () => { ); }); + it("routes markdown href file paths through the file opener", () => { + const linkedPath = + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-link", + kind: "message", + role: "assistant", + text: `Open [this file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("this file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + + it("keeps non-file relative links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-href-link", + kind: "message", + role: "assistant", + text: "See [Help](/help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("/help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("does not crash or navigate on malformed codex-file links", () => { + const items: ConversationItem[] = [ + { + id: "msg-malformed-file-link", + kind: "message", + role: "assistant", + text: "Bad [path](codex-file:%E0%A4%A)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("path")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("hides file parent paths when message file path display is disabled", () => { const items: ConversationItem[] = [ { From e3f23ed161032d925b9aa7023dbedc14979dc933 Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 25 Feb 2026 15:49:34 +0200 Subject: [PATCH 2/3] fix(messages): handle encoded href paths and tighten relative link detection --- src/features/messages/components/Markdown.tsx | 11 ++-- .../messages/components/Messages.test.tsx | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 37e74ebbd..9b183d27f 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -268,13 +268,12 @@ function isLikelyFileHref(url: string) { if (hasLikelyFileName(trimmed)) { return true; } - if ( - trimmed.startsWith("~/") || - trimmed.startsWith("./") || - trimmed.startsWith("../") - ) { + if (trimmed.startsWith("~/")) { return true; } + if (trimmed.startsWith("./") || trimmed.startsWith("../")) { + return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed); + } return false; } @@ -608,7 +607,7 @@ export function Markdown({ if (isLikelyFileHref(url)) { const directPath = getLinkablePath(url); if (directPath) { - return directPath; + return safeDecodeURIComponent(directPath) ?? directPath; } } const decodedUrl = safeDecodeURIComponent(url); diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index 5ab716547..ca19e6ed8 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -261,6 +261,31 @@ describe("Messages", () => { expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); }); + it("decodes percent-encoded href file paths before opening", () => { + const items: ConversationItem[] = [ + { + id: "msg-file-href-encoded-link", + kind: "message", + role: "assistant", + text: "Open [guide](./docs/My%20Guide.md)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("guide")); + expect(openFileLinkMock).toHaveBeenCalledWith("./docs/My Guide.md"); + }); + it("keeps non-file relative links as normal markdown links", () => { const items: ConversationItem[] = [ { @@ -288,6 +313,33 @@ describe("Messages", () => { expect(openFileLinkMock).not.toHaveBeenCalled(); }); + it("keeps dot-relative non-file links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-dot-relative-href-link", + kind: "message", + role: "assistant", + text: "See [Help](./help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("./help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("does not crash or navigate on malformed codex-file links", () => { const items: ConversationItem[] = [ { From c216c249ab58d939e7ad0197975d71b0d692e862 Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 25 Feb 2026 20:16:18 +0200 Subject: [PATCH 3/3] fix(messages): tighten markdown file-href routing and fallback behavior --- .../messages/components/Markdown.test.tsx | 72 +++++++++++++++++++ src/features/messages/components/Markdown.tsx | 34 +++++++-- .../messages/components/Messages.test.tsx | 53 ++++++++++++++ 3 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/features/messages/components/Markdown.test.tsx diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx new file mode 100644 index 000000000..b96f29419 --- /dev/null +++ b/src/features/messages/components/Markdown.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { cleanup, createEvent, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Markdown } from "./Markdown"; + +describe("Markdown file-like href behavior", () => { + afterEach(() => { + cleanup(); + }); + + it("preserves default anchor navigation when no file opener is provided", () => { + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(false); + }); + + it("intercepts file-like href clicks when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md"); + }); + + it("does not intercept bare relative links even when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("docs/setup.md"); + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(false); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 9b183d27f..91bb49e15 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -207,6 +207,12 @@ const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/private/", "/Volumes/", "/mnt/", + "/usr/", + "/workspace/", + "/workspaces/", + "/root/", + "/srv/", + "/data/", ]; function stripPathLineSuffix(value: string) { @@ -232,6 +238,10 @@ function hasLikelyLocalAbsolutePrefix(path: string) { ); } +function pathSegmentCount(path: string) { + return path.split("/").filter(Boolean).length; +} + function isLikelyFileHref(url: string) { const trimmed = url.trim(); if (!trimmed) { @@ -260,20 +270,26 @@ function isLikelyFileHref(url: string) { return true; } if (trimmed.startsWith("/")) { - return hasLikelyLocalAbsolutePrefix(trimmed); + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (hasLikelyFileName(trimmed)) { + return true; + } + return hasLikelyLocalAbsolutePrefix(trimmed) && pathSegmentCount(trimmed) >= 3; } if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { return true; } - if (hasLikelyFileName(trimmed)) { - return true; - } if (trimmed.startsWith("~/")) { return true; } if (trimmed.startsWith("./") || trimmed.startsWith("../")) { return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed); } + if (hasLikelyFileName(trimmed)) { + return pathSegmentCount(trimmed) >= 3; + } return false; } @@ -673,11 +689,17 @@ export function Markdown({ } const hrefFilePath = resolveHrefFilePath(url); if (hrefFilePath) { + const clickHandler = onOpenFileLink + ? (event: React.MouseEvent) => handleFileLinkClick(event, hrefFilePath) + : undefined; + const contextMenuHandler = onOpenFileLinkMenu + ? (event: React.MouseEvent) => handleFileLinkContextMenu(event, hrefFilePath) + : undefined; return ( handleFileLinkClick(event, hrefFilePath)} - onContextMenu={(event) => handleFileLinkContextMenu(event, hrefFilePath)} + onClick={clickHandler} + onContextMenu={contextMenuHandler} > {children} diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index ca19e6ed8..14cd95398 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -261,6 +261,32 @@ describe("Messages", () => { expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); }); + it("routes absolute non-whitelisted file href paths through the file opener", () => { + const linkedPath = "/custom/project/src/App.tsx:12"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-absolute-non-whitelisted-link", + kind: "message", + role: "assistant", + text: `Open [app file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("app file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + it("decodes percent-encoded href file paths before opening", () => { const items: ConversationItem[] = [ { @@ -313,6 +339,33 @@ describe("Messages", () => { expect(openFileLinkMock).not.toHaveBeenCalled(); }); + it("keeps route-like absolute links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-workspace-route-link", + kind: "message", + role: "assistant", + text: "See [Workspace Home](/workspace/settings)", + }, + ]; + + render( + , + ); + + const link = screen.getByText("Workspace Home").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings"); + fireEvent.click(screen.getByText("Workspace Home")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("keeps dot-relative non-file links as normal markdown links", () => { const items: ConversationItem[] = [ {