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/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index 08fb61eac0bb..b62431a08383 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -871,15 +871,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
');
});
});
@@ -1272,3 +1272,77 @@ it.each([
}
},
);
+
+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)",
+ 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 42b4a0618b6b..4c712365475d 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -1807,16 +1807,77 @@ 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" });
+}
+
+type HeadingHastNode = {
+ type?: string;
+ tagName?: string;
+ properties?: Record;
+ children?: HeadingHastNode[];
+};
+
+/** 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: HeadingHastNode) => {
+ // 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: HeadingHastNode) => {
+ 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: HeadingHastNode) => {
+ if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) {
+ const slug = githubHeadingSlug(hastPlainTextDeep(node));
+ if (node.properties?.id === undefined && slug) {
+ 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;
+ }
+ node.children?.forEach(visit);
+ };
+ visit(tree);
+ };
}
+// Heading ids are added after sanitizing, which would prefix them a second time.
+const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [
+ ...CHAT_MARKDOWN_REHYPE_PLUGINS,
+ rehypeHeadingIds,
+] satisfies NonNullable;
+
+const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable<
+ ReactMarkdownOptions["rehypePlugins"]
+>;
+
function MarkdownExternalLinkContent({
host,
plainText,
@@ -3363,7 +3424,11 @@ function ChatMarkdown({
);
}
diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts
index a8e01e834c7a..3aaed9058c01 100644
--- a/apps/web/src/markdown-links.test.ts
+++ b/apps/web/src/markdown-links.test.ts
@@ -91,6 +91,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 d25d089f6528..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";
@@ -50,7 +51,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(
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;
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))