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 {
117117 className?: string;
118118 /** Treat single newlines as hard breaks — chat-style user input. */
119119 lineBreaks?: boolean;
120 /** Parse sanitized raw HTML instead of displaying its source text. */
121 parseRawHtml?: boolean;
120122}
121123
122124const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
......@@ -1360,6 +1362,7 @@ function ChatMarkdown({
13601362 skills = EMPTY_MARKDOWN_SKILLS,
13611363 className,
13621364 lineBreaks = false,
1365 parseRawHtml = true,
13631366}: ChatMarkdownProps) {
13641367 const { resolvedTheme } = useTheme();
13651368 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
......@@ -1622,7 +1625,7 @@ function ChatMarkdown({
16221625 />
16231626 );
16241627 },
1625 a({ node, href, children, ...props }) {
1628 a({ node, href, children, title: _title, ...props }) {
16261629 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
16271630 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
16281631 if (!fileLinkMeta) {
......@@ -1707,6 +1710,9 @@ function ChatMarkdown({
17071710 props.className,
17081711 );
17091712 },
1713 img({ node: _node, title: _title, ...props }) {
1714 return <img {...props} />;
1715 },
17101716 code({ node, children, className, ...props }) {
17111717 if (node?.properties?.dataInlineCode != null) {
17121718 const codeText = nodeToPlainText(children);
......@@ -1777,6 +1783,9 @@ function ChatMarkdown({
17771783 ]);
17781784 /* 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.
17801789 return (
17811790 <div
17821791 className={cn(
......@@ -1789,7 +1798,8 @@ function ChatMarkdown({
17891798 remarkPlugins={
17901799 lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
17911800 }
1792 rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
1801 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
1802 skipHtml={false}
17931803 components={markdownComponents}
17941804 urlTransform={markdownUrlTransform}
17951805 >
apps/web/src/components/chat/MessagesTimeline.test.tsx+147-1
......@@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) {
226226 };
227227}
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
229240describe("MessagesTimeline", () => {
230241 it("sizes expanded tool details with the configured code font size", () => {
231242 expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code");
......@@ -470,7 +481,142 @@ describe("MessagesTimeline", () => {
470481 expect(markup).toContain("rounded-2xl bg-message p-3");
471482 });
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");
474620 const markup = renderToStaticMarkup(
475621 <MessagesTimeline
476622 {...buildProps()}
apps/web/src/components/chat/MessagesTimeline.tsx+4
......@@ -1692,6 +1692,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
16921692 skills={props.skills}
16931693 className="text-message-foreground"
16941694 lineBreaks
1695 parseRawHtml={false}
16951696 />
16961697 ) : null}
16971698 {trailingWhitespace ? <span aria-hidden="true">{trailingWhitespace}</span> : null}
......@@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
17141715 skills={props.skills}
17151716 className="text-message-foreground"
17161717 lineBreaks
1718 parseRawHtml={false}
17171719 />
17181720 </div>
17191721 ) : null
......@@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
18021804 skills={props.skills}
18031805 className="text-message-foreground"
18041806 lineBreaks
1807 parseRawHtml={false}
18051808 />,
18061809 );
18071810 } else if (inlinePrefix.length === 0) {
......@@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
18271830 skills={props.skills}
18281831 className="text-message-foreground"
18291832 lineBreaks
1833 parseRawHtml={false}
18301834 />
18311835 );
18321836});