Skip to content

fix(ai): strip NUL bytes from outgoing requests - #58361

Open
nik875 wants to merge 1 commit into
anomalyco:devfrom
nik875:strip-nul-output
Open

nik875 wants to merge 1 commit into
anomalyco:devfrom
nik875:strip-nul-output

Conversation

@nik875

@nik875 nik875 commented Oct 23, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #58354

Type of change

  • Bug fix

What does this PR do?

Shell output containing a NUL byte gets stored in session history and replayed to the provider on every later request. Venice's OpenAI-compatible endpoint rejects such strings with a 400 ("String must not contain null bytes"), and since the byte is in history, every retry rebuilds the same invalid request — the session can't recover.

Stripping NULs in sanitizeSurrogates (packages/ai/src/utils/sanitize.ts), which already walks every outgoing request string for lone surrogates, covers every entry path — stored history, user ! shell output, failed-tool error text — and every provider in one place, and it also un-bricks sessions whose history already contains a NUL byte. Strip rather than escape, since escaping would inject text the tool never printed; if a NUL byte matters for the task, it's easy to check for explicitly (grep -c, od), in the same spirit as the read tool refusing binary files. Strings without NUL bytes are returned untouched.

How did you verify your code works?

New test in packages/ai/test/sanitize.test.ts covering user text and tool output containing NUL (fails without the fix). Full packages/ai suite passes and bun run check is clean.

Screenshots / recordings

n/a

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #51351

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

@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.

The fix works. I ran the #58354 reproduction from source with a simulated strict provider. On the base, every request after the shell call contains 003dsha HEAD\u0000multi_ack. With this change the stored tool result is 003dsha HEADmulti_ack and no request contains a NUL. The shell tool tests pass and tsc on packages/core is clean.

The public API is unchanged.

The fix only cleans tool results at the moment they are stored (see the inline comment), so a few ways a NUL reaches the provider remain:

  • A session that already has a NUL in its history keeps failing on every request. That is the state the reporter's session is in.
  • Output from user-run ! shell commands is replayed from message.output.output (session/runner/to-llm-message.ts, the shell case) without going through normalizeContent.
  • The error text of a failed tool (tool.state.error) isn't cleaned either.

Removing NULs at request time, next to sanitizeSurrogates in packages/ai/src/utils/sanitize.ts, would cover all of these and every provider in one place. It could replace this change or sit alongside it.

Related: #51351

Comment thread packages/core/src/tool/runtime.ts Outdated
// Tool output is text content, not a byte channel: NUL bytes are rejected outright by some
// providers ("String must not contain null bytes"), which permanently bricks the session since
// the output is replayed from history on every later request.
const stripNul = (text: string) => text.replaceAll("\0", "")

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 cleans tool results when they are first stored. History is replayed from storage as-is, so a session that already has a NUL (like the one in #58354) still fails on every request. User ! shell output (to-llm-message.ts, shell case) and tool error messages don't go through normalizeContent at all. A NUL pass in the request-time sanitizer (packages/ai/src/utils/sanitize.ts, which already walks every request string for lone surrogates) would cover all of these and all providers.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, and thanks — this covers all the entry paths you listed and fixes the reporter's already-poisoned session too, which store-time cleaning can never do. Stripping now happens in sanitizeSurrogates (packages/ai/src/utils/sanitize.ts) next to the surrogate pass, and the store-time change is reverted. New request-time test in packages/ai/test/sanitize.test.ts; commit 777da50.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Oct 23, 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.

The new commit only merges the latest dev. The fix itself is unchanged, and the #58354 reproduction still shows no NUL in any request on the new head. The open point about cleaning NULs only when tool output is stored is not addressed yet: sessions that already contain a NUL, user ! shell output and tool error text are still sent unchanged. The public API is unchanged.

@nik875 nik875 changed the title fix(core): strip NUL bytes from tool output fix(ai): strip NUL bytes from outgoing requests Oct 23, 2026

@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.

The new commit addresses my earlier point. NULs are now removed in the request-time sanitizer, which runs on every outgoing request (route/client.ts, route/media.ts). That covers replayed history, so sessions that already contain a NUL work again. It also covers user ! shell output, tool error text and every provider.

I re-ran the #58354 reproduction: the base still sends the NUL on every later request, and with this change no request contains one. The packages/ai tests pass and tsc is clean. The public API is unchanged. I have no further concerns.

@nik875

nik875 commented Oct 23, 2026

Copy link
Copy Markdown
Author

Took your suggestion in the force-push (777da50) — NULs are now stripped in sanitizeSurrogates at request time instead of at store time, which covers ! shell output, tool errors, and sessions that already have a NUL in history. The normalizeContent change is reverted. Thanks for the review.

@OrangerXu

Copy link
Copy Markdown

Reviewing from #58384 (my two-site capture/relay fix of the same issue, closed in favor of this):

This is the right layer — sanitizeSurrogates already runs on every string of every outgoing request, so one change covers all providers, already-poisoned sessions, ! shell output and failed-tool error text. My version approximated that with per-site stripping; this is strictly better.

One thing worth confirming (it held in my investigation of the same bug): the sanitizer runs on the outgoing request only, so the stored session history keeps its NUL bytes and every request re-sanitizes — that's fine for providers, but opencode session export output will still contain the raw NUL. Harmless if the export path is JSON-escaped, just noting the asymmetry in case anyone debugs from an export.

Also +1 to the escaped "�" form — the bot flagged that my branch's literal NUL made git treat the files as binary, which this PR avoids.

@webpoinz

Copy link
Copy Markdown

Tested this fix on Windows 11 x64 running OpenCode 2.1.4 against Venice AI (deepseek-v4-flash-uncensored).

Can confirm that stripping NUL bytes in sanitizeSurrogates as implemented in commit 777da50 successfully un-bricks poisoned sessions: the provider immediately returns HTTP 200 and autonomous multi-turn execution resumes without issue.

A complementary observation regarding @OrangerXu's point on stored session history:
While handling this in sanitizeSurrogates is definitely the right single site for outgoing provider requests (especially for rescuing already-poisoned sessions and covering non-shell paths), keeping a lightweight NUL strip at capture time in ShellTool (output.output.replace(/\0/g, "")) is also beneficial as a second layer of defense. In my local SQLite database (opencode.db / session_message), persisting raw NULs inside JSON strings causes issues with downstream tooling, FTS indexing, and terminal UI rendering.

Having both (store-time sanitization for clean persistence + sanitizeSurrogates for resilient outgoing requests) provides complete coverage.

@joshua-mo-143

Copy link
Copy Markdown

Looking forward to having this fixed! 🥳

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.

server: shell tool output containing NUL bytes bricks the session with "Invalid request parameters"

4 participants