Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
16710d5
test: expose missing JSX and written HTML artifact detection
teamleaderleo Oct 25, 2026
f051567
feat: render offline interactive chat artifacts
teamleaderleo Oct 25, 2026
b64dd71
Merge feat-cmux-next into nx-artifact-html-rebase-1025
teamleaderleo Oct 26, 2026
7560a4b
test(agent-chat): preserve written artifact save names
teamleaderleo Oct 27, 2026
005860e
fix(agent-chat): keep written artifact filename on save
teamleaderleo Oct 27, 2026
aaee38b
fix(agent-chat): preview artifacts in built gallery frames
teamleaderleo Oct 27, 2026
b864c0e
style(agent-chat): format gallery artifact route regression
teamleaderleo Oct 27, 2026
8928e73
Merge commit 'aa82735368f69b31aafcd6666304502a7716bc62' into nx-artif…
teamleaderleo Oct 27, 2026
2b5c9a7
test: cover safe artifact previews and write revision dedup
teamleaderleo Oct 27, 2026
9e2f9d1
fix: keep automatic artifact previews scriptless
teamleaderleo Oct 27, 2026
fbfd741
test: keep canonical identity across raw artifact overwrites
teamleaderleo Oct 27, 2026
127e59d
fix: preserve canonical paths in raw artifact writes
teamleaderleo Oct 27, 2026
b71fd15
test(cef): deny artifact documents page file authority
teamleaderleo Oct 27, 2026
a47a65d
fix(cef): isolate offline artifact documents on the shared page host
teamleaderleo Oct 27, 2026
a342922
Merge commit 'a47a65d01ca5b1a4e8168c520164ec37d15988d4' into nx-artif…
teamleaderleo Oct 27, 2026
bdfa39f
test: cover artifact previews on the shared page host
teamleaderleo Oct 27, 2026
6881532
fix: route artifact previews through the shared page host
teamleaderleo Oct 27, 2026
bd85af3
test: require trusted ancestry for legacy artifact frames
teamleaderleo Oct 27, 2026
70854f5
fix: restrict legacy artifact frames to the trusted pane
teamleaderleo Oct 27, 2026
0959547
test: preserve render provenance and opaque artifact responses
teamleaderleo Oct 27, 2026
8bc170b
fix: preserve render policy and harden artifact responses
teamleaderleo Oct 27, 2026
2181a8b
Merge feat-cmux-next into nx-artifacts-resume-a-1027
teamleaderleo Oct 28, 2026
6c68b5c
Merge remote-tracking branch 'origin/feat-cmux-next' into nx-artifact…
teamleaderleo Oct 30, 2026
a9515b3
test: detect OpenCode and Gemini raw HTML writes as artifacts
teamleaderleo Oct 30, 2026
4230a1d
fix: read OpenCode and Gemini raw write paths for HTML artifacts
teamleaderleo Oct 30, 2026
f2ce3e8
Merge branch 'feat-cmux-next' into nx-artifacts-resume-a-1027
teamleaderleo Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public extension PageDescriptor {
static let agent = PageDescriptor(
id: "cmux.agent", resource: "agent-pane", namespaces: [AgentPageOps.namespace],
csp: PageCSP(connect: ["'none'"],
frame: [AgentPaneRenderFrame.source, "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"],
frame: [AgentPaneRenderFrame.source, "cmux-page://cmux.agent/__artifact", "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"],
script: ["'wasm-unsafe-eval'"], inlineScript: false),
// Permission cards (Allow once, Allow for chat), tool cards, Push, Apply and Remove act on one click and
// appear when the agent decides: a click meant only to bring the window forward must not answer them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate {
// never loads the pane's own page (a dev-server pane is on loopback too), which would make
// it same-origin with the pane.
if !mainFrame {
// Only the pane may create an artifact's script-capable frame. A loopback preview
// must not acquire that endpoint by creating a nested frame of its own.
if AgentPaneRenderFrame.isArtifact(url) {
if case .bundled = source, source.isTrusted(sourceFrameURL) { return .allow }
return .cancel
}
if case .bundled = source, AgentPaneRenderFrame.isFrame(url) { return .allow }
// A question's HTML option preview (question/HtmlPreview.tsx): the pane's own
// document, written into a sandboxed frame with no scripts. It loads nothing itself.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ nonisolated enum AgentPaneRenderFrame {

static let artifactPolicy = [
"default-src 'none'",
"sandbox allow-scripts",
"frame-ancestors cmux-agent://pane",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src data: blob:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ struct AgentPaneArtifactNavigationTests {
@Test func artifactsCannotNavigateEvenToAnotherAllowedFrame() throws {
let pane = AgentPaneSource.bundled(try #require(URL(string: "cmux-agent://pane/index.html")))
let frame = try #require(URL(string: "cmux-agent://render/artifact"))
#expect(AgentPaneNavigation.decision(for: frame, source: pane, userClicked: false, mainFrame: false) == .allow)
#expect(AgentPaneNavigation.decision(for: frame, source: pane, userClicked: false, mainFrame: false,
sourceFrameURL: pane.pageURL) == .allow)
for text in ["http://127.0.0.1:8080/leak", "https://example.com", "file:///tmp/private", "cmux-agent://render/frame", "about:srcdoc"] {
let url = try #require(URL(string: text))
for main in [true, false] {
Expand All @@ -30,4 +31,38 @@ struct AgentPaneArtifactNavigationTests {
// The parent is allowed to create its srcdoc child; only a navigation that originated there is refused.
#expect(AgentPaneNavigation.decision(for: srcdoc, source: pane, userClicked: false, mainFrame: false) == .allow)
}

@Test func onlyTheTrustedPaneMayCreateAnArtifactFrame() throws {
let pane = AgentPaneSource.bundled(try #require(URL(string: "cmux-agent://pane/index.html")))
let artifact = try #require(URL(string: "cmux-agent://render/artifact"))
let render = try #require(URL(string: "cmux-agent://render/frame"))
for text in ["http://127.0.0.1:8080/preview", "https://example.com/", "cmux-agent://pane/other.html", "about:blank"] {
let untrusted = try #require(URL(string: text))
#expect(AgentPaneNavigation.decision(for: artifact, source: pane, userClicked: false, mainFrame: false,
sourceFrameURL: untrusted) == .cancel)
// Ordinary render cards keep their existing navigation policy.
#expect(AgentPaneNavigation.decision(for: render, source: pane, userClicked: false, mainFrame: false,
sourceFrameURL: untrusted) == .allow)
}
#expect(AgentPaneNavigation.decision(for: artifact, source: pane, userClicked: false, mainFrame: false) == .cancel)
// The pane creates a fresh child whose target request may still be empty or about:blank.
for target in [nil, URL(string: "about:blank")] {
#expect(AgentPaneNavigation.decision(for: artifact, source: pane, userClicked: false, mainFrame: false,
sourceFrameURL: pane.pageURL, targetFrameURL: target) == .allow)
}
}

@Test func artifactResponsesEnforceOpaqueSandboxAndPaneAncestors() throws {
let headers = AgentPaneRenderFrame.headers(length: 1, artifact: true)
let policy = try #require(headers["Content-Security-Policy"])
let directives = policy.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) }
#expect(directives.contains("sandbox allow-scripts"))
#expect(directives.contains("frame-ancestors cmux-agent://pane"))
#expect(!policy.contains("allow-same-origin"))
#expect(!policy.contains("127.0.0.1"))
let render = try #require(AgentPaneRenderFrame.headers(length: 1)["Content-Security-Policy"])
#expect(render.contains("https://cdn.jsdelivr.net"))
#expect(render.contains("'unsafe-eval'"))
#expect(!render.contains("sandbox"))
}
}
41 changes: 29 additions & 12 deletions webviews/src/agent-session/acpmux/conversation/ArtifactPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { useT } from "../i18n";
import { artifactFileName, documentName, previewHtml, type Artifact, type ArtifactKind } from "./artifactPreview";
import { ARTIFACT_FRAME_URL, artifactGallery, artifactNeedsRun, galleryArtifactDocument } from "./artifactSandbox";
import {
artifactCanRender,
artifactFrameURL,
artifactGallery,
artifactNeedsRun,
galleryArtifactDocument,
} from "./artifactSandbox";
import { renderFrameCSS } from "./renderBase";
import { copyText } from "./clipboard";
import { CodeBlock, languageLabel } from "./CodeBlock";
import { Check, ChevronLeft, ChevronRight, Close, Copy, SaveTray } from "./icons";
import { Markdown } from "./Markdown";
import { canRender, ranRenders, useRenderFrame } from "./RenderCard";
import { canRender, ranRenders, RENDER_FRAME_URL, useRenderFrame } from "./RenderCard";

