Merge branch 'pr-374-ssh-remote-cli-relay' into issue-151-ssh-remote-port-proxying

# Conflicts:
#	CLI/cmux.swift
#	Sources/ContentView.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/Panels/TerminalPanel.swift
#	Sources/SocketControlSettings.swift
#	Sources/TabManager.swift
#	Sources/TerminalController.swift
#	Sources/Workspace.swift
#	daemon/remote/README.md
#	daemon/remote/cmd/cmuxd-remote/main.go
#	docs/remote-daemon-spec.md
#	tests_v2/test_ssh_remote_cli_metadata.py
This commit is contained in:
Lawrence Chen 2026-03-09 18:31:10 -07:00
commit 30bb74dc92
26 changed files with 4896 additions and 1954 deletions

View file

@ -1,5 +1,11 @@
import Foundation
import Darwin
#if canImport(LocalAuthentication)
import LocalAuthentication
#endif
#if canImport(Security)
import Security
#endif
#if canImport(Sentry)
import Sentry
#endif
@ -415,17 +421,22 @@ enum CLIIDFormat: String {
}
private enum SocketPasswordResolver {
private static let service = "com.cmuxterm.app.socket-control"
private static let account = "local-socket-password"
private static let directoryName = "cmux"
private static let fileName = "socket-control-password"
static func resolve(explicit: String?) -> String? {
static func resolve(explicit: String?, socketPath: String) -> String? {
if let explicit = normalized(explicit) {
return explicit
}
if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) {
return env
}
return loadFromFile()
if let filePassword = loadFromFile() {
return filePassword
}
return loadFromKeychain(socketPath: socketPath)
}
private static func normalized(_ value: String?) -> String? {
@ -449,6 +460,83 @@ private enum SocketPasswordResolver {
}
return normalized(value)
}
private static func keychainServices(socketPath: String) -> [String] {
guard let scope = keychainScope(socketPath: socketPath) else {
return [service]
}
return ["\(service).\(scope)"]
}
private static func keychainScope(socketPath: String) -> String? {
if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) {
let scoped = sanitizeScope(tag)
if !scoped.isEmpty {
return scoped
}
}
let candidate = URL(fileURLWithPath: socketPath).lastPathComponent
let prefixes = ["cmux-debug-", "cmux-"]
for prefix in prefixes {
guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue }
let start = candidate.index(candidate.startIndex, offsetBy: prefix.count)
let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count)
guard start < end else { continue }
let rawScope = String(candidate[start..<end])
let scoped = sanitizeScope(rawScope)
if !scoped.isEmpty {
return scoped
}
}
return nil
}
private static func sanitizeScope(_ raw: String) -> String {
let lowered = raw.lowercased()
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-"))
let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in
allowed.contains(scalar) ? Character(scalar) : "."
}
var normalizedScope = String(mappedScalars)
normalizedScope = normalizedScope.replacingOccurrences(
of: "\\.+",
with: ".",
options: .regularExpression
)
normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: "."))
return normalizedScope
}
private static func loadFromKeychain(socketPath: String) -> String? {
for service in keychainServices(socketPath: socketPath) {
let authContext = LAContext()
authContext.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
// Never trigger keychain UI from CLI commands; fail fast instead.
kSecUseAuthenticationContext as String: authContext,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed {
continue
}
guard status == errSecSuccess else {
continue
}
guard let data = result as? Data,
let password = String(data: data, encoding: .utf8) else {
continue
}
return password
}
return nil
}
}
final class SocketClient {
@ -469,6 +557,10 @@ final class SocketClient {
self.path = path
}
var socketPath: String {
path
}
func connect() throws {
if socketFD >= 0 { return }
@ -613,8 +705,56 @@ final class SocketClient {
struct CMUXCLI {
let args: [String]
private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path"
private static func normalizedEnvValue(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
private static func pathIsSocket(_ path: String) -> Bool {
var st = stat()
guard lstat(path, &st) == 0 else { return false }
return (st.st_mode & S_IFMT) == S_IFSOCK
}
private static func debugSocketPathFromHintFile() -> String? {
#if DEBUG
guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else {
return nil
}
guard let hinted = normalizedEnvValue(raw),
hinted.hasPrefix("/tmp/cmux-debug"),
hinted.hasSuffix(".sock"),
pathIsSocket(hinted) else {
return nil
}
return hinted
#else
return nil
#endif
}
private static func defaultSocketPath(environment: [String: String]) -> String {
if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) {
return explicit
}
#if DEBUG
if let hinted = debugSocketPathFromHintFile() {
return hinted
}
return "/tmp/cmux-debug.sock"
#else
return "/tmp/cmux.sock"
#endif
}
func run() throws {
var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock"
let environment = ProcessInfo.processInfo.environment
var socketPath = Self.defaultSocketPath(environment: environment)
var jsonOutput = false
var idFormatArg: String? = nil
var windowId: String? = nil
@ -715,7 +855,7 @@ struct CMUXCLI {
}
defer { client.close() }
if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) {
if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath) {
let authResponse = try client.send(command: "auth \(socketPassword)")
if authResponse.hasPrefix("ERROR:"),
!authResponse.contains("Unknown command 'auth'") {
@ -1321,109 +1461,6 @@ struct CMUXCLI {
throw error
}
case "set-status":
let (icon, r1) = parseOption(commandArgs, name: "--icon")
let (color, r2) = parseOption(r1, name: "--color")
let (wsFlag, r3) = parseOption(r2, name: "--workspace")
guard r3.count >= 2 else {
throw CLIError(message: "set-status requires <key> and <value>")
}
let key = r3[0]
let value = r3.dropFirst().joined(separator: " ")
guard !value.isEmpty else {
throw CLIError(message: "set-status requires a non-empty value")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "set_status \(key) \(socketQuote(value))"
if let icon { socketCmd += " --icon=\(socketQuote(icon))" }
if let color { socketCmd += " --color=\(socketQuote(color))" }
socketCmd += " --tab=\(wsId)"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "clear-status":
let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace")
guard let key = csRemaining.first else {
throw CLIError(message: "clear-status requires a <key>")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client)
print(response)
case "list-status":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("list_status --tab=\(wsId)", client: client)
print(response)
case "set-progress":
let (label, spR1) = parseOption(commandArgs, name: "--label")
let (wsFlag, spR2) = parseOption(spR1, name: "--workspace")
guard let valueStr = spR2.first else {
throw CLIError(message: "set-progress requires a progress value (0.0-1.0)")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "set_progress \(valueStr)"
if let label { socketCmd += " --label=\(socketQuote(label))" }
socketCmd += " --tab=\(wsId)"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "clear-progress":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client)
print(response)
case "log":
let (level, r1) = parseOption(commandArgs, name: "--level")
let (source, r2) = parseOption(r1, name: "--source")
let (wsFlag, r3) = parseOption(r2, name: "--workspace")
// Strip leading "--" separator if present
let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3
let message = positional.joined(separator: " ")
guard !message.isEmpty else {
throw CLIError(message: "log requires a message")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "log"
if let level { socketCmd += " --level=\(level)" }
if let source { socketCmd += " --source=\(socketQuote(source))" }
socketCmd += " --tab=\(wsId) -- \(socketQuote(message))"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "clear-log":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("clear_log --tab=\(wsId)", client: client)
print(response)
case "list-log":
let (limitStr, r1) = parseOption(commandArgs, name: "--limit")
let (wsFlag, _) = parseOption(r1, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "list_log"
if let limitStr { socketCmd += " --limit=\(limitStr)" }
socketCmd += " --tab=\(wsId)"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "sidebar-state":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client)
print(response)
case "set-app-focus":
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
let response = try sendV1Command("set_app_focus \(value)", client: client)
@ -1510,14 +1547,6 @@ struct CMUXCLI {
}
}
private func sendV1Command(_ command: String, client: SocketClient) throws -> String {
let response = try client.send(command: command)
if response.hasPrefix("ERROR:") {
throw CLIError(message: response)
}
return response
}
private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat {
_ = jsonOutput
if let parsed = try CLIIDFormat.parse(raw) {
@ -1526,6 +1555,14 @@ struct CMUXCLI {
return .refs
}
private func sendV1Command(_ command: String, client: SocketClient) throws -> String {
let response = try client.send(command: command)
if response.hasPrefix("ERROR:") {
throw CLIError(message: response)
}
return response
}
private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any {
switch object {
case let dict as [String: Any]:
@ -2124,6 +2161,54 @@ struct CMUXCLI {
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
}
private func runRenameTab(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
let (tabOpt, rem1) = parseOption(rem0, name: "--tab")
let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface")
let (titleOpt, rem3) = parseOption(rem2, name: "--title")
if rem3.contains("--action") {
throw CLIError(message: "rename-tab does not accept --action (it always performs rename)")
}
if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) {
throw CLIError(message: "rename-tab: unknown flag '\(unknown)'")
}
let inferredTitle = rem3
.dropFirst(rem3.first == "--" ? 1 : 0)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let title, !title.isEmpty else {
throw CLIError(message: "rename-tab requires a title")
}
var forwarded: [String] = ["--action", "rename", "--title", title]
if let workspaceOpt {
forwarded += ["--workspace", workspaceOpt]
}
if let tabOpt {
forwarded += ["--tab", tabOpt]
} else if let surfaceOpt {
forwarded += ["--surface", surfaceOpt]
}
try runTabAction(
commandArgs: forwarded,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
}
private struct SSHCommandOptions {
let destination: String
let port: Int?
@ -2131,6 +2216,13 @@ struct CMUXCLI {
let workspaceName: String?
let sshOptions: [String]
let extraArguments: [String]
let localSocketPath: String
let remoteRelayPort: Int
}
private func generateRemoteRelayPort() -> Int {
// Random port in the ephemeral range (49152-65535)
Int.random(in: 49152...65535)
}
private func runSSH(
@ -2139,7 +2231,10 @@ struct CMUXCLI {
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
let sshOptions = try parseSSHCommandOptions(commandArgs)
// Use the socket path from this invocation (supports --socket overrides).
let localSocketPath = client.socketPath
let remoteRelayPort = generateRemoteRelayPort()
let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort)
let sshCommand = buildSSHCommandText(sshOptions)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
let sshStartupCommand = buildSSHStartupCommand(sshCommand: sshCommand, shellFeatures: shellFeaturesValue)
@ -2152,6 +2247,8 @@ struct CMUXCLI {
guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else {
throw CLIError(message: "workspace.create did not return workspace_id")
}
let workspaceWindowId = (workspaceCreate["window_id"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteSSHOptions = sshOptionsWithControlSocketDefaults(sshOptions.sshOptions)
let configuredPayload: [String: Any]
@ -2178,11 +2275,17 @@ struct CMUXCLI {
if !remoteSSHOptions.isEmpty {
configureParams["ssh_options"] = remoteSSHOptions
}
if sshOptions.remoteRelayPort > 0 {
configureParams["relay_port"] = sshOptions.remoteRelayPort
configureParams["local_socket_path"] = sshOptions.localSocketPath
}
configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams)
_ = try client.sendV2(method: "workspace.select", params: [
"workspace_id": workspaceId,
])
var selectParams: [String: Any] = ["workspace_id": workspaceId]
if let workspaceWindowId, !workspaceWindowId.isEmpty {
selectParams["window_id"] = workspaceWindowId
}
_ = try client.sendV2(method: "workspace.select", params: selectParams)
} catch {
_ = try? client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
throw error
@ -2195,6 +2298,7 @@ struct CMUXCLI {
payload["ssh_env_overrides"] = [
"GHOSTTY_SHELL_FEATURES": shellFeaturesValue,
]
payload["remote_relay_port"] = remoteRelayPort
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
@ -2205,7 +2309,7 @@ struct CMUXCLI {
}
}
private func parseSSHCommandOptions(_ commandArgs: [String]) throws -> SSHCommandOptions {
private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions {
var destination: String?
var port: Int?
var identityFile: String?
@ -2290,7 +2394,9 @@ struct CMUXCLI {
identityFile: identityFile,
workspaceName: workspaceName,
sshOptions: sshOptions,
extraArguments: extraArguments
extraArguments: extraArguments,
localSocketPath: localSocketPath,
remoteRelayPort: remoteRelayPort
)
}
@ -2309,8 +2415,35 @@ struct CMUXCLI {
for option in effectiveSSHOptions {
parts += ["-o", option]
}
if options.extraArguments.isEmpty {
// No explicit remote command provided: keep destination-only argv so Ghostty's
// ssh-terminfo bootstrap can safely append its own remote install command.
// Use RemoteCommand for session-local PATH bootstrap to make `cmux` available.
if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") {
parts.append("-tt")
}
if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") {
var startupExports = [
"export PATH=\"$HOME/.cmux/bin:$PATH\"",
]
if options.remoteRelayPort > 0 {
// Pin this shell to the relay allocated for this workspace so parallel
// SSH sessions (including from different cmux versions) don't race on
// shared ~/.cmux/socket_addr.
startupExports.append("export CMUX_SOCKET_PATH=127.0.0.1:\(options.remoteRelayPort)")
}
startupExports.append("exec \"${SHELL:-/bin/zsh}\" -l")
parts += [
"-o",
"RemoteCommand=\(startupExports.joined(separator: "; "))",
]
}
parts.append(options.destination)
} else {
parts.append(options.destination)
parts.append(contentsOf: options.extraArguments)
}
return parts.map(shellQuote).joined(separator: " ")
}
@ -2415,54 +2548,6 @@ fi
return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
private func runRenameTab(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
let (tabOpt, rem1) = parseOption(rem0, name: "--tab")
let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface")
let (titleOpt, rem3) = parseOption(rem2, name: "--title")
if rem3.contains("--action") {
throw CLIError(message: "rename-tab does not accept --action (it always performs rename)")
}
if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) {
throw CLIError(message: "rename-tab: unknown flag '\(unknown)'")
}
let inferredTitle = rem3
.dropFirst(rem3.first == "--" ? 1 : 0)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let title, !title.isEmpty else {
throw CLIError(message: "rename-tab requires a title")
}
var forwarded: [String] = ["--action", "rename", "--title", title]
if let workspaceOpt {
forwarded += ["--workspace", workspaceOpt]
}
if let tabOpt {
forwarded += ["--tab", tabOpt]
} else if let surfaceOpt {
forwarded += ["--surface", surfaceOpt]
}
try runTabAction(
commandArgs: forwarded,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
}
private func runBrowserCommand(
commandArgs: [String],
client: SocketClient,
@ -2565,7 +2650,6 @@ fi
return lines.joined(separator: "\n")
}
func nonFlagArgs(_ values: [String]) -> [String] {
values.filter { !$0.hasPrefix("-") }
}
@ -2733,13 +2817,7 @@ fi
throw CLIError(message: "browser eval requires a script")
}
let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed])
let fallback: String
if let value = payload["value"] {
fallback = displayBrowserValue(value)
} else {
fallback = "OK"
}
output(payload, fallback: fallback)
output(payload, fallback: "OK")
return
}
@ -3350,8 +3428,7 @@ fi
throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid])
let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK"
output(payload, fallback: fallback)
output(payload, fallback: "OK")
return
}
@ -3365,8 +3442,7 @@ fi
throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)")
}
let payload = try client.sendV2(method: "browser.errors.list", params: params)
let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK"
output(payload, fallback: fallback)
output(payload, fallback: "OK")
return
}
@ -3890,7 +3966,7 @@ fi
new-terminal-right | new-browser-right
reload | duplicate
pin | unpin
mark-read | mark-unread
mark-unread
Flags:
--action <name> Action name (required if not positional)
@ -3909,18 +3985,21 @@ fi
return """
Usage: cmux rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title>
Rename a tab (surface). Defaults to the focused tab, using:
1) explicit --tab/--surface
2) $CMUX_TAB_ID / $CMUX_SURFACE_ID
3) focused tab in the resolved workspace context
Compatibility alias for tab-action rename.
Resolution order for target tab:
1) --tab
2) --surface
3) $CMUX_TAB_ID / $CMUX_SURFACE_ID
4) currently focused tab (optionally within --workspace)
Flags:
--workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--tab <id|ref> Target tab (accepts tab:<n> or surface:<n>)
--tab <id|ref> Tab target (supports tab:<n> or surface:<n>)
--surface <id|ref> Alias for --tab
--title <text> New title (or pass trailing title)
--title <text> Explicit title (or use trailing positional title)
Example:
Examples:
cmux rename-tab "build logs"
cmux rename-tab --tab tab:3 "staging server"
cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run"
@ -4759,20 +4838,6 @@ fi
return true
}
/// Escape and quote a string for safe embedding in a v1 socket command.
/// The socket tokenizer treats `\` and `"` as special inside quoted strings,
/// so both must be escaped before wrapping in double quotes. Newlines and
/// carriage returns must also be escaped since the socket protocol uses
/// newline as the message terminator.
private func socketQuote(_ s: String) -> String {
let escaped = s
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
return "\"\(escaped)\""
}
private func parseOption(_ args: [String], name: String) -> (String?, [String]) {
var remaining: [String] = []
var value: String?
@ -5897,7 +5962,7 @@ fi
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
print(response)
} else {
print("OK")
@ -5938,7 +6003,7 @@ fi
)
}
let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
_ = try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
@ -6173,8 +6238,7 @@ fi
]
let session = firstString(in: object, keys: ["session_id", "sessionId"])
let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input"
let dedupedMessage = dedupeBranchContextLines(message)
let normalizedMessage = normalizedSingleLine(dedupedMessage)
let normalizedMessage = normalizedSingleLine(message)
let signal = signalParts.compactMap { $0 }.joined(separator: " ")
var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage)
@ -6207,42 +6271,6 @@ fi
return ("Attention", body)
}
private func dedupeBranchContextLines(_ value: String) -> String {
let lines = value.components(separatedBy: .newlines)
guard lines.count > 1 else { return value }
var lastIndexByPath: [String: Int] = [:]
for (index, line) in lines.enumerated() {
guard let path = branchContextPath(from: line) else { continue }
lastIndexByPath[path] = index
}
guard !lastIndexByPath.isEmpty else { return value }
let deduped = lines.enumerated().compactMap { index, line -> String? in
guard let path = branchContextPath(from: line) else { return line }
return lastIndexByPath[path] == index ? line : nil
}
return deduped.joined(separator: "\n")
}
private func branchContextPath(from line: String) -> String? {
let parts = line.split(separator: "", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { return nil }
let branch = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let path = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard !branch.isEmpty, !path.isEmpty else { return nil }
let looksLikePath = path.hasPrefix("/") || path.hasPrefix("~") || path.hasPrefix(".") || path.contains("/")
guard looksLikePath else { return nil }
let trimmedQuotes = path.trimmingCharacters(in: CharacterSet(charactersIn: "`'\""))
let expanded = NSString(string: trimmedQuotes).expandingTildeInPath
let standardized = NSString(string: expanded).standardizingPath
let normalized = standardized.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty ? nil : normalized
}
private func firstString(in object: [String: Any], keys: [String]) -> String? {
for key in keys {
guard let value = object[key] else { continue }
@ -6477,8 +6505,6 @@ fi
candidates.append(current.appendingPathComponent("Info.plist"))
}
// Local dev fallback: resolve version from the repo's app Info.plist
// when running a standalone cmux-cli binary from build/Debug.
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj")
let repoInfo = current.appendingPathComponent("Resources/Info.plist")
if fileManager.fileExists(atPath: projectMarker.path),
@ -6551,8 +6577,8 @@ fi
--password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings.
Commands:
version
ping
version
capabilities
identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller]
list-windows
@ -6598,18 +6624,6 @@ fi
list-notifications
clear-notifications
claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>]
# sidebar metadata commands
set-status <key> <value> [--icon <name>] [--color <#hex>] [--workspace <id|ref>]
clear-status <key> [--workspace <id|ref>]
list-status [--workspace <id|ref>]
set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>]
clear-progress [--workspace <id|ref>]
log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message>
clear-log [--workspace <id|ref>]
list-log [--limit <n>] [--workspace <id|ref>]
sidebar-state [--workspace <id|ref>]
set-app-focus <active|inactive|clear>
simulate-app-active
@ -6673,7 +6687,9 @@ fi
ALL commands (send, list-panels, new-split, notify, etc.).
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock).
CMUX_SOCKET_PATH Override the default Unix socket path.
Debug CLI defaults: /tmp/cmux-last-socket-path -> /tmp/cmux-debug.sock.
Release CLI default: /tmp/cmux.sock.
CMUX_CLI_SENTRY_DISABLED
Set to 1 to disable CLI Sentry socket diagnostics.
"""

View file

@ -6465,6 +6465,26 @@ private struct TabItemView: View {
)
}
private var copyableSidebarSSHError: String? {
let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty {
let target = tab.remoteDisplayTarget ?? "unknown"
return "SSH error (\(target)): \(trimmedDetail)"
}
if let statusValue = tab.statusEntries["remote.error"]?.value
.trimmingCharacters(in: .whitespacesAndNewlines),
!statusValue.isEmpty {
return statusValue
}
return nil
}
private func copyTextToPasteboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
var body: some View {
let latestNotificationSubtitle = latestNotificationText
let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest)
@ -6525,13 +6545,6 @@ private struct TabItemView: View {
.foregroundColor(activeSecondaryColor(0.8))
}
if tab.isRemoteWorkspace {
Image(systemName: remoteStateIcon(tab.remoteConnectionState))
.font(.system(size: 9, weight: .semibold))
.foregroundColor(remoteStateColor(tab.remoteConnectionState, isActive: isActive))
.help(remoteStateHelpText)
}
Text(tab.title)
.font(.system(size: 12.5, weight: titleFontWeight))
.foregroundColor(activePrimaryTextColor)
@ -6924,6 +6937,12 @@ private struct TabItemView: View {
}
}
if let copyableSidebarSSHError {
Button("Copy SSH Error") {
copyTextToPasteboard(copyableSidebarSSHError)
}
}
Divider()
Button("Move Up") {
@ -7208,7 +7227,6 @@ private struct TabItemView: View {
return "SSH disconnected from \(target)"
}
}
private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) {
guard let app = AppDelegate.shared else { return }
let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil }
@ -7414,45 +7432,6 @@ private struct TabItemView: View {
}
}
private func remoteStateIcon(_ state: WorkspaceRemoteConnectionState) -> String {
switch state {
case .connected:
return "network"
case .connecting:
return "network.badge.shield.half.filled"
case .error:
return "network.slash"
case .disconnected:
return "network.slash"
}
}
private func remoteStateColor(_ state: WorkspaceRemoteConnectionState, isActive: Bool) -> Color {
if isActive {
switch state {
case .connected:
return .white.opacity(0.9)
case .connecting:
return .white.opacity(0.85)
case .error:
return .white.opacity(0.9)
case .disconnected:
return .white.opacity(0.65)
}
}
switch state {
case .connected:
return .green
case .connecting:
return .blue
case .error:
return .red
case .disconnected:
return .secondary
}
}
private func shortenPath(_ path: String, home: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return path }

View file

@ -24,9 +24,9 @@ struct TerminalPanelView: View {
portalZPriority: portalPriority,
showsInactiveOverlay: isSplit && !isFocused,
showsUnreadNotificationRing: hasUnreadNotification,
searchState: panel.searchState,
inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
searchState: panel.searchState,
reattachToken: panel.viewReattachToken,
onFocus: { _ in onFocus() },
onTriggerFlash: onTriggerFlash

View file

@ -105,47 +105,6 @@ enum SidebarActiveTabIndicatorSettings {
}
}
enum WorkspacePlacementSettings {
static let placementKey = "newWorkspacePlacement"
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
static func current(defaults: UserDefaults = .standard) -> NewWorkspacePlacement {
guard let raw = defaults.string(forKey: placementKey),
let placement = NewWorkspacePlacement(rawValue: raw) else {
return defaultPlacement
}
return placement
}
static func insertionIndex(
placement: NewWorkspacePlacement,
selectedIndex: Int?,
selectedIsPinned: Bool,
pinnedCount: Int,
totalCount: Int
) -> Int {
let clampedTotalCount = max(0, totalCount)
let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount))
switch placement {
case .top:
// Keep pinned workspaces grouped at the top by inserting ahead of unpinned items.
return clampedPinnedCount
case .end:
return clampedTotalCount
case .afterCurrent:
guard let selectedIndex, clampedTotalCount > 0 else {
return clampedTotalCount
}
let clampedSelectedIndex = max(0, min(selectedIndex, clampedTotalCount - 1))
if selectedIsPinned {
return clampedPinnedCount
}
return min(clampedSelectedIndex + 1, clampedTotalCount)
}
}
}
struct WorkspaceTabColorEntry: Equatable, Identifiable {
let name: String
let hex: String
@ -353,6 +312,47 @@ enum WorkspaceTabColorSettings {
}
}
enum WorkspacePlacementSettings {
static let placementKey = "newWorkspacePlacement"
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
static func current(defaults: UserDefaults = .standard) -> NewWorkspacePlacement {
guard let raw = defaults.string(forKey: placementKey),
let placement = NewWorkspacePlacement(rawValue: raw) else {
return defaultPlacement
}
return placement
}
static func insertionIndex(
placement: NewWorkspacePlacement,
selectedIndex: Int?,
selectedIsPinned: Bool,
pinnedCount: Int,
totalCount: Int
) -> Int {
let clampedTotalCount = max(0, totalCount)
let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount))
switch placement {
case .top:
// Keep pinned workspaces grouped at the top by inserting ahead of unpinned items.
return clampedPinnedCount
case .end:
return clampedTotalCount
case .afterCurrent:
guard let selectedIndex, clampedTotalCount > 0 else {
return clampedTotalCount
}
let clampedSelectedIndex = max(0, min(selectedIndex, clampedTotalCount - 1))
if selectedIsPinned {
return clampedPinnedCount
}
return min(clampedSelectedIndex + 1, clampedTotalCount)
}
}
}
/// Coalesces repeated main-thread signals into one callback after a short delay.
/// Useful for notification storms where only the latest update matters.
final class NotificationBurstCoalescer {
@ -558,12 +558,9 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor
class TabManager: ObservableObject {
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?
@Published var tabs: [Workspace] = []
@Published private(set) var isWorkspaceCycleHot: Bool = false
weak var window: NSWindow?
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
@ -571,9 +568,6 @@ class TabManager: ObservableObject {
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
sentryBreadcrumb("workspace.switch", data: [
"tabCount": tabs.count
])
let previousTabId = oldValue
if let previousTabId,
let previousPanelId = focusedPanelId(for: previousTabId) {
@ -812,9 +806,6 @@ class TabManager: ObservableObject {
if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal
}
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
return rememberedTerminal
}
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
return paneTerminal
@ -822,19 +813,71 @@ class TabManager: ObservableObject {
return workspace.terminalPanelForConfigInheritance()
}
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
let workspaceSnapshots = tabs
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedId in
tabs.firstIndex(where: { $0.id == selectedId })
}
return SessionTabManagerSnapshot(
selectedWorkspaceIndex: selectedWorkspaceIndex,
workspaces: workspaceSnapshots
)
}
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
return config
func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) {
for tab in tabs {
unwireClosedBrowserTracking(for: tab)
}
tabs.removeAll(keepingCapacity: false)
lastFocusedPanelByTab.removeAll()
pendingPanelTitleUpdates.removeAll()
tabHistory.removeAll()
historyIndex = -1
isNavigatingHistory = false
pendingWorkspaceUnfocusTarget = nil
workspaceCycleCooldownTask?.cancel()
workspaceCycleCooldownTask = nil
isWorkspaceCycleHot = false
selectionSideEffectsGeneration &+= 1
recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
let workspaceSnapshots = snapshot.workspaces
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
for workspaceSnapshot in workspaceSnapshots {
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let workspace = Workspace(
title: workspaceSnapshot.processTitle,
workingDirectory: workspaceSnapshot.currentDirectory,
portOrdinal: ordinal
)
workspace.restoreSessionSnapshot(workspaceSnapshot)
wireClosedBrowserTracking(for: workspace)
tabs.append(workspace)
}
if tabs.isEmpty {
_ = addWorkspace(select: false)
}
selectedTabId = nil
if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex,
tabs.indices.contains(selectedWorkspaceIndex) {
selectedTabId = tabs[selectedWorkspaceIndex].id
} else {
selectedTabId = tabs.first?.id
}
if let selectedTabId {
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: selectedTabId]
)
}
return nil
}
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
@ -933,11 +976,6 @@ class TabManager: ObservableObject {
setCustomTitle(tabId: tabId, title: nil)
}
func setTabColor(tabId: UUID, color: String?) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.setCustomColor(color)
}
func togglePin(tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index]
@ -950,6 +988,11 @@ class TabManager: ObservableObject {
reorderTabForPinnedState(tab)
}
func setTabColor(tabId: UUID, color: String?) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.setCustomColor(color)
}
private func reorderTabForPinnedState(_ tab: Workspace) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
tabs.remove(at: index)
@ -979,7 +1022,6 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
workspace.teardownRemoteConnection()
@ -1313,24 +1355,11 @@ class TabManager: ObservableObject {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
guard tab.panels[surfaceId] != nil else { return }
#if DEBUG
dlog(
"surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)"
)
#endif
// Keep AppKit first responder in sync with workspace focus before routing the close.
// If split reparenting caused a temporary model/view mismatch, fallback close logic in
// Workspace.closePanel uses focused selection to resolve the correct tab deterministically.
reconcileFocusedPanelFromFirstResponderForKeyboard()
let closed = tab.closePanel(surfaceId, force: true)
#if DEBUG
dlog(
"surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)"
)
#endif
_ = tab.closePanel(surfaceId, force: true)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
}
@ -1339,33 +1368,6 @@ class TabManager: ObservableObject {
/// This should never prompt: the process is already gone, and Ghostty emits the
/// `SHOW_CHILD_EXITED` action specifically so the host app can decide what to do.
func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
guard tab.panels[surfaceId] != nil else { return }
#if DEBUG
dlog(
"surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)"
)
#endif
// Child-exit on the last panel should collapse the workspace, matching explicit close
// semantics (and close the window when it was the last workspace).
if tab.panels.count <= 1 {
if tabs.count <= 1 {
if let app = AppDelegate.shared {
app.notificationStore?.clearNotifications(forTabId: tabId)
app.closeMainWindowContainingTabId(tabId)
} else {
// Headless/test fallback when no AppDelegate window context exists.
closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId)
}
} else {
closeWorkspace(tab)
}
return
}
closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId)
}
@ -1631,8 +1633,8 @@ class TabManager: ObservableObject {
private func updateWindowTitle(for tab: Workspace?) {
let title = windowTitle(for: tab)
guard let targetWindow = window else { return }
targetWindow.title = title
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first
targetWindow?.title = title
}
private func windowTitle(for tab: Workspace?) -> String {
@ -1646,11 +1648,7 @@ class TabManager: ObservableObject {
}
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
if let surfaceId, tab.panels[surfaceId] != nil {
// Keep selected-surface intent stable across selectedTabId didSet async restore.
lastFocusedPanelByTab[tabId] = surfaceId
}
guard tabs.contains(where: { $0.id == tabId }) else { return }
selectedTabId = tabId
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
@ -1658,15 +1656,10 @@ class TabManager: ObservableObject {
userInfo: [GhosttyNotificationKey.tabId: tabId]
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
if let app = AppDelegate.shared,
let windowId = app.windowId(for: self),
let window = app.mainWindow(for: windowId) {
window.makeKeyAndOrderFront(nil)
} else if let window = NSApp.keyWindow ?? NSApp.windows.first {
if let window = NSApp.keyWindow ?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
@ -1674,7 +1667,7 @@ class TabManager: ObservableObject {
if let surfaceId {
if !suppressFlash {
focusSurface(tabId: tabId, surfaceId: surfaceId)
} else {
} else if let tab = tabs.first(where: { $0.id == tabId }) {
tab.focusPanel(surfaceId)
}
}
@ -1988,13 +1981,12 @@ class TabManager: ObservableObject {
/// Create a new split in the specified direction
/// Returns the new panel's ID (which is also the surface ID for terminals)
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
focus: focus
insertFirst: direction.insertFirst
)?.id
}
@ -2096,16 +2088,14 @@ class TabManager: ObservableObject {
fromPanelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil,
focus: Bool = true
url: URL? = nil
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newBrowserSplit(
from: fromPanelId,
orientation: orientation,
insertFirst: insertFirst,
url: url,
focus: focus
url: url
)?.id
}
@ -2198,8 +2188,6 @@ class TabManager: ObservableObject {
)
}
/// Reopen the most recently closed browser panel (Cmd+Shift+T).
/// No-op when no browser panel restore snapshot is available.
@discardableResult
func reopenMostRecentlyClosedBrowserPanel() -> Bool {
while let snapshot = recentlyClosedBrowsers.pop() {
@ -3078,10 +3066,6 @@ class TabManager: ObservableObject {
let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1"
let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input")
.trimmingCharacters(in: .whitespacesAndNewlines)
let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d"
let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d"
let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger
let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger
let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr")
.trimmingCharacters(in: .whitespacesAndNewlines)
let expectedPanelsAfter = max(
@ -3355,11 +3339,8 @@ class TabManager: ObservableObject {
return
}
let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift
? [.control, .shift]
: [.control]
let shouldWaitForSurface = !useEarlyTrigger
// Wait for the target panel to be fully attached after split churn.
let readyDeadline = Date().addingTimeInterval(2.0)
var attachedBeforeTrigger = false
var hasSurfaceBeforeTrigger = false
if shouldWaitForSurface {
@ -3378,9 +3359,12 @@ class TabManager: ObservableObject {
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
} else if let panel = tab.terminalPanel(for: exitPanelId) {
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
write([
"exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0",
@ -3396,7 +3380,7 @@ class TabManager: ObservableObject {
return
}
// Exercise the real key path (ghostty_surface_key for Ctrl+D).
if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
if panel.hostedView.sendSyntheticCtrlDForUITest() {
write(["autoTriggerSentCtrlDKey1": "1"])
} else {
write([
@ -3408,20 +3392,13 @@ class TabManager: ObservableObject {
// In strict mode, never mask routing bugs with fallback writes.
if strictKeyOnly {
let strictModeLabel: String = {
if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" }
if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" }
if triggerUsesShift { return "strict_ctrl_shift_d" }
return "strict_ctrl_d"
}()
write(["autoTriggerMode": strictModeLabel])
write(["autoTriggerMode": "strict_ctrl_d"])
return
}
// Non-strict mode keeps one additional Ctrl+D retry for startup timing variance.
try? await Task.sleep(nanoseconds: 450_000_000)
if tab.panels[exitPanelId] != nil,
panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() {
write(["autoTriggerSentCtrlDKey2": "1"])
}
}
@ -3554,7 +3531,6 @@ extension TabManager {
}
}
}
// MARK: - Direction Types for Backwards Compatibility
/// Split direction for backwards compatibility with old API
@ -3594,11 +3570,12 @@ extension Notification.Name {
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink")
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -53,51 +53,12 @@ struct WorkspaceContentView: View {
}()
BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
// Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
panelView(
tab: tab,
paneId: paneId,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: hasUnreadNotification,
onFocus: {
// Keep bonsplit focus in sync with the AppKit first responder for the
// active workspace. This prevents divergence between the blue focused-tab
// indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
appearance: appearance
)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
} else {
// Fallback for tabs without panels (shouldn't happen normally)
EmptyPanelView(workspace: workspace, paneId: paneId)
}
} emptyPane: { paneId in
// Empty pane content
EmptyPanelView(workspace: workspace, paneId: paneId)
@ -143,6 +104,55 @@ struct WorkspaceContentView: View {
}
}
@ViewBuilder
private func panelView(
tab: Bonsplit.Tab,
paneId: PaneID,
isSplit: Bool,
appearance: PanelAppearance
) -> some View {
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: hasUnreadNotification,
onFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
} else {
EmptyPanelView(workspace: workspace, paneId: paneId)
}
}
private func syncBonsplitNotificationBadges() {
let unreadFromNotifications: Set<UUID> = Set(
notificationStore.notifications
@ -243,7 +253,8 @@ struct WorkspaceContentView: View {
)
let chromeReason =
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
_ = chromeReason
workspace.applyGhosttyChrome(from: next)
if let terminalPanel = workspace.focusedTerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
logTheme(

View file

@ -3065,6 +3065,74 @@ final class UpdateChannelSettingsTests: XCTestCase {
}
}
final class SidebarRemoteErrorCopySupportTests: XCTestCase {
func testMenuLabelIsNilWhenThereAreNoErrors() {
XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: []))
XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: []))
}
func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() {
let entries = [
SidebarRemoteErrorCopyEntry(
workspaceTitle: "alpha",
target: "devbox:22",
detail: "failed to start reverse relay"
)
]
XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error")
XCTAssertEqual(
SidebarRemoteErrorCopySupport.clipboardText(for: entries),
"SSH error (devbox:22): failed to start reverse relay"
)
}
func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() {
let entries = [
SidebarRemoteErrorCopyEntry(
workspaceTitle: "alpha",
target: "devbox-a:22",
detail: "connection timed out"
),
SidebarRemoteErrorCopyEntry(
workspaceTitle: "beta",
target: "devbox-b:22",
detail: "permission denied"
),
]
XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors")
XCTAssertEqual(
SidebarRemoteErrorCopySupport.clipboardText(for: entries),
"""
1. alpha (devbox-a:22): connection timed out
2. beta (devbox-b:22): permission denied
"""
)
}
func testParsedTargetAndDetailParsesCanonicalStatusValue() {
let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail(
from: "SSH error (devbox:22): failed to bootstrap daemon"
)
XCTAssertEqual(parsed?.target, "devbox:22")
XCTAssertEqual(parsed?.detail, "failed to bootstrap daemon")
}
func testParsedTargetAndDetailUsesFallbackTargetWhenStatusOmitsTarget() {
let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail(
from: "SSH error: connection refused",
fallbackTarget: "fallback-host"
)
XCTAssertEqual(parsed?.target, "fallback-host")
XCTAssertEqual(parsed?.detail, "connection refused")
}
func testParsedTargetAndDetailIgnoresNonSSHStatusValues() {
XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good"))
}
}
final class WorkspaceReorderTests: XCTestCase {
@MainActor
func testReorderWorkspaceMovesWorkspaceToRequestedIndex() {

View file

@ -0,0 +1,49 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@MainActor
final class TabManagerSessionSnapshotTests: XCTestCase {
func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() {
let manager = TabManager()
guard let firstWorkspace = manager.selectedWorkspace else {
XCTFail("Expected initial workspace")
return
}
firstWorkspace.setCustomTitle("First")
let secondWorkspace = manager.addWorkspace(select: true)
secondWorkspace.setCustomTitle("Second")
XCTAssertEqual(manager.tabs.count, 2)
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
let snapshot = manager.sessionSnapshot(includeScrollback: false)
XCTAssertEqual(snapshot.workspaces.count, 2)
XCTAssertEqual(snapshot.selectedWorkspaceIndex, 1)
let restored = TabManager()
restored.restoreSessionSnapshot(snapshot)
XCTAssertEqual(restored.tabs.count, 2)
XCTAssertEqual(restored.selectedTabId, restored.tabs[1].id)
XCTAssertEqual(restored.tabs[0].customTitle, "First")
XCTAssertEqual(restored.tabs[1].customTitle, "Second")
}
func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() {
let manager = TabManager()
let emptySnapshot = SessionTabManagerSnapshot(
selectedWorkspaceIndex: nil,
workspaces: []
)
manager.restoreSessionSnapshot(emptySnapshot)
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertNotNil(manager.selectedTabId)
}
}

View file

@ -0,0 +1,214 @@
import XCTest
import AppKit
import Darwin
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@MainActor
final class TerminalControllerSocketSecurityTests: XCTestCase {
private func makeSocketPath(_ name: String) -> String {
FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock")
.path
}
override func setUp() {
super.setUp()
TerminalController.shared.stop()
}
override func tearDown() {
TerminalController.shared.stop()
super.tearDown()
}
func testSocketPermissionsFollowAccessMode() throws {
let tabManager = TabManager()
let allowAllPath = makeSocketPath("allow-all")
TerminalController.shared.start(
tabManager: tabManager,
socketPath: allowAllPath,
accessMode: .allowAll
)
try waitForSocket(at: allowAllPath)
XCTAssertEqual(try socketMode(at: allowAllPath), 0o666)
TerminalController.shared.stop()
let restrictedPath = makeSocketPath("cmux-only")
TerminalController.shared.start(
tabManager: tabManager,
socketPath: restrictedPath,
accessMode: .cmuxOnly
)
try waitForSocket(at: restrictedPath)
XCTAssertEqual(try socketMode(at: restrictedPath), 0o600)
}
func testPasswordModeRejectsUnauthenticatedCommands() throws {
let socketPath = makeSocketPath("password-mode")
let tabManager = TabManager()
TerminalController.shared.start(
tabManager: tabManager,
socketPath: socketPath,
accessMode: .password
)
try waitForSocket(at: socketPath)
let pingOnly = try sendCommands(["ping"], to: socketPath)
XCTAssertEqual(pingOnly.count, 1)
XCTAssertTrue(pingOnly[0].hasPrefix("ERROR:"))
XCTAssertFalse(pingOnly[0].localizedCaseInsensitiveContains("PONG"))
let wrongAuthThenPing = try sendCommands(
["auth not-the-password", "ping"],
to: socketPath
)
XCTAssertEqual(wrongAuthThenPing.count, 2)
XCTAssertTrue(wrongAuthThenPing[0].hasPrefix("ERROR:"))
XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:"))
}
func testSocketCommandPolicyDistinguishesFocusIntent() throws {
#if DEBUG
let nonFocus = TerminalController.debugSocketCommandPolicySnapshot(
commandKey: "ping",
isV2: false
)
XCTAssertTrue(nonFocus.insideSuppressed)
XCTAssertFalse(nonFocus.insideAllowsFocus)
XCTAssertFalse(nonFocus.outsideSuppressed)
XCTAssertFalse(nonFocus.outsideAllowsFocus)
let focusV1 = TerminalController.debugSocketCommandPolicySnapshot(
commandKey: "focus_window",
isV2: false
)
XCTAssertTrue(focusV1.insideSuppressed)
XCTAssertTrue(focusV1.insideAllowsFocus)
XCTAssertFalse(focusV1.outsideSuppressed)
let focusV2 = TerminalController.debugSocketCommandPolicySnapshot(
commandKey: "workspace.select",
isV2: true
)
XCTAssertTrue(focusV2.insideSuppressed)
XCTAssertTrue(focusV2.insideAllowsFocus)
XCTAssertFalse(focusV2.outsideSuppressed)
#else
throw XCTSkip("Socket command policy snapshot helper is debug-only.")
#endif
}
private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: path) {
return
}
usleep(20_000)
}
XCTFail("Timed out waiting for socket at \(path)")
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
}
private func socketMode(at path: String) throws -> UInt16 {
var fileInfo = stat()
guard lstat(path, &fileInfo) == 0 else {
throw posixError("lstat(\(path))")
}
return UInt16(fileInfo.st_mode & 0o777)
}
private func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] {
let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw posixError("socket(AF_UNIX)")
}
defer { Darwin.close(fd) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let bytes = Array(socketPath.utf8)
let maxPathLen = MemoryLayout.size(ofValue: addr.sun_path)
guard bytes.count < maxPathLen else {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG))
}
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let cPath = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
cPath.initialize(repeating: 0, count: maxPathLen)
for (index, byte) in bytes.enumerated() {
cPath[index] = CChar(bitPattern: byte)
}
}
let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + bytes.count + 1)
let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(fd, sockaddrPtr, addrLen)
}
}
guard connectResult == 0 else {
throw posixError("connect(\(socketPath))")
}
var responses: [String] = []
for command in commands {
try writeLine(command, to: fd)
responses.append(try readLine(from: fd))
}
return responses
}
private func writeLine(_ command: String, to fd: Int32) throws {
let payload = Array((command + "\n").utf8)
var offset = 0
while offset < payload.count {
let wrote = payload.withUnsafeBytes { raw in
Darwin.write(fd, raw.baseAddress!.advanced(by: offset), payload.count - offset)
}
guard wrote >= 0 else {
throw posixError("write(\(command))")
}
offset += wrote
}
}
private func readLine(from fd: Int32) throws -> String {
var buffer = [UInt8](repeating: 0, count: 1)
var data = Data()
while true {
let count = Darwin.read(fd, &buffer, 1)
guard count >= 0 else {
throw posixError("read")
}
if count == 0 { break }
if buffer[0] == 0x0A { break }
data.append(buffer[0])
}
guard let line = String(data: data, encoding: .utf8) else {
throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [
NSLocalizedDescriptionKey: "Invalid UTF-8 response from socket"
])
}
return line
}
private func posixError(_ operation: String) -> NSError {
NSError(
domain: NSPOSIXErrorDomain,
code: Int(errno),
userInfo: [NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))"]
)
}
}

View file

@ -1,12 +1,17 @@
# cmuxd-remote (Go)
Go remote daemon for `cmux ssh` bootstrap and capability negotiation.
Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and CLI relay.
## Commands
Current 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
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.
## RPC methods (newline-delimited JSON over stdio)
Current RPC methods (newline-delimited JSON):
1. `hello`
2. `ping`
3. `proxy.open`
@ -31,3 +36,20 @@ Current integration in cmux:
2. Out-of-range values and invalid types return `invalid_params`.
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.
## 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.
Socket discovery order:
1. `--socket <path>` flag
2. `CMUX_SOCKET_PATH` environment variable
3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes)
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.
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.

View file

@ -0,0 +1,568 @@
package main
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol.
type protocolVersion int
const (
protoV1 protocolVersion = iota
protoV2
)
// commandSpec describes a single CLI command and how to relay it.
type commandSpec struct {
name string // CLI command name (e.g. "ping", "new-window")
proto protocolVersion // v1 text or v2 JSON-RPC
v1Cmd string // v1: literal command string sent over the socket
v2Method string // v2: JSON-RPC method name
// flagKeys lists parameter keys this command accepts.
// They are extracted from --key flags and added to params.
flagKeys []string
// noParams means the command takes no parameters at all.
noParams bool
}
var commands = []commandSpec{
// V1 text protocol commands
{name: "ping", proto: protoV1, v1Cmd: "ping", noParams: true},
{name: "new-window", proto: protoV1, v1Cmd: "new_window", noParams: true},
{name: "current-window", proto: protoV1, v1Cmd: "current_window", noParams: true},
{name: "close-window", proto: protoV1, v1Cmd: "close_window", flagKeys: []string{"window"}},
{name: "focus-window", proto: protoV1, v1Cmd: "focus_window", flagKeys: []string{"window"}},
{name: "list-windows", proto: protoV1, v1Cmd: "list_windows", noParams: true},
// V2 JSON-RPC commands
{name: "capabilities", proto: protoV2, v2Method: "system.capabilities", noParams: true},
{name: "list-workspaces", proto: protoV2, v2Method: "workspace.list", noParams: true},
{name: "new-workspace", proto: protoV2, v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}},
{name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}},
{name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}},
{name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true},
{name: "list-panels", proto: protoV2, v2Method: "panel.list", flagKeys: []string{"workspace"}},
{name: "focus-panel", proto: protoV2, v2Method: "panel.focus", flagKeys: []string{"panel", "workspace"}},
{name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}},
{name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}},
{name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace"}},
{name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane"}},
{name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}},
{name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}},
{name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}},
{name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}},
{name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}},
{name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true},
}
var commandIndex map[string]*commandSpec
func init() {
commandIndex = make(map[string]*commandSpec, len(commands))
for i := range commands {
commandIndex[commands[i].name] = &commands[i]
}
}
// runCLI is the entry point for the "cli" subcommand (or busybox "cmux" invocation).
func runCLI(args []string) int {
socketPath := os.Getenv("CMUX_SOCKET_PATH")
// Parse global flags
var jsonOutput bool
var remaining []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--socket":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "cmux: --socket requires a path")
return 2
}
socketPath = args[i+1]
i++
case "--json":
jsonOutput = true
case "--help", "-h":
cliUsage()
return 0
default:
remaining = append(remaining, args[i:]...)
goto doneFlags
}
}
doneFlags:
if len(remaining) == 0 {
cliUsage()
return 2
}
cmdName := remaining[0]
cmdArgs := remaining[1:]
if cmdName == "help" {
cliUsage()
return 0
}
// refreshAddr is set when the address came from socket_addr file (not env/flag),
// allowing retry loops to pick up updated relay ports.
var refreshAddr func() string
if socketPath == "" {
socketPath = readSocketAddrFile()
refreshAddr = readSocketAddrFile
}
if socketPath == "" {
fmt.Fprintln(os.Stderr, "cmux: CMUX_SOCKET_PATH not set and --socket not provided")
return 1
}
// Special case: "rpc" passthrough
if cmdName == "rpc" {
return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr)
}
// Browser subcommand delegation
if cmdName == "browser" {
return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr)
}
spec, ok := commandIndex[cmdName]
if !ok {
fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName)
return 2
}
switch spec.proto {
case protoV1:
return execV1(socketPath, spec, cmdArgs, refreshAddr)
case protoV2:
return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr)
default:
fmt.Fprintf(os.Stderr, "cmux: internal error: unknown protocol for %q\n", cmdName)
return 1
}
}
// execV1 sends a v1 text command over the socket.
func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr func() string) int {
cmd := spec.v1Cmd
if !spec.noParams {
parsed := parseFlags(args, spec.flagKeys)
for _, key := range spec.flagKeys {
if val, ok := parsed.flags[key]; ok {
cmd += " " + val
}
}
}
resp, err := socketRoundTrip(socketPath, cmd, refreshAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
return 1
}
fmt.Print(resp)
if !strings.HasSuffix(resp, "\n") {
fmt.Println()
}
return 0
}
// execV2 sends a v2 JSON-RPC request over the socket.
func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int {
params := make(map[string]any)
if !spec.noParams {
parsed := parseFlags(args, spec.flagKeys)
// Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate)
for _, key := range spec.flagKeys {
if val, ok := parsed.flags[key]; ok {
paramKey := flagToParamKey(key)
params[paramKey] = val
}
}
// First positional arg is used as initial_command if --command wasn't given
if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 {
params["initial_command"] = parsed.positional[0]
}
// Fall back to env vars for common IDs
if _, ok := params["workspace_id"]; !ok {
if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" {
params["workspace_id"] = envWs
}
}
if _, ok := params["surface_id"]; !ok {
if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" {
params["surface_id"] = envSf
}
}
}
resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
return 1
}
if jsonOutput {
fmt.Println(resp)
} else {
fmt.Println("OK")
}
return 0
}
// runRPC sends an arbitrary JSON-RPC method with optional JSON params.
func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "cmux rpc: requires a method name")
return 2
}
method := args[0]
var params map[string]any
if len(args) > 1 {
if err := json.Unmarshal([]byte(args[1]), &params); err != nil {
fmt.Fprintf(os.Stderr, "cmux rpc: invalid JSON params: %v\n", err)
return 2
}
}
resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
return 1
}
fmt.Println(resp)
return 0
}
// runBrowserRelay handles "cmux browser <subcommand>" by mapping to browser.* v2 methods.
func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "cmux browser: requires a subcommand (open, navigate, back, forward, reload, get-url)")
return 2
}
sub := args[0]
subArgs := args[1:]
var method string
var flagKeys []string
switch sub {
case "open", "open-split", "new":
method = "browser.open"
flagKeys = []string{"url", "workspace", "surface"}
case "navigate":
method = "browser.navigate"
flagKeys = []string{"url", "surface"}
case "back":
method = "browser.back"
flagKeys = []string{"surface"}
case "forward":
method = "browser.forward"
flagKeys = []string{"surface"}
case "reload":
method = "browser.reload"
flagKeys = []string{"surface"}
case "get-url":
method = "browser.get_url"
flagKeys = []string{"surface"}
default:
fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub)
return 2
}
params := make(map[string]any)
parsed := parseFlags(subArgs, flagKeys)
for _, key := range flagKeys {
if val, ok := parsed.flags[key]; ok {
paramKey := flagToParamKey(key)
params[paramKey] = val
}
}
resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
return 1
}
if jsonOutput {
fmt.Println(resp)
} else {
fmt.Println("OK")
}
return 0
}
// flagToParamKey maps a CLI flag name to its JSON-RPC param key.
func flagToParamKey(key string) string {
switch key {
case "workspace":
return "workspace_id"
case "surface":
return "surface_id"
case "panel":
return "panel_id"
case "pane":
return "pane_id"
case "window":
return "window_id"
case "command":
return "initial_command"
case "name":
return "title"
case "working-directory":
return "working_directory"
default:
return key
}
}
// parsedFlags holds the results of flag parsing.
type parsedFlags struct {
flags map[string]string // --key value pairs
positional []string // non-flag arguments
}
// parseFlags extracts --key value pairs from args for the given allowed keys.
// Non-flag arguments are collected in positional.
func parseFlags(args []string, keys []string) parsedFlags {
allowed := make(map[string]bool, len(keys))
for _, k := range keys {
allowed[k] = true
}
result := parsedFlags{flags: make(map[string]string)}
for i := 0; i < len(args); i++ {
if !strings.HasPrefix(args[i], "--") {
result.positional = append(result.positional, args[i])
continue
}
key := strings.TrimPrefix(args[i], "--")
if !allowed[key] {
continue
}
if i+1 < len(args) {
result.flags[key] = args[i+1]
i++
}
}
return result
}
// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback
// when CMUX_SOCKET_PATH is not set. Written by the cmux app after the relay establishes.
func readSocketAddrFile() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".cmux", "socket_addr"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// 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)
}
return net.Dial("unix", addr)
}
// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout.
// This handles the case where the SSH reverse relay hasn't finished establishing yet.
// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses
// (e.g. when socket_addr is rewritten by a new relay process).
func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, error) {
deadline := time.Now().Add(timeout)
interval := 250 * time.Millisecond
printed := false
for {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
return conn, nil
}
if time.Now().After(deadline) {
return nil, err
}
// Only retry on connection refused (relay not ready yet)
if !isConnectionRefused(err) {
return nil, err
}
if !printed {
fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr)
printed = true
}
time.Sleep(interval)
// Re-read socket_addr in case the relay port has changed
if refreshAddr != nil {
if newAddr := refreshAddr(); newAddr != "" && newAddr != addr {
addr = newAddr
fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr)
}
}
}
}
func isConnectionRefused(err error) bool {
if opErr, ok := err.(*net.OpError); ok {
return strings.Contains(opErr.Err.Error(), "connection refused")
}
return strings.Contains(err.Error(), "connection refused")
}
// 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)
if err != nil {
return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err)
}
defer conn.Close()
if _, err := fmt.Fprintf(conn, "%s\n", command); err != nil {
return "", fmt.Errorf("failed to send command: %w", err)
}
// V1 handlers may return multiple lines (e.g. list_windows). Read until
// the stream goes idle briefly after seeing at least one newline.
reader := bufio.NewReader(conn)
var response strings.Builder
sawNewline := false
for {
readTimeout := 15 * time.Second
if sawNewline {
readTimeout = 120 * time.Millisecond
}
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
chunk, err := reader.ReadString('\n')
if chunk != "" {
response.WriteString(chunk)
if strings.Contains(chunk, "\n") {
sawNewline = true
}
}
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
if sawNewline {
break
}
return "", fmt.Errorf("failed to read response: timeout waiting for response")
}
if errors.Is(err, io.EOF) {
break
}
return "", fmt.Errorf("failed to read response: %w", err)
}
}
return strings.TrimRight(response.String(), "\n"), nil
}
// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON.
func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) {
conn, err := dialSocket(socketPath, refreshAddr)
if err != nil {
return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err)
}
defer conn.Close()
id := randomHex(8)
req := map[string]any{
"id": id,
"method": method,
}
if params != nil {
req["params"] = params
} else {
req["params"] = map[string]any{}
}
payload, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
if _, err := conn.Write(append(payload, '\n')); err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
// Parse the response to check for errors
var resp map[string]any
if err := json.Unmarshal([]byte(line), &resp); err != nil {
return strings.TrimRight(line, "\n"), nil
}
if ok, _ := resp["ok"].(bool); !ok {
if errObj, _ := resp["error"].(map[string]any); errObj != nil {
code, _ := errObj["code"].(string)
msg, _ := errObj["message"].(string)
return "", fmt.Errorf("server error [%s]: %s", code, msg)
}
return "", fmt.Errorf("server returned error response")
}
// Return the result portion as JSON
if result, ok := resp["result"]; ok {
resultJSON, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(resultJSON), nil
}
return "{}", nil
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func cliUsage() {
fmt.Fprintln(os.Stderr, "Usage: cmux [--socket <path>] [--json] <command> [args...]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Commands:")
fmt.Fprintln(os.Stderr, " ping Check connectivity")
fmt.Fprintln(os.Stderr, " capabilities List server capabilities")
fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces")
fmt.Fprintln(os.Stderr, " new-window Create a new window")
fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace")
fmt.Fprintln(os.Stderr, " new-surface Create a new surface")
fmt.Fprintln(os.Stderr, " new-split Split an existing surface")
fmt.Fprintln(os.Stderr, " close-surface Close a surface")
fmt.Fprintln(os.Stderr, " close-workspace Close a workspace")
fmt.Fprintln(os.Stderr, " select-workspace Select a workspace")
fmt.Fprintln(os.Stderr, " send Send text to a surface")
fmt.Fprintln(os.Stderr, " send-key Send a key to a surface")
fmt.Fprintln(os.Stderr, " notify Create a notification")
fmt.Fprintln(os.Stderr, " browser <sub> Browser commands (open, navigate, back, forward, reload, get-url)")
fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC")
}

View file

@ -0,0 +1,482 @@
package main
import (
"encoding/json"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// startMockSocket creates a Unix socket that accepts one connection,
// reads a line, and responds with the given canned response.
func startMockSocket(t *testing.T, response string) string {
t.Helper()
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
_ = n // consume request
conn.Write([]byte(response + "\n"))
conn.Close()
}
}()
return sockPath
}
// startMockV2Socket creates a Unix socket that echoes the received request's method
// back as a successful JSON-RPC response with the method name in the result.
func startMockV2Socket(t *testing.T) string {
t.Helper()
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
if n > 0 {
var req map[string]any
if err := json.Unmarshal(buf[:n], &req); err == nil {
resp := map[string]any{
"id": req["id"],
"ok": true,
"result": map[string]any{"method": req["method"], "params": req["params"]},
}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
} else {
conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n"))
}
}
conn.Close()
}
}()
return sockPath
}
// startMockTCPSocket creates a TCP listener that responds with a canned response.
func startMockTCPSocket(t *testing.T, response string) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on TCP: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
_ = n
conn.Write([]byte(response + "\n"))
conn.Close()
}
}()
return ln.Addr().String()
}
func TestDialTCPRetrySuccess(t *testing.T) {
// Get a free port, then close the listener so connection is refused initially.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
ln.Close()
// Start a listener after a delay so the retry logic finds it.
go func() {
time.Sleep(400 * time.Millisecond)
ln2, err := net.Listen("tcp", addr)
if err != nil {
return
}
defer ln2.Close()
conn, err := ln2.Accept()
if err != nil {
return
}
conn.Close()
}()
conn, err := dialTCPRetry(addr, 3*time.Second, nil)
if err != nil {
t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err)
}
conn.Close()
}
func TestDialTCPRetryTimeout(t *testing.T) {
// Get a free port and close it — nothing will ever listen.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
ln.Close()
start := time.Now()
_, err = dialTCPRetry(addr, 600*time.Millisecond, nil)
elapsed := time.Since(start)
if err == nil {
t.Fatal("dialTCPRetry should fail when nothing is listening")
}
if elapsed < 500*time.Millisecond {
t.Fatalf("should have retried for ~600ms, only took %v", elapsed)
}
}
func TestCLIPingV1(t *testing.T) {
sockPath := startMockSocket(t, "pong")
code := runCLI([]string{"--socket", sockPath, "ping"})
if code != 0 {
t.Fatalf("ping should return 0, got %d", code)
}
}
func TestCLIPingV1OverTCP(t *testing.T) {
addr := startMockTCPSocket(t, "pong")
code := runCLI([]string{"--socket", addr, "ping"})
if code != 0 {
t.Fatalf("ping over TCP should return 0, got %d", code)
}
}
func TestDialSocketDetection(t *testing.T) {
// Unix socket paths should attempt Unix dial
for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} {
conn, err := dialSocket(path, nil)
if conn != nil {
conn.Close()
}
// We expect a connection error (not found), not a panic
if err == nil {
t.Fatalf("dialSocket(%q) should fail for non-existent path", path)
}
}
// TCP addresses should attempt TCP dial
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, _ := ln.Accept()
if conn != nil {
conn.Close()
}
}()
conn, err := dialSocket(ln.Addr().String(), nil)
if err != nil {
t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err)
}
conn.Close()
}
func TestCLINewWindowV1(t *testing.T) {
sockPath := startMockSocket(t, "OK window_id=abc123")
code := runCLI([]string{"--socket", sockPath, "new-window"})
if code != 0 {
t.Fatalf("new-window should return 0, got %d", code)
}
}
func TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) {
addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma")
resp, err := socketRoundTrip(addr, "list_windows", nil)
if err != nil {
t.Fatalf("socketRoundTrip should succeed, got error: %v", err)
}
want := "window:alpha\nwindow:beta\nwindow:gamma"
if resp != want {
t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want)
}
}
func TestCLICloseWindowV1(t *testing.T) {
// Verify that the flag value is appended to the v1 command
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var received string
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
received = strings.TrimSpace(string(buf[:n]))
conn.Write([]byte("OK\n"))
conn.Close()
}()
code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"})
if code != 0 {
t.Fatalf("close-window should return 0, got %d", code)
}
if received != "close_window win-42" {
t.Fatalf("expected 'close_window win-42', got %q", received)
}
}
func TestCLIListWorkspacesV2(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"})
if code != 0 {
t.Fatalf("list-workspaces should return 0, got %d", code)
}
}
func TestCLIRPCPassthrough(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"})
if code != 0 {
t.Fatalf("rpc should return 0, got %d", code)
}
}
func TestCLIRPCWithParams(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`})
if code != 0 {
t.Fatalf("rpc with params should return 0, got %d", code)
}
}
func TestCLIUnknownCommand(t *testing.T) {
code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"})
if code != 2 {
t.Fatalf("unknown command should return 2, got %d", code)
}
}
func TestCLINoSocket(t *testing.T) {
// Without CMUX_SOCKET_PATH set, should fail
os.Unsetenv("CMUX_SOCKET_PATH")
code := runCLI([]string{"ping"})
if code != 1 {
t.Fatalf("missing socket should return 1, got %d", code)
}
}
func TestCLISocketEnvVar(t *testing.T) {
sockPath := startMockSocket(t, "pong")
os.Setenv("CMUX_SOCKET_PATH", sockPath)
defer os.Unsetenv("CMUX_SOCKET_PATH")
code := runCLI([]string{"ping"})
if code != 0 {
t.Fatalf("ping with env socket should return 0, got %d", code)
}
}
func TestCLIV2FlagMapping(t *testing.T) {
// Verify that --workspace gets mapped to workspace_id in params
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var receivedParams map[string]any
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
var req map[string]any
json.Unmarshal(buf[:n], &req)
receivedParams, _ = req["params"].(map[string]any)
resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
conn.Close()
}()
code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"})
if code != 0 {
t.Fatalf("close-workspace should return 0, got %d", code)
}
if receivedParams["workspace_id"] != "ws-abc" {
t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams)
}
}
func TestBusyboxArgv0Detection(t *testing.T) {
// Verify that when argv[0] base is "cmux", we enter CLI mode
base := filepath.Base("cmux")
if base != "cmux" {
t.Fatalf("expected base 'cmux', got %q", base)
}
base2 := filepath.Base("/home/user/.cmux/bin/cmux")
if base2 != "cmux" {
t.Fatalf("expected base 'cmux', got %q", base2)
}
base3 := filepath.Base("cmuxd-remote")
if base3 == "cmux" {
t.Fatalf("cmuxd-remote should not match cmux")
}
}
func TestCLIBrowserSubcommand(t *testing.T) {
sockPath := startMockV2Socket(t)
code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"})
if code != 0 {
t.Fatalf("browser open should return 0, got %d", code)
}
}
func TestCLINoArgs(t *testing.T) {
code := runCLI([]string{})
if code != 2 {
t.Fatalf("no args should return 2, got %d", code)
}
}
func TestCLIHelpFlag(t *testing.T) {
code := runCLI([]string{"--help"})
if code != 0 {
t.Fatalf("--help should return 0, got %d", code)
}
}
func TestCLIHelpCommand(t *testing.T) {
code := runCLI([]string{"help"})
if code != 0 {
t.Fatalf("help should return 0, got %d", code)
}
}
func TestFlagToParamKey(t *testing.T) {
tests := []struct {
input, expected string
}{
{"workspace", "workspace_id"},
{"surface", "surface_id"},
{"panel", "panel_id"},
{"pane", "pane_id"},
{"window", "window_id"},
{"command", "initial_command"},
{"name", "title"},
{"working-directory", "working_directory"},
{"title", "title"},
{"url", "url"},
{"direction", "direction"},
}
for _, tc := range tests {
got := flagToParamKey(tc.input)
if got != tc.expected {
t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected)
}
}
}
func TestParseFlags(t *testing.T) {
args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"}
result := parseFlags(args, []string{"workspace", "surface"})
if result.flags["workspace"] != "ws-1" {
t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"])
}
if result.flags["surface"] != "sf-2" {
t.Errorf("expected surface=sf-2, got %q", result.flags["surface"])
}
if _, ok := result.flags["unknown"]; ok {
t.Errorf("unknown flag should not be parsed")
}
if len(result.positional) == 0 || result.positional[0] != "positional-cmd" {
t.Errorf("expected first positional=positional-cmd, got %v", result.positional)
}
}
func TestCLIEnvVarDefaults(t *testing.T) {
// Test that CMUX_WORKSPACE_ID and CMUX_SURFACE_ID are used as defaults
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var receivedParams map[string]any
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
var req map[string]any
json.Unmarshal(buf[:n], &req)
receivedParams, _ = req["params"].(map[string]any)
resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
conn.Close()
}()
os.Setenv("CMUX_WORKSPACE_ID", "env-ws-id")
os.Setenv("CMUX_SURFACE_ID", "env-sf-id")
defer os.Unsetenv("CMUX_WORKSPACE_ID")
defer os.Unsetenv("CMUX_SURFACE_ID")
code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"})
if code != 0 {
t.Fatalf("close-surface should return 0, got %d", code)
}
if receivedParams["workspace_id"] != "env-ws-id" {
t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"])
}
if receivedParams["surface_id"] != "env-sf-id" {
t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"])
}
}

View file

@ -11,6 +11,7 @@ import (
"io"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
@ -62,6 +63,11 @@ type sessionState struct {
const maxRPCFrameBytes = 4 * 1024 * 1024
func main() {
// Busybox-style: if invoked as "cmux" (via symlink), act as CLI relay.
base := filepath.Base(os.Args[0])
if base == "cmux" {
os.Exit(runCLI(os.Args[1:]))
}
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
@ -91,6 +97,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 1
}
return 0
case "cli":
return runCLI(args[1:])
default:
usage(stderr)
return 2
@ -101,6 +109,7 @@ func usage(w io.Writer) {
_, _ = fmt.Fprintln(w, "Usage:")
_, _ = fmt.Fprintln(w, " cmuxd-remote version")
_, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio")
_, _ = fmt.Fprintln(w, " cmuxd-remote cli <command> [args...]")
}
func runStdioServer(stdin io.Reader, stdout io.Writer) error {

View file

@ -1,8 +1,9 @@
# Remote SSH Living Spec
Last updated: February 21, 2026
Last updated: February 23, 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
This document is the working source of truth for:
1. what is implemented now
@ -37,7 +38,21 @@ This is a **living implementation spec** (also called an **execution spec**): a
- `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller.
- `DONE` SOCKS handshake parsing now preserves pipelined post-connect payload bytes instead of dropping request-prefix bytes.
- `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage.
- `DONE` bootstrap/proxy failures surface actionable details.
- `DONE` bootstrap/probe failures surface actionable details.
- `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote.
### 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` 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` 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.3 Error Surfacing
- `DONE` remote errors are surfaced in sidebar status + logs + notifications.
@ -107,6 +122,7 @@ Recompute effective size on:
| M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Includes daemon capability handshake + status surfacing |
| M-003 | Reconnect/disconnect UX + API + improved error surfacing | DONE | Includes retry count in surfaced errors |
| M-004 | Docker e2e for bootstrap/reconnect shell niceties | DONE | Docker suites validate proxy-path bootstrap and reconnect behavior |
| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper |
| M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint |
| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint |
| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented |
@ -126,7 +142,19 @@ Recompute effective size on:
| T-004 | reconnect API success/error paths | DONE |
| T-005 | retry count visible in daemon error detail | DONE |
### 7.2 Browser Proxy (Target)
### 7.2 CLI Relay
| ID | Scenario | Status |
|---|---|---|
| C-001 | `cmux ping` from remote session | DONE |
| C-002 | `cmux list-workspaces --json` from remote | DONE |
| C-003 | `cmux new-workspace` from remote | DONE |
| C-004 | `cmux rpc system.capabilities` passthrough | DONE |
| C-005 | TCP retry handles relay not yet established | DONE |
| C-006 | multi-workspace port conflict silent skip | DONE |
| C-007 | ephemeral port filtering excludes relay ports | DONE |
### 7.3 Browser Proxy (Target)
| ID | Scenario | Status |
|---|---|---|
@ -138,7 +166,7 @@ Recompute effective size on:
| W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE |
| W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE |
### 7.3 Resize
### 7.4 Resize
| ID | Scenario | Status |
|---|---|---|

View file

@ -418,6 +418,7 @@ if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
elif [[ -n "${TAG_SLUG:-}" ]]; then
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$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
"${OPEN_CLEAN_ENV[@]}" open "$APP_PATH"
fi

View file

@ -20,6 +20,8 @@ AcceptEnv TERM_PROGRAM TERM_PROGRAM_VERSION COLORTERM
X11Forwarding no
AllowTcpForwarding yes
AllowStreamLocalForwarding yes
StreamLocalBindUnlink yes
GatewayPorts no
PermitTunnel no
ClientAliveInterval 30

View file

@ -32,13 +32,17 @@ def resolve_cmux_cli() -> str:
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
def run(cli_path: str, *args: str) -> tuple[int, str, str]:
def run(cli_path: str, *args: str, timeout: float = 5.0) -> tuple[int, str, str]:
try:
proc = subprocess.run(
[cli_path, *args],
text=True,
capture_output=True,
check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return 124, "", f"timed out after {timeout:.1f}s"
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()

View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Regression test: sidebar context menu shows Copy SSH Error only when an SSH error exists."""
from __future__ import annotations
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle not in content:
failures.append(message)
def main() -> int:
repo_root = get_repo_root()
content_view_path = repo_root / "Sources" / "ContentView.swift"
if not content_view_path.exists():
print(f"FAIL: missing expected file: {content_view_path}")
return 1
content = content_view_path.read_text(encoding="utf-8")
failures: list[str] = []
require(
content,
"private var copyableSidebarSSHError: String?",
"Missing sidebar SSH error extraction helper",
failures,
)
require(
content,
'tab.statusEntries["remote.error"]?.value',
"Missing remote.error status fallback for copyable SSH error text",
failures,
)
require(
content,
"if let copyableSidebarSSHError {",
"Copy SSH Error menu entry is no longer conditionally gated",
failures,
)
require(
content,
'Button("Copy SSH Error")',
"Missing Copy SSH Error context menu button",
failures,
)
require(
content,
"copyTextToPasteboard(copyableSidebarSSHError)",
"Copy SSH Error button no longer writes the resolved error text",
failures,
)
if failures:
print("FAIL: sidebar copy SSH error context-menu regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: sidebar Copy SSH Error context menu wiring is intact")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Regression test: socket password keychain entries are scoped per debug instance."""
from __future__ import annotations
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle not in content:
failures.append(message)
def reject(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle in content:
failures.append(message)
def main() -> int:
repo_root = get_repo_root()
cli_path = repo_root / "CLI" / "cmux.swift"
settings_path = repo_root / "Sources" / "SocketControlSettings.swift"
missing = [str(path) for path in (cli_path, settings_path) if not path.exists()]
if missing:
print("FAIL: missing expected files:")
for path in missing:
print(f"- {path}")
return 1
cli = cli_path.read_text(encoding="utf-8")
settings = settings_path.read_text(encoding="utf-8")
failures: list[str] = []
require(
cli,
"static func resolve(explicit: String?, socketPath: String) -> String?",
"CLI resolver must accept socketPath to determine scoped keychain service",
failures,
)
require(
cli,
"private static func keychainServices(socketPath: String) -> [String]",
"CLI must derive keychain services from socket context",
failures,
)
require(
cli,
'return ["\\(service).\\(scope)"]',
"CLI should use only the scoped keychain service when scope is present",
failures,
)
require(
cli,
"URL(fileURLWithPath: socketPath).lastPathComponent",
"CLI scope detection should parse the socket file name",
failures,
)
require(
cli,
"kSecUseAuthenticationContext as String: authContext",
"CLI keychain lookup must fail fast without interactive keychain prompts",
failures,
)
require(
cli,
"SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath)",
"CLI run path must pass socketPath into password resolution",
failures,
)
require(
settings,
"private static func keychainScope(environment: [String: String]) -> String?",
"App keychain store should compute a scoped keychain namespace",
failures,
)
require(
settings,
"environment[SocketControlSettings.launchTagEnvKey]",
"App keychain scope should prioritize CMUX_TAG",
failures,
)
require(
settings,
"URL(fileURLWithPath: socketPath).lastPathComponent",
"App keychain scope should parse the socket file name",
failures,
)
require(
settings,
"private static func keychainService(environment: [String: String]) -> String",
"App keychain service should be derived from environment scope",
failures,
)
require(
settings,
'return "\\(service).\\(scope)"',
"App keychain service should append the scoped suffix",
failures,
)
require(
settings,
"kSecAttrService as String: keychainService(environment: environment)",
"App keychain queries should use mode-specific scoped service",
failures,
)
require(
settings,
"return try? loadPassword(environment: environment)",
"configuredPassword should read keychain from matching scoped service",
failures,
)
reject(
settings,
"private static var baseQuery: [String: Any]",
"Legacy global baseQuery should not remain as a static unscoped property",
failures,
)
if failures:
print("FAIL: keychain scope regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: socket password keychain service is scoped by tagged debug instance")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit."""
from __future__ import annotations
import glob
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
def _merged_output(proc: subprocess.CompletedProcess[str]) -> str:
return f"{proc.stdout}\n{proc.stderr}".strip()
def main() -> int:
cli = _find_cli_binary()
# Global --version should be handled before socket command dispatch.
version_proc = _run([cli, "--version"])
version_out = _merged_output(version_proc).lower()
_must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}")
_must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}")
# Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path
# when CMUX_SOCKET_PATH is not set.
hint_backup: str | None = None
hint_had_file = LAST_SOCKET_HINT_PATH.exists()
if hint_had_file:
hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8")
try:
LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8")
auto_env = dict(os.environ)
auto_env.pop("CMUX_SOCKET_PATH", None)
auto_ping = _run([cli, "ping"], env=auto_env)
auto_ping_out = _merged_output(auto_ping).lower()
_must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}")
_must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}")
finally:
try:
if hint_had_file:
LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8")
else:
LAST_SOCKET_HINT_PATH.unlink(missing_ok=True)
except OSError:
pass
# Global --password should parse as a flag (not a command name) and still allow non-password sockets.
ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"])
ping_out = _merged_output(ping_proc).lower()
_must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}")
_must("pong" in ping_out, f"ping should still return pong: {ping_out!r}")
# V1 errors must produce non-zero exit codes for automation correctness.
bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"])
bad_out = _merged_output(bad_focus).lower()
_must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}")
_must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}")
print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) ->
return proc.stdout.strip()
def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str:
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
for row in payload.get("surfaces") or []:
if str(row.get("id") or "") == surface_id:
return str(row.get("title") or "")
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
def main() -> int:
cli = _find_cli_binary()
stamp = int(time.time() * 1000)
@ -82,7 +74,7 @@ def main() -> int:
_must(bool(surface_id), f"surface.current returned no surface_id: {current}")
socket_title = f"socket rename {stamp}"
c._call(
socket_payload = c._call(
"tab.action",
{
"workspace_id": ws_id,
@ -91,14 +83,20 @@ def main() -> int:
"title": socket_title,
},
)
_must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title")
_must(
str((socket_payload or {}).get("title") or "") == socket_title,
f"tab.action rename response missing requested title: {socket_payload}",
)
cli_title = f"cli rename {stamp}"
_run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
_must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title")
cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
_must(
"action=rename" in cli_out.lower() and "tab=" in cli_out.lower(),
f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}",
)
env_title = f"env rename {stamp}"
_run_cli(
env_out = _run_cli(
cli,
["rename-tab", env_title],
env={
@ -106,7 +104,10 @@ def main() -> int:
"CMUX_TAB_ID": surface_id,
},
)
_must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title")
_must(
"action=rename" in env_out.lower() and "tab=" in env_out.lower(),
f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}",
)
invalid = subprocess.run(
[cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id],

View file

@ -143,6 +143,9 @@ def main() -> int:
selected_workspace_id == workspace_id,
f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}",
)
remote_relay_port = payload.get("remote_relay_port")
_must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}")
remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}"
ssh_command = str(payload.get("ssh_command") or "")
_must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}")
_must(
@ -164,6 +167,14 @@ def main() -> int:
_must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}")
_must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}")
_must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}")
_must(
(
f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; "
f"export CMUX_SOCKET_PATH={remote_socket_addr}; "
"exec \"${SHELL:-/bin/zsh}\" -l"
) in ssh_command,
f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {ssh_command!r}",
)
listed_row = None
deadline = time.time() + 8.0

View file

@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding."""
from __future__ import annotations
import glob
import json
import os
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
# Keep the fixture's extra HTTP server below 1024 so there are no eligible
# (>1023) ports to auto-forward. This guards the "connecting forever" regression.
REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81"))
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
# Ensure --socket is what drives the relay path during tests.
env.pop("CMUX_SOCKET_PATH", None)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
proc = _run([cli, "--socket", SOCKET_PATH, "--json", "--id-format", "both", *args], env=env)
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _docker_available() -> bool:
if shutil.which("docker") is None:
return False
probe = _run(["docker", "info"], check=False)
return probe.returncode == 0
def _parse_host_port(docker_port_output: str) -> int:
text = docker_port_output.strip()
if not text:
raise cmuxError("docker port output was empty")
last = text.split(":")[-1]
return int(last)
def _shell_single_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
return _run(
[
"ssh",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=5",
"-p", str(host_port),
"-i", str(key_path),
host,
f"sh -lc {_shell_single_quote(script)}",
],
check=check,
)
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
if probe.returncode == 0 and "ready" in probe.stdout:
return
time.sleep(0.5)
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict:
deadline = time.time() + timeout
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
daemon = remote.get("daemon") or {}
state = str(remote.get("state") or "")
daemon_state = str(daemon.get("state") or "")
if state == "connected" and daemon_state == "ready":
return last_status
time.sleep(0.5)
raise cmuxError(f"Remote daemon did not become ready: {last_status}")
def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None:
ping_result = _ssh_run(
host, host_port, key_path,
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping",
check=False,
)
_must(
ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(),
f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}",
)
def main() -> int:
if not _docker_available():
print("SKIP: docker is not available")
return 0
cli = _find_cli_binary()
repo_root = Path(__file__).resolve().parents[1]
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-"))
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}"
workspace_id = ""
workspace_id_2 = ""
try:
# Generate SSH key pair
key_path = temp_dir / "id_ed25519"
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
_must(bool(pubkey), "Generated SSH public key was empty")
# Build and start Docker container
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
_run([
"docker", "run", "-d", "--rm",
"--name", container_name,
"-e", f"AUTHORIZED_KEY={pubkey}",
"-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}",
"-p", "127.0.0.1::22",
image_tag,
])
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
host_ssh_port = _parse_host_port(port_info)
host = "root@127.0.0.1"
_wait_for_ssh(host, host_ssh_port, key_path)
with cmux(SOCKET_PATH) as client:
# Create SSH workspace (this sets up the reverse socket forward)
payload = _run_cli_json(
cli,
[
"ssh",
host,
"--name", "docker-cli-relay",
"--port", str(host_ssh_port),
"--identity", str(key_path),
"--ssh-option", "UserKnownHostsFile=/dev/null",
"--ssh-option", "StrictHostKeyChecking=no",
],
)
workspace_id = str(payload.get("workspace_id") or "")
workspace_ref = str(payload.get("workspace_ref") or "")
if not workspace_id and workspace_ref.startswith("workspace:"):
listed = client._call("workspace.list", {}) or {}
for row in listed.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref:
workspace_id = str(row.get("id") or "")
break
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
remote_relay_port = payload.get("remote_relay_port")
_must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}")
remote_relay_port = int(remote_relay_port)
_must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}")
remote_socket_addr = f"127.0.0.1:{remote_relay_port}"
startup_cmd = str(payload.get("ssh_startup_command") or "")
_must(
'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd,
f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}",
)
_must(
f"CMUX_SOCKET_PATH={remote_socket_addr}" in startup_cmd,
f"ssh startup command should pin CMUX_SOCKET_PATH to workspace relay: {startup_cmd!r}",
)
workspace_window_id = payload.get("window_id")
current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {}
current = client._call("workspace.current", current_params) or {}
current_workspace_id = str(current.get("workspace_id") or "")
_must(
current_workspace_id == workspace_id,
f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}",
)
# Wait for daemon to be ready
first_status = _wait_for_remote_ready(client, workspace_id)
first_remote = first_status.get("remote") or {}
# Regression: should transition to connected even with no eligible
# (>1023, non-ephemeral) remote ports.
_must(
not (first_remote.get("detected_ports") or []),
f"expected no eligible detected ports in fixture: {first_status}",
)
_must(
not (first_remote.get("forwarded_ports") or []),
f"expected no forwarded ports when none are eligible: {first_status}",
)
# Verify remote cmux wrapper + relay-specific daemon mapping were installed.
wrapper_check = None
wrapper_deadline = time.time() + 10.0
while time.time() < wrapper_deadline:
wrapper_check = _ssh_run(
host, host_ssh_port, key_path,
f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && "
f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && "
"daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && "
"test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok",
check=False,
)
if "wrapper-ok" in (wrapper_check.stdout or ""):
break
time.sleep(0.4)
_must(
wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""),
f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}",
)
# Start a second SSH workspace to the same destination and verify both
# relays remain healthy (regression: same-host workspaces killed each other).
payload_2 = _run_cli_json(
cli,
[
"ssh",
host,
"--name", "docker-cli-relay-2",
"--port", str(host_ssh_port),
"--identity", str(key_path),
"--ssh-option", "UserKnownHostsFile=/dev/null",
"--ssh-option", "StrictHostKeyChecking=no",
],
)
workspace_id_2 = str(payload_2.get("workspace_id") or "")
workspace_ref_2 = str(payload_2.get("workspace_ref") or "")
if not workspace_id_2 and workspace_ref_2.startswith("workspace:"):
listed_2 = client._call("workspace.list", {}) or {}
for row in listed_2.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref_2:
workspace_id_2 = str(row.get("id") or "")
break
_must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}")
remote_relay_port_2 = payload_2.get("remote_relay_port")
_must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}")
remote_relay_port_2 = int(remote_relay_port_2)
_must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}")
_must(
remote_relay_port_2 != remote_relay_port,
f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}",
)
remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}"
startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "")
_must(
f"CMUX_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2,
f"second ssh startup command should pin CMUX_SOCKET_PATH to second relay: {startup_cmd_2!r}",
)
_ = _wait_for_remote_ready(client, workspace_id_2)
stability_deadline = time.time() + 8.0
while time.time() < stability_deadline:
_assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay")
_assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay")
time.sleep(0.5)
# Test 1: cmux ping (v1)
_assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux")
# Test 2: cmux list-workspaces --json (v2)
list_ws_result = _ssh_run(
host, host_ssh_port, key_path,
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces",
check=False,
)
_must(
list_ws_result.returncode == 0,
f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}",
)
try:
ws_data = json.loads(list_ws_result.stdout.strip())
_must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}")
except json.JSONDecodeError:
raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}")
# Test 3: cmux new-window (v1)
new_win_result = _ssh_run(
host, host_ssh_port, key_path,
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window",
check=False,
)
_must(
new_win_result.returncode == 0,
f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}",
)
# Test 4: cmux rpc system.capabilities (v2 passthrough)
rpc_result = _ssh_run(
host, host_ssh_port, key_path,
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities",
check=False,
)
_must(
rpc_result.returncode == 0,
f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}",
)
try:
caps_data = json.loads(rpc_result.stdout.strip())
_must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}")
except json.JSONDecodeError:
raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}")
# Cleanup
try:
client.close_workspace(workspace_id)
except Exception:
pass
workspace_id = ""
if workspace_id_2:
try:
client.close_workspace(workspace_id_2)
except Exception:
pass
workspace_id_2 = ""
print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding")
return 0
finally:
if workspace_id:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
if workspace_id_2:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client.close_workspace(workspace_id_2)
except Exception:
pass
_run(["docker", "rm", "-f", container_name], check=False)
_run(["docker", "rmi", "-f", image_tag], check=False)
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -405,6 +405,11 @@ def main() -> int:
surfaces = client.list_surfaces(workspace_id)
_must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}")
surface_id = surfaces[0][1]
terminal_text = client.read_terminal_text(surface_id)
_must(
"Reconstructed via infocmp" not in terminal_text,
"ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell",
)
try:
term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"")

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Regression: workspace.create must apply initial_env to the initial terminal."""
import os
import sys
import time
import base64
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str:
deadline = time.time() + timeout_s
last_text = ""
while time.time() < deadline:
payload = c._call(
"surface.read_text",
{"workspace_id": workspace_id},
) or {}
if "text" in payload:
last_text = str(payload.get("text") or "")
else:
b64 = str(payload.get("base64") or "")
raw = base64.b64decode(b64) if b64 else b""
last_text = raw.decode("utf-8", errors="replace")
if needle in last_text:
return last_text
time.sleep(0.1)
raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}")
def main() -> int:
with cmux(SOCKET_PATH) as c:
baseline_workspace = c.current_workspace()
created_workspace = ""
try:
token = f"tok_{int(time.time() * 1000)}"
payload = c._call(
"workspace.create",
{
"initial_env": {"CMUX_INITIAL_ENV_TOKEN": token},
},
) or {}
created_workspace = str(payload.get("workspace_id") or "")
_must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}")
_must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus")
# Terminal surfaces in background workspaces may not be attached/render-ready yet.
# Select it before reading text so the initial command output is available.
c.select_workspace(created_workspace)
listed = c._call("surface.list", {"workspace_id": created_workspace}) or {}
rows = list(listed.get("surfaces") or [])
_must(bool(rows), "Expected at least one surface in the created workspace")
terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None)
_must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}")
c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n")
text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}")
_must(
f"CMUX_ENV_CHECK={token}" in text,
f"initial_env token missing from terminal output: {text!r}",
)
c.select_workspace(baseline_workspace)
finally:
if created_workspace:
try:
c.close_workspace(created_workspace)
except Exception:
pass
print("PASS: workspace.create applies initial_env to initial terminal")
return 0
if __name__ == "__main__":
raise SystemExit(main())