Compare commits

...

1 Commits

Author SHA1 Message Date
Nicholas Tindle
351001fdca fix(frontend/copilot): keep artifact sidebar alive on bad HTML artifacts
Two defensive fixes so one misbehaving artifact can't take down the chat
sidebar:

1. Intercept fragment-link clicks inside artifact iframes. srcdoc iframes
   with `sandbox="allow-scripts"` (no `allow-same-origin`) resolve
   `<a href="#x">` against the parent's URL, so clicking a TOC anchor in
   AI-generated HTML was navigating the copilot page itself to
   `/copilot?sessionId=...#activation` and crashing it. A small click-capture
   script injected alongside the Tailwind CDN now preventDefaults fragment
   clicks and scrolls the local target into view.

2. Wrap the artifact renderer in an ArtifactErrorBoundary so any future
   render-time throw surfaces as a visible, copyable error instead of
   tearing down the whole panel. The fallback exposes a "Copy error
   details" button that puts the artifact title, type, and stack on the
   clipboard for the user to paste back to the agent.

Regression coverage at every injection site: srcdoc for HTML artifacts
(ArtifactContent), for the inline HTMLRenderer, and for React artifacts
(buildReactArtifactSrcDoc). The interceptor logic itself is exercised
against real DOM in iframe-sandbox-csp.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:47:17 -05:00
9 changed files with 549 additions and 12 deletions

View File

