From c2d31a5e59c1a3d2f4c04ede8dfbda4980d8c755 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 20:34:35 +0100 Subject: [PATCH] fix(outbound): strip internal runtime scaffolding --- .../discord/src/outbound-adapter.test.ts | 20 +++ extensions/discord/src/outbound-adapter.ts | 14 ++ src/infra/outbound/deliver.test.ts | 169 ++++++++++++++++++ src/infra/outbound/deliver.ts | 52 +++++- src/infra/outbound/sanitize-text.test.ts | 34 +++- src/infra/outbound/sanitize-text.ts | 26 ++- 6 files changed, 308 insertions(+), 7 deletions(-) diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index f4dfca39357..6abe33663ad 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -72,6 +72,26 @@ describe("discordOutbound", () => { }); }); + it("sanitizes internal runtime scaffolding before Discord delivery", () => { + expect( + discordOutbound.sanitizeText?.({ + text: "nullvisible", + payload: { text: "nullvisible" }, + }), + ).toBe("visible"); + }); + + it("preserves Discord-native angle markup while stripping internal scaffolding", () => { + expect( + discordOutbound.sanitizeText?.({ + text: "soon run null", + payload: { + text: "soon run null", + }, + }), + ).toBe("soon run "); + }); + it("forwards explicit formatting options to Discord text sends", async () => { await discordOutbound.sendText?.({ cfg: {}, diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index d790b141c42..f4ceb58ccea 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -26,6 +26,19 @@ import { } from "./outbound-send-context.js"; export const DISCORD_TEXT_CHUNK_LIMIT = 2000; +const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE = + /<\s*(system-reminder|previous_response)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi; +const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE = + /<\s*(?:system-reminder|previous_response)\b[^>]*\/\s*>/gi; +const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE = + /<\s*\/?\s*(?:system-reminder|previous_response)\b[^>]*>/gi; + +function stripDiscordInternalRuntimeScaffolding(text: string): string { + return text + .replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE, "") + .replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE, "") + .replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE, ""); +} type DiscordThreadBindingsModule = typeof import("./monitor/thread-bindings.js"); @@ -97,6 +110,7 @@ export const discordOutbound: ChannelOutboundAdapter = { maxLines: ctx?.formatting?.maxLinesPerMessage, }), textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT, + sanitizeText: ({ text }) => stripDiscordInternalRuntimeScaffolding(text), pollMaxOptions: 10, normalizePayload: ({ payload }) => normalizeDiscordApprovalPayload(payload), presentationCapabilities: { diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 26110e9a482..0f7c3af55d8 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -706,6 +706,175 @@ describe("deliverOutboundPayloads", () => { expect(sendText).not.toHaveBeenCalled(); }); + it("strips internal runtime scaffolding added by message_sending hooks before delivery", async () => { + hookMocks.runner.hasHooks.mockImplementation( + (hookName?: string) => hookName === "message_sending", + ); + hookMocks.runner.runMessageSending.mockResolvedValue({ + content: + "nullhiddenvisible", + }); + const sendText = vi.fn().mockResolvedValue({ + channel: "matrix" as const, + messageId: "clean", + roomId: "!room", + }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + sendText, + }, + }), + }, + ]), + ); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room", + payloads: [{ text: "original" }], + }); + + expect(sendText).toHaveBeenCalledWith(expect.objectContaining({ text: "visible" })); + }); + + it("strips internal runtime scaffolding before adapter payload normalization copies text", async () => { + hookMocks.runner.hasHooks.mockImplementation( + (hookName?: string) => hookName === "message_sending", + ); + hookMocks.runner.runMessageSending.mockResolvedValue({ + content: "nullvisible", + }); + const sendPayload = vi.fn().mockResolvedValue({ + channel: "matrix" as const, + messageId: "clean", + roomId: "!room", + }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + normalizePayload: ({ payload }) => ({ + ...payload, + channelData: { copiedText: payload.text }, + }), + sendText: vi.fn(), + sendMedia: vi.fn(), + sendPayload, + }, + }), + }, + ]), + ); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room", + payloads: [{ text: "original" }], + }); + + expect(sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + text: "visible", + channelData: { copiedText: "visible" }, + }), + }), + ); + }); + + it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => { + const sendPayload = vi.fn().mockResolvedValue({ + channel: "matrix" as const, + messageId: "clean-nested", + roomId: "!room", + }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + renderPresentation: ({ payload }) => ({ + ...payload, + channelData: { + renderedText: payload.text, + renderedBlocks: [{ text: payload.text }], + }, + }), + normalizePayload: ({ payload }) => { + const text = payload.text ?? ""; + return { + ...payload, + channelData: { + ...payload.channelData, + normalizedText: text, + }, + interactive: { + blocks: [{ type: "text", text }], + }, + }; + }, + sendText: vi.fn(), + sendMedia: vi.fn(), + sendPayload, + }, + }), + }, + ]), + ); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room", + payloads: [ + { + text: "nullvisible", + presentation: { + title: "Title", + blocks: [], + }, + }, + ], + }); + + expect(JSON.stringify(sendPayload.mock.calls[0]?.[0]?.payload)).not.toContain( + "previous_response", + ); + expect(sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + text: "visible", + channelData: { + renderedText: "visible", + renderedBlocks: [{ text: "visible" }], + normalizedText: "visible", + }, + interactive: { + blocks: [{ type: "text", text: "visible" }], + }, + }), + }), + ); + }); + it("runs adapter after-delivery hooks with the payload delivery results", async () => { const afterDeliverPayload = vi.fn(); setActivePluginRegistry( diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 4ea06eac070..8cab39103e9 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -55,6 +55,7 @@ import { type OutboundPayloadPlan, } from "./payloads.js"; import { createReplyToDeliveryPolicy } from "./reply-policy.js"; +import { stripInternalRuntimeScaffolding } from "./sanitize-text.js"; import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js"; import type { OutboundSessionContext } from "./session-context.js"; import type { OutboundChannel } from "./targets.js"; @@ -478,7 +479,7 @@ function normalizePayloadsForChannelDelivery( ): ReplyPayload[] { const normalizedPayloads: ReplyPayload[] = []; for (const payload of projectOutboundPayloadPlanForDelivery(plan)) { - let sanitizedPayload = payload; + let sanitizedPayload = stripInternalRuntimeScaffoldingFromPayload(payload); if (handler.sanitizeText && sanitizedPayload.text) { if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) { sanitizedPayload = { @@ -491,7 +492,9 @@ function normalizePayloadsForChannelDelivery( ? handler.normalizePayload(sanitizedPayload) : sanitizedPayload; const normalized = normalizedPayload - ? normalizeEmptyPayloadForDelivery(normalizedPayload) + ? normalizeEmptyPayloadForDelivery( + stripInternalRuntimeScaffoldingFromPayload(normalizedPayload), + ) : null; if (normalized) { normalizedPayloads.push(normalized); @@ -500,6 +503,43 @@ function normalizePayloadsForChannelDelivery( return normalizedPayloads; } +function stripInternalRuntimeScaffoldingFromValue(value: unknown): unknown { + if (typeof value === "string") { + return stripInternalRuntimeScaffolding(value); + } + if (Array.isArray(value)) { + let changed = false; + const next = value.map((entry) => { + const stripped = stripInternalRuntimeScaffoldingFromValue(entry); + changed ||= stripped !== entry; + return stripped; + }); + return changed ? next : value; + } + if (!value || typeof value !== "object") { + return value; + } + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + return value; + } + let changed = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const stripped = stripInternalRuntimeScaffoldingFromValue(entry); + changed ||= stripped !== entry; + next[key] = stripped; + } + return changed ? next : value; +} + +function stripInternalRuntimeScaffoldingFromPayload(payload: ReplyPayload): ReplyPayload { + const stripped = stripInternalRuntimeScaffoldingFromValue(payload); + return stripped && typeof stripped === "object" && !Array.isArray(stripped) + ? (stripped as ReplyPayload) + : payload; +} + function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { return summarizeOutboundPayloadForTransport(payload); } @@ -1032,12 +1072,16 @@ async function deliverOutboundPayloadsCore( if (hookResult.cancelled) { continue; } - const renderedPayload = await renderPresentationForDelivery(handler, hookResult.payload); + const renderedPayload = stripInternalRuntimeScaffoldingFromPayload( + await renderPresentationForDelivery(handler, hookResult.payload), + ); const normalizedEffectivePayload = handler.normalizePayload ? handler.normalizePayload(renderedPayload) : renderedPayload; const effectivePayload = normalizedEffectivePayload - ? normalizeEmptyPayloadForDelivery(normalizedEffectivePayload) + ? normalizeEmptyPayloadForDelivery( + stripInternalRuntimeScaffoldingFromPayload(normalizedEffectivePayload), + ) : null; if (!effectivePayload) { continue; diff --git a/src/infra/outbound/sanitize-text.test.ts b/src/infra/outbound/sanitize-text.test.ts index f43ae615246..8842088b761 100644 --- a/src/infra/outbound/sanitize-text.test.ts +++ b/src/infra/outbound/sanitize-text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sanitizeForPlainText } from "./sanitize-text.js"; +import { sanitizeForPlainText, stripInternalRuntimeScaffolding } from "./sanitize-text.js"; // --------------------------------------------------------------------------- // sanitizeForPlainText @@ -63,6 +63,15 @@ describe("sanitizeForPlainText", () => { expect(sanitizeForPlainText('link')).toBe("link"); }); + it("strips known internal runtime scaffolding tags including underscore names", () => { + expect(sanitizeForPlainText("ok null done")).toBe( + "ok done", + ); + expect(sanitizeForPlainText("ok use todos done")).toBe( + "ok done", + ); + }); + it("preserves angle-bracket autolinks", () => { expect(sanitizeForPlainText("See now")).toBe( "See https://example.com/path?q=1 now", @@ -92,3 +101,26 @@ describe("sanitizeForPlainText", () => { expect(sanitizeForPlainText("a



b")).toBe("a\n\nb"); }); }); + +describe("stripInternalRuntimeScaffolding", () => { + it("removes closed, self-closing, and stray internal runtime tags", () => { + expect( + stripInternalRuntimeScaffolding( + [ + "before", + "internal hint", + "null", + "", + "", + "visible", + ].join("\n"), + ), + ).toBe(["before", "", "", "", "", "visible"].join("\n")); + }); + + it("does not strip arbitrary XML-like user content", () => { + expect(stripInternalRuntimeScaffolding("keep this")).toBe( + "keep this", + ); + }); +}); diff --git a/src/infra/outbound/sanitize-text.ts b/src/infra/outbound/sanitize-text.ts index 36b0bd3e254..9bb68b76051 100644 --- a/src/infra/outbound/sanitize-text.ts +++ b/src/infra/outbound/sanitize-text.ts @@ -11,6 +11,28 @@ * @see https://github.com/openclaw/openclaw/issues/18558 */ +const INTERNAL_RUNTIME_SCAFFOLDING_TAGS = ["system-reminder", "previous_response"] as const; +const INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN = INTERNAL_RUNTIME_SCAFFOLDING_TAGS.join("|"); +const INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE = new RegExp( + `<\\s*(${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*>[\\s\\S]*?<\\s*\\/\\s*\\1\\s*>`, + "gi", +); +const INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE = new RegExp( + `<\\s*(?:${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*\\/\\s*>`, + "gi", +); +const INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE = new RegExp( + `<\\s*\\/?\\s*(?:${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*>`, + "gi", +); + +export function stripInternalRuntimeScaffolding(text: string): string { + return text + .replace(INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE, "") + .replace(INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE, "") + .replace(INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE, ""); +} + /** * Convert common HTML tags to their plain-text/lightweight-markup equivalents * and strip anything that remains. @@ -21,7 +43,7 @@ */ export function sanitizeForPlainText(text: string): string { return ( - text + stripInternalRuntimeScaffolding(text) // Preserve angle-bracket autolinks as plain URLs before tag stripping. .replace(/<((?:https?:\/\/|mailto:)[^<>\s]+)>/gi, "$1") // Line breaks @@ -41,7 +63,7 @@ export function sanitizeForPlainText(text: string): string { // List items → bullet points .replace(/]*>(.*?)<\/li>/gi, "• $1\n") // Strip remaining HTML tags (require tag-like structure: ) - .replace(/<\/?[a-z][a-z0-9]*\b[^>]*>/gi, "") + .replace(/<\/?[a-z][a-z0-9_-]*\b[^>]*>/gi, "") // Collapse 3+ consecutive newlines into 2 .replace(/\n{3,}/g, "\n\n") );