authorgravatar for outsightszs@Outlook.comJohn Surles <outsightszs@Outlook.com> 2026-08-15 08:08:29-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:08:29+00:00
logcf7bfd1c93974428262ab1419d11c972d01d65fa
tree770f5b4277f19e6ff9302263acfc484cabc1b918
parent5e147371527154be385f28e57339a71521e528c6
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve XML-like tags in user messages (#4133)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 163 insertions(+), 3 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+12-2
...@@ -117,6 +117,8 @@ interface ChatMarkdownProps {...@@ -117,6 +117,8 @@ interface ChatMarkdownProps {
117 className?: string;117 className?: string;
118 /** Treat single newlines as hard breaks — chat-style user input. */118 /** Treat single newlines as hard breaks — chat-style user input. */
119 lineBreaks?: boolean;119 lineBreaks?: boolean;
120 /** Parse sanitized raw HTML instead of displaying its source text. */
121 parseRawHtml?: boolean;
120}122}
121123
122const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];124const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
...@@ -1360,6 +1362,7 @@ function ChatMarkdown({...@@ -1360,6 +1362,7 @@ function ChatMarkdown({
1360 skills = EMPTY_MARKDOWN_SKILLS,1362 skills = EMPTY_MARKDOWN_SKILLS,
1361 className,1363 className,
1362 lineBreaks = false,1364 lineBreaks = false,
1365 parseRawHtml = true,
1363}: ChatMarkdownProps) {1366}: ChatMarkdownProps) {
1364 const { resolvedTheme } = useTheme();1367 const { resolvedTheme } = useTheme();
1365 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {1368 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
...@@ -1622,7 +1625,7 @@ function ChatMarkdown({...@@ -1622,7 +1625,7 @@ function ChatMarkdown({
1622 />1625 />
1623 );1626 );
1624 },1627 },
1625 a({ node, href, children, ...props }) {1628 a({ node, href, children, title: _title, ...props }) {
1626 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";1629 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
1627 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;1630 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
1628 if (!fileLinkMeta) {1631 if (!fileLinkMeta) {
...@@ -1707,6 +1710,9 @@ function ChatMarkdown({...@@ -1707,6 +1710,9 @@ function ChatMarkdown({
1707 props.className,1710 props.className,
1708 );1711 );
1709 },1712 },
1713 img({ node: _node, title: _title, ...props }) {
1714 return <img {...props} />;
1715 },
1710 code({ node, children, className, ...props }) {1716 code({ node, children, className, ...props }) {
1711 if (node?.properties?.dataInlineCode != null) {1717 if (node?.properties?.dataInlineCode != null) {
1712 const codeText = nodeToPlainText(children);1718 const codeText = nodeToPlainText(children);
...@@ -1777,6 +1783,9 @@ function ChatMarkdown({...@@ -1777,6 +1783,9 @@ function ChatMarkdown({
1777 ]);1783 ]);
1778 /* eslint-enable react/no-unstable-nested-components */1784 /* eslint-enable react/no-unstable-nested-components */
17791785
1786 // react-markdown converts unparsed HTML nodes to text when skipHtml is false.
1787 // Keep that behavior explicit because literal mode depends on escaping the
1788 // complete source token instead of dropping it from the rendered message.
1780 return (1789 return (
1781 <div1790 <div
1782 className={cn(1791 className={cn(
...@@ -1789,7 +1798,8 @@ function ChatMarkdown({...@@ -1789,7 +1798,8 @@ function ChatMarkdown({
1789 remarkPlugins={1798 remarkPlugins={
1790 lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS1799 lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
1791 }1800 }
1792 rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}1801 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
1802 skipHtml={false}
1793 components={markdownComponents}1803 components={markdownComponents}
1794 urlTransform={markdownUrlTransform}1804 urlTransform={markdownUrlTransform}
1795 >1805 >
apps/web/src/components/chat/MessagesTimeline.test.tsx+147-1
...@@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) {...@@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) {
226 };226 };
227}227}
228228
229function buildAssistantTimelineEntry(text: string) {
230 const entry = buildUserTimelineEntry(text);
231 return {
232 ...entry,
233 message: {
234 ...entry.message,
235 role: "assistant" as const,
236 },
237 };
238}
239
229describe("MessagesTimeline", () => {240describe("MessagesTimeline", () => {
230 it("sizes expanded tool details with the configured code font size", () => {241 it("sizes expanded tool details with the configured code font size", () => {
231 expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code");242 expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code");
...@@ -470,7 +481,142 @@ describe("MessagesTimeline", () => {...@@ -470,7 +481,142 @@ describe("MessagesTimeline", () => {
470 expect(markup).toContain("rounded-2xl bg-message p-3");481 expect(markup).toContain("rounded-2xl bg-message p-3");
471 });482 });
472483
473 it("renders inline terminal labels with the composer chip UI", () => {484 it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => {
485 const { MessagesTimeline } = await import("./MessagesTimeline");
486 const markup = renderToStaticMarkup(
487 <MessagesTimeline
488 {...buildProps()}
489 timelineEntries={[
490 buildUserTimelineEntry(
491 [
492 'Without reading a file, do you have <global-agent-instructions scope="workspace">',
493 'Before <nested data-value="a&b">inside</nested> after',
494 "</global-agent-instructions> in your context?",
495 "Comparison: 2 < 3 and 5 > 4.",
496 ].join("\n"),
497 ),
498 ]}
499 />,
500 );
501
502 expect(markup).toContain("&lt;global-agent-instructions scope=&quot;workspace&quot;&gt;");
503 expect(markup).toContain(
504 "Before &lt;nested data-value=&quot;a&amp;b&quot;&gt;inside&lt;/nested&gt; after",
505 );
506 expect(markup).toContain("&lt;/global-agent-instructions&gt; in your context?");
507 expect(markup).toContain("Comparison: 2 &lt; 3 and 5 &gt; 4.");
508 });
509
510 it("preserves XML-like source inside user code spans and fences", async () => {
511 const { MessagesTimeline } = await import("./MessagesTimeline");
512 const markup = renderToStaticMarkup(
513 <MessagesTimeline
514 {...buildProps()}
515 timelineEntries={[
516 buildUserTimelineEntry(
517 [
518 'Inline `<tag attr="x">`',
519 "",
520 "```xml",
521 '<root><child enabled="true" /></root>',
522 "```",
523 ].join("\n"),
524 ),
525 ]}
526 />,
527 );
528
529 expect(markup).toContain('<code data-inline-code="">&lt;tag attr=&quot;x&quot;&gt;</code>');
530 expect(markup).toContain("&lt;root&gt;&lt;child enabled=&quot;true&quot; /&gt;&lt;/root&gt;");
531 });
532
533 it("does not render markdown title attributes in user messages", async () => {
534 const { MessagesTimeline } = await import("./MessagesTimeline");
535 const markup = renderToStaticMarkup(
536 <MessagesTimeline
537 {...buildProps()}
538 timelineEntries={[
539 buildUserTimelineEntry(
540 '[link](https://example.com "link tip") ![image](https://example.com/image.png "image tip")',
541 ),
542 ]}
543 />,
544 );
545
546 expect(markup).toContain('href="https://example.com"');
547 expect(markup).toContain('src="https://example.com/image.png"');
548 expect(markup).not.toContain('title="link tip"');
549 expect(markup).not.toContain('title="image tip"');
550 });
551
552 it("renders unsafe user HTML as inert source text", async () => {
553 const { MessagesTimeline } = await import("./MessagesTimeline");
554 const markup = renderToStaticMarkup(
555 <MessagesTimeline
556 {...buildProps()}
557 timelineEntries={[
558 buildUserTimelineEntry(
559 '<script>globalThis.__t3Xss = 1</script><img src="x" onerror="globalThis.__t3Xss = 2">',
560 ),
561 ]}
562 />,
563 );
564
565 expect(markup).toContain("&lt;script&gt;globalThis.__t3Xss = 1&lt;/script&gt;");
566 expect(markup).toContain(
567 "&lt;img src=&quot;x&quot; onerror=&quot;globalThis.__t3Xss = 2&quot;&gt;",
568 );
569 expect(markup).not.toMatch(/<script(?:\s|>)/i);
570 expect(markup).not.toMatch(/<img(?:\s|>)/i);
571 });
572
573 it("continues to render sanitized raw HTML in assistant messages", async () => {
574 const { MessagesTimeline } = await import("./MessagesTimeline");
575 const markup = renderToStaticMarkup(
576 <MessagesTimeline
577 {...buildProps()}
578 timelineEntries={[
579 buildAssistantTimelineEntry("<details><summary>More</summary>Details</details>"),
580 ]}
581 />,
582 );
583
584 expect(markup).toContain('data-markdown-details=""');
585 expect(markup).toContain("More");
586 expect(markup).not.toContain("&lt;details&gt;");
587 });
588
589 it("sanitizes executable HTML while preserving supported assistant markup", async () => {
590 const { MessagesTimeline } = await import("./MessagesTimeline");
591 const markup = renderToStaticMarkup(
592 <MessagesTimeline
593 {...buildProps()}
594 timelineEntries={[
595 buildAssistantTimelineEntry(
596 [
597 '<details open onclick="globalThis.__t3Xss = 1">',
598 "<summary>Safe details</summary>",
599 "<script>globalThis.__t3Xss = 2</script>",
600 '<img src="x" onerror="globalThis.__t3Xss = 3">',
601 '<a href="javascript:globalThis.__t3Xss = 4">Unsafe link</a>',
602 "</details>",
603 ].join(""),
604 ),
605 ]}
606 />,
607 );
608
609 expect(markup).toContain('data-markdown-details=""');
610 expect(markup).toContain("Safe details");
611 expect(markup).not.toMatch(/<script(?:\s|>)/i);
612 expect(markup).not.toContain("onclick=");
613 expect(markup).not.toContain("onerror=");
614 expect(markup).not.toContain("javascript:");
615 expect(markup).not.toContain("globalThis.__t3Xss");
616 });
617
618 it("renders inline terminal labels with the composer chip UI", async () => {
619 const { MessagesTimeline } = await import("./MessagesTimeline");
474 const markup = renderToStaticMarkup(620 const markup = renderToStaticMarkup(
475 <MessagesTimeline621 <MessagesTimeline
476 {...buildProps()}622 {...buildProps()}
apps/web/src/components/chat/MessagesTimeline.tsx+4
...@@ -1692,6 +1692,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1692,6 +1692,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1692 skills={props.skills}1692 skills={props.skills}
1693 className="text-message-foreground"1693 className="text-message-foreground"
1694 lineBreaks1694 lineBreaks
1695 parseRawHtml={false}
1695 />1696 />
1696 ) : null}1697 ) : null}
1697 {trailingWhitespace ? <span aria-hidden="true">{trailingWhitespace}</span> : null}1698 {trailingWhitespace ? <span aria-hidden="true">{trailingWhitespace}</span> : null}
...@@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1714 skills={props.skills}1715 skills={props.skills}
1715 className="text-message-foreground"1716 className="text-message-foreground"
1716 lineBreaks1717 lineBreaks
1718 parseRawHtml={false}
1717 />1719 />
1718 </div>1720 </div>
1719 ) : null1721 ) : null
...@@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1802 skills={props.skills}1804 skills={props.skills}
1803 className="text-message-foreground"1805 className="text-message-foreground"
1804 lineBreaks1806 lineBreaks
1807 parseRawHtml={false}
1805 />,1808 />,
1806 );1809 );
1807 } else if (inlinePrefix.length === 0) {1810 } else if (inlinePrefix.length === 0) {
...@@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1827 skills={props.skills}1830 skills={props.skills}
1828 className="text-message-foreground"1831 className="text-message-foreground"
1829 lineBreaks1832 lineBreaks
1833 parseRawHtml={false}
1830 />1834 />
1831 );1835 );
1832});1836});