fix(outbound): strip internal runtime scaffolding

This commit is contained in:
Peter Steinberger
2026-04-28 20:34:35 +01:00
parent c5c08c074a
commit c2d31a5e59
6 changed files with 308 additions and 7 deletions

View File

@@ -72,6 +72,26 @@ describe("discordOutbound", () => {
});
});
it("sanitizes internal runtime scaffolding before Discord delivery", () => {
expect(
discordOutbound.sanitizeText?.({
text: "<previous_response>null</previous_response>visible",
payload: { text: "<previous_response>null</previous_response>visible" },
}),
).toBe("visible");
});
it("preserves Discord-native angle markup while stripping internal scaffolding", () => {
expect(
discordOutbound.sanitizeText?.({
text: "soon <t:1710000000:R> run </deploy:123> <previous_response>null</previous_response>",
payload: {
text: "soon <t:1710000000:R> run </deploy:123> <previous_response>null</previous_response>",
},
}),
).toBe("soon <t:1710000000:R> run </deploy:123> ");
});
it("forwards explicit formatting options to Discord text sends", async () => {
await discordOutbound.sendText?.({
cfg: {},

View File

@@ -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: {

View File

@@ -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:
"<previous_response>null</previous_response><system-reminder>hidden</system-reminder>visible",
});
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: "<previous_response>null</previous_response>visible",
});
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: "<previous_response>null</previous_response>visible",
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(

View File

@@ -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<string, unknown> = {};
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;

View File

@@ -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('<a href="https://example.com">link</a>')).toBe("link");
});
it("strips known internal runtime scaffolding tags including underscore names", () => {
expect(sanitizeForPlainText("ok <previous_response>null</previous_response> done")).toBe(
"ok done",
);
expect(sanitizeForPlainText("ok <system-reminder>use todos</system-reminder> done")).toBe(
"ok done",
);
});
it("preserves angle-bracket autolinks", () => {
expect(sanitizeForPlainText("See <https://example.com/path?q=1> now")).toBe(
"See https://example.com/path?q=1 now",
@@ -92,3 +101,26 @@ describe("sanitizeForPlainText", () => {
expect(sanitizeForPlainText("a<br><br><br><br>b")).toBe("a\n\nb");
});
});
describe("stripInternalRuntimeScaffolding", () => {
it("removes closed, self-closing, and stray internal runtime tags", () => {
expect(
stripInternalRuntimeScaffolding(
[
"before",
"<system-reminder>internal hint</system-reminder>",
"<previous_response>null</previous_response>",
"<system-reminder />",
"<previous_response>",
"visible",
].join("\n"),
),
).toBe(["before", "", "", "", "", "visible"].join("\n"));
});
it("does not strip arbitrary XML-like user content", () => {
expect(stripInternalRuntimeScaffolding("<note>keep this</note>")).toBe(
"<note>keep this</note>",
);
});
});

View File

@@ -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[^>]*>(.*?)<\/li>/gi, "• $1\n")
// Strip remaining HTML tags (require tag-like structure: <word...>)
.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")
);