mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(plugin-sdk): add shared HTML escaping seam (#105975)
This commit is contained in:
committed by
GitHub
parent
6908e6a63b
commit
b9f0246765
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeLogicalPath(value: string): string {
|
||||
const normalized = value.replaceAll("\\", "/").replace(/^\/+/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
|
||||
@@ -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 = /^<svg/i.test(widgetCode);
|
||||
const bodyClass = isSvg ? ' class="svg-widget"' : "";
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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'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");
|
||||
});
|
||||
|
||||
@@ -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, ">")
|
||||
.replace(/"/gu, """);
|
||||
}
|
||||
|
||||
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="&<>"'"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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's <status></title>");
|
||||
expect(html).toContain("<h1>Team's <status></h1>");
|
||||
expect(html).toContain("<footer>Built by agent's helper</footer>");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("&<>"'");
|
||||
expect(escapeHtml("already & escaped")).toBe("already &amp; escaped");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user