export { OpenPreview } from "./OpenPreview";

Expand Down Expand Up @@ -63,7 +69,8 @@ export function ArtifactPanel({
const index = Math.max(0, found);
const artifact = list[index]!;
const jsx = /^(jsx|tsx)$/i.test(artifact.lang);
const previews = artifact.kind === "markdown" || canRender() || artifactGallery();
const previews =
artifact.kind === "markdown" || (artifact.render ? canRender() : artifactCanRender()) || artifactGallery();
const [tab, setTab] = useState<"preview" | "code">(previews ? "preview" : "code");
const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false);
Expand Down Expand Up @@ -199,7 +206,7 @@ export function ArtifactPanel({
</span>
</div>
<div className="acpmux-artifact-body">
{tab === "code" ? (
{tab === "code" || !previews ? (
<CodeBlock code={artifact.code} lang={artifact.lang} />
) : artifact.kind === "markdown" ? (
<div className="acpmux-artifact-doc">
Expand All @@ -208,16 +215,22 @@ export function ArtifactPanel({
) : (
// Each version is its own frame: a frame draws the first document it is sent, and a
// version with script waits for Run again.
<ArtifactFrame key={artifact.code} html={previewHtml(artifact)} title={title} jsx={jsx} />
<ArtifactFrame
key={artifact.code}
html={previewHtml(artifact)}
title={title}
jsx={jsx}
render={!!artifact.render}
/>
)}
</div>
</aside>
);
}

function ArtifactFrame({ html, title, jsx }: { html: string; title: string; jsx: boolean }) {
function ArtifactFrame({ html, title, jsx, render }: { html: string; title: string; jsx: boolean; render: boolean }) {
const t = useT();
const requiresRun = jsx || artifactNeedsRun(html);
const requiresRun = render || jsx || artifactNeedsRun(html);
const [allowed, setAllowed] = useState(() => ranRenders.has(html));
if (!requiresRun) return <PassiveFrame html={html} title={title} />;
if (!allowed)
Expand All @@ -235,7 +248,11 @@ function ArtifactFrame({ html, title, jsx }: { html: string; title: string; jsx:
</button>
</div>
);
return jsx ? <JsxFrame code={html} title={title} /> : <RunningFrame html={html} title={title} />;
return jsx ? (
<JsxFrame code={html} title={title} render={render} />
) : (
<RunningFrame html={html} title={title} render={render} />
);
}

function PassiveFrame({ html, title }: { html: string; title: string }) {
Expand All @@ -250,7 +267,7 @@ function PassiveFrame({ html, title }: { html: string; title: string }) {
);
}

function JsxFrame({ code, title }: { code: string; title: string }) {
function JsxFrame({ code, title, render }: { code: string; title: string; render: boolean }) {
const [html, setHtml] = useState<string>();
const [failed, setFailed] = useState(false);
useEffect(() => {
Expand All @@ -267,16 +284,16 @@ function JsxFrame({ code, title }: { code: string; title: string }) {
};
}, [code]);
if (failed) return <CodeBlock code={code} lang="jsx" />;
return html === undefined ? null : <RunningFrame html={html} title={title} />;
return html === undefined ? null : <RunningFrame html={html} title={title} render={render} />;
}

function RunningFrame({ html, title }: { html: string; title: string }) {
function RunningFrame({ html, title, render }: { html: string; title: string; render: boolean }) {
const { frame } = useRenderFrame(html);
return (
<iframe
ref={frame}
className="acpmux-artifact-frame"
src={artifactGallery() ? undefined : ARTIFACT_FRAME_URL}
src={artifactGallery() ? undefined : render ? RENDER_FRAME_URL : artifactFrameURL()}
srcDoc={artifactGallery() ? galleryArtifactDocument(html, renderFrameCSS()) : undefined}
title={title}
sandbox="allow-scripts"
Expand Down
18 changes: 18 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/artifactSandbox.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
// Passive previews run public-safe markup in srcdoc. Explicit Run uses the native /artifact endpoint,
// whose response CSP and navigation delegate also deny self-navigation (CSP cannot).
export const ARTIFACT_FRAME_URL = "cmux-agent://render/artifact";
export const SHARED_ARTIFACT_FRAME_URL = "cmux-page://cmux.agent/__artifact";

/// Only native agent pane origins provide the offline script frame. Other shared pages and web
/// pages retain source-only previews; the public gallery supplies its own fixture frame below.
export function artifactFrameURL(): string | undefined {
if (typeof location === "undefined") return undefined;
try {
const page = new URL(location.href);
if (page.username || page.password || page.port) return undefined;
if (page.protocol === "cmux-agent:" && page.hostname === "pane") return ARTIFACT_FRAME_URL;
if (page.protocol === "cmux-page:" && page.hostname === "cmux.agent") return SHARED_ARTIFACT_FRAME_URL;
} catch {
// A page with no usable URL has no native artifact endpoint.
}
return undefined;
}
export const artifactCanRender = () => artifactFrameURL() !== undefined;

export const artifactGallery = () =>
typeof location !== "undefined" &&
location.pathname.endsWith("/frame.html") &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ export function toolArtifacts(tool: Tool): Artifact[] {
const previous = cache.get(tool);
if (previous) return previous;
const full = (tool.diffs ?? []).filter((diff) => !diff.fragment || (diff.fromInput && diff.oldText == null));
// Claude/pi/Gemini may send the whole write in rawInput without a diff block.
// Claude/pi/OpenCode/Gemini may send the whole write in rawInput without a diff block; each names
// the file its own way (toolPaths.ts).
if (!full.length && /^(write|write_file|writefile|create_file)(?:\s|$)/i.test(tool.title)) {
try {
const input = JSON.parse(tool.inputSummary ?? "null");
const path = input?.file_path ?? input?.path;
const path = input?.file_path ?? input?.filePath ?? input?.absolute_path ?? input?.path;
if (typeof path === "string" && typeof input?.content === "string")
full.push({ path, canonical: toolCanonical(tool, path), newText: input.content });
} catch {
Expand Down
33 changes: 32 additions & 1 deletion webviews/test/artifact-html.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { expect, test } from "bun:test";
import { artifactKind } from "../src/agent-session/acpmux/conversation/artifactPreview";
import { outputArtifacts } from "../src/agent-session/acpmux/inspector/OutputsSection";
import type { AcpmuxActivity, AcpmuxRow } from "../src/agent-session/acpmux/model";
import { artifactGallery, artifactNeedsRun } from "../src/agent-session/acpmux/conversation/artifactSandbox";
import {
artifactCanRender,
artifactFrameURL,
artifactGallery,
artifactNeedsRun,
} from "../src/agent-session/acpmux/conversation/artifactSandbox";
import { uniqueArtifacts } from "../src/agent-session/acpmux/conversation/artifactDedupe";
import { writtenArtifacts } from "../src/agent-session/acpmux/conversation/writtenArtifacts";

Expand Down Expand Up @@ -37,6 +42,29 @@ test("JSX fences use the HTML artifact surface", () => {
expect(artifactKind("tsx", "export default () => <svg />")).toBe("html");
});

test("artifact frames select only the native pane endpoints", () => {
const before = Object.getOwnPropertyDescriptor(globalThis, "location");
try {
for (const [href, frame] of [
["cmux-agent://pane/index.html", "cmux-agent://render/artifact"],
["cmux-page://cmux.agent/", "cmux-page://cmux.agent/__artifact"],
["cmux-page://cmux.agent/index.html", "cmux-page://cmux.agent/__artifact"],
["cmux-page://cmux.agent.evil/", undefined],
["cmux-page://cmux.agent:1234/", undefined],
["cmux-page://user@cmux.agent/", undefined],
["cmux-page://cmux.settings/", undefined],
["https://cmux.agent/", undefined],
] as const) {
Object.defineProperty(globalThis, "location", { configurable: true, value: new URL(href) });
expect(artifactCanRender()).toBe(frame !== undefined);
expect(artifactFrameURL()).toBe(frame);
}
} finally {
if (before) Object.defineProperty(globalThis, "location", before);
else Reflect.deleteProperty(globalThis, "location");
}
});

test("artifact script consent conservatively catches parsed handlers and encoded javascript URLs", () => {
for (const code of [
'<svg/onload="for(;;){}"></svg>',
Expand Down Expand Up @@ -73,6 +101,9 @@ test("full write inputs are detected without reading the file; malformed inputs
["Write", "file_path"],
["write", "path"],
["write_file", "file_path"],
// OpenCode and Gemini name the written file differently (toolPaths.ts).
["write", "filePath"],
["write_file", "absolute_path"],
]) {
const tool = {
id: title!,
Expand Down
63 changes: 62 additions & 1 deletion webviews/test/artifact-panel-sandbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const scope = globalThis as Record<string, unknown>;
const replacements: Record<string, unknown> = {
window: dom.window,
document: dom.window.document,
location: { protocol: "cmux-agent:", pathname: "/index.html", search: "" },
location: new URL("cmux-agent://pane/index.html"),
navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement,
Element: dom.window.Element,
Expand Down Expand Up @@ -94,3 +94,64 @@ test("malformed handlers and encoded javascript URLs never receive automatic scr
expect(host.querySelector("iframe")).toBeNull();
}
});

test("the shared page host previews passive HTML and SVG and grants scripts only after Run", async () => {
const { ArtifactPanel } = await import("../src/agent-session/acpmux/conversation/ArtifactPanel");
const previous = scope.location;
scope.location = new URL("cmux-page://cmux.agent/");
try {
for (const kind of ["html", "svg"] as const) {
act(() =>
root!.render(
<ArtifactPanel
artifact={{ kind, lang: kind, code: kind === "html" ? "<p>shared</p>" : "<svg/>" }}
onClose={() => {}}
/>,
),
);
expect(host.querySelector("iframe")?.getAttribute("sandbox")).toBe("");
}
act(() =>
root!.render(
<ArtifactPanel
artifact={{ kind: "html", lang: "html", code: '<button onclick="this.textContent++">10</button>' }}
onClose={() => {}}
/>,
),
);
expect(host.querySelector("iframe")).toBeNull();
act(() => button("Run")!.click());
expect(host.querySelector("iframe")?.getAttribute("src")).toBe("cmux-page://cmux.agent/__artifact");
expect(host.querySelector("iframe")?.getAttribute("sandbox")).toBe("allow-scripts");
act(() =>
root!.render(
<ArtifactPanel
artifact={{ kind: "html", lang: "jsx", code: "<button>shared JSX</button>" }}
onClose={() => {}}
/>,
),
);
expect(button("Run")).toBeDefined();
expect(host.querySelector("iframe")).toBeNull();
} finally {
scope.location = previous;
}
});

test("render-card versions retain the original CDN-capable render endpoint", async () => {
const { ArtifactPanel } = await import("../src/agent-session/acpmux/conversation/ArtifactPanel");
const code = '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script><canvas id="chart"></canvas>';
const versions = [code, code + '<script>new Chart(document.getElementById("chart"), {})</script>'].map((code) => ({
kind: "html" as const,
lang: "html",
code,
render: "chart-tool",
}));
act(() => root!.render(<ArtifactPanel artifact={versions[0]!} versions={versions} onClose={() => {}} />));
act(() => button("Run")!.click());
expect(host.querySelector("iframe")?.getAttribute("src")).toBe("cmux-agent://render/frame");
act(() => button("Next version")!.click());
act(() => button("Run")!.click());
expect(host.querySelector("iframe")?.getAttribute("src")).toBe("cmux-agent://render/frame");
expect(host.querySelector("iframe")?.getAttribute("sandbox")).toBe("allow-scripts");
});
Loading