Bash tool fails on trivial commands with 'unexpected EOF...line 86' on Windows (v2.1.220)
Version: Claude Code 2.1.220 (confirmed latest at time of filing)
OS: Windows 11 Pro (build 10.0.26200)
Shell config: PowerShell listed as primary shell in environment; Bash tool also available
Bug
Every Bash-tool invocation fails, even trivial ones with no quoting or multi-line content:
echo test1 && echo test2
Result:
Exit code 2
/usr/bin/bash: -c: line 86: unexpected EOF while looking for matching `''
The line number cited ("line 86") does not correspond to anything in the actual command sent — this points to a bug in Claude Code's internal wrapper/generated script around the user command, not in the command itself.
What we ruled out
CLAUDE_CODE_USE_POWERSHELL_TOOLenv var — confirmed unset everywhere (~/.claude/settings.json,~/.claude/settings.local.json, User/Machine env vars, current process env). This var was a real cause of similar corruption in an earlier session, but is fully removed now and the bug still reproduces.- PreToolUse hooks on the Bash matcher (
rtk hook claude, agraphify.EXE hook-guard searchcall) — both tested directly with representativetool_inputJSON piped to stdin, both exit 0 cleanly with no output/mutation. - Git Bash itself — running the exact same command directly via
& "C:\Program Files\Git\bin\bash.exe" -c "echo test1 && echo test2"(bypassing the Claude Code harness entirely) works fine, exit 0, correct output.
This isolates the bug to Claude Code's own command-wrapping/execution layer for the Bash tool on Windows, not to any user-side hook, env var, or the underlying Git Bash install.
Repro steps
- On Windows, with Claude Code 2.1.220, PowerShell tool present/primary in the environment.
- Ask Claude to run any Bash-tool command, e.g.
echo test1 && echo test2. - Observe
unexpected EOF while looking for matching ''at "line 86" — a line number with no correspondence to the actual command.
Expected
Simple Bash-tool commands should execute normally, as they do when the exact same command is run directly through Git Bash outside the harness.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Root-cause analysis from a machine hitting this exact signature (trivial commands,
-c: line 83/85: unexpected EOF while looking for matching''`, every Bash call failing while the same command works in Git Bash directly):**TL;DR: the Bash tool assembles each command into a single
bash -cargv string whose preamble embeds the machine's fullexport PATH='…'line, and that string is silently truncated at a fixed absolute offset (empirically ~4 KB). Once the machine's PATH grows long enough, the cut lands inside the quoted PATH value — from that point on every command dies withunexpected EOF while looking for matching'`, regardless of the command's content or length. The "line 86" in the OP is the line number of the embedded PATH export inside the generated preamble, which is why it corresponds to nothing in the user command.**Mechanism (read out of the bundled
buildExecCommandin the 2.1.219 desktop runtime; OP reproduces on 2.1.220):A.join(" && ")where A ≈ [source <snapshot> 2>/dev/null || true,export TEMP=… TMP=…, an ~85-line inline preamble (rg/pkill shim functions + a fullexport PATH='…'),shopt -u extglob …,{ unalias 'unsetenv' … },eval '<command>' < /dev/null,pwd -P >| '<cwd-file>'], andgetSpawnArgspasses it as one argv element after-c.Evidence (byte-exact, one Windows 11 machine, multiple sessions across 4 weeks):
eval ', and the max workable command length obeyed budget = 65 − len(surviving cwd-path suffix) exactly across 8 measured lengths (45→20-char surviving path, 46→19, 48→17, 49→16, 50→15, 58→7, 65→0). Worse: commands under the budget "succeeded" while the truncatedpwd -P >| '<cut-path>'silently created junk files namedA,Ap,AppD,AppDainC:\Users\<user>, each containing the pwd output, exit 0.truefails with-c: line 83: unexpected EOF while looking for matching''`. Line number 83/85/86 varies with version/env — matching the OP's 86.line 85: exp: command not found— the cut now lands mid-word inexport, and the 84 preamble lines before it execute fine. This shows the preamble is inlined independently of the snapshot file and the truncation offset stays fixed while content shifts.Likely same bug in different clothes: the apostrophe theory in #83871 (no apostrophe needed — the unmatched quote is the PATH export's own), the mid-session onset in #82376 (a snapshot regeneration absorbing a PATH change moves the cut), #81732, and possibly #71591 (backtick variant). #50191 (snapshot-source wrapper accumulating across subagent dispatch) is a sibling defect in the same snapshot/preamble mechanism with a different manifestation. The truncation also yields
Is a directory/Permission denied/ heredocdelimited by end-of-fileflavors depending on where the cut lands, which sends users hunting quoting bugs.Suggested fix: pass the assembled script via stdin or a temp file instead of a single argv element — or at minimum detect the assembled length and fail loudly. (Same silent-truncation pattern as the timeout-parameter clamp in #83824.)
Correction to my previous comment — after capturing the actual spawned command line with WMI while the failure reproduced, two attributions need fixing:
-cstring (it is onlysourced via the snapshot file). Trimming the machine PATH by 613 bytes changed nothing — a clean negative control. What actually bloats the wrapper is the session environment script: the harness concatenates every~/.claude/session-env/<session-id>/(setup|sessionstart|cwdchanged|filechanged)-hook-N.shinto every Bash invocation, and SessionStart hook output is appended to the same file on each session resume/compact/restart, never deduplicated. On this machine a plugin's SessionStart hook emits 3 export lines (~289 bytes); one long-lived session accumulated 40 identical copies = 11.5 KB (worst file on disk: 21.9 KB). Same accumulation defect family as #50191.-c: line 83: unexpected EOF while looking for matching'') sits exactly at the 8192-byte boundary of the-c` argument (line-by-line byte accounting). The full string reaches Windows process creation; only the first 8 KB survive into bash's parsing — the drop is on the MSYS reception side, but the harness-side defects stand: (a) unbounded accumulation of session-env hook output, (b) no length check or loud failure when the wrapper exceeds what the shell will receive.Repro without touching PATH: any SessionStart hook that emits env exports + resume/compact the same session ~26+ times → every Bash call dies with this issue's exact signature (the line number = wherever the 8192 boundary lands in the accumulated preamble). Interim user-side fix: deduplicate
session-env/<session-id>/sessionstart-hook-0.shdown to the last copy — takes effect after the session host restarts (the env script is memoized per session id).What remains true from my earlier comment: the false-success phase (silent junk files in
%USERPROFILE%+ exit 0 when the cut lands in thepwd -P >|tail), the phenotype spectrum, and the snapshot-deletion discriminating probe. The byte-exact "PATH grew 244 ↔ threshold shrank 244" lockstep I cited was two co-occurring events at one session resume: the snapshot regeneration absorbed the PATH change at the same moment one ~289-byte hook copy was appended.Independent corroboration on 2.1.224 (Windows 11 Pro, Git Bash), with two additions: a non-plugin trigger, and a positive proof of the memoization that explains why the user-side fix cannot self-heal a live session.
Matches the root-cause comment above
Every Bash tool call in one long-lived session failed with the same wrapper error regardless of content — including
ls <dir>andbash -n <file>:Cause on this machine was the same accumulation defect:
~/.claude/session-env/<session-id>/sessionstart-hook-1.shhad grown to 8100 bytes / 60 lines.Addition 1 — the trigger was a plain SessionStart hook, not a plugin
The report above attributes the emission to a plugin's SessionStart hook. Here it was an ordinary user hook configured in
settings.json, writing to$CLAUDE_ENV_FILEin append mode:Claude Code never truncates that file between SessionStarts, so each resume/compact appended another copy. 30 copies accumulated over ~3 weeks — 29 of them dead, since these are plain sequential exports and only the last assignment ever takes effect.
Worth stating plainly for other users hitting this: append mode on
$CLAUDE_ENV_FILEis a latent session-killer. Nothing in the hook contract signals that the file survives across SessionStarts, and the failure surfaces weeks later in a completely unrelated tool.Scanning all
session-envfiles on this machine: 107 files, 11 accumulating, worst 9916 bytes, total 63784 → 28676 after collapsing each to last-wins.Addition 2 — proof the preamble is memoized at session start
The interim fix above notes it "takes effect after the session host restarts". Here is direct evidence for that, which also rules out a live self-heal:
After collapsing the file from 60 lines to 2 lines, the very next Bash call in the same session still failed with
line 59. There is no line 59 in a 2-line file. The harness is therefore serving a copy of the preamble captured at session start, not re-reading the file per invocation.Practical consequence: a session already in this state cannot be repaired from inside itself — not by fixing the hook, not by deduplicating the file. It stays bricked until the session host restarts. That is worth surfacing in the error path, because from inside the session the fix appears to do nothing.
Negative control — shell snapshots are not implicated
Consistent with #81732, the snapshot content is clean here:
bash -nbash --noprofile --norcat rc=0So the fault is in the assembled
bash -cargument, not in snapshot data.Scope note
I did not capture the spawned command line, so I am not claiming the byte-exact position of the cut on this machine — only that the accumulated preamble was 8100 bytes, that the cited line number falls inside it, and that collapsing the file (plus a restart) resolves it. The 8192 constant is the earlier commenter's measurement, not independently re-derived here.
Suggested harness-side guards, in priority order
session-envoutput. These are idempotent exports; last-wins collapse is lossless. Unbounded growth of a file that is prepended to every shell invocation has no upside.$CLAUDE_ENV_FILEpersists across SessionStarts, so hook authors do not reach for append mode as I did.