@@ -6,9 +6,11 @@ import { Suspense, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import type { ArtifactRef } from "../../../store";
import type { ArtifactClassification } from "../helpers";
import { ArtifactErrorBoundary } from "./ArtifactErrorBoundary";
import { ArtifactReactPreview } from "./ArtifactReactPreview";
import { ArtifactSkeleton } from "./ArtifactSkeleton";
import {
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
TAILWIND_CDN_URL,
wrapWithHeadInjection,
} from "@/lib/iframe-sandbox-csp";
@@ -53,13 +55,18 @@ function ArtifactContentLoader({
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<ArtifactRenderer
artifact={artifact}
content={content}
pdfUrl={pdfUrl}
isSourceView={isSourceView}
classification={classification}
/>
<ArtifactErrorBoundary
artifactTitle={artifact.title}
artifactType={classification.type}
>
<ArtifactRenderer
artifact={artifact}
content={content}
pdfUrl={pdfUrl}
isSourceView={isSourceView}
classification={classification}
/>
</ArtifactErrorBoundary>
</div>
);
}
@@ -200,7 +207,10 @@ function ArtifactRenderer({
if (classification.type === "html") {
// Inject Tailwind CDN — no CSP (see iframe-sandbox-csp.ts for why)
const tailwindScript = `<script src="${TAILWIND_CDN_URL}"></script>`;
const wrapped = wrapWithHeadInjection(content, tailwindScript);
const wrapped = wrapWithHeadInjection(
content,
tailwindScript + FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
);
return (
<iframe
sandbox="allow-scripts"

View File

@@ -0,0 +1,96 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
artifactTitle: string;
artifactType: string;
}
interface State {
error: Error | null;
}
export class ArtifactErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
Sentry.captureException(error, {
contexts: {
react: { componentStack: errorInfo.componentStack },
},
tags: { errorBoundary: "true", context: "copilot-artifact" },
extra: {
artifactTitle: this.props.artifactTitle,
artifactType: this.props.artifactType,
},
});
}
componentDidUpdate(prevProps: Props) {
if (
this.state.error &&
(prevProps.artifactTitle !== this.props.artifactTitle ||
prevProps.artifactType !== this.props.artifactType)
) {
this.setState({ error: null });
}
}
handleCopy = () => {
const { error } = this.state;
if (!error) return;
const details = [
`Artifact: ${this.props.artifactTitle}`,
`Type: ${this.props.artifactType}`,
`Error: ${error.message}`,
error.stack ? `Stack:\n${error.stack}` : "",
]
.filter(Boolean)
.join("\n");
navigator.clipboard?.writeText(details).catch(() => {});
};
render() {
const { error } = this.state;
if (!error) return this.props.children;
const message = error.message || "Unknown rendering error";
return (
<div
role="alert"
className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center"
>
<p className="text-sm font-medium text-zinc-700">
This artifact couldn&apos;t be rendered
</p>
<p className="max-w-md break-words text-xs text-zinc-500">
Something in{" "}
<span className="font-mono">{this.props.artifactTitle}</span> threw an
error while rendering. The chat and sidebar are still working.
</p>
<pre className="max-h-32 max-w-md overflow-auto whitespace-pre-wrap break-words rounded-md bg-zinc-100 px-3 py-2 text-left text-xs text-zinc-700">
{message}
</pre>
<button
type="button"
onClick={this.handleCopy}
className="rounded-md border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-sm transition-colors hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
>
Copy error details
</button>
<p className="max-w-md text-xs text-zinc-400">
Paste this into the chat so the agent can regenerate a working
version.
</p>
</div>
);
}
}

View File

@@ -412,6 +412,41 @@ describe("ArtifactContent", () => {
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
});
it("injects the fragment-link interceptor into HTML artifact iframes (regression)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: () =>
Promise.resolve(
'<html><head></head><body><a href="#x">x</a><div id="x">x</div></body></html>',
),
}),
);
const { container } = render(
<ArtifactContent
artifact={makeArtifact({
id: "html-frag",
title: "page.html",
mimeType: "text/html",
})}
isSourceView={false}
classification={makeClassification({ type: "html" })}
/>,
);
await screen.findByTitle("page.html");
const srcdoc = container.querySelector("iframe")?.getAttribute("srcdoc");
expect(srcdoc).toBeTruthy();
// Markers unique to FRAGMENT_LINK_INTERCEPTOR_SCRIPT — if any of these
// disappear, the interceptor is no longer being injected and fragment
// links will navigate the parent URL again.
expect(srcdoc).toContain("__fragmentLinkInterceptor");
expect(srcdoc).toContain('a[href^="#"]');
expect(srcdoc).toContain("scrollIntoView");
});
// ── Source view ───────────────────────────────────────────────────
it("renders source view as pre tag", async () => {
@@ -923,6 +958,164 @@ describe("ArtifactContent", () => {
},
);
// ── Error boundary ────────────────────────────────────────────────
it("shows a visible error instead of crashing when the renderer throws", async () => {
const consoleErr = vi.spyOn(console, "error").mockImplementation(() => {});
const originalImpl = vi
.mocked(ArtifactReactPreview)
.getMockImplementation();
vi.mocked(ArtifactReactPreview).mockImplementation(() => {
throw new Error("boom in renderer");
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve("source"),
}),
);
const artifact = makeArtifact({
id: "crash-001",
title: "broken.tsx",
mimeType: "text/tsx",
});
const classification = makeClassification({ type: "react" });
render(
<ArtifactContent
artifact={artifact}
isSourceView={false}
classification={classification}
/>,
);
expect(
await screen.findByText(/This artifact couldn't be rendered/i),
).toBeTruthy();
expect(screen.getByText(/boom in renderer/)).toBeTruthy();
expect(
screen.getByRole("button", { name: /copy error details/i }),
).toBeTruthy();
if (originalImpl) {
vi.mocked(ArtifactReactPreview).mockImplementation(originalImpl);
}
consoleErr.mockRestore();
});
it("copies artifact title, type, and error to the clipboard", async () => {
const consoleErr = vi.spyOn(console, "error").mockImplementation(() => {});
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
const originalImpl = vi
.mocked(ArtifactReactPreview)
.getMockImplementation();
vi.mocked(ArtifactReactPreview).mockImplementation(() => {
throw new Error("jsx parse failed at line 42");
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve("source"),
}),
);
render(
<ArtifactContent
artifact={makeArtifact({
id: "crash-002",
title: "report.tsx",
mimeType: "text/tsx",
})}
isSourceView={false}
classification={makeClassification({ type: "react" })}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: /copy error details/i }),
);
await waitFor(() => {
expect(writeText).toHaveBeenCalled();
});
const payload = writeText.mock.calls[0]![0] as string;
expect(payload).toContain("report.tsx");
expect(payload).toContain("react");
expect(payload).toContain("jsx parse failed at line 42");
if (originalImpl) {
vi.mocked(ArtifactReactPreview).mockImplementation(originalImpl);
}
consoleErr.mockRestore();
});
it("renders the user-reported plotly HTML artifact into a sandboxed iframe", async () => {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AutoGPT Beta Launch Interactive Report</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
:root { --bg: #f8f9fa; --primary: #6c5ce7; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; }
</style>
</head>
<body>
<header><h1>\u{1F4CA} AutoGPT Beta Launch Interactive Report</h1></header>
<div class="chart-container" id="globalActivationChart"></div>
<script>
function showTab(tabId, groupId) {
const group = document.getElementById(groupId);
group.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
}
Plotly.newPlot('globalActivationChart', [{ type: 'pie', values: [1, 2] }], {});
</script>
</body>
</html>`;
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve(html),
}),
);
const artifact = makeArtifact({
id: "html-big-report",
title: "report.html",
mimeType: "text/html",
});
const { container } = render(
<ArtifactContent
artifact={artifact}
isSourceView={false}
classification={makeClassification({ type: "html" })}
/>,
);
await screen.findByTitle("report.html");
const iframe = container.querySelector("iframe");
expect(iframe).toBeTruthy();
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
expect(screen.queryByText(/couldn't be rendered/i)).toBeNull();
});
it("falls back to pre tag when no renderer matches", async () => {
const { globalRegistry } = await import(
"@/components/contextual/OutputRenderers"

View File

@@ -116,4 +116,11 @@ describe("buildReactArtifactSrcDoc", () => {
expect(doc).toContain("/^[A-Z]/.test(name)");
expect(doc).toContain("wrapWithProviders");
});
it("injects the fragment-link interceptor so #anchor clicks stay inside the iframe (regression)", () => {
const doc = buildReactArtifactSrcDoc("module.exports = {};", "A", STYLES);
expect(doc).toContain("__fragmentLinkInterceptor");
expect(doc).toContain('a[href^="#"]');
expect(doc).toContain("scrollIntoView");
});
});

View File

@@ -19,7 +19,10 @@
* React is loaded from unpkg with pinned version and SRI integrity hashes.
*/
import { TAILWIND_CDN_URL } from "@/lib/iframe-sandbox-csp";
import {
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
TAILWIND_CDN_URL,
} from "@/lib/iframe-sandbox-csp";
export { transpileReactArtifactSource } from "./transpileReactArtifact";
@@ -95,6 +98,7 @@ export function buildReactArtifactSrcDoc(
}
</style>
<script src="${TAILWIND_CDN_URL}"></script>
${FRAGMENT_LINK_INTERCEPTOR_SCRIPT}
<script crossorigin="anonymous" src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"></script><!-- pragma: allowlist secret -->
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"></script><!-- pragma: allowlist secret -->
</head>

View File

@@ -0,0 +1,54 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { htmlRenderer } from "./HTMLRenderer";
describe("HTMLRenderer", () => {
afterEach(() => {
cleanup();
});
it("renders text/html content in a sandboxed iframe", () => {
const { container } = render(
<>
{htmlRenderer.render("<h1>Hi</h1>", {
mimeType: "text/html",
filename: "page.html",
})}
</>,
);
const iframe = container.querySelector("iframe");
expect(iframe).toBeTruthy();
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
});
it("injects the fragment-link interceptor into the srcDoc (regression)", () => {
const { container } = render(
<>
{htmlRenderer.render(
'<html><head></head><body><a href="#x">x</a><div id="x">x</div></body></html>',
{ mimeType: "text/html", filename: "page.html" },
)}
</>,
);
const srcdoc = container.querySelector("iframe")?.getAttribute("srcdoc");
expect(srcdoc).toBeTruthy();
expect(srcdoc).toContain("__fragmentLinkInterceptor");
expect(srcdoc).toContain('a[href^="#"]');
expect(srcdoc).toContain("scrollIntoView");
});
it("canRender recognises text/html mime type and .html/.htm filenames", () => {
expect(
htmlRenderer.canRender("<h1>Hi</h1>", { mimeType: "text/html" }),
).toBe(true);
expect(
htmlRenderer.canRender("<h1>Hi</h1>", { filename: "report.html" }),
).toBe(true);
expect(
htmlRenderer.canRender("<h1>Hi</h1>", { filename: "report.htm" }),
).toBe(true);
expect(
htmlRenderer.canRender("<h1>Hi</h1>", { mimeType: "text/plain" }),
).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import React from "react";
import {
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
TAILWIND_CDN_URL,
wrapWithHeadInjection,
} from "@/lib/iframe-sandbox-csp";
@@ -13,7 +14,10 @@ import {
function HTMLPreview({ value }: { value: string }) {
// Inject Tailwind CDN — no CSP (see iframe-sandbox-csp.ts for why)
const tailwindScript = `<script src="${TAILWIND_CDN_URL}"></script>`;
const srcDoc = wrapWithHeadInjection(value, tailwindScript);
const srcDoc = wrapWithHeadInjection(
value,
tailwindScript + FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
);
return (
<iframe
sandbox="allow-scripts"

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { TAILWIND_CDN_URL, wrapWithHeadInjection } from "../iframe-sandbox-csp";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
TAILWIND_CDN_URL,
wrapWithHeadInjection,
} from "../iframe-sandbox-csp";
describe("wrapWithHeadInjection", () => {
const injection = '<script src="https://example.com/lib.js"></script>';
@@ -45,6 +49,139 @@ describe("TAILWIND_CDN_URL", () => {
});
});
describe("FRAGMENT_LINK_INTERCEPTOR_SCRIPT", () => {
// Evaluate the script body (without <script> tags) against the current
// document. Because sandboxed srcdoc iframes run their scripts in isolation
// anyway, the behavior we care about is just "this code, when executed in
// a document, intercepts #anchor clicks and calls scrollIntoView".
function installInterceptor() {
const body = FRAGMENT_LINK_INTERCEPTOR_SCRIPT.replace(
/^<script>\s*/,
"",
).replace(/\s*<\/script>$/, "");
new Function(body)();
}
let cleanup: (() => void) | null = null;
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
if (cleanup) cleanup();
cleanup = null;
document.body.innerHTML = "";
const doc = document as Document & {
__fragmentLinkInterceptor?: EventListener;
};
if (doc.__fragmentLinkInterceptor) {
document.removeEventListener("click", doc.__fragmentLinkInterceptor);
delete doc.__fragmentLinkInterceptor;
}
});
it("exports a <script> tag wrapping the interceptor", () => {
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT.startsWith("<script>")).toBe(true);
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT.endsWith("</script>")).toBe(true);
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT).toContain("addEventListener");
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT).toContain("scrollIntoView");
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT).toContain('a[href^="#"]');
});
// Install the interceptor first, then a tail listener that records
// defaultPrevented. Listeners fire in registration order, so the tail
// sees the post-interceptor state.
function installWithObserver() {
installInterceptor();
const observed = { defaulted: false };
const listener = (e: Event) => {
observed.defaulted = e.defaultPrevented;
};
document.addEventListener("click", listener);
cleanup = () => document.removeEventListener("click", listener);
return observed;
}
it("intercepts fragment-link clicks, calls preventDefault, and scrolls the target into view", () => {
document.body.innerHTML = `
<nav><a id="nav-link" href="#activation">Activation</a></nav>
<section id="activation">Target</section>
`;
const scrollSpy = vi.fn();
document.getElementById("activation")!.scrollIntoView = scrollSpy;
const observed = installWithObserver();
document.getElementById("nav-link")!.click();
expect(scrollSpy).toHaveBeenCalledTimes(1);
expect(observed.defaulted).toBe(true);
});
it("does not intercept bare '#' links (no target id)", () => {
document.body.innerHTML = `<a id="top" href="#">Back to top</a>`;
const observed = installWithObserver();
document.getElementById("top")!.click();
expect(observed.defaulted).toBe(false);
});
it("does not intercept links with no matching target in the document", () => {
document.body.innerHTML = `<a id="dangle" href="#missing">Nowhere</a>`;
const observed = installWithObserver();
document.getElementById("dangle")!.click();
expect(observed.defaulted).toBe(false);
});
it("does not intercept non-fragment links", () => {
document.body.innerHTML = `<a id="ext" href="https://example.com/x">Ext</a>`;
installInterceptor();
const observed = { defaulted: false };
const listener = (e: Event) => {
observed.defaulted = e.defaultPrevented;
e.preventDefault();
};
document.addEventListener("click", listener);
cleanup = () => document.removeEventListener("click", listener);
document.getElementById("ext")!.click();
expect(observed.defaulted).toBe(false);
});
it("scrolls to target when click originates from a nested child of the anchor", () => {
document.body.innerHTML = `
<a id="outer" href="#costs"><span id="inner">💰 Costs</span></a>
<section id="costs">Target</section>
`;
const scrollSpy = vi.fn();
document.getElementById("costs")!.scrollIntoView = scrollSpy;
installInterceptor();
document.getElementById("inner")!.click();
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
it("handles percent-encoded ids", () => {
document.body.innerHTML = `
<a id="enc" href="#top%20costs">Jump</a>
<section id="top costs">Target</section>
`;
const scrollSpy = vi.fn();
document.getElementById("top costs")!.scrollIntoView = scrollSpy;
installInterceptor();
document.getElementById("enc")!.click();
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
});
describe("no CSP is exported", () => {
it("does not export ARTIFACT_IFRAME_CSP", async () => {
const mod = await import("../iframe-sandbox-csp");

View File

@@ -32,6 +32,38 @@
// changes (SRI is not possible because the JIT runtime is generated on demand).
export const TAILWIND_CDN_URL = "https://cdn.tailwindcss.com/3.4.16";
// Sandboxed srcdoc iframes without `allow-same-origin` resolve `href="#id"` links
// against the parent's URL as base. The default click then either navigates the
// iframe to `<parent-url>#id` (reloading our app inside the iframe) or updates
// the parent window's hash — both of which break the artifact preview.
//
// This script stays inside the iframe document and handles in-page anchor
// navigation locally by scrolling to the element with the matching id.
export const FRAGMENT_LINK_INTERCEPTOR_SCRIPT = `<script>
(function() {
if (document.__fragmentLinkInterceptor) return;
function handler(e) {
var t = e.target;
if (!t || typeof t.closest !== 'function') return;
var a = t.closest('a[href^="#"]');
if (!a) return;
var href = a.getAttribute('href');
if (!href || href === '#') return;
var id;
try { id = decodeURIComponent(href.slice(1)); } catch (_) { id = href.slice(1); }
if (!id) return;
var target = document.getElementById(id);
if (!target) return;
e.preventDefault();
if (typeof target.scrollIntoView === 'function') {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
document.__fragmentLinkInterceptor = handler;
document.addEventListener('click', handler);
})();
</script>`;
/**
* Inject content into the <head> of an HTML document string.
* If the content has no <head> tag, wraps it in a full document skeleton.