Skip to content

fix(media): keep input_file text truncation UTF-16 safe#102634

Merged
steipete merged 2 commits into
openclaw:mainfrom
mushuiyu886:fix/followup-102478
Jul 9, 2026
Merged

fix(media): keep input_file text truncation UTF-16 safe#102634
steipete merged 2 commits into
openclaw:mainfrom
mushuiyu886:fix/followup-102478

Conversation

@mushuiyu886

Copy link
Copy Markdown
Contributor

Summary

  • Problem: oversized /v1/responses input_file text used text.slice(0, maxChars), so an emoji at the default 60_000 character cap could be split into a dangling UTF-16 surrogate in the model-visible file context.
  • Solution: reuse truncateUtf16Safe from @openclaw/normalization-core/utf16-slice in src/media/input-files.ts::clampText.
  • What changed: one truncation helper call and one live HTTP regression test covering base64 input_file content through /v1/responses.
  • What was not changed: MIME sniffing, byte limits, URL/SSRF guards, PDF extraction, schemas, public API, config, and the untrusted <file> wrapper.
  • Fixes #N/A — follow-up gap-fill PR with no closing issue.

What Problem This Solves

Oversized text extracted from an OpenResponses input_file can land exactly inside an emoji surrogate pair. The current clampText() implementation slices by UTF-16 code unit, so a payload like "a".repeat(59_999) + "😀tail" is truncated to a file-context string ending in the lone high surrogate \uD83D. That malformed model-visible text can then be forwarded in extraSystemPrompt instead of a valid UTF-16 boundary-aligned prefix.

User Impact

Users attaching large local text files through /v1/responses can get corrupted file context at the truncation boundary when the boundary falls inside emoji or another astral Unicode character. After this patch, the same oversized file is still capped, but the capped text never ends with a split surrogate pair.

Origin / follow-up

  • Follows / completes: fix(codex): use truncateUtf16Safe for tool transcript output truncation #102522 (fix(codex): use truncateUtf16Safe for tool transcript output truncation).
  • Gap this fills: that merged UTF-16 truncation sweep fixed another user-visible truncation path, while src/media/input-files.ts::clampText still used text.slice(0, maxChars) on the canonical /v1/responses input_file path.
  • Consistency: this PR uses the same shared truncateUtf16Safe helper rather than adding a local surrogate check.

Competition / linked PR analysis

  • Linked PR(s): N/A — no linked open PR for src/media/input-files.ts.
  • Gap in linked PR(s): N/A.
  • Why this patch is better/canonical: the patch changes the sole input_file text clamp before the file text is wrapped and passed into agentCommand({ extraSystemPrompt }).
  • Local real behavior proof advantage: the added regression starts the real OpenResponses HTTP server, posts an actual /v1/responses request, runs real base64 input_file extraction, and asserts on the model-visible extraSystemPrompt.

Related scan:

gh search prs --repo openclaw/openclaw --state open "input_file"
#97166 feat(media): allow host-read vCard (.vcf) files
#80418 fix(/v1/responses): accept text field on requests for OpenAI SDK 6.x parity

Neither open PR touches src/media/input-files.ts::clampText or the UTF-16 truncation path.
Recently merged UTF-16 PRs checked: #102522, #102539, #102542, #102560, #102565, #102573.
None modifies src/media/input-files.ts or src/gateway/openresponses-http.test.ts.

Merge risk

  • Why it matters / User impact: the model should receive valid text for attached files even when the file is capped at the maximum context size.
  • Risk labels considered: compatibility, message-delivery, session-state, security-boundary, auth-provider, availability, automation.
  • Risk explanation: compatibility is the only relevant consideration. The public request shape, schema, config, MIME handling, byte guard, and file wrapper are unchanged; only an oversized capped prefix may become one UTF-16 code unit shorter when the cap would otherwise split a surrogate pair. The other risk categories are not touched.
  • Why acceptable: the diff is two files and changes one truncation call to an existing shared helper already used across the repo for this exact invariant. Focused E2E coverage proves the canonical HTTP path.
  • Maintainer-ready confidence: High — the patch is minimal, reuses the repo standard helper, and the proof crosses the real /v1/responses HTTP boundary.
  • Patch quality notes: The as never in the test only satisfies the existing typed agentCommand mock return shape and does not construct the malformed runtime state; the malformed state comes from a real HTTP request payload and real input_file extraction. No fallback, broad catch, default shim, or unrelated cleanup was added.
  • Target test file: src/gateway/openresponses-http.test.ts.
  • Root cause: src/media/input-files.ts::clampText used text.slice(0, maxChars), which can split UTF-16 surrogate pairs because JavaScript string indices are code-unit based.
  • Why this is root-cause fix: the clamp now calls truncateUtf16Safe, whose source-of-truth implementation backs up before a split surrogate pair instead of masking the corrupted value downstream.
  • What did NOT change: Public API / schema / protocol / defaults; MIME allowlist; byte limits; URL/SSRF guards; PDF extraction; unrelated channel/provider/session/security behavior; unrelated cleanup/docs/formatting/lockfiles.
  • Architecture / source-of-truth check: src/media/input-files.ts::extractFileContentFromSource owns model-visible text extraction for input_file; this changes the text before downstream prompt wrapping and before agentCommand, not a mirrored or persisted projection.
  • Scenario locked in: a base64 input_file whose default cap lands between the two UTF-16 code units of 😀.
  • Why this is the smallest reliable guardrail: it tests the shipped /v1/responses server path and checks the final model-visible prompt payload, while the production change is a one-line replacement of the unsafe clamp.

Real behavior proof

  • Behavior or issue addressed: /v1/responses input_file text truncation could split emoji at the default maxChars boundary and forward a dangling surrogate in extraSystemPrompt.
  • Canonical reachability path: user POSTs /v1/responses → OpenResponses HTTP input parsing in src/gateway/openresponses-http.tsinput_file source passed to extractFileContentFromSource()src/media/input-files.ts::clampTextrenderFileContextBlock({ content: wrapUntrustedFileContent(rawText) })agentCommand({ extraSystemPrompt }).
  • Boundary crossed: full config-to-request local gateway path: real HTTP server, real /v1/responses request parsing, real base64 input_file extraction, and assertion on agentCommand model prompt input.
  • Shared helper / provider constraint check: shared helper reused: truncateUtf16Safe from @openclaw/normalization-core/utf16-slice. Provider constraint check is N/A because this is not a provider range/parser change.
  • Real environment tested: local OpenClaw worktree /media/vdb/code/ai/aispace/openclaw-worktrees/followup-102478 after rebase onto current origin/main, Node/Vitest through pnpm -C "$TASK_WORKTREE" exec vitest.
  • Exact steps or command run after this patch:
    pnpm -C "$TASK_WORKTREE" exec vitest run src/gateway/openresponses-http.test.ts
    pnpm -C "$TASK_WORKTREE" exec vitest run src/media/input-files.fetch-guard.test.ts
    node "$EVIDENCE_DIR/before-after-input-file.mjs"
  • Evidence after fix:
    src/gateway/openresponses-http.test.ts
      ✔ OpenResponses HTTP API (e2e) > keeps base64 input_file text truncation UTF-16 safe
    Test Files  2 passed (2)
         Tests  64 passed (64)
    
    input length: 60005
    BEFORE last 8 codeUnits: "aaaaaaa\ud83d"
    BEFORE ends with lone high surrogate? true
    AFTER  last 8 codeUnits: "aaaaaaaa"
    AFTER  ends with lone high surrogate? false
    AFTER  contains lone low surrogate? false
    AFTER  contains 😀? false
    
  • Observed result after fix: the live HTTP regression confirms the generated extraSystemPrompt contains <file name="emoji-boundary.txt">, does not include the split emoji, and does not match the lone high-surrogate regex /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/u.
  • What was not tested: no live external model/provider call was made; the proof stops at the OpenClaw gateway's model-facing agentCommand boundary, which is the source boundary for this local prompt-construction bug. URL input_file fetching and PDF image extraction were not separately exercised because they converge on the same clampText helper after extraction.
  • Fix classification: Root cause fix.

Evidence

Focused command output from the rebased fix worktree:

RUN  v4.1.9 /media/vdb/code/ai/aispace/openclaw-worktrees/followup-102478

Test Files  2 passed (2)
     Tests  64 passed (64)
Start at  17:39:29
Duration  80.50s (transform 67.00s, setup 1.80s, import 27.47s, tests 50.66s, environment 0ms)

Additional focused extraction guard suite:

RUN  v4.1.9 /media/vdb/code/ai/aispace/openclaw-worktrees/followup-102478

Test Files  1 passed (1)
     Tests  17 passed (17)

Before/after repro for the exact boundary:

input length: 60005
BEFORE last 8 codeUnits: "aaaaaaa\ud83d"
BEFORE ends with lone high surrogate? true
AFTER  last 8 codeUnits: "aaaaaaaa"
AFTER  ends with lone high surrogate? false
AFTER  contains lone low surrogate? false
AFTER  contains 😀? false

Review findings addressed

  • RF-001: N/A — this is a new follow-up PR, not an existing review-fix PR.

Regression Test Plan

pnpm -C "$TASK_WORKTREE" exec vitest run src/gateway/openresponses-http.test.ts
pnpm -C "$TASK_WORKTREE" exec vitest run src/media/input-files.fetch-guard.test.ts
node "$EVIDENCE_DIR/before-after-input-file.mjs"

Risk / Compatibility

Low risk. The request schema, endpoint behavior, MIME and byte validation, URL fetch guards, PDF extraction, and config defaults are unchanged. The only observable difference is for oversized extracted file text whose cap would otherwise land inside a surrogate pair; that output now drops the incomplete pair instead of forwarding a lone surrogate.

What was not changed

  • Public API / schema / protocol / defaults: unchanged.
  • Channel/provider/session/security behavior outside the fixed path: unchanged.
  • Unrelated cleanup, docs, formatting, lockfiles: unchanged.

Root Cause

  • Root cause: src/media/input-files.ts::clampText used text.slice(0, maxChars) and could cut between UTF-16 surrogate halves.
  • Why this is root-cause fix: replacing the unsafe slice with the repo's shared truncateUtf16Safe enforces the boundary invariant at the only input_file text clamp before downstream prompt construction.
  • Maintainer-ready confidence: High.
  • Patch quality notes: Minimal source change; no fallback masking; no schema/config/provider behavior change; focused HTTP regression covers the shipped path.
  • Architecture / source-of-truth check: extractFileContentFromSource is the owner of normalized input_file text before it is wrapped for the model. The patch changes the source-of-truth text before feedback/model input, not only a mirror/projection/persistence record.

@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: XS labels Jul 9, 2026
@clawsweeper

clawsweeper Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 9, 2026, 5:57 AM ET / 09:57 UTC.

Summary
The PR replaces raw input_file text slicing with the shared UTF-16-safe truncation helper and adds an OpenResponses HTTP regression test.

PR surface: Source +1, Tests +37. Total +38 across 2 files.

Reproducibility: yes. from source inspection: current main slices decoded input_file text at the 60,000 code-unit cap before wrapping it into the model prompt, so a boundary after a high surrogate can create a dangling surrogate. The PR body also includes a before/after boundary proof and an after-fix HTTP regression path.

Review metrics: none identified.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🐚 platinum hermit
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • none.

Next step before merge

  • No ClawSweeper repair job is needed; normal PR review and CI can gate this already-focused fix.

Security
Cleared: No concrete security or supply-chain regression found in the two-file diff; it changes one in-repo helper import/use and one regression test.

Review details

Best possible solution:

Use the shared UTF-16-safe truncation helper at the input_file extraction owner, with focused HTTP coverage of the model-visible prompt boundary.

Do we have a high-confidence way to reproduce the issue?

Yes from source inspection: current main slices decoded input_file text at the 60,000 code-unit cap before wrapping it into the model prompt, so a boundary after a high surrogate can create a dangling surrogate. The PR body also includes a before/after boundary proof and an after-fix HTTP regression path.

Is this the best way to solve the issue?

Yes. The fix is at the extraction owner before both direct text and PDF-extracted text are rendered into extraSystemPrompt, and it reuses the repository's shared UTF-16 helper instead of adding a second local surrogate rule.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against c86fe21cada0.

Label changes

Label changes:

  • add P2: The PR fixes a bounded prompt-context corruption bug for oversized OpenResponses input_file text without changing API or config surfaces.
  • add proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes after-fix live HTTP gateway proof that posts a base64 input_file and asserts the final model-facing extraSystemPrompt has no dangling high surrogate.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body includes after-fix live HTTP gateway proof that posts a base64 input_file and asserts the final model-facing extraSystemPrompt has no dangling high surrogate.

Label justifications:

  • P2: The PR fixes a bounded prompt-context corruption bug for oversized OpenResponses input_file text without changing API or config surfaces.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body includes after-fix live HTTP gateway proof that posts a base64 input_file and asserts the final model-facing extraSystemPrompt has no dangling high surrogate.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes after-fix live HTTP gateway proof that posts a base64 input_file and asserts the final model-facing extraSystemPrompt has no dangling high surrogate.
Evidence reviewed

PR surface:

Source +1, Tests +37. Total +38 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 2 1 +1
Tests 1 37 0 +37
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 39 1 +38

What I checked:

  • Current main still has raw slice: Current main's input_file clamp returns text.slice(0, maxChars), which can end on a lone high surrogate when the cap lands inside an astral character. (src/media/input-files.ts:272, c86fe21cada0)
  • Gateway path forwards file text into model context: OpenResponses extracts input_file content via extractFileContentFromSource, wraps nonblank text with renderFileContextBlock, and joins it into extraSystemPrompt. (src/gateway/openresponses-http.ts:587, c86fe21cada0)
  • Shared helper contract: truncateUtf16Safe clamps/floors the limit and delegates to sliceUtf16Safe, which backs up before returning a prefix ending after a high surrogate followed by a low surrogate. (packages/normalization-core/src/utf16-slice.ts:43, c86fe21cada0)
  • Patch diff is focused: The PR changes one production truncation call and adds one OpenResponses HTTP regression that posts a base64 input_file and inspects model-facing extraSystemPrompt. (src/media/input-files.ts:276, 30421aff0ca2)
  • History provenance for affected path: Blame shows the current input_file clamp, default cap, and OpenResponses input_file extraction path came from commit ecc2cff. (src/media/input-files.ts:272, ecc2cffad8aa)
  • Canonical search: Live GitHub search found this PR as the only result for UTF-16 input_file truncate and truncateUtf16Safe input_file; broader input-files search showed adjacent but distinct media/API PRs.

Likely related people:

  • steipete: The merged commit that added the OpenResponses input_file extraction/test files and the shared UTF-16 helper is authored by Peter Steinberger and appears in blame for the affected lines. (role: introduced behavior and recent area contributor; confidence: high; commits: ecc2cffad8aa; files: src/media/input-files.ts, src/gateway/openresponses-http.ts, src/gateway/openresponses-http.test.ts)
  • lsr911: Recently authored merged UTF-16-safe truncation work in fix(codex): use truncateUtf16Safe for tool transcript output truncation #102522, which the PR cites as sibling sweep context. (role: adjacent UTF-16 sweep contributor; confidence: medium; commits: 32de66616303; files: extensions/codex/src/app-server/event-projector.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@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. P2 Normal backlog priority with limited blast radius. labels Jul 9, 2026
@steipete steipete self-assigned this Jul 9, 2026
@steipete
steipete requested a review from a team as a code owner July 9, 2026 11:49
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: googlechat Channel integration: googlechat channel: mattermost Channel integration: mattermost channel: msteams Channel integration: msteams channel: nextcloud-talk Channel integration: nextcloud-talk channel: slack Channel integration: slack channel: telegram Channel integration: telegram channel: voice-call Channel integration: voice-call channel: whatsapp-web Channel integration: whatsapp-web app: android App: android app: ios App: ios app: macos App: macos app: web-ui App: web-ui cli CLI command changes scripts Repository scripts commands Command implementations agents Agent runtime and tooling extensions: openai channel: qqbot extensions: qa-lab labels Jul 9, 2026
@steipete
steipete force-pushed the fix/followup-102478 branch from 2365ce0 to cae64db Compare July 9, 2026 11:52
@openclaw-barnacle openclaw-barnacle Bot removed docs Improvements or additions to documentation channel: googlechat Channel integration: googlechat channel: mattermost Channel integration: mattermost channel: msteams Channel integration: msteams channel: nextcloud-talk Channel integration: nextcloud-talk channel: slack Channel integration: slack channel: telegram Channel integration: telegram channel: voice-call Channel integration: voice-call channel: whatsapp-web Channel integration: whatsapp-web app: android App: android app: ios App: ios app: macos App: macos app: web-ui App: web-ui cli CLI command changes scripts Repository scripts commands Command implementations agents Agent runtime and tooling extensions: openai channel: qqbot extensions: qa-lab extensions: codex plugin: google-meet extensions: tts-local-cli extensions: inworld Extension: inworld plugin: azure-speech Azure Speech plugin extensions: diagnostics-prometheus labels Jul 9, 2026
@steipete

steipete commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Maintainer fixup and land-ready proof for exact head cae64db2c800ef631f8969142ac879d6839c589e.

The final regression now proves the real /v1/responses path retains the complete 59,999-code-unit prefix, removes the over-limit emoji/suffix, and emits no lone surrogate before the extracted file reaches agentCommand.

Proof:

No external provider key was used: the defect and asserted invariant terminate at the model-facing agentCommand boundary, so a provider call would not exercise any additional changed code. Known proof gaps: none.

@steipete

steipete commented Jul 9, 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

gateway Gateway runtime P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: XS status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants