diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5b3e21a9..1fb75cf1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,6 +20,8 @@ concurrency: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -142,6 +144,11 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} @@ -233,6 +240,7 @@ jobs: NIGHTLY_BUILD="${NIGHTLY_DATE}000000" fi echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" + echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV" ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg" @@ -277,6 +285,24 @@ jobs: echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" + - name: Build remote daemon nightly assets and inject manifest + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + run: | + set -euo pipefail + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$NIGHTLY_REMOTE_DAEMON_VERSION" \ + --release-tag "nightly" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + for APP_PLIST in \ + "build-arm/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" \ + "build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" + do + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + done + - name: Import signing cert if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' env: @@ -420,6 +446,18 @@ jobs: ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml + - name: Attest remote daemon nightly assets + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload branch nightly artifacts if: needs.decide.outputs.should_publish != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -430,6 +468,12 @@ jobs: cmux-nightly-universal-macos*.dmg appcast.xml appcast-universal.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json if-no-files-found: error - name: Move nightly tag to built commit @@ -465,6 +509,12 @@ jobs: cmux-nightly-universal-macos.dmg appcast.xml appcast-universal.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json overwrite_files: true - name: Cleanup keychain diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec935c63..bd59b8d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -114,6 +116,12 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key if: steps.guard_release_assets.outputs.skip_all != 'true' env: @@ -134,6 +142,21 @@ jobs: -clonedSourcePackagesDirPath .spm-cache \ CODE_SIGNING_ALLOWED=NO build + - name: Build remote daemon release assets and inject manifest + if: steps.guard_release_assets.outputs.skip_all != 'true' + run: | + set -euo pipefail + APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist" + APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$APP_VERSION" \ + --release-tag "$GITHUB_REF_NAME" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + - name: Inject Sparkle keys into Info.plist if: steps.guard_release_assets.outputs.skip_all != 'true' run: | @@ -260,6 +283,18 @@ jobs: fi ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml + - name: Attest remote daemon release assets + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -267,6 +302,12 @@ jobs: files: | cmux-macos.dmg appcast.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json generate_release_notes: true overwrite_files: false diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 602b6a73..543db4b5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit import Darwin #if canImport(LocalAuthentication) import LocalAuthentication @@ -1030,6 +1031,11 @@ struct CMUXCLI { return } + if command == "remote-daemon-status" { + try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput) + return + } + // If the argument looks like a path (not a known command), open a workspace there. if looksLikePath(command) { try openPath(command, socketPath: resolvedSocketPath) @@ -2867,11 +2873,42 @@ struct CMUXCLI { let remoteRelayPort: Int } + private struct RemoteDaemonManifest: Decodable { + struct Entry: Decodable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } + } + private func generateRemoteRelayPort() -> Int { // Random port in the ephemeral range (49152-65535) Int.random(in: 49152...65535) } + private func randomHex(byteCount: Int) throws -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw CLIError(message: "failed to generate SSH relay credential") + } + return bytes.map { String(format: "%02x", $0) }.joined() + } + private func runSSH( commandArgs: [String], client: SocketClient, @@ -2881,6 +2918,8 @@ struct CMUXCLI { // Use the socket path from this invocation (supports --socket overrides). let localSocketPath = client.socketPath let remoteRelayPort = generateRemoteRelayPort() + let relayID = UUID().uuidString.lowercased() + let relayToken = try randomHex(byteCount: 32) let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) prepareSSHTerminfoIfNeeded(sshOptions) let sshCommand = buildSSHCommandText(sshOptions) @@ -2943,6 +2982,8 @@ struct CMUXCLI { } if sshOptions.remoteRelayPort > 0 { configureParams["relay_port"] = sshOptions.remoteRelayPort + configureParams["relay_id"] = relayID + configureParams["relay_token"] = relayToken configureParams["local_socket_path"] = sshOptions.localSocketPath } configureParams["terminal_startup_command"] = sshStartupCommand @@ -3329,6 +3370,171 @@ struct CMUXCLI { ]) } + private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { + let requestedOS = optionValue(commandArgs, name: "--os")?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestedArch = optionValue(commandArgs, name: "--arch")?.trimmingCharacters(in: .whitespacesAndNewlines) + let info = resolvedVersionInfo() + let manifest = remoteDaemonManifest() + let platform = defaultRemoteDaemonPlatform(requestedOS: requestedOS, requestedArch: requestedArch) + let cacheURL = remoteDaemonCacheURL(version: manifest?.appVersion ?? remoteDaemonVersionString(from: info), goOS: platform.goOS, goArch: platform.goArch) + let cacheExists = FileManager.default.fileExists(atPath: cacheURL.path) + let cacheSHA = cacheExists ? try? sha256Hex(forFile: cacheURL) : nil + let entry = manifest?.entry(goOS: platform.goOS, goArch: platform.goArch) + let cacheVerified = (entry != nil && cacheSHA?.lowercased() == entry?.sha256.lowercased()) + let releaseTag = manifest?.releaseTag ?? "unknown" + let assetName = entry?.assetName ?? "unknown" + let downloadURL = entry?.downloadURL ?? "unknown" + let checksumsAssetName = manifest?.checksumsAssetName ?? "unknown" + let checksumsURL = manifest?.checksumsURL ?? "unknown" + let downloadCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(assetName)" + let downloadChecksumsCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(checksumsAssetName)" + let checksumVerifyCommand = "shasum -a 256 -c \(checksumsAssetName) --ignore-missing" + let signerWorkflow = releaseTag == "nightly" + ? "manaflow-ai/cmux/.github/workflows/nightly.yml" + : "manaflow-ai/cmux/.github/workflows/release.yml" + let verifyCommand = "gh attestation verify ./\(assetName) --repo manaflow-ai/cmux --signer-workflow \(signerWorkflow)" + + let payload: [String: Any] = [ + "app_version": remoteDaemonVersionString(from: info), + "build": info["CFBundleVersion"] ?? NSNull(), + "commit": info["CMUXCommit"] ?? NSNull(), + "manifest_present": manifest != nil, + "release_tag": releaseTag, + "release_url": manifest?.releaseURL ?? NSNull(), + "target_goos": platform.goOS, + "target_goarch": platform.goArch, + "asset_name": assetName, + "download_url": downloadURL, + "checksums_asset_name": checksumsAssetName, + "checksums_url": checksumsURL, + "expected_sha256": entry?.sha256 ?? NSNull(), + "cache_path": cacheURL.path, + "cache_exists": cacheExists, + "cache_sha256": cacheSHA ?? NSNull(), + "cache_verified": cacheVerified, + "dev_local_build_fallback": ProcessInfo.processInfo.environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1", + "download_command": downloadCommand, + "download_checksums_command": downloadChecksumsCommand, + "checksum_verify_command": checksumVerifyCommand, + "attestation_verify_command": verifyCommand, + ] + + if jsonOutput { + print(jsonString(payload)) + return + } + + print("app version: \(payload["app_version"] as? String ?? "unknown")") + if let build = payload["build"] as? String { + print("build: \(build)") + } + if let commit = payload["commit"] as? String { + print("commit: \(commit)") + } + print("manifest: \(manifest != nil ? "present" : "missing")") + print("platform: \(platform.goOS)/\(platform.goArch)") + print("release: \(releaseTag)") + print("asset: \(assetName)") + print("download url: \(downloadURL)") + print("checksums asset: \(checksumsAssetName)") + print("checksums: \(checksumsURL)") + if let expectedSHA = entry?.sha256 { + print("expected sha256: \(expectedSHA)") + } + print("cache: \(cacheURL.path)") + print("cache exists: \(cacheExists ? "yes" : "no")") + if let cacheSHA { + print("cache sha256: \(cacheSHA)") + } + print("cache verified: \(cacheVerified ? "yes" : "no")") + print("download command: \(downloadCommand)") + print("download checksums: \(downloadChecksumsCommand)") + print("verify checksum: \(checksumVerifyCommand)") + print("attestation verify: \(verifyCommand)") + if manifest == nil { + print("note: this build has no embedded remote daemon manifest. Set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 only for dev builds.") + } + } + + private func defaultRemoteDaemonPlatform(requestedOS: String?, requestedArch: String?) -> (goOS: String, goArch: String) { + let normalizedOS = requestedOS? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let normalizedArch = requestedArch? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let goOS = (normalizedOS?.isEmpty == false ? normalizedOS! : hostGoOS()) + let goArch = (normalizedArch?.isEmpty == false ? normalizedArch! : hostGoArch()) + return (goOS, goArch) + } + + private func hostGoOS() -> String { +#if os(macOS) + return "darwin" +#elseif os(Linux) + return "linux" +#else + return "unknown" +#endif + } + + private func hostGoArch() -> String { +#if arch(arm64) + return "arm64" +#elseif arch(x86_64) + return "amd64" +#else + return "unknown" +#endif + } + + private func remoteDaemonManifest() -> RemoteDaemonManifest? { + for plistURL in candidateInfoPlistURLs() { + guard let raw = NSDictionary(contentsOf: plistURL) as? [String: Any], + let rawManifest = raw["CMUXRemoteDaemonManifestJSON"] as? String, + let data = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8), + let manifest = try? JSONDecoder().decode(RemoteDaemonManifest.self, from: data) else { + continue + } + return manifest + } + return nil + } + + private func remoteDaemonVersionString(from info: [String: String]) -> String { + info["CFBundleShortVersionString"] ?? "dev" + } + + private func remoteDaemonCacheURL(version: String, goOS: String, goArch: String) -> URL { + let root: URL + do { + root = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } catch { + return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + return root + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { let loweredKey = key.lowercased() for option in options { @@ -5197,6 +5403,17 @@ struct CMUXCLI { cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no """ + case "remote-daemon-status": + return """ + Usage: cmux remote-daemon-status [--os ] [--arch ] + + Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state, + and the GitHub attestation verification command for a target platform. + + Example: + cmux remote-daemon-status + cmux remote-daemon-status --os linux --arch arm64 + """ case "new-split": return """ Usage: cmux new-split [flags] @@ -9030,6 +9247,7 @@ struct CMUXCLI { list-workspaces new-workspace [--cwd ] [--command ] ssh [--name ] [--port <n>] [--identity <path>] [--ssh-option <opt>] [-- <remote-command-args>] + remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 917840a2..001a40ba 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3340,10 +3340,29 @@ class TerminalController { let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) let sshOptions = v2StringArray(params, "ssh_options") ?? [] let autoConnect = v2Bool(params, "auto_connect") ?? true - let relayPort = v2Int(params, "relay_port") + var relayPort: Int? + if v2HasNonNullParam(params, "relay_port") { + guard let parsedRelayPort = v2StrictInt(params, "relay_port"), + parsedRelayPort > 0, + parsedRelayPort <= 65535 else { + return .err(code: "invalid_params", message: "relay_port must be 1-65535", data: nil) + } + relayPort = parsedRelayPort + } + let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) + let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) let localSocketPath = v2RawString(params, "local_socket_path") let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? .trimmingCharacters(in: .whitespacesAndNewlines) + if relayPort != nil { + guard let relayID, !relayID.isEmpty else { + return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) + } + guard let relayToken, + relayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + return .err(code: "invalid_params", message: "relay_token must be 64 lowercase hex characters when relay_port is set", data: nil) + } + } #if DEBUG dlog( @@ -3373,6 +3392,8 @@ class TerminalController { sshOptions: sshOptions, localProxyPort: localProxyPort, relayPort: relayPort, + relayID: relayID?.isEmpty == true ? nil : relayID, + relayToken: relayToken?.isEmpty == true ? nil : relayToken, localSocketPath: localSocketPath, terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand ) @@ -3516,8 +3537,9 @@ class TerminalController { guard let surfaceId = v2UUID(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) } - guard let relayPort = v2Int(params, "relay_port"), - relayPort > 0 else { + guard let relayPort = v2StrictInt(params, "relay_port"), + relayPort > 0, + relayPort <= 65535 else { return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c1253fb8..530d4b5d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,7 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CryptoKit import Darwin import Network import CoreText @@ -106,6 +107,28 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } +struct WorkspaceRemoteDaemonManifest: Decodable, Equatable { + struct Entry: Decodable, Equatable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } +} + extension Workspace { func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() @@ -933,10 +956,12 @@ private final class WorkspaceRemoteDaemonRPCClient { } private func stop(suppressTerminationCallback: Bool) { - let captured: (Process?, FileHandle?, FileHandle?, FileHandle?) = stateQueue.sync { + let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" + let shouldNotify = !suppressTerminationCallback && !isClosed shouldReportTermination = !suppressTerminationCallback if isClosed { - return (nil, nil, nil, nil) + return (nil, nil, nil, nil, false, detail) } isClosed = true @@ -950,7 +975,7 @@ private final class WorkspaceRemoteDaemonRPCClient { stdinHandle = nil stdoutHandle = nil stderrHandle = nil - return (capturedProcess, capturedStdin, capturedStdout, capturedStderr) + return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) } captured.2?.readabilityHandler = nil @@ -961,6 +986,9 @@ private final class WorkspaceRemoteDaemonRPCClient { if let process = captured.0, process.isRunning { process.terminate() } + if captured.4 { + onUnexpectedTermination(captured.5) + } } private func signalPendingFailureLocked(_ message: String) { @@ -1924,6 +1952,443 @@ private final class WorkspaceRemoteProxyBroker { } } +private final class WorkspaceRemoteCLIRelayServer { + private final class Session { + private enum Phase { + case awaitingAuth + case awaitingCommand + case forwarding + case closed + } + + private let connection: NWConnection + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue: DispatchQueue + private let onClose: () -> Void + private let challengeProtocol = "cmux-relay-auth" + private let challengeVersion = 1 + private let minimumFailureDelay: TimeInterval = 0.05 + private let maximumFrameBytes = 16 * 1024 + + private var buffer = Data() + private var phase: Phase = .awaitingAuth + private var challengeNonce = "" + private var challengeSentAt = Date() + private var isClosed = false + + init( + connection: NWConnection, + localSocketPath: String, + relayID: String, + relayToken: Data, + queue: DispatchQueue, + onClose: @escaping () -> Void + ) { + self.connection = connection + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleState(state) + } + } + connection.start(queue: queue) + } + + func stop() { + close() + } + + private func handleState(_ state: NWConnection.State) { + guard !isClosed else { return } + switch state { + case .ready: + sendChallenge() + receive() + case .failed, .cancelled: + close() + default: + break + } + } + + private func sendChallenge() { + challengeSentAt = Date() + challengeNonce = Self.randomHex(byteCount: 16) + let challenge: [String: Any] = [ + "protocol": challengeProtocol, + "version": challengeVersion, + "relay_id": relayID, + "nonce": challengeNonce, + ] + sendJSONLine(challenge) { _ in } + } + + private func receive() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.queue.async { + if error != nil { + self.close() + return + } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.buffer.count > self.maximumFrameBytes { + self.sendFailureAndClose() + return + } + self.processBufferedLines() + } + if isComplete { + self.close() + return + } + if !self.isClosed { + self.receive() + } + } + } + } + + private func processBufferedLines() { + while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(...newlineIndex) + let line = String(data: lineData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch phase { + case .awaitingAuth: + handleAuthLine(line) + case .awaitingCommand: + handleCommandLine(Data(lineData) + Data([0x0A])) + case .forwarding, .closed: + return + } + } + } + + private func handleAuthLine(_ line: String) { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let receivedRelayID = object["relay_id"] as? String, + receivedRelayID == relayID, + let macHex = object["mac"] as? String, + let receivedMAC = Self.hexData(from: macHex) + else { + sendFailureAndClose() + return + } + + let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) + let expectedMAC = Self.authMAC(token: relayToken, message: message) + guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { + sendFailureAndClose() + return + } + + phase = .awaitingCommand + sendJSONLine(["ok": true]) { [weak self] _ in + self?.queue.async { + self?.processBufferedLines() + } + } + } + + private func handleCommandLine(_ commandLine: Data) { + guard !commandLine.isEmpty else { + sendFailureAndClose() + return + } + phase = .forwarding + DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in + let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + queue.async { [weak self] in + guard let self else { return } + switch result { + case .success(let response): + self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in + self?.queue.async { + self?.close() + } + }) + case .failure: + self.sendFailureAndClose() + } + } + } + } + + private func sendFailureAndClose() { + let elapsed = Date().timeIntervalSince(challengeSentAt) + let delay = max(0, minimumFailureDelay - elapsed) + phase = .closed + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendJSONLine(["ok": false]) { [weak self] _ in + self?.queue.async { + self?.close() + } + } + } + } + + private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { + guard !isClosed else { + completion(nil) + return + } + guard let payload = try? JSONSerialization.data(withJSONObject: object) else { + completion(nil) + return + } + connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) + } + + private func close() { + guard !isClosed else { return } + isClosed = true + phase = .closed + connection.stateUpdateHandler = nil + connection.cancel() + onClose() + } + + private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { + Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) + } + + private static func authMAC(token: Data, message: Data) -> Data { + let key = SymmetricKey(data: token) + let code = HMAC<SHA256>.authenticationCode(for: message, using: key) + return Data(code) + } + + private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var diff: UInt8 = 0 + for index in lhs.indices { + diff |= lhs[index] ^ rhs[index] + } + return diff == 0 + } + + fileprivate static func hexData(from string: String) -> Data? { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } + var data = Data(capacity: normalized.count / 2) + var cursor = normalized.startIndex + while cursor < normalized.endIndex { + let next = normalized.index(cursor, offsetBy: 2) + guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { return nil } + data.append(byte) + cursor = next + } + return data + } + + private static func randomHex(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "cmux.remote.relay", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "failed to create local relay socket", + ]) + } + defer { Darwin.close(fd) } + + var timeout = timeval(tv_sec: 15, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw NSError(domain: "cmux.remote.relay", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "local relay socket path is too long", + ]) + } + let sunPathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0 + withUnsafeMutableBytes(of: &address) { rawBuffer in + let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) + pathBytes.withUnsafeBytes { pathBuffer in + destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) + } + } + + let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) + let connectResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + guard connectResult == 0 else { + throw NSError(domain: "cmux.remote.relay", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "failed to connect to local cmux socket", + ]) + } + + try request.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var bytesRemaining = rawBuffer.count + var pointer = baseAddress + while bytesRemaining > 0 { + let written = Darwin.write(fd, pointer, bytesRemaining) + if written <= 0 { + throw NSError(domain: "cmux.remote.relay", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "failed to write relay request", + ]) + } + bytesRemaining -= written + pointer = pointer.advanced(by: written) + } + } + _ = shutdown(fd, SHUT_WR) + + var response = Data() + var scratch = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(fd, &scratch, scratch.count) + if count > 0 { + response.append(scratch, count: count) + continue + } + if count == 0 { + break + } + + if errno == EAGAIN || errno == EWOULDBLOCK { + if !response.isEmpty { + break + } + throw NSError(domain: "cmux.remote.relay", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local cmux response", + ]) + } + throw NSError(domain: "cmux.remote.relay", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "failed to read local cmux response", + ]) + } + return response + } + } + + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var sessions: [UUID: Session] = [:] + private var isStopped = false + private(set) var localPort: Int? + + init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { + guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { + throw NSError(domain: "cmux.remote.relay", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "invalid relay token", + ]) + } + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + } + + func start() throws -> Int { + var capturedError: Error? + var boundPort: Int = 0 + queue.sync { + do { + if let localPort { + boundPort = localPort + return + } + let listener = try Self.makeLoopbackListener() + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { _ in } + listener.start(queue: queue) + guard let tcpPort = listener.port?.rawValue else { + throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "failed to bind local relay listener", + ]) + } + self.listener = listener + self.localPort = Int(tcpPort) + boundPort = Int(tcpPort) + } catch { + capturedError = error + } + } + if let capturedError { + throw capturedError + } + return boundPort + } + + func stop() { + queue.sync { + guard !isStopped else { return } + isStopped = true + listener?.newConnectionHandler = nil + listener?.stateUpdateHandler = nil + listener?.cancel() + listener = nil + localPort = nil + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + let sessionID = UUID() + let session = Session( + connection: connection, + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken, + queue: queue + ) { [weak self] in + self?.sessions.removeValue(forKey: sessionID) + } + sessions[sessionID] = session + session.start() + } + + private static func makeLoopbackListener() throws -> NWListener { + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) + return try NWListener(using: parameters) + } +} + private final class WorkspaceRemoteSessionController { private struct CommandResult { let status: Int32 @@ -1956,6 +2421,7 @@ private final class WorkspaceRemoteSessionController { private var daemonBootstrapVersion: String? private var daemonRemotePath: String? private var reverseRelayProcess: Process? + private var cliRelayServer: WorkspaceRemoteCLIRelayServer? private var reverseRelayStderrPipe: Pipe? private var reverseRelayRestartWorkItem: DispatchWorkItem? private var reverseRelayStderrBuffer = "" @@ -2090,13 +2556,16 @@ private final class WorkspaceRemoteSessionController { private func prepareRemoteCLISessionLocked(remotePath: String) { createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) - writeRemoteRelayDaemonPathLocked(remotePath: remotePath) } private func startReverseRelayLocked(remotePath: String) { guard !isStopping else { return } guard daemonReady else { return } guard let relayPort = configuration.relayPort, relayPort > 0, + let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayID.isEmpty, + let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayToken.isEmpty, let localSocketPath = configuration.localSocketPath? .trimmingCharacters(in: .whitespacesAndNewlines), !localSocketPath.isEmpty else { @@ -2106,50 +2575,53 @@ private final class WorkspaceRemoteSessionController { reverseRelayRestartWorkItem?.cancel() reverseRelayRestartWorkItem = nil - Self.killOrphanedRelayProcesses( - relayPort: relayPort, - socketPath: localSocketPath, - destination: configuration.destination - ) + do { + let relayServer = try ensureCLIRelayServerLocked( + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken + ) + let localRelayPort = try relayServer.start() + Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination) - let process = Process() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = reverseRelayArguments(relayPort: relayPort, localSocketPath: localSocketPath) - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - self?.queue.async { - guard let self else { return } - if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { - self.reverseRelayStderrBuffer.append(chunk) - if self.reverseRelayStderrBuffer.count > 8192 { - self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } } } } - } - process.terminationHandler = { [weak self] terminated in - self?.queue.async { - self?.handleReverseRelayTerminationLocked(process: terminated) + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTerminationLocked(process: terminated) + } } - } - do { try process.run() reverseRelayProcess = process + cliRelayServer = relayServer reverseRelayStderrPipe = stderrPipe reverseRelayStderrBuffer = "" debugLog( - "remote.relay.start relayPort=\(relayPort) localSocket=\(localSocketPath) " + + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + "target=\(configuration.displayTarget)" ) @@ -2157,14 +2629,24 @@ private final class WorkspaceRemoteSessionController { guard let self else { return } guard !self.isStopping else { return } guard self.reverseRelayProcess === process, process.isRunning else { return } - self.writeRemoteSocketAddrLocked(relayPort: relayPort) self.writeRemoteRelayDaemonPathLocked(remotePath: remotePath) + do { + try self.writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken) + } catch { + self.debugLog("remote.relay.auth.error \(error.localizedDescription)") + self.stopReverseRelayLocked() + self.scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + self.writeRemoteSocketAddrLocked(relayPort: relayPort) } } catch { debugLog( - "remote.relay.startFailed relayPort=\(relayPort) localSocket=\(localSocketPath) " + + "remote.relay.startFailed relayPort=\(relayPort) " + "error=\(error.localizedDescription)" ) + cliRelayServer?.stop() + cliRelayServer = nil scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) } } @@ -2209,6 +2691,9 @@ private final class WorkspaceRemoteSessionController { reverseRelayProcess = nil reverseRelayStderrPipe = nil reverseRelayStderrBuffer = "" + cliRelayServer?.stop() + cliRelayServer = nil + removeRemoteRelayMetadataLocked() } private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { @@ -2390,7 +2875,7 @@ private final class WorkspaceRemoteSessionController { } } - private func reverseRelayArguments(relayPort: Int, localSocketPath: String) -> [String] { + private func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { // `-o ControlPath=none` is not enough on macOS OpenSSH, the client can still // attach to an existing master and exit immediately with its status. // `-S none` forces a standalone transport for the reverse relay. @@ -2399,12 +2884,15 @@ private final class WorkspaceRemoteSessionController { args += [ "-o", "ExitOnForwardFailure=no", "-o", "RequestTTY=no", - "-R", "127.0.0.1:\(relayPort):\(localSocketPath)", + "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", configuration.destination, ] return args } + private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__=" + private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__=" + private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { if batchMode { @@ -2533,33 +3021,29 @@ private final class WorkspaceRemoteSessionController { let captureQueue = DispatchQueue(label: "cmux.remote.process.capture") var stdoutData = Data() var stderrData = Data() - - stdoutHandle.readabilityHandler = { handle in - let data = handle.availableData + let captureGroup = DispatchGroup() + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stdoutHandle.readDataToEndOfFile() captureQueue.sync { - if data.isEmpty { - handle.readabilityHandler = nil - } else { - stdoutData.append(data) - } + stdoutData = data } + captureGroup.leave() } - stderrHandle.readabilityHandler = { handle in - let data = handle.availableData + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stderrHandle.readDataToEndOfFile() captureQueue.sync { - if data.isEmpty { - handle.readabilityHandler = nil - } else { - stderrData.append(data) - } + stderrData = data } + captureGroup.leave() } do { try process.run() } catch { - stdoutHandle.readabilityHandler = nil - stderrHandle.readabilityHandler = nil + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() debugLog( "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + "error=\(error.localizedDescription)" @@ -2568,6 +3052,8 @@ private final class WorkspaceRemoteSessionController { NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", ]) } + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() if let stdin, let pipe = process.standardInput as? Pipe { pipe.fileHandleForWriting.write(stdin) @@ -2597,12 +3083,7 @@ private final class WorkspaceRemoteSessionController { ]) } - stdoutHandle.readabilityHandler = nil - stderrHandle.readabilityHandler = nil - captureQueue.sync { - stdoutData.append(stdoutHandle.readDataToEndOfFile()) - stderrData.append(stderrHandle.readDataToEndOfFile()) - } + _ = captureGroup.wait(timeout: .now() + 2.0) try? stdoutHandle.close() try? stderrHandle.close() let stdout = String(data: stdoutData, encoding: .utf8) ?? "" @@ -2669,6 +3150,19 @@ private final class WorkspaceRemoteSessionController { } } + private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { + if let cliRelayServer { + return cliRelayServer + } + let relayServer = try WorkspaceRemoteCLIRelayServer( + localSocketPath: localSocketPath, + relayID: relayID, + relayTokenHex: relayToken + ) + cliRelayServer = relayServer + return relayServer + } + private func writeRemoteSocketAddrLocked(relayPort: Int) { let script = """ mkdir -p "$HOME/.cmux" @@ -2709,8 +3203,47 @@ private final class WorkspaceRemoteSessionController { } } + private func writeRemoteRelayAuthLocked(relayPort: Int, relayID: String, relayToken: String) throws { + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + let script = """ + umask 077 + mkdir -p "$HOME/.cmux/relay" + chmod 700 "$HOME/.cmux/relay" + cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' + \(authPayload) + CMUXRELAYAUTH + chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.relay", code: 70, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote relay auth: \(detail)", + ]) + } + } + + private func removeRemoteRelayMetadataLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let script = """ + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + } catch { + debugLog("remote.relay.cleanup.error \(error.localizedDescription)") + } + } + private func resolveRemotePlatformLocked() throws -> RemotePlatform { - let script = "uname -s; uname -m" + let script = """ + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$(uname -s)" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$(uname -m)" + """ let command = "sh -c \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) guard result.status == 0 else { @@ -2721,19 +3254,23 @@ private final class WorkspaceRemoteSessionController { } let lines = result.stdout - .split(separator: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - guard lines.count >= 2 else { + let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } + let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } + guard let unameOS, let unameArch else { throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ NSLocalizedDescriptionKey: "remote platform probe returned invalid output", ]) } - guard let goOS = Self.mapUnameOS(lines[0]), - let goArch = Self.mapUnameArch(lines[1]) else { + guard let goOS = Self.mapUnameOS(unameOS), + let goArch = Self.mapUnameArch(unameArch) else { throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "unsupported remote platform \(lines[0])/\(lines[1])", + NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", ]) } @@ -2748,15 +3285,170 @@ private final class WorkspaceRemoteSessionController { return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" } + static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" + + static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } + let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { + remoteDaemonManifest(from: Bundle.main.infoDictionary) + } + + private static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { + let appSupportRoot = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let cacheRoot = appSupportRoot + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + return cacheRoot + } + + static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try remoteDaemonCacheRoot(fileManager: fileManager) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private static func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" + } + + private static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { + guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } + guard let path = environment["CMUX_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + } + + private static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemon-build", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String) throws -> URL { + guard let url = URL(string: entry.downloadURL) else { + throw NSError(domain: "cmux.remote.daemon", code: 25, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", + ]) + } + + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) + let fileManager = FileManager.default + try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let request = NSMutableURLRequest(url: url) + request.timeoutInterval = 60 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + + let semaphore = DispatchSemaphore(value: 0) + var downloadedURL: URL? + var downloadError: Error? + session.downloadTask(with: request as URLRequest) { localURL, response, error in + defer { semaphore.signal() } + if let error { + downloadError = error + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + downloadError = NSError(domain: "cmux.remote.daemon", code: 26, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", + ]) + return + } + downloadedURL = localURL + }.resume() + _ = semaphore.wait(timeout: .now() + 75.0) + session.finishTasksAndInvalidate() + + if let downloadError { + throw downloadError + } + guard let downloadedURL else { + throw NSError(domain: "cmux.remote.daemon", code: 27, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download did not produce a file", + ]) + } + + let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) + guard downloadedSHA == entry.sha256.lowercased() else { + throw NSError(domain: "cmux.remote.daemon", code: 28, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", + ]) + } + + let tempURL = cacheURL.deletingLastPathComponent() + .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.moveItem(at: downloadedURL, to: tempURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: cacheURL) + try fileManager.moveItem(at: tempURL, to: cacheURL) + return cacheURL + } + private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { - if let bundledBinary = Self.findBundledDaemonBinary(goOS: goOS, goArch: goArch, version: version) { - debugLog("remote.build.bundled path=\(bundledBinary.path)") - return bundledBinary + if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), + FileManager.default.isExecutableFile(atPath: explicitBinary.path) { + debugLog("remote.build.explicit path=\(explicitBinary.path)") + return explicitBinary + } + + if let manifest = Self.remoteDaemonManifest(), + manifest.appVersion == version, + let entry = manifest.entry(goOS: goOS, goArch: goArch) { + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) + if FileManager.default.fileExists(atPath: cacheURL.path) { + let cachedSHA = try Self.sha256Hex(forFile: cacheURL) + if cachedSHA == entry.sha256.lowercased(), + FileManager.default.isExecutableFile(atPath: cacheURL.path) { + debugLog("remote.build.cached path=\(cacheURL.path)") + return cacheURL + } + try? FileManager.default.removeItem(at: cacheURL) + } + let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion) + debugLog("remote.build.downloaded path=\(downloadedURL.path)") + return downloadedURL + } + + guard Self.allowLocalDaemonBuildFallback() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "this build does not include a verified cmuxd-remote manifest for \(goOS)-\(goArch). Use a release/nightly build, or set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", + ]) } guard let repoRoot = Self.findRepoRoot() else { throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "cannot locate cmux repo root for daemon build and no bundled cmuxd-remote binary was found", + NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only cmuxd-remote build fallback", ]) } let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) @@ -2768,16 +3460,12 @@ private final class WorkspaceRemoteSessionController { } guard let goBinary = Self.which("go") else { throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "go is required to build cmuxd-remote when no bundled binary is available", + NSLocalizedDescriptionKey: "go is required for the dev-only cmuxd-remote build fallback", ]) } - let cacheRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("cmux-remote-daemon-build", isDirectory: true) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) - let output = cacheRoot.appendingPathComponent("cmuxd-remote", isDirectory: false) + let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) var env = ProcessInfo.processInfo.environment env["GOOS"] = goOS @@ -2807,27 +3495,6 @@ private final class WorkspaceRemoteSessionController { return output } - private static func findBundledDaemonBinary(goOS: String, goArch: String, version: String) -> URL? { - let fm = FileManager.default - var candidates: [URL] = [] - let env = ProcessInfo.processInfo.environment - if let explicit = env["CMUX_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !explicit.isEmpty { - candidates.append(URL(fileURLWithPath: explicit, isDirectory: false)) - } - if let resourceRoot = Bundle.main.resourceURL { - candidates.append(resourceRoot.appendingPathComponent("bin/cmuxd-remote-\(goOS)-\(goArch)", isDirectory: false)) - candidates.append(resourceRoot.appendingPathComponent("bin/cmuxd-remote", isDirectory: false)) - candidates.append(resourceRoot.appendingPathComponent("cmuxd-remote/\(goOS)-\(goArch)/cmuxd-remote", isDirectory: false)) - candidates.append(resourceRoot.appendingPathComponent("cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote", isDirectory: false)) - } - - for candidate in candidates.map(\.standardizedFileURL) where fm.isExecutableFile(atPath: candidate.path) { - return candidate - } - return nil - } - private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { let remoteDirectory = (remotePath as NSString).deletingLastPathComponent let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" @@ -3024,10 +3691,10 @@ private final class WorkspaceRemoteSessionController { ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" } - private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) { + private static func killOrphanedRelayProcesses(relayPort: Int, destination: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") - process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):\(socketPath).*\(destination)"] + process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):127\\.0\\.0\\.1:[0-9]+.*\(destination)"] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice do { @@ -3203,6 +3870,8 @@ struct WorkspaceRemoteConfiguration: Equatable { let sshOptions: [String] let localProxyPort: Int? let relayPort: Int? + let relayID: String? + let relayToken: String? let localSocketPath: String? let terminalStartupCommand: String? diff --git a/daemon/remote/README.md b/daemon/remote/README.md index a510a19e..07a2afaf 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -1,12 +1,12 @@ # cmuxd-remote (Go) -Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and CLI relay. +Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote proxy RPC. It is not in the terminal keystroke hot path. ## Commands 1. `cmuxd-remote version` 2. `cmuxd-remote serve --stdio` -3. `cmuxd-remote cli <command> [args...]` — relay cmux commands to the local app over the reverse TCP forward +3. `cmuxd-remote cli <command> [args...]` — relay cmux commands to the local app over the reverse SSH forward When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. @@ -37,9 +37,30 @@ Current integration in cmux: 3. `local_proxy_port` is an internal deterministic test hook used by bind-conflict regressions. 4. SSH option precedence checks are case-insensitive; user overrides for `StrictHostKeyChecking` and control-socket keys prevent default injection. +## Distribution + +Release and nightly builds publish prebuilt `cmuxd-remote` binaries on GitHub Releases for: +1. `darwin/arm64` +2. `darwin/amd64` +3. `linux/arm64` +4. `linux/amd64` + +The app embeds a compact manifest in `Info.plist` with: +1. exact release asset URLs +2. pinned SHA-256 digests +3. release tag and checksums asset URL + +Release and nightly apps download and cache the matching binary locally, verify its SHA-256, then upload it to the remote host if needed. Dev builds can opt into a local `go build` fallback with `CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1`. + +To inspect what a given app build trusts, run: +1. `cmux remote-daemon-status` +2. `cmux remote-daemon-status --os linux --arch amd64` + +The command prints the exact release asset URL, expected SHA-256, local cache status, and a copy-pasteable `gh attestation verify` command for the selected platform. + ## CLI relay -The `cli` subcommand (or `cmux` wrapper/symlink) connects to the local cmux app's socket through an SSH reverse TCP forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. +The `cli` subcommand (or `cmux` wrapper/symlink) connects to the local cmux app through an SSH reverse forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. Socket discovery order: 1. `--socket <path>` flag @@ -48,8 +69,14 @@ Socket discovery order: For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports. +Authenticated relay details: +1. Each SSH workspace gets its own relay ID and relay token. +2. The app runs a local loopback relay server that requires an HMAC-SHA256 challenge-response before forwarding a command to the real local Unix socket. +3. The remote shell never gets direct access to the local app socket. It only gets the reverse-forwarded relay port plus `~/.cmux/relay/<port>.auth`, which is written with `0600` permissions and removed when the relay stops. + Integration additions for the relay path: 1. Bootstrap installs `~/.cmux/bin/cmux` wrapper and keeps a default daemon target (`~/.cmux/bin/cmuxd-remote-current`). -2. A background `ssh -N -R` process reverse-forwards a TCP port to the local cmux Unix socket. The relay address is written to `~/.cmux/socket_addr` on the remote. -3. Relay startup writes `~/.cmux/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local cmux instances/versions coexist. +2. A background `ssh -N -R` process reverse-forwards a TCP port to the authenticated local relay server. The relay address is written to `~/.cmux/socket_addr` on the remote. +3. Relay startup writes `~/.cmux/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local cmux instances or versions coexist. +4. Relay startup writes `~/.cmux/relay/<port>.auth` with the relay ID and token needed for HMAC authentication. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index fad8b4d9..3c667d67 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -2,7 +2,9 @@ package main import ( "bufio" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -15,6 +17,11 @@ import ( "time" ) +type relayAuthState struct { + RelayID string `json:"relay_id"` + RelayToken string `json:"relay_token"` +} + // protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol. type protocolVersion int @@ -376,13 +383,58 @@ func readSocketAddrFile() string { return strings.TrimSpace(string(data)) } +func readRelayAuthFile(socketPath string) *relayAuthState { + if strings.Contains(socketPath, ":") && !strings.HasPrefix(socketPath, "/") { + _, port, err := net.SplitHostPort(socketPath) + if err != nil || port == "" { + return nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "relay", port+".auth")) + if err != nil { + return nil + } + var state relayAuthState + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + if state.RelayID == "" || state.RelayToken == "" { + return nil + } + return &state + } + return nil +} + +func currentRelayAuth(socketPath string) *relayAuthState { + relayID := strings.TrimSpace(os.Getenv("CMUX_RELAY_ID")) + relayToken := strings.TrimSpace(os.Getenv("CMUX_RELAY_TOKEN")) + if relayID != "" && relayToken != "" { + return &relayAuthState{RelayID: relayID, RelayToken: relayToken} + } + return readRelayAuthFile(socketPath) +} + // dialSocket connects to the cmux socket. If addr contains a colon and doesn't // start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. // For TCP connections, it retries briefly to allow the SSH reverse forward to establish. // refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { - return dialTCPRetry(addr, 15*time.Second, refreshAddr) + conn, err := dialTCPRetry(addr, 15*time.Second, refreshAddr) + if err != nil { + return nil, err + } + if auth := currentRelayAuth(addr); auth != nil { + if err := authenticateRelayConn(conn, auth); err != nil { + conn.Close() + return nil, err + } + } + return conn, nil } return net.Dial("unix", addr) } @@ -429,6 +481,66 @@ func isConnectionRefused(err error) bool { return strings.Contains(err.Error(), "connection refused") } +func authenticateRelayConn(conn net.Conn, auth *relayAuthState) error { + reader := bufio.NewReader(conn) + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + var challenge struct { + Protocol string `json:"protocol"` + Version int `json:"version"` + RelayID string `json:"relay_id"` + Nonce string `json:"nonce"` + } + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth challenge: %w", err) + } + if err := json.Unmarshal([]byte(line), &challenge); err != nil { + return fmt.Errorf("invalid relay auth challenge") + } + if challenge.Protocol != "cmux-relay-auth" || challenge.Version != 1 || challenge.RelayID != auth.RelayID || challenge.Nonce == "" { + return fmt.Errorf("relay auth challenge mismatch") + } + + tokenBytes, err := hex.DecodeString(auth.RelayToken) + if err != nil { + return fmt.Errorf("invalid relay auth token") + } + mac := computeRelayMAC(tokenBytes, auth.RelayID, challenge.Nonce, challenge.Version) + payload, err := json.Marshal(map[string]any{ + "relay_id": auth.RelayID, + "mac": hex.EncodeToString(mac), + }) + if err != nil { + return fmt.Errorf("failed to encode relay auth response: %w", err) + } + if _, err := conn.Write(append(payload, '\n')); err != nil { + return fmt.Errorf("failed to send relay auth response: %w", err) + } + + line, err = reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth result: %w", err) + } + var result struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal([]byte(line), &result); err != nil { + return fmt.Errorf("invalid relay auth result") + } + if !result.OK { + return fmt.Errorf("relay auth rejected") + } + _ = conn.SetDeadline(time.Time{}) + return nil +} + +func computeRelayMAC(token []byte, relayID, nonce string, version int) []byte { + mac := hmac.New(sha256.New, token) + _, _ = io.WriteString(mac, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, version)) + return mac.Sum(nil) +} + // socketRoundTrip sends a raw text line and reads a raw text response (v1). func socketRoundTrip(socketPath, command string, refreshAddr func() string) (string, error) { conn, err := dialSocket(socketPath, refreshAddr) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 15f8efa3..22db25a3 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -9,6 +9,7 @@ import ( "flag" "fmt" "io" + "math" "net" "os" "path/filepath" @@ -1017,6 +1018,9 @@ func getIntParam(params map[string]any, key string) (int, bool) { case uint64: return int(value), true case float64: + if math.Trunc(value) != value { + return 0, false + } return int(value), true case json.Number: n, err := value.Int64() diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 2c88e9bf..03aaa248 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -1,6 +1,6 @@ # Remote SSH Living Spec -Last updated: February 23, 2026 +Last updated: March 12, 2026 Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 Primary PR: https://github.com/manaflow-ai/cmux/pull/239 CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 @@ -30,7 +30,7 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` socket API includes `workspace.remote.reconnect`. ### 3.2 Bootstrap + Daemon -- `DONE` local app probes remote platform, builds/uploads `cmuxd-remote`, and runs `serve --stdio`. +- `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`. - `DONE` daemon `hello` handshake is enforced. - `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). - `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`. @@ -44,16 +44,22 @@ This is a **living implementation spec** (also called an **execution spec**): a ### 3.5 CLI Relay (Running cmux Commands From Remote) - `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. - `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. -- `DONE` background `ssh -N -R 127.0.0.1:PORT:/local/cmux.sock` process reverse-forwards a TCP port to the local cmux socket. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. +- `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. - `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). - `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. - `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. - `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr. - `DONE` relay startup writes `~/.cmux/relay/<relay_port>.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket. - `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. - `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. - `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. +### 3.6 Artifact Trust +- `DONE` release and nightly workflows publish `cmuxd-remote` assets for `darwin/linux × arm64/amd64`. +- `DONE` release and nightly apps embed a compact `CMUXRemoteDaemonManifestJSON` in `Info.plist` with exact asset URLs and SHA-256 digests. +- `DONE` `cmux remote-daemon-status` exposes the current manifest entry, local cache verification state, release download command, and GitHub attestation verification command. + ### 3.3 Error Surfacing - `DONE` remote errors are surfaced in sidebar status + logs + notifications. - `DONE` reconnect retry count/time is included in surfaced error text (for example, `retry 1 in 4s`). diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh new file mode 100755 index 00000000..a6be6fc6 --- /dev/null +++ b/scripts/build_remote_daemon_release_assets.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/build_remote_daemon_release_assets.sh \ + --version <app-version> \ + --release-tag <tag> \ + --repo <owner/repo> \ + --output-dir <dir> + +Builds cmuxd-remote release assets for the supported remote platforms and emits: + cmuxd-remote-<goos>-<goarch> + cmuxd-remote-checksums.txt + cmuxd-remote-manifest.json +EOF +} + +VERSION="" +RELEASE_TAG="" +REPO="" +OUTPUT_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --release-tag) + RELEASE_TAG="${2:-}" + shift 2 + ;; + --repo) + REPO="${2:-}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$VERSION" || -z "$RELEASE_TAG" || -z "$REPO" || -z "$OUTPUT_DIR" ]]; then + echo "error: --version, --release-tag, --repo, and --output-dir are required" >&2 + usage + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "error: go is required to build cmuxd-remote release assets" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +DAEMON_ROOT="${REPO_ROOT}/daemon/remote" +mkdir -p "$OUTPUT_DIR" +rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json + +RELEASE_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}" +CHECKSUMS_ASSET_NAME="cmuxd-remote-checksums.txt" +CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}" +MANIFEST_PATH="${OUTPUT_DIR}/cmuxd-remote-manifest.json" + +TARGETS=( + "darwin arm64" + "darwin amd64" + "linux arm64" + "linux amd64" +) + +declare -a manifest_entries=() +: > "$CHECKSUMS_PATH" + +for target in "${TARGETS[@]}"; do + read -r GOOS GOARCH <<<"$target" + ASSET_NAME="cmuxd-remote-${GOOS}-${GOARCH}" + OUTPUT_PATH="${OUTPUT_DIR}/${ASSET_NAME}" + + ( + cd "$DAEMON_ROOT" + GOOS="$GOOS" \ + GOARCH="$GOARCH" \ + CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$OUTPUT_PATH" \ + ./cmd/cmuxd-remote + ) + chmod 755 "$OUTPUT_PATH" + + SHA256="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')" + printf '%s %s\n' "$SHA256" "$ASSET_NAME" >> "$CHECKSUMS_PATH" + + manifest_entries+=("{\"goOS\":\"${GOOS}\",\"goArch\":\"${GOARCH}\",\"assetName\":\"${ASSET_NAME}\",\"downloadURL\":\"${RELEASE_URL}/${ASSET_NAME}\",\"sha256\":\"${SHA256}\"}") +done + +ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")" +trap 'rm -f "$ENTRIES_FILE"' EXIT +printf '%s\n' "${manifest_entries[@]}" > "$ENTRIES_FILE" +ENTRIES_JSON="$(python3 - <<'PY' "$ENTRIES_FILE" +import json +import sys +from pathlib import Path + +entries = [json.loads(line) for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() if line.strip()] +print(json.dumps(entries, separators=(",", ":"))) +PY +)" + +python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$RELEASE_URL" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_JSON" +import json +import sys +from pathlib import Path + +version, release_tag, release_url, checksums_asset_name, checksums_path, manifest_path, entries_json = sys.argv[1:] +checksums_url = f"{release_url}/{checksums_asset_name}" +manifest = { + "schemaVersion": 1, + "appVersion": version, + "releaseTag": release_tag, + "releaseURL": release_url, + "checksumsAssetName": checksums_asset_name, + "checksumsURL": checksums_url, + "entries": json.loads(entries_json), +} +Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + +echo "Built cmuxd-remote assets in ${OUTPUT_DIR}" diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js index d16d328e..4699b324 100644 --- a/scripts/release_asset_guard.js +++ b/scripts/release_asset_guard.js @@ -1,6 +1,15 @@ "use strict"; -const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; +const IMMUTABLE_RELEASE_ASSETS = [ + "cmux-macos.dmg", + "appcast.xml", + "cmuxd-remote-darwin-arm64", + "cmuxd-remote-darwin-amd64", + "cmuxd-remote-linux-arm64", + "cmuxd-remote-linux-amd64", + "cmuxd-remote-checksums.txt", + "cmuxd-remote-manifest.json", +]; const RELEASE_ASSET_GUARD_STATE = Object.freeze({ CLEAR: "clear", PARTIAL: "partial", diff --git a/scripts/reload.sh b/scripts/reload.sh index 6bbd755d..5a4f2a6e 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -358,6 +358,10 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXTERM_REPO_ROOT string \"${PWD}\"" "$INFO_PLIST" if [[ -S "$CMUXD_SOCKET" ]]; then for PID in $(lsof -t "$CMUXD_SOCKET" 2>/dev/null); do kill "$PID" 2>/dev/null || true @@ -441,9 +445,9 @@ OPEN_CLEAN_ENV=( if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" else echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true