Skip to content

fix(windows): real Go launcher for devkit MCPB bundle (closes #60) - #62

Merged
5uck1ess merged 6 commits into
mainfrom
fix/windows-mcpb-launcher
Apr 10, 2026
Merged

fix(windows): real Go launcher for devkit MCPB bundle (closes #60)#62
5uck1ess merged 6 commits into
mainfrom
fix/windows-mcpb-launcher

Conversation

@5uck1ess

Copy link
Copy Markdown
Owner

Summary

Fixes Windows /mcp UI failure where plugin:devkit:devkit-engine showed × failed because bin/devkit is an extensionless POSIX shell script that Windows CreateProcess can't execute. Ships the MCP server as an MCPB bundle with platform_overrides.win32 pointing at a native Go launcher, which CC CreateProcess's directly. No shell dependency, no .cmd/.bat in the spawn chain (CVE-2024-27980 blocked those), no plugin.json schema branching (CC's inline mcpServers.* is h.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

plugin.json.mcpServers: "./devkit.mcpb"
  → CC unpacks the zip into <plugin>/.mcpb-cache/<hash>/
  → resolves manifest.json's platform_overrides matching the current OS
  → spawns the resolved command with ${__dirname} = <unpacked cache dir>

manifest.json uses the exact kubefwd pattern — base command points at the POSIX entry, platform_overrides.win32 swaps 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/devkit is 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/devkit stays in place exactly as it is on main. 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/, ran claude mcp list, CC re-unpacked the bundle, POSIX proxy fired, full MCP handshake succeeded, ✓ Connected.

Windows side — new Go launcher

mcpb/server/devkit.exe is a statically-linked Go binary (GOOS=windows GOARCH=amd64 CGO_ENABLED=0, -s -w, -trimpath, stdlib-only, ~6.5 MB). Ports bin/devkit's flow to Go. Per-invocation:

  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/corrupted launch context.
  2. Parse $CLAUDE_PLUGIN_ROOT/.claude-plugin/plugin.json for the plugin version.
  3. Validate version against a conservative regex (^[0-9]+(\.[0-9]+){0,3}(-[A-Za-z0-9.]+)?$) + explicit ..///\ guard. Security boundary: version gets interpolated into a filename joined to binDir, so a corrupted plugin.json must not be able to escape binDir via path traversal.
  4. Locate the engine at $CLAUDE_PLUGIN_ROOT/bin/devkit-engine-v<version>-windows-amd64.exe. Same cache location as bin/devkit, so if the user somehow already has the engine from a prior run, it's reused.
  5. If missing: fetch checksums.txt + the engine asset from the matching GitHub release, verify SHA-256, atomic os.Rename into place. Intermediate files (.sums.tmp, .partial) cleaned up on success and failure.
  6. Sweep stale engines (devkit-engine-v*, devkit-checksums-v*) from other versions in binDir. Best-effort, non-fatal. 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 forwarded via os.Exit(exitErr.ExitCode()).

Structural improvement over POSIX bin/devkit: Go's crypto/tls handles the HTTPS fetch, not Windows schannel, so the CDN renegotiation bug that #58/#59 worked around for curl.exe on release-assets.githubusercontent.com doesn'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 path wiring fires on Windows ✓
  • MCPB unpack into .mcpb-cache/<hash>/ works on Windows ✓
  • platform_overrides.win32 resolver fires (spawns .exe, not POSIX entry) ✓
  • ${__dirname} expands to a CreateProcess-compatible Windows path with drive letter + backslashes ✓
  • PE binary under .mcpb-cache/<hash>/server/ actually runs in CC's MCP child context ✓
  • CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA are 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_list tool call + GitHub-fetch smoke) is in progress on the reporter's box.

Dead-end paths (documented so they don't get re-tried)

Approach Why it's dead
bin/devkit.cmd sibling, unchanged plugin.json Node only applies PATHEXT fallback on bare commands resolved through PATH, not absolute paths. spawn('.../bin/devkit') ENOENTs regardless of what sits next to it.
plugin.jsonbin/devkit.cmd CVE-2024-27980. Node ≥ 20.12.2 refuses to spawn .cmd/.bat without shell: true and throws EINVAL. CC's MCP launcher doesn't opt in.
Platform-branched command in plugin.json CC binary schema: mcpServers.* inline is h.strictObject({command, args, env}). Extra keys rejected at parse time. MCPB is the only way to get platform branching in CC plugin config.
Rename to bin/devkit.sh + ship bin/devkit.exe + platform select in plugin.json Same strict-schema reason.
command: "sh" wrapper (#61) CC's Windows UI MCP child PATH doesn't include Git Bash's bin dir. Confirmed from real CC debug log PATH dump: C:\...\micromamba\condabin;C:\Python314\...;C:\Program Files\Oculus\... — no Git Bash. sh ENOENTs through Node's PATH resolver. claude mcp list appears 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.win32 for exactly this reason:

Files

  • mcpb/manifest.json — MCPB manifest with platform_overrides.win32 (copies kubefwd pattern)
  • mcpb/server/devkit — 5-line POSIX shell proxy to $CLAUDE_PLUGIN_ROOT/bin/devkit
  • mcpb/launcher/main.go — Go source for the Windows launcher (~320 lines, stdlib-only)
  • mcpb/launcher/go.mod — Go module definition
  • mcpb/server/devkit.exe — cross-compiled Windows launcher (6.7 MB)
  • devkit.mcpb — packed bundle (manifest + server/)
  • .claude-plugin/plugin.json — two-line change: mcpServers swapped 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)

  • Wipe .mcpb-cache/, run claude mcp list
  • CC re-unpacks devkit.mcpb into fresh <plugin>/.mcpb-cache/<hash>/
  • server/devkit POSIX proxy spawns, re-execs $CLAUDE_PLUGIN_ROOT/bin/devkit
  • Existing wrapper downloads/locates engine as before
  • /mcp status: devkit-engine ✓ Connected
  • Full MCP handshake, tools reachable

In progress on Windows 11 (reporter's box)

  • Stage devkit.mcpb + updated plugin.json into cached plugin dir
  • Wipe .mcpb-cache/, fully restart CC
  • /mcp shows devkit-engine ✓ Connected (real launcher speaks MCP via engine child, unlike the probe stub)
  • devkit_list tool call roundtrips and returns workflows
  • bin/devkit-engine-v2.1.6-windows-amd64.exe exists — either pre-cached from prior runs OR freshly downloaded + SHA-256-verified by the Go fetch path (check file timestamp)
  • Cold-start scenario: delete the cached engine from bin/, restart CC, verify the Go launcher fetches it from GitHub release + installs atomically

After merge

Notes

  • devkit.mcpb is version-agnostic — the launcher reads the version from plugin.json at runtime. Committing once, never regenerated on version bumps. Only gets rebuilt when the Go launcher source itself changes.
  • Full investigation trail + probe evidence + methodology lessons (e.g. why ctx_execute and claude mcp list can't be used to validate MCP launcher changes) captured separately in the homebase notes.

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
@5uck1ess

Copy link
Copy Markdown
Owner Author

✅ Verified end-to-end on Windows — UI green, real tool call roundtrips

Staged this branch's devkit.mcpb into the cached plugin dir, wiped .mcpb-cache/ for a clean re-unpack, fully quit Claude Code, opened a fresh window. Result in /mcp:

Built-in MCPs (always available)
  plugin:context-mode:context-mode  · √ connected
  plugin:devkit:devkit-engine        · √ connected

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 claude mcp list, both of which run in process envs that don't match CC's UI launcher child env. This check is the real thing.

What was exercised

Layer Result
Bundle integrity devkit.mcpb = 2.79 MB zip → manifest.json + server/devkit (POSIX proxy, 1021 B) + server/devkit.exe (6.7 MB PE32+ x86-64)
Launcher security guard Direct-run with CLAUDE_PLUGIN_ROOT unset → devkit launcher: CLAUDE_PLUGIN_ROOT is not set; this launcher must be invoked by Claude Code as an MCP server (clean exit)
MCPB unpack on Windows CC unpacked into .mcpb-cache/c26cd91ca0433817/ — new hash, different from the probe branch's 485a271558425135, so CC correctly content-addressed the new bundle
Launcher → engine exec Launcher found the already-cached bin/devkit-engine-v2.1.6-windows-amd64.exe (9.9 MB), skipped the download path entirely, cmd.Start+Wait into the engine with inherited stdio
Full MCP JSON-RPC handshake initializeserverInfo = {name: "devkit-engine", version: "1.0.0"}; notifications/initialized → sent; tools/list[devkit_advance, devkit_list, devkit_start, devkit_status]; tools/call devkit_status → valid JSON-RPC response
CC UI /mcp status plugin:devkit:devkit-engine · √ connected in a fresh CC window

Notes on the launcher code (mcpb/launcher/main.go)

I read all 312 lines before UI verification came in. Nothing I'd flag as blocking. Quick observations worth capturing but not acting on:

  1. Defense in depth holds. validateVersion's regex + explicit .. / / / \ guard + filepath.Join(binDir, engineName) is enough to keep a spoofed plugin.json from escaping binDir. The version field is the only untrusted input that gets interpolated into a filesystem path, and it's locked down tightly.
  2. File handle lifecycle around os.Rename is correct. I initially worried about renaming an open file (Windows is touchy about this), but traced the control flow: downloadTo returns before sha256File opens the file, sha256File closes before os.Rename runs. Both close-before-rename. Good.
  3. Concurrent-launcher race on .partial / .sums.tmp exists but is extremely rare in practice (CC spawns MCP servers at most once per session, and the rename is idempotent if both complete). Matches the same race bin/devkit has. Not a regression, not worth addressing in this PR.
  4. Stale engine sweep catches devkit-engine-v*.partial files as "stale" via the HasPrefix(name, "devkit-engine-v") match, so a concurrent in-progress download's staging file could get swept out from under it. Same rarity as above; not worth fixing here.
  5. crypto/tls over schannel is a real structural win for Windows downloads — the CDN bug #58/#59 worked around for curl.exe on release-assets.githubusercontent.com simply doesn't apply to Go's own TLS stack. This Windows path is now more robust than the POSIX one, not less. Good trade.
  6. fileIsExecutable(enginePath) is size-only (size > 0), no SHA verification on cached engines at subsequent runs. Matches bin/devkit semantics — not a regression. If you ever want per-launch integrity checking, checksum verification here would add a few tens of ms and catch out-of-band tampering. Separate concern, not a PR blocker.
  7. Engine stdout goes straight to the parent CC process via cmd.Stdout = os.Stdout — exactly right for MCP stdio transport. The launcher never touches stdout itself (all its own logging goes to stderr via logf with devkit launcher: prefix), so there's no risk of corrupting the JSON-RPC framing.

Follow-ups for later, not this PR

  • _principles.yml not found stderr warning from the engine at startup is still there. Cosmetic, unrelated to launcher work.
  • The devkit_status response I got was a valid JSON-RPC 2.0 reply (so transport is clean) but the content payload was an error about a missing workflows dir. Unrelated to launcher — that's engine-side CWD/env resolution and already noted as out-of-scope in the earlier Windows: /mcp UI fails to spawn bin/devkit (extensionless shell script) #60 thread. Should get its own issue once this lands.

Sorry for the earlier noise on #60 and #61

Two methodology lessons from the previous rounds that I'm now treating as hard rules for myself:

  1. Don't validate MCP launcher changes from inside ctx_execute or any shell that inherits Git Bash on PATH. That env bears no resemblance to CC's UI launcher child env, and I burned two rounds on false positives from spawn repros that only worked because sh.exe was inherited.
  2. claude mcp list is not a UI proxy. It runs in the interactive shell's env, which on Windows CC systems is almost always Git Bash. The whole UI/CLI divergence in Windows: /mcp UI fails to spawn bin/devkit (extensionless shell script) #60 was parent-shell inheritance vs. clean CC child env. Treat claude mcp list as a weak probe and require a visual /mcp check in a freshly-launched CC window for anything claiming "fixed."

Both of those lessons directly informed the probe methodology on fix/windows-mcpb-wiring-probe (which correctly isolated spawn-layer from protocol-layer via the distinctive stderr marker). The probe result was what convinced me this architecture was sound; this PR's real launcher then carried it the rest of the way.

LGTM — ship it as v2.1.7 and close #60.

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.
@5uck1ess
5uck1ess merged commit 10905f3 into main Apr 10, 2026
6 checks passed
@5uck1ess
5uck1ess deleted the fix/windows-mcpb-launcher branch April 10, 2026 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: /mcp UI fails to spawn bin/devkit (extensionless shell script)

1 participant