From cb37d9ee72f8fef5ce0f04958d52a3176bda34c0 Mon Sep 17 00:00:00 2001 From: Mars <40958792+Mellowambience@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:36:19 -0500 Subject: [PATCH] fix(ui): strip injected inbound metadata from user messages in history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #21106 Fixes #21109 Fixes #22116 OpenClaw prepends structured metadata blocks ("Conversation info", "Sender:", reply-context) to user messages before sending them to the LLM. These blocks are intentionally AI-context-only and must never reach the chat history that users see. Root cause: `buildInboundUserContextPrefix` in `inbound-meta.ts` prepends the blocks directly to the stored user message content string, so they are persisted verbatim and later shown in webchat, TUI, and every other rendering surface. Fix: • `src/auto-reply/reply/strip-inbound-meta.ts` — new utility with a 6-sentinel fast-path strip (zero-alloc on miss) + 9-test suite. • `src/tui/tui-session-actions.ts` — wraps `chatLog.addUser(...)` with `stripInboundMetadata()` so the TUI never stores the prefix. • `ui/src/ui/chat/message-normalizer.ts` — strips user-role text content items during normalisation so webchat renders clean messages. --- .../reply/strip-inbound-meta.test.ts | 80 +++++++++++++++++ src/auto-reply/reply/strip-inbound-meta.ts | 89 +++++++++++++++++++ src/tui/tui-session-actions.ts | 3 +- ui/src/ui/chat/message-normalizer.ts | 11 +++ 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/auto-reply/reply/strip-inbound-meta.test.ts create mode 100644 src/auto-reply/reply/strip-inbound-meta.ts diff --git a/src/auto-reply/reply/strip-inbound-meta.test.ts b/src/auto-reply/reply/strip-inbound-meta.test.ts new file mode 100644 index 00000000000..29d52b14b40 --- /dev/null +++ b/src/auto-reply/reply/strip-inbound-meta.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { stripInboundMetadata } from "./strip-inbound-meta.js"; + +const CONV_BLOCK = `Conversation info (untrusted metadata): +\`\`\`json +{ + "message_id": "msg-abc", + "sender": "+1555000" +} +\`\`\``; + +const SENDER_BLOCK = `Sender (untrusted metadata): +\`\`\`json +{ + "label": "Alice", + "name": "Alice" +} +\`\`\``; + +const REPLY_BLOCK = `Replied message (untrusted, for context): +\`\`\`json +{ + "body": "What time is it?" +} +\`\`\``; + +describe("stripInboundMetadata", () => { + it("fast-path: returns same string when no sentinels present", () => { + const text = "Hello, how are you?"; + expect(stripInboundMetadata(text)).toBe(text); + }); + + it("fast-path: returns empty string unchanged", () => { + expect(stripInboundMetadata("")).toBe(""); + }); + + it("strips a single Conversation info block", () => { + const input = `${CONV_BLOCK}\n\nWhat is the weather today?`; + expect(stripInboundMetadata(input)).toBe("What is the weather today?"); + }); + + it("strips multiple chained metadata blocks", () => { + const input = `${CONV_BLOCK}\n\n${SENDER_BLOCK}\n\nCan you help me?`; + expect(stripInboundMetadata(input)).toBe("Can you help me?"); + }); + + it("strips Replied message block leaving user message intact", () => { + const input = `${REPLY_BLOCK}\n\nGot it, thanks!`; + expect(stripInboundMetadata(input)).toBe("Got it, thanks!"); + }); + + it("strips all six known sentinel types", () => { + const sentinels = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", + ]; + for (const sentinel of sentinels) { + const input = `${sentinel}\n\`\`\`json\n{"x": 1}\n\`\`\`\n\nUser message`; + expect(stripInboundMetadata(input)).toBe("User message"); + } + }); + + it("handles metadata block with no user text after it", () => { + expect(stripInboundMetadata(CONV_BLOCK)).toBe(""); + }); + + it("preserves message containing json fences that are not metadata", () => { + const text = `Here is my code:\n\`\`\`json\n{"key": "value"}\n\`\`\``; + expect(stripInboundMetadata(text)).toBe(text); + }); + + it("preserves leading newlines in user content after stripping", () => { + const input = `${CONV_BLOCK}\n\nActual message`; + expect(stripInboundMetadata(input)).toBe("Actual message"); + }); +}); diff --git a/src/auto-reply/reply/strip-inbound-meta.ts b/src/auto-reply/reply/strip-inbound-meta.ts new file mode 100644 index 00000000000..291da7fa8b4 --- /dev/null +++ b/src/auto-reply/reply/strip-inbound-meta.ts @@ -0,0 +1,89 @@ +/** + * Strips OpenClaw-injected inbound metadata blocks from a user-role message + * text before it is displayed in any UI surface (TUI, webchat, macOS app). + * + * Background: `buildInboundUserContextPrefix` in `inbound-meta.ts` prepends + * structured metadata blocks (Conversation info, Sender info, reply context, + * etc.) directly to the stored user message content so the LLM can access + * them. These blocks are AI-facing only and must never surface in user-visible + * chat history. + */ + +/** + * Sentinel strings that identify the start of an injected metadata block. + * Must stay in sync with `buildInboundUserContextPrefix` in `inbound-meta.ts`. + */ +const INBOUND_META_SENTINELS = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", +] as const; + +// Pre-compiled fast-path regex — avoids line-by-line parse when no blocks present. +const SENTINEL_FAST_RE = new RegExp( + INBOUND_META_SENTINELS.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), +); + +/** + * Remove all injected inbound metadata prefix blocks from `text`. + * + * Each block has the shape: + * + * ``` + * + * ```json + * { … } + * ``` + * ``` + * + * Returns the original string reference unchanged when no metadata is present + * (fast path — zero allocation). + */ +export function stripInboundMetadata(text: string): string { + if (!text || !SENTINEL_FAST_RE.test(text)) { + return text; + } + + const lines = text.split("\n"); + const result: string[] = []; + let inMetaBlock = false; + let inFencedJson = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect start of a metadata block. + if (!inMetaBlock && INBOUND_META_SENTINELS.some((s) => line.startsWith(s))) { + inMetaBlock = true; + inFencedJson = false; + continue; + } + + if (inMetaBlock) { + if (!inFencedJson && line.trim() === "```json") { + inFencedJson = true; + continue; + } + if (inFencedJson) { + if (line.trim() === "```") { + inMetaBlock = false; + inFencedJson = false; + } + continue; + } + // Blank separator lines between consecutive blocks are dropped. + if (line.trim() === "") { + continue; + } + // Unexpected non-blank line outside a fence — treat as user content. + inMetaBlock = false; + } + + result.push(line); + } + + return result.join("\n").trimStart(); +} diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 82c6a795dd9..3adc3333699 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -8,6 +8,7 @@ import { import type { ChatLog } from "./components/chat-log.js"; import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; +import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; type SessionActionContext = { @@ -326,7 +327,7 @@ export function createSessionActions(context: SessionActionContext) { if (message.role === "user") { const text = extractTextFromMessage(message); if (text) { - chatLog.addUser(text); + chatLog.addUser(stripInboundMetadata(text)); } continue; } diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index fbc867f664e..a293c4bd20a 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -3,6 +3,7 @@ */ import type { NormalizedMessage, MessageContentItem } from "../types/chat-types.ts"; +import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; /** * Normalize a raw message object into a consistent structure. @@ -50,6 +51,16 @@ export function normalizeMessage(message: unknown): NormalizedMessage { const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now(); const id = typeof m.id === "string" ? m.id : undefined; + // Strip AI-injected metadata prefix blocks from user messages before display. + if (role === "user" || role === "User") { + content = content.map((item) => { + if (item.type === "text" && typeof item.text === "string") { + return { ...item, text: stripInboundMetadata(item.text) }; + } + return item; + }); + } + return { role, content, timestamp, id }; }