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:
commit
30bb74dc92
26 changed files with 4896 additions and 1954 deletions
534
CLI/cmux.swift
534
CLI/cmux.swift
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
49
cmuxTests/TabManagerSessionSnapshotTests.swift
Normal file
49
cmuxTests/TabManagerSessionSnapshotTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
214
cmuxTests/TerminalControllerSocketSecurityTests.swift
Normal file
214
cmuxTests/TerminalControllerSocketSecurityTests.swift
Normal 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)))"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
568
daemon/remote/cmd/cmuxd-remote/cli.go
Normal file
568
daemon/remote/cmd/cmuxd-remote/cli.go
Normal 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]), ¶ms); 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")
|
||||
}
|
||||
482
daemon/remote/cmd/cmuxd-remote/cli_test.go
Normal file
482
daemon/remote/cmd/cmuxd-remote/cli_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
tests/fixtures/ssh-remote/sshd_config
vendored
2
tests/fixtures/ssh-remote/sshd_config
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
79
tests/test_sidebar_copy_ssh_error_context_menu.py
Normal file
79
tests/test_sidebar_copy_ssh_error_context_menu.py
Normal 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())
|
||||
146
tests/test_socket_password_keychain_scope.py
Normal file
146
tests/test_socket_password_keychain_scope.py
Normal 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())
|
||||
100
tests_v2/test_cli_global_flags_and_v1_error_contract.py
Normal file
100
tests_v2/test_cli_global_flags_and_v1_error_contract.py
Normal 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())
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
392
tests_v2/test_ssh_remote_cli_relay.py
Normal file
392
tests_v2/test_ssh_remote_cli_relay.py
Normal 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())
|
||||
|
|
@ -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\"")
|
||||
|
|
|
|||
86
tests_v2/test_workspace_create_initial_env.py
Normal file
86
tests_v2/test_workspace_create_initial_env.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue