Skip to content

fix(sanitizer): strip new runtime-context preface headers from outbound text#72969

Closed
jhsmith409 wants to merge 2 commits intoopenclaw:mainfrom
jhsmith409:fix/strip-runtime-context-preface
Closed

fix(sanitizer): strip new runtime-context preface headers from outbound text#72969
jhsmith409 wants to merge 2 commits intoopenclaw:mainfrom
jhsmith409:fix/strip-runtime-context-preface

Conversation

@jhsmith409
Copy link
Copy Markdown
Contributor

Summary

Outbound sanitizer extension that catches the new runtime-context preface headers introduced by the v2026.4.24 / #71761 redesign, so models that ignore the privacy-notice line cannot leak the preface into user-visible replies.

Closes the visible half of #72386 (Telegram + Qwen-class vLLM model echoing the preface verbatim). The structural fix — making the Pi conversion path treat openclaw.runtime-context custom messages as non-user-role context — remains a separate, larger change and is tracked in that issue.

What's leaking

buildRuntimeContextMessageContent in src/agents/pi-embedded-runner/run/runtime-context-prompt.ts builds custom-message bodies that begin with one of two human-readable headers, followed by a privacy-notice line:

OpenClaw runtime context for the immediately preceding user message.
This context is runtime-generated, not user-authored. Keep internal details private.

…runtime context payload…

or:

OpenClaw runtime event.
This context is runtime-generated, not user-authored. Keep internal details private.

…runtime context payload…

queueRuntimeContextForNextTurn queues that body as customType: "openclaw.runtime-context", display: false, deliverAs: "nextTurn". The pinned Pi runtime (@mariozechner/pi-coding-agent@0.70.2) converts those custom messages into role: "user" LLM turns, so the model reads the entire body — including the human-readable preface.

Smaller open-weight models do not reliably honor the Keep internal details private line and echo the preface back into their visible reply. Reproduced on v2026.4.24 and confirmed still present in v2026.4.25 (clawsweeper bot review on #72386 walks through the same source path with file:line citations).

Why a sanitizer (and not a structural fix) here

The bot's review on #72386 explicitly recommends the structural fix as the right long-term answer. That requires either:

  • a new Pi capability for non-user-role runtime context (cross-repo work in @mariozechner/pi-coding-agent), or
  • routing runtime-context through the system-prompt assembly path instead of sendCustomMessage, which has wider behavioral implications.

In the meantime, the existing stripInternalRuntimeContext already handles two prior runtime-context shapes (the delimited <<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>> block and the legacy OpenClaw runtime context (internal): header). Extending it with the two new headers is the same pattern, single-file, low-risk, and addresses the user-visible regression today. #71847 just shipped a similar outbound-strip for inbound metadata envelopes.

Implementation

src/agents/internal-runtime-context.ts:

  • New constants for the two new preface headers and the privacy-notice line.
  • findLineStartIndex, findRuntimeContextPrefaceEnd helpers — explicit substring matching (not regex) so the strip is precise. Line-anchored: the header must start at the beginning of the string or immediately after a newline. The privacy notice must follow on the next line. Tolerates an optional \r before the header→notice newline.
  • stripRuntimeContextPreface walks each header in turn, removes matched preface blocks, and trims the surrounding whitespace so the remaining text re-flows as a clean two-newline paragraph break.
  • hasRuntimeContextPreface parallel detection, exposed via hasInternalRuntimeContext.
  • stripInternalRuntimeContext now calls the new strip after stripLegacyInternalRuntimeContext, preserving order/precedence with the existing block + legacy strips.

The strip is conservative: an inline prose mention of either header phrase (e.g. "the OpenClaw runtime event. came up earlier") is preserved because the immediately-following line is not the exact privacy notice. Tests cover this case explicitly.

Tests

src/agents/internal-runtime-context.test.ts adds 7 new cases:

  1. Standalone next-turn preface stripped to empty
  2. Next-turn preface + visible reply → reply preserved alone
  3. Runtime-event preface in the middle of text → surrounding text preserved
  4. Multiple prefaces in same text (mixed kinds) → all stripped, blank-line spacing preserved
  5. Inline prose mentioning the preface phrasing → preserved
  6. Privacy-notice line alone (no header above) → preserved
  7. hasInternalRuntimeContext detects both headers when paired with the notice; rejects header-only and inline mentions

All 10 tests in the file pass (3 pre-existing + 7 new).

Test plan

  • vitest run src/agents/internal-runtime-context.test.ts — 10 / 10 pass
  • Manual code review of the strip path against the original buildRuntimeContextMessageContent shape
  • Reviewers: please verify in CI / on a real model. I do not have access to run the full test suite locally with all OpenClaw deps installed.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

…nd text

The v2026.4.24 redesign of embedded runtime-context delivery routes the
context as a hidden `openclaw.runtime-context` next-turn custom message
rather than appending it inline to the user prompt. The Pi runtime
(`@mariozechner/pi-coding-agent` v0.70.2 pinned in `package.json`) then
converts that custom message into a `role: "user"` LLM turn so the model
can read it.

The custom-message body starts with two human-readable lines:

    OpenClaw runtime context for the immediately preceding user message.
    This context is runtime-generated, not user-authored. Keep internal details private.

(or the `OpenClaw runtime event.` variant for runtime events.) Models
that don't strictly follow the "Keep internal details private" line —
notably smaller open-weight models like Qwen3.6-35B served via local
vLLM — echo the preface verbatim into their visible Telegram / WhatsApp
replies. The existing `stripInternalRuntimeContext` only catches the
delimited `<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>` block and the legacy
`OpenClaw runtime context (internal):` header, neither of which match
the new shape emitted by `buildRuntimeContextMessageContent` in
`src/agents/pi-embedded-runner/run/runtime-context-prompt.ts`.

This change extends the same outbound sanitizer with line-anchored
detection of the two new preface headers, mirroring the existing
adjacent fix in openclaw#71847 that strips copied inbound metadata blocks from
user-facing replies. The strip is conservative: it only fires when the
exact header line is followed by an exact privacy-notice line on the
next line, both at line-start. Inline mentions of the header or notice
phrasing are preserved.

The structural fix (changing how the Pi runtime treats
`openclaw.runtime-context` custom messages so the human-readable
preface never reaches the user-role LLM turn) remains the right
long-term solution and is tracked in openclaw#72386. This sanitizer extension
addresses the visible regression in the meantime.

Fixes openclaw#72386
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M labels Apr 27, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

Extends stripInternalRuntimeContext / hasInternalRuntimeContext with two new patterns matching the preface headers introduced by the v2026.4.24 runtime-context redesign, mirroring the approach used for the legacy OpenClaw runtime context (internal): header. The implementation is precise (explicit substring matching, line-anchored) and the test coverage is solid across the main scenarios.

Confidence Score: 4/5

Safe to merge; both findings are minor edge-case hardening opportunities that don't affect the common-path stripping behaviour.

Only P2 findings: a missing end-of-line boundary guard in findRuntimeContextPrefaceEnd (theoretical partial-line leak on unusual model output) and a redundant g flag. Core logic and tests are correct for the stated use case.

src/agents/internal-runtime-context.ts — specifically findRuntimeContextPrefaceEnd (lines 196-210)

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 205-209

Comment:
**No end-of-line boundary check after the notice line**

`findRuntimeContextPrefaceEnd` uses a prefix match on the notice line — it returns a valid `blockEnd` even if the notice line is immediately followed by more characters on the same line (no `\n` or end-of-string terminator). If a model echoes something like `"...Keep internal details private. [ack]"`, `blockEnd` lands in the middle of that line and `after` begins with ` [ack]`, which then passes through with no leading-newline to strip — leaking that fragment into the sanitized output.

Adding a boundary check after the notice is the safer approach:

```typescript
  if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !==
      RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) {
    return null;
  }
  const endOfNotice = cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length;
  const charAfter = text.charCodeAt(endOfNotice);
  // Must end at EOF or at a newline; reject if extra characters follow on the same line.
  if (!isNaN(charAfter) && charAfter !== 0x0a && charAfter !== 0x0d) {
    return null;
  }
  return endOfNotice;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 243-244

Comment:
**Redundant `g` flag on anchored `replace` patterns**

Both regexes use `^` or `$` without the `m` (multiline) flag, so they can only match once regardless. The `g` flag is inert here and may be misleading to future readers.

```typescript
      const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/, "");
      const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/, "");
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(sanitizer): strip new runtime-contex..." | Re-trigger Greptile

Comment thread src/agents/internal-runtime-context.ts Outdated
Comment on lines +205 to +209
if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !==
RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) {
return null;
}
return cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No end-of-line boundary check after the notice line

findRuntimeContextPrefaceEnd uses a prefix match on the notice line — it returns a valid blockEnd even if the notice line is immediately followed by more characters on the same line (no \n or end-of-string terminator). If a model echoes something like "...Keep internal details private. [ack]", blockEnd lands in the middle of that line and after begins with [ack], which then passes through with no leading-newline to strip — leaking that fragment into the sanitized output.

Adding a boundary check after the notice is the safer approach:

  if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !==
      RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) {
    return null;
  }
  const endOfNotice = cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length;
  const charAfter = text.charCodeAt(endOfNotice);
  // Must end at EOF or at a newline; reject if extra characters follow on the same line.
  if (!isNaN(charAfter) && charAfter !== 0x0a && charAfter !== 0x0d) {
    return null;
  }
  return endOfNotice;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 205-209

Comment:
**No end-of-line boundary check after the notice line**

`findRuntimeContextPrefaceEnd` uses a prefix match on the notice line — it returns a valid `blockEnd` even if the notice line is immediately followed by more characters on the same line (no `\n` or end-of-string terminator). If a model echoes something like `"...Keep internal details private. [ack]"`, `blockEnd` lands in the middle of that line and `after` begins with ` [ack]`, which then passes through with no leading-newline to strip — leaking that fragment into the sanitized output.

Adding a boundary check after the notice is the safer approach:

