Fix ssh stack review regressions
This commit is contained in:
parent
19b59cae37
commit
2e6856ff2f
27 changed files with 1270 additions and 506 deletions
236
CLI/cmux.swift
236
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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -92,27 +92,23 @@ enum SidebarRemoteErrorCopySupport {
|
|||
static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
|
||||
guard !entries.isEmpty else { return nil }
|
||||
if entries.count == 1, let entry = entries.first {
|
||||
return "SSH error (\(entry.target)): \(entry.detail)"
|
||||
return String.localizedStringWithFormat(
|
||||
String(localized: "clipboard.sshError.single", defaultValue: "SSH error (%@): %@"),
|
||||
entry.target,
|
||||
entry.detail
|
||||
)
|
||||
}
|
||||
|
||||
return entries.enumerated().map { index, entry in
|
||||
"\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)"
|
||||
String.localizedStringWithFormat(
|
||||
String(localized: "clipboard.sshError.item", defaultValue: "%lld. %@ (%@): %@"),
|
||||
Int64(index + 1),
|
||||
entry.workspaceTitle,
|
||||
entry.target,
|
||||
entry.detail
|
||||
)
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("SSH error") else { return nil }
|
||||
|
||||
if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) {
|
||||
return (String(match.1), String(match.2))
|
||||
}
|
||||
if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) {
|
||||
guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil }
|
||||
return (fallbackTarget, String(match.1))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor {
|
||||
|
|
@ -7220,6 +7216,13 @@ struct VerticalTabsSidebar: View {
|
|||
|
||||
LazyVStack(spacing: tabRowSpacing) {
|
||||
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
|
||||
let selectedContextIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id]
|
||||
let contextTargetIds = tabManager.tabs.compactMap { workspace in
|
||||
selectedContextIds.contains(workspace.id) ? workspace.id : nil
|
||||
}
|
||||
let remoteContextMenuTargets = tabManager.tabs.filter { workspace in
|
||||
contextTargetIds.contains(workspace.id) && workspace.isRemoteWorkspace
|
||||
}
|
||||
TabItemView(
|
||||
tabManager: tabManager,
|
||||
notificationStore: notificationStore,
|
||||
|
|
@ -7241,7 +7244,10 @@ struct VerticalTabsSidebar: View {
|
|||
showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed,
|
||||
dragAutoScrollController: dragAutoScrollController,
|
||||
draggedTabId: $draggedTabId,
|
||||
dropIndicator: $dropIndicator
|
||||
dropIndicator: $dropIndicator,
|
||||
remoteContextMenuWorkspaceIds: remoteContextMenuTargets.map(\.id),
|
||||
allRemoteContextMenuTargetsConnecting: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .connecting },
|
||||
allRemoteContextMenuTargetsDisconnected: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected }
|
||||
)
|
||||
.equatable()
|
||||
}
|
||||
|
|
@ -9497,7 +9503,10 @@ private struct TabItemView: View, Equatable {
|
|||
lhs.unreadCount == rhs.unreadCount &&
|
||||
lhs.latestNotificationText == rhs.latestNotificationText &&
|
||||
lhs.rowSpacing == rhs.rowSpacing &&
|
||||
lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints
|
||||
lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints &&
|
||||
lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds &&
|
||||
lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting &&
|
||||
lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected
|
||||
}
|
||||
|
||||
// Use plain references instead of @EnvironmentObject to avoid subscribing
|
||||
|
|
@ -9520,6 +9529,9 @@ private struct TabItemView: View, Equatable {
|
|||
let dragAutoScrollController: SidebarDragAutoScrollController
|
||||
@Binding var draggedTabId: UUID?
|
||||
@Binding var dropIndicator: SidebarDropIndicator?
|
||||
let remoteContextMenuWorkspaceIds: [UUID]
|
||||
let allRemoteContextMenuTargetsConnecting: Bool
|
||||
let allRemoteContextMenuTargetsDisconnected: Bool
|
||||
@State private var isHovering = false
|
||||
@State private var rowHeight: CGFloat = 1
|
||||
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
||||
|
|
@ -9645,15 +9657,28 @@ private struct TabItemView: View, Equatable {
|
|||
}
|
||||
|
||||
private var copyableSidebarSSHError: String? {
|
||||
let fallbackTarget = tab.remoteDisplayTarget ?? String(
|
||||
localized: "sidebar.remote.help.targetFallback",
|
||||
defaultValue: "remote host"
|
||||
)
|
||||
let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty {
|
||||
let target = tab.remoteDisplayTarget ?? "unknown"
|
||||
return "SSH error (\(target)): \(trimmedDetail)"
|
||||
let entry = SidebarRemoteErrorCopyEntry(
|
||||
workspaceTitle: tab.title,
|
||||
target: fallbackTarget,
|
||||
detail: trimmedDetail
|
||||
)
|
||||
return SidebarRemoteErrorCopySupport.clipboardText(for: [entry])
|
||||
}
|
||||
if let statusValue = tab.statusEntries["remote.error"]?.value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!statusValue.isEmpty {
|
||||
return statusValue
|
||||
let entry = SidebarRemoteErrorCopyEntry(
|
||||
workspaceTitle: tab.title,
|
||||
target: fallbackTarget,
|
||||
detail: statusValue
|
||||
)
|
||||
return SidebarRemoteErrorCopySupport.clipboardText(for: [entry])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -10080,14 +10105,19 @@ private struct TabItemView: View, Equatable {
|
|||
isMulti ? multi : single
|
||||
}
|
||||
|
||||
private func remoteContextMenuWorkspaces() -> [Workspace] {
|
||||
guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] }
|
||||
return remoteContextMenuWorkspaceIds.compactMap { workspaceId in
|
||||
tabManager.tabs.first(where: { $0.id == workspaceId })
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceContextMenu: some View {
|
||||
let targetIds = contextTargetIds()
|
||||
let isMulti = targetIds.count > 1
|
||||
let tabColorPalette = WorkspaceTabColorSettings.palette()
|
||||
let shouldPin = !tab.isPinned
|
||||
let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) }
|
||||
let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace }
|
||||
let reconnectLabel = contextMenuLabel(
|
||||
multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"),
|
||||
single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"),
|
||||
|
|
@ -10145,22 +10175,22 @@ private struct TabItemView: View, Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
if !remoteTargetWorkspaces.isEmpty {
|
||||
if !remoteContextMenuWorkspaceIds.isEmpty {
|
||||
Divider()
|
||||
|
||||
Button(reconnectLabel) {
|
||||
for workspace in remoteTargetWorkspaces {
|
||||
for workspace in remoteContextMenuWorkspaces() {
|
||||
workspace.reconnectRemoteConnection()
|
||||
}
|
||||
}
|
||||
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting })
|
||||
.disabled(allRemoteContextMenuTargetsConnecting)
|
||||
|
||||
Button(disconnectLabel) {
|
||||
for workspace in remoteTargetWorkspaces {
|
||||
for workspace in remoteContextMenuWorkspaces() {
|
||||
workspace.disconnectRemoteConnection(clearConfiguration: false)
|
||||
}
|
||||
}
|
||||
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected })
|
||||
.disabled(allRemoteContextMenuTargetsDisconnected)
|
||||
}
|
||||
|
||||
Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1192,6 +1192,189 @@ private final class WorkspaceRemoteDaemonRPCClient {
|
|||
}
|
||||
}
|
||||
|
||||
enum RemoteLoopbackHTTPRequestRewriter {
|
||||
private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a])
|
||||
private static let canonicalLoopbackHost = "localhost"
|
||||
private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"]
|
||||
|
||||
static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data {
|
||||
guard let headerRange = data.range(of: headerDelimiter) else { return data }
|
||||
let headerData = Data(data[..<headerRange.upperBound])
|
||||
guard let headerText = String(data: headerData, encoding: .utf8) else { return data }
|
||||
|
||||
var lines = headerText.components(separatedBy: "\r\n")
|
||||
guard !lines.isEmpty else { return data }
|
||||
guard let requestLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data }
|
||||
guard requestLineLooksHTTP(lines[requestLineIndex]) else { return data }
|
||||
|
||||
let rewrittenRequestLine = rewriteRequestLine(lines[requestLineIndex], aliasHost: aliasHost)
|
||||
if rewrittenRequestLine != lines[requestLineIndex] {
|
||||
lines[requestLineIndex] = rewrittenRequestLine
|
||||
}
|
||||
|
||||
for index in (requestLineIndex + 1)..<lines.count where !lines[index].isEmpty {
|
||||
lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost)
|
||||
}
|
||||
|
||||
let rewrittenHeaderText = lines.joined(separator: "\r\n")
|
||||
guard rewrittenHeaderText != headerText else { return data }
|
||||
return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...]
|
||||
}
|
||||
|
||||
private static func requestLineLooksHTTP(_ requestLine: String) -> Bool {
|
||||
let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? ""
|
||||
return requestLineMethods.contains(method)
|
||||
}
|
||||
|
||||
private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String {
|
||||
let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 3 else { return requestLine }
|
||||
|
||||
var components = URLComponents(string: String(parts[1]))
|
||||
guard let host = components?.host,
|
||||
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
|
||||
return requestLine
|
||||
}
|
||||
components?.host = canonicalLoopbackHost
|
||||
guard let rewrittenURL = components?.string else { return requestLine }
|
||||
|
||||
var rewritten = parts
|
||||
rewritten[1] = Substring(rewrittenURL)
|
||||
let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline }
|
||||
let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed())
|
||||
return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia
|
||||
}
|
||||
|
||||
private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String {
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return line }
|
||||
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let valueStart = line.index(after: colonIndex)
|
||||
let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
switch name {
|
||||
case "host":
|
||||
guard let rewrittenHost = rewriteHostValue(rawValue, aliasHost: aliasHost) else { return line }
|
||||
return "\(line[..<valueStart]) \(rewrittenHost)"
|
||||
case "origin", "referer":
|
||||
guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line }
|
||||
return "\(line[..<valueStart]) \(rewrittenURL)"
|
||||
default:
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
private static func rewriteHostValue(_ value: String, aliasHost: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("["),
|
||||
let closing = trimmed.firstIndex(of: "]") {
|
||||
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing])
|
||||
guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
|
||||
return nil
|
||||
}
|
||||
let remainder = String(trimmed[closing...].dropFirst())
|
||||
return canonicalLoopbackHost + remainder
|
||||
}
|
||||
|
||||
if let colonIndex = trimmed.lastIndex(of: ":"), !trimmed[..<colonIndex].contains(":") {
|
||||
let host = String(trimmed[..<colonIndex])
|
||||
guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
|
||||
return nil
|
||||
}
|
||||
return canonicalLoopbackHost + trimmed[colonIndex...]
|
||||
}
|
||||
|
||||
guard BrowserInsecureHTTPSettings.normalizeHost(trimmed) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
|
||||
return nil
|
||||
}
|
||||
return canonicalLoopbackHost
|
||||
}
|
||||
|
||||
private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? {
|
||||
var components = URLComponents(string: value)
|
||||
guard let host = components?.host,
|
||||
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
|
||||
return nil
|
||||
}
|
||||
components?.host = canonicalLoopbackHost
|
||||
return components?.string
|
||||
}
|
||||
}
|
||||
|
||||
enum RemoteLoopbackHTTPResponseRewriter {
|
||||
private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a])
|
||||
private static let canonicalLoopbackHost = "localhost"
|
||||
|
||||
static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data {
|
||||
guard let headerRange = data.range(of: headerDelimiter) else { return data }
|
||||
let headerData = Data(data[..<headerRange.upperBound])
|
||||
guard let headerText = String(data: headerData, encoding: .utf8) else { return data }
|
||||
|
||||
var lines = headerText.components(separatedBy: "\r\n")
|
||||
guard let statusLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data }
|
||||
guard lines[statusLineIndex].uppercased().hasPrefix("HTTP/") else { return data }
|
||||
|
||||
for index in (statusLineIndex + 1)..<lines.count where !lines[index].isEmpty {
|
||||
lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost)
|
||||
}
|
||||
|
||||
let rewrittenHeaderText = lines.joined(separator: "\r\n")
|
||||
guard rewrittenHeaderText != headerText else { return data }
|
||||
return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...]
|
||||
}
|
||||
|
||||
private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String {
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return line }
|
||||
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let valueStart = line.index(after: colonIndex)
|
||||
let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
switch name {
|
||||
case "location", "content-location", "origin", "referer", "access-control-allow-origin":
|
||||
guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line }
|
||||
return "\(line[..<valueStart]) \(rewrittenURL)"
|
||||
case "set-cookie":
|
||||
guard let rewrittenCookie = rewriteCookieValue(rawValue, aliasHost: aliasHost) else { return line }
|
||||
return "\(line[..<valueStart]) \(rewrittenCookie)"
|
||||
default:
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? {
|
||||
var components = URLComponents(string: value)
|
||||
guard let host = components?.host,
|
||||
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else {
|
||||
return nil
|
||||
}
|
||||
components?.host = aliasHost
|
||||
return components?.string
|
||||
}
|
||||
|
||||
private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? {
|
||||
let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
|
||||
var didRewrite = false
|
||||
let rewrittenParts = parts.map { part -> String in
|
||||
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.lowercased().hasPrefix("domain=") else { return part }
|
||||
let domainValue = String(trimmed.dropFirst("domain=".count))
|
||||
guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else {
|
||||
return part
|
||||
}
|
||||
didRewrite = true
|
||||
let leadingWhitespace = part.prefix { $0.isWhitespace }
|
||||
return "\(leadingWhitespace)Domain=\(aliasHost)"
|
||||
}
|
||||
|
||||
return didRewrite ? rewrittenParts.joined(separator: ";") : nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class WorkspaceRemoteDaemonProxyTunnel {
|
||||
private final class ProxySession {
|
||||
private static let maxHandshakeBytes = 64 * 1024
|
||||
|
|
@ -1229,6 +1412,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
|
|||
private var handshakeBuffer = Data()
|
||||
private var streamID: String?
|
||||
private var localInputEOF = false
|
||||
private var rewritesLoopbackHTTPHeaders = false
|
||||
private var pendingRemoteHTTPHeaderBytes = Data()
|
||||
private var hasForwardedRemoteHTTPHeaders = false
|
||||
|
||||
init(
|
||||
connection: NWConnection,
|
||||
|
|
@ -1477,6 +1663,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
|
|||
) {
|
||||
guard !isClosed else { return }
|
||||
do {
|
||||
rewritesLoopbackHTTPHeaders =
|
||||
BrowserInsecureHTTPSettings.normalizeHost(host)
|
||||
== BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost)
|
||||
let targetHost = Self.normalizedProxyTargetHost(host)
|
||||
let streamID = try rpcClient.openStream(host: targetHost, port: port)
|
||||
self.streamID = streamID
|
||||
|
|
@ -1501,7 +1690,13 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
|
|||
guard !localInputEOF || allowAfterEOF else { return }
|
||||
guard let streamID else { return }
|
||||
do {
|
||||
try rpcClient.writeStream(streamID: streamID, data: data)
|
||||
let outgoingData = rewritesLoopbackHTTPHeaders
|
||||
? RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
|
||||
data: data,
|
||||
aliasHost: Self.remoteLoopbackProxyAliasHost
|
||||
)
|
||||
: data
|
||||
try rpcClient.writeStream(streamID: streamID, data: outgoingData)
|
||||
} catch {
|
||||
close(reason: "proxy.write failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
|
@ -1540,8 +1735,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
|
|||
return
|
||||
}
|
||||
|
||||
if !readResult.data.isEmpty {
|
||||
connection.send(content: readResult.data, completion: .contentProcessed { [weak self] error in
|
||||
let localData = rewriteRemoteResponseIfNeeded(readResult.data, eof: readResult.eof)
|
||||
if !localData.isEmpty {
|
||||
connection.send(content: localData, completion: .contentProcessed { [weak self] error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
self.close(reason: "proxy client send error: \(error)")
|
||||
|
|
@ -1563,6 +1759,30 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
|
|||
}
|
||||
}
|
||||
|
||||
private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data {
|
||||
guard rewritesLoopbackHTTPHeaders else { return data }
|
||||
guard !data.isEmpty else { return data }
|
||||
guard !hasForwardedRemoteHTTPHeaders else { return data }
|
||||
|
||||
pendingRemoteHTTPHeaderBytes.append(data)
|
||||
let marker = Data([0x0D, 0x0A, 0x0D, 0x0A])
|
||||
guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else {
|
||||
guard eof else { return Data() }
|
||||
hasForwardedRemoteHTTPHeaders = true
|
||||
let payload = pendingRemoteHTTPHeaderBytes
|
||||
pendingRemoteHTTPHeaderBytes = Data()
|
||||
return payload
|
||||
}
|
||||
|
||||
hasForwardedRemoteHTTPHeaders = true
|
||||
let payload = pendingRemoteHTTPHeaderBytes
|
||||
pendingRemoteHTTPHeaderBytes = Data()
|
||||
return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded(
|
||||
data: payload,
|
||||
aliasHost: Self.remoteLoopbackProxyAliasHost
|
||||
)
|
||||
}
|
||||
|
||||
private func close(reason: String?) {
|
||||
guard !isClosed else { return }
|
||||
isClosed = true
|
||||
|
|
@ -2387,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
tests/fixtures/ssh-remote/ws_echo.py
vendored
28
tests/fixtures/ssh-remote/ws_echo.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
124
tests_v2/pane_resize_test_support.py
Normal file
124
tests_v2/pane_resize_test_support.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
||||
|
||||
|
||||
def must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def clean_line(raw: str) -> str:
|
||||
line = OSC_ESCAPE_RE.sub("", raw)
|
||||
line = ANSI_ESCAPE_RE.sub("", line)
|
||||
line = line.replace("\r", "")
|
||||
return line.strip()
|
||||
|
||||
|
||||
def layout_panes(client: cmux) -> list[dict]:
|
||||
layout_payload = client.layout_debug() or {}
|
||||
layout = layout_payload.get("layout") or {}
|
||||
return list(layout.get("panes") or [])
|
||||
|
||||
|
||||
def pane_extent(client: cmux, pane_id: str, axis: str) -> float:
|
||||
panes = layout_panes(client)
|
||||
for pane in panes:
|
||||
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
|
||||
if pid != pane_id:
|
||||
continue
|
||||
frame = pane.get("frame") or {}
|
||||
return float(frame.get(axis) or 0.0)
|
||||
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
|
||||
|
||||
|
||||
def workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]:
|
||||
payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
|
||||
out: list[tuple[str, bool, int]] = []
|
||||
for row in payload.get("panes") or []:
|
||||
out.append((
|
||||
str(row.get("id") or ""),
|
||||
bool(row.get("focused")),
|
||||
int(row.get("surface_count") or 0),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def focused_pane_id(client: cmux, workspace_id: str) -> str:
|
||||
for pane_id, focused, _surface_count in workspace_panes(client, workspace_id):
|
||||
if focused:
|
||||
return pane_id
|
||||
raise cmuxError("No focused pane found")
|
||||
|
||||
|
||||
def surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
|
||||
) or {}
|
||||
return str(payload.get("text") or "")
|
||||
|
||||
|
||||
def surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
|
||||
text = surface_scrollback_text(client, workspace_id, surface_id)
|
||||
return [clean_line(raw) for raw in text.splitlines()]
|
||||
|
||||
|
||||
def scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
|
||||
return token in surface_scrollback_lines(client, workspace_id, surface_id)
|
||||
|
||||
|
||||
def wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None:
|
||||
for _attempt in range(1, 5):
|
||||
token = f"CMUX_READY_{secrets.token_hex(4)}"
|
||||
client.send_surface(surface_id, f"echo {token}\n")
|
||||
try:
|
||||
wait_for(
|
||||
lambda: scrollback_has_exact_line(client, workspace_id, surface_id, token),
|
||||
timeout_s=2.5,
|
||||
)
|
||||
return
|
||||
except cmuxError:
|
||||
time.sleep(0.1)
|
||||
raise cmuxError("Timed out waiting for surface command roundtrip")
|
||||
|
||||
|
||||
def pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
|
||||
panes = [p for p in layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
|
||||
if len(panes) < 2:
|
||||
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
|
||||
|
||||
def x_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("x") or 0.0)
|
||||
|
||||
def y_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("y") or 0.0)
|
||||
|
||||
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
|
||||
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
|
||||
|
||||
if x_span >= y_span:
|
||||
left_pane = min(panes, key=x_of)
|
||||
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
|
||||
return ("right" if target_pane == left_id else "left"), "width"
|
||||
|
||||
top_pane = min(panes, key=y_of)
|
||||
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
|
||||
return ("down" if target_pane == top_id else "up"), "height"
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
113
tests_v2/test_cli_sidebar_metadata_commands.py
Normal file
113
tests_v2/test_cli_sidebar_metadata_commands.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: sidebar metadata CLI commands still dispatch through the public cmux CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: list[str]) -> str:
|
||||
proc = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=dict(os.environ),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
workspace_id = ""
|
||||
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
workspace_id = client.new_workspace()
|
||||
|
||||
status_response = _run_cli(cli, ["set-status", "build", "compiling", "--workspace", workspace_id])
|
||||
_must(status_response.startswith("OK"), f"set-status should succeed, got {status_response!r}")
|
||||
|
||||
status_list = _run_cli(cli, ["list-status", "--workspace", workspace_id])
|
||||
_must("build=compiling" in status_list, f"list-status should include the inserted status entry: {status_list!r}")
|
||||
|
||||
progress_response = _run_cli(cli, ["set-progress", "0.5", "--workspace", workspace_id, "--label", "Building"])
|
||||
_must(progress_response.startswith("OK"), f"set-progress should succeed, got {progress_response!r}")
|
||||
|
||||
log_response = _run_cli(cli, ["log", "--workspace", workspace_id, "--", "ship it"])
|
||||
_must(log_response.startswith("OK"), f"log should succeed, got {log_response!r}")
|
||||
|
||||
log_list = _run_cli(cli, ["list-log", "--workspace", workspace_id, "--limit", "5"])
|
||||
_must("ship it" in log_list, f"list-log should include the appended log entry: {log_list!r}")
|
||||
|
||||
sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id])
|
||||
_must("status_count=1" in sidebar_state, f"sidebar-state should include the status entry count: {sidebar_state!r}")
|
||||
_must("progress=0.50 Building" in sidebar_state, f"sidebar-state should include the progress label: {sidebar_state!r}")
|
||||
_must("[info] ship it" in sidebar_state, f"sidebar-state should include the recent log entry: {sidebar_state!r}")
|
||||
|
||||
clear_status_response = _run_cli(cli, ["clear-status", "build", "--workspace", workspace_id])
|
||||
_must(clear_status_response.startswith("OK"), f"clear-status should succeed, got {clear_status_response!r}")
|
||||
|
||||
clear_progress_response = _run_cli(cli, ["clear-progress", "--workspace", workspace_id])
|
||||
_must(clear_progress_response.startswith("OK"), f"clear-progress should succeed, got {clear_progress_response!r}")
|
||||
|
||||
clear_log_response = _run_cli(cli, ["clear-log", "--workspace", workspace_id])
|
||||
_must(clear_log_response.startswith("OK"), f"clear-log should succeed, got {clear_log_response!r}")
|
||||
|
||||
cleared_sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id])
|
||||
_must("status_count=0" in cleared_sidebar_state, f"sidebar-state should clear status entries: {cleared_sidebar_state!r}")
|
||||
_must("progress=none" in cleared_sidebar_state, f"sidebar-state should clear progress: {cleared_sidebar_state!r}")
|
||||
_must("log_count=0" in cleared_sidebar_state, f"sidebar-state should clear log entries: {cleared_sidebar_state!r}")
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_id = ""
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: sidebar metadata CLI commands dispatch and update workspace state")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue