From 3071acae31c88bc436203d3e09cfa164b8e8f0e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 13:53:36 -0700 Subject: [PATCH 1/6] fix(web): download buttons in HTML previews work Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/http.test.ts | 4 ++-- apps/server/src/http.ts | 6 ++++-- apps/web/src/components/files/BrowserDocumentFrame.tsx | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9e710d89717e..6ea97f243038 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -674,14 +674,14 @@ describe("assetResponseHeaders", () => { assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }), ).toMatchObject({ "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", }); }); it("serves HTML assets as utf-8 inside a sandboxed origin", () => { for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { expect(assetResponseHeaders(path)).toMatchObject({ "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", }); } }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 057e64e96d9d..de4ce26cc915 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -57,8 +57,10 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin // opaque origin: scripts run, but same-origin cookies, storage, and API calls are // out of reach. Relative sibling assets still load through their signed URLs. // No modals: agent HTML can open without a click (inline renders, and mobile -// loads it as the top document), and must not raise blocking dialogs. -const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups"; +// loads it as the top document), and must not raise blocking dialogs. Downloads +// stay allowed so download links and buttons in the page work. +const HTML_CONTENT_SECURITY_POLICY = + "sandbox allow-scripts allow-forms allow-popups allow-downloads"; // Types a browser may render as a document if a proxy strips the disposition // header. Downloads of these fall back to octet-stream. diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 827a81f2c6ed..ff41865a1e5b 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -55,7 +55,7 @@ export function BrowserDocumentFrame(props: { src={props.src} title={props.title} className={className} - sandbox="allow-scripts allow-forms allow-popups" + sandbox="allow-scripts allow-forms allow-popups allow-downloads" /> ); } From 0ac56f392447cfb0b1826df83a4bb8e1d0bc8010 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 13:57:07 -0700 Subject: [PATCH 2/6] fix(web): markdown table-of-contents links scroll to their heading Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatMarkdown.test.tsx | 58 +++++++++++++++++-- apps/web/src/components/ChatMarkdown.tsx | 54 ++++++++++++++--- 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index b1ceb33062c8..ffa29ca2b922 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -861,15 +861,15 @@ describe("ChatMarkdown heading levels", () => { />, ); - expect(html).toContain('

Top

'); - expect(html).toContain('

Section

'); - expect(html).toContain('
Fine print
'); + expect(html).toContain('

Top

'); + expect(html).toContain('

Section

'); + expect(html).toContain('
Fine print
'); }); it("leaves heading levels alone when the markdown is not nested", () => { const html = renderToStaticMarkup(); - expect(html).toContain("

Top

"); + expect(html).toContain('

Top

'); }); }); @@ -1034,3 +1034,53 @@ describe("ChatMarkdown Windows file links", () => { expect(html).not.toContain("chat-markdown-file-link"); }); }); + +describe("ChatMarkdown in-page links", () => { + it.each([true, false])( + "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", + async (parseRawHtml) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + window.history.replaceState(null, "", "/#/env/thread"); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + try { + await act(async () => { + root.render( + , + ); + }); + const headings = [...container.querySelectorAll("h2")]; + expect(headings.map((heading) => heading.id)).toEqual([ + "user-content-1-operating-model", + "user-content-1-operating-model-1", + ]); + + const [tocLink, missingLink] = [...container.querySelectorAll("a")]; + const click = () => new MouseEvent("click", { bubbles: true, cancelable: true }); + const tocClick = click(); + tocLink!.dispatchEvent(tocClick); + expect(tocClick.defaultPrevented).toBe(true); + expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); + + const missingClick = click(); + missingLink!.dispatchEvent(missingClick); + expect(missingClick.defaultPrevented).toBe(true); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(window.location.hash).toBe("#/env/thread"); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }, + ); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42a34d8b5d25..ff27952aeea4 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -572,13 +572,53 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; +/** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */ +function githubHeadingSlug(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") + .replace(/ /g, "-"); +} + +/** + * Gives headings without an authored id GitHub's slug id, deduplicated per document. Like the + * sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app element ids; + * fragment lookup strips it. + */ +function rehypeHeadingIds() { + return (tree: MarkdownImageHastNode) => { + const seen = new Map(); + const visit = (node: MarkdownImageHastNode) => { + if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { + const slug = githubHeadingSlug(hastPlainTextDeep(node)); + if (node.properties?.id === undefined && slug) { + const count = seen.get(slug) ?? 0; + seen.set(slug, count + 1); + const id = `${SANITIZED_FRAGMENT_PREFIX}${count === 0 ? slug : `${slug}-${count}`}`; + node.properties = { ...node.properties, id }; + } + return; + } + node.children?.forEach(visit); + }; + visit(tree); + }; +} + +// Heading ids are added after sanitizing, which would prefix them a second time. const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypePreserveBareAnchorPlaceholders, rehypeRaw, rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], + rehypeHeadingIds, ] satisfies NonNullable; +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< + ReactMarkdownOptions["rehypePlugins"] +>; + /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ const GITHUB_ALERT_PRESENTATIONS: Record< string, @@ -2065,14 +2105,10 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent, return; } - const target = findMarkdownFragmentTarget(event.currentTarget, href); - if (!target) return; - + // Never let the browser follow the fragment or write it to the URL: desktop keeps + // its route in the hash, so replacing the hash navigates away from the thread. event.preventDefault(); - const nextUrl = new URL(window.location.href); - nextUrl.hash = href.slice(1); - window.history.pushState(window.history.state, "", nextUrl); - target.scrollIntoView({ block: "nearest" }); + findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); } function MarkdownExternalLinkContent({ @@ -3622,7 +3658,9 @@ function ChatMarkdown({ Date: Wed, 7 Oct 2026 13:58:36 -0700 Subject: [PATCH 3/6] fix(web): repo-root inline code paths in markdown previews resolve from the workspace Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/markdown-links.test.ts | 29 +++++++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 22 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index fe352ec1a44e..66bfd06abf84 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -351,6 +351,35 @@ describe("relative links inside a rendered host file", () => { workspaceRelativePath: "docs/src/main.ts", }); }); + + it("resolve multi-segment inline code from the workspace root in a workspace file", () => { + expect( + resolveInlineCodeFileLinkMeta("docs/ai/design.md", "/repo", "/repo/docs/ai"), + ).toMatchObject({ filePath: "/repo/docs/ai/design.md" }); + expect( + resolveInlineCodeFileLinkMeta("src/index.ts:4", "/repo", "/repo/packages/a"), + ).toMatchObject({ filePath: "/repo/src/index.ts", line: 4 }); + }); + + it("keep sibling and explicitly relative inline code beside the file", () => { + expect(resolveInlineCodeFileLinkMeta("design.md:12", "/repo", "/repo/docs/ai")).toMatchObject({ + filePath: "/repo/docs/ai/design.md", + line: 12, + }); + expect( + resolveInlineCodeFileLinkMeta("./src/index.ts", "/repo", "/repo/packages/a"), + ).toMatchObject({ filePath: "/repo/packages/a/./src/index.ts" }); + expect(resolveInlineCodeFileLinkMeta("../b/notes.md", "/repo", "/repo/docs/a")).toMatchObject({ + filePath: "/repo/docs/a/../b/notes.md", + }); + }); + + it("keep multi-segment inline code beside a file outside the workspace", () => { + expect(resolveInlineCodeFileLinkMeta("src/main.ts", "/repo", "/tmp/report")).toMatchObject({ + filePath: "/tmp/report/src/main.ts", + workspaceRelativePath: null, + }); + }); }); describe("resolveInlineCodeFileLinkMeta", () => { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 8ed59eb8775b..6eaa6ba95a4d 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -93,7 +93,27 @@ export function resolveInlineCodeFileLinkMeta( const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); + return resolveMarkdownFileLinkMeta( + candidate, + cwd, + inlineCodePathNamesFromWorkspaceRoot(candidate, cwd, baseDir) ? cwd : baseDir, + ); +} + +/** + * Prose in a workspace file names other files from the repo root (`docs/ai/design.md`), + * unlike an explicit link. Single-segment names (`design.md:12`) and `./`, `../` + * paths still read as siblings, and files outside the workspace keep their own base. + */ +function inlineCodePathNamesFromWorkspaceRoot( + candidate: string, + cwd: string | undefined, + baseDir: string | undefined, +): boolean { + if (!cwd || !baseDir || !isRelativeFilePath(candidate)) return false; + if (/^(?:~|\.{1,2})\//.test(candidate)) return false; + if (!splitFilePathPosition(candidate).path.includes("/")) return false; + return workspaceRelativeFilePath(baseDir, cwd) !== null; } export function resolveMarkdownFileLinkMeta( From b96304e76faf0b3bfea9e56df4ebc8f4f7b3c9f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 14:00:06 -0700 Subject: [PATCH 4/6] fix(web): link favicons never send ports or internal hostnames to Google Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/shared/src/favicon.test.ts | 9 +++++++-- packages/shared/src/favicon.ts | 10 +++++++--- packages/shared/src/hostClassification.ts | 22 +++++++++++++++++++++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index 676f7811011e..e9b4607ca89b 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -23,13 +23,18 @@ describe("faviconUrlForOrigin", () => { "http://service.test", "http://private.onion", "http://127.1..", + "https://grafana.corp", + "https://build.lan", + "https://wiki.intranet", + "https://grafana.internal.acme-corp.com", + "https://jira.corp.acme-corp.com:8443", ])("does not disclose %s to the favicon provider", (origin) => { expect(faviconUrlForOrigin(origin)).toBeNull(); }); - it("keeps the public origin, port and requested size", () => { + it("sends only the public hostname and requested size, never the port", () => { expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe( - "https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64", + "https://www.google.com/s2/favicons?domain=github.com&sz=64", ); }); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index 2c4847115b90..877f178d6072 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -82,15 +82,19 @@ export function toolActivityFaviconUrl( ); } -/** Return a public favicon URL without disclosing private or reserved hosts. */ +/** + * Return a public favicon URL that discloses only a public hostname, never the + * port, path, or a private or internal-looking host. Callers show a generic + * icon when this returns null. + */ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { if (!rawUrl) return null; try { const url = new URL(rawUrl); - if (!url.host) return null; + if (!url.hostname) return null; if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (!isPublicFaviconHost(url.hostname)) return null; - return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`; + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=${size}`; } catch { return null; } diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts index ac56cd640af9..3f6dee4bf9da 100644 --- a/packages/shared/src/hostClassification.ts +++ b/packages/shared/src/hostClassification.ts @@ -119,6 +119,23 @@ export const isPrivateNetworkHost = (host: string): boolean => { ); }; +// Reserved and special-use names, plus the private TLDs RFC 6762 Appendix G +// records as common on internal networks. +const PRIVATE_FAVICON_TLDS = [ + ".alt", + ".corp", + ".example", + ".home", + ".internal", + ".intranet", + ".invalid", + ".lan", + ".onion", + ".private", + ".test", +]; +const INTERNAL_HOST_LABELS: ReadonlySet = new Set(["corp", "internal", "intranet"]); + /** Whether a hostname is eligible to be disclosed to a public favicon provider. */ export const isPublicFaviconHost = (host: string): boolean => { // A single trailing dot is a valid absolute DNS name. Repeated trailing @@ -127,12 +144,15 @@ export const isPublicFaviconHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (isPrivateNetworkHost(normalized)) return false; if ( - [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( + PRIVATE_FAVICON_TLDS.some( (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), ) ) { return false; } + // Company networks often nest internal services under a public domain + // (`grafana.internal.acme.com`); never send those names to a third party. + if (normalized.split(".").some((label) => INTERNAL_HOST_LABELS.has(label))) return false; const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); if (!normalized.includes(":")) return true; From ba5a8a5a968127d49b8698efce51e596695db93b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 15:11:11 -0700 Subject: [PATCH 5/6] fix(web): markdown headings never share an id `Setup`, `Setup`, `Setup-1` gave the second and third headings the same `setup-1` id, so a link to the third scrolled to the second. Ids now skip any id already in the document, authored or assigned. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatMarkdown.test.tsx | 24 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 22 +++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index ffa29ca2b922..2611828baab8 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1035,6 +1035,30 @@ describe("ChatMarkdown Windows file links", () => { }); }); +describe("ChatMarkdown heading ids", () => { + it("never gives two headings the same id, even when a suffix matches another heading", () => { + const html = renderToStaticMarkup( + Pinned\n\n## Install\n\n## Install' + } + />, + ); + const ids = [...html.matchAll(/

match[1]); + expect(ids).toEqual([ + "user-content-setup", + "user-content-setup-1", + "user-content-setup-1-1", + "user-content-install-1", + "user-content-install", + "user-content-install-2", + ]); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + describe("ChatMarkdown in-page links", () => { it.each([true, false])( "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ff27952aeea4..dfb125b6254c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -588,14 +588,28 @@ function githubHeadingSlug(text: string): string { */ function rehypeHeadingIds() { return (tree: MarkdownImageHastNode) => { - const seen = new Map(); + // Every id already in the document, authored or assigned, so a suffix never + // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids. + const taken = new Set(); + const collect = (node: MarkdownImageHastNode) => { + const id = node.properties?.id; + if (typeof id === "string") taken.add(id); + node.children?.forEach(collect); + }; + collect(tree); + const nextSuffix = new Map(); const visit = (node: MarkdownImageHastNode) => { if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { const slug = githubHeadingSlug(hastPlainTextDeep(node)); if (node.properties?.id === undefined && slug) { - const count = seen.get(slug) ?? 0; - seen.set(slug, count + 1); - const id = `${SANITIZED_FRAGMENT_PREFIX}${count === 0 ? slug : `${slug}-${count}`}`; + let count = nextSuffix.get(slug) ?? 0; + let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; + while (taken.has(id)) { + count += 1; + id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; + } + nextSuffix.set(slug, count); + taken.add(id); node.properties = { ...node.properties, id }; } return; From b2da8483734c7ab3ff7d77bd8566269ca41466b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 14:21:43 -0700 Subject: [PATCH 6/6] fix(web): import the relative-path check from its new shared home Co-Authored-By: Claude Opus 5.5 --- apps/web/src/markdown-links.ts | 1 + packages/shared/src/markdownLinks.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 09b6cb49701d..3aa188d27ec2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,6 +1,7 @@ import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path"; import { inlineCodeFilePathCandidate, + isRelativeFilePath, normalizeMarkdownLinkDestination, resolveMarkdownFileLinkTarget, } from "@t3tools/shared/markdownLinks"; diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 875f764d448d..4055ccb08967 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -252,7 +252,7 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean { return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`); } -function isRelativeFilePath(path: string): boolean { +export function isRelativeFilePath(path: string): boolean { return ( RELATIVE_PATH_PREFIX_PATTERN.test(path) || (!path.startsWith("/") && !isWindowsAbsolutePath(path))