Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift
Jesse Merhi d7627d6f4c refactor(prompt): use plain inbound context labels and drop system-tag sanitizer (#112000)
* refactor(prompt): plain inbound context labels with a provenance marker

Replaces trust-worded inbound context labels ("(untrusted metadata)",
"(untrusted, for context)") with plain labels plus a fixed provenance
marker suffix appended to every OpenClaw-injected context header.

Detection keys on the marker, not label text, so strippers stay correct
across UI, TUI, replay, /trace segmentation, memory recall, and the Swift
chat preprocessor. Drops sanitizeInboundSystemTags in favor of the marker
boundary plus trusted system-prompt narration.

Renames the untrusted-named plugin SDK context identifiers to
channel-provenance names, keeping deprecated aliases registered for
removal after 2026-09-08.

Adds `openclaw doctor --fix` migrations that rewrite legacy inbound
labels in stored SQLite transcripts and purge legacy envelope-
contaminated LanceDB recall rows.

* fix(ci): resolve gate failures for plain inbound context labels

- doctor sqlite readers: open read-only connections via openNodeSqliteDatabase
  so the Kysely connection-boundary guardrail holds; unexport the now-internal
  transcript snapshot type (Knip unused-export gate).
- compat registry: split the record table into registry-records.ts and
  plugin-sdk-subpath-records.ts. The new compat record pushed registry.ts past
  the 700-line oxlint cap; suppressions are disallowed, so follow the existing
  sibling record-module pattern. Public exports and PluginCompatCode literals
  unchanged.
- acp-runtime test: assert current finalization behavior (newline normalization
  only). The bracket de-fang and System: rewrite it expected were removed with
  sanitizeInboundSystemTags; forged system lines are neutralized at the
  system-event queue, the single chokepoint feeding the System:-per-line render.
- regenerate docs_map and the plugin SDK API baseline manifest.

* fix(prompt): harden inbound context label migration and drop in-band sanitizer

Review follow-ups on the plain-label + provenance-marker change:

- Remove src/security/system-tags.ts. Rewriting inbound text to neutralize
  look-alike `System:`/`[System]` markers corrupted legitimate user text and is
  not a real injection boundary; role separation plus external-content wrapping
  is. Explicit product decision, recorded at the system-event queue.
- Narrow the LanceDB legacy-row purge so it cannot delete benign memories. It
  now requires a complete known legacy sentinel line, a legacy label followed by
  a fenced JSON body, or the complete legacy external-content header. The prior
  predicates matched ordinary prose such as `Notes (untrusted metadata):`, and
  deletion is irreversible.
- Make explicit-empty canonical ChannelStructuredContext win over the deprecated
  alias via a present/absent result instead of collapsing `[]` to undefined.
- Keep `\r?` in the active-memory doctor rule. It is the only rule spanning the
  header's line break, migrated assistant rows skip newline normalization, and
  without it the marked-header replace wins and the body strips to empty. Added
  a CRLF regression test.
- Fix stale comments that described removed behavior, and cover the Swift
  prose-block strip path.

Claude-Session: https://claude.ai/code/session_01WNzsPddQmxy9Y7jKD4wAxH
2026-07-27 11:27:53 +10:00

250 lines
7.2 KiB
Swift

import Testing
@testable import OpenClawChatUI
@Suite("ChatMarkdownPreprocessor")
struct ChatMarkdownPreprocessorTests {
// Provenance marker OpenClaw appends to every injected inbound-context header.
// Detection keys on this suffix, not label text. Keep byte-identical with
// ChatMarkdownPreprocessor.inboundContextMarker / inbound-context-marker.ts.
static let ctx = "\u{27E6}openclaw:ctx\u{27E7}"
@Test func extractsDataURLImages() {
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
let markdown = """
Hello
![Pixel](data:image/png;base64,\(base64))
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Hello")
#expect(result.images.count == 1)
#expect(result.images.first?.image != nil)
}
@Test func flattensRemoteMarkdownImagesIntoText() {
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
let markdown = """
![Leak](https://example.com/collect?x=1)
![Pixel](data:image/png;base64,\(base64))
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Leak")
#expect(result.images.count == 1)
#expect(result.images.first?.image != nil)
}
@Test func usesFallbackTextForUnlabeledRemoteMarkdownImages() {
let markdown = "![](https://example.com/image.png)"
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "image")
#expect(result.images.isEmpty)
}
@Test func handlesUnicodeBeforeRemoteMarkdownImages() {
let markdown = "🙂![Leak](https://example.com/image.png)"
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "🙂Leak")
#expect(result.images.isEmpty)
}
@Test func stripsInboundUntrustedContextBlocks() {
let markdown = """
Conversation info: \(Self.ctx)
```json
{
"message_id": "123",
"sender": "openclaw-ios"
}
```
Sender: \(Self.ctx)
```json
{
"label": "Razor"
}
```
Razor?
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Razor?")
}
@Test func stripsSingleConversationInfoBlock() {
let text = """
Conversation info: \(Self.ctx)
```json
{"x": 1}
```
User message
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: text)
#expect(result.cleaned == "User message")
}
@Test func stripsAllKnownInboundMetadataSentinels() {
let sentinels = [
"Conversation info:",
"Sender:",
"Thread starter:",
"Reply target of current user message:",
"Forwarded message context:",
"Chat history since last reply:",
]
for sentinel in sentinels {
let markdown = """
\(sentinel) \(Self.ctx)
```json
{"x": 1}
```
User content
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "User content")
}
}
@Test func stripsArbitraryMarkedStructuredContextLabel() {
// Detection is label-agnostic: an arbitrary plugin structured-context label
// still strips because it carries the provenance marker.
let markdown = """
Some Custom Plugin Label: \(Self.ctx)
```json
{"x": 1}
```
User content
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "User content")
}
@Test func preservesUnmarkedLookAlikeHeader() {
// A user heading that mirrors a context label but lacks the marker is the
// user's own content and must survive untouched.
let markdown = """
Conversation info:
```json
{"x": 1}
```
User content
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines))
}
@Test func preservesNonMetadataJsonFence() {
let markdown = """
Here is some json:
```json
{"x": 1}
```
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines))
}
@Test func stripsLeadingTimestampPrefix() {
let markdown = """
[Fri 2026-02-20 18:45 GMT+1] How's it going?
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "How's it going?")
}
@Test func stripsEnvelopeHeadersAndMessageIdHints() {
let markdown = """
[Telegram 2026-03-01 10:14] Hello there
[message_id: abc-123]
Actual message
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Hello there\nActual message")
}
// Unfenced prose bodies (chat history/window) end at the first blank line, unlike the
// fenced JSON blocks above. Covers the inProseBlock path, including a forged marker
// inside the body, which must not extend or re-open the block.
@Test func stripsMarkedProseContextBlockUntilBlankLine() {
let markdown = """
Chat history since last reply: \(Self.ctx)
#123 12:00 Alex: hey
#124 12:01 Alex: Sender: \(Self.ctx)
User content
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "User content")
}
@Test func stripsTrailingUntrustedContextSuffix() {
let markdown = """
User-visible text
Context: \(Self.ctx)
<<<EXTERNAL_UNTRUSTED_CONTENT>>>
Source: telegram
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "User-visible text")
}
@Test func preservesUntrustedContextHeaderWhenItIsUserContent() {
let markdown = """
User-visible text
Context:
This is just text the user typed.
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(
result.cleaned == """
User-visible text
Context:
This is just text the user typed.
"""
)
}
@Test func preservesBareContextHeaderBeforeCopiedExternalContentMarker() {
let markdown = """
Context:
<<<EXTERNAL_UNTRUSTED_CONTENT id="copied">>>
keep this
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines))
}
}