From b9f02467653eb6e815623bfb1c4702e113e2d241 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 22:33:53 -0700 Subject: [PATCH] feat(plugin-sdk): add shared HTML escaping seam (#105975) --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +-- docs/plugins/sdk-subpaths.md | 1 + extensions/canvas/src/documents.ts | 11 +------ extensions/canvas/src/widget-tool.ts | 10 +----- extensions/diffs/src/render.ts | 10 +----- .../discord/discord-live.runtime.test.ts | 7 ++-- .../discord/discord-live.runtime.ts | 9 +----- extensions/telegram/src/miniapp/page.test.ts | 6 ++++ extensions/telegram/src/miniapp/page.ts | 19 ++--------- extensions/workspaces/src/scaffold.test.ts | 32 +++++++++++++++++++ extensions/workspaces/src/scaffold.ts | 9 +----- scripts/plugin-sdk-surface-report.mjs | 4 +-- scripts/ts-max-loc-baseline-v2.json | 4 +-- src/plugin-sdk/text-utility-runtime.test.ts | 9 ++++++ src/plugin-sdk/text-utility-runtime.ts | 10 ++++++ 15 files changed, 75 insertions(+), 70 deletions(-) create mode 100644 extensions/workspaces/src/scaffold.test.ts create mode 100644 src/plugin-sdk/text-utility-runtime.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index d28e49ffd824..1d6e724edca5 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -5843643ebc635ab35cf50668e0e1a14ec599f5d297bfb0bd0e019048caf425f0 plugin-sdk-api-baseline.json -2102fd06e7da61320aa6a1113a2227f6aeca64444cb7a8c4a3a7bc4886957845 plugin-sdk-api-baseline.jsonl +5ddfbeaef2941a299f3677c7f8dd951f112f80ceefef7c6cbd8e4b1d5ec0670f plugin-sdk-api-baseline.json +df6272053970ab3c15f586f7a19c2abe0c9709b8938187e1ec0a85368e5ccd6c plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 1381d3460971..ba60b100f94d 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -336,6 +336,7 @@ usage endpoint failed or returned no usable usage data. | `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores | | `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports | | `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports | + | `plugin-sdk/text-utility-runtime` | Low-level text and path helpers, including five-entity HTML escaping | | `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers | | `plugin-sdk/retry-runtime` | Retry config and retry runner helpers | | `plugin-sdk/agent-runtime` | Deprecated broad barrel for agent dir/identity/workspace helpers, including `resolveAgentDir`, `resolveDefaultAgentDir`, and the deprecated `resolveOpenClawAgentDir` compatibility export; prefer focused agent/runtime subpaths | diff --git a/extensions/canvas/src/documents.ts b/extensions/canvas/src/documents.ts index 2e2438a85ae3..e8d1c9f2ef87 100644 --- a/extensions/canvas/src/documents.ts +++ b/extensions/canvas/src/documents.ts @@ -7,7 +7,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { root as fsRoot, sanitizeUntrustedFileName } from "openclaw/plugin-sdk/security-runtime"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; +import { escapeHtml, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; import { CANVAS_HOST_PATH } from "./host/a2ui.js"; type CanvasDocumentKind = "html_bundle" | "url_embed" | "document" | "image" | "video_asset"; @@ -72,15 +72,6 @@ function buildPdfWrapper(url: string): string { return `

Unable to render PDF preview. Open PDF.

`; } -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function normalizeLogicalPath(value: string): string { const normalized = value.replaceAll("\\", "/").replace(/^\/+/, ""); const parts = normalized.split("/").filter(Boolean); diff --git a/extensions/canvas/src/widget-tool.ts b/extensions/canvas/src/widget-tool.ts index ed466b2fe538..29df1d60fb57 100644 --- a/extensions/canvas/src/widget-tool.ts +++ b/extensions/canvas/src/widget-tool.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; +import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveCanvasHostConfig } from "./config.js"; import { createCanvasDocument } from "./documents.js"; import { SHOW_WIDGET_REQUIRED_CLIENT_CAPS, ShowWidgetToolSchema } from "./tool-schema.js"; @@ -24,15 +25,6 @@ class WidgetToolInputError extends Error { } } -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function buildWidgetDocument(title: string, widgetCode: string): string { const isSvg = /^", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function escapeJsonScript(value: unknown): string { return JSON.stringify(value).replaceAll("<", "\\u003c"); } diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts index 8298baa567df..01a3fa2532bc 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.test.ts @@ -359,7 +359,7 @@ describe("discord live qa runtime", () => { it("renders a human-readable status reaction timeline artifact", () => { const html = testing.renderDiscordStatusReactionHtml({ - scenarioTitle: "Discord status reactions", + scenarioTitle: "Discord's status reactions", expectedSequence: ["👀", "🤔", "👍"], seenSequence: ["👀", "🤔"], snapshots: [ @@ -371,7 +371,7 @@ describe("discord live qa runtime", () => { ], }); - expect(html).toContain("Discord status reactions"); + expect(html).toContain("Discord's status reactions"); expect(html).toContain("Expected: 👀 → 🤔 → 👍"); expect(html).toContain("Seen: 👀 → 🤔"); }); @@ -380,13 +380,14 @@ describe("discord live qa runtime", () => { const html = testing.renderDiscordThreadReplyAttachmentHtml({ attachmentFilenames: [], expectedAttachmentFilename: "mantis-thread-report.md", - messageContent: "Mantis thread attachment reply", + messageContent: "Mantis' thread attachment reply", scenarioTitle: "Discord thread reply preserves filePath attachment", status: "fail", threadName: "mantis-thread-filepath-1234", }); expect(html).toContain("Attachment missing"); + expect(html).toContain("Mantis' thread attachment reply"); expect(html).toContain("No attachments on the SUT thread reply"); expect(html).toContain("mantis-thread-report.md"); }); diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts index 793c566de139..e447b70fa256 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts @@ -13,6 +13,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; import { chromium } from "playwright-core"; import { z } from "zod"; import { createQaArtifactRunId } from "../../artifact-run-id.js"; @@ -787,14 +788,6 @@ function collectSeenReactionSequence( return sequence; } -function escapeHtml(value: string) { - return value - .replace(/&/gu, "&") - .replace(//gu, ">") - .replace(/"/gu, """); -} - function renderDiscordStatusReactionHtml(params: { expectedSequence: readonly string[]; scenarioTitle: string; diff --git a/extensions/telegram/src/miniapp/page.test.ts b/extensions/telegram/src/miniapp/page.test.ts index 3c290f0c022a..c8e944985c6b 100644 --- a/extensions/telegram/src/miniapp/page.test.ts +++ b/extensions/telegram/src/miniapp/page.test.ts @@ -9,4 +9,10 @@ describe("renderTelegramMiniAppPage", () => { expect(html).toContain("new URL(payload.controlUiUrl)"); expect(html).not.toContain("const controlUiUrl ="); }); + + it("escapes the nonce for its quoted HTML attribute", () => { + const html = renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: `&<>"'` }); + + expect(html).toContain('nonce="&<>"'"'); + }); }); diff --git a/extensions/telegram/src/miniapp/page.ts b/extensions/telegram/src/miniapp/page.ts index 8a3a0ea3c3db..f6df3949afec 100644 --- a/extensions/telegram/src/miniapp/page.ts +++ b/extensions/telegram/src/miniapp/page.ts @@ -1,4 +1,6 @@ // Telegram Mini App bootstrap page. +import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; + export const TELEGRAM_MINIAPP_EXPIRED_MESSAGE = "This link expired. Reopen the dashboard from your bot chat."; @@ -62,20 +64,3 @@ export function renderTelegramMiniAppPage(params: { `; } - -function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, (char) => { - switch (char) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case '"': - return """; - default: - return "'"; - } - }); -} diff --git a/extensions/workspaces/src/scaffold.test.ts b/extensions/workspaces/src/scaffold.test.ts new file mode 100644 index 000000000000..3a427d2bfb0d --- /dev/null +++ b/extensions/workspaces/src/scaffold.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { scaffoldWorkspaceWidget } from "./scaffold.js"; + +const stateDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + stateDirs.splice(0).map((stateDir) => fs.rm(stateDir, { recursive: true, force: true })), + ); +}); + +describe("scaffoldWorkspaceWidget", () => { + it("escapes apostrophes in generated HTML text nodes", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-scaffold-")); + stateDirs.push(stateDir); + + const result = await scaffoldWorkspaceWidget({ + name: "team-status", + title: "Team's ", + createdBy: "agent's helper", + stateDir, + }); + const html = await fs.readFile(path.join(result.dir, "index.html"), "utf8"); + + expect(html).toContain("Team's <status>"); + expect(html).toContain("

Team's <status>

"); + expect(html).toContain(""); + }); +}); diff --git a/extensions/workspaces/src/scaffold.ts b/extensions/workspaces/src/scaffold.ts index 9973f384b71e..f68ea01f3f29 100644 --- a/extensions/workspaces/src/scaffold.ts +++ b/extensions/workspaces/src/scaffold.ts @@ -6,6 +6,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; import { validateWidgetManifest } from "./manifest.js"; export type WorkspaceScaffoldOptions = { @@ -119,14 +120,6 @@ Control UI through the document-bound workspace message bridge exposed as `; } -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); -} - function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 8d0761b1b7db..3cba4ff01a83 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -203,12 +203,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10639, + 10640, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5355, + 5356, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/ts-max-loc-baseline-v2.json b/scripts/ts-max-loc-baseline-v2.json index 1403d8316f6a..6ae5c192732a 100644 --- a/scripts/ts-max-loc-baseline-v2.json +++ b/scripts/ts-max-loc-baseline-v2.json @@ -87,7 +87,7 @@ "extensions/diagnostics-otel/src/service.ts": 4131, "extensions/diagnostics-prometheus/src/service.ts": 1068, "extensions/diffs/src/browser.ts": 585, - "extensions/diffs/src/render.ts": 793, + "extensions/diffs/src/render.ts": 785, "extensions/diffs/src/tool.ts": 529, "extensions/discord/src/actions/runtime.guild.ts": 737, "extensions/discord/src/actions/runtime.messaging.shared.ts": 697, @@ -274,7 +274,7 @@ "extensions/qa-lab/src/gateway-child.ts": 1606, "extensions/qa-lab/src/gateway-process-boundary.ts": 894, "extensions/qa-lab/src/lab-server.ts": 891, - "extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts": 1964, + "extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts": 1957, "extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts": 651, "extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts": 3787, "extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts": 2063, diff --git a/src/plugin-sdk/text-utility-runtime.test.ts b/src/plugin-sdk/text-utility-runtime.test.ts new file mode 100644 index 000000000000..247f63fc14bc --- /dev/null +++ b/src/plugin-sdk/text-utility-runtime.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { escapeHtml } from "./text-utility-runtime.js"; + +describe("escapeHtml", () => { + it("escapes five HTML-sensitive characters and existing entity markers", () => { + expect(escapeHtml(`&<>"'`)).toBe("&<>"'"); + expect(escapeHtml("already & escaped")).toBe("already &amp; escaped"); + }); +}); diff --git a/src/plugin-sdk/text-utility-runtime.ts b/src/plugin-sdk/text-utility-runtime.ts index aa9e2e25b21b..2f6ed1d72c4d 100644 --- a/src/plugin-sdk/text-utility-runtime.ts +++ b/src/plugin-sdk/text-utility-runtime.ts @@ -1,5 +1,15 @@ // Focused low-level text/runtime helpers used by bundled plugins. +/** Escapes text for safe insertion into HTML text and quoted attribute values. */ +export function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + export { CONFIG_DIR, clamp,