diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a50d2c8..3b4706d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -5,12 +5,5 @@ "author": { "name": "5uck1ess" }, - "mcpServers": { - "devkit-engine": { - "command": "${CLAUDE_PLUGIN_ROOT}/bin/devkit", - "args": [ - "mcp" - ] - } - } + "mcpServers": "./devkit.mcpb" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912647b..7c50194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,19 +69,54 @@ jobs: exit 1 fi - - name: plugin.json MCP command must match wrapper path + - name: plugin.json mcpServers must point at MCPB bundle run: | - cmd=$(jq -r '.mcpServers["devkit-engine"].command' .claude-plugin/plugin.json) - expected='${CLAUDE_PLUGIN_ROOT}/bin/devkit' - if [ "$cmd" != "$expected" ]; then - echo "FATAL: plugin.json mcpServers.devkit-engine.command is '$cmd', expected '$expected'" + ref=$(jq -r '.mcpServers' .claude-plugin/plugin.json) + expected='./devkit.mcpb' + if [ "$ref" != "$expected" ]; then + echo "FATAL: plugin.json mcpServers is '$ref', expected '$expected'" exit 1 fi - - name: shellcheck wrapper + - name: MCPB bundle must exist and contain launcher stubs + run: | + test -f devkit.mcpb || { echo "FATAL: devkit.mcpb bundle missing from repo root"; exit 1; } + entries=$(unzip -Z1 devkit.mcpb) + for required in manifest.json server/devkit server/devkit.exe; do + if ! printf '%s\n' "$entries" | grep -qx "$required"; then + echo "FATAL: devkit.mcpb missing required entry '$required'" + printf 'bundle contents:\n%s\n' "$entries" + exit 1 + fi + done + unzip -p devkit.mcpb manifest.json | jq -e \ + '.server.mcp_config.platform_overrides.win32.command == "${__dirname}/server/devkit.exe"' \ + > /dev/null || { + echo "FATAL: mcpb manifest.json platform_overrides.win32.command is not the expected Windows launcher" + exit 1 + } + + - name: Bundled server/devkit.exe must be a real PE binary + run: | + magic=$(unzip -p devkit.mcpb server/devkit.exe | head -c 2 | xxd -p) + if [ "$magic" != "4d5a" ]; then + echo "FATAL: server/devkit.exe in bundle is not a PE binary (MZ header missing; got '$magic')" + echo "Did a probe stub or wrong-architecture binary get committed?" + exit 1 + fi + + - name: Bundled server/devkit must have a shell shebang + run: | + first=$(unzip -p devkit.mcpb server/devkit | head -c 2) + if [ "$first" != "#!" ]; then + echo "FATAL: server/devkit in bundle has no shebang; got '$first'" + exit 1 + fi + + - name: shellcheck wrappers run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck bin/devkit + shellcheck bin/devkit bin/mcpb-build mcpb/server/devkit - name: Wrapper --help must exit with a download attempt (no local engine) run: | @@ -102,6 +137,105 @@ jobs: exit 1 fi + mcpb-launcher-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcpb/launcher + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: mcpb/launcher/go.mod + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -v -count=1 + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Files not formatted:" + echo "$unformatted" + exit 1 + fi + + # Guards against the class of bug where mcpb/launcher/main.go, + # mcpb/manifest.json, or mcpb/server/devkit are edited but devkit.mcpb is + # not rebuilt — CI would otherwise greenlight a shipped bundle with stale + # runtime behavior. + # + # devkit.mcpb.sources.json is a sidecar manifest that records the sha256 + # of each source file at bundle-build time. This job re-computes each + # hash and compares. We don't byte-compare the cross-compiled .exe + # because Go cross-builds aren't byte-identical across host OSes even + # with -trimpath (linker build ID leaks host state) — the sidecar is + # the portable equivalent. + mcpb-bundle-integrity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Sources sidecar must exist + run: | + test -f devkit.mcpb.sources.json || { + echo "FATAL: devkit.mcpb.sources.json is missing; did you rebuild the bundle with bin/mcpb-build?" + exit 1 + } + + - name: Source file hashes must match sidecar + run: | + set -eo pipefail + stale=0 + # Read the tracked file list from the sidecar itself so there's + # one source of truth — bin/mcpb-build defines what's tracked, + # CI re-verifies each entry. + files=$(jq -r 'keys[]' devkit.mcpb.sources.json) + if [ -z "$files" ]; then + echo "FATAL: devkit.mcpb.sources.json is empty or invalid" + exit 1 + fi + while IFS= read -r file; do + want=$(jq -r --arg f "$file" '.[$f]' devkit.mcpb.sources.json) + if [ ! -f "$file" ]; then + echo "FATAL: sidecar references $file but the file is missing" + stale=1 + continue + fi + have=$(sha256sum "$file" | awk '{print $1}') + if [ "$want" != "$have" ]; then + echo "FATAL: $file has been edited since devkit.mcpb was built" + echo " sidecar sha256: $want" + echo " current sha256: $have" + stale=1 + fi + done <<< "$files" + if [ "$stale" != "0" ]; then + echo "" + echo "Rebuild with: bin/mcpb-build" + exit 1 + fi + + - name: Bundled manifest.json must match source + run: | + unzip -p devkit.mcpb manifest.json > /tmp/bundled-manifest.json + if ! diff -u mcpb/manifest.json /tmp/bundled-manifest.json; then + echo "FATAL: devkit.mcpb manifest.json has drifted from mcpb/manifest.json" + exit 1 + fi + + - name: Bundled server/devkit must match source + run: | + unzip -p devkit.mcpb server/devkit > /tmp/bundled-proxy.sh + if ! diff -u mcpb/server/devkit /tmp/bundled-proxy.sh; then + echo "FATAL: devkit.mcpb server/devkit has drifted from mcpb/server/devkit" + exit 1 + fi + validate-counts: runs-on: ubuntu-latest steps: diff --git a/bin/mcpb-build b/bin/mcpb-build new file mode 100755 index 0000000..1501511 --- /dev/null +++ b/bin/mcpb-build @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Rebuild devkit.mcpb from mcpb/ sources. +# +# Cross-compiles mcpb/launcher/main.go into mcpb/server/devkit.exe, +# rezips devkit.mcpb, and regenerates devkit.mcpb.sources.json with +# sha256s of every source file the bundle depends on. Dev and CI both +# call this so there's one definition of "bundle rebuild." +# +# Must be run from the repo root. Writes the bundle and sidecar via +# tempfile-then-rename so a failing step can't leave half-written +# artifacts behind. + +set -euo pipefail + +if [ ! -f mcpb/launcher/main.go ]; then + printf 'mcpb-build: must be run from repo root (mcpb/launcher/main.go not found)\n' >&2 + exit 1 +fi + +# Pick a sha256 frontend. macOS ships shasum, Linux ships sha256sum. +if command -v sha256sum >/dev/null 2>&1; then + HASHER="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + HASHER="shasum -a 256" +else + printf 'mcpb-build: neither sha256sum nor shasum available on PATH\n' >&2 + exit 1 +fi + +sha256_of() { + # Use a real failure path: if the file is unreadable or missing, + # $HASHER prints an error to stderr and exits non-zero, which + # pipefail + errexit will bubble up. + $HASHER "$1" | awk '{print $1}' +} + +printf 'mcpb-build: cross-compiling Windows launcher\n' +( + cd mcpb/launcher + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ + go build -trimpath -ldflags='-s -w' -o ../server/devkit.exe . +) + +printf 'mcpb-build: rebuilding devkit.mcpb\n' +rm -f devkit.mcpb.tmp +( + cd mcpb + zip -q -r ../devkit.mcpb.tmp manifest.json server/devkit server/devkit.exe +) + +printf 'mcpb-build: regenerating devkit.mcpb.sources.json\n' +rm -f devkit.mcpb.sources.json.tmp +{ + printf '{\n' + first=1 + for f in \ + mcpb/launcher/main.go \ + mcpb/launcher/go.mod \ + mcpb/launcher/go.sum \ + mcpb/launcher/main_test.go \ + mcpb/manifest.json \ + mcpb/server/devkit \ + mcpb/server/devkit.exe + do + if [ ! -f "$f" ]; then + # go.sum is optional when the launcher has no external deps; + # skip silently so an empty-dep module doesn't force a stub + # file. Everything else is required. + if [ "$f" = "mcpb/launcher/go.sum" ]; then + continue + fi + printf 'mcpb-build: FATAL: required source %s is missing\n' "$f" >&2 + exit 1 + fi + hash=$(sha256_of "$f") + if [ -z "$hash" ]; then + printf 'mcpb-build: FATAL: empty hash for %s\n' "$f" >&2 + exit 1 + fi + if [ "$first" = "1" ]; then + first=0 + else + printf ',\n' + fi + printf ' "%s": "%s"' "$f" "$hash" + done + printf '\n}\n' +} > devkit.mcpb.sources.json.tmp + +# Atomic-ish install: both files land together or neither does. The +# tempfiles survive on a failing step above so the prior bundle + sidecar +# stay untouched. +mv devkit.mcpb.tmp devkit.mcpb +mv devkit.mcpb.sources.json.tmp devkit.mcpb.sources.json + +printf 'mcpb-build: done\n' +printf ' devkit.mcpb %s bytes\n' "$(wc -c < devkit.mcpb | tr -d ' ')" +printf ' mcpb/server/devkit.exe %s bytes\n' "$(wc -c < mcpb/server/devkit.exe | tr -d ' ')" diff --git a/devkit.mcpb b/devkit.mcpb new file mode 100644 index 0000000..2777f5d Binary files /dev/null and b/devkit.mcpb differ diff --git a/devkit.mcpb.sources.json b/devkit.mcpb.sources.json new file mode 100644 index 0000000..d4efe45 --- /dev/null +++ b/devkit.mcpb.sources.json @@ -0,0 +1,8 @@ +{ + "mcpb/launcher/main.go": "ad2ce60992bcff323663ac31a63dfae93e782c8556043be8b77ece3b874ade36", + "mcpb/launcher/go.mod": "7f2e8c3f695fe8cbe8cafa7eb9a269e8d0e5141023f2ea20ef5fc7cbd2f22bc2", + "mcpb/launcher/main_test.go": "b723d82d0ce371a6c46eb062a1a312cf6aaaf306ef7edf40f3e35972cc4b153f", + "mcpb/manifest.json": "81d880aee266f821c75afc7578002d562da33a38ce87383ec922f3253eb5f998", + "mcpb/server/devkit": "3833a48a67dfb8d9db7e1513617c0b1df5d7ebb6bf0c3023c60ab048ed63002f", + "mcpb/server/devkit.exe": "6f2f817306d523ddd5d7087277ac933500a0e6a0dd1adc02d7c0a11d27878be3" +} diff --git a/mcpb/launcher/go.mod b/mcpb/launcher/go.mod new file mode 100644 index 0000000..416429d --- /dev/null +++ b/mcpb/launcher/go.mod @@ -0,0 +1,3 @@ +module github.com/5uck1ess/devkit/mcpb/launcher + +go 1.26.1 diff --git a/mcpb/launcher/main.go b/mcpb/launcher/main.go new file mode 100644 index 0000000..11c287a --- /dev/null +++ b/mcpb/launcher/main.go @@ -0,0 +1,383 @@ +// Package main is the devkit MCPB Windows launcher. +// +// This binary is what plugin.json's mcpb platform_overrides.win32 points at, +// i.e. it's what Claude Code CreateProcess()'s on Windows when spawning the +// devkit MCP server. Same contract as the POSIX bin/devkit shell wrapper — +// read plugin.json, fetch the engine from the matching GitHub release, +// verify SHA-256, exec — but the Windows path is simpler: single-shot HTTP +// fetch, no resume, no downloader fallback chain. A killed launcher +// mid-download leaves no state; the next run starts over. +// +// Go stdlib only, so the static build stays small and reproducible. Go's +// crypto/tls handles the HTTPS fetch, not Windows schannel, which sidesteps +// the renegotiation abort (CURLE_WRITE_ERROR / exit 23) that curl.exe hits +// mid-stream on release-assets.githubusercontent.com. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" +) + +const ( + releaseOwner = "5uck1ess" + releaseRepo = "devkit" + + // Cap a single HTTP fetch; past this ceiling a slow connection is a + // stuck one. + httpTimeout = 5 * time.Minute + + // Cap plugin.json reads. A legitimate manifest is under a kilobyte; + // anything above this ceiling is either corrupted or hostile. + maxPluginJSONBytes = 64 * 1024 +) + +func main() { + // Stdout is reserved for the MCP stdio transport once execEngine runs; + // pin the stdlib log package to stderr so a future refactor can't leak + // diagnostics into the JSON-RPC framing. + log.SetOutput(os.Stderr) + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "devkit launcher: %v\n", err) + os.Exit(1) + } +} + +func run() error { + pluginRoot := os.Getenv("CLAUDE_PLUGIN_ROOT") + if pluginRoot == "" { + return errors.New("CLAUDE_PLUGIN_ROOT is not set; this launcher must be invoked by Claude Code as an MCP server") + } + + // CLAUDE_PLUGIN_ROOT must be absolute. CC always sets it that way; + // anything else is a corrupted env or a spoofed launch. + if !filepath.IsAbs(pluginRoot) { + return fmt.Errorf("CLAUDE_PLUGIN_ROOT is not absolute: %q", pluginRoot) + } + + pluginJSON := filepath.Join(pluginRoot, ".claude-plugin", "plugin.json") + version, err := readPluginVersion(pluginJSON) + if err != nil { + return fmt.Errorf("reading plugin version from %s: %w", pluginJSON, err) + } + // version is interpolated into a filename joined to binDir below. Reject + // anything outside a narrow charset so a corrupted or spoofed plugin.json + // can't produce a path that escapes binDir via "..", a path separator, + // or shell metacharacters. + if err := validateVersion(version); err != nil { + return fmt.Errorf("invalid plugin version %q: %w", version, err) + } + + platform := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) + engineName := fmt.Sprintf("devkit-engine-v%s-%s.exe", version, platform) + binDir := filepath.Join(pluginRoot, "bin") + enginePath := filepath.Join(binDir, engineName) + + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", binDir, err) + } + + cached, err := engineLooksCached(enginePath) + if err != nil { + return fmt.Errorf("checking engine cache at %s: %w", enginePath, err) + } + if !cached { + logf("first-run: downloading engine v%s (%s)...", version, platform) + if err := ensureEngine(version, platform, enginePath); err != nil { + return fmt.Errorf("downloading engine: %w", err) + } + logf("installed engine at %s", enginePath) + } + + // Best-effort sweep of engines from other versions; never fatal. + if err := sweepStaleEngines(binDir, engineName); err != nil { + logf("warning: could not sweep stale engines: %v", err) + } + + return execEngine(enginePath, os.Args[1:]) +} + +// logf writes to stderr with a "devkit launcher:" prefix. Stdout is +// reserved for the MCP stdio transport once the engine takes over; the +// launcher must never write to it. +func logf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "devkit launcher: "+format+"\n", args...) +} + +// versionPattern accepts digits + dots + an optional single pre-release +// identifier. Narrower than semver on purpose — no "+build" metadata, no +// hyphens inside the pre-release — so the result is safe to join onto a +// filename. +var versionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){0,3}(-[A-Za-z0-9.]+)?$`) + +func validateVersion(version string) error { + if version == "" { + return errors.New("empty") + } + if !versionPattern.MatchString(version) { + return errors.New("must match ^[0-9]+(\\.[0-9]+){0,3}(-[A-Za-z0-9.]+)?$") + } + // Defense in depth: even inside the charset, reject ".." and path + // separators. If a future refactor widens versionPattern these guards + // still hold — they are load-bearing for the filesystem boundary and are + // enforced by table tests in main_test.go. + if strings.Contains(version, "..") || strings.ContainsAny(version, `/\`) { + return errors.New("contains path traversal sequence or separator") + } + return nil +} + +func readPluginVersion(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + // Reject oversize before reading. io.LimitReader would silently + // truncate, leaving a prefix that may happen to parse as valid JSON + // and return the wrong version — stat-first makes truncation a hard + // fail instead of a silent prefix parse. + if info.Size() > maxPluginJSONBytes { + return "", fmt.Errorf("plugin.json is %d bytes, max %d", info.Size(), maxPluginJSONBytes) + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + var manifest struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + return "", err + } + if manifest.Version == "" { + return "", errors.New("version field missing or empty") + } + return manifest.Version, nil +} + +// engineLooksCached reports whether enginePath is present and non-empty. +// Size-only check matches bin/devkit's POSIX semantics; stat errors other +// than IsNotExist are surfaced so ACL/permission issues don't masquerade +// as a cache miss and trigger spurious redownloads on every invocation. +func engineLooksCached(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + if info.IsDir() || info.Size() == 0 { + return false, nil + } + return true, nil +} + +// ensureEngine fetches the engine asset from the matching GitHub release, +// verifies SHA-256, and atomic-installs the binary at enginePath. The +// .sums.tmp and .partial staging files are cleaned up on every exit path. +func ensureEngine(version, platform, enginePath string) error { + tag := "v" + version + baseURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s", releaseOwner, releaseRepo, tag) + + sumsPath := enginePath + ".sums.tmp" + defer os.Remove(sumsPath) + if err := downloadTo(baseURL+"/checksums.txt", sumsPath); err != nil { + return fmt.Errorf("fetching checksums.txt: %w", err) + } + + assetName := fmt.Sprintf("devkit-%s.exe", platform) + expected, err := findChecksum(sumsPath, assetName) + if err != nil { + return fmt.Errorf("finding checksum for %s: %w", assetName, err) + } + + stagingPath := enginePath + ".partial" + defer os.Remove(stagingPath) + if err := downloadTo(baseURL+"/"+assetName, stagingPath); err != nil { + return fmt.Errorf("fetching %s: %w", assetName, err) + } + + actual, err := sha256File(stagingPath) + if err != nil { + return fmt.Errorf("checksumming %s: %w", assetName, err) + } + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", assetName, expected, actual) + } + + // os.Rename is atomic on Windows within the same volume. enginePath and + // stagingPath are both in binDir, so always same volume. + if err := os.Rename(stagingPath, enginePath); err != nil { + return fmt.Errorf("installing %s -> %s: %w", stagingPath, enginePath, err) + } + return nil +} + +func downloadTo(url, destPath string) (retErr error) { + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + out, err := os.Create(destPath) + if err != nil { + return err + } + // Capture a late Close error so antivirus quarantine-on-close, disk + // quota, and SMB sync-on-close failures don't manifest only as a + // mysterious checksum mismatch later. Must join with an existing + // retErr so we don't mask the primary failure — io.Copy or Sync + // errors win the "root cause" slot, Close errors are appended. + defer func() { + if cerr := out.Close(); cerr != nil { + cerr = fmt.Errorf("closing %s: %w", destPath, cerr) + if retErr == nil { + retErr = cerr + } else { + retErr = errors.Join(retErr, cerr) + } + } + }() + + n, err := io.Copy(out, resp.Body) + if err != nil { + return err + } + // A server advertising Content-Length must deliver it; a silent + // short read would otherwise only surface as a SHA-256 mismatch + // downstream (and for checksums.txt there is no SHA to verify + // against, so truncation would masquerade as "no entry"). + if resp.ContentLength >= 0 && n != resp.ContentLength { + return fmt.Errorf("short read from %s: got %d bytes, expected %d", url, n, resp.ContentLength) + } + // Chunked / identity responses with no Content-Length fall through + // to the engine's SHA-256 verify, but an empty body from either is + // always wrong — reject it up front. + if n == 0 { + return fmt.Errorf("empty response from %s", url) + } + if err := out.Sync(); err != nil { + return err + } + return nil +} + +// findChecksum parses a sha256sum-format file and returns the hash for +// assetName. Two columns per line: hash, filename. The filename may have +// a leading "*" for binary mode (per GNU coreutils sha256sum), which we +// trim before comparing. +func findChecksum(sumsFile, assetName string) (string, error) { + data, err := os.ReadFile(sumsFile) + if err != nil { + return "", err + } + // Normalize CRLF so a Windows-produced checksums.txt doesn't leave a + // trailing \r on the filename field and silently miss the match. + text := strings.ReplaceAll(string(data), "\r\n", "\n") + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + name := strings.TrimPrefix(fields[1], "*") + if name == assetName { + return fields[0], nil + } + } + return "", fmt.Errorf("no checksum entry for %s in checksums.txt", assetName) +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// sweepStaleEngines removes engine binaries from other versions that are +// sitting in binDir. Best-effort: a few stale megabytes on disk is strictly +// better than failing to start the engine. The prefix match deliberately +// skips the current engine and ignores any name that doesn't start with +// "devkit-engine-v" or "devkit-checksums-v". +func sweepStaleEngines(binDir, currentEngineName string) error { + entries, err := os.ReadDir(binDir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if name == currentEngineName { + continue + } + if strings.HasPrefix(name, "devkit-engine-v") || strings.HasPrefix(name, "devkit-checksums-v") { + _ = os.Remove(filepath.Join(binDir, name)) + } + } + return nil +} + +// execEngine runs the engine binary with inherited stdio and forwards the +// exit code. Windows has no execve, so the launcher stays alive as the +// parent process until the engine exits. stdin/stdout/stderr are assigned +// as *os.File values, which os/exec passes directly to CreateProcess via +// STARTUPINFO handles — no pipe, no goroutine copy, no buffering. MCP +// JSON-RPC framing is untouched by the launcher. +// +// On a non-ExitError failure (CreateProcess rejected the cached binary — +// wrong architecture, truncated PE, missing dependent DLL), the cached +// engine is removed so the next launch self-heals by re-downloading +// instead of getting stuck in a loop. A failing os.Remove is surfaced in +// the error so "cache purged" is never a lie. +func execEngine(enginePath string, args []string) error { + cmd := exec.Command(enginePath, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil { + return nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + code := exitErr.ExitCode() + if code < 0 { + // Abnormal termination (signal, job kill). Windows maps -1 to + // 0xFFFFFFFF via os.Exit, which CC's MCP manager can't read. + code = 1 + } + os.Exit(code) + } + if rmErr := os.Remove(enginePath); rmErr != nil { + return fmt.Errorf("executing engine %s (cache purge also failed: %v): %w", enginePath, rmErr, err) + } + return fmt.Errorf("executing engine %s (cache purged for next run): %w", enginePath, err) +} diff --git a/mcpb/launcher/main_test.go b/mcpb/launcher/main_test.go new file mode 100644 index 0000000..c32edc7 --- /dev/null +++ b/mcpb/launcher/main_test.go @@ -0,0 +1,374 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +func writeFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o644) +} + +func readDirNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + return names, nil +} + +func mkdirAll(path string) error { + return os.MkdirAll(path, 0o755) +} + +func TestValidateVersion(t *testing.T) { + cases := []struct { + name string + version string + wantErr bool + }{ + // Valid — these must stay accepted. + {"plain", "1.2.3", false}, + {"four parts", "1.2.3.4", false}, + {"pre-release", "2.1.7-rc.1", false}, + {"two digits", "10.20", false}, + {"single major", "1", false}, + {"three-digit components", "10.20.30", false}, + {"alpha dot num", "1.0.0-alpha.1", false}, + // Invalid — these are the security contract. + {"empty", "", true}, + {"dotdot", "..", true}, + {"traversal forward", "1.2.3/../x", true}, + {"traversal back", `1.2.3\..\x`, true}, + {"leading traversal", "../1.2.3", true}, + {"semicolon injection", "1.2.3;rm", true}, + {"newline", "1.2.3\n", true}, + {"double dot inside pre", "1.2.3-foo..bar", true}, + {"forward slash", "1/2/3", true}, + {"backslash", `1\2\3`, true}, + {"space", "1.2 .3", true}, + {"build metadata not supported", "1.0.0+build.1", true}, + {"dangling hyphen", "1.2.3-", true}, + {"null byte", "1.2.3\x00", true}, + {"backtick", "1.2.3`pwd`", true}, + {"var expansion", "${PATH}", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateVersion(tc.version) + if tc.wantErr && err == nil { + t.Fatalf("validateVersion(%q) = nil, want error", tc.version) + } + if !tc.wantErr && err != nil { + t.Fatalf("validateVersion(%q) = %v, want nil", tc.version, err) + } + }) + } +} + +func TestFindChecksum(t *testing.T) { + cases := []struct { + name string + body string + asset string + wantHash string + wantErr bool + }{ + { + name: "plain single entry", + body: "abc123 devkit-windows-amd64.exe\n", + asset: "devkit-windows-amd64.exe", + wantHash: "abc123", + }, + { + name: "binary-mode star prefix", + body: "abc123 *devkit-windows-amd64.exe\n", + asset: "devkit-windows-amd64.exe", + wantHash: "abc123", + }, + { + name: "target is second entry", + body: "deadbeef other.exe\n" + + "cafef00d devkit-windows-amd64.exe\n", + asset: "devkit-windows-amd64.exe", + wantHash: "cafef00d", + }, + { + name: "missing trailing newline", + body: "abc123 devkit-windows-amd64.exe", + asset: "devkit-windows-amd64.exe", + wantHash: "abc123", + }, + { + name: "CRLF line endings from Windows builder", + body: "abc123 devkit-windows-amd64.exe\r\n", + asset: "devkit-windows-amd64.exe", + wantHash: "abc123", + }, + { + name: "CRLF mixed multi-line", + body: "deadbeef other.exe\r\n" + + "cafef00d devkit-windows-amd64.exe\r\n", + asset: "devkit-windows-amd64.exe", + wantHash: "cafef00d", + }, + { + name: "no match", + body: "abc123 different.exe\n", + asset: "devkit-windows-amd64.exe", + wantErr: true, + }, + { + name: "empty file", + body: "", + asset: "devkit-windows-amd64.exe", + wantErr: true, + }, + { + name: "single-column garbage", + body: "notachecksum\n", + asset: "devkit-windows-amd64.exe", + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sums.txt") + if err := writeFile(path, tc.body); err != nil { + t.Fatal(err) + } + got, err := findChecksum(path, tc.asset) + if tc.wantErr { + if err == nil { + t.Fatalf("findChecksum() = %q, want error", got) + } + return + } + if err != nil { + t.Fatalf("findChecksum() error = %v", err) + } + if got != tc.wantHash { + t.Fatalf("findChecksum() = %q, want %q", got, tc.wantHash) + } + }) + } +} + +func TestSweepStaleEngines(t *testing.T) { + dir := t.TempDir() + files := []string{ + "devkit-engine-v2.1.6-windows-amd64.exe", // current + "devkit-engine-v2.1.5-windows-amd64.exe", // stale + "devkit-engine-v2.0.0-darwin-arm64", // stale + "devkit-checksums-v2.1.5.txt", // stale + "README.md", // must be preserved + "devkit", // must be preserved (no -engine-v prefix) + "unrelated-binary.exe", // must be preserved + } + for _, name := range files { + if err := writeFile(filepath.Join(dir, name), "x"); err != nil { + t.Fatal(err) + } + } + + current := "devkit-engine-v2.1.6-windows-amd64.exe" + if err := sweepStaleEngines(dir, current); err != nil { + t.Fatalf("sweepStaleEngines() error = %v", err) + } + + want := map[string]bool{ + "devkit-engine-v2.1.6-windows-amd64.exe": true, + "README.md": true, + "devkit": true, + "unrelated-binary.exe": true, + } + entries, err := readDirNames(dir) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, name := range entries { + got[name] = true + } + for name := range want { + if !got[name] { + t.Errorf("file %q was deleted but should have been preserved", name) + } + } + for name := range got { + if !want[name] { + t.Errorf("file %q was preserved but should have been swept", name) + } + } +} + +// TestExecEngineRemovesCorruptCache covers the self-heal path in +// execEngine: when cmd.Run returns a non-ExitError (CreateProcess / +// execve rejection — wrong architecture, truncated PE, ENOEXEC), +// the cached binary must be removed so the next launch re-downloads. +// A regression that moves the os.Remove inside the ExitError branch +// would quietly recreate the stuck-cache class this fix exists for. +func TestExecEngineRemovesCorruptCache(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "bad.exe") + // Non-binary contents with exec permission. execve/CreateProcess + // reject this before the child ever runs, which is the non-ExitError + // path execEngine must self-heal. + if err := os.WriteFile(bad, []byte("not an executable"), 0o755); err != nil { + t.Fatal(err) + } + err := execEngine(bad, nil) + if err == nil { + t.Fatal("execEngine(non-binary) = nil, want error") + } + if _, statErr := os.Stat(bad); !os.IsNotExist(statErr) { + t.Fatalf("cached binary not removed: stat err = %v", statErr) + } +} + +func TestEngineLooksCached(t *testing.T) { + dir := t.TempDir() + + missing := filepath.Join(dir, "nope.exe") + ok, err := engineLooksCached(missing) + if err != nil || ok { + t.Fatalf("missing: got ok=%v err=%v, want ok=false err=nil", ok, err) + } + + empty := filepath.Join(dir, "empty.exe") + if err := writeFile(empty, ""); err != nil { + t.Fatal(err) + } + ok, err = engineLooksCached(empty) + if err != nil || ok { + t.Fatalf("empty: got ok=%v err=%v, want ok=false err=nil", ok, err) + } + + present := filepath.Join(dir, "present.exe") + if err := writeFile(present, "MZ"); err != nil { + t.Fatal(err) + } + ok, err = engineLooksCached(present) + if err != nil || !ok { + t.Fatalf("real: got ok=%v err=%v, want ok=true err=nil", ok, err) + } + + subdir := filepath.Join(dir, "adir") + if err := mkdirAll(subdir); err != nil { + t.Fatal(err) + } + ok, err = engineLooksCached(subdir) + if err != nil || ok { + t.Fatalf("dir: got ok=%v err=%v, want ok=false err=nil", ok, err) + } +} + +func TestReadPluginVersion(t *testing.T) { + dir := t.TempDir() + + good := filepath.Join(dir, "good.json") + if err := writeFile(good, `{"name":"devkit","version":"2.1.6"}`); err != nil { + t.Fatal(err) + } + v, err := readPluginVersion(good) + if err != nil || v != "2.1.6" { + t.Fatalf("good: got %q err=%v, want 2.1.6 nil", v, err) + } + + noVersion := filepath.Join(dir, "nover.json") + if err := writeFile(noVersion, `{"name":"devkit"}`); err != nil { + t.Fatal(err) + } + if _, err := readPluginVersion(noVersion); err == nil { + t.Fatal("no version field: want error, got nil") + } + + empty := filepath.Join(dir, "empty.json") + if err := writeFile(empty, `{"version":""}`); err != nil { + t.Fatal(err) + } + if _, err := readPluginVersion(empty); err == nil { + t.Fatal("empty version: want error, got nil") + } + + garbage := filepath.Join(dir, "garbage.json") + if err := writeFile(garbage, `not json at all`); err != nil { + t.Fatal(err) + } + if _, err := readPluginVersion(garbage); err == nil { + t.Fatal("garbage: want error, got nil") + } + + // Oversize rejection must catch a file that would parse correctly if + // we only read the first maxPluginJSONBytes. Use a legitimately large + // JSON: a valid object at the head, then whitespace padding that + // pushes total size past the cap. A buggy size guard (e.g. a naive + // LimitReader) would silently truncate the padding and parse the + // head, returning "2.1.6" instead of an error. + oversized := filepath.Join(dir, "huge.json") + head := `{"name":"devkit","version":"2.1.6"}` + padding := strings.Repeat(" ", maxPluginJSONBytes) + if err := writeFile(oversized, head+padding); err != nil { + t.Fatal(err) + } + v, err = readPluginVersion(oversized) + if err == nil { + t.Fatalf("oversized: got %q, want error", v) + } + + // Boundary: a manifest under the cap with some slack must still parse. + slack := 4096 + boundary := filepath.Join(dir, "boundary.json") + extra := strings.Repeat(" ", maxPluginJSONBytes-len(head)-slack) + if err := writeFile(boundary, head+extra); err != nil { + t.Fatal(err) + } + v, err = readPluginVersion(boundary) + if err != nil || v != "2.1.6" { + t.Fatalf("boundary: got %q err=%v, want 2.1.6 nil", v, err) + } +} + +// TestValidateVersionDefenseInDepth exercises the explicit Contains("..") +// and ContainsAny(`/\`) guards independently of the regex. The current +// regex already rejects these inputs, so a refactor that widens the regex +// must still hit the guards — this test lets that change fail fast. +func TestValidateVersionDefenseInDepth(t *testing.T) { + // Temporarily widen the pattern to one that admits any printable + // ASCII so we can prove the guards are doing load-bearing work. We + // restore the original at the end. + original := versionPattern + defer func() { versionPattern = original }() + versionPattern = regexp.MustCompile(`^[ -~]+$`) + + badInputs := []string{ + "1.2..3", + "1/2/3", + `1\2\3`, + "..", + "../etc/passwd", + `..\windows\system32`, + } + for _, in := range badInputs { + if err := validateVersion(in); err == nil { + t.Errorf("widened-regex + guard: validateVersion(%q) = nil, want error", in) + } + } + + // Sanity: plain versions still pass under the widened regex. + for _, in := range []string{"1.2.3", "2.1.7-rc.1"} { + if err := validateVersion(in); err != nil { + t.Errorf("widened-regex: validateVersion(%q) = %v, want nil", in, err) + } + } +} diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..fbab0c6 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": "0.3", + "name": "devkit-engine", + "version": "0.0.0", + "description": "devkit MCP engine launcher — POSIX proxies to bin/devkit, Windows spawns a native PE stub to bypass CreateProcess + CVE-2024-27980 constraints", + "author": { "name": "5uck1ess" }, + "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"] + } + } + } + } +} diff --git a/mcpb/server/devkit b/mcpb/server/devkit new file mode 100755 index 0000000..7945a8a --- /dev/null +++ b/mcpb/server/devkit @@ -0,0 +1,27 @@ +#!/bin/sh +# POSIX entry point for the devkit MCPB bundle. +# +# Intentionally a thin proxy: re-execs the existing bin/devkit wrapper that +# lives at the plugin root. CC exports CLAUDE_PLUGIN_ROOT to every MCP child, +# so we use it to locate the real wrapper. This keeps the POSIX behavior +# 100% identical to the pre-MCPB architecture — all download, checksum, +# resume, and exec logic stays in bin/devkit where it's already been tested. +# +# This file exists inside devkit.mcpb so plugin.json's +# mcpServers: "./devkit.mcpb" wiring resolves. Windows bypasses this entirely +# and runs server/devkit.exe via platform_overrides.win32. + +set -eu + +if [ -z "${CLAUDE_PLUGIN_ROOT:-}" ]; then + printf 'devkit mcpb proxy: CLAUDE_PLUGIN_ROOT is not set; cannot locate bin/devkit\n' >&2 + exit 1 +fi + +REAL_WRAPPER="$CLAUDE_PLUGIN_ROOT/bin/devkit" +if [ ! -x "$REAL_WRAPPER" ]; then + printf 'devkit mcpb proxy: expected wrapper at %s (not found or not executable)\n' "$REAL_WRAPPER" >&2 + exit 1 +fi + +exec "$REAL_WRAPPER" "$@" diff --git a/mcpb/server/devkit.exe b/mcpb/server/devkit.exe new file mode 100755 index 0000000..5c975d3 Binary files /dev/null and b/mcpb/server/devkit.exe differ