```typescript
  if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !==
      RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) {
    return null;
  }
  const endOfNotice = cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length;
  const charAfter = text.charCodeAt(endOfNotice);
  // Must end at EOF or at a newline; reject if extra characters follow on the same line.
  if (!isNaN(charAfter) && charAfter !== 0x0a && charAfter !== 0x0d) {
    return null;
  }
  return endOfNotice;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/agents/internal-runtime-context.ts Outdated
Comment on lines +243 to +244
const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/g, "");
const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/g, "");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant g flag on anchored replace patterns

Both regexes use ^ or $ without the m (multiline) flag, so they can only match once regardless. The g flag is inert here and may be misleading to future readers.

      const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/, "");
      const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/, "");
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 243-244

Comment:
**Redundant `g` flag on anchored `replace` patterns**

Both regexes use `^` or `$` without the `m` (multiline) flag, so they can only match once regardless. The `g` flag is inert here and may be misleading to future readers.

```typescript
      const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/, "");
      const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/, "");
```

How can I resolve this? If you propose a fix, please make it concise.

- findRuntimeContextPrefaceEnd now requires the privacy-notice line to
  end at \n, \r, or end-of-string. Previously the prefix-only match would
  accept a notice line that had additional content on the same line
  (e.g. a model echoing "Keep internal details private. [ack]"), and
  the strip would land mid-line and leak the trailing fragment.
- Drop redundant `g` flag from the two anchored `replace` patterns in
  stripRuntimeContextPreface — without `m` flag the anchors can only
  match once, and the g flag was misleading.
- Add a regression test for the trailing-content-after-notice case.

All 11 tests pass.
@jhsmith409
Copy link
Copy Markdown
Contributor Author

Both Greptile P2 findings addressed in 39fde09:

  1. End-of-line boundary checkfindRuntimeContextPrefaceEnd now rejects a notice line followed by extra characters on the same line (charCodeAt(endOfNotice) must be \n, \r, or EOF). Added a regression test (does not strip when extra characters follow the privacy notice on the same line) using your example with [ack] appended.

  2. Redundant g flag — dropped from both anchored replace patterns in stripRuntimeContextPreface. As you noted, without the m flag the ^/$ anchors can only match once.

All 11 tests pass.

@jhsmith409
Copy link
Copy Markdown
Contributor Author

Production validation: sanitizer works mechanically, but exposes a deeper model-quality issue

Tested this PR on a live OpenClaw v2026.4.25 deployment (Telegram + local vLLM vllm3/Qwen3.6-35B) by mounting the patched internal-runtime-context-*.js over the bundled dist file.

Mechanical result: works as intended. The visible reply no longer contains the preface text. hasInternalRuntimeContext correctly flags the new headers.

But the user-facing UX is still broken, just shifted one layer over. On a fraction of turns the model's entire assistant text is just the preface verbatim, e.g. (from session.trajectory.jsonl):

"assistantTexts": [
  "OpenClaw runtime context for the immediately preceding user message.\nThis context is runtime-generated, not user-authored. Keep internal details private."
]

After the patched sanitizer strips that to empty, the channel-delivery path reaches a fallback and the user sees:

No response generated. Please try again.

I also patched the TTS path (extensions/speech-core/runtime-api.js) to call stripInternalRuntimeContext on reply.text before parseTtsDirectives — without that, the visible text was empty but the voice-note audio still contained a full TTS rendering of the preface. The TTS-path patch is needed any time messages.tts.auto is enabled.

This confirms the structural concern raised in the original PR description. A sanitizer can keep the leak from reaching the channel, but it can't fix the underlying reason the leak shows up: smaller open-weight models (Qwen3.6-35B in our case) are so disoriented by the new openclaw.runtime-context custom-message-as-user-role contract that they treat the preface as the user's actual message and either echo it or fail to produce any other content. The structural fix tracked in #72386 (separate non-user role for runtime-context, or restructure so the human-readable preface never reaches the model's user channel) remains required.

Recommendation: I'm fine with this PR landing as a hardening / belt-and-suspenders fix — the sanitizer should catch echoed prefaces regardless of whether the structural fix lands — but it isn't sufficient to restore correct behavior on its own. For our deployment we're rolling back to v2026.4.24 (which uses the inline conversation envelope and doesn't trigger this confusion in the model).

@steipete
Copy link
Copy Markdown
Contributor

Thanks @jhsmith409. I did not merge this PR as-is because your production validation was right: sanitizer-only hardening prevented the visible echo, but it could still leave the model confused enough to return an empty/fallback reply.

I landed a structural fix on main in 11e6928 (fix: keep runtime context out of user turns) that carries this PR forward:

  • runtime context now reaches the active turn as prompt-local system context;
  • hidden openclaw.runtime-context transcript entries are filtered out before provider conversion;
  • compaction and compaction-provider summarization paths also filter those entries;
  • the stale-preface sanitizer fallback from this PR is included for old/session-replay outputs.

The changelog credits you and references this PR as carried forward. Closing this PR as superseded by the landed main fix.

@steipete steipete closed this Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants