Bash tool fails on trivial commands with 'unexpected EOF...line 86' on Windows (v2.1.220)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 5 comments · opened Aug 2, 2026

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_TOOL env 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, a graphify.EXE hook-guard search call) — both tested directly with representative tool_input JSON 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

  1. On Windows, with Claude Code 2.1.220, PowerShell tool present/primary in the environment.
  2. Ask Claude to run any Bash-tool command, e.g. echo test1 && echo test2.
  3. 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.

View original on GitHub ↗

3 Comments

0e96 · 26 days ago

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 -c argv string whose preamble embeds the machine's full export 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 with unexpected 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 buildExecCommand in the 2.1.219 desktop runtime; OP reproduces on 2.1.220):

  • The tool builds A.join(" && ") where A ≈ [source <snapshot> 2>/dev/null || true, export TEMP=… TMP=…, an ~85-line inline preamble (rg/pkill shim functions + a full export PATH='…'), shopt -u extglob …, { unalias 'unsetenv' … }, eval '<command>' < /dev/null, pwd -P >| '<cwd-file>'], and getSpawnArgs passes it as one argv element after -c.
  • Between spawn and bash's argv reception on Windows, the string loses everything past a fixed absolute offset. Nothing fails loudly — bash just parses a truncated prefix.

Evidence (byte-exact, one Windows 11 machine, multiple sessions across 4 weeks):

  1. Formula phase (PATH slightly shorter): the cut fell after 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 truncated pwd -P >| '<cut-path>' silently created junk files named A, Ap, AppD, AppDa in C:\Users\<user>, each containing the pwd output, exit 0.
  2. Lockstep shift: when installers grew the system PATH by exactly 244 bytes (CUDA ×2, Nsight, an IDE bin dir), the shell-snapshot PATH line went 1653→1897 bytes and the failure threshold moved by exactly 244.
  3. Current phase: with the 1897-byte PATH line, the cut lands inside the quoted PATH value → even true fails with -c: line 83: unexpected EOF while looking for matching ''`. Line number 83/85/86 varies with version/env — matching the OP's 86.
  4. Discriminating probe: deleting the session's shell-snapshot file (forcing the "falling back to login shell" path) changes the error to line 85: exp: command not found — the cut now lands mid-word in export, 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.
  5. PowerShell tool calls on the same machine are completely unaffected (different spawn path).

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 / heredoc delimited by end-of-file flavors 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.)

0e96 · 26 days ago

Correction to my previous comment — after capturing the actual spawned command line with WMI while the failure reproduced, two attributions need fixing:

  1. PATH is not inlined in the -c string (it is only sourced 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.sh into 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.
  1. The truncation constant is 8192 bytes, and the loss happens after CreateProcess. WMI captured the spawned bash with the complete 12,024-char command line, yet bash's parse error (-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.sh down 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 the pwd -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.

deafsquad · 23 days ago

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> and bash -n <file>:

/usr/bin/bash: -c: line 59: unexpected EOF while looking for matching `"'

Cause on this machine was the same accumulation defect: ~/.claude/session-env/<session-id>/sessionstart-hook-1.sh had 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_FILE in append mode:

with open(env_file, 'a') as f:                 # <- the defect
    f.write(f'export SUPERVISOR_STATE="{state_json}"\n')
    f.write(f'export SUPERVISOR_INITIALIZED="true"\n')

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_FILE is 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-env files 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:

  • latest snapshot passes bash -n
  • sources non-interactively with bash --noprofile --norc at rc=0

So the fault is in the assembled bash -c argument, 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

  1. Bound or deduplicate session-env output. These are idempotent exports; last-wins collapse is lossless. Unbounded growth of a file that is prepended to every shell invocation has no upside.
  2. Fail loudly when the assembled command exceeds what the shell will receive. A truncation that produces a parse error attributing a line number to a preamble the user cannot see is close to undiagnosable — three separate open issues here reached the same wall.
  3. Re-read the preamble per invocation, or invalidate on change. Memoization is what converts a fixable state into a session that must be discarded.
  4. Document that $CLAUDE_ENV_FILE persists across SessionStarts, so hook authors do not reach for append mode as I did.

Showing cached comments. Read the full discussion on GitHub ↗