Secure remote daemon distribution and relay auth
This commit is contained in:
parent
76cfe01fa2
commit
8a9e28e129
12 changed files with 1419 additions and 117 deletions
50
.github/workflows/nightly.yml
vendored
50
.github/workflows/nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
218
CLI/cmux.swift
218
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 <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>]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
140
scripts/build_remote_daemon_release_assets.sh
Executable file
140
scripts/build_remote_daemon_release_assets.sh
Executable 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}"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue