Files
Moltbot/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift
Mars a4e7e952e1 fix(ui): strip injected inbound metadata from user messages in history (#22142)
* fix(ui): strip injected inbound metadata from user messages in history

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.

* fix(ui): strip inbound metadata for user messages in display path

* test: fix discord component send test spread typing

* fix: strip inbound metadata from mac chat history decode

* fix: align Swift metadata stripping parser with TS implementation

* fix: normalize line endings in inbound metadata stripper

* chore: document Swift/TS metadata-sentinel ownership

* chore: update changelog for inbound metadata strip fix

* changelog: credit Mellowambience for 22142

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-20 17:35:13 -08:00

124 lines
4.8 KiB
Swift

import Foundation
enum ChatMarkdownPreprocessor {
// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
// (`INBOUND_META_SENTINELS`), and extend parser expectations in
// `ChatMarkdownPreprocessorTests` when sentinels change.
private static let inboundContextHeaders = [
"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):",
]
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: OpenClawPlatformImage?
}
struct Result {
let cleaned: String
let images: [InlineImage]
}
static func preprocess(markdown raw: String) -> Result {
let withoutContextBlocks = self.stripInboundContextBlocks(raw)
let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks)
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return Result(cleaned: self.normalize(withoutTimestamps), images: [])
}
let ns = withoutTimestamps as NSString
let matches = re.matches(
in: withoutTimestamps,
range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) }
var images: [InlineImage] = []
var cleaned = withoutTimestamps
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: OpenClawPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return OpenClawPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
return Result(cleaned: self.normalize(cleaned), images: images.reversed())
}
private static func stripInboundContextBlocks(_ raw: String) -> String {
guard self.inboundContextHeaders.contains(where: raw.contains) else {
return raw
}
let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n")
var outputLines: [String] = []
var inMetaBlock = false
var inFencedJson = false
for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) {
let currentLine = String(line)
if !inMetaBlock && self.inboundContextHeaders.contains(where: currentLine.hasPrefix) {
inMetaBlock = true
inFencedJson = false
continue
}
if inMetaBlock {
if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
inFencedJson = true
continue
}
if inFencedJson {
if currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```" {
inMetaBlock = false
inFencedJson = false
}
continue
}
if currentLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
continue
}
inMetaBlock = false
}
outputLines.append(currentLine)
}
return outputLines.joined(separator: "\n").replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression)
}
private static func stripPrefixedTimestamps(_ raw: String) -> String {
let pattern = #"(?m)^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(?:GMT|UTC)[+-]?\d{0,2}\]\s*"#
return raw.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
private static func normalize(_ raw: String) -> String {
var output = raw
output = output.replacingOccurrences(of: "\r\n", with: "\n")
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
return output.trimmingCharacters(in: .whitespacesAndNewlines)
}
}