From 76cfe01fa25f1dbac0f94068308992937ef07590 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:04:35 -0700 Subject: [PATCH] Add remote daemon distribution regressions --- .github/workflows/ci.yml | 21 ++++ cmuxTests/GhosttyConfigTests.swift | 42 +++++++ daemon/remote/cmd/cmuxd-remote/cli_test.go | 121 ++++++++++++++++++++ daemon/remote/cmd/cmuxd-remote/main_test.go | 19 +++ scripts/release_asset_guard.test.js | 12 +- tests/test_remote_daemon_release_assets.sh | 65 +++++++++++ 6 files changed, 276 insertions(+), 4 deletions(-) create mode 100755 tests/test_remote_daemon_release_assets.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7315d8ed..8e5f08df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,27 @@ jobs: - name: Validate GhosttyKit checksum verification run: ./tests/test_ci_ghosttykit_checksum_verification.sh + - name: Validate release asset guard + run: node scripts/release_asset_guard.test.js + + remote-daemon-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + + - name: Run remote daemon tests + working-directory: daemon/remote + run: go test ./... + + - name: Validate remote daemon release assets + run: ./tests/test_remote_daemon_release_assets.sh + web-typecheck: runs-on: ubuntu-latest defaults: diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 26d3a789..2e38deea 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -659,6 +659,48 @@ final class WindowTransparencyDecisionTests: XCTestCase { } } +final class WorkspaceRemoteDaemonManifestTests: XCTestCase { + func testParsesEmbeddedRemoteDaemonManifestJSON() throws { + let manifestJSON = """ + { + "schemaVersion": 1, + "appVersion": "0.62.0", + "releaseTag": "v0.62.0", + "releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0", + "checksumsAssetName": "cmuxd-remote-checksums.txt", + "checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-checksums.txt", + "entries": [ + { + "goOS": "linux", + "goArch": "amd64", + "assetName": "cmuxd-remote-linux-amd64", + "downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-linux-amd64", + "sha256": "abc123" + } + ] + } + """ + + let manifest = Workspace.remoteDaemonManifest(from: [ + Workspace.remoteDaemonManifestInfoKey: manifestJSON, + ]) + + XCTAssertEqual(manifest?.releaseTag, "v0.62.0") + XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "cmuxd-remote-linux-amd64") + } + + func testRemoteDaemonCachePathIsVersionedByPlatform() throws { + let url = try Workspace.remoteDaemonCachedBinaryURL( + version: "0.62.0", + goOS: "linux", + goArch: "arm64" + ) + + XCTAssertTrue(url.path.contains("/Application Support/cmux/remote-daemons/0.62.0/linux-arm64/")) + XCTAssertEqual(url.lastPathComponent, "cmuxd-remote") + } +} + final class WindowBackgroundSelectionGateTests: XCTestCase { func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { let tabId = UUID() diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index 924c4e00..fff424db 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -1,7 +1,13 @@ package main import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" + "fmt" + "io" "net" "os" "path/filepath" @@ -108,6 +114,80 @@ func startMockTCPSocket(t *testing.T, response string) string { return ln.Addr().String() } +func startMockAuthenticatedTCPSocket(t *testing.T, relayID, relayToken, response string) string { + t.Helper() + relayTokenBytes := mustHex(t, relayToken) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + nonce := "testnonce" + challenge, _ := json.Marshal(map[string]any{ + "protocol": "cmux-relay-auth", + "version": 1, + "relay_id": relayID, + "nonce": nonce, + }) + _, _ = conn.Write(append(challenge, '\n')) + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return + } + var authResp map[string]any + if err := json.Unmarshal([]byte(line), &authResp); err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + macHex, _ := authResp["mac"].(string) + receivedMAC, err := hex.DecodeString(macHex) + if err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + h := hmac.New(sha256.New, relayTokenBytes) + _, _ = io.WriteString(h, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, 1)) + expectedMAC := h.Sum(nil) + if !hmac.Equal(receivedMAC, expectedMAC) { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + _, _ = conn.Write([]byte(`{"ok":true}` + "\n")) + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _, _ = conn.Write([]byte(response)) + if n > 0 && !strings.HasSuffix(response, "\n") { + _, _ = conn.Write([]byte("\n")) + } + }(conn) + } + }() + + return ln.Addr().String() +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + data, err := hex.DecodeString(value) + if err != nil { + t.Fatalf("decode hex: %v", err) + } + return data +} + func TestDialTCPRetrySuccess(t *testing.T) { // Get a free port, then close the listener so connection is refused initially. ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -175,6 +255,47 @@ func TestCLIPingV1OverTCP(t *testing.T) { } } +func TestCLIPingV1OverAuthenticatedTCPWithEnv(t *testing.T) { + relayID := "relay-1" + relayToken := strings.Repeat("a1", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + t.Setenv("CMUX_RELAY_ID", relayID) + t.Setenv("CMUX_RELAY_TOKEN", relayToken) + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP should return 0, got %d", code) + } +} + +func TestCLIPingV1OverAuthenticatedTCPWithRelayFile(t *testing.T) { + relayID := "relay-2" + relayToken := strings.Repeat("b2", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + _, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host port: %v", err) + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CMUX_RELAY_ID", "") + t.Setenv("CMUX_RELAY_TOKEN", "") + relayDir := filepath.Join(home, ".cmux", "relay") + if err := os.MkdirAll(relayDir, 0o700); err != nil { + t.Fatalf("mkdir relay dir: %v", err) + } + authPayload, _ := json.Marshal(relayAuthState{RelayID: relayID, RelayToken: relayToken}) + if err := os.WriteFile(filepath.Join(relayDir, port+".auth"), authPayload, 0o600); err != nil { + t.Fatalf("write auth file: %v", err) + } + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP file relay should return 0, got %d", code) + } +} + func TestDialSocketDetection(t *testing.T) { // Unix socket paths should attempt Unix dial for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} { diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 51a3f80f..9ee08f07 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -247,6 +247,25 @@ func TestProxyStreamRoundTrip(t *testing.T) { } } +func TestGetIntParamRejectsFractionalFloat64(t *testing.T) { + params := map[string]any{ + "port": 80.9, + "timeout_ms": 100.0, + } + + if _, ok := getIntParam(params, "port"); ok { + t.Fatalf("fractional float64 should be rejected") + } + + timeout, ok := getIntParam(params, "timeout_ms") + if !ok { + t.Fatalf("integral float64 should be accepted") + } + if timeout != 100 { + t.Fatalf("timeout_ms = %d, want 100", timeout) + } +} + func TestRunStdioOversizedFrameContinuesServing(t *testing.T) { oversized := `{"id":1,"method":"ping","params":{"blob":"` + strings.Repeat("a", maxRPCFrameBytes) + `"}}` input := strings.NewReader(oversized + "\n" + `{"id":2,"method":"ping","params":{}}` + "\n") diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js index c320cf81..39cdcf89 100644 --- a/scripts/release_asset_guard.test.js +++ b/scripts/release_asset_guard.test.js @@ -11,7 +11,7 @@ const { test("marks guard as complete and skips build/upload when all immutable assets already exist", () => { const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], + existingAssetNames: [...IMMUTABLE_RELEASE_ASSETS, "notes.txt"], }); assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); @@ -36,12 +36,16 @@ test("marks guard as clear when immutable assets are not present", () => { }); test("marks guard as partial when only some immutable assets exist", () => { + const partialAssets = ["appcast.xml", "cmuxd-remote-manifest.json"]; const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["appcast.xml"], + existingAssetNames: partialAssets, }); - assert.deepEqual(result.conflicts, ["appcast.xml"]); - assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]); + assert.deepEqual(result.conflicts, partialAssets); + assert.deepEqual( + result.missingImmutableAssets, + IMMUTABLE_RELEASE_ASSETS.filter((assetName) => !partialAssets.includes(assetName)), + ); assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL); assert.equal(result.hasPartialConflict, true); assert.equal(result.shouldSkipBuildAndUpload, false); diff --git a/tests/test_remote_daemon_release_assets.sh b/tests/test_remote_daemon_release_assets.sh new file mode 100755 index 00000000..8495d835 --- /dev/null +++ b/tests/test_remote_daemon_release_assets.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUTPUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-assets-test.XXXXXX")" +trap 'rm -rf "$OUTPUT_DIR"' EXIT + +"$ROOT_DIR/scripts/build_remote_daemon_release_assets.sh" \ + --version "0.62.0-test" \ + --release-tag "v0.62.0-test" \ + --repo "manaflow-ai/cmux" \ + --output-dir "$OUTPUT_DIR" >/dev/null + +for asset in \ + cmuxd-remote-darwin-arm64 \ + cmuxd-remote-darwin-amd64 \ + cmuxd-remote-linux-arm64 \ + cmuxd-remote-linux-amd64 \ + cmuxd-remote-checksums.txt \ + cmuxd-remote-manifest.json +do + if [[ ! -f "$OUTPUT_DIR/$asset" ]]; then + echo "FAIL: missing asset $asset" >&2 + exit 1 + fi +done + +python3 - <<'PY' "$OUTPUT_DIR/cmuxd-remote-manifest.json" "$OUTPUT_DIR/cmuxd-remote-checksums.txt" +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +checksums_path = Path(sys.argv[2]) +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + +expected_targets = { + ("darwin", "arm64"), + ("darwin", "amd64"), + ("linux", "arm64"), + ("linux", "amd64"), +} +actual_targets = {(entry["goOS"], entry["goArch"]) for entry in manifest["entries"]} +if actual_targets != expected_targets: + raise SystemExit(f"FAIL: manifest targets {sorted(actual_targets)} != {sorted(expected_targets)}") + +if manifest["appVersion"] != "0.62.0-test": + raise SystemExit(f"FAIL: unexpected appVersion {manifest['appVersion']}") +if manifest["releaseTag"] != "v0.62.0-test": + raise SystemExit(f"FAIL: unexpected releaseTag {manifest['releaseTag']}") +if not manifest["checksumsURL"].endswith("/cmuxd-remote-checksums.txt"): + raise SystemExit(f"FAIL: unexpected checksumsURL {manifest['checksumsURL']}") + +checksum_lines = [line for line in checksums_path.read_text(encoding="utf-8").splitlines() if line.strip()] +if len(checksum_lines) != 4: + raise SystemExit(f"FAIL: expected 4 checksum lines, got {len(checksum_lines)}") + +for entry in manifest["entries"]: + if not entry["downloadURL"].endswith("/" + entry["assetName"]): + raise SystemExit(f"FAIL: downloadURL mismatch for {entry['assetName']}") + if len(entry["sha256"]) != 64: + raise SystemExit(f"FAIL: invalid sha256 for {entry['assetName']}") + +print("PASS: remote daemon release assets include all targets and manifest entries") +PY