fix(ui): catch marked.js parse errors to prevent Control UI crash (#36445)

- Prevent Control UI session render crashes when `marked.parse()` encounters pathological recursive markdown by safely falling back to escaped `<pre>` output.
- Tighten markdown fallback regression coverage and keep changelog attribution in sync for this crash-hardening path.

Co-authored-by: Bin Deng <dengbin@romangic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Bin Deng
2026-03-06 03:46:49 +08:00
committed by GitHub
parent 6c0376145f
commit edc386e9a5
3 changed files with 51 additions and 6 deletions

View File

@@ -35,6 +35,8 @@ Docs: https://docs.openclaw.ai
- Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (`&amp;`, `&quot;`, `&lt;`, `&gt;`, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.
- Agents/thinking-tag promotion hardening: guard `promoteThinkingTagsToBlocks` against malformed assistant content entries (`null`/`undefined`) before `block.type` reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.
- Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid `dev` placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap `serverVersion` to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.
- Control UI/markdown parser crash fallback: catch `marked.parse()` failures and fall back to escaped plain-text `<pre>` rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.
- Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.
- Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
- Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
@@ -349,6 +351,7 @@ Docs: https://docs.openclaw.ai
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
- Control UI/markdown recursion fallback: catch markdown parser failures and safely render escaped plain-text fallback instead of crashing the Control UI on pathological markdown history payloads. (#36445, fixes #36213) Thanks @BinHPdev.
## 2026.3.1

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { marked } from "marked";
import { describe, expect, it, vi } from "vitest";
import { toSanitizedMarkdownHtml } from "./markdown.ts";
describe("toSanitizedMarkdownHtml", () => {
@@ -82,4 +83,36 @@ describe("toSanitizedMarkdownHtml", () => {
// Pipes from table delimiters must not appear as raw text
expect(html).not.toContain("|------|");
});
it("does not throw on deeply nested emphasis markers (#36213)", () => {
// Pathological patterns that can trigger catastrophic backtracking / recursion
const nested = "*".repeat(500) + "text" + "*".repeat(500);
expect(() => toSanitizedMarkdownHtml(nested)).not.toThrow();
const html = toSanitizedMarkdownHtml(nested);
expect(html).toContain("text");
});
it("does not throw on deeply nested brackets (#36213)", () => {
const nested = "[".repeat(200) + "link" + "]".repeat(200) + "(" + "x".repeat(200) + ")";
expect(() => toSanitizedMarkdownHtml(nested)).not.toThrow();
const html = toSanitizedMarkdownHtml(nested);
expect(html).toContain("link");
});
it("falls back to escaped plain text if marked.parse throws (#36213)", () => {
const parseSpy = vi.spyOn(marked, "parse").mockImplementation(() => {
throw new Error("forced parse failure");
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const input = `Fallback **probe** ${Date.now()}`;
try {
const html = toSanitizedMarkdownHtml(input);
expect(html).toContain('<pre class="code-block">');
expect(html).toContain("Fallback **probe**");
expect(warnSpy).toHaveBeenCalledOnce();
} finally {
parseSpy.mockRestore();
warnSpy.mockRestore();
}
});
});

View File

@@ -110,11 +110,20 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
}
return sanitized;
}
const rendered = marked.parse(`${truncated.text}${suffix}`, {
renderer: htmlEscapeRenderer,
gfm: true,
breaks: true,
}) as string;
let rendered: string;
try {
rendered = marked.parse(`${truncated.text}${suffix}`, {
renderer: htmlEscapeRenderer,
gfm: true,
breaks: true,
}) as string;
} catch (err) {
// Fall back to escaped plain text when marked.parse() throws (e.g.
// infinite recursion on pathological markdown patterns — #36213).
console.warn("[markdown] marked.parse failed, falling back to plain text:", err);
const escaped = escapeHtml(`${truncated.text}${suffix}`);
rendered = `<pre class="code-block">${escaped}</pre>`;
}
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);