diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 3615c501a4..0e8572fbdc 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -120,6 +120,33 @@ describe("MarkdownBody", () => { expect(html).toContain('alt="Org chart"'); }); + it("renders decision images as inert references without resolving or preloading them", () => { + const resolveImageSrc = vi.fn((src: string) => `https://resolver.invalid/${src}`); + const html = renderMarkdown( + '# Plan\n\n**Keep this step.**\n\n![Evidence](https://provider.invalid/track.png "Provider image")\n\n![Relative](images/proof.png)\n\n![Protocol relative](//provider.invalid/other.png)', + [], { mediaMode: "reference", resolveImageSrc }, + ); + + expect(html).toContain("

Plan

"); + expect(html).toContain("Keep this step."); + expect(html).toContain("Image: Evidence (https://provider.invalid/track.png)"); + expect(html).toContain('title="Provider image"'); + expect(html).toContain("Image: Relative (images/proof.png)"); + expect(html).toContain("Image: Protocol relative (//provider.invalid/other.png)"); + expect(html).not.toMatch(/<(?:img|link|iframe|video|audio)\b/); + expect(resolveImageSrc).not.toHaveBeenCalled(); + }); + + it("keeps diagram source inert in decision media mode", () => { + const source = 'flowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }'; + const html = renderMarkdown(`\`\`\`mermaid\n${source}\n\`\`\``, [], { mediaMode: "reference" }); + + expect(html).toContain('class="language-mermaid"'); + expect(html).toContain("https://provider.invalid/diagram.png"); + expect(html).not.toContain('class="paperclip-mermaid"'); + expect(html).not.toContain("Rendering Mermaid diagram"); + }); + it("renders user, agent, project, skill, and routine mentions as chips", () => { const html = renderToStaticMarkup( diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 98c06bf01f..24b9772854 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -90,6 +90,8 @@ interface MarkdownBodyProps { resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ onImageClick?: (src: string) => void; + /** Keep untrusted decision media inert: image references and diagram source only. */ + mediaMode?: "render" | "reference"; /** * Resolver that decides which inline-code workspace file paths may be linked * to the issue file viewer. Omitting it (or returning null) leaves every @@ -719,6 +721,7 @@ function MarkdownBodyImpl({ externalReferences, resolveImageSrc, onImageClick, + mediaMode = "render", resolveWorkspaceFileRef, }: MarkdownBodyProps) { const { theme } = useTheme(); @@ -803,7 +806,7 @@ function MarkdownBodyImpl({ ), pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); - if (mermaidSource) { + if (mermaidSource && mediaMode === "render") { return ; } return {preChildren}; @@ -914,7 +917,13 @@ function MarkdownBodyImpl({ ); }, }; - if (resolveImageSrc || onImageClick) { + if (mediaMode === "reference") { + map.img = ({ src, alt, title }) => ( + + Image: {alt || "Untitled image"}{src ? ` (${src})` : ""} + + ); + } else if (resolveImageSrc || onImageClick) { map.img = ({ node: _node, src, alt, ...imgProps }) => { const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null; const finalSrc = resolved ?? src; @@ -930,7 +939,7 @@ function MarkdownBodyImpl({ }; } return map; - }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick]); + }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick, mediaMode]); return (
- {questionSet.description} + {questionSet.description}
) : null} {question.answerMode === "text" ? ( diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx index 9af1b59b91..00961fdbaa 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx @@ -632,6 +632,35 @@ describe("TaskChatProtocolCard", () => { expect(container.textContent).toContain("Submitting…"); }); + it("keeps complete provider plan Markdown while making image references inert", () => { + const prefix = '# Release plan\n\n**Preserve every instruction.**\n\n![Evidence](https://provider.invalid/track.png "Provider image")\n\n![Local image](/api/attachments/untrusted/content)\n\n'; + const suffix = '\n\n```mermaid\nflowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }\n```\n\nFINAL_PLAN_BOUNDARY'; + const fullInstructions = "x".repeat(100_000 - prefix.length - suffix.length); + const description = prefix + fullInstructions + suffix; + expect(description).toHaveLength(100_000); + renderCard(root, { + id: "provider-plan", kind: "protocol", surface: "runtime_request", runId: "run-1", + requestId: "plan-revision-7", requestKind: "runtime", turnId: "turn-1", + requestType: "input", status: "pending", prompt: "Review the complete plan.", choices: [], fields: [], + questionSet: { + schema: "paperclip.question_set.v1", description, + questions: [{ id: "decision", prompt: "Accept this plan?", required: true, answerMode: "single_select", + options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }] }], + }, + }); + + const context = container.querySelector('[role="region"][aria-label="Question context"]'); + expect(context?.querySelector("h1")?.textContent).toBe("Release plan"); + expect(context?.querySelector("strong")?.textContent).toBe("Preserve every instruction."); + expect(context?.textContent).toContain(fullInstructions); + expect(context?.textContent).toContain("FINAL_PLAN_BOUNDARY"); + expect(context?.textContent).toContain("Evidence"); + expect(context?.textContent).toContain("https://provider.invalid/track.png"); + expect(context?.querySelector("img, video, audio, iframe, object, embed, image, link")).toBeNull(); + expect(context?.querySelector('.language-mermaid')?.textContent).toContain('https://provider.invalid/diagram.png'); + expect(context?.querySelector('.paperclip-mermaid')).toBeNull(); + }); + it("submits the canonical response from a v2 harness question set", async () => { const onDecision = vi.fn().mockResolvedValue(undefined); renderCard(