mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix(outbound): strip internal runtime scaffolding
This commit is contained in:
@@ -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: {},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user