Skip to content

fix(read): treat invalid UTF-8 as binary instead of silently replacing bytes - #57833

Open
iamtyroon wants to merge 1 commit into
anomalyco:devfrom
iamtyroon:fix/read-byte-faithful-utf8
Open

iamtyroon wants to merge 1 commit into
anomalyco:devfrom
iamtyroon:fix/read-byte-faithful-utf8

Conversation

@iamtyroon

@iamtyroon iamtyroon commented Oct 21, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #57837

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The Read tool's non-paged path decoded file bytes with a non-fatal TextDecoder, silently replacing invalid UTF-8 sequences with U+FFFD and losing the original bytes. Also, Mime.detect's isText() streamed a chunk without flushing, so truncated trailing multi-byte UTF-8 sequences were accepted as valid text instead of being classified as binary. Both now use a fatal decoder (and isText flushes it), so invalid/truncated UTF-8 is treated as binary rather than corrupting the bytes the model sees.

How did you verify your code works?

Reviewed the changed decode paths; mime/read tests unchanged. Local sanity check of invalid-UTF-8 input now returns binary.

Screenshots / recordings

N/A

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes

@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. needs:issue labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions github-actions Bot removed needs:compliance This means the issue will auto-close after 2 hours. needs:issue labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@opencode-agent opencode-agent Bot left a comment

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.

Not ready: the fix only covers one of three read paths, and it breaks reading valid UTF-8 files larger than 256KB.

How I checked: a temporary test calling ReadToolFileSystem.read on real files, on base and PR.

  • A small file (under 50KB) with invalid bytes and no offset/limit now gives BinaryFileError, as intended.
  • The same file read with limit, or any file between 50KB and 256KB, still comes back with U+FFFD. Those reads go through textPage's lossy decoder, which the PR doesn't touch.
  • A valid 400KB UTF-8 file whose 256KB read boundary splits a multi-byte character now fails with BinaryFileError when read with an offset. On base it reads correctly.

The existing test reads malformed UTF-8 lossily and still rejects null-byte binary content in test/tool-read-filesystem.test.ts now fails. It shows that lossy decoding is deliberate today. Rejecting these files instead means some files that read today (Latin-1 source, files with a stray bad byte) would fail completely. That is a behaviour change a maintainer should agree to first, and the test needs updating to match. Typecheck passes.

The mime.ts change is the same as #55946. In Read it has no effect, because Mime.detect there only picks out images and PDFs. I'd drop it here and leave it to that PR.

Public API: unchanged.

const length = Math.min(leaf.summary.bytes, consumed - checked)
checked += leaf.summary.bytes
return length > 0 && leaf.bytes.subarray(0, length).includes(0)
if (length > 0 && leaf.bytes.subarray(0, length).includes(0)) return true

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.

Leaves are raw 256KB reads, so a leaf boundary can split a multi-byte character. Decoding each leaf on its own with fatal: true then throws for valid UTF-8. I reproduced this: a valid 400KB file of é lines, shifted one byte and read with offset: 3000, returns BinaryFileError here, but reads fine on base. If invalid UTF-8 should count as binary, the check should decode the concatenated bytes (e.g. one streaming fatal decoder fed each leaf with stream: true), not each leaf on its own.

const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
let decoded: string

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.

This only covers the small-file path with no offset/limit. Any read with offset/limit, and any file between 50KB and 256KB, goes through textPage(), which still uses new TextDecoder() and returns U+FFFD. I checked both cases on this branch. The behaviour should be the same across read paths. Also, the existing test reads malformed UTF-8 lossily... asserts the lossy behaviour on purpose and now fails, so this needs a maintainer decision and an updated test.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read tool is not byte-faithful: invalid UTF-8 silently replaced, truncated multi-byte accepted as text

1 participant