Secure remote daemon distribution and relay auth

This commit is contained in:
Lawrence Chen 2026-03-12 05:04:44 -07:00
parent 76cfe01fa2
commit 8a9e28e129
12 changed files with 1419 additions and 117 deletions

View file

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

View file

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

View file

@ -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 <darwin|linux>] [--arch <arm64|amd64>]
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 <left|right|up|down> [flags]
@ -9030,6 +9247,7 @@ struct CMUXCLI {
list-workspaces
new-workspace [--cwd <path>] [--command <text>]
ssh <destination> [--name <title>] [--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>]

View file

@ -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)
}

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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)

View file

@ -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()

View file

@ -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`).

View file

@ -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}"

View file

@ -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",

View file

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