fix(windows): real Go launcher for devkit MCPB bundle (closes #60) - #62
Conversation
Proving-ground probe — NOT FOR MERGE. This verifies that CC's plugin.json
-> mcpb path -> platform_overrides.win32 runtime wiring actually fires end-
to-end on a real Windows box in the MCP UI spawn context.
Architecture being proved:
plugin.json.mcpServers: "./devkit.mcpb"
-> CC detects zip, unpacks to <plugin-root>/.mcpb-cache/<hash>/
-> reads manifest.json, resolves platform_overrides.<current-platform>
-> spawns the resolved command with ${__dirname} = <unpacked dir>
POSIX side (verified locally on macOS via claude mcp list):
platform_overrides base = ${__dirname}/server/devkit
-> server/devkit is a 5-line shell proxy that re-execs
$CLAUDE_PLUGIN_ROOT/bin/devkit with the original args.
-> The existing wrapper does all the download/checksum/resume/exec logic
unchanged. Full MCP handshake succeeds, /mcp shows Connected, no
behavior change versus pre-MCPB.
-> Cold-start test (rm -rf .mcpb-cache) confirms CC re-unpacks the
bundle automatically on the next mcp list.
Windows side (the thing we actually need the reporter to verify):
platform_overrides.win32 = ${__dirname}/server/devkit.exe
-> server/devkit.exe is a tiny (~1.7MB) statically-linked Go stub,
cross-compiled with GOOS=windows GOARCH=amd64 CGO_ENABLED=0.
-> The stub does NOT implement MCP. It prints a distinctive marker
("DEVKIT-WIRING-PROBE: WIN32_OVERRIDE_ACTIVE") plus diagnostic
info (args, cwd, executable path, CLAUDE_PLUGIN_ROOT,
CLAUDE_PLUGIN_DATA, truncated PATH) to stderr, then sleeps 60s.
-> /mcp WILL report this as failed to connect — that's expected and
fine. We're verifying the spawn layer, not the protocol layer.
The stderr marker in CC's MCP error log tells us:
1. plugin.json -> mcpb path wiring works on Windows
2. mcpb unpack works on Windows
3. platform_overrides.win32 fires (not the POSIX base branch)
4. ${__dirname} expands to a CreateProcess-compatible path
5. A PE binary under .mcpb-cache/<hash>/server/ actually runs
in CC's MCP child process context
6. CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA are exported to
MCP children on Windows (we need this for the real launcher)
If all six pass, the architecture is proven and a follow-up PR replaces
this stub with the real launcher — a Go port of bin/devkit's download/
checksum/resume loop that also stdio-proxies into the downloaded engine.
Files:
mcpb/manifest.json — platform_overrides schema, kubefwd-pattern
mcpb/server/devkit — POSIX shell proxy to $CLAUDE_PLUGIN_ROOT/bin/devkit
mcpb/launcher/main.go — Go source for the Windows stub
mcpb/launcher/go.mod — Go module definition
devkit.mcpb — packed bundle (manifest + server/), referenced
from plugin.json.mcpServers
.claude-plugin/plugin.json — swap inline mcpServers for mcpb path ref
Rollback for the reporter: copy bin/devkit back into plugin.json as the
command (pre-probe state) and rm devkit.mcpb.
Refs #60
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
✅ Verified end-to-end on Windows — UI green, real tool call roundtripsStaged this branch's This is the load-bearing evidence I was missing in earlier rounds — a visual check in a freshly-launched CC window. Earlier "validated" claims in #60 came from Node spawn repros inside Git Bash and from What was exercised
Notes on the launcher code (
|
plugin.json mcpServers is now a string pointing at ./devkit.mcpb,
not the old inline {command, args, env} object. The jq query
'.mcpServers["devkit-engine"].command' fails with "Cannot index
string with string" now that the field is a path reference.
Replace with three assertions that match the new architecture:
mcpServers literal, bundle file present, and the bundle contains
manifest.json + server/devkit + server/devkit.exe with the Windows
launcher wired into platform_overrides.win32.
Mega-review on PR #62 (Claude tri-review + Codex + 4 pr-review-toolkit agents) flagged two blocker-severity silent failures, a handful of high-severity hardening opportunities, and a CI drift class that wasn't previously covered. This commit addresses all of them, adds Go tests for the security-sensitive paths, and wires the new assertions into CI. Launcher fixes (mcpb/launcher/main.go) - Self-heal corrupt cache: on non-ExitError from cmd.Run, remove the cached engine so the next launch re-downloads instead of looping forever against a truncated or wrong-architecture binary. - Distinguish os.IsNotExist from other stat errors in the cache probe (renamed engineLooksCached for accuracy). Permission/ACL failures now surface via logf instead of silently manifesting as "first-run download" on every invocation. - Clamp exitErr.ExitCode() < 0 to 1 before os.Exit so abnormal Windows termination doesn't pass through as 0xFFFFFFFF. - Propagate the deferred out.Close() error in downloadTo via a named return, so late AV quarantine / disk quota / SMB sync-on-close failures no longer vanish. - Add explicit Content-Length vs io.Copy byte-count check to surface short reads as "short read from URL" instead of hiding inside a later checksum mismatch. - Cap plugin.json read at 64 KiB via LimitReader. - Pin stdlib log package to stderr in main() so a future refactor can't leak into stdout and corrupt MCP JSON-RPC framing. - Normalize CRLF in findChecksum so a Windows-produced checksums.txt doesn't leave a trailing \r and silently miss the asset match. - Comment rot cleanup: drop stale "v2.1.7" banner, rewrite the schannel WHY as symptom-based (CURLE_WRITE_ERROR on release-assets CDN), delete bin/devkit cross-references, drop the "~8 MB" specific-numbers rot in httpTimeout, honest framing on the bin/devkit parity claim. Tests (mcpb/launcher/main_test.go, new) - Table-driven validateVersion with ~20 rows including the full path traversal / injection / encoding set. Converts the defense-in-depth comment at validateVersion into an enforceable invariant. - Table-driven findChecksum covering CRLF (Windows builder), binary-mode star prefix, multi-line files, and error cases. - sweepStaleEngines scenario test locking the prefix match against over-deletion refactors (README.md, unrelated binaries preserved). - engineLooksCached + readPluginVersion (including oversize guard). CI additions (.github/workflows/ci.yml) - New mcpb-launcher-test job: go vet / go test / gofmt on the launcher module so Go regressions fail CI instead of shipping in the next rebuild. - New mcpb-bundle-integrity job: rebuilds the Windows launcher from source with deterministic flags (-trimpath, -s -w, pinned Go version via go.mod), then sha256-compares against the bundled .exe and diff-compares manifest.json and server/devkit against their sources. This is the critical drift guard — previously, someone could edit main.go without rebuilding devkit.mcpb and CI would greenlight stale runtime behavior because the static assertions still matched the old bundle contents. - PE-header check on the bundled server/devkit.exe (MZ magic) catches probe-stub regressions and wrong-architecture commits. - Shebang check on the bundled server/devkit catches mode-bit loss or file-type substitution. - Extend the existing shellcheck step to cover mcpb/server/devkit in addition to bin/devkit. Bundle rebuild - mcpb/server/devkit.exe rebuilt from the updated source with GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w". Reproducible across local and CI given the pinned Go 1.26.1 in mcpb/launcher/go.mod. - devkit.mcpb rezipped with the new .exe, unchanged manifest.json and POSIX proxy.
The previous bundle-integrity job cross-compiled the Windows launcher in CI and compared sha256 of the freshly built binary against the one unpacked from devkit.mcpb. That check worked locally (same host) but failed in Ubuntu CI against a Mac-built bundle — Go cross-builds are not byte-identical across host OSes even with -trimpath and a pinned toolchain version, because the linker embeds a build ID derived from host state that survives -trimpath. Replace the fragile binary compare with a portable source-hash sidecar: - New devkit.mcpb.sources.json records the sha256 of every source file the bundle depends on (main.go, go.mod, main_test.go, manifest.json, server/devkit). Committed next to devkit.mcpb. - New bin/mcpb-build is the single definition of "rebuild the bundle": cross-compile the launcher, rezip, regenerate the sidecar. Dev and CI both call it, so there's no drift between "how I built it" and "how CI wants it built." - mcpb-bundle-integrity job now verifies the committed source files still hash to what the sidecar recorded. If someone edits main.go without running bin/mcpb-build, CI fails with "rebuild with: bin/mcpb-build." Same drift-detection guarantee, no reproducibility requirement on the Go toolchain across hosts. - The manifest.json / server/devkit diff checks already worked (text files survive cross-host builds byte-identically) and stay. - bin/mcpb-build added to the shellcheck step for coverage. Also regenerate devkit.mcpb and devkit.mcpb.sources.json via the new script to establish the baseline.
Four reviewers audited the first remediation and landed on a second
wave of real findings. Two blockers and six high-severity issues.
Blockers
- downloadTo swallowed close errors on the io.Copy / Sync failure
branches — only the happy path surfaced them. Fix: errors.Join the
close error onto the primary so both are preserved.
- readPluginVersion used io.LimitReader, which silently truncates to
the cap without reporting oversize. A 64 KiB-plus plugin.json whose
first 64 KiB parses as valid JSON would return the wrong version
with no error. The prior "oversized" test used garbage that json-
fails regardless of the limit, so the test gave false confidence.
Fix: stat first, hard-reject if info.Size() > cap. Test rewritten
to use a genuinely oversize file whose head is valid JSON.
High
- run() logged and continued on non-IsNotExist stat errors. Fix:
return the wrapped error directly.
- Content-Length check used ">0" which skipped chunked (ContentLength
== -1) and zero-length bodies. Fix: flip to ">=0" so advertised
Content-Length is always honored, and reject empty-body responses
explicitly.
- execEngine discarded os.Remove's error on the corrupt-cache
self-heal path. "Cache purged" was a lie when remove failed. Fix:
capture rmErr and surface it.
- bin/mcpb-build lacked pipefail. Empty hashes could silently land in
the sidecar. Fix: switch to bash with set -euo pipefail, add
explicit empty-hash assertion, drop stderr suppression on the
hasher probe.
- bin/mcpb-build ran 'rm -f devkit.mcpb' before the zip step, so a
failing zip left the repo with no bundle. Fix: tempfile-then-rename
for both the bundle and the sidecar.
- Sidecar did not track mcpb/server/devkit.exe or mcpb/launcher/go.sum,
so manual .exe tampering and dep pin drift were invisible. Fix:
track both; go.sum is optional.
CI
- mcpb-bundle-integrity now reads the tracked-file list from the
sidecar via 'jq keys' — bin/mcpb-build is the single source of
truth for what's tracked.
- Added a present-on-disk check so a sidecar entry referencing a
deleted file fails fast.
Tests
- TestExecEngineRemovesCorruptCache exercises the self-heal path via
a non-binary with exec permissions. execve rejects as non-ExitError
and the fix must os.Remove so the next run re-downloads.
- TestValidateVersionDefenseInDepth widens versionPattern to '^[ -~]+$'
so the explicit Contains("..") and ContainsAny guards are exercised
independently of the regex.
- TestReadPluginVersion boundary row asserts a valid manifest under
the cap still parses, locking the size guard at both ends.
Comments
- Restored a one-line WHY above httpTimeout (over-corrected in round 1).
- Tightened execEngine's self-heal comment to just the CreateProcess
rejection case.
Bundle rebuilt via bin/mcpb-build. Sidecar now includes .exe sha256.
Summary
Fixes Windows
/mcpUI failure whereplugin:devkit:devkit-engineshowed× failedbecausebin/devkitis an extensionless POSIX shell script that WindowsCreateProcesscan't execute. Ships the MCP server as an MCPB bundle withplatform_overrides.win32pointing at a native Go launcher, which CCCreateProcess's directly. No shell dependency, no.cmd/.batin the spawn chain (CVE-2024-27980 blocked those), noplugin.jsonschema branching (CC's inlinemcpServers.*ish.strictObject— doesn't support conditional keys).Closes #60. Supersedes #61 — that PR's
command: "sh"approach is a Windows regression because CC's UI MCP child PATH doesn't include Git Bash (confirmed from real CC debug log on Windows, not just Node spawn repros).Architecture
manifest.jsonuses the exact kubefwd pattern — base command points at the POSIX entry,platform_overrides.win32swaps to the.exe:{ "server": { "type": "binary", "entry_point": "server/devkit", "mcp_config": { "command": "${__dirname}/server/devkit", "args": ["mcp"], "platform_overrides": { "win32": { "command": "${__dirname}/server/devkit.exe", "args": ["mcp"] } } } } }POSIX side — zero behavior change
mcpb/server/devkitis a 5-line shell proxy that re-execs$CLAUDE_PLUGIN_ROOT/bin/devkit "$@". The existing wrapper does all the download/checksum/resume/exec work unchanged.bin/devkitstays in place exactly as it is onmain. POSIX users see byte-identical behavior — same download logic, same cache location (bin/devkit-engine-v<ver>-<platform>[.exe]), same atomic install, same engine exec.Verified on macOS via cold-start test: wiped
.mcpb-cache/, ranclaude mcp list, CC re-unpacked the bundle, POSIX proxy fired, full MCP handshake succeeded,✓ Connected.Windows side — new Go launcher
mcpb/server/devkit.exeis a statically-linked Go binary (GOOS=windows GOARCH=amd64 CGO_ENABLED=0,-s -w,-trimpath, stdlib-only, ~6.5 MB). Portsbin/devkit's flow to Go. Per-invocation:CLAUDE_PLUGIN_ROOTfrom env (CC exports it to every MCP child). Reject if unset or non-absolute — either state indicates a spoofed/corrupted launch context.$CLAUDE_PLUGIN_ROOT/.claude-plugin/plugin.jsonfor the plugin version.^[0-9]+(\.[0-9]+){0,3}(-[A-Za-z0-9.]+)?$) + explicit..///\guard. Security boundary:versiongets interpolated into a filename joined tobinDir, so a corruptedplugin.jsonmust not be able to escapebinDirvia path traversal.$CLAUDE_PLUGIN_ROOT/bin/devkit-engine-v<version>-windows-amd64.exe. Same cache location asbin/devkit, so if the user somehow already has the engine from a prior run, it's reused.checksums.txt+ the engine asset from the matching GitHub release, verify SHA-256, atomicos.Renameinto place. Intermediate files (.sums.tmp,.partial) cleaned up on success and failure.devkit-engine-v*,devkit-checksums-v*) from other versions inbinDir. Best-effort, non-fatal. Matchesbin/devkit'sfindsweep.cmd.Start+cmd.Waitthe engine with inherited stdio. Windows has noexecve, 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 forwarded viaos.Exit(exitErr.ExitCode()).Structural improvement over POSIX
bin/devkit: Go'scrypto/tlshandles the HTTPS fetch, not Windows schannel, so the CDN renegotiation bug that #58/#59 worked around forcurl.exeonrelease-assets.githubusercontent.comdoesn't apply here. The Windows download path is more robust than the POSIX one — no need for the gh/curl/wget/PowerShell fallback tree.Probe already confirmed on a real Windows CC UI (full writeup in the earlier #60 comments, debug-log evidence preserved):
plugin.json → mcpb pathwiring fires on Windows ✓.mcpb-cache/<hash>/works on Windows ✓platform_overrides.win32resolver fires (spawns.exe, not POSIX entry) ✓${__dirname}expands to a CreateProcess-compatible Windows path with drive letter + backslashes ✓.mcpb-cache/<hash>/server/actually runs in CC's MCP child context ✓CLAUDE_PLUGIN_ROOTandCLAUDE_PLUGIN_DATAare exported to Windows MCP children, correctly scoped to devkit (not cross-contaminated) ✓This PR replaces the probe stub with the real launcher. End-to-end Windows validation (full MCP JSON-RPC roundtrip +
devkit_listtool call + GitHub-fetch smoke) is in progress on the reporter's box.Dead-end paths (documented so they don't get re-tried)
bin/devkit.cmdsibling, unchangedplugin.jsonspawn('.../bin/devkit')ENOENTs regardless of what sits next to it.plugin.json→bin/devkit.cmd.cmd/.batwithoutshell: trueand throwsEINVAL. CC's MCP launcher doesn't opt in.commandinplugin.jsonmcpServers.*inline ish.strictObject({command, args, env}). Extra keys rejected at parse time. MCPB is the only way to get platform branching in CC plugin config.bin/devkit.sh+ shipbin/devkit.exe+ platform select in plugin.jsoncommand: "sh"wrapper (#61)C:\...\micromamba\condabin;C:\Python314\...;C:\Program Files\Oculus\...— no Git Bash.shENOENTs through Node's PATH resolver.claude mcp listappears to work because it runs in the user's interactive shell which is Git Bash, but the UI launcher uses a clean child env. That's the whole UI/CLI divergence.Reference implementations
This PR copies the pattern directly from two production Go-binary MCP servers that already use MCPB
platform_overrides.win32for exactly this reason:type: "binary", same${__dirname}/kubefwd+platform_overrides.win32→kubefwd.exepatternFiles
mcpb/manifest.json— MCPB manifest withplatform_overrides.win32(copies kubefwd pattern)mcpb/server/devkit— 5-line POSIX shell proxy to$CLAUDE_PLUGIN_ROOT/bin/devkitmcpb/launcher/main.go— Go source for the Windows launcher (~320 lines, stdlib-only)mcpb/launcher/go.mod— Go module definitionmcpb/server/devkit.exe— cross-compiled Windows launcher (6.7 MB)devkit.mcpb— packed bundle (manifest + server/).claude-plugin/plugin.json— two-line change:mcpServersswapped from inline to"./devkit.mcpb"Total commit: mcpb source + one devkit.mcpb + two-line plugin.json diff.
Test plan
Verified on macOS (Mac Claude, cold-start)
.mcpb-cache/, runclaude mcp listdevkit.mcpbinto fresh<plugin>/.mcpb-cache/<hash>/server/devkitPOSIX proxy spawns, re-execs$CLAUDE_PLUGIN_ROOT/bin/devkit/mcpstatus:devkit-engine ✓ ConnectedIn progress on Windows 11 (reporter's box)
devkit.mcpb+ updatedplugin.jsoninto cached plugin dir.mcpb-cache/, fully restart CC/mcpshowsdevkit-engine ✓ Connected(real launcher speaks MCP via engine child, unlike the probe stub)devkit_listtool call roundtrips and returns workflowsbin/devkit-engine-v2.1.6-windows-amd64.exeexists — either pre-cached from prior runs OR freshly downloaded + SHA-256-verified by the Go fetch path (check file timestamp)bin/, restart CC, verify the Go launcher fetches it from GitHub release + installs atomicallyAfter merge
Notes
devkit.mcpbis version-agnostic — the launcher reads the version fromplugin.jsonat runtime. Committing once, never regenerated on version bumps. Only gets rebuilt when the Go launcher source itself changes.ctx_executeandclaude mcp listcan't be used to validate MCP launcher changes) captured separately in the homebase notes.