diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e77d56b90ce5..52c4486d3ff0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -558,6 +558,26 @@ jobs: echo "::warning::Passwordless sudo unavailable; XCTest will use its default automation-mode setup" fi + - name: Run remote tmux mirror detach and placement regressions + if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} + run: | + # Closing a mirrored workspace must never kill the remote tmux session, + # and --new-window must consolidate mirrors moved across source windows. + # Keep these destructive/topology regressions outside the tolerant full + # suite so an ordinary assertion failure cannot be accepted as expected. + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + scripts/ci/run-in-console-session.sh \ + scripts/ci/run-app-host-xcodebuild.sh \ + -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + CMUX_SKIP_ZIG_BUILD=1 \ + -only-testing:cmuxTests/RemoteTmuxMirrorCloseDetachTests \ + test + - name: Run browser system proxy mirror regression if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} run: | diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2009c40edbd2..2dc507a0cc1e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8683,16 +8683,8 @@ struct CMUXCLI { ) } - /// `cmux ssh-tmux ` — mirror a remote host's tmux sessions as - /// workspaces in the current window over `tmux -CC` (the remote-tmux beta). - /// - /// Unlike `cmux ssh`, this carries no cmuxd-remote/relay bootstrap: it only - /// drives the SSH ControlMaster the mirror multiplexes over. The app's mirror - /// control client uses plain pipes and cannot service interactive auth, so if - /// the host needs a password / host-key confirmation / MFA / FIDO touch, the - /// app returns the `ssh` argv and this CLI runs it **inline in the user's - /// terminal** (which supplies the tty), then retries the mirror over the - /// now-authenticated master. + /// Mirrors a remote host's tmux sessions; interactive SSH authentication runs + /// inline in the caller's terminal before retrying over the shared master. private func runRemoteTmux( commandArgs: [String], client: SocketClient, @@ -8702,11 +8694,10 @@ struct CMUXCLI { var port: Int? var identityFile: String? var noFocus = false + var newWindow = false - // Intentional subset of parseSSHCommandOptions: the mirror verb has a - // different pipeline (no relay/cmuxd bootstrap, no `--` passthrough, no - // --ssh-option/--name/--window), so it parses only the flags it supports - // rather than reusing the heavier SSH-workspace parser. + // Intentional subset of parseSSHCommandOptions: ssh-tmux has no relay, + // passthrough, --ssh-option, --name, or --window support. var index = 0 while index < commandArgs.count { let arg = commandArgs[index] @@ -8729,6 +8720,9 @@ struct CMUXCLI { case "--no-focus": noFocus = true index += 1 + case "--new-window": + newWindow = true + index += 1 default: if arg.hasPrefix("-") { throw CLIError( @@ -8752,21 +8746,20 @@ struct CMUXCLI { if let port { params["port"] = port } if let identityFile, !identityFile.isEmpty { params["identity_file"] = identityFile } params["activate"] = !noFocus - try applyWindowOrCallerContext(to: ¶ms, client: client, windowRaw: nil) - - // The first call runs a non-interactive (BatchMode) discovery in the app, - // which can take a couple of seconds; show progress so it doesn't look idle. + if !newWindow { + try applyWindowOrCallerContext(to: ¶ms, client: client, windowRaw: nil) + } + // BatchMode discovery can take a couple of seconds; show progress. if !jsonOutput { print("Connecting to \(destination)…") } - // The app reports `auth_required` at most once per attempt; run the - // returned interactive ssh, then retry exactly once. `didAuthenticate` - // bounds the loop so a host that keeps reporting auth-required can't spin. + // Retry interactive authentication once; never spin on auth-required. + let method = newWindow ? "remote.tmux.window" : "remote.tmux.mirror" var didAuthenticate = false while true { let result = try client.sendV2( - method: "remote.tmux.mirror", + method: method, params: params, responseTimeout: 75 // > the app-side 60s timeout, so the app's result/error always arrives first ) @@ -8793,8 +8786,7 @@ struct CMUXCLI { } try runInteractiveAuthSSH(sshArgv: sshArgv, destination: destination) didAuthenticate = true - // Retry immediately so the just-opened ControlMaster (ControlPersist) - // is still warm; tell the user we're proceeding. + // Retry while the just-opened ControlMaster is warm. if !jsonOutput { print("Authenticated; opening remote tmux mirror for \(destination)…") } @@ -15862,7 +15854,7 @@ struct CMUXCLI { cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no """) case "ssh-tmux": - return String(localized: "cli.help.ssh-tmux", defaultValue: """ + let help = String(localized: "cli.help.ssh-tmux", defaultValue: """ Usage: cmux ssh-tmux [--port ] [--identity ] [--no-focus] Mirror a remote host's tmux sessions into the current window's sidebar over @@ -15885,6 +15877,14 @@ struct CMUXCLI { cmux ssh-tmux dev@my-host cmux ssh-tmux dev@my-host --port 2222 --identity ~/.ssh/id_ed25519 """) + let newWindowHelp = String( + localized: "cli.help.ssh-tmux.newWindow", + defaultValue: """ + Additional flag: + --new-window Open the mirror in a dedicated new window + """ + ) + return "\(help)\n\n\(newWindowHelp)" case "ssh-session-list": return """ Usage: cmux ssh-session-list [--workspace | --all-workspaces] @@ -35202,7 +35202,7 @@ export default CMUXSessionRestore; list-workspaces [--window ] new-workspace [--name ] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>] ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [-A|--forward-agent] [-a|--no-forward-agent] [--ssh-option <opt>] [--window <id|ref|index>] [--no-focus] [-- <remote-command-args>] - ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus] + ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus] [--new-window] ssh-session-list [--workspace <id|ref|index> | --all-workspaces] ssh-session-attach --session-id <id> [--workspace <id|ref|index>] [--pane <id|ref|index> | --split <left|right|up|down>] ssh-session-cleanup [--workspace <id|ref|index> | --all-workspaces] (--session-id <id> | --all) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d1cd820a21e0..a4408d758e63 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -32385,6 +32385,131 @@ } } }, + "cli.help.ssh-tmux.newWindow": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خيار إضافي:\n --new-window فتح المرآة في نافذة جديدة مخصصة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodatna opcija:\n --new-window Otvori zrcaljenje u namjenskom novom prozoru" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ekstra flag:\n --new-window Åbn spejlingen i et dedikeret nyt vindue" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zusätzliche Option:\n --new-window Spiegel in einem eigenen neuen Fenster öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Additional flag:\n --new-window Open the mirror in a dedicated new window" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Opción adicional:\n --new-window Abrir el reflejo en una nueva ventana dedicada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Option supplémentaire :\n --new-window Ouvrir le miroir dans une nouvelle fenêtre dédiée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Opzione aggiuntiva:\n --new-window Apri la replica in una nuova finestra dedicata" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加フラグ:\n --new-window 専用の新しいウィンドウでミラーを開く" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ជម្រើសបន្ថែម៖\n --new-window បើកការឆ្លុះក្នុងបង្អួចថ្មីដាច់ដោយឡែក" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가 플래그:\n --new-window 전용 새 창에서 미러 열기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstra flagg:\n --new-window Åpne speilet i et eget nytt vindu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodatkowa flaga:\n --new-window Otwórz kopię lustrzaną w dedykowanym nowym oknie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Opção adicional:\n --new-window Abrir o espelho em uma nova janela dedicada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дополнительный флаг:\n --new-window Открыть зеркало в отдельном новом окне" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แฟล็กเพิ่มเติม:\n --new-window เปิดมิเรอร์ในหน้าต่างใหม่แยกต่างหาก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ek bayrak:\n --new-window Yansımayı ayrılmış yeni bir pencerede aç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додатковий прапорець:\n --new-window Відкрити дзеркало в окремому новому вікні" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其他选项:\n --new-window 在专用的新窗口中打开镜像" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其他選項:\n --new-window 在專用的新視窗中開啟鏡像" + } + } + } + }, "cli.hooks.antigravity.aborted": { "extractionState": "manual", "localizations": { @@ -132512,6 +132637,131 @@ } } }, + "remoteTmux.error.windowCreationFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر على cmux إنشاء نافذة جديدة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao kreirati novi prozor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke oprette et nyt vindue" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte kein neues Fenster erstellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not create a new window" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo crear una ventana nueva" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n’a pas pu créer une nouvelle fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a creare una nuova finestra" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux は新しいウインドウを作成できませんでした" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux មិនអាចបង្កើតបង្អួចថ្មីបានទេ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 새 창을 만들 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke opprette et nytt vindu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł utworzyć nowego okna" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu criar uma nova janela" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось создать новое окно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถสร้างหน้าต่างใหม่ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux yeni bir pencere oluşturamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux не вдалося створити нове вікно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法创建新窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法建立新視窗" + } + } + } + }, "remoteTmux.pane.close": { "extractionState": "manual", "localizations": { diff --git a/Sources/RemoteTmuxAttachWindowTarget.swift b/Sources/RemoteTmuxAttachWindowTarget.swift index 324f38be44f0..460d0bf07364 100644 --- a/Sources/RemoteTmuxAttachWindowTarget.swift +++ b/Sources/RemoteTmuxAttachWindowTarget.swift @@ -2,6 +2,8 @@ import Foundation /// Window-routing intent for a remote-tmux attach, preserved across SSH awaits. enum RemoteTmuxAttachWindowTarget: Sendable, Equatable { + /// Create a dedicated window after SSH preflight succeeds. + case dedicatedNewWindow /// A non-null `window_id` that resolved when the request was parsed. case explicitWindow(UUID) /// A non-null `window_id` that did not resolve; never falls back to active. @@ -20,6 +22,8 @@ enum RemoteTmuxAttachWindowTarget: Sendable, Equatable { return existingMirrorWindowID } switch self { + case .dedicatedNewWindow: + return nil case .explicitWindow(let windowID): return isLive(windowID) ? windowID : nil case .unresolvedExplicitWindow: diff --git a/Sources/RemoteTmuxController+Attach.swift b/Sources/RemoteTmuxController+Attach.swift index 245fd9d9c99a..2cf999066281 100644 --- a/Sources/RemoteTmuxController+Attach.swift +++ b/Sources/RemoteTmuxController+Attach.swift @@ -15,14 +15,16 @@ extension RemoteTmuxController { .flatMap { appDelegate.windowId(for: $0) } let initialActiveWindowID = appDelegate.tabManager .flatMap { appDelegate.windowId(for: $0) } - guard windowTarget.resolve( - existingMirrorWindowID: initialExistingMirrorWindowID, - activeWindowID: initialActiveWindowID, - isLive: { appDelegate.tabManagerFor(windowId: $0) != nil } - ) != nil else { - // Reject a guaranteed-invalid destination before discovery can - // create a default remote session or open a cached SSH master. - throw RemoteTmuxError.unreachable("app not ready") + if windowTarget != .dedicatedNewWindow { + guard windowTarget.resolve( + existingMirrorWindowID: initialExistingMirrorWindowID, + activeWindowID: initialActiveWindowID, + isLive: { appDelegate.tabManagerFor(windowId: $0) != nil } + ) != nil else { + // Reject a guaranteed-invalid destination before discovery can + // create a default remote session or open a cached SSH master. + throw RemoteTmuxError.unreachable("app not ready") + } } guard windowRegistry.beginAttach(hostHash: host.connectionHash) else { throw RemoteTmuxError.unreachable("already attaching \(host.destination)") @@ -47,32 +49,63 @@ extension RemoteTmuxController { // Resolve stable ids after every SSH await. Explicit window routing // fails closed if that window disappeared; contextual routing may - // recover to the active window. A live existing mirror stays first so - // one host cannot be split across windows. - let existingMirrorWindowID = existingMirrorManager(for: host) - .flatMap { appDelegate.windowId(for: $0) } - let activeWindowID = appDelegate.tabManager - .flatMap { appDelegate.windowId(for: $0) } - guard let resolvedWindowId = windowTarget.resolve( - existingMirrorWindowID: existingMirrorWindowID, - activeWindowID: activeWindowID, - isLive: { appDelegate.tabManagerFor(windowId: $0) != nil } - ), let targetManager = appDelegate.tabManagerFor(windowId: resolvedWindowId) else { - // A valid target can close while SSH discovery is in flight. A new - // host has no mirror owner to clean up the transport in that race. - if initialExistingMirrorWindowID == nil { - transportRegistry.remove(connectionHash: host.connectionHash) - RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + // recover to the active window. Dedicated-window requests create their + // window only after discovery/auth preflight, so failures never leave + // empty chrome behind. + let resolvedWindowId: UUID + let targetManager: TabManager + let bootstrapWorkspaceId: UUID? + if windowTarget == .dedicatedNewWindow { + resolvedWindowId = appDelegate.createMainWindow(shouldActivate: false) + guard let newWindowManager = appDelegate.tabManagerFor(windowId: resolvedWindowId) else { + appDelegate.discardMainWindowWithoutClosedHistory(windowId: resolvedWindowId) + cleanUpTransportAfterFailedMirror(host: host) + throw RemoteTmuxError.windowCreationFailed } - throw RemoteTmuxError.unreachable("app not ready") + targetManager = newWindowManager + bootstrapWorkspaceId = newWindowManager.tabs.first?.id + moveExistingMirrors(for: host, into: newWindowManager) + } else { + // A live existing mirror stays first so one host cannot be split + // across windows by a contextual or explicit attach. + let existingMirrorWindowID = existingMirrorManager(for: host) + .flatMap { appDelegate.windowId(for: $0) } + let activeWindowID = appDelegate.tabManager + .flatMap { appDelegate.windowId(for: $0) } + guard let existingWindowId = windowTarget.resolve( + existingMirrorWindowID: existingMirrorWindowID, + activeWindowID: activeWindowID, + isLive: { appDelegate.tabManagerFor(windowId: $0) != nil } + ), let existingWindowManager = appDelegate.tabManagerFor(windowId: existingWindowId) else { + // A valid target can close while SSH discovery is in flight. A new + // host has no mirror owner to clean up the transport in that race. + if initialExistingMirrorWindowID == nil { + transportRegistry.remove(connectionHash: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + } + throw RemoteTmuxError.unreachable("app not ready") + } + resolvedWindowId = existingWindowId + targetManager = existingWindowManager + bootstrapWorkspaceId = nil } let workspaceIds = mirrorDiscoveredSessions(host: host, sessions: sessions, into: targetManager) guard !workspaceIds.isEmpty else { cleanUpTransportAfterFailedMirror(host: host) + if windowTarget == .dedicatedNewWindow { + appDelegate.discardMainWindowWithoutClosedHistory(windowId: resolvedWindowId) + } throw RemoteTmuxError.unreachable("could not mirror any tmux session on \(host.destination)") } + if let bootstrapWorkspaceId, + targetManager.tabs.count > 1, + let bootstrap = targetManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + !bootstrap.isRemoteTmuxMirror { + targetManager.closeWorkspace(bootstrap, recordHistory: false) + } + if activate { selectFirstMirrorWorkspace(for: host, in: targetManager) _ = appDelegate.focusMainWindow(windowId: resolvedWindowId) @@ -135,6 +168,31 @@ extension RemoteTmuxController { return nil } + /// Consolidates an existing host mirror into a newly created dedicated window. + private func moveExistingMirrors(for host: RemoteTmuxHost, into targetManager: TabManager) { + let hostWorkspaceIds = Set(sessionMirrors.values.compactMap { mirror -> UUID? in + guard mirror.host.connectionHash == host.connectionHash else { return nil } + return mirror.mirroredWorkspaceId + }) + var sourceManagers: [TabManager] = [] + var seenSourceManagers: Set<ObjectIdentifier> = [] + for mirror in sessionMirrors.values where mirror.host.connectionHash == host.connectionHash { + guard let workspaceId = mirror.mirroredWorkspaceId, + let sourceManager = mirror.mirroredWorkspace?.owningTabManager + ?? AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + sourceManager !== targetManager, + seenSourceManagers.insert(ObjectIdentifier(sourceManager)).inserted else { continue } + sourceManagers.append(sourceManager) + } + for sourceManager in sourceManagers { + let workspaces = sourceManager.tabs.filter { hostWorkspaceIds.contains($0.id) } + for workspace in workspaces { + guard let detached = sourceManager.detachWorkspace(tabId: workspace.id) else { continue } + targetManager.attachWorkspace(detached, select: false) + } + } + } + private func selectFirstMirrorWorkspace(for host: RemoteTmuxHost, in tabManager: TabManager) { let hostWorkspaceIds = Set(sessionMirrors.values.compactMap { mirror -> UUID? in guard mirror.host.connectionHash == host.connectionHash else { return nil } diff --git a/Sources/RemoteTmuxError.swift b/Sources/RemoteTmuxError.swift index fe9eef189a5d..6dcf44aceded 100644 --- a/Sources/RemoteTmuxError.swift +++ b/Sources/RemoteTmuxError.swift @@ -12,6 +12,9 @@ enum RemoteTmuxError: Error, Sendable, Equatable { /// The remote host is not reachable / the SSH master could not be opened. case unreachable(String) + /// cmux could not create the local window requested for a dedicated mirror. + case windowCreationFailed + /// The remote tmux is older than ``RemoteTmuxVersion/minimumSupported``, so the /// control-mode mirror would attach into a broken/degraded state (no live pane /// subscriptions, or no `%begin`/`%end` framing). Carries the detected version @@ -65,6 +68,11 @@ extension RemoteTmuxError { defaultValue: "host unreachable: %@" ) return String(format: format, Self.sanitizedDetail(detail)) + case .windowCreationFailed: + return String( + localized: "remoteTmux.error.windowCreationFailed", + defaultValue: "cmux could not create a new window" + ) case let .unsupportedTmux(detected): let format = String( localized: "remoteTmux.error.unsupportedVersion", diff --git a/Sources/TabManager+NonInteractiveClose.swift b/Sources/TabManager+NonInteractiveClose.swift index ebef52e4aa48..953cf490085d 100644 --- a/Sources/TabManager+NonInteractiveClose.swift +++ b/Sources/TabManager+NonInteractiveClose.swift @@ -3,9 +3,9 @@ import Foundation extension TabManager { /// Closes a socket/API-targeted workspace without an interactive veto. /// - /// Closing a window's last workspace means closing the window. A dead or - /// reconnecting remote-tmux mirror is detached from its local owner first so - /// the window close cannot leave a frozen mirror registered behind it. + /// Closing a window's last workspace means closing the window. A remote-tmux + /// mirror is detached from its local owner first so a socket close never maps + /// to the explicit remote-session kill path. @discardableResult func closeWorkspaceNonInteractively(_ workspace: Workspace, recordHistory: Bool = true) -> Bool { guard canCloseWorkspace(workspace), @@ -18,7 +18,7 @@ extension TabManager { let windowId = appDelegate.windowId(for: self), appDelegate.mainWindow(for: windowId) != nil else { return false } if workspace.isRemoteTmuxMirror { - appDelegate.remoteTmuxController.handleWorkspaceClosed(workspaceId: workspace.id) + appDelegate.remoteTmuxController.detachMirrorWorkspaceKeptOpenLocally(workspaceId: workspace.id) } return appDelegate.closeMainWindow(windowId: windowId, recordHistory: recordHistory) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index c042ee7aad51..29511a362906 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1994,11 +1994,12 @@ class TabManager: ObservableObject { guard tabs.count > 1 else { return } panelTitleUpdateCoalescer.flushNow() sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) - // User-initiated close of a mirrored remote tmux session kills it on the - // remote. (App quit tears down windows without calling closeWorkspace, so - // quitting still leaves remote sessions alive.) + // Closing a mirrored remote tmux workspace DETACHES from the remote session, + // leaving it alive on the server for resume. Killing the session is never a + // side effect of closing a tab (PR #7264 review); it is only ever an explicit + // disconnect action. if workspace.isRemoteTmuxMirror { - AppDelegate.shared?.remoteTmuxController.handleWorkspaceClosed(workspaceId: workspace.id) + AppDelegate.shared?.remoteTmuxController.detachMirrorWorkspaceKeptOpenLocally(workspaceId: workspace.id) } if recordHistory, workspace.isRestorableInSessionSnapshot, @@ -2214,19 +2215,16 @@ class TabManager: ObservableObject { sidebarMultiSelection.replaceSelection(with: workspaceIds.intersection(existingIds)) } - /// Marks the window's pending close as a tab/session close so a remote-tmux - /// mirror among `workspaces` is KILLED (synced with tmux) on the close commit - /// rather than detached. The single decision point for every close path that - /// closes the whole window directly — the last-workspace branch of - /// ``closeWorkspaceIfRunningProcess`` and the batch/anchor paths in - /// ``closeWorkspacesWithConfirmation`` — so every explicit tab-close intent kills - /// consistently. ``AppDelegate``'s `shouldClose`/`onClose` consume or clear the - /// marker (veto vs commit). - private func markRemoteTmuxKillOnWindowCloseIfNeeded(for workspaces: [Workspace]) { - guard workspaces.contains(where: { $0.isRemoteTmuxMirror }), - let windowId = AppDelegate.shared?.windowId(for: self) else { return } - AppDelegate.shared?.remoteTmuxController.markKillSessionsOnWindowClose(windowId: windowId) - } + /// No-op: closing a remote-tmux mirror workspace/tab/window must DETACH from the + /// remote session, never kill it. Killing a live tmux session is only ever an + /// explicit disconnect action, never a side effect of closing a tab (PR #7264 + /// review by the ssh-tmux author). This seam formerly set the window + /// kill-on-close marker so the close committed a `kill-session`; it is retained + /// as a no-op (still called from the last-workspace and batch/anchor close paths) + /// so those paths fall through to detach via `AppDelegate`'s window-close handlers + /// and the app-quit deferral gate stays empty. The marker machinery is left in + /// place for a future explicit "disconnect host" action. + func markRemoteTmuxKillOnWindowCloseIfNeeded(for workspaces: [Workspace]) {} func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) { let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned) @@ -2247,8 +2245,9 @@ class TabManager: ObservableObject { if plan.workspaces.count == tabs.count, let firstWorkspace = plan.workspaces.first { - // Closing every tab is still an explicit tab/session close: kill the - // remote-tmux session(s) on commit, not detach. + // Closing every tab routes through the window-close path, which DETACHES + // the remote-tmux session(s) (kept alive on the server for resume); the + // mark seam is a retained no-op (see markRemoteTmuxKillOnWindowCloseIfNeeded). markRemoteTmuxKillOnWindowCloseIfNeeded(for: plan.workspaces) if let window { window.performClose(nil) @@ -2279,7 +2278,7 @@ class TabManager: ObservableObject { // Anchor confirmed (or suppressed); skip the inner re-prompt // by closing without going through closeWorkspaceIfRunningProcess. if tabs.count <= 1 { - // Still a tab/session close → kill the remote session on commit. + // Mirror close detaches from the remote session (retained no-op). markRemoteTmuxKillOnWindowCloseIfNeeded(for: [workspace]) if let window { window.performClose(nil) @@ -2524,13 +2523,11 @@ class TabManager: ObservableObject { return false } if tabs.count <= 1 { - // Last workspace in this window closes via the window-close path, but it - // is still an explicit TAB/session close: for a remote-tmux mirror, mark - // the close to KILL the session on commit (synced with tmux), even though - // it also closes the app window. The marker is consumed on the (non-vetoed) - // close commit, or cleared if the close is vetoed (single-window quit - // warning) so a cancelled close never kills. A plain window/quit close - // never sets it, so it detaches. Non-last workspaces kill via closeWorkspace. + // Last workspace in this window closes via the window-close path. For a + // remote-tmux mirror this DETACHES from the remote session (kept alive for + // resume); the mark seam is a retained no-op (see + // markRemoteTmuxKillOnWindowCloseIfNeeded). Non-last workspaces also detach + // via closeWorkspace. markRemoteTmuxKillOnWindowCloseIfNeeded(for: [workspace]) if let window { window.performClose(nil) diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index f128caa965ef..969def1bd01d 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -129,7 +129,7 @@ extension TerminalController { guard let host = Self.remoteTmuxHost(from: params) else { return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostRequired", defaultValue: "host is required")) } - let activate = (params["activate"] as? Bool) ?? false + let activate = Self.remoteTmuxActivate(from: params) let routing = remoteTmuxRouting(from: params) return v2VmCall(id: id, timeoutSeconds: 60) { guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) @@ -162,6 +162,45 @@ extension TerminalController { } } + /// `remote.tmux.window` — mirror every tmux session on a host into a + /// dedicated new window. Params: `host` (required), optional `port`, + /// `identity_file`, and `activate`. + nonisolated func v2RemoteTmuxWindow(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) + } + guard let host = Self.remoteTmuxHost(from: params) else { + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostRequired", defaultValue: "host is required")) + } + let activate = Self.remoteTmuxActivate(from: params) + return v2VmCall(id: id, timeoutSeconds: 60) { + guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) + else { + throw RemoteTmuxError.unreachable("app not ready") + } + let outcome = try await controller.attachHost( + host: host, + windowTarget: .dedicatedNewWindow, + activate: activate + ) + switch outcome { + case .mirrored(let windowId, let workspaceIds): + return [ + "host": host.destination, + "mirrored": true, + "window_id": windowId.uuidString, + "workspace_ids": workspaceIds.map(\.uuidString), + ] + case .authRequired(let sshArgv): + return [ + "host": host.destination, + "auth_required": true, + "ssh_argv": sshArgv, + ] + } + } + } + nonisolated func remoteTmuxRouting(from params: [String: Any]) -> ControlRoutingSelectors { ControlRoutingSelectors( hasWindowIDParam: v2HasNonNullParam(params, "window_id"), @@ -175,6 +214,10 @@ extension TerminalController { ) } + private nonisolated static func remoteTmuxActivate(from params: [String: Any]) -> Bool { + (params["activate"] as? Bool) ?? false + } + @MainActor func remoteTmuxAttachWindowTarget( routing: ControlRoutingSelectors diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 15f36331ba12..5534f4d78566 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1382,8 +1382,8 @@ class TerminalController { return v2RemoteTmuxDetach(id: request.id, params: request.params) case "remote.tmux.state": return v2RemoteTmuxState(id: request.id, params: request.params) - case "remote.tmux.mirror": - return v2RemoteTmuxMirror(id: request.id, params: request.params) + case "remote.tmux.mirror": return v2RemoteTmuxMirror(id: request.id, params: request.params) + case "remote.tmux.window": return v2RemoteTmuxWindow(id: request.id, params: request.params) case "remote.tmux.pane_grids": return v2RemoteTmuxPaneGrids(id: request.id, params: request.params) #if DEBUG case "remote.tmux.test_exec": return v2RemoteTmuxTestExec(id: request.id, params: request.params) @@ -2416,7 +2416,7 @@ class TerminalController { "workspace.remote.status", "workspace.remote.pty_sessions", "workspace.remote.pty_close", "workspace.remote.pty_detach", "workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_attach_end", - "workspace.remote.terminal_session_end", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.pane_grids", + "workspace.remote.terminal_session_end", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", "remote.tmux.pane_grids", "session.restore_previous", "settings.open", "feedback.open", diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c146675df12e..107a464aa25e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1130,6 +1130,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */; }; 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */; }; 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738B0017738B0017738B001 /* RemoteTmuxMirrorCLIObservabilityTests.swift */; }; + C2C5F24B63F3E5516408EE44 /* RemoteTmuxMirrorCloseDetachTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DBAA681DEE6F9121D289ABE /* RemoteTmuxMirrorCloseDetachTests.swift */; }; D4F8A2E61C5B39707A8E6F13 /* RemoteTmuxMirrorFeedForwardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C4D2B3F09865E217D4C1 /* RemoteTmuxMirrorFeedForwardTests.swift */; }; 783300000000000000000002 /* RemoteTmuxMirrorLayoutIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */; }; 74060000000000000000000A /* RemoteTmuxMirrorLayoutMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740600000000000000000009 /* RemoteTmuxMirrorLayoutMathTests.swift */; }; @@ -2910,6 +2911,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMasterReadinessTests.swift; sourceTree = "<group>"; }; 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCLIFailClosedTests.swift; sourceTree = "<group>"; }; 7738B0017738B0017738B001 /* RemoteTmuxMirrorCLIObservabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCLIObservabilityTests.swift; sourceTree = "<group>"; }; + 0DBAA681DEE6F9121D289ABE /* RemoteTmuxMirrorCloseDetachTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCloseDetachTests.swift; sourceTree = "<group>"; }; A7E1C4D2B3F09865E217D4C1 /* RemoteTmuxMirrorFeedForwardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorFeedForwardTests.swift; sourceTree = "<group>"; }; 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLayoutIdentityTests.swift; sourceTree = "<group>"; }; 740600000000000000000009 /* RemoteTmuxMirrorLayoutMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLayoutMathTests.swift; sourceTree = "<group>"; }; @@ -5326,6 +5328,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */, 3F704ED4F177122D7DCD0B01 /* RemoteTmuxSessionRenameTitleTests.swift */, 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */, + 0DBAA681DEE6F9121D289ABE /* RemoteTmuxMirrorCloseDetachTests.swift */, 736200000000000000000007 /* RemoteTmuxMirrorTargetingTests.swift */, 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */, B8E2A4C1D5F3096871A2B3C4 /* RemoteTmuxNativeMirrorLayoutFuzzTests.swift */, @@ -7414,6 +7417,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */, 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */, 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */, + C2C5F24B63F3E5516408EE44 /* RemoteTmuxMirrorCloseDetachTests.swift in Sources */, D4F8A2E61C5B39707A8E6F13 /* RemoteTmuxMirrorFeedForwardTests.swift in Sources */, 783300000000000000000002 /* RemoteTmuxMirrorLayoutIdentityTests.swift in Sources */, 74060000000000000000000A /* RemoteTmuxMirrorLayoutMathTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxCapabilitiesTests.swift b/cmuxTests/RemoteTmuxCapabilitiesTests.swift index 511262b32601..31f05cac2f62 100644 --- a/cmuxTests/RemoteTmuxCapabilitiesTests.swift +++ b/cmuxTests/RemoteTmuxCapabilitiesTests.swift @@ -23,6 +23,23 @@ import Testing "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", + "remote.tmux.window", ].allSatisfy { advertisedMethods.contains($0) }) } + + /// Requests without a host must fail a network-free guard, never dispatch as + /// unknown methods or touch SSH. This covers both placement entry points. + @Test(arguments: ["remote.tmux.mirror", "remote.tmux.window"]) + func mirrorWithoutHostReturnsStructuredErrorBeforeNetwork(method: String) throws { + let request = #"{"jsonrpc":"2.0","id":1,"method":"\#(method)","params":{}}"# + let responseText = TerminalController.shared.handleSocketLine(request) + let responseData = try #require(responseText.data(using: .utf8)) + let response = try #require(JSONSerialization.jsonObject(with: responseData) as? [String: Any]) + + #expect(response["ok"] as? Bool == false) + let error = try #require(response["error"] as? [String: Any]) + let code = try #require(error["code"] as? String) + + #expect(code == "disabled" || code == "invalid_params") + } } diff --git a/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift b/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift new file mode 100644 index 000000000000..4adf8fafbf68 --- /dev/null +++ b/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift @@ -0,0 +1,373 @@ +import AppKit +import CmuxControlSocket +import CmuxSettings +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression coverage for the remote-tmux mirror close contract +/// (https://github.com/manaflow-ai/cmux/pull/7264 review): closing a mirrored +/// remote-tmux workspace must DETACH from the remote session, never `kill-session` +/// it. The ssh-tmux author flagged that with mirrors living as plain workspaces in +/// the current window, the natural "close this tab to get it off my screen" gesture +/// would silently kill the user's live tmux session on the server. Killing a remote +/// session is only ever an explicit disconnect action, never a side effect of +/// closing a tab, a window, or quitting the app. +/// +/// The seam that used to translate a tab close into "kill on commit" is +/// `TabManager.markRemoteTmuxKillOnWindowCloseIfNeeded`, which set the window +/// kill-on-close marker in `RemoteTmuxWindowRegistry`. After the fix that seam must +/// never mark a mirror for kill, so every close path (non-last tab, last-tab window +/// close, and the app-quit deferral gate) detaches and the remote session survives. +/// The marker is set-then-consumed synchronously inside the real close gesture, so +/// this test exercises the marking decision directly to observe it deterministically. +@MainActor +@Suite(.serialized) struct RemoteTmuxMirrorCloseDetachTests { + private let sshOverrideKey = "CMUX_REMOTE_TMUX_SSH_FOR_TESTING" + private let sshLogKey = "CMUX_PR7264_SSH_LOG" + + /// The mark seam must NOT flag a mirror workspace's window for kill-on-close: + /// the close detaches, the remote tmux session survives for resume. Before the + /// fix this marked the window for kill; after, it never does. + @Test func markSeamDoesNotMarkMirrorForKill() throws { + let harness = try Harness() + defer { harness.tearDown() } + + harness.workspace.isRemoteTmuxMirror = true + harness.manager.markRemoteTmuxKillOnWindowCloseIfNeeded(for: [harness.workspace]) + + #expect( + !harness.appDelegate.remoteTmuxController + .windowsMarkedForKillOnClose() + .contains(harness.windowId) + ) + } + + /// The v2 socket close path must detach a live last-workspace mirror without + /// issuing the destructive `tmux kill-session` used by an explicit remote + /// disconnect. The fake SSH executable records every argv element and treats + /// the local ControlMaster exit as success, so this exercises the production + /// close route without opening a network connection. + @Test func socketCloseOfLiveLastMirrorDetachesWithoutKillingSession() async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("remote-tmux-close-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let logURL = root.appendingPathComponent("ssh.log") + let sshURL = root.appendingPathComponent("ssh") + try writeExecutable( + at: sshURL, + contents: """ + #!/bin/sh + for arg in "$@"; do + printf 'ARG=%s\\n' "$arg" >> "${CMUX_PR7264_SSH_LOG:?}" + done + exit 0 + """ + ) + let previousSSH = environmentValue(for: sshOverrideKey) + let previousLog = environmentValue(for: sshLogKey) + setenv(sshOverrideKey, sshURL.path, 1) + setenv(sshLogKey, logURL.path, 1) + defer { + restoreEnvironment(sshOverrideKey, previousValue: previousSSH) + restoreEnvironment(sshLogKey, previousValue: previousLog) + } + + let harness = try Harness() + defer { harness.tearDown() } + let host = RemoteTmuxHost(destination: "close-\(UUID().uuidString)@example.test") + let connection = RemoteTmuxControlConnection(host: host, sessionName: "dev") + let controller = harness.appDelegate.remoteTmuxController + defer { + if controller.sessionMirror(host: host, sessionName: "dev") != nil { + controller.detach(host: host, sessionName: "dev") + } + } + controller.cacheConnection(connection) + #expect(try controller.mirrorSession(host: host, sessionName: "dev", into: harness.manager)) + let mirrorWorkspace = try #require(harness.manager.tabs.first(where: { $0.isRemoteTmuxMirror })) + harness.manager.closeWorkspace(harness.workspace, recordHistory: false) + #expect(harness.manager.tabs.map(\.id) == [mirrorWorkspace.id]) + #expect(!connection.exited) + + let resolution = TerminalController.shared.controlCloseWorkspace( + routing: ControlRoutingSelectors( + hasWindowIDParam: true, + windowID: harness.windowId, + groupID: nil, + workspaceID: mirrorWorkspace.id, + surfaceID: nil, + paneID: nil + ), + workspaceID: mirrorWorkspace.id + ) + + #expect(resolution == .resolved(windowID: harness.windowId)) + let log = try await waitForSSHArgument("exit", at: logURL) + #expect(!log.contains("kill-session"), Comment(rawValue: log)) + #expect(controller.sessionMirror(host: host, sessionName: "dev") == nil) + #expect(connection.exited) + } + + /// The ordinary non-last tab-close route shares the same detach contract as + /// socket/window close: removing the mirror from a mixed local window must + /// stop its control client without issuing `tmux kill-session`. + @Test func ordinaryCloseOfLiveMirrorDetachesWithoutKillingSession() async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("remote-tmux-tab-close-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let logURL = root.appendingPathComponent("ssh.log") + let sshURL = root.appendingPathComponent("ssh") + try writeExecutable( + at: sshURL, + contents: """ + #!/bin/sh + for arg in "$@"; do + printf 'ARG=%s\\n' "$arg" >> "${CMUX_PR7264_SSH_LOG:?}" + done + exit 0 + """ + ) + let previousSSH = environmentValue(for: sshOverrideKey) + let previousLog = environmentValue(for: sshLogKey) + setenv(sshOverrideKey, sshURL.path, 1) + setenv(sshLogKey, logURL.path, 1) + defer { + restoreEnvironment(sshOverrideKey, previousValue: previousSSH) + restoreEnvironment(sshLogKey, previousValue: previousLog) + } + + let harness = try Harness() + defer { harness.tearDown() } + let host = RemoteTmuxHost(destination: "tab-close-\(UUID().uuidString)@example.test") + let connection = RemoteTmuxControlConnection(host: host, sessionName: "dev") + let controller = harness.controller + defer { + if controller.sessionMirror(host: host, sessionName: "dev") != nil { + controller.detach(host: host, sessionName: "dev") + } + } + controller.cacheConnection(connection) + #expect(try controller.mirrorSession(host: host, sessionName: "dev", into: harness.manager)) + let mirrorWorkspace = try #require(harness.manager.tabs.first(where: { $0.isRemoteTmuxMirror })) + #expect(harness.manager.tabs.count == 2) + + harness.manager.closeWorkspace(mirrorWorkspace, recordHistory: false) + + let log = try await waitForSSHArgument("exit", at: logURL) + #expect(!log.contains("kill-session"), Comment(rawValue: log)) + #expect(harness.manager.tabs.map(\.id) == [harness.workspace.id]) + #expect(controller.sessionMirror(host: host, sessionName: "dev") == nil) + #expect(connection.exited) + } + + @Test func windowCreationFailureUsesLocalErrorMessage() { + let message = RemoteTmuxError.windowCreationFailed.message + + #expect(message == String( + localized: "remoteTmux.error.windowCreationFailed", + defaultValue: "cmux could not create a new window" + )) + #expect(!message.localizedCaseInsensitiveContains("host unreachable")) + } + + /// `--new-window` must consolidate every mirror for the host even when the + /// Move Workspace action previously distributed those mirrors across several + /// source windows. The fake SSH executable supplies discovery and readiness + /// responses while cached control connections keep the test network-free. + @Test func dedicatedWindowConsolidatesMirrorsFromEverySourceWindow() async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("remote-tmux-placement-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let sshURL = root.appendingPathComponent("ssh") + try writeExecutable( + at: sshURL, + contents: """ + #!/bin/sh + case "$*" in + *display-message*) printf '3.4\\n' ;; + *list-sessions*) printf '$1:1:0:1:one\\n$2:1:0:1:two\\n' ;; + esac + exit 0 + """ + ) + let previousSSH = environmentValue(for: sshOverrideKey) + setenv(sshOverrideKey, sshURL.path, 1) + defer { restoreEnvironment(sshOverrideKey, previousValue: previousSSH) } + + let harness = try Harness() + var extraWindowIDs: [UUID] = [] + defer { + extraWindowIDs.reversed().forEach(harness.closeWindow) + harness.tearDown() + } + let secondWindowID = harness.appDelegate.createMainWindow() + extraWindowIDs.append(secondWindowID) + let secondManager = try #require(harness.appDelegate.tabManagerFor(windowId: secondWindowID)) + let host = RemoteTmuxHost(destination: "placement-\(UUID().uuidString)@example.test") + defer { + harness.controller.detach(host: host, sessionName: "one") + harness.controller.detach(host: host, sessionName: "two") + } + harness.cacheConnection(host: host, session: "one") + harness.cacheConnection(host: host, session: "two") + #expect(try harness.controller.mirrorSession(host: host, sessionName: "one", into: harness.manager)) + #expect(try harness.controller.mirrorSession(host: host, sessionName: "two", into: harness.manager)) + let secondMirror = try #require(harness.manager.tabs.first(where: { $0.title == "two" })) + let detached = try #require(harness.manager.detachWorkspace(tabId: secondMirror.id)) + secondManager.attachWorkspace(detached, select: false) + #expect(harness.manager.tabs.filter(\.isRemoteTmuxMirror).count == 1) + #expect(secondManager.tabs.filter(\.isRemoteTmuxMirror).count == 1) + + let outcome = try await harness.controller.attachHost( + host: host, + windowTarget: .dedicatedNewWindow, + activate: false + ) + guard case let .mirrored(targetWindowID, workspaceIDs) = outcome else { + Issue.record("Expected dedicated-window attach to mirror the host") + return + } + extraWindowIDs.append(targetWindowID) + let targetManager = try #require(harness.appDelegate.tabManagerFor(windowId: targetWindowID)) + + #expect(workspaceIDs.count == 2) + #expect(targetManager.tabs.filter(\.isRemoteTmuxMirror).count == 2) + #expect(harness.manager.tabs.allSatisfy { !$0.isRemoteTmuxMirror }) + #expect(secondManager.tabs.allSatisfy { !$0.isRemoteTmuxMirror }) + } + + /// A direct socket caller must opt into focus. The CLI supplies an explicit + /// `activate` value, but a raw `remote.tmux.window` request with no such field + /// must leave the caller's current cmux window active. + @Test func dedicatedWindowSocketDefaultsToFocusNeutral() async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("remote-tmux-focus-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let sshURL = root.appendingPathComponent("ssh") + try writeExecutable( + at: sshURL, + contents: """ + #!/bin/sh + case "$*" in + *display-message*) printf '3.4\\n' ;; + *list-sessions*) printf '$1:1:0:1:one\\n' ;; + esac + exit 0 + """ + ) + let previousSSH = environmentValue(for: sshOverrideKey) + setenv(sshOverrideKey, sshURL.path, 1) + defer { restoreEnvironment(sshOverrideKey, previousValue: previousSSH) } + let remoteTmuxKey = SettingCatalog().betaFeatures.remoteTmux.userDefaultsKey + let previousRemoteTmux = UserDefaults.standard.object(forKey: remoteTmuxKey) + UserDefaults.standard.set(true, forKey: remoteTmuxKey) + defer { + if let previousRemoteTmux { + UserDefaults.standard.set(previousRemoteTmux, forKey: remoteTmuxKey) + } else { + UserDefaults.standard.removeObject(forKey: remoteTmuxKey) + } + } + + let harness = try Harness() + var targetWindowID: UUID? + defer { + if let targetWindowID { harness.closeWindow(targetWindowID) } + harness.tearDown() + } + let host = RemoteTmuxHost(destination: "focus-\(UUID().uuidString)@example.test") + defer { harness.controller.detach(host: host, sessionName: "one") } + harness.cacheConnection(host: host, session: "one") + #expect(harness.appDelegate.focusMainWindow(windowId: harness.windowId)) + #expect(harness.appDelegate.tabManager === harness.manager) + + let responseText = await Task.detached { + TerminalController.shared.v2RemoteTmuxWindow( + id: 1, + params: ["host": host.destination] + ) + }.value + let responseData = try #require(responseText.data(using: .utf8)) + let response = try #require(JSONSerialization.jsonObject(with: responseData) as? [String: Any]) + let result = try #require(response["result"] as? [String: Any]) + targetWindowID = try #require( + (result["window_id"] as? String).flatMap(UUID.init(uuidString:)) + ) + + #expect(harness.appDelegate.tabManager === harness.manager) + } + + private func writeExecutable(at url: URL, contents: String) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func environmentValue(for key: String) -> String? { + getenv(key).map { String(cString: $0) } + } + + private func restoreEnvironment(_ key: String, previousValue: String?) { + if let previousValue { + setenv(key, previousValue, 1) + } else { + unsetenv(key) + } + } + + private func waitForSSHArgument(_ argument: String, at logURL: URL) async throws -> String { + for _ in 0..<200 { + let log = (try? String(contentsOf: logURL, encoding: .utf8)) ?? "" + if log.split(separator: "\n").contains(Substring("ARG=\(argument)")) { + return log + } + try await Task.sleep(for: .milliseconds(10)) + } + let log = (try? String(contentsOf: logURL, encoding: .utf8)) ?? "" + Issue.record("Timed out waiting for fake SSH argument '\(argument)': \(log)") + return log + } + + @MainActor + private struct Harness { + let appDelegate: AppDelegate + let windowId: UUID + let manager: TabManager + let workspace: Workspace + var controller: RemoteTmuxController { appDelegate.remoteTmuxController } + + init() throws { + appDelegate = try #require(AppDelegate.shared) + windowId = appDelegate.createMainWindow() + manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) + workspace = try #require(manager.selectedWorkspace) + } + + func tearDown() { + workspace.isRemoteTmuxMirror = false + // Clear any marker so it can't leak into another serialized test. + controller.consumeKillSessionsOnWindowClose(windowId: windowId) + closeWindow(windowId) + } + + func cacheConnection(host: RemoteTmuxHost, session: String) { + controller.cacheConnection(RemoteTmuxControlConnection(host: host, sessionName: session)) + } + + func closeWindow(_ id: UUID) { + let identifier = "cmux.main.\(id.uuidString)" + if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) { + window.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + } + } +} diff --git a/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx b/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx index e14e51813731..efee26fac29e 100644 --- a/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx @@ -70,8 +70,9 @@ export default async function RemoteTmuxPage({ <DocsHeading level={2} id="attach">{t("attachTitle")}</DocsHeading> <p>{t("attachIntro")}</p> + <p>{t("attachNewWindow")}</p> <p>{t("attachCli")}</p> - <CodeBlock lang="bash">{`cmux ssh-tmux dev@example.com\ncmux ssh-tmux my-ssh-alias --port 2222 --identity ~/.ssh/id_ed25519`}</CodeBlock> + <CodeBlock lang="bash">{`cmux ssh-tmux dev@example.com\ncmux ssh-tmux my-ssh-alias --port 2222 --identity ~/.ssh/id_ed25519\ncmux ssh-tmux dev@example.com --new-window`}</CodeBlock> <p>{t("attachSockets")}</p> <DocsHeading level={3} id="permission-denied">{t("troubleshootTitle")}</DocsHeading> diff --git a/web/messages/en.json b/web/messages/en.json index 49d30e59186f..484dc86b3897 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1406,6 +1406,7 @@ "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", "attachTitle": "Attaching", "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux mirrors that host's tmux sessions into the current window's sidebar: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachNewWindow": "Pass --new-window to open the mirror in a dedicated new window instead of the current window.", "attachSockets": "The remote.tmux.* socket commands (below) give finer control. remote.tmux.mirror is the mirror entry point; it accepts window/caller routing and can optionally select the mirrored workspace.", "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then mirrors into the current window. Accepts --port, --identity, and --no-focus.", "troubleshootTitle": "If you get \"Permission denied\"", diff --git a/web/messages/ja.json b/web/messages/ja.json index 7e94e604b66d..42f0aed0004b 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1325,6 +1325,7 @@ "enableDesc": "設定 → ベータ機能 を開き、「リモート tmux」をオンにします。デフォルトはオフなので、オプトインするまでローカルのターミナルには何も影響しません。", "attachTitle": "接続する", "attachIntro": "ターミナルで cmux ssh-tmux <宛先>(~/.ssh/config のエイリアスまたは user@host)を実行します。cmux はそのホストの tmux セッションを現在のウィンドウのサイドバーにミラーリングします。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはタブ内の分割になります。", + "attachNewWindow": "--new-window を指定すると、現在のウィンドウの代わりに専用の新しいウィンドウでミラーを開きます。", "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。remote.tmux.mirror がミラーリングのエントリポイントで、ウィンドウや呼び出し元のルーティングを受け取り、必要に応じてミラーワークスペースを選択できます。", "attachCli": "非対話で認証されるホスト(ssh-agent や ~/.ssh/config の鍵)はプロンプトなしで接続されます。対話的な認証(パスワード、ホストキーの確認、多要素認証)が必要なホストでは、cmux がそのターミナル内で ssh を実行するため、その場で認証でき、その後 cmux が現在のウィンドウにミラーリングします。--port、--identity、--no-focus を指定できます。", "troubleshootTitle": "「Permission denied」が出る場合",