feat(plugin-sdk): add shared HTML escaping seam (#105975)

This commit is contained in:
Peter Steinberger
2026-07-12 22:33:53 -07:00
committed by GitHub
parent 6908e6a63b
commit b9f0246765
15 changed files with 75 additions and 70 deletions
@@ -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
+1
View File
@@ -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 |
+1 -10
View File
@@ -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 `<!doctype html><html><body style="margin:0;background:#e5e7eb;"><object data="${escaped}" type="application/pdf" style="width:100%;height:100vh;border:0;"><iframe src="${escaped}" style="width:100%;height:100vh;border:0;"></iframe><p style="padding:16px;font:14px system-ui,sans-serif;">Unable to render PDF preview. <a href="${escaped}" target="_blank" rel="noopener noreferrer">Open PDF</a>.</p></object></body></html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeLogicalPath(value: string): string {
const normalized = value.replaceAll("\\", "/").replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
+1 -9
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function buildWidgetDocument(title: string, widgetCode: string): string {
const isSvg = /^<svg/i.test(widgetCode);
const bodyClass = isSvg ? ' class="svg-widget"' : "";
+1 -9
View File
@@ -2,6 +2,7 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime";
import { normalizeDiffFontSize, normalizeDiffLineSpacing } from "./config.js";
import {
collectDiffPayloadLanguageHints,
@@ -35,15 +36,6 @@ function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeJsonScript(value: unknown): string {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
@@ -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&#39;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&#39; thread attachment reply");
expect(html).toContain("No attachments on the SUT thread reply");
expect(html).toContain("mantis-thread-report.md");
});
@@ -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, "&amp;")
.replace(/</gu, "&lt;")
.replace(/>/gu, "&gt;")
.replace(/"/gu, "&quot;");
}
function renderDiscordStatusReactionHtml(params: {
expectedSequence: readonly string[];
scenarioTitle: string;
@@ -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="&amp;&lt;&gt;&quot;&#39;"');
});
});
+2 -17
View File
@@ -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: {
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&#39;";
}
});
}
@@ -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 <status>",
createdBy: "agent's helper",
stateDir,
});
const html = await fs.readFile(path.join(result.dir, "index.html"), "utf8");
expect(html).toContain("<title>Team&#39;s &lt;status&gt;</title>");
expect(html).toContain("<h1>Team&#39;s &lt;status&gt;</h1>");
expect(html).toContain("<footer>Built by agent&#39;s helper</footer>");
});
});
+1 -8
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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,
@@ -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("&amp;&lt;&gt;&quot;&#39;");
expect(escapeHtml("already &amp; escaped")).toBe("already &amp;amp; escaped");
});
});
+10
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export {
CONFIG_DIR,
clamp,