bin/devkit: fix Windows first-run install (curl+schannel bug, #58) - #59
Conversation
… bug
Windows curl.exe uses the schannel TLS backend, which aborts with
CURLE_WRITE_ERROR (exit 23) partway through responses from
release-assets.githubusercontent.com — the CDN that
github.com/.../releases/download/... 302-redirects to. Git Bash users
on Windows could not install the devkit engine on first run.
Replace download_fresh/download_resume with a unified
download_release_asset helper that tries, in order:
1. gh release download — Go crypto/tls, no schannel.
2. curl -fsSL [-C -] — unchanged Unix path.
3. wget [-c] — Linux fallback.
4. powershell.exe Invoke-WebRequest — Windows last resort,
.NET TLS stack also sidesteps the schannel bug.
gh and PowerShell paths are non-resumable, but the outer checksum
verification loop catches corruption and re-runs, so a bad retry
costs at most one fresh fetch of the ~10MB engine asset.
Also pin bin/devkit to LF via .gitattributes so Git for Windows'
core.autocrlf does not rewrite it with CRLF on checkout.
Fixes #58.
Review — one blocker, two nits, some verified non-issuesTested the change on Windows 11 Git Bash (MSYS2). Overall the shape is right — Blocker: PowerShell fallback uses an MSYS-style path
Reproduction (Windows 11, Git Bash, PowerShell 5.1)out="/c/Users/Public/test.txt"
url="https://raw.githubusercontent.com/git/git/master/README.md"
powershell.exe -NoProfile -Command \
"Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$out'"Output: Exit 1, no file created. Note the Effect on the fixFor Windows users without
So the PR ships a working path only for the Suggested fixConvert the path with case "${PLATFORM-}" in
windows-*)
if command -v powershell.exe >/dev/null 2>&1; then
winout=$out
if command -v cygpath >/dev/null 2>&1; then
winout=$(cygpath -w "$out")
fi
log "trying PowerShell Invoke-WebRequest fallback"
run_downloader powershell.exe -NoProfile -Command \
"Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$winout'" \
&& return 0
fi
;;
esac
Verified on the same box: winout=$(cygpath -w "$out") # → C:\Users\Public\test.txt
powershell.exe -NoProfile -Command \
"Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$winout'"
# exit 0, file downloaded successfullyNits (non-blocking)
Verified non-issuesChecked these and they're all fine, listing so reviewers don't have to re-check:
Open questionOn a failed |
Two issues caught in PR #59 review: 1. The PowerShell fallback embedded the MSYS output path (/c/Users/...) inside a quoted -Command string. MSYS2/Git Bash only auto-converts POSIX paths to Windows paths when they appear as standalone arguments to a native .exe; paths inside a quoted string are just characters, so PowerShell saw `/c/Users/...` and resolved it against the current drive root as `C:\c\Users\...`, failing with DirectoryNotFoundException. In practice this meant the only working path on Windows was `gh release download` -- users without gh hit the same broken state as before this fix. Convert the output path with `cygpath -w` before embedding it in the -Command string. cygpath ships with Git for Windows and MSYS2; the \`winout=\$out\` initializer is a harmless defensive fallback for exotic shells that lack it. 2. Drop any partial $out after a failed `gh release download` before falling through on the resumable path. Without this, curl -C - would resume from whatever byte offset gh had written and splice two downloaders' byte streams into one file; the outer checksum loop catches it on the next invocation, but the extra round-trip is free to avoid.
|
Thanks — all three addressed in Blocker: PowerShell MSYS-path bugFixed. Now runs winout=$out
if command -v cygpath >/dev/null 2>&1; then
winout=$(cygpath -w "$out") || winout=$out
fi
…
run_downloader powershell.exe -NoProfile -Command \
"Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$winout'"The Open question (gh partial bytes poisoning curl resume)Also fixed in the same commit — Nits
Revised Windows verification steps
Ready for another look. |
Blocker verified fixed — end-to-end on Windows 11 Git BashRe-tested
Verification on the fetched branch fileEnd-to-end PowerShell fallback (the previously-broken path)Reproduced with a generic public path so the test matches what a fresh first-run would do: out="/c/Users/Public/devkit-fix-verify.txt"
url="https://raw.githubusercontent.com/git/git/master/README.md"
winout=$out
if command -v cygpath >/dev/null 2>&1; then
winout=$(cygpath -w "$out") || winout=$out
fi
# winout=C:\Users\Public\devkit-fix-verify.txt
powershell.exe -NoProfile -Command \
"Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$winout'"
# exit 0, file created (3662 bytes)Same invocation without the Effective Windows fallback chain post-fix
So Windows users without Remaining (non-blocking) nits — still open but fine to land as-is
Still outstanding for sign-offThe actual first-run LGTM on the code level for the blocker + open question. 👍 |
Correction + two more blockers found during end-to-end testRetracting my earlier "blocker verified fixed" comment (link). That verification tested the The good news: both bugs are small and isolated to Bug A —
|
End-to-end test on Windows 11 / Git Bash (MSYS2) against ccf8a2f exposed two more bugs in the download_release_asset refactor, both in the same function and both blocking first-run install even after the previous cygpath fix to the PowerShell path. Bug A: gh release download -O <posix-abspath> silently no-ops. gh.exe is a native Windows binary; MSYS2 only auto-converts POSIX paths to Windows paths for top-level args that "look" like paths, not for values passed to a flag. So when we invoked gh release download ... -O "/<plugin-dir>/bin/checksums-vX.txt" gh saw the leading `/` as the current drive root, silently failed to write anywhere, and exited 0. The next awk lookup then died on an empty file. Fix: run $out through `cygpath -w` once up front and pass the converted form to both gh.exe and powershell.exe. The existing PowerShell path already did this; hoisting the conversion above the gh block means both native-Windows consumers get a real Windows path. Harmless on macOS/Linux where cygpath is absent (the shell falls back to the untouched POSIX path). Bug B: download_release_asset clobbered caller globals. POSIX /bin/sh has no `local`, so the bare `tag=$1 / asset=$2 / out=$3 / resumable=$4` at the top of the function assigned to the global scope and silently rewrote ensure_engine's own $asset after the first call — turning "devkit-windows-amd64.exe" into "checksums.txt" and breaking the awk lookup in the checksum file with `no checksum entry for checksums.txt`. Fix: rename all four params (and the derived $url / $winout) with a `_` prefix so the function's locals no longer collide with caller state. Namespacing discipline rather than a subshell wrapper — keeps `return` semantics simple and avoids a fork on every call. With both fixes applied, bin/devkit mcp </dev/null from a clean bin/ directory succeeds end-to-end on Windows in both the gh-on-PATH path and the PowerShell-fallback path (gh hidden). Refs #58. Addresses review comment on PR #59.
|
Both end-to-end blockers fixed in Bug A — gh POSIX-path silent no-opFixed. Hoisted the _winout=$_out
if command -v cygpath >/dev/null 2>&1; then
_winout=$(cygpath -w "$_out") || _winout=$_out
fi
…
run_downloader gh release download "$_tag" -R "…" -p "$_asset" -O "$_winout" --clobber
…
run_downloader powershell.exe -NoProfile -Command \
"Invoke-WebRequest -UseBasicParsing -Uri '$_url' -OutFile '$_winout'"Harmless on macOS/Linux — Bug B — global variable clobberFixed. Renamed all four function params plus the derived Verified on a minimal reproduction that the outer Other checks
Ready for another end-to-end run on Windows. If both fallback chains come back green this should be merge-ready. |
Replaces the probe stub at mcpb/launcher/main.go with the real launcher
that ports bin/devkit's download/verify/exec loop to Go. Claude Code
spawns this binary directly on Windows via the MCPB bundle's
platform_overrides.win32 entry, and from there the flow mirrors the
POSIX bin/devkit wrapper end-to-end.
Per-invocation flow:
1. Read CLAUDE_PLUGIN_ROOT from env (CC exports it to every MCP child).
Reject if unset or non-absolute — either state indicates a spoofed
or corrupted launch context.
2. Parse <CLAUDE_PLUGIN_ROOT>/.claude-plugin/plugin.json for the plugin
version.
3. Validate the version against a conservative regex so a corrupted
plugin.json can't inject path traversal into the engine filename
(version gets interpolated into a filename joined to binDir). Regex
allows semver + pre-release tags; explicit ".." / "/" / "\\" guard
as defense in depth.
4. Locate the engine at
<CLAUDE_PLUGIN_ROOT>/bin/devkit-engine-v<version>-windows-amd64.exe
(same cache location as bin/devkit, so nothing needs re-downloading
if the user already ran the engine through the POSIX path).
5. If missing: fetch checksums.txt + the engine asset from the matching
GitHub release, verify SHA-256, atomic os.Rename into place.
6. Best-effort sweep of stale devkit-engine-v* and devkit-checksums-v*
files from other versions in binDir. Matches bin/devkit's find sweep.
7. cmd.Start + cmd.Wait the engine with inherited stdio. Windows has
no execve, so the launcher stays alive as the engine's parent until
it exits. MCP JSON-RPC flows straight through CC's stdio pipes
into the engine with zero buffering or framing on the launcher side.
Exit code is forwarded via os.Exit(exitErr.ExitCode()).
Go stdlib only, no external modules. GOOS=windows GOARCH=amd64
CGO_ENABLED=0 static build. ~6.5MB binary — larger than the probe stub
because it now pulls in net/http, crypto/tls, encoding/json, and
regexp, but still reasonable for the amount of work it does.
Critically, Go's crypto/tls handles the HTTPS fetch — not Windows
schannel — which sidesteps the CDN renegotiation bug that PR #59 had
to work around for curl.exe on release-assets.githubusercontent.com.
So the Windows download path here is more robust than the POSIX one.
Verified on macOS via full mcpb-cache wipe + claude mcp list: the
POSIX proxy still works unchanged (server/devkit still just re-execs
$CLAUDE_PLUGIN_ROOT/bin/devkit), /mcp shows Connected, full MCP
handshake succeeds. This commit only touches the Windows code path.
Pending: end-to-end Windows verification in a real CC UI window.
The probe already proved the spawn layer (platform_overrides.win32,
${__dirname} expansion, PE spawn under .mcpb-cache, env var export).
The real launcher adds download + exec on top; those need a separate
pass to confirm the fetch + checksum path works against the live
GitHub release and the engine child JSON-RPC roundtrips correctly
through the launcher's inherited stdio.
Refs #60
Summary
Fixes #58. Windows Git Bash users cannot install the devkit engine on first run because
curl.exeships with the schannel TLS backend, which aborts withCURLE_WRITE_ERROR(exit 23) partway through responses fromrelease-assets.githubusercontent.com— the CDN thatgithub.com/.../releases/download/...302-redirects to. The wrapper dies at thechecksums.txtdownload step anddevkit-engineis never installed, so the MCP server never starts.Changes
bin/devkitReplaces
download_freshanddownload_resumewith a singledownload_release_assethelper that tries downloaders in order of TLS-stack reliability:gh release download— Gocrypto/tls, no schannel involvement. Preferred.curl -fsSL [-C -]— unchanged Unix path; still fails on Windows schannel.wget [-c]— Linux fallback.powershell.exe Invoke-WebRequest— Windows last resort; .NET TLS stack also sidesteps the schannel bug.The
ghand PowerShell paths are non-resumable (fresh GET,--clobberoverwrites any existing partial). That is acceptable because the outer checksum-verify loop inensure_engine()already catches corruption and re-runs, so a bad retry costs at most one fresh fetch of the ~10MB engine asset..gitattributes(new)Pins
bin/devkitto LF. Git for Windows' defaultcore.autocrlf=truewould otherwise rewrite the wrapper with CRLF on checkout; Git Bash tolerates that today but a strict POSIX/bin/shor a future autocrlf change could break the shebang or embedded heredocs.Verification
bash -n bin/devkit— syntax OK on macOS.gh auth status, runbin/devkit mcp </dev/null— expectinstalled engine at …with no curl errors.ghfromPATH, re-run — expectcurl: (23), thendevkit: trying PowerShell Invoke-WebRequest fallback, then success.bash -n bin/devkiton Windows too.ghis absent — the curl/wget path is identical to before.Test plan
ghinstalled + authed → first-run succeeds viagh release downloadghhidden fromPATH→ curl fails, PowerShellInvoke-WebRequestfallback succeedsbash -n bin/devkitpasses on all three.gitattributesforces LF onbin/devkiton fresh Windows cloneNotes
Not opening as draft because the change is self-contained and reviewable today; mark it draft if you'd rather wait for Windows verification to land first.