diff --git a/CHANGELOG.md b/CHANGELOG.md index 352fce0f514..8a1647d3b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (`&`, `"`, `<`, `>`, 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 `
` 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
diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts
index c9084a6c305..e355ff922a4 100644
--- a/ui/src/ui/markdown.test.ts
+++ b/ui/src/ui/markdown.test.ts
@@ -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('');
+ expect(html).toContain("Fallback **probe**");
+ expect(warnSpy).toHaveBeenCalledOnce();
+ } finally {
+ parseSpy.mockRestore();
+ warnSpy.mockRestore();
+ }
+ });
});
diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts
index 3ca420bd030..354d4765265 100644
--- a/ui/src/ui/markdown.ts
+++ b/ui/src/ui/markdown.ts
@@ -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 = `${escaped}`;
+ }
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);