From 2e6856ff2f6975ef7036ba63f6de39c91df9c1ad Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:14:52 -0700 Subject: [PATCH] Fix ssh stack review regressions --- CLI/cmux.swift | 236 +++++++++-- Resources/Localizable.xcstrings | 68 ++++ Sources/AppDelegate.swift | 25 +- Sources/ContentView.swift | 86 ++-- Sources/GhosttyTerminalView.swift | 8 +- Sources/Panels/BrowserPanel.swift | 35 +- Sources/TabManager.swift | 22 +- Sources/Workspace.swift | 367 +++++++++++++++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 25 +- cmuxTests/GhosttyConfigTests.swift | 87 +++++ ...erminalControllerSocketSecurityTests.swift | 32 +- daemon/remote/cmd/cmuxd-remote/cli.go | 40 +- daemon/remote/cmd/cmuxd-remote/cli_test.go | 67 +++- daemon/remote/cmd/cmuxd-remote/main.go | 1 + docs/remote-daemon-spec.md | 2 +- scripts/build_remote_daemon_release_assets.sh | 41 +- tests/fixtures/ssh-remote/ws_echo.py | 28 +- tests_v2/pane_resize_test_support.py | 124 ++++++ ..._cli_global_flags_and_v1_error_contract.py | 1 + .../test_cli_sidebar_metadata_commands.py | 113 ++++++ ...est_pane_resize_preserves_ls_scrollback.py | 124 +----- ...t_pane_resize_preserves_visible_content.py | 128 +----- tests_v2/test_ssh_remote_cli_metadata.py | 102 +++-- tests_v2/test_ssh_remote_cli_relay.py | 4 +- .../test_ssh_remote_daemon_resize_stdio.py | 2 + .../test_ssh_remote_proxy_bind_conflict.py | 4 +- ...sh_remote_second_session_mux_regression.py | 4 + 27 files changed, 1270 insertions(+), 506 deletions(-) create mode 100644 tests_v2/pane_resize_test_support.py create mode 100644 tests_v2/test_cli_sidebar_metadata_commands.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6329c5d4..23ad3071 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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", + ] + let zshEnvLines = [ + "export CMUX_REAL_ZDOTDIR=\"${CMUX_REAL_ZDOTDIR:-$HOME}\"", + "[ -f \"$HOME/.zshenv\" ] && source \"$HOME/.zshenv\"", ] - .compactMap { $0 } - .joined(separator: "; ") + 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 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 137f9f92..8207735c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e441d37c..885555e9 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11197,12 +11197,17 @@ 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 } - return nil + if cmuxAllowsPortalSlotTextEntryFocus(view) { + return nil + } + return portalWebView } current = candidate.superview } @@ -11210,6 +11215,20 @@ private extension NSWindow { 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? diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 29fdf434..341b8ba3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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 = 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")) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5b4db687..6c75cb51 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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 } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b2927a8a..a6d331bb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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 } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2455e8d5..a7f6e3fa 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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 } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 496ebeb2..590abf37 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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[.. 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[.. 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).. 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[.. String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[.. 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,38 +2607,79 @@ private final class WorkspaceRemoteCLIRelayServer { } func start() throws -> Int { + 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 = 0 - queue.sync { - do { - if let localPort { - boundPort = localPort - return - } - let listener = try Self.makeLoopbackListener() - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { _ in } - listener.start(queue: queue) - guard let tcpPort = listener.port?.rawValue else { - throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "failed to bind local relay listener", - ]) - } - self.listener = listener - self.localPort = Int(tcpPort) - boundPort = Int(tcpPort) - } catch { - capturedError = error + var boundPort: Int? + + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) } } - if let capturedError { - throw capturedError + 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) + + 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 = startupPort + return startupPort } - return boundPort } func stop() { @@ -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) } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index bbe59232..619db448 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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")) } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index d7a4b136..d1b8faa1 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -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() diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index ccf3f116..48127765 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -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 { diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index 14d69481..e0a15118 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -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) diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index 32d08280..d9a09390 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -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,8 +445,13 @@ func TestCLICloseWindowV1(t *testing.T) { if code != 0 { t.Fatalf("close-window should return 0, got %d", code) } - if received != "close_window win-42" { - t.Fatalf("expected 'close_window win-42', got %q", received) + 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") } } @@ -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,8 +565,13 @@ func TestCLIV2FlagMapping(t *testing.T) { if code != 0 { t.Fatalf("close-workspace should return 0, got %d", code) } - if receivedParams["workspace_id"] != "ws-abc" { - t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + 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") } } @@ -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) } - 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"]) + 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") } } diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 22db25a3..021c8e6a 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -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)] diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 03aaa248..57e0f443 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -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: diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh index a6be6fc6..c5d9502b 100755 --- a/scripts/build_remote_daemon_release_assets.sh +++ b/scripts/build_remote_daemon_release_assets.sh @@ -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 diff --git a/tests/fixtures/ssh-remote/ws_echo.py b/tests/fixtures/ssh-remote/ws_echo.py index 4acb8935..ec857287 100644 --- a/tests/fixtures/ssh-remote/ws_echo.py +++ b/tests/fixtures/ssh-remote/ws_echo.py @@ -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 diff --git a/tests_v2/pane_resize_test_support.py b/tests_v2/pane_resize_test_support.py new file mode 100644 index 00000000..4b55bbde --- /dev/null +++ b/tests_v2/pane_resize_test_support.py @@ -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" diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py index e09741fd..badc306a 100644 --- a/tests_v2/test_cli_global_flags_and_v1_error_contract.py +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -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}") diff --git a/tests_v2/test_cli_sidebar_metadata_commands.py b/tests_v2/test_cli_sidebar_metadata_commands.py new file mode 100644 index 00000000..142ce093 --- /dev/null +++ b/tests_v2/test_cli_sidebar_metadata_commands.py @@ -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()) diff --git a/tests_v2/test_pane_resize_preserves_ls_scrollback.py b/tests_v2/test_pane_resize_preserves_ls_scrollback.py index 0eb450d2..88c7511d 100644 --- a/tests_v2/test_pane_resize_preserves_ls_scrollback.py +++ b/tests_v2/test_pane_resize_preserves_ls_scrollback.py @@ -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, diff --git a/tests_v2/test_pane_resize_preserves_visible_content.py b/tests_v2/test_pane_resize_preserves_visible_content.py index ea175d0c..a249679b 100644 --- a/tests_v2/test_pane_resize_preserves_visible_content.py +++ b/tests_v2/test_pane_resize_preserves_visible_content.py @@ -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: diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 0b3aabfc..9764da35 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -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) diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py index 53e01a95..2d2adf6a 100644 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -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}", diff --git a/tests_v2/test_ssh_remote_daemon_resize_stdio.py b/tests_v2/test_ssh_remote_daemon_resize_stdio.py index d11cb845..f91a6175 100644 --- a/tests_v2/test_ssh_remote_daemon_resize_stdio.py +++ b/tests_v2/test_ssh_remote_daemon_resize_stdio.py @@ -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}") diff --git a/tests_v2/test_ssh_remote_proxy_bind_conflict.py b/tests_v2/test_ssh_remote_proxy_bind_conflict.py index d47e2957..4828c20e 100644 --- a/tests_v2/test_ssh_remote_proxy_bind_conflict.py +++ b/tests_v2/test_ssh_remote_proxy_bind_conflict.py @@ -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: diff --git a/tests_v2/test_ssh_remote_second_session_mux_regression.py b/tests_v2/test_ssh_remote_second_session_mux_regression.py index c521485c..d17b23ae 100644 --- a/tests_v2/test_ssh_remote_second_session_mux_regression.py +++ b/tests_v2/test_ssh_remote_second_session_mux_regression.py @@ -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)