Fix ssh stack review regressions

This commit is contained in:
Lawrence Chen 2026-03-13 04:14:52 -07:00
parent 19b59cae37
commit 2e6856ff2f
27 changed files with 1270 additions and 506 deletions

View file

@ -1722,6 +1722,87 @@ struct CMUXCLI {
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "set-status":
let response = try forwardSidebarMetadataCommand(
"set_status",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "clear-status":
let response = try forwardSidebarMetadataCommand(
"clear_status",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "list-status":
let response = try forwardSidebarMetadataCommand(
"list_status",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "set-progress":
let response = try forwardSidebarMetadataCommand(
"set_progress",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "clear-progress":
let response = try forwardSidebarMetadataCommand(
"clear_progress",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "log":
let response = try forwardSidebarMetadataCommand(
"log",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "clear-log":
let response = try forwardSidebarMetadataCommand(
"clear_log",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "list-log":
let response = try forwardSidebarMetadataCommand(
"list_log",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "sidebar-state":
let response = try forwardSidebarMetadataCommand(
"sidebar_state",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "claude-hook":
cliTelemetry.breadcrumb("claude-hook.dispatch")
do {
@ -3153,42 +3234,86 @@ struct CMUXCLI {
}
private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String {
let relayExport = remoteRelayPort > 0
? "export CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort)"
: nil
let remoteEnvExports = interactiveRemoteShellExports(shellFeatures: shellFeatures)
let innerCommand = [
remoteEnvExports,
"export PATH=\"$HOME/.cmux/bin:$PATH\"",
relayExport,
"exec \"${SHELL:-/bin/zsh}\" -i",
let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures)
let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil
let commonShellLines = remoteEnvExportLines
+ ["export PATH=\"$HOME/.cmux/bin:$PATH\""]
+ (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? [])
+ [
"hash -r >/dev/null 2>&1 || true",
"rehash >/dev/null 2>&1 || true",
]
.compactMap { $0 }
.joined(separator: "; ")
let zshEnvLines = [
"export CMUX_REAL_ZDOTDIR=\"${CMUX_REAL_ZDOTDIR:-$HOME}\"",
"[ -f \"$HOME/.zshenv\" ] && source \"$HOME/.zshenv\"",
]
let zshRCLines = [
"export ZDOTDIR=\"${CMUX_REAL_ZDOTDIR:-$HOME}\"",
"[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\"",
] + commonShellLines
let bashRCLines = [
"[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"",
] + commonShellLines
let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort)
let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell"
let outerCommand = [
var outerLines: [String] = [
"CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"",
"case \"${CMUX_LOGIN_SHELL##*/}\" in",
" zsh|bash)",
" exec \"$CMUX_LOGIN_SHELL\" -lc \(shellQuote(innerCommand))",
" zsh)",
" mkdir -p \"$HOME/.cmux/relay\"",
" cmux_shell_dir=\"\(shellStateDir)\"",
" mkdir -p \"$cmux_shell_dir\"",
" cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'",
]
outerLines.append(contentsOf: zshEnvLines)
outerLines += [
"CMUXZSHENV",
" cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'",
]
outerLines.append(contentsOf: zshRCLines)
outerLines += [
"CMUXZSHRC",
" chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zshrc\" >/dev/null 2>&1 || true",
]
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines += [
" export ZDOTDIR=\"$cmux_shell_dir\"",
" exec \"$CMUX_LOGIN_SHELL\" -i",
" ;;",
" bash)",
" mkdir -p \"$HOME/.cmux/relay\"",
" cmux_shell_dir=\"\(shellStateDir)\"",
" mkdir -p \"$cmux_shell_dir\"",
" cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'",
]
outerLines.append(contentsOf: bashRCLines)
outerLines += [
"CMUXBASHRC",
" chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true",
]
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines += [
" exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i",
" ;;",
" *)",
remoteEnvExports,
" export PATH=\"$HOME/.cmux/bin:$PATH\"",
relayExport,
]
outerLines.append(contentsOf: commonShellLines.map { " " + $0 })
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines += [
" exec \"$CMUX_LOGIN_SHELL\" -i",
" ;;",
"esac",
]
.compactMap { $0 }
.joined(separator: "; ")
return outerCommand
let outerCommand = outerLines.joined(separator: "\n")
return "/bin/sh -lc \(shellQuote(outerCommand))"
}
private func interactiveRemoteShellExports(shellFeatures: String) -> String {
private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] {
let environment = ProcessInfo.processInfo.environment
let term = Self.normalizedEnvValue(environment["TERM"]) ?? "xterm-ghostty"
let term = "xterm-ghostty"
let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor"
let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty"
let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"])
@ -3207,7 +3332,21 @@ struct CMUXCLI {
if !trimmedShellFeatures.isEmpty {
exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))")
}
return exports.joined(separator: "; ")
return exports
}
private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] {
guard remoteRelayPort > 0 else { return [] }
return [
"cmux_wait_attempt=0",
"while [ \"$cmux_wait_attempt\" -lt 40 ]; do",
" if [ -x \"$HOME/.cmux/bin/cmux\" ] && [ -f \"$HOME/.cmux/relay/\(remoteRelayPort).auth\" ] && CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort) \"$HOME/.cmux/bin/cmux\" ping >/dev/null 2>&1; then",
" break",
" fi",
" cmux_wait_attempt=$((cmux_wait_attempt + 1))",
" sleep 0.2",
"done",
]
}
private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] {
@ -4000,7 +4139,13 @@ struct CMUXCLI {
throw CLIError(message: "browser eval requires a script")
}
let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed])
output(payload, fallback: "OK")
let fallback: String
if let value = payload["value"] {
fallback = displayBrowserValue(value)
} else {
fallback = "OK"
}
output(payload, fallback: fallback)
return
}
@ -6307,6 +6452,49 @@ struct CMUXCLI {
return ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]
}
private func forwardSidebarMetadataCommand(
_ socketCommand: String,
commandArgs: [String],
client: SocketClient,
windowOverride: String?
) throws -> String {
var forwardedArgs: [String] = []
var resolvedExplicitWorkspace = false
var index = 0
while index < commandArgs.count {
let arg = commandArgs[index]
if arg == "--workspace", index + 1 < commandArgs.count {
let workspaceId = try resolveWorkspaceId(commandArgs[index + 1], client: client)
forwardedArgs.append("--tab=\(workspaceId)")
resolvedExplicitWorkspace = true
index += 2
continue
}
if arg.hasPrefix("--workspace=") {
let rawWorkspace = String(arg.dropFirst("--workspace=".count))
let workspaceId = try resolveWorkspaceId(rawWorkspace, client: client)
forwardedArgs.append("--tab=\(workspaceId)")
resolvedExplicitWorkspace = true
index += 1
continue
}
forwardedArgs.append(arg)
index += 1
}
if !resolvedExplicitWorkspace,
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) {
let workspaceId = try resolveWorkspaceId(workspaceArg, client: client)
forwardedArgs.append("--tab=\(workspaceId)")
}
let command = ([socketCommand] + forwardedArgs)
.map(shellQuote)
.joined(separator: " ")
return try sendV1Command(command, client: client)
}
/// Pick the display handle for an item dict based on --id-format.
private func textHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String {
let ref = item["ref"] as? String

View file

@ -25486,6 +25486,40 @@
}
}
},
"clipboard.sshError.item": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld. %@ (%@): %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld. %@ (%@): %@"
}
}
}
},
"clipboard.sshError.single": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH error (%@): %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH エラー (%@): %@"
}
}
}
},
"contextMenu.copySshError": {
"extractionState": "manual",
"localizations": {
@ -61642,6 +61676,40 @@
}
}
},
"sidebar.activeTabIndicator.leftRail": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Left Rail"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "左レール"
}
}
}
},
"sidebar.activeTabIndicator.solidFill": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Solid Fill"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "塗りつぶし"
}
}
}
},
"sidebar.workspace.moveDownAction": {
"extractionState": "manual",
"localizations": {

View file

@ -11197,19 +11197,38 @@ private extension NSWindow {
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
// Portal-hosted browser chrome (for example the Cmd+F overlay) is a
// sibling of the hosted WKWebView inside WindowBrowserSlotView, not a
// descendant of it. Treating every view in that slot as "web-owned"
// blocks legitimate first-responder changes to overlay text fields.
// descendant of it. Allow native text-entry controls in that slot to
// acquire first responder directly, but keep generic sibling views
// associated with the hosted web view so blocked browser focus policy
// still protects inspector/overlay chrome from stray focus changes.
if view === portalWebView || view.isDescendant(of: portalWebView) {
return portalWebView
}
if cmuxAllowsPortalSlotTextEntryFocus(view) {
return nil
}
return portalWebView
}
current = candidate.superview
}
return nil
}
private static func cmuxAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool {
var current: NSView? = view
while let candidate = current {
if let textField = candidate as? NSTextField {
return textField.isEditable || textField.acceptsFirstResponder
}
if let textView = candidate as? NSTextView {
return textView.isEditable || textView.isSelectable || textView.isFieldEditor
}
current = candidate.superview
}
return false
}
private static func cmuxUniqueBrowserWebView(in root: NSView) -> CmuxWebView? {
var stack: [NSView] = [root]
var found: CmuxWebView?

View file

@ -92,27 +92,23 @@ enum SidebarRemoteErrorCopySupport {
static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
guard !entries.isEmpty else { return nil }
if entries.count == 1, let entry = entries.first {
return "SSH error (\(entry.target)): \(entry.detail)"
return String.localizedStringWithFormat(
String(localized: "clipboard.sshError.single", defaultValue: "SSH error (%@): %@"),
entry.target,
entry.detail
)
}
return entries.enumerated().map { index, entry in
"\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)"
String.localizedStringWithFormat(
String(localized: "clipboard.sshError.item", defaultValue: "%lld. %@ (%@): %@"),
Int64(index + 1),
entry.workspaceTitle,
entry.target,
entry.detail
)
}.joined(separator: "\n")
}
static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("SSH error") else { return nil }
if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) {
return (String(match.1), String(match.2))
}
if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) {
guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil }
return (fallbackTarget, String(match.1))
}
return nil
}
}
func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor {
@ -7220,6 +7216,13 @@ struct VerticalTabsSidebar: View {
LazyVStack(spacing: tabRowSpacing) {
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
let selectedContextIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id]
let contextTargetIds = tabManager.tabs.compactMap { workspace in
selectedContextIds.contains(workspace.id) ? workspace.id : nil
}
let remoteContextMenuTargets = tabManager.tabs.filter { workspace in
contextTargetIds.contains(workspace.id) && workspace.isRemoteWorkspace
}
TabItemView(
tabManager: tabManager,
notificationStore: notificationStore,
@ -7241,7 +7244,10 @@ struct VerticalTabsSidebar: View {
showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed,
dragAutoScrollController: dragAutoScrollController,
draggedTabId: $draggedTabId,
dropIndicator: $dropIndicator
dropIndicator: $dropIndicator,
remoteContextMenuWorkspaceIds: remoteContextMenuTargets.map(\.id),
allRemoteContextMenuTargetsConnecting: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .connecting },
allRemoteContextMenuTargetsDisconnected: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected }
)
.equatable()
}
@ -9497,7 +9503,10 @@ private struct TabItemView: View, Equatable {
lhs.unreadCount == rhs.unreadCount &&
lhs.latestNotificationText == rhs.latestNotificationText &&
lhs.rowSpacing == rhs.rowSpacing &&
lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints
lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints &&
lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds &&
lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting &&
lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected
}
// Use plain references instead of @EnvironmentObject to avoid subscribing
@ -9520,6 +9529,9 @@ private struct TabItemView: View, Equatable {
let dragAutoScrollController: SidebarDragAutoScrollController
@Binding var draggedTabId: UUID?
@Binding var dropIndicator: SidebarDropIndicator?
let remoteContextMenuWorkspaceIds: [UUID]
let allRemoteContextMenuTargetsConnecting: Bool
let allRemoteContextMenuTargetsDisconnected: Bool
@State private var isHovering = false
@State private var rowHeight: CGFloat = 1
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
@ -9645,15 +9657,28 @@ private struct TabItemView: View, Equatable {
}
private var copyableSidebarSSHError: String? {
let fallbackTarget = tab.remoteDisplayTarget ?? String(
localized: "sidebar.remote.help.targetFallback",
defaultValue: "remote host"
)
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)"
let entry = SidebarRemoteErrorCopyEntry(
workspaceTitle: tab.title,
target: fallbackTarget,
detail: trimmedDetail
)
return SidebarRemoteErrorCopySupport.clipboardText(for: [entry])
}
if let statusValue = tab.statusEntries["remote.error"]?.value
.trimmingCharacters(in: .whitespacesAndNewlines),
!statusValue.isEmpty {
return statusValue
let entry = SidebarRemoteErrorCopyEntry(
workspaceTitle: tab.title,
target: fallbackTarget,
detail: statusValue
)
return SidebarRemoteErrorCopySupport.clipboardText(for: [entry])
}
return nil
}
@ -10080,14 +10105,19 @@ private struct TabItemView: View, Equatable {
isMulti ? multi : single
}
private func remoteContextMenuWorkspaces() -> [Workspace] {
guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] }
return remoteContextMenuWorkspaceIds.compactMap { workspaceId in
tabManager.tabs.first(where: { $0.id == workspaceId })
}
}
@ViewBuilder
private var workspaceContextMenu: some View {
let targetIds = contextTargetIds()
let isMulti = targetIds.count > 1
let tabColorPalette = WorkspaceTabColorSettings.palette()
let shouldPin = !tab.isPinned
let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) }
let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace }
let reconnectLabel = contextMenuLabel(
multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"),
single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"),
@ -10145,22 +10175,22 @@ private struct TabItemView: View, Equatable {
}
}
if !remoteTargetWorkspaces.isEmpty {
if !remoteContextMenuWorkspaceIds.isEmpty {
Divider()
Button(reconnectLabel) {
for workspace in remoteTargetWorkspaces {
for workspace in remoteContextMenuWorkspaces() {
workspace.reconnectRemoteConnection()
}
}
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting })
.disabled(allRemoteContextMenuTargetsConnecting)
Button(disconnectLabel) {
for workspace in remoteTargetWorkspaces {
for workspace in remoteContextMenuWorkspaces() {
workspace.disconnectRemoteConnection(clearConfiguration: false)
}
}
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected })
.disabled(allRemoteContextMenuTargetsDisconnected)
}
Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) {

View file

@ -2811,9 +2811,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
env["CMUX_PANEL_ID"] = id.uuidString
env["CMUX_TAB_ID"] = tabId.uuidString
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path,
!bundledCLIPath.isEmpty {
env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath
if let bundledCLIURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"),
FileManager.default.isExecutableFile(atPath: bundledCLIURL.path) {
env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIURL.path
}
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
env["CMUX_BUNDLE_ID"] = bundleId
@ -2883,7 +2883,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
if !initialEnvironmentOverrides.isEmpty {
for (key, value) in initialEnvironmentOverrides {
for (key, value) in initialEnvironmentOverrides where !key.hasPrefix("CMUX_") {
env[key] = value
}
}

View file

@ -1420,6 +1420,7 @@ final class BrowserPanel: Panel, ObservableObject {
/// The underlying web view
private(set) var webView: WKWebView
private let websiteDataStore: WKWebsiteDataStore
/// Monotonic identity for the current WKWebView instance.
/// Incremented whenever we replace the underlying WKWebView after a process crash.
@ -1975,13 +1976,13 @@ final class BrowserPanel: Panel, ObservableObject {
false
}
private static func makeWebView() -> CmuxWebView {
private static func makeWebView(websiteDataStore: WKWebsiteDataStore) -> CmuxWebView {
let config = WKWebViewConfiguration()
config.processPool = BrowserPanel.sharedProcessPool
config.mediaTypesRequiringUserActionForPlayback = []
// Ensure browser cookies/storage persist across navigations and launches.
// This reduces repeated consent/bot-challenge flows on sites like Google.
config.websiteDataStore = .default()
config.websiteDataStore = websiteDataStore
// Enable developer extras (DevTools)
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
@ -2050,11 +2051,13 @@ final class BrowserPanel: Panel, ObservableObject {
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
self.remoteProxyEndpoint = proxyEndpoint
self.browserThemeMode = BrowserThemeSettings.mode()
self.websiteDataStore = isRemoteWorkspace
? WKWebsiteDataStore(forIdentifier: self.id)
: .default()
let webView = Self.makeWebView()
let webView = Self.makeWebView(websiteDataStore: websiteDataStore)
self.webView = webView
self.insecureHTTPAlertFactory = { NSAlert() }
let _ = isRemoteWorkspace
applyRemoteProxyConfigurationIfAvailable()
// Set up navigation delegate
@ -2245,7 +2248,7 @@ final class BrowserPanel: Panel, ObservableObject {
let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return }
self.currentURL = webView.url
self.currentURL = Self.remoteProxyDisplayURL(for: webView.url)
}
}
webViewObservers.append(urlObserver)
@ -2314,7 +2317,7 @@ final class BrowserPanel: Panel, ObservableObject {
guard terminatedWebView === webView else { return }
let wasRenderable = shouldRenderWebView
let restoreURL = terminatedWebView.url ?? currentURL
let restoreURL = Self.remoteProxyDisplayURL(for: terminatedWebView.url) ?? currentURL
let restoreURLString = restoreURL?.absoluteString
let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString
let history = sessionNavigationHistorySnapshot()
@ -2344,7 +2347,7 @@ final class BrowserPanel: Panel, ObservableObject {
terminatedCmuxWebView.onContextMenuDownloadStateChanged = nil
}
let replacement = Self.makeWebView()
let replacement = Self.makeWebView(websiteDataStore: websiteDataStore)
replacement.pageZoom = desiredZoom
webViewInstanceID = UUID()
webView = replacement
@ -2401,7 +2404,7 @@ final class BrowserPanel: Panel, ObservableObject {
// If nothing meaningful is loaded yet, prefer letting the omnibar take focus.
if !webView.isLoading {
let urlString = webView.url?.absoluteString ?? currentURL?.absoluteString
let urlString = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString ?? currentURL?.absoluteString
if urlString == nil || urlString == "about:blank" {
return
}
@ -2694,6 +2697,16 @@ final class BrowserPanel: Panel, ObservableObject {
return rewrittenRequest
}
private static func remoteProxyDisplayURL(for url: URL?) -> URL? {
guard let url else { return nil }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url }
guard host == BrowserInsecureHTTPSettings.normalizeHost(remoteLoopbackProxyAliasHost) else { return url }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.host = "localhost"
return components?.url ?? url
}
private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil }
@ -2924,7 +2937,7 @@ extension BrowserPanel {
oldCmuxWebView.onContextMenuDownloadStateChanged = nil
}
let replacement = Self.makeWebView()
let replacement = Self.makeWebView(websiteDataStore: websiteDataStore)
webViewInstanceID = UUID()
webView = replacement
shouldRenderWebView = false
@ -4159,7 +4172,7 @@ extension BrowserPanel {
/// Returns the most reliable URL string for omnibar-related matching and UI decisions.
/// `currentURL` can lag behind navigation changes, so prefer the live WKWebView URL.
func preferredURLStringForOmnibar() -> String? {
if let webViewURL = webView.url?.absoluteString
if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString
.trimmingCharacters(in: .whitespacesAndNewlines),
!webViewURL.isEmpty,
webViewURL != blankURLString {
@ -4177,7 +4190,7 @@ extension BrowserPanel {
}
private func resolvedCurrentSessionHistoryURL() -> URL? {
if let webViewURL = webView.url,
if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url),
Self.serializableSessionHistoryURLString(webViewURL) != nil {
return webViewURL
}

View file

@ -81,9 +81,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .leftRail:
return "Left Rail"
return String(localized: "sidebar.activeTabIndicator.leftRail", defaultValue: "Left Rail")
case .solidFill:
return "Solid Fill"
return String(localized: "sidebar.activeTabIndicator.solidFill", defaultValue: "Solid Fill")
}
}
}
@ -1456,8 +1456,8 @@ class TabManager: ObservableObject {
let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace),
!confirmClose(
title: "Close workspace?",
message: "This will close the workspace and all of its panels.",
title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."),
acceptCmdD: willCloseWindow
) {
return
@ -1498,8 +1498,8 @@ class TabManager: ObservableObject {
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? "This will close the last tab and close the window."
: "This will close the last tab and close its workspace."
? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.")
: String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.")
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
@ -1507,7 +1507,7 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: message,
acceptCmdD: willCloseWindow
) else {
@ -1539,8 +1539,8 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else {
#if DEBUG
@ -1578,8 +1578,8 @@ class TabManager: ObservableObject {
if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() {
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else { return }
}

View file

@ -1192,6 +1192,189 @@ private final class WorkspaceRemoteDaemonRPCClient {
}
}
enum RemoteLoopbackHTTPRequestRewriter {
private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a])
private static let canonicalLoopbackHost = "localhost"
private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"]
static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data {
guard let headerRange = data.range(of: headerDelimiter) else { return data }
let headerData = Data(data[..<headerRange.upperBound])
guard let headerText = String(data: headerData, encoding: .utf8) else { return data }
var lines = headerText.components(separatedBy: "\r\n")
guard !lines.isEmpty else { return data }
guard let requestLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data }
guard requestLineLooksHTTP(lines[requestLineIndex]) else { return data }
let rewrittenRequestLine = rewriteRequestLine(lines[requestLineIndex], aliasHost: aliasHost)
if rewrittenRequestLine != lines[requestLineIndex] {
lines[requestLineIndex] = rewrittenRequestLine
}
for index in (requestLineIndex + 1)..<lines.count where !lines[index].isEmpty {
lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost)
}
let rewrittenHeaderText = lines.joined(separator: "\r\n")
guard rewrittenHeaderText != headerText else { return data }
return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...]
}
private static func requestLineLooksHTTP(_ requestLine: String) -> Bool {
let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines)
let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? ""
return requestLineMethods.contains(method)
}
private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String {
let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false)
guard parts.count >= 3 else { return requestLine }
var components = URLComponents(string: String(parts[1]))
guard let host = components?.host,
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return requestLine
}
components?.host = canonicalLoopbackHost
guard let rewrittenURL = components?.string else { return requestLine }
var rewritten = parts
rewritten[1] = Substring(rewrittenURL)
let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline }
let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed())
return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia
}
private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String {
guard let colonIndex = line.firstIndex(of: ":") else { return line }
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let valueStart = line.index(after: colonIndex)
let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
switch name {
case "host":
guard let rewrittenHost = rewriteHostValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenHost)"
case "origin", "referer":
guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenURL)"
default:
return line
}
}
private static func rewriteHostValue(_ value: String, aliasHost: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("["),
let closing = trimmed.firstIndex(of: "]") {
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing])
guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
let remainder = String(trimmed[closing...].dropFirst())
return canonicalLoopbackHost + remainder
}
if let colonIndex = trimmed.lastIndex(of: ":"), !trimmed[..<colonIndex].contains(":") {
let host = String(trimmed[..<colonIndex])
guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
return canonicalLoopbackHost + trimmed[colonIndex...]
}
guard BrowserInsecureHTTPSettings.normalizeHost(trimmed) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
return canonicalLoopbackHost
}
private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? {
var components = URLComponents(string: value)
guard let host = components?.host,
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
components?.host = canonicalLoopbackHost
return components?.string
}
}
enum RemoteLoopbackHTTPResponseRewriter {
private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a])
private static let canonicalLoopbackHost = "localhost"
static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data {
guard let headerRange = data.range(of: headerDelimiter) else { return data }
let headerData = Data(data[..<headerRange.upperBound])
guard let headerText = String(data: headerData, encoding: .utf8) else { return data }
var lines = headerText.components(separatedBy: "\r\n")
guard let statusLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data }
guard lines[statusLineIndex].uppercased().hasPrefix("HTTP/") else { return data }
for index in (statusLineIndex + 1)..<lines.count where !lines[index].isEmpty {
lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost)
}
let rewrittenHeaderText = lines.joined(separator: "\r\n")
guard rewrittenHeaderText != headerText else { return data }
return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...]
}
private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String {
guard let colonIndex = line.firstIndex(of: ":") else { return line }
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let valueStart = line.index(after: colonIndex)
let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
switch name {
case "location", "content-location", "origin", "referer", "access-control-allow-origin":
guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenURL)"
case "set-cookie":
guard let rewrittenCookie = rewriteCookieValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenCookie)"
default:
return line
}
}
private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? {
var components = URLComponents(string: value)
guard let host = components?.host,
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else {
return nil
}
components?.host = aliasHost
return components?.string
}
private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? {
let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init)
guard !parts.isEmpty else { return nil }
var didRewrite = false
let rewrittenParts = parts.map { part -> String in
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.lowercased().hasPrefix("domain=") else { return part }
let domainValue = String(trimmed.dropFirst("domain=".count))
guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else {
return part
}
didRewrite = true
let leadingWhitespace = part.prefix { $0.isWhitespace }
return "\(leadingWhitespace)Domain=\(aliasHost)"
}
return didRewrite ? rewrittenParts.joined(separator: ";") : nil
}
}
private final class WorkspaceRemoteDaemonProxyTunnel {
private final class ProxySession {
private static let maxHandshakeBytes = 64 * 1024
@ -1229,6 +1412,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
private var handshakeBuffer = Data()
private var streamID: String?
private var localInputEOF = false
private var rewritesLoopbackHTTPHeaders = false
private var pendingRemoteHTTPHeaderBytes = Data()
private var hasForwardedRemoteHTTPHeaders = false
init(
connection: NWConnection,
@ -1477,6 +1663,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
) {
guard !isClosed else { return }
do {
rewritesLoopbackHTTPHeaders =
BrowserInsecureHTTPSettings.normalizeHost(host)
== BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost)
let targetHost = Self.normalizedProxyTargetHost(host)
let streamID = try rpcClient.openStream(host: targetHost, port: port)
self.streamID = streamID
@ -1501,7 +1690,13 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
guard !localInputEOF || allowAfterEOF else { return }
guard let streamID else { return }
do {
try rpcClient.writeStream(streamID: streamID, data: data)
let outgoingData = rewritesLoopbackHTTPHeaders
? RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
data: data,
aliasHost: Self.remoteLoopbackProxyAliasHost
)
: data
try rpcClient.writeStream(streamID: streamID, data: outgoingData)
} catch {
close(reason: "proxy.write failed: \(error.localizedDescription)")
}
@ -1540,8 +1735,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
return
}
if !readResult.data.isEmpty {
connection.send(content: readResult.data, completion: .contentProcessed { [weak self] error in
let localData = rewriteRemoteResponseIfNeeded(readResult.data, eof: readResult.eof)
if !localData.isEmpty {
connection.send(content: localData, completion: .contentProcessed { [weak self] error in
guard let self else { return }
if let error {
self.close(reason: "proxy client send error: \(error)")
@ -1563,6 +1759,30 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
}
}
private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data {
guard rewritesLoopbackHTTPHeaders else { return data }
guard !data.isEmpty else { return data }
guard !hasForwardedRemoteHTTPHeaders else { return data }
pendingRemoteHTTPHeaderBytes.append(data)
let marker = Data([0x0D, 0x0A, 0x0D, 0x0A])
guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else {
guard eof else { return Data() }
hasForwardedRemoteHTTPHeaders = true
let payload = pendingRemoteHTTPHeaderBytes
pendingRemoteHTTPHeaderBytes = Data()
return payload
}
hasForwardedRemoteHTTPHeaders = true
let payload = pendingRemoteHTTPHeaderBytes
pendingRemoteHTTPHeaderBytes = Data()
return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded(
data: payload,
aliasHost: Self.remoteLoopbackProxyAliasHost
)
}
private func close(reason: String?) {
guard !isClosed else { return }
isClosed = true
@ -2387,39 +2607,80 @@ private final class WorkspaceRemoteCLIRelayServer {
}
func start() throws -> Int {
var capturedError: Error?
var boundPort: Int = 0
queue.sync {
do {
if let localPort {
boundPort = localPort
return
if let existingPort = queue.sync(execute: { localPort }) {
return existingPort
}
let listener = try Self.makeLoopbackListener()
let readySemaphore = DispatchSemaphore(value: 0)
let stateLock = NSLock()
var capturedError: Error?
var boundPort: Int?
listener.newConnectionHandler = { [weak self] connection in
self?.queue.async {
self?.acceptConnectionLocked(connection)
}
}
listener.stateUpdateHandler = { _ in }
listener.stateUpdateHandler = { listenerState in
switch listenerState {
case .ready:
stateLock.lock()
boundPort = listener.port.map { Int($0.rawValue) }
stateLock.unlock()
readySemaphore.signal()
case .failed(let error):
stateLock.lock()
capturedError = error
stateLock.unlock()
readySemaphore.signal()
default:
break
}
}
listener.start(queue: queue)
guard let tcpPort = listener.port?.rawValue else {
let waitResult = readySemaphore.wait(timeout: .now() + 5.0)
stateLock.lock()
let startupError = capturedError
let startupPort = boundPort
stateLock.unlock()
if waitResult != .success {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for local relay listener",
])
}
if let startupError {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
throw startupError
}
guard let startupPort, startupPort > 0 else {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [
NSLocalizedDescriptionKey: "failed to bind local relay listener",
])
}
return try queue.sync {
if let localPort {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
return localPort
}
self.listener = listener
self.localPort = Int(tcpPort)
boundPort = Int(tcpPort)
} catch {
capturedError = error
self.localPort = startupPort
return startupPort
}
}
if let capturedError {
throw capturedError
}
return boundPort
}
func stop() {
queue.sync {
@ -2696,26 +2957,20 @@ private final class WorkspaceRemoteSessionController {
cliRelayServer = relayServer
reverseRelayStderrPipe = stderrPipe
reverseRelayStderrBuffer = ""
writeRemoteRelayDaemonPathLocked(remotePath: remotePath)
do {
try writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken)
} catch {
debugLog("remote.relay.auth.error \(error.localizedDescription)")
stopReverseRelayLocked()
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0)
return
}
writeRemoteSocketAddrLocked(relayPort: relayPort)
debugLog(
"remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " +
"target=\(configuration.displayTarget)"
)
queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in
guard let self else { return }
guard !self.isStopping else { return }
guard self.reverseRelayProcess === process, process.isRunning else { return }
self.writeRemoteRelayDaemonPathLocked(remotePath: remotePath)
do {
try self.writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken)
} catch {
self.debugLog("remote.relay.auth.error \(error.localizedDescription)")
self.stopReverseRelayLocked()
self.scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0)
return
}
self.writeRemoteSocketAddrLocked(relayPort: relayPort)
}
} catch {
debugLog(
"remote.relay.startFailed relayPort=\(relayPort) " +
@ -3177,20 +3432,21 @@ private final class WorkspaceRemoteSessionController {
let platform = try resolveRemotePlatformLocked()
let version = Self.remoteDaemonVersion()
let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch)
let forceDevOverrideInstall = Self.allowLocalDaemonBuildFallback()
debugLog(
"remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " +
"version=\(version) remotePath=\(remotePath)"
"version=\(version) remotePath=\(remotePath) devOverride=\(forceDevOverrideInstall ? 1 : 0)"
)
let hadExistingBinary = try remoteDaemonExistsLocked(remotePath: remotePath)
debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)")
if !hadExistingBinary {
if forceDevOverrideInstall || !hadExistingBinary {
let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version)
try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath)
}
var hello = try helloRemoteDaemonLocked(remotePath: remotePath)
if hadExistingBinary, !hello.capabilities.contains("proxy.stream") {
if !forceDevOverrideInstall, hadExistingBinary, !hello.capabilities.contains("proxy.stream") {
debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))")
let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version)
try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath)
@ -5313,14 +5569,14 @@ final class Workspace: Identifiable, ObservableObject {
if let remoteConfiguration {
payload["destination"] = remoteConfiguration.destination
payload["port"] = remoteConfiguration.port ?? NSNull()
payload["identity_file"] = remoteConfiguration.identityFile ?? NSNull()
payload["ssh_options"] = remoteConfiguration.sshOptions
payload["has_identity_file"] = remoteConfiguration.identityFile != nil
payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty
payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull()
} else {
payload["destination"] = NSNull()
payload["port"] = NSNull()
payload["identity_file"] = NSNull()
payload["ssh_options"] = []
payload["has_identity_file"] = false
payload["has_ssh_options"] = false
payload["local_proxy_port"] = NSNull()
}
return payload
@ -5436,6 +5692,9 @@ final class Workspace: Identifiable, ObservableObject {
guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return }
let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel }
if !hasBrowserPanels {
if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting {
return
}
disconnectRemoteConnection(clearConfiguration: true)
}
}

View file

@ -4828,25 +4828,16 @@ final class SidebarRemoteErrorCopySupportTests: XCTestCase {
)
}
func testParsedTargetAndDetailParsesCanonicalStatusValue() {
let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail(
from: "SSH error (devbox:22): failed to bootstrap daemon"
func testClipboardTextSingleEntryUsesStructuredEntryFields() {
let entry = SidebarRemoteErrorCopyEntry(
workspaceTitle: "alpha",
target: "devbox:22",
detail: "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(
SidebarRemoteErrorCopySupport.clipboardText(for: [entry]),
"SSH error (devbox:22): failed to bootstrap daemon"
)
XCTAssertEqual(parsed?.target, "fallback-host")
XCTAssertEqual(parsed?.detail, "connection refused")
}
func testParsedTargetAndDetailIgnoresNonSSHStatusValues() {
XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good"))
}
}

View file

@ -1,5 +1,6 @@
import XCTest
import AppKit
import WebKit
#if canImport(cmux_DEV)
@testable import cmux_DEV
@ -701,6 +702,92 @@ final class WorkspaceRemoteDaemonManifestTests: XCTestCase {
}
}
final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase {
func testRewritesLoopbackAliasHostHeadersToLocalhost() {
let original = Data(
(
"GET /demo HTTP/1.1\r\n" +
"Host: cmux-loopback.localtest.me:3000\r\n" +
"Origin: http://cmux-loopback.localtest.me:3000\r\n" +
"Referer: http://cmux-loopback.localtest.me:3000/app\r\n" +
"\r\n"
).utf8
)
let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
data: original,
aliasHost: "cmux-loopback.localtest.me"
)
let text = String(decoding: rewritten, as: UTF8.self)
XCTAssertTrue(text.contains("Host: localhost:3000"))
XCTAssertTrue(text.contains("Origin: http://localhost:3000"))
XCTAssertTrue(text.contains("Referer: http://localhost:3000/app"))
XCTAssertFalse(text.contains("cmux-loopback.localtest.me"))
}
func testRewritesAbsoluteFormRequestLineForLoopbackAlias() {
let original = Data(
(
"GET http://cmux-loopback.localtest.me:3000/demo HTTP/1.1\r\n" +
"Host: cmux-loopback.localtest.me:3000\r\n" +
"\r\n"
).utf8
)
let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
data: original,
aliasHost: "cmux-loopback.localtest.me"
)
let text = String(decoding: rewritten, as: UTF8.self)
XCTAssertTrue(text.hasPrefix("GET http://localhost:3000/demo HTTP/1.1\r\n"))
XCTAssertTrue(text.contains("Host: localhost:3000"))
}
func testLeavesNonHTTPPayloadUntouched() {
let original = Data([0x16, 0x03, 0x01, 0x00, 0x2a, 0x01, 0x00])
let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
data: original,
aliasHost: "cmux-loopback.localtest.me"
)
XCTAssertEqual(rewritten, original)
}
func testRewritesLoopbackResponseHeadersBackToAlias() {
let original = Data(
(
"HTTP/1.1 302 Found\r\n" +
"Location: http://localhost:3000/login\r\n" +
"Access-Control-Allow-Origin: http://localhost:3000\r\n" +
"Set-Cookie: sid=1; Domain=localhost; Path=/\r\n" +
"\r\n"
).utf8
)
let rewritten = RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded(
data: original,
aliasHost: "cmux-loopback.localtest.me"
)
let text = String(decoding: rewritten, as: UTF8.self)
XCTAssertTrue(text.contains("Location: http://cmux-loopback.localtest.me:3000/login"))
XCTAssertTrue(text.contains("Access-Control-Allow-Origin: http://cmux-loopback.localtest.me:3000"))
XCTAssertTrue(text.contains("Set-Cookie: sid=1; Domain=cmux-loopback.localtest.me; Path=/"))
}
}
@MainActor
final class BrowserPanelRemoteStoreTests: XCTestCase {
func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() {
let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
let remotePanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: true)
XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertFalse(remotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
}
}
final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase {
func testSupportsMultiplePendingCallsResolvedOutOfOrder() {
let registry = WorkspaceRemoteDaemonPendingCallRegistry()

View file

@ -11,8 +11,9 @@ import Darwin
@MainActor
final class TerminalControllerSocketSecurityTests: XCTestCase {
private func makeSocketPath(_ name: String) -> String {
FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock")
let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8)
return URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("csec-\(name.prefix(4))-\(shortID).sock")
.path
}
@ -106,6 +107,33 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
#endif
}
func testRemoteStatusPayloadOmitsSensitiveSSHConfiguration() {
let tabManager = TabManager()
let workspace = tabManager.addWorkspace(select: false, eagerLoadTerminal: false)
workspace.configureRemoteConnection(
.init(
destination: "example.com",
port: 2222,
identityFile: "/Users/test/.ssh/id_ed25519",
sshOptions: ["ControlMaster=auto", "ControlPersist=600"],
localProxyPort: 1080,
relayPort: 4444,
relayID: "relay-id",
relayToken: "relay-token",
localSocketPath: "/tmp/cmux-test.sock",
terminalStartupCommand: "ssh example.com"
),
autoConnect: false
)
let payload = workspace.remoteStatusPayload()
XCTAssertNil(payload["identity_file"])
XCTAssertNil(payload["ssh_options"])
XCTAssertEqual(payload["has_identity_file"] as? Bool, true)
XCTAssertEqual(payload["has_ssh_options"] as? Bool, true)
}
private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {

View file

@ -165,7 +165,11 @@ func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr fun
cmd := spec.v1Cmd
if !spec.noParams {
parsed := parseFlags(args, spec.flagKeys)
parsed, err := parseFlags(args, spec.flagKeys)
if err != nil {
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
return 2
}
for _, key := range spec.flagKeys {
if val, ok := parsed.flags[key]; ok {
cmd += " " + val
@ -190,7 +194,11 @@ func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool
params := make(map[string]any)
if !spec.noParams {
parsed := parseFlags(args, spec.flagKeys)
parsed, err := parseFlags(args, spec.flagKeys)
if err != nil {
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
return 2
}
// 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 {
@ -292,7 +300,11 @@ func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshA
}
params := make(map[string]any)
parsed := parseFlags(subArgs, flagKeys)
parsed, err := parseFlags(subArgs, flagKeys)
if err != nil {
fmt.Fprintf(os.Stderr, "cmux browser: %v\n", err)
return 2
}
for _, key := range flagKeys {
if val, ok := parsed.flags[key]; ok {
paramKey := flagToParamKey(key)
@ -386,7 +398,7 @@ type parsedFlags struct {
// 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 {
func parseFlags(args []string, keys []string) (parsedFlags, error) {
allowed := make(map[string]bool, len(keys))
for _, k := range keys {
allowed[k] = true
@ -394,20 +406,24 @@ func parseFlags(args []string, keys []string) parsedFlags {
result := parsedFlags{flags: make(map[string]string)}
for i := 0; i < len(args); i++ {
if args[i] == "--" {
result.positional = append(result.positional, args[i+1:]...)
break
}
if !strings.HasPrefix(args[i], "--") {
result.positional = append(result.positional, args[i])
continue
}
key := strings.TrimPrefix(args[i], "--")
if !allowed[key] {
continue
return parsedFlags{}, fmt.Errorf("unknown flag --%s", key)
}
if i+1 < len(args) {
result.flags[key] = args[i+1]
i++
}
}
return result
return result, nil
}
// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback
@ -465,11 +481,11 @@ func currentRelayAuth(socketPath string) *relayAuthState {
// 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, "/") {
conn, err := dialTCPRetry(addr, 15*time.Second, refreshAddr)
conn, connectedAddr, err := dialTCPRetry(addr, 15*time.Second, refreshAddr)
if err != nil {
return nil, err
}
if auth := currentRelayAuth(addr); auth != nil {
if auth := currentRelayAuth(connectedAddr); auth != nil {
if err := authenticateRelayConn(conn, auth); err != nil {
conn.Close()
return nil, err
@ -484,21 +500,21 @@ func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
// 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) {
func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, string, 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
return conn, addr, nil
}
if time.Now().After(deadline) {
return nil, err
return nil, addr, err
}
// Only retry on connection refused (relay not ready yet)
if !isConnectionRefused(err) {
return nil, err
return nil, addr, err
}
if !printed {
fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr)

View file

@ -279,7 +279,7 @@ func TestDialTCPRetrySuccess(t *testing.T) {
conn.Close()
}()
conn, err := dialTCPRetry(addr, 3*time.Second, nil)
conn, _, err := dialTCPRetry(addr, 3*time.Second, nil)
if err != nil {
t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err)
}
@ -296,7 +296,7 @@ func TestDialTCPRetryTimeout(t *testing.T) {
ln.Close()
start := time.Now()
_, err = dialTCPRetry(addr, 600*time.Millisecond, nil)
_, _, err = dialTCPRetry(addr, 600*time.Millisecond, nil)
elapsed := time.Since(start)
if err == nil {
t.Fatal("dialTCPRetry should fail when nothing is listening")
@ -422,7 +422,7 @@ func TestCLICloseWindowV1(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var received string
receivedCh := make(chan string, 1)
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
@ -436,7 +436,7 @@ func TestCLICloseWindowV1(t *testing.T) {
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
received = strings.TrimSpace(string(buf[:n]))
receivedCh <- strings.TrimSpace(string(buf[:n]))
conn.Write([]byte("OK\n"))
conn.Close()
}()
@ -445,9 +445,14 @@ func TestCLICloseWindowV1(t *testing.T) {
if code != 0 {
t.Fatalf("close-window should return 0, got %d", code)
}
select {
case received := <-receivedCh:
if received != "close_window win-42" {
t.Fatalf("expected 'close_window win-42', got %q", received)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for close-window payload")
}
}
func TestCLIListWorkspacesV2(t *testing.T) {
@ -532,7 +537,7 @@ func TestCLIV2FlagMapping(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var receivedParams map[string]any
receivedParamsCh := make(chan map[string]any, 1)
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
@ -548,7 +553,8 @@ func TestCLIV2FlagMapping(t *testing.T) {
n, _ := conn.Read(buf)
var req map[string]any
json.Unmarshal(buf[:n], &req)
receivedParams, _ = req["params"].(map[string]any)
receivedParams, _ := req["params"].(map[string]any)
receivedParamsCh <- receivedParams
resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
@ -559,9 +565,14 @@ func TestCLIV2FlagMapping(t *testing.T) {
if code != 0 {
t.Fatalf("close-workspace should return 0, got %d", code)
}
select {
case receivedParams := <-receivedParamsCh:
if receivedParams["workspace_id"] != "ws-abc" {
t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for close-workspace payload")
}
}
func TestBusyboxArgv0Detection(t *testing.T) {
@ -635,16 +646,24 @@ func TestFlagToParamKey(t *testing.T) {
func TestParseFlags(t *testing.T) {
args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"}
result := parseFlags(args, []string{"workspace", "surface"})
_, err := parseFlags(args, []string{"workspace", "surface"})
if err == nil {
t.Fatal("parseFlags should reject unknown flags")
}
}
func TestParseFlagsCollectsKnownFlagsAndPositionalArgs(t *testing.T) {
args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2"}
result, err := parseFlags(args, []string{"workspace", "surface"})
if err != nil {
t.Fatalf("parseFlags should succeed for known flags: %v", err)
}
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)
}
@ -655,7 +674,7 @@ func TestCLIEnvVarDefaults(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
var receivedParams map[string]any
receivedParamsCh := make(chan map[string]any, 1)
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
@ -671,7 +690,8 @@ func TestCLIEnvVarDefaults(t *testing.T) {
n, _ := conn.Read(buf)
var req map[string]any
json.Unmarshal(buf[:n], &req)
receivedParams, _ = req["params"].(map[string]any)
receivedParams, _ := req["params"].(map[string]any)
receivedParamsCh <- receivedParams
resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}}
payload, _ := json.Marshal(resp)
conn.Write(append(payload, '\n'))
@ -687,10 +707,15 @@ func TestCLIEnvVarDefaults(t *testing.T) {
if code != 0 {
t.Fatalf("close-surface should return 0, got %d", code)
}
select {
case receivedParams := <-receivedParamsCh:
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"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for close-surface payload")
}
}

View file

@ -548,6 +548,7 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
}
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond))
defer conn.SetReadDeadline(time.Time{})
buffer := make([]byte, maxBytes)
n, readErr := conn.Read(buffer)
data := buffer[:max(0, n)]

View file

@ -2,7 +2,7 @@
Last updated: March 12, 2026
Tracking issue: https://github.com/manaflow-ai/cmux/issues/151
Primary PR: https://github.com/manaflow-ai/cmux/pull/239
Primary PR: https://github.com/manaflow-ai/cmux/pull/1296
CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374
This document is the working source of truth for:

View file

@ -68,7 +68,6 @@ DAEMON_ROOT="${REPO_ROOT}/daemon/remote"
mkdir -p "$OUTPUT_DIR"
rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json
RELEASE_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}"
CHECKSUMS_ASSET_NAME="cmuxd-remote-checksums.txt"
CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}"
MANIFEST_PATH="${OUTPUT_DIR}/cmuxd-remote-manifest.json"
@ -80,8 +79,10 @@ TARGETS=(
"linux amd64"
)
declare -a manifest_entries=()
: > "$CHECKSUMS_PATH"
ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")"
trap 'rm -f "$ENTRIES_FILE"' EXIT
: > "$ENTRIES_FILE"
for target in "${TARGETS[@]}"; do
read -r GOOS GOARCH <<<"$target"
@ -102,29 +103,33 @@ for target in "${TARGETS[@]}"; do
SHA256="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')"
printf '%s %s\n' "$SHA256" "$ASSET_NAME" >> "$CHECKSUMS_PATH"
manifest_entries+=("{\"goOS\":\"${GOOS}\",\"goArch\":\"${GOARCH}\",\"assetName\":\"${ASSET_NAME}\",\"downloadURL\":\"${RELEASE_URL}/${ASSET_NAME}\",\"sha256\":\"${SHA256}\"}")
printf '%s\t%s\t%s\t%s\n' "$GOOS" "$GOARCH" "$ASSET_NAME" "$SHA256" >> "$ENTRIES_FILE"
done
ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")"
trap 'rm -f "$ENTRIES_FILE"' EXIT
printf '%s\n' "${manifest_entries[@]}" > "$ENTRIES_FILE"
ENTRIES_JSON="$(python3 - <<'PY' "$ENTRIES_FILE"
python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$REPO" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_FILE"
import json
import sys
import urllib.parse
from pathlib import Path
entries = [json.loads(line) for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() if line.strip()]
print(json.dumps(entries, separators=(",", ":")))
PY
)"
version, release_tag, repo, checksums_asset_name, checksums_path, manifest_path, entries_file = sys.argv[1:]
quoted_tag = urllib.parse.quote(release_tag, safe="")
release_url = f"https://github.com/{repo}/releases/download/{quoted_tag}"
checksums_url = f"{release_url}/{urllib.parse.quote(checksums_asset_name, safe='')}"
python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$RELEASE_URL" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_JSON"
import json
import sys
from pathlib import Path
entries = []
for line in Path(entries_file).read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
go_os, go_arch, asset_name, sha256 = line.split("\t")
entries.append({
"goOS": go_os,
"goArch": go_arch,
"assetName": asset_name,
"downloadURL": f"{release_url}/{urllib.parse.quote(asset_name, safe='')}",
"sha256": sha256,
})
version, release_tag, release_url, checksums_asset_name, checksums_path, manifest_path, entries_json = sys.argv[1:]
checksums_url = f"{release_url}/{checksums_asset_name}"
manifest = {
"schemaVersion": 1,
"appVersion": version,
@ -132,7 +137,7 @@ manifest = {
"releaseURL": release_url,
"checksumsAssetName": checksums_asset_name,
"checksumsURL": checksums_url,
"entries": json.loads(entries_json),
"entries": entries,
}
Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY

View file

@ -14,8 +14,13 @@ import threading
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def _recv_exact(conn: socket.socket, n: int) -> bytes:
def _recv_exact(conn: socket.socket, n: int, pending: bytearray | None = None) -> bytes:
data = bytearray()
if pending:
take = min(len(pending), n)
if take:
data.extend(pending[:take])
del pending[:take]
while len(data) < n:
chunk = conn.recv(n - len(data))
if not chunk:
@ -24,7 +29,7 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes:
return bytes(data)
def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> bytes:
def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> tuple[bytes, bytearray]:
data = bytearray()
while marker not in data:
chunk = conn.recv(1024)
@ -33,21 +38,22 @@ def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> bytes:
data.extend(chunk)
if len(data) > limit:
raise ValueError("header too large")
return bytes(data)
marker_end = data.index(marker) + len(marker)
return bytes(data[:marker_end]), bytearray(data[marker_end:])
def _read_frame(conn: socket.socket) -> tuple[int, bytes]:
first, second = _recv_exact(conn, 2)
def _read_frame(conn: socket.socket, pending: bytearray | None = None) -> tuple[int, bytes]:
first, second = _recv_exact(conn, 2, pending)
opcode = first & 0x0F
masked = (second & 0x80) != 0
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", _recv_exact(conn, 2))[0]
length = struct.unpack("!H", _recv_exact(conn, 2, pending))[0]
elif length == 127:
length = struct.unpack("!Q", _recv_exact(conn, 8))[0]
length = struct.unpack("!Q", _recv_exact(conn, 8, pending))[0]
mask_key = _recv_exact(conn, 4) if masked else b""
payload = _recv_exact(conn, length) if length else b""
mask_key = _recv_exact(conn, 4, pending) if masked else b""
payload = _recv_exact(conn, length, pending) if length else b""
if masked and payload:
payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
return opcode, payload
@ -67,7 +73,7 @@ def _send_frame(conn: socket.socket, opcode: int, payload: bytes) -> None:
def handle_client(conn: socket.socket) -> None:
try:
request = _recv_until(conn, b"\r\n\r\n")
request, pending = _recv_until(conn, b"\r\n\r\n")
headers_raw = request.decode("utf-8", errors="replace").split("\r\n")
header_map: dict[str, str] = {}
for line in headers_raw[1:]:
@ -94,7 +100,7 @@ def handle_client(conn: socket.socket) -> None:
conn.sendall(response.encode("utf-8"))
while True:
opcode, payload = _read_frame(conn)
opcode, payload = _read_frame(conn, pending)
if opcode == 0x8: # close
_send_frame(conn, 0x8, b"")
return

View file

@ -0,0 +1,124 @@
from __future__ import annotations
import re
import secrets
import time
from cmux import cmux, cmuxError
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
def must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def clean_line(raw: str) -> str:
line = OSC_ESCAPE_RE.sub("", raw)
line = ANSI_ESCAPE_RE.sub("", line)
line = line.replace("\r", "")
return line.strip()
def layout_panes(client: cmux) -> list[dict]:
layout_payload = client.layout_debug() or {}
layout = layout_payload.get("layout") or {}
return list(layout.get("panes") or [])
def pane_extent(client: cmux, pane_id: str, axis: str) -> float:
panes = layout_panes(client)
for pane in panes:
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
if pid != pane_id:
continue
frame = pane.get("frame") or {}
return float(frame.get(axis) or 0.0)
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
def workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]:
payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
out: list[tuple[str, bool, int]] = []
for row in payload.get("panes") or []:
out.append((
str(row.get("id") or ""),
bool(row.get("focused")),
int(row.get("surface_count") or 0),
))
return out
def focused_pane_id(client: cmux, workspace_id: str) -> str:
for pane_id, focused, _surface_count in workspace_panes(client, workspace_id):
if focused:
return pane_id
raise cmuxError("No focused pane found")
def surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
payload = client._call(
"surface.read_text",
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
) or {}
return str(payload.get("text") or "")
def surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
text = surface_scrollback_text(client, workspace_id, surface_id)
return [clean_line(raw) for raw in text.splitlines()]
def scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
return token in surface_scrollback_lines(client, workspace_id, surface_id)
def wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None:
for _attempt in range(1, 5):
token = f"CMUX_READY_{secrets.token_hex(4)}"
client.send_surface(surface_id, f"echo {token}\n")
try:
wait_for(
lambda: scrollback_has_exact_line(client, workspace_id, surface_id, token),
timeout_s=2.5,
)
return
except cmuxError:
time.sleep(0.1)
raise cmuxError("Timed out waiting for surface command roundtrip")
def pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
panes = [p for p in layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
if len(panes) < 2:
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
def x_of(p: dict) -> float:
return float((p.get("frame") or {}).get("x") or 0.0)
def y_of(p: dict) -> float:
return float((p.get("frame") or {}).get("y") or 0.0)
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
if x_span >= y_span:
left_pane = min(panes, key=x_of)
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
return ("right" if target_pane == left_id else "left"), "width"
top_pane = min(panes, key=y_of)
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
return ("down" if target_pane == top_id else "up"), "height"

View file

@ -67,6 +67,7 @@ def main() -> int:
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_env.pop("CMUX_SOCKET", 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}")

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Regression: sidebar metadata CLI commands still dispatch through the public cmux CLI."""
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 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 _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_cli(cli: str, args: list[str]) -> str:
proc = subprocess.run(
[cli, "--socket", SOCKET_PATH, *args],
capture_output=True,
text=True,
check=False,
env=dict(os.environ),
)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}")
return proc.stdout.strip()
def main() -> int:
cli = _find_cli_binary()
workspace_id = ""
try:
with cmux(SOCKET_PATH) as client:
workspace_id = client.new_workspace()
status_response = _run_cli(cli, ["set-status", "build", "compiling", "--workspace", workspace_id])
_must(status_response.startswith("OK"), f"set-status should succeed, got {status_response!r}")
status_list = _run_cli(cli, ["list-status", "--workspace", workspace_id])
_must("build=compiling" in status_list, f"list-status should include the inserted status entry: {status_list!r}")
progress_response = _run_cli(cli, ["set-progress", "0.5", "--workspace", workspace_id, "--label", "Building"])
_must(progress_response.startswith("OK"), f"set-progress should succeed, got {progress_response!r}")
log_response = _run_cli(cli, ["log", "--workspace", workspace_id, "--", "ship it"])
_must(log_response.startswith("OK"), f"log should succeed, got {log_response!r}")
log_list = _run_cli(cli, ["list-log", "--workspace", workspace_id, "--limit", "5"])
_must("ship it" in log_list, f"list-log should include the appended log entry: {log_list!r}")
sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id])
_must("status_count=1" in sidebar_state, f"sidebar-state should include the status entry count: {sidebar_state!r}")
_must("progress=0.50 Building" in sidebar_state, f"sidebar-state should include the progress label: {sidebar_state!r}")
_must("[info] ship it" in sidebar_state, f"sidebar-state should include the recent log entry: {sidebar_state!r}")
clear_status_response = _run_cli(cli, ["clear-status", "build", "--workspace", workspace_id])
_must(clear_status_response.startswith("OK"), f"clear-status should succeed, got {clear_status_response!r}")
clear_progress_response = _run_cli(cli, ["clear-progress", "--workspace", workspace_id])
_must(clear_progress_response.startswith("OK"), f"clear-progress should succeed, got {clear_progress_response!r}")
clear_log_response = _run_cli(cli, ["clear-log", "--workspace", workspace_id])
_must(clear_log_response.startswith("OK"), f"clear-log should succeed, got {clear_log_response!r}")
cleared_sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id])
_must("status_count=0" in cleared_sidebar_state, f"sidebar-state should clear status entries: {cleared_sidebar_state!r}")
_must("progress=none" in cleared_sidebar_state, f"sidebar-state should clear progress: {cleared_sidebar_state!r}")
_must("log_count=0" in cleared_sidebar_state, f"sidebar-state should clear log entries: {cleared_sidebar_state!r}")
client.close_workspace(workspace_id)
workspace_id = ""
finally:
if workspace_id:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
print("PASS: sidebar metadata CLI commands dispatch and update workspace state")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -4,7 +4,6 @@
from __future__ import annotations
import os
import re
import secrets
import shlex
import shutil
@ -15,97 +14,20 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
from pane_resize_test_support import (
clean_line as _clean_line,
focused_pane_id as _focused_pane_id,
pane_extent as _pane_extent,
pick_resize_direction_for_pane as _pick_resize_direction_for_pane,
scrollback_has_exact_line as _scrollback_has_exact_line,
surface_scrollback_text as _surface_scrollback_text,
wait_for as _wait_for,
wait_for_surface_command_roundtrip as _wait_for_surface_command_roundtrip,
workspace_panes as _workspace_panes,
)
DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _clean_line(raw: str) -> str:
line = OSC_ESCAPE_RE.sub("", raw)
line = ANSI_ESCAPE_RE.sub("", line)
line = line.replace("\r", "")
return line.strip()
def _layout_panes(client: cmux) -> list[dict]:
layout_payload = client.layout_debug() or {}
layout = layout_payload.get("layout") or {}
return list(layout.get("panes") or [])
def _pane_extent(client: cmux, pane_id: str, axis: str) -> float:
panes = _layout_panes(client)
for pane in panes:
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
if pid != pane_id:
continue
frame = pane.get("frame") or {}
return float(frame.get(axis) or 0.0)
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]:
payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
out: list[tuple[str, bool, int]] = []
for row in payload.get("panes") or []:
out.append((
str(row.get("id") or ""),
bool(row.get("focused")),
int(row.get("surface_count") or 0),
))
return out
def _focused_pane_id(client: cmux, workspace_id: str) -> str:
for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id):
if focused:
return pane_id
raise cmuxError("No focused pane found")
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
payload = client._call(
"surface.read_text",
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
) or {}
return str(payload.get("text") or "")
def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
text = _surface_scrollback_text(client, workspace_id, surface_id)
lines = [_clean_line(raw) for raw in text.splitlines()]
return token in lines
def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None:
for _attempt in range(1, 5):
token = f"CMUX_READY_{secrets.token_hex(4)}"
client.send_surface(surface_id, f"echo {token}\n")
try:
_wait_for(
lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token),
timeout_s=2.5,
)
return
except cmuxError:
time.sleep(0.1)
raise cmuxError("Timed out waiting for surface command roundtrip")
def _has_exact_marker_lines(
@ -120,30 +42,6 @@ def _has_exact_marker_lines(
return start_marker in lines and end_marker in lines
def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
if len(panes) < 2:
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
def x_of(p: dict) -> float:
return float((p.get("frame") or {}).get("x") or 0.0)
def y_of(p: dict) -> float:
return float((p.get("frame") or {}).get("y") or 0.0)
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
if x_span >= y_span:
left_pane = min(panes, key=x_of)
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
return ("right" if target_pane == left_id else "left"), "width"
top_pane = min(panes, key=y_of)
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
return ("down" if target_pane == top_id else "up"), "height"
def _extract_segment_lines(
text: str,
start_marker: str,

View file

@ -4,132 +4,26 @@
from __future__ import annotations
import os
import re
import secrets
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
from pane_resize_test_support import (
focused_pane_id as _focused_pane_id,
pane_extent as _pane_extent,
pick_resize_direction_for_pane as _pick_resize_direction_for_pane,
scrollback_has_exact_line as _scrollback_has_exact_line,
surface_scrollback_lines as _surface_scrollback_lines,
wait_for as _wait_for,
wait_for_surface_command_roundtrip as _wait_for_surface_command_roundtrip,
workspace_panes as _workspace_panes,
must as _must,
)
DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _clean_line(raw: str) -> str:
line = OSC_ESCAPE_RE.sub("", raw)
line = ANSI_ESCAPE_RE.sub("", line)
line = line.replace("\r", "")
return line.strip()
def _layout_panes(client: cmux) -> list[dict]:
layout_payload = client.layout_debug() or {}
layout = layout_payload.get("layout") or {}
return list(layout.get("panes") or [])
def _pane_extent(client: cmux, pane_id: str, axis: str) -> float:
panes = _layout_panes(client)
for pane in panes:
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
if pid != pane_id:
continue
frame = pane.get("frame") or {}
return float(frame.get(axis) or 0.0)
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]:
payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
out: list[tuple[str, bool, int]] = []
for row in payload.get("panes") or []:
out.append((
str(row.get("id") or ""),
bool(row.get("focused")),
int(row.get("surface_count") or 0),
))
return out
def _focused_pane_id(client: cmux, workspace_id: str) -> str:
for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id):
if focused:
return pane_id
raise cmuxError("No focused pane found")
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
payload = client._call(
"surface.read_text",
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
) or {}
return str(payload.get("text") or "")
def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
text = _surface_scrollback_text(client, workspace_id, surface_id)
return [_clean_line(raw) for raw in text.splitlines()]
def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
return token in _surface_scrollback_lines(client, workspace_id, surface_id)
def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None:
for _attempt in range(1, 5):
token = f"CMUX_READY_{secrets.token_hex(4)}"
client.send_surface(surface_id, f"echo {token}\n")
try:
_wait_for(
lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token),
timeout_s=2.5,
)
return
except cmuxError:
time.sleep(0.1)
raise cmuxError("Timed out waiting for surface command roundtrip")
def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
if len(panes) < 2:
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
def x_of(p: dict) -> float:
return float((p.get("frame") or {}).get("x") or 0.0)
def y_of(p: dict) -> float:
return float((p.get("frame") or {}).get("y") or 0.0)
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
if x_span >= y_span:
left_pane = min(panes, key=x_of)
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
return ("right" if target_pane == left_id else "left"), "width"
top_pane = min(panes, key=y_of)
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
return ("down" if target_pane == top_id else "up"), "height"
def _run_once(socket_path: str) -> int:

View file

@ -74,15 +74,6 @@ def _extract_control_path(ssh_command: str) -> str:
return match.group(1) if match else ""
def _has_ssh_option_key(options: list[str], key: str) -> bool:
lowered_key = key.lower()
for option in options:
token = re.split(r"[=\s]+", str(option).strip(), maxsplit=1)[0].strip().lower()
if token == lowered_key:
return True
return False
def _read_any_terminal_text(client: cmux, workspace_id: str, timeout: float = 8.0) -> str | None:
deadline = time.time() + timeout
last_exc: Exception | None = None
@ -187,12 +178,36 @@ def main() -> int:
_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}",
"RemoteCommand=/bin/sh -lc " in ssh_command,
f"cmux ssh should route RemoteCommand through /bin/sh for non-POSIX login shells: {ssh_command!r}",
)
_must(
f"export PATH=\"$HOME/.cmux/bin:$PATH\"" in ssh_command,
f"cmux ssh should still prepend the remote cmux wrapper path: {ssh_command!r}",
)
_must(
f"export CMUX_SOCKET_PATH=127.0.0.1:{int(remote_relay_port)}" in ssh_command,
f"cmux ssh should still pin the relay socket path in RemoteCommand: {ssh_command!r}",
)
_must(
"case \"${CMUX_LOGIN_SHELL##*/}\" in" in ssh_command,
f"cmux ssh should still branch on the user's login shell when possible: {ssh_command!r}",
)
_must(
"cat > \"$cmux_shell_dir/.zshrc\"" in ssh_command,
f"cmux ssh should install a post-rc zsh wrapper so the remote cmux wrapper stays first on PATH: {ssh_command!r}",
)
_must(
"cmux_wait_attempt=0" in ssh_command,
f"cmux ssh should wait briefly for the authenticated relay before showing the remote shell: {ssh_command!r}",
)
_must(
"exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i" in ssh_command,
f"cmux ssh should still support bash login shells with a post-rc wrapper file: {ssh_command!r}",
)
_must(
"exec \"$CMUX_LOGIN_SHELL\" -i" in ssh_command,
f"cmux ssh should still hand off to the user's interactive login shell when possible: {ssh_command!r}",
)
listed_row = None
@ -221,18 +236,17 @@ def main() -> int:
str(proxy.get("state") or "") in {"connecting", "ready", "error", "unavailable"},
f"remote payload should include proxy state metadata: {remote}",
)
remote_ssh_options = [str(item) for item in (remote.get("ssh_options") or [])]
_must(
_has_ssh_option_key(remote_ssh_options, "ControlMaster"),
f"workspace.remote.configure should include ControlMaster default: {remote}",
"ssh_options" not in remote,
f"workspace remote payload should not expose raw ssh_options: {remote}",
)
_must(
_has_ssh_option_key(remote_ssh_options, "ControlPersist"),
f"workspace.remote.configure should include ControlPersist default: {remote}",
"identity_file" not in remote,
f"workspace remote payload should not expose identity_file: {remote}",
)
_must(
_has_ssh_option_key(remote_ssh_options, "ControlPath"),
f"workspace.remote.configure should include ControlPath default: {remote}",
bool(remote.get("has_ssh_options")) is True,
f"workspace remote payload should indicate ssh options are configured: {remote}",
)
# Regression: cmux ssh should launch through initial_command, not visibly type a giant command into the shell.
terminal_text = _read_any_terminal_text(client, workspace_id)
@ -352,10 +366,13 @@ def main() -> int:
f"ssh command should not force default StrictHostKeyChecking when override is supplied: {ssh_command_strict_override!r}",
)
strict_override_remote = payload_strict_override.get("remote") or {}
strict_override_options = [str(item) for item in (strict_override_remote.get("ssh_options") or [])]
_must(
any(item.lower() == "stricthostkeychecking=no" for item in strict_override_options),
f"workspace.remote.configure should preserve explicit StrictHostKeyChecking override: {strict_override_remote}",
"ssh_options" not in strict_override_remote,
f"workspace remote payload should not expose raw ssh_options: {strict_override_remote}",
)
_must(
bool(strict_override_remote.get("has_ssh_options")) is True,
f"workspace remote payload should indicate ssh options are configured: {strict_override_remote}",
)
payload_case_override = _run_cli_json(
@ -420,38 +437,13 @@ def main() -> int:
f"ssh command should include exactly one ControlPath when lowercase override is supplied: {ssh_command_case_override!r}",
)
case_override_remote = payload_case_override.get("remote") or {}
case_override_options = [str(item) for item in (case_override_remote.get("ssh_options") or [])]
_must(
any(item.lower() == "stricthostkeychecking=no" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase StrictHostKeyChecking override: {case_override_remote}",
"ssh_options" not in case_override_remote,
f"workspace remote payload should not expose raw ssh_options: {case_override_remote}",
)
_must(
not any(item.lower() == "stricthostkeychecking=accept-new" for item in case_override_options),
f"workspace.remote.configure should not inject default StrictHostKeyChecking when lowercase override is supplied: {case_override_remote}",
)
_must(
any(item.lower() == "controlmaster=no" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase ControlMaster override: {case_override_remote}",
)
_must(
not any(item.lower() == "controlmaster=auto" for item in case_override_options),
f"workspace.remote.configure should not inject default ControlMaster when lowercase override is supplied: {case_override_remote}",
)
_must(
any(item.lower() == "controlpersist=0" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase ControlPersist override: {case_override_remote}",
)
_must(
not any(item.lower() == "controlpersist=600" for item in case_override_options),
f"workspace.remote.configure should not inject default ControlPersist when lowercase override is supplied: {case_override_remote}",
)
_must(
any(item.lower() == "controlpath=/tmp/cmux-ssh-%c-custom" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase ControlPath override: {case_override_remote}",
)
_must(
sum(1 for item in case_override_options if item.lower().startswith("controlpath=")) == 1,
f"workspace.remote.configure should include exactly one ControlPath when lowercase override is supplied: {case_override_remote}",
bool(case_override_remote.get("has_ssh_options")) is True,
f"workspace remote payload should indicate ssh options are configured: {case_override_remote}",
)
payload3 = _run_cli_json(
@ -475,7 +467,7 @@ def main() -> int:
except Exception:
pass
invalid_proxy_port_workspace = client._call("workspace.create", {"initial_command": "echo invalid-local-proxy-port"}) or {}
invalid_proxy_port_workspace = client._call("workspace.create", {}) or {}
workspace_id_invalid_proxy_port = str(invalid_proxy_port_workspace.get("workspace_id") or "")
if workspace_id_invalid_proxy_port:
workspaces_to_close.append(workspace_id_invalid_proxy_port)

View file

@ -207,7 +207,7 @@ def main() -> int:
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}")
_must(1 <= remote_relay_port <= 65535, f"remote_relay_port should be a valid TCP port: {remote_relay_port}")
remote_socket_addr = f"127.0.0.1:{remote_relay_port}"
startup_cmd = str(payload.get("ssh_startup_command") or "")
_must(
@ -288,7 +288,7 @@ def main() -> int:
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(1 <= remote_relay_port_2 <= 65535, f"second remote_relay_port should be a valid TCP port: {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}",

View file

@ -70,6 +70,8 @@ def _as_int(value: object, field: str) -> int:
if isinstance(value, int):
return value
if isinstance(value, float):
if not value.is_integer():
raise cmuxError(f"{field} should be an integer value, got float {value!r}")
return int(value)
raise cmuxError(f"{field} has unexpected type {type(value).__name__}: {value!r}")

View file

@ -185,10 +185,10 @@ def main() -> int:
host = f"root@{DOCKER_SSH_HOST}"
_wait_for_ssh(host, host_ssh_port, key_path)
conflict_port = _find_free_loopback_port()
conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
conflict_listener.bind(("127.0.0.1", conflict_port))
conflict_listener.bind(("127.0.0.1", 0))
conflict_port = int(conflict_listener.getsockname()[1])
conflict_listener.listen(1)
with cmux(SOCKET_PATH) as client:

View file

@ -131,6 +131,10 @@ def main() -> int:
second = _run_cli_json(cli, ["ssh", SSH_HOST])
second_workspace_id = _workspace_id_from_payload(client, second)
_must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}")
_must(
second_workspace_id != first_workspace_id,
f"second cmux ssh should create a distinct workspace: {first_workspace_id} vs {second_workspace_id}",
)
workspace_ids.append(second_workspace_id)
_wait_remote_ready(client, second_workspace_id)