Skip to content

fix(agents): prevent malformed HTML entities from breaking tool calls#99564

Merged
steipete merged 2 commits into
openclaw:mainfrom
mikasa0818:fix/decode-surrogate-entities
Jul 6, 2026
Merged

fix(agents): prevent malformed HTML entities from breaking tool calls#99564
steipete merged 2 commits into
openclaw:mainfrom
mikasa0818:fix/decode-surrogate-entities

Conversation

@mikasa0818

@mikasa0818 mikasa0818 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

OpenClaw's HTML-entity repair wrapper mutates the actual streamed tool-call arguments object before tool execution. A provider payload that is still valid ASCII, such as bad � end, could previously be repaired into bad \uD800 end, introducing a lone UTF-16 surrogate into live tool input. That malformed text can break downstream JSON/URI handling, transcript persistence, or tool-specific string processing. This PR keeps surrogate numeric entities escaped while preserving normal decoding for valid HTML entities.

Evidence

OpenAI-compatible provider/tool execution proof

I ran a local OpenAI-compatible HTTP/SSE provider through OpenClaw's real openai-completions provider adapter, with model compat set to toolCallArgumentsEncoding: "html-entities" and the production embedded-runner HTML-entity tool-call wrapper installed. The provider emitted a streamed tool_calls response for a registered probe_tool; after the OpenClaw stream produced a type: "toolCall" block, the proof invoked the probe tool and logged the arguments immediately before execution.

Command form:

pnpm exec tsx <proof script that starts a local OpenAI-compatible SSE provider, imports OpenClaw's provider adapter, and executes the probe tool>

Redacted terminal excerpt after this patch:

[provider-transport-fetch] [model-fetch] start provider=xai api=openai-completions model=openclaw-qa-html-entities method=POST url=http://127.0.0.1:<port>/v1/chat/completions proxy=none policy=custom
[provider-transport-fetch] [model-fetch] response provider=xai api=openai-completions model=openclaw-qa-html-entities status=200 contentType=text/event-stream; charset=utf-8
{
  "provider": "local OpenAI-compatible SSE provider",
  "providerEndpoint": "/v1/chat/completions",
  "modelCompat": {
    "toolCallArgumentsEncoding": "html-entities"
  },
  "providerSawTools": true,
  "providerEmittedToolCall": true,
  "rawProviderArguments": {
    "query": "bad &#xD800; and &#55296; end",
    "ok": "ok &#x1F600;"
  },
  "streamEvents": [
    "start",
    "toolcall_start",
    "toolcall_delta",
    "toolcall_end",
    "done"
  ],
  "finalToolCall": {
    "type": "toolCall",
    "name": "probe_tool",
    "id": "call_surrogate_entity"
  },
  "executedProbeTool": true,
  "receivedBeforeExecute": {
    "query": "bad &#xD800; and &#55296; end",
    "ok": "ok 😀"
  },
  "hasLoneSurrogate": false,
  "uriEncoding": "bad%20%26%23xD800%3B%20and%20%26%2355296%3B%20end"
}

This exercises provider transport, streamed OpenAI-compatible tool-call parsing, the embedded-runner HTML-entity wrapper, and the pre-tool-execution argument boundary. It shows surrogate numeric entities remain ASCII entity text before the tool executes, while the valid astral numeric entity still decodes to 😀; the resulting query contains no lone surrogate and can be URI-encoded.

Focused wrapper proof

I also ran a provider-shaped tool-call stream through the production createHtmlEntityToolCallArgumentDecodingWrapper, then consumed the wrapped stream the same way downstream tool execution can inspect the final assistant message.

Observed output after this patch:

{
  "query": "bad &#xD800; and &#55296; end",
  "ok": "ok 😀",
  "hasLoneSurrogate": false,
  "uriEncoding": "bad%20%26%23xD800%3B%20and%20%26%2355296%3B%20end"
}

Focused regression test

pnpm exec vitest run --reporter=verbose --config test/vitest/vitest.unit-fast.config.ts src/agents/embedded-agent-runner/tool-call-argument-decoding.test.ts

The focused suite reports Test Files 1 passed (1) and Tests 5 passed (5). The regression now covers &#xD800; and &#55296; preservation, plus a positive astral scalar case (&#x1F600; -> 😀).

Review findings addressed

  • RF-001 — provider-wire proof gap: The current head has GitHub's Real behavior proof workflow passing (run 28670300540), and the PR body keeps the OpenAI-compatible provider/tool execution proof as real OpenClaw adapter + streamed tool-call + pre-tool-execution boundary coverage. I still do not have credentialed xAI or Venice live-provider credentials in this environment, so live third-party provider escaping remains explicitly disclosed as not tested and a maintainer-decision proof boundary rather than hidden or replaced with unit-only evidence.
  • RF-002 — QA Smoke CI status: The current-head QA Smoke CI failure is isolated to native-command-session-target (passed=24 failed=1, job 85015792777). The same native-command-session-target scenario also fails on latest origin/main at 48e8965b1060d61beda1a52d572373a409317498 (passed=24 failed=1, run 28671452150/job 85035716021). This PR's diff only changes src/agents/embedded-agent-runner/tool-call-argument-decoding.ts and its focused test, so the QA Smoke failure is treated as a main-baseline CI blocker, not a PR-introduced code failure.

Summary

  • Behavior or issue addressed: Tool-call argument HTML entity repair now preserves surrogate numeric entities such as &#xD800; and &#55296; as their original ASCII entity text instead of converting them into lone UTF-16 surrogates.
  • Why it matters / User impact: The repair wrapper mutates the actual tool-call arguments object before tools execute. Producing malformed UTF-16 there can break JSON/URI handling, transcript recording, or downstream tool processing even though the original provider payload was valid ASCII text.
  • Patch quality notes: The change is limited to the numeric HTML entity scalar-value guard and focused regression coverage. Named HTML entities and valid numeric code points continue to decode.
  • What did NOT change: Out of scope / not changed: named HTML entity decoding, valid numeric scalar decoding, object traversal, stream wrapping, provider routing, tool execution, session state, and public provider/config protocol behavior are unchanged. No unrelated refactor or fallback behavior was added.
  • Architecture / source-of-truth check: The source-of-truth owner is the embedded-agent-runner tool-call argument HTML entity decoder at the raw-vs-transformed tool-argument boundary. This is not a public config/schema/protocol contract surface change; compatibility, default behavior, and migration behavior are unchanged. Related open PR scan for the same surrogate HTML-entity tool-argument contract found no competing same-contract fix; test(shared): add unit tests for message content block visitor #99545 is message content block visitor test coverage, not this decoder invariant.

Root Cause

  • Root cause: The decoder's numeric-entity validation treated every value from 0 through 0x10ffff as safe, then handed that value to String.fromCodePoint(). That parser invariant missed the Unicode scalar-value rule: UTF-16 surrogate code points in the 0xd800-0xdfff range are numeric code points but must not be introduced as standalone text, so provider ASCII entity text could become malformed UTF-16 tool input.
  • Why this is root-cause fix: The fix changes the source decoder validation boundary itself: numeric entities now decode only when they are valid Unicode scalar values, and surrogate-range values take the same preserve-original-entity path as out-of-range numeric entities. This fixes the invariant at the raw-to-transformed argument parser boundary rather than masking downstream JSON, URI, transcript, or tool execution failures.

Real behavior proof

  • Real environment tested: Local Windows Git Bash with Node v22.17.0 and pnpm 11.2.2.
  • Exact steps or command run after this patch: Ran the OpenAI-compatible provider/tool execution proof above, ran the focused wrapper proof above, then reran the focused Vitest command above on the current head.
  • Evidence after fix: The provider/tool proof posted to OpenClaw's openai-completions adapter over HTTP/SSE, emitted a streamed tool call, produced OpenClaw stream events through toolcall_end, executed the probe tool, and recorded pre-execution arguments with query preserved as bad &#xD800; and &#55296; end, valid astral entity decoded to ok 😀, hasLoneSurrogate: false, and successful URI encoding. The focused Vitest rerun on the current head also passed Test Files 1 passed (1) and Tests 5 passed (5).
  • Observed result after fix: Surrogate numeric entities remain ASCII entity text in decoded tool arguments before tool execution; valid numeric HTML entities continue to decode.
  • What was not tested: Live third-party provider credentials were not used locally. The provider proof uses a local OpenAI-compatible SSE provider so the provider transport, OpenClaw provider adapter, streamed tool-call parser, wrapper, and pre-tool-execution boundary are all exercised without exposing external credentials.

Regression Test Plan

  • Target test file: src/agents/embedded-agent-runner/tool-call-argument-decoding.test.ts
  • Scenario locked in: The regression fixture decodes a nested tool-argument object containing out-of-range numeric entities, a hex surrogate entity (&#xD800;), a decimal surrogate entity (&#55296;), and a valid astral scalar (&#x1F600;). The wrapper tests also lock the shared-arguments object path through streamed partial/message events and result() so double-decoding does not corrupt already-repaired arguments.
  • Why this is the smallest reliable guardrail: The unit-fast test targets the source decoder and stream wrapper invariant directly, including the invalid-surrogate boundary and the positive scalar boundary, while the provider proof covers the OpenClaw adapter and pre-tool-execution path. Broader QA suites would add noise without increasing coverage of this specific parser invariant.

Merge risk

  • Risk labels considered: merge-risk:low
  • Risk explanation: The diff only tightens invalid numeric entity handling for surrogate values, which should never be introduced as standalone text. Existing invalid-entity behavior is reused by preserving the original entity string.
  • Why acceptable: The affected path is limited to tool-call argument HTML entity repair; valid entity decoding behavior and wrapper object traversal are unchanged.
  • Fix classification: Minimal root-cause fix.
  • Maintainer-ready confidence: High, with the live third-party xAI/Venice provider credential gap and unrelated main-baseline QA Smoke CI blocker explicitly disclosed above.

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: XS triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. labels Jul 3, 2026
@clawsweeper

clawsweeper Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Codex review: stale review; fresh review needed.

Summary
The latest durable ClawSweeper review was for head 444fd8be029c0be47c8ac248da74f5d6cc4cf056, but the PR head is now 6f976c818c5b6fd387c08d03c99091091aba8973. Its old verdict and PR readiness labels are no longer current.

Next step
Run or wait for a fresh ClawSweeper review on the current PR head.

Review history (5 earlier review cycles)
  • reviewed 2026-07-04T02:54:26.033Z sha 329b13f :: needs maintainer review before merge. :: none
  • reviewed 2026-07-05T09:10:38.094Z sha 329b13f :: needs maintainer review before merge. :: none
  • reviewed 2026-07-05T09:26:04.312Z sha 329b13f :: needs maintainer review before merge. :: none
  • reviewed 2026-07-06T00:18:16.355Z sha ebc55ad :: needs maintainer review before merge. :: none
  • reviewed 2026-07-06T00:55:56.063Z sha f01db73 :: needs maintainer review before merge. :: none

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. labels Jul 3, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. label Jul 3, 2026
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Jul 3, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Jul 3, 2026
@mikasa0818

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@openclaw-barnacle openclaw-barnacle Bot added size: XS and removed gateway Gateway runtime scripts Repository scripts size: XL labels Jul 6, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Jul 6, 2026
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: discord Channel integration: discord channel: slack Channel integration: slack channel: telegram Channel integration: telegram gateway Gateway runtime size: XL and removed size: XS labels Jul 6, 2026
@steipete
steipete force-pushed the fix/decode-surrogate-entities branch from 44127ca to f01db73 Compare July 6, 2026 00:35
@openclaw-barnacle openclaw-barnacle Bot removed docs Improvements or additions to documentation channel: discord Channel integration: discord channel: slack Channel integration: slack channel: telegram Channel integration: telegram gateway Gateway runtime size: XL labels Jul 6, 2026
@steipete

steipete commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Merged via squash.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling P2 Normal backlog priority with limited blast radius. size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants