You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
server: shell tool output containing NUL bytes bricks the session with "Invalid request parameters" #58354
The Venice AI provider (venice/z-ai-glm-5-3-flash, OpenAI-compatible) rejects request strings containing NUL bytes with a 400:
{"error":"Invalid request parameters","issues":[{"code":"custom","message":"String must not contain null bytes","path":["messages",95,"content"]}]}
opencode captures shell tool output verbatim, so when a command's output contains a NUL byte it goes into session history, and every later request replays it — the provider rejects the whole request from then on. The turn with the tool call itself succeeds; the failure starts on the next prompt and repeats deterministically, so the session is effectively bricked (server log: AI.Error.InvalidRequest: Invalid request parameters). Recovery required reverting to the turn before the poisoned message via the revert API.
Trigger in my case: piping a git-upload-pack capability advertisement through the shell tool — that output legitimately contains HEAD\x00multi_ack thin-pack ....
Plugins
One 12-line custom plugin: a session.retry hook that caps the retry delay at 5 seconds.
OpenCode version
2.0.32
Steps to reproduce
Set the model to venice/z-ai-glm-5-3-flash
Have the agent run a command whose captured output contains a NUL byte, e.g. printf '003dsha HEAD\x00multi_ack\n'
Let the tool-call turn complete (it succeeds)
Send any further message — every request now fails with Invalid request parameters (400)
Thanks for the clear report. I could reproduce this on 2.0.32, and it still happens on the latest dev (09399e3).
What I did, using a simulated model in place of Venice:
The model runs the shell tool with printf '003dsha HEAD\000multi_ack\n'. That turn completes normally.
Every request after that turn, both the follow-up to the tool result and the next user message, contains the tool result verbatim: "content":"003dsha HEAD\u0000multi_ack\n".
So the raw NUL from the command output is stored in the session and sent again on every turn. A provider that rejects NULs, as Venice does, will fail every request from then on, which matches what you saw. The script below reproduces it. The mock stands in for Venice and flags any request that contains a NUL.
Related: #51351 (a NUL in the shell command makes the tool hang) and #57833 (the read tool and invalid UTF-8).
Reproduction script (drive.ts)
// Repro for #58354: shell output containing a NUL byte is replayed verbatim// in every later model request. The simulated model plays a strict provider// (like Venice) and rejects any request whose body contains U+0000.import{Effect,Stream}from"effect"import{Llm,OpenCodeDriver}from"opencode-drive"consthasNul=(req: unknown)=>JSON.stringify(req).includes("\\u0000")exportdefaultOpenCodeDriver.use({
...(process.env.OC_DEV ? {opencode: {dev: process.env.OC_DEV}} : {}),tui: {recording: false},// the renderer cannot draw the NUL cellproject: {git: true},},({ ui, llm })=>Effect.gen(function*(){constseen: boolean[]=[]yield*llm.serve((request,index)=>{constnul=hasNul(request)seen.push(nul)if(nul){constj=JSON.stringify(request);consti=j.indexOf("\\u0000");console.log(`[mock] ...${j.slice(Math.max(0,i-60),i+30)}...`)}console.log(`[mock] request #${index+1}: body contains NUL byte = ${nul}`)if(nul)returnStream.make(Llm.text("MOCK-PROVIDER-400: String must not contain null bytes"))constbody=JSON.stringify(request)if(body.includes("Reply with only the title"))returnStream.make(Llm.text("NUL repro"))if(!body.includes("call_nul"))returnStream.make(Llm.toolCall({index: 0,id: "call_nul",name: "shell",input: {command: "printf '003dsha HEAD\\000multi_ack\\n'",description: "Print NUL"},}),Llm.finish("tool-calls"),)returnStream.make(Llm.text("ok, clean request"))})yield*ui.submit("run the command")yield*ui.waitFor("MOCK-PROVIDER-400",{timeout: 60000}asany)yield*ui.submit("hello again")yield*Effect.sleep(4000)console.log(`[mock] NUL flags per request: ${JSON.stringify(seen)}`)}),)
Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.
Same bug on OpenCode 2.1.4, Windows 11, Venice provider (DeepSeek V4.1 Flash Uncensored).
What I found in the local database:
A shell tool output containing a NUL byte was saved in the session as \u0000 inside the stored message JSON.
Every later request in that session resent it. Venice's response body says: "error":"Invalid request parameters","issues":[{"code":"custom","message":"String must not contain null bytes","path":["messages",51,"content" ...
The same NUL was found in 4 sessions (6 stored messages). Starting a new session avoids it.
Repro:
Ask the agent to run: node -e "process.stdout.write('a\0b\n')"
Send any follow-up message.
The request fails with "Invalid request parameters".
Suggested fix:
Strip NUL characters (and other control characters except tab, newline and carriage return) from tool output before it is saved, and again from the messages before they are sent to the provider.
Show the provider's issues array in the error. "Invalid request parameters" alone does not say what to fix.
Confirming on 2.1.4 with venice/deepseek-v4-1-flash-1017, plus two details that may matter for the fix.
1. The bug propagates to other sessions
The first bricked session was poisoned by a binary SDB (Samsung TV) protocol dump. The session I then started to investigate it bricked itself by running tail on a log file that happened to contain gzip-compressed SSE bodies — 5 NUL bytes in one tool result.
So an agent does not need to generate binary output itself. Merely reading a binary artifact into a tool result (log, sqlite file, pcap, core dump) is enough. Any session can be taken out this way, including one being used to debug another, which makes this self-amplifying: the instinct to tail a suspicious log is exactly the thing that kills the investigator.
2. Recovery while keeping history
session_message.data is JSON, so the NUL is stored as the escape \u0000. Searching the raw column for a NUL byte finds nothing — the offending character only exists after parsing the row.
-- SQLite does not interpret backslash escapes, so '\u0000' here is the-- 6-character JSON escape sequence, which is what we want to remove.update session_message
set data = replace(data, '\u0000', '')
where data like'%\u0000%';
Back up first with sqlite3 opencode.db ".backup 'backup.db'", and restart the TUI/serve afterwards, since the server can resend a message it already has cached in memory.
In my case this repaired 2 rows across 2 sessions: pragma integrity_check → ok, message counts unchanged, and both sessions then answered normally (opencode run -s <id> "Reply with PONG" → PONG, exit 0).
One caveat for anyone scripting this: don't blunt-edit the JSON text. A string that legitimately contains the literal characters \u0000 is stored as \\u0000, and replace(data, '\u0000', '') will eat part of that too, leaving a lone \ and corrupting the row. Parse each row as JSON and strip U+0000 from every string instead — that is what I ended up using, and it is safe to re-run.
On the guard
Agree with the earlier comment: sanitize both on write and on replay. Replay-time sanitization matters more for users, because it also recovers sessions that are already bricked. For the record, only U+0000 was rejected by Venice — other control bytes in the same output (0x02, 0x0C) were accepted, so a narrow sanitizer is enough.
Still happens on 2.2.1 (Windows 11, PowerShell shell tool), and it is not Venice-specific: our self-hosted OpenAI-compatible gateway (@ai-sdk/openai-compatible) rejects NUL the same way, with a 400 Request contains invalid null byte character at $["messages"][51]["content"].
Trigger: the agent ran Get-Content <ELF binary> -TotalCount 3 to check whether a file was a git-LFS pointer. PowerShell printed the raw bytes, and the tool output stored 745 NUL bytes (as \u0000 in session_message.data). Every later request in that session failed.
Until #58361 ships, here is a global plugin workaround (the escape handling is unit-checked, not yet verified end to end against the provider). It strips the \u0000 escapes from the outgoing JSON body and keeps a literal \u0000 written as text:
For the session that was already stuck, I edited opencode.db directly instead: I replaced the binary part of that one tool output in session_message.data.
Confirming on 2.2.5 (current release), Windows 11 (10.0.26300), venice via @ai-sdk/openai-compatible.
Same trigger shape: a shell tool call dumped raw HTTP request/response bodies (base64 plus binary), so 44 NUL bytes landed in one tool result. Venice then returned:
{"error":"Invalid request parameters",
"issues":[{"code":"custom","message":"String must not contain null bytes",
"path":["messages",986,"content"]}]}
Two things that may be worth adding to the report:
There is no in-app way out of a poisoned session./compact fails with the same 400, and switching to a different model fails too, because compaction rebuilds the same history. In my session the last successful turn is followed by every follow-up prompt, the manual compaction, and the model switch all failing with this provider error. The user is left with an unusable session and no supported recovery path.
Editing the database can be enough; a restart may not be needed. After parsing that one row as JSON and stripping U+0000 from every string (the blunt replace() caveat above is real - I used the JSON-aware version), the already-running server built a clean payload on the next GET /api/session/{id}/context, with no restart. So the "restart afterwards" advice seems to depend on whether the process already has that message in memory - the context endpoint reflected the fix immediately here.
+1 for strip-on-replay inside sanitizeSurrogates (#58361). Replay-time sanitization is the part users need most, since it also un-bricks sessions whose history is already dirty.
Activity
opencode-agent commentedon Oct 23, 2026
Thanks for the clear report. I could reproduce this on 2.0.32, and it still happens on the latest
dev(09399e3).What I did, using a simulated model in place of Venice:
printf '003dsha HEAD\000multi_ack\n'. That turn completes normally."content":"003dsha HEAD\u0000multi_ack\n".So the raw NUL from the command output is stored in the session and sent again on every turn. A provider that rejects NULs, as Venice does, will fail every request from then on, which matches what you saw. The script below reproduces it. The mock stands in for Venice and flags any request that contains a NUL.
Related: #51351 (a NUL in the shell command makes the tool hang) and #57833 (the read tool and invalid UTF-8).
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.README + plugin header: cite upstream issue anomalyco/opencode#58354
aadqidjasda commentedon Oct 24, 2026
Same bug on OpenCode 2.1.4, Windows 11, Venice provider (DeepSeek V4.1 Flash Uncensored).
What I found in the local database:
\u0000inside the stored message JSON."error":"Invalid request parameters","issues":[{"code":"custom","message":"String must not contain null bytes","path":["messages",51,"content" ...Repro:
node -e "process.stdout.write('a\0b\n')"Suggested fix:
issuesarray in the error. "Invalid request parameters" alone does not say what to fix.hobodave commentedon Oct 26, 2026
Confirming on 2.1.4 with
venice/deepseek-v4-1-flash-1017, plus two details that may matter for the fix.1. The bug propagates to other sessions
The first bricked session was poisoned by a binary SDB (Samsung TV) protocol dump. The session I then started to investigate it bricked itself by running
tailon a log file that happened to contain gzip-compressed SSE bodies — 5 NUL bytes in one tool result.So an agent does not need to generate binary output itself. Merely reading a binary artifact into a tool result (log, sqlite file, pcap, core dump) is enough. Any session can be taken out this way, including one being used to debug another, which makes this self-amplifying: the instinct to
taila suspicious log is exactly the thing that kills the investigator.2. Recovery while keeping history
session_message.datais JSON, so the NUL is stored as the escape\u0000. Searching the raw column for a NUL byte finds nothing — the offending character only exists after parsing the row.Back up first with
sqlite3 opencode.db ".backup 'backup.db'", and restart the TUI/serve afterwards, since the server can resend a message it already has cached in memory.In my case this repaired 2 rows across 2 sessions:
pragma integrity_check→ok, message counts unchanged, and both sessions then answered normally (opencode run -s <id> "Reply with PONG"→PONG, exit 0).One caveat for anyone scripting this: don't blunt-edit the JSON text. A string that legitimately contains the literal characters
\u0000is stored as\\u0000, andreplace(data, '\u0000', '')will eat part of that too, leaving a lone\and corrupting the row. Parse each row as JSON and strip U+0000 from every string instead — that is what I ended up using, and it is safe to re-run.On the guard
Agree with the earlier comment: sanitize both on write and on replay. Replay-time sanitization matters more for users, because it also recovers sessions that are already bricked. For the record, only U+0000 was rejected by Venice — other control bytes in the same output (0x02, 0x0C) were accepted, so a narrow sanitizer is enough.
AlexeyShalaev commentedon Oct 29, 2026
Still happens on 2.2.1 (Windows 11, PowerShell shell tool), and it is not Venice-specific: our self-hosted OpenAI-compatible gateway (
@ai-sdk/openai-compatible) rejects NUL the same way, with a 400Request contains invalid null byte character at $["messages"][51]["content"].Trigger: the agent ran
Get-Content <ELF binary> -TotalCount 3to check whether a file was a git-LFS pointer. PowerShell printed the raw bytes, and the tool output stored 745 NUL bytes (as\u0000insession_message.data). Every later request in that session failed.Until #58361 ships, here is a global plugin workaround (the escape handling is unit-checked, not yet verified end to end against the provider). It strips the
\u0000escapes from the outgoing JSON body and keeps a literal\u0000written as text:For the session that was already stuck, I edited
opencode.dbdirectly instead: I replaced the binary part of that one tool output insession_message.data.logicxking54 commentedon Oct 31, 2026
Confirming on 2.2.5 (current release), Windows 11 (10.0.26300),
venicevia@ai-sdk/openai-compatible.Same trigger shape: a
shelltool call dumped raw HTTP request/response bodies (base64 plus binary), so 44 NUL bytes landed in one tool result. Venice then returned:{"error":"Invalid request parameters", "issues":[{"code":"custom","message":"String must not contain null bytes", "path":["messages",986,"content"]}]}Two things that may be worth adding to the report:
There is no in-app way out of a poisoned session.
/compactfails with the same 400, and switching to a different model fails too, because compaction rebuilds the same history. In my session the last successful turn is followed by every follow-up prompt, the manual compaction, and the model switch all failing with this provider error. The user is left with an unusable session and no supported recovery path.Editing the database can be enough; a restart may not be needed. After parsing that one row as JSON and stripping U+0000 from every string (the blunt
replace()caveat above is real - I used the JSON-aware version), the already-running server built a clean payload on the nextGET /api/session/{id}/context, with no restart. So the "restart afterwards" advice seems to depend on whether the process already has that message in memory - the context endpoint reflected the fix immediately here.+1 for strip-on-replay inside
sanitizeSurrogates(#58361). Replay-time sanitization is the part users need most, since it also un-bricks sessions whose history is already dirty.