diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ee48bf4c75a..92ece625de14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,12 +398,9 @@ jobs: runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} # A cold DerivedData cache (any project.pbxproj or Package.resolved change # mints a new cache key with no restore-keys fallback) forces a full - # cmux build whose Swift codegen alone runs ~18-20 min, so 20 was right at - # the edge and got guillotined mid-build (the post-cache step never ran, so - # the cache stayed cold and every retry timed out the same way). 35 gives a - # cold build enough room to finish and populate the cache. Extension project - # and package graph changes can still spend more than 35 minutes in the - # build step before the lag regression starts. + # cmux build whose Swift codegen alone can run 20+ min. Project/package + # changes from the sidebar extension kit pushed this full build plus the + # CA and lag regressions beyond 35 min before the cache could repopulate. timeout-minutes: 55 steps: - name: Checkout diff --git a/.github/workflows/tmux-corpus.yml b/.github/workflows/tmux-corpus.yml index 736efa583bae..04bbf50bb77c 100644 --- a/.github/workflows/tmux-corpus.yml +++ b/.github/workflows/tmux-corpus.yml @@ -1,6 +1,10 @@ name: tmux corpus on: + pull_request: + paths: + - ".github/workflows/tmux-corpus.yml" + - "daemon/remote/**" workflow_dispatch: inputs: fuzztime: @@ -41,7 +45,7 @@ jobs: - name: Run tmux corpus fuzz targets working-directory: daemon/remote env: - FUZZTIME: ${{ github.event.inputs.fuzztime || '2m' }} + FUZZTIME: ${{ github.event_name == 'pull_request' && '30s' || github.event.inputs.fuzztime || '2m' }} run: | set -euo pipefail for target in \ diff --git a/CLI/cmux.swift b/CLI/cmux.swift index eac36e586855..f9d4be9205bb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7461,6 +7461,9 @@ struct CMUXCLI { sshOptions.extraArguments.isEmpty && remoteTerminalBootstrapScript?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && deferredRemoteReconnectCommandScript != nil + let persistentDaemonSlot = usesPersistentSSHPTY + ? "ssh-\(UUID().uuidString.lowercased())" + : nil let startupInitialSSHCommand = buildSSHCommandText( sshOptions, localCommandScript: combinedLocalCommandScript @@ -7612,8 +7615,9 @@ struct CMUXCLI { if sshOptions.skipDaemonBootstrap { configureParams["skip_daemon_bootstrap"] = true } - if usesPersistentSSHPTY { + if let persistentDaemonSlot { configureParams["preserve_after_terminal_exit"] = true + configureParams["persistent_daemon_slot"] = persistentDaemonSlot } cliDebugLog( @@ -7682,6 +7686,9 @@ struct CMUXCLI { if usesPersistentSSHPTY, let workspaceInitialSurfaceId { payload["ssh_pty_session_id"] = "ssh-\(workspaceId)-\(workspaceInitialSurfaceId)" } + if let persistentDaemonSlot { + payload["persistent_daemon_slot"] = persistentDaemonSlot + } logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) @@ -9779,7 +9786,7 @@ struct CMUXCLI { let quotedSessionID = shellQuote(sessionID) let currentExecutable = shellQuote(resolvedExecutableURL()?.path ?? (args.first ?? "cmux")) let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait --require-existing --workspace \"$CMUX_WORKSPACE_ID\" --session-id \(quotedSessionID) --attachment-id \"${CMUX_SURFACE_ID:-}\"" - return ([ + let script = ([ "cmux_ssh_attach_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"", "if [ -z \"$cmux_ssh_attach_cli\" ] || [ ! -x \"$cmux_ssh_attach_cli\" ]; then cmux_ssh_attach_cli=\(currentExecutable); fi", "if [ -z \"$cmux_ssh_attach_cli\" ] || [ ! -x \"$cmux_ssh_attach_cli\" ]; then cmux_ssh_attach_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", @@ -9787,6 +9794,7 @@ struct CMUXCLI { "if [ -z \"${CMUX_SOCKET_PATH:-}\" ]; then printf '%s\\n' '[cmux] required configuration missing for SSH PTY attach.' >&2; exit 1; fi", "if [ -z \"${CMUX_WORKSPACE_ID:-}\" ]; then printf '%s\\n' '[cmux] required workspace context missing for SSH PTY attach.' >&2; exit 1; fi", ] + sshPTYAttachRetryLoopLines(command: attachCommand)).joined(separator: "\n") + return "/bin/sh -c \(shellQuote(script))" } private func sshPTYAttachRetryLoopLines(command: String) -> [String] { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d4a17085e6ca..5cdb3cd95f9e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,750 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "remoteDaemon.error.missingPersistentPTYCapability": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンは永続 SSH PTY セッションをサポートしていません。cmux を更新するにはリモートワークスペースに再接続してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + } + } + }, + "remoteDaemon.error.missingRequiredFunctionality": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンに必要な機能がありません。cmux を更新するにはリモートワークスペースに再接続してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + } + } + }, + "remotePTYAttach.error.attachFailed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote PTY attach failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート PTY への接続に失敗しました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + } + } + }, + "remotePTYAttach.error.daemonTimeout": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote daemon did not respond in time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンが時間内に応答しませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + } + } + }, + "remotePTYAttach.error.inputBackedUp": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote PTY input is temporarily backed up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート PTY 入力が一時的に滞留しています" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + } + } + }, + "remotePTYAttach.error.sessionEnded": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "永続 SSH PTY セッションはもう実行されていません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + } + } + }, "settings.error.alert.dismiss": { "localizations": { "en": { diff --git a/Sources/RemoteInteractiveShellBootstrapBuilder.swift b/Sources/RemoteInteractiveShellBootstrapBuilder.swift new file mode 100644 index 000000000000..18c1b5799fb6 --- /dev/null +++ b/Sources/RemoteInteractiveShellBootstrapBuilder.swift @@ -0,0 +1,278 @@ +import Foundation + +enum RemoteInteractiveShellBootstrapBuilder { + static func script( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil, + bundledZshIntegration: String? = nil, + bundledBashIntegration: String? = nil + ) -> String { + let shellStateDir = shellStateDirForRemoteRelayPort(remoteRelayPort) + let commonShellExportLines = commonShellLines( + remoteRelayPort: remoteRelayPort, + shellStateDir: shellStateDir, + shellFeatures: shellFeatures, + terminfoSource: terminfoSource + ) + var zshShellLines = commonShellExportLines + zshShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh"; fi"# + ) + var bashShellLines = commonShellExportLines + bashShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash"; fi"# + ) + let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir) + let relayWarmupLines = relayWarmupLines(remoteRelayPort: remoteRelayPort) + + var outerLines: [String] = [ + "mkdir -p \"$HOME/.cmux/relay\"", + "cmux_shell_dir=\"\(shellStateDir)\"", + "mkdir -p \"$cmux_shell_dir\"", + ] + if let bundledZshIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-zsh-integration.zsh\" <<'CMUXCMUXZSH'", + bundledZshIntegration, + "CMUXCMUXZSH", + ] + } + if let bundledBashIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-bash-integration.bash\" <<'CMUXCMUXBASH'", + bundledBashIntegration, + "CMUXCMUXBASH", + ] + } + outerLines.append(contentsOf: commonShellExportLines) + outerLines += [ + "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", + "case \"${CMUX_LOGIN_SHELL##*/}\" in", + " zsh)", + " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", + ] + outerLines.append(contentsOf: zshBootstrap.zshEnvLines) + outerLines += [ + "CMUXZSHENV", + " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", + ] + outerLines.append(contentsOf: zshBootstrap.zshProfileLines) + outerLines += [ + "CMUXZSHPROFILE", + " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", + ] + outerLines.append(contentsOf: zshBootstrap.zshRCLines(commonShellLines: zshShellLines)) + outerLines += [ + "CMUXZSHRC", + " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", + ] + outerLines.append(contentsOf: zshBootstrap.zshLoginLines) + outerLines += [ + "CMUXZSHLOGIN", + " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", + " export ZDOTDIR=\"$cmux_shell_dir\"", + " exec \"$CMUX_LOGIN_SHELL\" -il", + " ;;", + " bash)", + " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", + ] + outerLines.append(contentsOf: [ + "if [ -f \"$HOME/.bash_profile\" ]; then", + " . \"$HOME/.bash_profile\"", + "elif [ -f \"$HOME/.bash_login\" ]; then", + " . \"$HOME/.bash_login\"", + "elif [ -f \"$HOME/.profile\" ]; then", + " . \"$HOME/.profile\"", + "fi", + "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", + ] + bashShellLines) + 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", + " ;;", + " *)", + ] + outerLines.append(contentsOf: relayWarmupLines) + outerLines += [ + "exec \"$CMUX_LOGIN_SHELL\" -i", + ";;", + "esac", + ] + + return outerLines.joined(separator: "\n") + } + + static func shellFeatures( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + let rawExisting = environment["GHOSTTY_SHELL_FEATURES"] ?? "" + var seen: Set = [] + var merged: [String] = [] + + for token in rawExisting.split(separator: ",") { + let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !feature.isEmpty else { continue } + if seen.insert(feature).inserted { + merged.append(feature) + } + } + + for required in ["ssh-env", "ssh-terminfo"] { + if seen.insert(required).inserted { + merged.append(required) + } + } + + return merged.joined(separator: ",") + } + + static func bundledShellIntegrationScript( + named fileName: String, + bundleResourceURL: URL? = Bundle.main.resourceURL, + fileManager: FileManager = .default + ) -> String? { + guard let bundleResourceURL else { return nil } + let url = bundleResourceURL + .appendingPathComponent("shell-integration", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + guard fileManager.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let contents = String(data: data, encoding: .utf8) else { + return nil + } + return contents + } + + private static func commonShellLines( + remoteRelayPort: Int, + shellStateDir: String, + shellFeatures: String, + terminfoSource: String? + ) -> [String] { + let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil + var lines = terminalSetupLines(terminfoSource: terminfoSource) + lines.append(contentsOf: RemoteShellEnvironment.utf8LocaleSetupLines()) + lines.append(contentsOf: shellExportLines(shellFeatures: shellFeatures)) + lines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"") + lines.append("export CMUX_BUNDLED_CLI_PATH=\"$HOME/.cmux/bin/cmux\"") + lines.append("export CMUX_SHELL_INTEGRATION_DIR=\"\(shellStateDir)\"") + if let relaySocket { + lines.append("export CMUX_SOCKET_PATH=\(relaySocket)") + } + // The assignment placeholders are replaced by `ssh-pty-attach` before + // this script runs. Split the sentinel patterns so a missed replacement + // does not export literal placeholder IDs into the remote shell. + lines.append(contentsOf: [ + "cmux_workspace_id='__CMUX_WORKSPACE_ID__'", + "case \"$cmux_workspace_id\" in \"\"|'__CMUX_''WORKSPACE_ID__') ;; *) export CMUX_WORKSPACE_ID=\"$cmux_workspace_id\"; export CMUX_TAB_ID=\"$cmux_workspace_id\" ;; esac", + "cmux_surface_id='__CMUX_SURFACE_ID__'", + "case \"$cmux_surface_id\" in \"\"|'__CMUX_''SURFACE_ID__') ;; *) export CMUX_SURFACE_ID=\"$cmux_surface_id\"; export CMUX_PANEL_ID=\"$cmux_surface_id\" ;; esac", + "unset cmux_workspace_id cmux_surface_id", + "hash -r >/dev/null 2>&1 || true", + "rehash >/dev/null 2>&1 || true", + ]) + return lines + } + + private static func terminalSetupLines(terminfoSource: String?) -> [String] { + var lines: [String] = [ + "cmux_term='xterm-256color'", + "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", + " cmux_term='xterm-ghostty'", + "fi", + "export TERM=\"$cmux_term\"", + ] + guard let terminfoSource else { return lines } + let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTerminfoSource.isEmpty else { return lines } + lines += [ + "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", + " (", + " command -v tic >/dev/null 2>&1 || exit 0", + " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", + " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", + trimmedTerminfoSource, + "CMUXTERMINFO", + " ) >/dev/null 2>&1 &", + "fi", + ] + return lines + } + + private static func shellExportLines(shellFeatures: String) -> [String] { + let environment = ProcessInfo.processInfo.environment + let colorTerm = normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" + let termProgram = normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" + let termProgramVersion = normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + ?? "" + let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + + var exports: [String] = [ + "export COLORTERM=\(shellQuote(colorTerm))", + "export TERM_PROGRAM=\(shellQuote(termProgram))", + ] + if !termProgramVersion.isEmpty { + exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") + } + if !trimmedShellFeatures.isEmpty { + exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") + } + return exports + } + + private static func relayWarmupLines(remoteRelayPort: Int) -> [String] { + guard remoteRelayPort > 0 else { + return [] + } + return [ + "cmux_relay_cli=\"${CMUX_BUNDLED_CLI_PATH:-$HOME/.cmux/bin/cmux}\"", + "if [ ! -x \"$cmux_relay_cli\" ]; then cmux_relay_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${CMUX_BOOTSTRAP_TTY:-}\"", + "if [ -z \"$cmux_relay_tty\" ]; then cmux_relay_tty=\"$(tty 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${cmux_relay_tty##*/}\"", + "if [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " mkdir -p \"$HOME/.cmux/relay\" >/dev/null 2>&1 || true", + " printf '%s' \"$cmux_relay_tty\" > \"$HOME/.cmux/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", + "fi", + "if [ -n \"$cmux_relay_cli\" ] && [ -n \"$CMUX_WORKSPACE_ID\" ] && [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " if [ -n \"$CMUX_SURFACE_ID\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " fi", + " \"$cmux_relay_cli\" rpc surface.report_tty \"$cmux_relay_report_tty\" >/dev/null 2>&1 || true", + " \"$cmux_relay_cli\" rpc surface.ports_kick \"$cmux_relay_ports_kick\" >/dev/null 2>&1 || true", + "fi", + "unset CMUX_BOOTSTRAP_TTY cmux_relay_cli cmux_relay_tty cmux_relay_report_tty cmux_relay_ports_kick", + ] + } + + private static func shellStateDirForRemoteRelayPort(_ remoteRelayPort: Int) -> String { + "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" + } + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func shellQuote(_ value: String) -> String { + let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" + if value.range(of: safePattern, options: .regularExpression) != nil { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index b3a35fc9d06c..27436b09b9e0 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1373,6 +1373,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { var hibernation: SessionAgentHibernationSnapshot? var resumeBinding: SurfaceResumeBindingSnapshot? var textBoxDraft: SessionTextBoxInputDraftSnapshot? + var isRemoteTerminal: Bool? var remotePTYSessionID: String? /// Whether the agent process was actively running when this snapshot was captured. /// Nil means unknown (legacy snapshots); treated as true for backwards compatibility. @@ -1386,6 +1387,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { hibernation: SessionAgentHibernationSnapshot? = nil, resumeBinding: SurfaceResumeBindingSnapshot? = nil, textBoxDraft: SessionTextBoxInputDraftSnapshot? = nil, + isRemoteTerminal: Bool? = nil, remotePTYSessionID: String? = nil, wasAgentRunning: Bool? = nil ) { @@ -1396,6 +1398,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { self.hibernation = hibernation self.resumeBinding = resumeBinding self.textBoxDraft = textBoxDraft + self.isRemoteTerminal = isRemoteTerminal self.remotePTYSessionID = remotePTYSessionID self.wasAgentRunning = wasAgentRunning } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2dd60865f78d..9b11230b7450 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -8053,23 +8053,32 @@ class TabManager: ObservableObject { func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } - let keepsRemoteWorkspaceOpen = + let keepsPersistentRemoteSurfaceOpen = + tab.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(surfaceId) + let handlesRemoteExitThroughWorkspace = tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) #if DEBUG cmuxDebugLog( "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count) " + - "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(keepsRemoteWorkspaceOpen ? 1 : 0)" + "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(handlesRemoteExitThroughWorkspace ? 1 : 0) " + + "keepPersistentRemote=\(keepsPersistentRemoteSurfaceOpen ? 1 : 0)" ) #endif - // Exiting the last SSH surface should demote the workspace back to a local one. - // Route through Workspace close handling so remote teardown and replacement-panel - // logic run before TabManager considers removing the workspace itself, including - // session-end paths where remote configuration was cleared before Ghostty delivered - // the child-exit callback. - if keepsRemoteWorkspaceOpen { + // A persistent SSH workspace must never silently replace a failed remote attach with + // a local login shell. Keep the exited surface visible so the user can see the error + // and retry instead of making a detached remote workspace look local after relaunch. + if keepsPersistentRemoteSurfaceOpen { + tab.markPersistentRemotePTYAttachFailed(surfaceId: surfaceId) + return + } + + // Exiting the last non-persistent SSH surface should demote the workspace back to a + // local one. Route through Workspace close handling so remote teardown and replacement + // panel logic run before TabManager considers removing the workspace itself. + if handlesRemoteExitThroughWorkspace { closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) return } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0f9a74530a10..c3e8638dd1d9 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -381,6 +381,15 @@ class TerminalController { return body() } + nonisolated func currentSocketPathForRemoteRestore() -> String? { + withListenerState { + if isRunning || acceptLoopAlive || listenerStartInProgress || serverSocket >= 0 { + return socketPath + } + return reservedStartupSocketPath + } + } + private nonisolated func listenerStateSnapshot() -> ListenerStateSnapshot { withListenerState { ListenerStateSnapshot( @@ -6833,6 +6842,21 @@ class TerminalController { let localSocketPath = v2RawString(params, "local_socket_path") let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? .trimmingCharacters(in: .whitespacesAndNewlines) + var persistentDaemonSlot = v2RawString(params, "persistent_daemon_slot")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if v2HasNonNullParam(params, "persistent_daemon_slot") { + guard let persistentDaemonSlot, + !persistentDaemonSlot.isEmpty, + persistentDaemonSlot.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil, + persistentDaemonSlot != ".", + persistentDaemonSlot != ".." else { + return .err( + code: "invalid_params", + message: "persistent_daemon_slot must contain only letters, numbers, '.', '_' or '-'", + data: nil + ) + } + } let daemonWebSocketURL = v2RawString(params, "daemon_websocket_url")? .trimmingCharacters(in: .whitespacesAndNewlines) let daemonWebSocketToken = v2RawString(params, "daemon_websocket_token")? @@ -6874,6 +6898,20 @@ class TerminalController { ) } let skipDaemonBootstrap = v2Bool(params, "skip_daemon_bootstrap") ?? false + if persistentDaemonSlot != nil, !preserveAfterTerminalExit { + return .err( + code: "invalid_params", + message: "preserve_after_terminal_exit is required when persistent_daemon_slot is set", + data: nil + ) + } + if preserveAfterTerminalExit, + transport == .ssh, + !skipDaemonBootstrap, + daemonWebSocketEndpoint == nil, + persistentDaemonSlot == nil { + persistentDaemonSlot = "ssh-\(workspaceId.uuidString.lowercased())" + } if relayPort != nil { guard let relayID, !relayID.isEmpty else { return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) @@ -6920,6 +6958,7 @@ class TerminalController { foregroundAuthToken: foregroundAuthToken?.isEmpty == true ? nil : foregroundAuthToken, daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, + persistentDaemonSlot: persistentDaemonSlot?.isEmpty == true ? nil : persistentDaemonSlot, skipDaemonBootstrap: skipDaemonBootstrap ) workspace.configureRemoteConnection(config, autoConnect: autoConnect) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 40bb7b7ffdf1..855b3a3b942f 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -273,7 +273,9 @@ extension Workspace { invalidatedRestoredAgentFingerprintsByPanelId.removeAll(keepingCapacity: false) surfaceResumeBindingsByPanelId.removeAll(keepingCapacity: false) - let restoredRemoteConfiguration = snapshot.remote?.workspaceConfiguration() + let restoredRemoteConfiguration = snapshot.remote?.workspaceConfiguration( + localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore() + ) if let restoredRemoteConfiguration { let shouldAutoConnect = Self.shouldAutoConnectRestoredRemote( foregroundAuthToken: restoredRemoteConfiguration.foregroundAuthToken, @@ -293,7 +295,12 @@ extension Workspace { } let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) - let leafEntries = restoreSessionLayout(snapshot.layout) + let leafEntries: [SessionPaneRestoreEntry] = { + let previousValue = suppressRemoteTerminalStartupForSessionRestoreScaffold + suppressRemoteTerminalStartupForSessionRestoreScaffold = true + defer { suppressRemoteTerminalStartupForSessionRestoreScaffold = previousValue } + return restoreSessionLayout(snapshot.layout) + }() var oldToNewPanelIds: [UUID: UUID] = [:] for entry in leafEntries { @@ -301,6 +308,7 @@ extension Workspace { entry.paneId, snapshot: entry.snapshot, panelSnapshotsById: panelSnapshotsById, + snapshotWorkspaceId: snapshot.workspaceId, oldToNewPanelIds: &oldToNewPanelIds ) } @@ -595,6 +603,7 @@ extension Workspace { }, resumeBinding: resumeBinding, textBoxDraft: terminalPanel.sessionTextBoxDraftSnapshot(), + isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), remotePTYSessionID: remotePTYSessionIDForSnapshot(panelId: panelId), wasAgentRunning: agentWasRunning ) @@ -790,7 +799,11 @@ extension Workspace { @discardableResult private func restoreClosedPanel(_ entry: ClosedPanelHistoryEntry, inPane pane: PaneID) -> UUID? { - guard let panelId = createPanel(from: entry.snapshot, inPane: pane) else { return nil } + guard let panelId = createPanel( + from: entry.snapshot, + inPane: pane, + snapshotWorkspaceId: nil + ) else { return nil } let maxIndex = max(0, bonsplitController.tabs(inPane: pane).count - 1) _ = reorderSurface(panelId: panelId, toIndex: min(max(entry.tabIndex, 0), maxIndex)) @@ -824,7 +837,11 @@ extension Workspace { return nil } - guard let panelId = createPanel(from: entry.snapshot, inPane: pane) else { + guard let panelId = createPanel( + from: entry.snapshot, + inPane: pane, + snapshotWorkspaceId: nil + ) else { _ = closePanel(placeholderPanel.id, force: true) return nil } @@ -869,7 +886,16 @@ extension Workspace { let normalizedForegroundAuthToken = foregroundAuthToken? .trimmingCharacters(in: .whitespacesAndNewlines) guard normalizedForegroundAuthToken?.isEmpty == false else { return true } - return !snapshot.panels.contains { $0.terminal != nil } + let hasTerminalThatWillAuthenticateReconnect = snapshot.panels.contains { + guard let terminal = $0.terminal else { return false } + if terminal.isRemoteTerminal != false { + return true + } + let remotePTYSessionID = terminal.remotePTYSessionID? + .trimmingCharacters(in: .whitespacesAndNewlines) + return remotePTYSessionID?.isEmpty == false + } + return !hasTerminalThatWillAuthenticateReconnect } nonisolated enum SurfaceResumeStartupLaunch { @@ -1475,6 +1501,7 @@ extension Workspace { _ paneId: PaneID, snapshot: SessionPaneLayoutSnapshot, panelSnapshotsById: [UUID: SessionPanelSnapshot], + snapshotWorkspaceId: UUID?, oldToNewPanelIds: inout [UUID: UUID] ) { let existingPanelIds = bonsplitController @@ -1485,7 +1512,11 @@ extension Workspace { var createdPanelIds: [UUID] = [] for oldPanelId in desiredOldPanelIds { guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } - guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + guard let createdPanelId = createPanel( + from: panelSnapshot, + inPane: paneId, + snapshotWorkspaceId: snapshotWorkspaceId + ) else { continue } createdPanelIds.append(createdPanelId) oldToNewPanelIds[oldPanelId] = createdPanelId } @@ -1556,7 +1587,11 @@ extension Workspace { return storedBinding } - private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + private func createPanel( + from snapshot: SessionPanelSnapshot, + inPane paneId: PaneID, + snapshotWorkspaceId: UUID? + ) -> UUID? { switch snapshot.type { case .terminal: let resumeBinding = snapshot.terminal?.resumeBinding @@ -1591,11 +1626,12 @@ extension Workspace { } } let effectiveResumeBinding = restoredBindingLaunch == nil ? nil : resumeBinding - let workingDirectory = + let savedWorkingDirectory = effectiveResumeBinding?.cwd ?? snapshot.terminal?.workingDirectory ?? restorableAgent?.workingDirectory ?? snapshot.directory + let workingDirectory = savedWorkingDirectory ?? currentDirectory let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingLaunch == nil ? Self.restorableTmuxStartCommand(snapshot.terminal?.tmuxStartCommand) @@ -1627,11 +1663,22 @@ extension Workspace { tmuxStartCommand: restoredTmuxStartCommand, hasResumeStartupWork: restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil ) - // Snapshot session IDs belong to the previous app run's remote daemon. - // Restored persistent SSH terminals start a fresh attach path and replay - // local scrollback until the new remote PTY is ready. - let restoredRemotePTYSessionID: String? = nil - let restoredRemotePTYAttachCommand: String? = nil + let restoredRemotePTYSessionID: String? = { + guard remoteConfiguration?.preserveAfterTerminalExit == true, + remoteConfiguration?.persistentDaemonSlot != nil else { + return nil + } + if let remotePTYSessionID = normalizedRemotePTYSessionID(snapshot.terminal?.remotePTYSessionID) { + return remotePTYSessionID + } + guard snapshot.terminal?.isRemoteTerminal == true else { + return nil + } + return Self.defaultSSHPTYSessionID(workspaceId: snapshotWorkspaceId ?? id, panelId: snapshot.id) + }() + let restoredRemotePTYAttachCommand = restoredRemotePTYSessionID.map { + remotePTYAttachStartupCommand(sessionID: $0) + } let restoredStartupCommand = restoredRemotePTYAttachCommand ?? restoredTmuxStartupScript?.path @@ -1646,8 +1693,18 @@ extension Workspace { (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) // Guarded startup commands cd themselves and tolerate deleted saved directories. // Passing the same cwd to Ghostty can fail before the guarded command runs. - let localWorkingDirectory = remoteStartupCommand == nil && !startupHandlesWorkingDirectory - ? workingDirectory + let suppressWorkspaceRemoteStartupCommand = + remoteConfiguration != nil && + snapshot.terminal?.isRemoteTerminal == false && + restoredRemotePTYAttachCommand == nil + let effectiveRemoteStartupCommand = suppressWorkspaceRemoteStartupCommand ? nil : remoteStartupCommand + let restoresRemoteWorkspaceTerminalSnapshot = + remoteConfiguration != nil && snapshot.terminal?.isRemoteTerminal == true + let localWorkingDirectory = effectiveRemoteStartupCommand == nil && + restoredRemotePTYAttachCommand == nil && + !restoresRemoteWorkspaceTerminalSnapshot && + !startupHandlesWorkingDirectory + ? (suppressWorkspaceRemoteStartupCommand ? savedWorkingDirectory : workingDirectory) : nil let restoredAgentWillRunStartupCommand = restorableAgent != nil && ( restoredAgentResumeLaunch?.initialCommand != nil || @@ -1690,10 +1747,22 @@ extension Workspace { tmuxStartCommand: restoredTmuxStartCommand, initialInput: restoredStartupInput, startupEnvironment: replayEnvironment, - remotePTYSessionID: restoredRemotePTYSessionID + remotePTYSessionID: restoredRemotePTYSessionID, + suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand ) else { return nil } + if let restoredRemotePTYSessionID { + registerRemoteRelayIDAliases( + remotePTYSessionID: restoredRemotePTYSessionID, + restoredPanelId: terminalPanel.id + ) + registerRemoteRelayIDAliases( + snapshotWorkspaceId: snapshotWorkspaceId, + snapshotPanelId: snapshot.id, + restoredPanelId: terminalPanel.id + ) + } if let storedResumeBinding = effectiveResumeBindingForStartup ?? resumeBinding { surfaceResumeBindingsByPanelId[terminalPanel.id] = storedResumeBinding } else { @@ -1842,6 +1911,7 @@ extension Workspace { } browserPanel.restoreSessionSnapshot(browserSnapshot) + syncBrowserAudioMuteStateForPanel(panelId, browserPanel: browserPanel) if browserSnapshot.developerToolsVisible && BrowserAvailabilitySettings.isEnabled() { _ = browserPanel.showDeveloperTools() @@ -2354,10 +2424,17 @@ protocol WorkspaceRemotePTYBridgeRPCClient: AnyObject { nonisolated func remoteDaemonMissingRequiredCapabilitiesMessage(_ missingCapabilities: [String]) -> String { let missing = Set(missingCapabilities) if missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionCapability) || - missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability) { - return "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability) || + missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYPersistentDaemonCapability) { + return String( + localized: "remoteDaemon.error.missingPersistentPTYCapability", + defaultValue: "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + ) } - return "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + return String( + localized: "remoteDaemon.error.missingRequiredFunctionality", + defaultValue: "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + ) } private final class WorkspaceRemoteDaemonRPCClient { @@ -2367,6 +2444,7 @@ private final class WorkspaceRemoteDaemonRPCClient { static let requiredProxyStreamCapability = "proxy.stream.push" static let requiredPTYSessionCapability = "pty.session" static let requiredPTYSessionTokenCapability = "pty.session.token" + static let requiredPTYPersistentDaemonCapability = "pty.session.persistent_daemon" enum StreamEvent { case data(Data) @@ -2508,6 +2586,9 @@ private final class WorkspaceRemoteDaemonRPCClient { capabilities.append(requiredPTYSessionCapability) capabilities.append(requiredPTYSessionTokenCapability) } + if configuration.persistentDaemonSlot != nil { + capabilities.append(requiredPTYPersistentDaemonCapability) + } return capabilities } @@ -4895,6 +4976,7 @@ private final class WorkspaceRemoteCLIRelayServer { private let localSocketPath: String private let relayID: String private let relayToken: Data + private let commandRewriter: (Data) -> Data private let queue: DispatchQueue private let onClose: () -> Void private let challengeProtocol = "cmux-relay-auth" @@ -4913,6 +4995,7 @@ private final class WorkspaceRemoteCLIRelayServer { localSocketPath: String, relayID: String, relayToken: Data, + commandRewriter: @escaping (Data) -> Data, queue: DispatchQueue, onClose: @escaping () -> Void ) { @@ -4920,6 +5003,7 @@ private final class WorkspaceRemoteCLIRelayServer { self.localSocketPath = localSocketPath self.relayID = relayID self.relayToken = relayToken + self.commandRewriter = commandRewriter self.queue = queue self.onClose = onClose } @@ -5040,8 +5124,11 @@ private final class WorkspaceRemoteCLIRelayServer { return } phase = .forwarding - DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in - let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + let forwardedCommandLine = commandRewriter(commandLine) + DispatchQueue.global(qos: .utility).async { [localSocketPath, forwardedCommandLine, queue] in + let result = Result { + try Self.roundTripUnixSocket(socketPath: localSocketPath, request: forwardedCommandLine) + } queue.async { [weak self] in guard let self else { return } switch result { @@ -5228,8 +5315,14 @@ private final class WorkspaceRemoteCLIRelayServer { private var sessions: [UUID: Session] = [:] private var isStopped = false private(set) var localPort: Int? + private var workspaceAliases: [UUID: UUID] = [:] + private var surfaceAliases: [UUID: UUID] = [:] - init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { + init( + localSocketPath: String, + relayID: String, + relayTokenHex: String + ) throws { guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { throw NSError(domain: "cmux.remote.relay", code: 7, userInfo: [ NSLocalizedDescriptionKey: "invalid relay token", @@ -5333,6 +5426,13 @@ private final class WorkspaceRemoteCLIRelayServer { } } + func updateRemoteRelayIDAliases(workspaceAliases: [UUID: UUID], surfaceAliases: [UUID: UUID]) { + queue.async { [weak self] in + self?.workspaceAliases = workspaceAliases + self?.surfaceAliases = surfaceAliases + } + } + private func acceptConnectionLocked(_ connection: NWConnection) { guard !isStopped else { connection.cancel() @@ -5344,6 +5444,9 @@ private final class WorkspaceRemoteCLIRelayServer { localSocketPath: localSocketPath, relayID: relayID, relayToken: relayToken, + commandRewriter: { [weak self] commandLine in + self?.rewriteCommandLineLocked(commandLine) ?? commandLine + }, queue: queue ) { [weak self] in self?.sessions.removeValue(forKey: sessionID) @@ -5352,6 +5455,14 @@ private final class WorkspaceRemoteCLIRelayServer { session.start() } + private func rewriteCommandLineLocked(_ commandLine: Data) -> Data { + Workspace.rewriteRemoteRelayCommandLine( + commandLine, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases + ) + } + private static func makeLoopbackListener() throws -> NWListener { let tcpOptions = NWProtocolTCP.Options() tcpOptions.noDelay = true @@ -5852,20 +5963,35 @@ final class WorkspaceRemotePTYBridgeServer { let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) let lowered = message.lowercased() if lowered.contains("missing required capability") || lowered.contains("pty.session") { - return "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + return String( + localized: "remoteDaemon.error.missingPersistentPTYCapability", + defaultValue: "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + ) } if lowered.contains("pty_session_not_found") || (lowered.contains("persistent ssh pty session") && lowered.contains("not running")) || (lowered.contains("persistent pty session") && lowered.contains("not running")) { - return "persistent SSH PTY session is no longer running" + return String( + localized: "remotePTYAttach.error.sessionEnded", + defaultValue: "persistent SSH PTY session is no longer running" + ) } if lowered.contains("pty_input_queue_full") || lowered.contains("pty input queue is full") { - return "remote PTY input is temporarily backed up" + return String( + localized: "remotePTYAttach.error.inputBackedUp", + defaultValue: "remote PTY input is temporarily backed up" + ) } if lowered.contains("timed out") || lowered.contains("timeout") { - return "remote daemon did not respond in time" + return String( + localized: "remotePTYAttach.error.daemonTimeout", + defaultValue: "remote daemon did not respond in time" + ) } - return "remote PTY attach failed" + return String( + localized: "remotePTYAttach.error.attachFailed", + defaultValue: "remote PTY attach failed" + ) } } @@ -6197,6 +6323,8 @@ final class WorkspaceRemoteSessionController { private var heartbeatCount: Int = 0 private var connectionAttemptStartedAt: Date? private var pendingPTYBridgeStarts: [UUID: PendingPTYBridgeStart] = [:] + private var remoteRelayWorkspaceAliases: [UUID: UUID] = [:] + private var remoteRelaySurfaceAliases: [UUID: UUID] = [:] private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 @@ -6226,6 +6354,18 @@ final class WorkspaceRemoteSessionController { } } + func updateRemoteRelayIDAliases(workspaceAliases: [UUID: UUID], surfaceAliases: [UUID: UUID]) { + queue.async { [weak self] in + guard let self else { return } + self.remoteRelayWorkspaceAliases = workspaceAliases + self.remoteRelaySurfaceAliases = surfaceAliases + self.cliRelayServer?.updateRemoteRelayIDAliases( + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases + ) + } + } + func listPTYSessions(timeout: TimeInterval = 8.0) throws -> [[String: Any]] { try runOnControllerQueue(timeout: timeout) { guard self.daemonReady, self.proxyLease != nil else { @@ -6604,7 +6744,8 @@ final class WorkspaceRemoteSessionController { Self.killOrphanedRemoteSSHProcesses( destination: configuration.destination, - relayPort: configuration.relayPort + relayPort: configuration.relayPort, + persistentDaemonSlot: configuration.persistentDaemonSlot ) connectionAttemptStartedAt = Date() debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") @@ -6755,11 +6896,12 @@ final class WorkspaceRemoteSessionController { let localRelayPort = try server.start() Self.killOrphanedRemoteSSHProcesses( destination: configuration.destination, - relayPort: relayPort + relayPort: relayPort, + persistentDaemonSlot: configuration.persistentDaemonSlot ) let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" - if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { + if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec, relayPort: relayPort) { cliRelayServer = relayServer reverseRelayStderrBuffer = "" do { @@ -6808,7 +6950,12 @@ final class WorkspaceRemoteSessionController { "remote.relay.startFailed relayPort=\(relayPort) " + "error=\(startupFailure)" ) - relayServer?.stop() + if let relayServer { + relayServer.stop() + if cliRelayServer === relayServer { + cliRelayServer = nil + } + } publishDaemonStatus( .error, detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" @@ -6844,8 +6991,12 @@ final class WorkspaceRemoteSessionController { "remote.relay.startFailed relayPort=\(relayPort) " + "error=\(error.localizedDescription)" ) - relayServer?.stop() - cliRelayServer = nil + if let relayServer { + relayServer.stop() + if cliRelayServer === relayServer { + cliRelayServer = nil + } + } scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) } } @@ -7172,7 +7323,7 @@ final class WorkspaceRemoteSessionController { return args } - private func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { + private func startReverseRelayViaControlMasterLocked(forwardSpec: String, relayPort: Int) -> Bool { guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( configuration: configuration, controlCommand: "forward", @@ -7181,13 +7332,26 @@ final class WorkspaceRemoteSessionController { return false } + cancelStaleReverseRelayViaControlMasterLocked(relayPort: relayPort) do { - let result = try sshExec(arguments: arguments, timeout: 6) + var result = try sshExec(arguments: arguments, timeout: 6) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") - return false + guard cleanupStaleRemoteRelayListenerLocked(relayPort: relayPort) else { + return false + } + + result = try sshExec(arguments: arguments, timeout: 6) + guard result.status == 0 else { + let retryDetail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.controlmaster.forwardRetryFailed \(retryDetail) \(debugConfigSummary())") + return false + } + reverseRelayControlMasterForwardSpec = forwardSpec + return true } reverseRelayControlMasterForwardSpec = forwardSpec return true @@ -7197,6 +7361,65 @@ final class WorkspaceRemoteSessionController { } } + private func cancelStaleReverseRelayViaControlMasterLocked(relayPort: Int) { + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterCancelArguments( + configuration: configuration, + relayPort: relayPort + ) else { + return + } + do { + let result = try sshExec(arguments: arguments, timeout: 4) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.controlmaster.cancelStaleIgnored \(detail) \(debugConfigSummary())") + return + } + debugLog("remote.relay.controlmaster.cancelStale relayPort=\(relayPort) \(debugConfigSummary())") + } catch { + debugLog("remote.relay.controlmaster.cancelStaleIgnored \(error.localizedDescription) \(debugConfigSummary())") + } + } + + private func cleanupStaleRemoteRelayListenerLocked(relayPort: Int) -> Bool { + guard let script = Self.remoteStaleRelayListenerCleanupScript( + relayPort: relayPort, + persistentDaemonSlot: configuration.persistentDaemonSlot + ) else { + debugLog("remote.relay.remoteListener.cleanupSkipped reason=no-persistent-slot relayPort=\(relayPort)") + return false + } + + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec( + arguments: ["-S", "none"] + sshCommonArguments(batchMode: true, dropControlPath: true) + [ + configuration.destination, + command, + ], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.remoteListener.cleanupFailed relayPort=\(relayPort) \(detail) \(debugConfigSummary())") + return false + } + + let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if output.isEmpty { + debugLog("remote.relay.remoteListener.cleanupNoop relayPort=\(relayPort) \(debugConfigSummary())") + } else { + debugLog("remote.relay.remoteListener.cleanup relayPort=\(relayPort) \(Self.debugLogSnippet(output)) \(debugConfigSummary())") + } + return true + } catch { + debugLog("remote.relay.remoteListener.cleanupFailed relayPort=\(relayPort) \(error.localizedDescription) \(debugConfigSummary())") + return false + } + } + private func stopReverseRelayViaControlMasterLocked() { guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } reverseRelayControlMasterForwardSpec = nil @@ -7245,10 +7468,10 @@ final class WorkspaceRemoteSessionController { return message.isEmpty ? "remote daemon bootstrap failed" : message } - private func sshCommonArguments(batchMode: Bool) -> [String] { + private func sshCommonArguments(batchMode: Bool, dropControlPath: Bool = false) -> [String] { let effectiveSSHOptions: [String] = { if batchMode { - return backgroundSSHOptions(configuration.sshOptions) + return backgroundSSHOptions(configuration.sshOptions, dropControlPath: dropControlPath) } return normalizedSSHOptions(configuration.sshOptions) }() @@ -7296,11 +7519,14 @@ final class WorkspaceRemoteSessionController { } } - private func backgroundSSHOptions(_ options: [String]) -> [String] { - let batchSSHControlOptionKeys: Set = [ + private func backgroundSSHOptions(_ options: [String], dropControlPath: Bool = false) -> [String] { + var batchSSHControlOptionKeys: Set = [ "controlmaster", "controlpersist", ] + if dropControlPath { + batchSSHControlOptionKeys.insert("controlpath") + } return normalizedSSHOptions(options).filter { option in guard let key = sshOptionKey(option) else { return false } return !batchSSHControlOptionKeys.contains(key) @@ -7603,6 +7829,10 @@ final class WorkspaceRemoteSessionController { relayID: relayID, relayTokenHex: relayToken ) + relayServer.updateRemoteRelayIDAliases( + workspaceAliases: remoteRelayWorkspaceAliases, + surfaceAliases: remoteRelaySurfaceAliases + ) cliRelayServer = relayServer return relayServer } @@ -7617,7 +7847,8 @@ final class WorkspaceRemoteSessionController { daemonRemotePath: remotePath, relayPort: relayPort, relayID: relayID, - relayToken: relayToken + relayToken: relayToken, + persistentDaemonSlot: configuration.persistentDaemonSlot ) let command = "sh -c \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) @@ -7653,10 +7884,144 @@ final class WorkspaceRemoteSessionController { if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then rm -f "$socket_addr_file" fi - rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" "$HOME/.cmux/relay/\(relayPort).tty" + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" "$HOME/.cmux/relay/\(relayPort).slot" "$HOME/.cmux/relay/\(relayPort).tty" """ } + static func remoteStaleRelayListenerCleanupScript( + relayPort: Int, + persistentDaemonSlot: String? + ) -> String? { + guard relayPort > 0, relayPort <= 65535 else { return nil } + guard let persistentDaemonSlot = normalizedPersistentDaemonSlotForRemoteCleanup(persistentDaemonSlot) else { + return nil + } + + return """ + cmux_stale_relay_listener_cleanup=1 + cmux_relay_port='\(relayPort)' + cmux_persistent_slot=\(shellSingleQuoted(persistentDaemonSlot)) + cmux_listener_pids='' + if command -v lsof >/dev/null 2>&1; then + cmux_listener_pids="$(lsof -nP -iTCP:"$cmux_relay_port" -sTCP:LISTEN -Fpn 2>/dev/null | awk -v port="$cmux_relay_port" ' + /^p/ { pid = substr($0, 2); next } + /^n/ { + name = substr($0, 2) + if (pid ~ /^[0-9]+$/ && name ~ ("(^|[^0-9])127[.]0[.]0[.]1:" port "$")) { + seen[pid] = 1 + } + } + END { + for (pid in seen) print pid + } + ')" + fi + [ -n "$cmux_listener_pids" ] || exit 0 + cmux_ps_output="$(ps -axo pid=,ppid=,command= 2>/dev/null || true)" + for cmux_listener_pid in $cmux_listener_pids; do + case "$cmux_listener_pid" in + ''|*[!0-9]*) continue ;; + esac + cmux_listener_command="$(printf '%s\\n' "$cmux_ps_output" | awk -v target="$cmux_listener_pid" '$1 == target { $1 = ""; $2 = ""; sub(/^[[:space:]]+/, ""); print; exit }')" + case "$cmux_listener_command" in + *sshd*|*ssh*) ;; + *) continue ;; + esac + cmux_child_pids="$(printf '%s\\n' "$cmux_ps_output" | awk -v parent="$cmux_listener_pid" -v slot="$cmux_persistent_slot" ' + function clean_token(value) { + gsub(/'\''/, "", value) + gsub(/"/, "", value) + gsub(/\\\\/, "", value) + return value + } + function has_token(target, i) { + for (i = 3; i <= NF; i++) { + if (clean_token($i) == target) return 1 + } + return 0 + } + function next_value(after, i, value) { + for (i = after + 1; i <= NF; i++) { + value = clean_token($i) + if (value != "") return value + } + return "" + } + function has_exact_slot(i, token, value) { + for (i = 3; i <= NF; i++) { + token = clean_token($i) + if (token == "--slot") { + return next_value(i) == slot + } + if (token ~ /^--slot=/) { + value = substr(token, 8) + if (value != "") return value == slot + return next_value(i) == slot + } + } + return 0 + } + $2 == parent && + index($0, "cmuxd-remote") && + has_token("serve") && + has_token("--stdio") && + has_token("--persistent") && + has_exact_slot() && + $1 ~ /^[0-9]+$/ { + print $1 + } + ')" + cmux_cleanup_reason=child + if [ -z "$cmux_child_pids" ]; then + cmux_cleanup_reason=metadata + cmux_metadata_ok=0 + cmux_slot_file="$HOME/.cmux/relay/${cmux_relay_port}.slot" + cmux_metadata_slot_ok=0 + if [ -r "$cmux_slot_file" ]; then + cmux_stored_slot="$(tr -d '\\r\\n' < "$cmux_slot_file")" + [ "$cmux_stored_slot" = "$cmux_persistent_slot" ] && cmux_metadata_slot_ok=1 + fi + if [ "$cmux_metadata_slot_ok" -eq 1 ]; then + cmux_daemon_map="$HOME/.cmux/relay/${cmux_relay_port}.daemon_path" + cmux_auth_file="$HOME/.cmux/relay/${cmux_relay_port}.auth" + if [ -r "$cmux_daemon_map" ]; then + cmux_daemon_path="$(tr -d '\\r\\n' < "$cmux_daemon_map")" + case "$cmux_daemon_path" in + *cmuxd-remote*) cmux_metadata_ok=1 ;; + esac + fi + if [ "$cmux_metadata_ok" -ne 1 ] && [ -r "$cmux_auth_file" ]; then + cmux_auth_payload="$(tr -d '\\r\\n' < "$cmux_auth_file")" + case "$cmux_auth_payload" in + *relay_id*relay_token*) cmux_metadata_ok=1 ;; + esac + fi + fi + [ "$cmux_metadata_ok" -eq 1 ] || continue + fi + kill -TERM "$cmux_listener_pid" $cmux_child_pids 2>/dev/null || true + for cmux_child_pid in $cmux_child_pids; do + kill -0 "$cmux_child_pid" 2>/dev/null && kill -KILL "$cmux_child_pid" 2>/dev/null || true + done + kill -0 "$cmux_listener_pid" 2>/dev/null && kill -KILL "$cmux_listener_pid" 2>/dev/null || true + cmux_child_list="$(printf '%s\\n' "$cmux_child_pids" | tr '\\n' ' ' | sed 's/[[:space:]]*$//')" + printf 'cmux_stale_relay_killed pid=%s children=%s port=%s reason=%s\\n' "$cmux_listener_pid" "$cmux_child_list" "$cmux_relay_port" "$cmux_cleanup_reason" + done + """ + } + + private static func normalizedPersistentDaemonSlotForRemoteCleanup(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed != ".", + trimmed != "..", + trimmed.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil else { + return nil + } + return trimmed + } + private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { let script = """ cmux_uname_os="$(uname -s)" @@ -8234,10 +8599,17 @@ final class WorkspaceRemoteSessionController { daemonRemotePath: String, relayPort: Int, relayID: String, - relayToken: String + relayToken: String, + persistentDaemonSlot: String? = nil ) -> String { let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) let daemonPathExpression = remoteDaemonPathShellExpression(trimmedRemotePath) + let slotMetadataLine: String + if let slot = normalizedPersistentDaemonSlotForRemoteCleanup(persistentDaemonSlot) { + slotMetadataLine = "printf '%s' \(shellSingleQuoted(slot)) > \"$HOME/.cmux/relay/\(relayPort).slot\"\nchmod 600 \"$HOME/.cmux/relay/\(relayPort).slot\"" + } else { + slotMetadataLine = "rm -f \"$HOME/.cmux/relay/\(relayPort).slot\"" + } let authPayload = """ {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} """ @@ -8247,6 +8619,7 @@ final class WorkspaceRemoteSessionController { chmod 700 "$HOME/.cmux/relay" \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) printf '%s' \(daemonPathExpression) > "$HOME/.cmux/relay/\(relayPort).daemon_path" + \(slotMetadataLine) cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' \(authPayload) CMUXRELAYAUTH @@ -8382,10 +8755,12 @@ final class WorkspaceRemoteSessionController { static func orphanedCMUXRemoteSSHPIDs( psOutput: String, destination: String, - relayPort: Int? = nil + relayPort: Int? = nil, + persistentDaemonSlot: String? = nil ) -> [Int] { let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDestination.isEmpty else { return [] } + let trimmedPersistentDaemonSlot = persistentDaemonSlot return psOutput .split(separator: "\n", omittingEmptySubsequences: false) @@ -8395,7 +8770,8 @@ final class WorkspaceRemoteSessionController { guard isOrphanedCMUXRemoteSSHCommand( parsed.command, destination: trimmedDestination, - relayPort: relayPort + relayPort: relayPort, + persistentDaemonSlot: trimmedPersistentDaemonSlot ) else { return nil } @@ -8404,7 +8780,11 @@ final class WorkspaceRemoteSessionController { .sorted() } - private static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { + private static func killOrphanedRemoteSSHProcesses( + destination: String, + relayPort: Int? = nil, + persistentDaemonSlot: String? = nil + ) { guard let output = captureCommandStandardOutput( executablePath: "/bin/ps", arguments: ["-axo", "pid=,ppid=,command="] @@ -8415,7 +8795,8 @@ final class WorkspaceRemoteSessionController { for pid in orphanedCMUXRemoteSSHPIDs( psOutput: output, destination: destination, - relayPort: relayPort + relayPort: relayPort, + persistentDaemonSlot: persistentDaemonSlot ) { _ = Darwin.kill(pid_t(pid), SIGTERM) } @@ -8468,27 +8849,107 @@ final class WorkspaceRemoteSessionController { private static func isOrphanedCMUXRemoteSSHCommand( _ command: String, destination: String, - relayPort: Int? + relayPort: Int?, + persistentDaemonSlot: String? ) -> Bool { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } guard commandContainsDestination(trimmed, destination: destination) else { return false } + let trimmedPersistentDaemonSlot: String? = { + guard let persistentDaemonSlot else { return nil } + let trimmed = persistentDaemonSlot.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() if let relayPort { - return trimmed.contains(" -N ") - && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") + if trimmed.contains(" -N ") + && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") { + return true + } + guard let trimmedPersistentDaemonSlot else { return false } + return isCMUXRemotePersistentDaemonServeStdioCommand( + trimmed, + slot: trimmedPersistentDaemonSlot + ) } if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { return true } - if trimmed.contains("cmuxd-remote") && trimmed.contains(" serve --stdio") { + if let trimmedPersistentDaemonSlot { + if isCMUXRemotePersistentDaemonServeStdioCommand( + trimmed, + slot: trimmedPersistentDaemonSlot + ) { + return true + } + return isCMUXRemoteNonPersistentDaemonServeStdioCommand(trimmed) + } + if isCMUXRemoteDaemonServeStdioCommand(trimmed) { return true } return false } + private static func isCMUXRemoteDaemonServeStdioCommand(_ command: String) -> Bool { + guard command.contains("cmuxd-remote") else { return false } + let normalized = command + .replacingOccurrences(of: "'", with: " ") + .replacingOccurrences(of: "\"", with: " ") + return normalized.contains(" serve ") && normalized.contains(" --stdio") + } + + private static func isCMUXRemoteNonPersistentDaemonServeStdioCommand(_ command: String) -> Bool { + guard isCMUXRemoteDaemonServeStdioCommand(command) else { return false } + let normalized = command + .replacingOccurrences(of: "'", with: " ") + .replacingOccurrences(of: "\"", with: " ") + return !normalized.contains(" --persistent") + } + + private static func isCMUXRemotePersistentDaemonServeStdioCommand( + _ command: String, + slot: String + ) -> Bool { + guard isCMUXRemoteDaemonServeStdioCommand(command) else { return false } + let normalized = command + .replacingOccurrences(of: "'", with: " ") + .replacingOccurrences(of: "\"", with: " ") + guard normalized.contains(" --persistent") else { return false } + let tokens = normalized.split(whereSeparator: { $0.isWhitespace }).map(String.init) + for index in tokens.indices { + let token = tokens[index] + if token == "--slot" { + return nextNonShellEscapeToken(after: index, in: tokens) == slot + } + if token.hasPrefix("--slot=") { + let slotValue = String(token.dropFirst("--slot=".count)) + if !slotValue.isEmpty { + return slotValue == slot + } + return nextNonShellEscapeToken(after: index, in: tokens) == slot + } + } + return false + } + + private static func nextNonShellEscapeToken(after index: Int, in tokens: [String]) -> String? { + var nextIndex = index + 1 + while tokens.indices.contains(nextIndex) { + let token = tokens[nextIndex] + if !isShellEscapeNoiseToken(token) { + return token + } + nextIndex += 1 + } + return nil + } + + private static func isShellEscapeNoiseToken(_ token: String) -> Bool { + !token.isEmpty && token.allSatisfy { $0 == "\\" } + } + private static func commandContainsDestination(_ command: String, destination: String) -> Bool { guard !destination.isEmpty else { return false } let escaped = NSRegularExpression.escapedPattern(for: destination) @@ -9994,7 +10455,11 @@ final class Workspace: Identifiable, ObservableObject { private var remoteLastPortConflictFingerprint: String? private var remoteDetectedSurfaceIds: Set = [] private var activeRemoteTerminalSurfaceIds: Set = [] + private var endedPersistentRemotePTYAttachSurfaceIds: Set = [] private var remotePTYSessionIDsByPanelId: [UUID: String] = [:] + private var remoteRelayWorkspaceIDAliases: [UUID: UUID] = [:] + private var remoteRelaySurfaceIDAliases: [UUID: UUID] = [:] + private var suppressRemoteTerminalStartupForSessionRestoreScaffold = false var pendingRemoteTerminalChildExitSurfaceIds: Set = [] /// Display target of the remote workspace that just disconnected. Set right before /// `createReplacementTerminalPanel()` so the replacement shell can print a banner @@ -10936,12 +11401,15 @@ final class Workspace: Identifiable, ObservableObject { } private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { - let subscription = Publishers.CombineLatest4( + let browserTabState = Publishers.CombineLatest4( browserPanel.$pageTitle.removeDuplicates(), browserPanel.$currentURL.removeDuplicates(), browserPanel.$isLoading.removeDuplicates(), browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) ) + let subscription = browserTabState + .combineLatest(browserPanel.$isMuted.removeDuplicates()) .receive(on: DispatchQueue.main) - .sink { [weak self, weak browserPanel] _, _, isLoading, favicon in + .sink { [weak self, weak browserPanel] output in + let ((_, _, isLoading, favicon), isMuted) = output guard let self = self, let browserPanel = browserPanel, let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return } @@ -10955,13 +11423,15 @@ final class Workspace: Identifiable, ObservableObject { let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon) let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading - guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return } + let mutedUpdate: Bool? = existing.isAudioMuted == isMuted ? nil : isMuted + guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil || mutedUpdate != nil else { return } self.bonsplitController.updateTab( tabId, title: titleUpdate, iconImageData: faviconUpdate, hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil, - isLoading: loadingUpdate + isLoading: loadingUpdate, + isAudioMuted: mutedUpdate ) } panelSubscriptions[browserPanel.id] = subscription @@ -10969,6 +11439,14 @@ final class Workspace: Identifiable, ObservableObject { setPreferredBrowserProfileID(browserPanel.profileID) } + private func syncBrowserAudioMuteStateForPanel(_ panelId: UUID, browserPanel: BrowserPanel? = nil) { + guard let browserPanel = browserPanel ?? self.browserPanel(for: panelId), + let tabId = surfaceIdFromPanelId(panelId), + let tab = bonsplitController.tab(tabId), + tab.isAudioMuted != browserPanel.isMuted else { return } + bonsplitController.updateTab(tabId, isAudioMuted: browserPanel.isMuted) + } + func setPreferredBrowserProfileID(_ profileID: UUID?) { guard let profileID else { preferredBrowserProfileID = nil @@ -12117,6 +12595,8 @@ final class Workspace: Identifiable, ObservableObject { surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } remotePTYSessionIDsByPanelId = remotePTYSessionIDsByPanelId.filter { validSurfaceIds.contains($0.key) } + endedPersistentRemotePTYAttachSurfaceIds = endedPersistentRemotePTYAttachSurfaceIds.filter { validSurfaceIds.contains($0) } + pruneRemoteRelaySurfaceAliases(validSurfaceIds: validSurfaceIds) remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } @@ -12359,6 +12839,13 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteTerminalSurfaceIds.contains(panelId) } + @MainActor + func shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(_ panelId: UUID) -> Bool { + guard remoteConfiguration?.preserveAfterTerminalExit == true else { return false } + return activeRemoteTerminalSurfaceIds.contains(panelId) || + endedPersistentRemotePTYAttachSurfaceIds.contains(panelId) + } + @MainActor func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool { isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) @@ -12540,6 +13027,7 @@ final class Workspace: Identifiable, ObservableObject { payload["has_identity_file"] = remoteConfiguration.identityFile != nil payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() + payload["persistent_daemon_slot"] = remoteConfiguration.persistentDaemonSlot ?? NSNull() } else { payload["transport"] = NSNull() payload["destination"] = NSNull() @@ -12547,6 +13035,7 @@ final class Workspace: Identifiable, ObservableObject { payload["has_identity_file"] = false payload["has_ssh_options"] = false payload["local_proxy_port"] = NSNull() + payload["persistent_daemon_slot"] = NSNull() } return payload } @@ -12555,8 +13044,12 @@ final class Workspace: Identifiable, ObservableObject { defer { TerminalController.shared.notifyRemotePTYControllerAvailabilityChanged() } let previousConfiguration = remoteConfiguration skipControlMasterCleanupAfterDetachedRemoteTransfer = false - if previousConfiguration != nil, previousConfiguration != configuration { + if let previousConfiguration, + previousConfiguration != configuration, + !previousConfiguration.hasSamePersistentPTYIdentity(as: configuration) { remotePTYSessionIDsByPanelId.removeAll() + endedPersistentRemotePTYAttachSurfaceIds.removeAll() + clearRemoteRelayIDAliases() } remoteConfiguration = configuration seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) @@ -12611,6 +13104,7 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteSessionControllerID = controllerID remoteSessionController = controller syncRemotePortScanTTYs() + syncRemoteRelayIDAliasesToController() controller.start() } @@ -12658,7 +13152,7 @@ final class Workspace: Identifiable, ObservableObject { previousController?.stop() pendingRemoteForegroundAuthToken = nil activeRemoteTerminalSurfaceIds.removeAll() - remotePTYSessionIDsByPanelId.removeAll() + endedPersistentRemotePTYAttachSurfaceIds.removeAll() activeRemoteTerminalSessionCount = 0 pendingRemoteSurfaceTTYName = nil pendingRemoteSurfaceTTYSurfaceId = nil @@ -12680,6 +13174,9 @@ final class Workspace: Identifiable, ObservableObject { remoteLastDaemonErrorFingerprint = nil remoteLastPortConflictFingerprint = nil if clearConfiguration { + remotePTYSessionIDsByPanelId.removeAll() + endedPersistentRemotePTYAttachSurfaceIds.removeAll() + clearRemoteRelayIDAliases() remoteConfiguration = nil skipControlMasterCleanupAfterDetachedRemoteTransfer = false } @@ -12713,8 +13210,13 @@ final class Workspace: Identifiable, ObservableObject { private func trackRemoteTerminalSurface(_ panelId: UUID) { skipControlMasterCleanupAfterDetachedRemoteTransfer = false + endedPersistentRemotePTYAttachSurfaceIds.remove(panelId) pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + if remoteConfiguration?.preserveAfterTerminalExit == true, + normalizedRemotePTYSessionID(remotePTYSessionIDsByPanelId[panelId]) == nil { + remotePTYSessionIDsByPanelId[panelId] = Self.defaultSSHPTYSessionID(workspaceId: id, panelId: panelId) + } guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) @@ -12736,19 +13238,274 @@ final class Workspace: Identifiable, ObservableObject { return trimmed } + private nonisolated static let remoteRelayWorkspaceIDKeys: Set = [ + "workspace_id", + "preferred_workspace_id", + "selected_workspace_id", + "before_workspace_id", + "after_workspace_id", + "from_workspace_id", + "to_workspace_id", + ] + + private nonisolated static let remoteRelaySurfaceIDKeys: Set = [ + "panel_id", + "surface_id", + "preferred_panel_id", + "preferred_surface_id", + "target_panel_id", + "target_surface_id", + "created_panel_id", + "created_surface_id", + "before_panel_id", + "before_surface_id", + "after_panel_id", + "after_surface_id", + ] + + private nonisolated static let remoteRelayAmbiguousIDKeys: Set = [ + "tab_id", + ] + + private nonisolated static let remoteRelayWorkspaceIDArrayKeys: Set = [ + "workspace_ids", + ] + + private nonisolated static let remoteRelaySurfaceIDArrayKeys: Set = [ + "panel_ids", + "surface_ids", + ] + + private nonisolated static let remoteRelayAmbiguousIDArrayKeys: Set = [ + "tab_ids", + "tab_id_groups", + ] + + private func syncRemoteRelayIDAliasesToController() { + remoteSessionController?.updateRemoteRelayIDAliases( + workspaceAliases: remoteRelayWorkspaceIDAliases, + surfaceAliases: remoteRelaySurfaceIDAliases + ) + } + + private func clearRemoteRelayIDAliases() { + guard !remoteRelayWorkspaceIDAliases.isEmpty || !remoteRelaySurfaceIDAliases.isEmpty else { return } + remoteRelayWorkspaceIDAliases.removeAll() + remoteRelaySurfaceIDAliases.removeAll() + syncRemoteRelayIDAliasesToController() + } + + private func pruneRemoteRelaySurfaceAliases(validSurfaceIds: Set) { + let nextAliases = remoteRelaySurfaceIDAliases.filter { validSurfaceIds.contains($0.value) } + guard nextAliases != remoteRelaySurfaceIDAliases else { return } + remoteRelaySurfaceIDAliases = nextAliases + syncRemoteRelayIDAliasesToController() + } + + private func removeRemoteRelaySurfaceAliases(targeting panelId: UUID) { + let nextAliases = remoteRelaySurfaceIDAliases.filter { $0.value != panelId } + guard nextAliases != remoteRelaySurfaceIDAliases else { return } + remoteRelaySurfaceIDAliases = nextAliases + syncRemoteRelayIDAliasesToController() + } + + private func registerRemoteRelayIDAliases( + snapshotWorkspaceId: UUID?, + snapshotPanelId: UUID, + restoredPanelId: UUID + ) { + var didMutate = false + if let snapshotWorkspaceId, snapshotWorkspaceId != id { + if remoteRelayWorkspaceIDAliases[snapshotWorkspaceId] != id { + remoteRelayWorkspaceIDAliases[snapshotWorkspaceId] = id + didMutate = true + } + } + if snapshotPanelId != restoredPanelId { + if remoteRelaySurfaceIDAliases[snapshotPanelId] != restoredPanelId { + remoteRelaySurfaceIDAliases[snapshotPanelId] = restoredPanelId + didMutate = true + } + } + if didMutate { + syncRemoteRelayIDAliasesToController() + } + } + + private func registerRemoteRelayIDAliases(remotePTYSessionID: String, restoredPanelId: UUID) { + guard let parsed = Self.parsedDefaultSSHPTYSessionID(remotePTYSessionID) else { return } + registerRemoteRelayIDAliases( + snapshotWorkspaceId: parsed.workspaceId, + snapshotPanelId: parsed.panelId, + restoredPanelId: restoredPanelId + ) + } + + func rewriteRemoteRelayCommandLine(_ commandLine: Data) -> Data { + Self.rewriteRemoteRelayCommandLine( + commandLine, + workspaceAliases: remoteRelayWorkspaceIDAliases, + surfaceAliases: remoteRelaySurfaceIDAliases + ) + } + + nonisolated static func rewriteRemoteRelayCommandLine( + _ commandLine: Data, + workspaceAliases: [UUID: UUID], + surfaceAliases: [UUID: UUID] + ) -> Data { + guard !workspaceAliases.isEmpty || !surfaceAliases.isEmpty, + let line = String(data: commandLine, encoding: .utf8) else { + return commandLine + } + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.hasPrefix("{"), + let requestData = trimmedLine.data(using: .utf8), + var request = try? JSONSerialization.jsonObject(with: requestData) as? [String: Any] else { + return commandLine + } + + var didRewrite = false + if let params = request["params"] as? [String: Any] { + request["params"] = Self.remappedRemoteRelayValue( + params, + key: nil, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases, + didRewrite: &didRewrite + ) + } + + guard didRewrite, + JSONSerialization.isValidJSONObject(request), + let rewritten = try? JSONSerialization.data(withJSONObject: request, options: []) else { + return commandLine + } + if commandLine.last == 0x0A { + return rewritten + Data([0x0A]) + } + return rewritten + } + + private nonisolated static func remappedRemoteRelayValue( + _ value: Any, + key: String?, + workspaceAliases: [UUID: UUID], + surfaceAliases: [UUID: UUID], + didRewrite: inout Bool + ) -> Any { + if let dictionary = value as? [String: Any] { + var result = dictionary + for (childKey, childValue) in dictionary { + result[childKey] = remappedRemoteRelayValue( + childValue, + key: childKey, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases, + didRewrite: &didRewrite + ) + } + return result + } + + if let array = value as? [Any] { + let elementKey: String? + if let key, remoteRelayWorkspaceIDArrayKeys.contains(key) { + elementKey = "workspace_id" + } else if let key, remoteRelaySurfaceIDArrayKeys.contains(key) { + elementKey = "surface_id" + } else if let key, remoteRelayAmbiguousIDArrayKeys.contains(key) { + elementKey = "tab_id" + } else if let key, remoteRelayWorkspaceIDKeys.contains(key) + || remoteRelaySurfaceIDKeys.contains(key) + || remoteRelayAmbiguousIDKeys.contains(key) { + elementKey = key + } else { + elementKey = nil + } + return array.map { + remappedRemoteRelayValue( + $0, + key: elementKey, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases, + didRewrite: &didRewrite + ) + } + } + + guard let id = value as? String else { + return value + } + + let trimmedID = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard let uuid = UUID(uuidString: trimmedID) else { + return value + } + + guard let key else { + return value + } + if remoteRelaySurfaceIDKeys.contains(key), + let mapped = surfaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + if remoteRelayWorkspaceIDKeys.contains(key), + let mapped = workspaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + guard remoteRelayAmbiguousIDKeys.contains(key) else { + return value + } + + if let mapped = workspaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + if let mapped = surfaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + + return value + } + private func remotePTYSessionIDForSnapshot(panelId: UUID) -> String? { - guard remoteConfiguration?.preserveAfterTerminalExit == true, - activeRemoteTerminalSurfaceIds.contains(panelId) else { + guard remoteConfiguration?.preserveAfterTerminalExit == true else { return nil } - return normalizedRemotePTYSessionID(remotePTYSessionIDsByPanelId[panelId]) - ?? Self.defaultSSHPTYSessionID(workspaceId: id, panelId: panelId) + if let storedSessionID = normalizedRemotePTYSessionID(remotePTYSessionIDsByPanelId[panelId]) { + return storedSessionID + } + guard activeRemoteTerminalSurfaceIds.contains(panelId) else { + return nil + } + return Self.defaultSSHPTYSessionID(workspaceId: id, panelId: panelId) } nonisolated static func defaultSSHPTYSessionID(workspaceId: UUID, panelId: UUID) -> String { "ssh-\(workspaceId.uuidString)-\(panelId.uuidString)" } + private nonisolated static func parsedDefaultSSHPTYSessionID(_ value: String) -> (workspaceId: UUID, panelId: UUID)? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("ssh-") else { return nil } + let suffix = String(trimmed.dropFirst(4)) + guard suffix.count == 73 else { return nil } + let separatorIndex = suffix.index(suffix.startIndex, offsetBy: 36) + guard suffix[separatorIndex] == "-" else { return nil } + let panelStart = suffix.index(after: separatorIndex) + let workspacePart = String(suffix[.. String { SSHPTYAttachStartupCommandBuilder.command(sessionID: sessionID) } @@ -12774,6 +13531,8 @@ final class Workspace: Identifiable, ObservableObject { func discardRemotePTYSessionID(panelId: UUID) { remotePTYSessionIDsByPanelId.removeValue(forKey: panelId) + endedPersistentRemotePTYAttachSurfaceIds.remove(panelId) + removeRemoteRelaySurfaceAliases(targeting: panelId) } func remotePTYSessionIDMatches(panelId: UUID, sessionID: String?) -> Bool { @@ -12796,11 +13555,33 @@ final class Workspace: Identifiable, ObservableObject { } let wasTracked = activeRemoteTerminalSurfaceIds.contains(surfaceId) + if remoteConfiguration?.preserveAfterTerminalExit == true { + endedPersistentRemotePTYAttachSurfaceIds.insert(surfaceId) + } else { + endedPersistentRemotePTYAttachSurfaceIds.remove(surfaceId) + } remotePTYSessionIDsByPanelId.removeValue(forKey: surfaceId) + removeRemoteRelaySurfaceAliases(targeting: surfaceId) untrackRemoteTerminalSurface(surfaceId) return (true, wasTracked) } + func markPersistentRemotePTYAttachFailed(surfaceId: UUID) { + guard remoteConfiguration?.preserveAfterTerminalExit == true else { return } + + remotePTYSessionIDsByPanelId.removeValue(forKey: surfaceId) + endedPersistentRemotePTYAttachSurfaceIds.remove(surfaceId) + removeRemoteRelaySurfaceAliases(targeting: surfaceId) + pendingRemoteTerminalChildExitSurfaceIds.remove(surfaceId) + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) + surfaceTTYNames.removeValue(forKey: surfaceId) + if activeRemoteTerminalSurfaceIds.remove(surfaceId) != nil { + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + } + syncRemotePortScanTTYs() + applyBrowserRemoteWorkspaceStatusToPanels() + } + private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } if remoteConfiguration?.preserveAfterTerminalExit == true { @@ -13452,6 +14233,7 @@ final class Workspace: Identifiable, ObservableObject { let tracksRemoteTerminalSurface = remoteTerminalStartupCommand != nil || normalizedRemotePTYSessionID != nil if let normalizedRemotePTYSessionID { remotePTYSessionIDsByPanelId[newPanel.id] = normalizedRemotePTYSessionID + registerRemoteRelayIDAliases(remotePTYSessionID: normalizedRemotePTYSessionID, restoredPanelId: newPanel.id) } if tracksRemoteTerminalSurface { trackRemoteTerminalSurface(newPanel.id) @@ -13488,6 +14270,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) remotePTYSessionIDsByPanelId.removeValue(forKey: newPanel.id) + removeRemoteRelaySurfaceAliases(targeting: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) if tracksRemoteTerminalSurface { untrackRemoteTerminalSurface(newPanel.id) @@ -13553,7 +14336,8 @@ final class Workspace: Identifiable, ObservableObject { tmuxStartCommand: String? = nil, initialInput: String? = nil, startupEnvironment: [String: String] = [:], - remotePTYSessionID: String? = nil + remotePTYSessionID: String? = nil, + suppressWorkspaceRemoteStartupCommand: Bool = false ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId @@ -13562,7 +14346,7 @@ final class Workspace: Identifiable, ObservableObject { var inheritedConfig = inheritedTerminalConfig(inPane: paneId) let requestedInitialCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let explicitInitialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + let remoteTerminalStartupCommand = suppressWorkspaceRemoteStartupCommand ? nil : remoteTerminalStartupCommand() let startupCommand = explicitInitialCommand ?? remoteTerminalStartupCommand // See the comment at the other call site: hold the PTY open after the remote // command exits so the user sees the error rather than a silently-respawned @@ -13592,6 +14376,7 @@ final class Workspace: Identifiable, ObservableObject { let tracksRemoteTerminalSurface = remoteTerminalStartupCommand != nil || normalizedRemotePTYSessionID != nil if let normalizedRemotePTYSessionID { remotePTYSessionIDsByPanelId[newPanel.id] = normalizedRemotePTYSessionID + registerRemoteRelayIDAliases(remotePTYSessionID: normalizedRemotePTYSessionID, restoredPanelId: newPanel.id) } if tracksRemoteTerminalSurface { trackRemoteTerminalSurface(newPanel.id) @@ -13610,6 +14395,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) remotePTYSessionIDsByPanelId.removeValue(forKey: newPanel.id) + removeRemoteRelaySurfaceAliases(targeting: newPanel.id) if tracksRemoteTerminalSurface { untrackRemoteTerminalSurface(newPanel.id) } @@ -13645,6 +14431,9 @@ final class Workspace: Identifiable, ObservableObject { } private func remoteTerminalStartupCommand() -> String? { + guard !suppressRemoteTerminalStartupForSessionRestoreScaffold else { + return nil + } guard let command = remoteConfiguration?.terminalStartupCommand? .trimmingCharacters(in: .whitespacesAndNewlines), !command.isEmpty else { @@ -13715,6 +14504,7 @@ final class Workspace: Identifiable, ObservableObject { kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, isLoading: browserPanel.isLoading, + isAudioMuted: browserPanel.isMuted, isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id @@ -13814,6 +14604,7 @@ final class Workspace: Identifiable, ObservableObject { kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, isLoading: browserPanel.isLoading, + isAudioMuted: browserPanel.isMuted, isPinned: false, inPane: paneId ) else { @@ -14901,6 +15692,7 @@ final class Workspace: Identifiable, ObservableObject { } else { restoredUnreadPanelIndicators.removeValue(forKey: detached.panelId) } + let detachedBrowserMuted = (detached.panel as? BrowserPanel)?.isMuted ?? false guard let newTabId = bonsplitController.createTab( title: detached.title, @@ -14910,6 +15702,7 @@ final class Workspace: Identifiable, ObservableObject { kind: detached.kind, isDirty: detached.panel.isDirty, isLoading: detached.isLoading, + isAudioMuted: detachedBrowserMuted, isPinned: detached.isPinned, inPane: paneId ) else { @@ -14991,6 +15784,11 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionIDsByPanelId.removeValue(forKey: detached.panelId) } if didAdoptWorkspaceRemoteTracking { + registerRemoteRelayIDAliases( + snapshotWorkspaceId: detached.sourceWorkspaceId, + snapshotPanelId: detached.panelId, + restoredPanelId: detached.panelId + ) trackRemoteTerminalSurface(detached.panelId) } if let cleanupConfiguration = detached.remoteCleanupConfiguration { @@ -16402,6 +17200,7 @@ final class Workspace: Identifiable, ObservableObject { bypassRemoteProxy: browser.bypassesRemoteWorkspaceProxyForTabDuplication ) else { return nil } newPanel.setMuted(browser.isMuted) + syncBrowserAudioMuteStateForPanel(newPanel.id, browserPanel: newPanel) _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) return newPanel } @@ -16861,7 +17660,13 @@ final class Workspace: Identifiable, ObservableObject { private func forkAgentRemoteConfigurationForNewWorkspace(fromPanelId panelId: UUID) -> WorkspaceRemoteConfiguration? { guard forkAgentRemoteStartupCommand(fromPanelId: panelId) != nil else { return nil } - return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration() ?? remoteConfiguration + let forkedSSHOptions = remoteConfiguration + .map { WorkspaceRemoteConfiguration.forkedAgentSSHOptions($0.sshOptions) } + return remoteConfiguration?.sessionSnapshot(sshOptionsOverride: forkedSSHOptions)?.workspaceConfiguration( + localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore(), + allowPersistentPTYRestore: false, + preserveSSHOptions: true + ) ?? remoteConfiguration } private static func firstNonEmptyPath(_ candidates: [String?]) -> String? { @@ -18184,6 +18989,14 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id), let browser = browserPanel(for: panelId) else { return } browser.reload() + case .toggleAudioMute: + guard let panelId = panelIdFromSurfaceId(tab.id), + let browser = browserPanel(for: panelId) else { return } + guard browser.toggleMute() else { + NSSound.beep() + return + } + syncBrowserAudioMuteStateForPanel(panelId, browserPanel: browser) case .duplicate: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } _ = duplicateBrowserToRight(panelId: panelId) diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 2494a78708e4..0538e6210e2b 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -1,5 +1,8 @@ import Darwin import Foundation +#if canImport(Security) +import Security +#endif private enum WorkspaceRemoteSSHOptionFilter { private static let transientControlSocketKeys: Set = [ @@ -9,12 +12,24 @@ private enum WorkspaceRemoteSSHOptionFilter { ] static func durableOptions(_ options: [String]) -> [String] { + filteredOptions(options, droppingKeys: transientControlSocketKeys) + } + + static func forkedWorkspaceOptions(_ options: [String]) -> [String] { + durableOptions(options) + } + + static func trimmedOptions(_ options: [String]) -> [String] { + filteredOptions(options, droppingKeys: []) + } + + private static func filteredOptions(_ options: [String], droppingKeys keys: Set) -> [String] { options.compactMap { option in let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed }.filter { option in guard let key = optionKey(option) else { return true } - return !transientControlSocketKeys.contains(key) + return !keys.contains(key) } } @@ -24,6 +39,16 @@ private enum WorkspaceRemoteSSHOptionFilter { return trimmed.isEmpty ? nil : trimmed } + static func normalizedPersistentDaemonSlot(_ value: String?) -> String? { + guard let slot = normalizedOptional(value), + slot != ".", + slot != "..", + slot.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil else { + return nil + } + return slot + } + static func normalizedIdentityPath(_ value: String?) -> String? { guard let trimmed = normalizedOptional(value) else { return nil } guard trimmed.hasPrefix("~") else { return trimmed } @@ -61,6 +86,8 @@ nonisolated struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable var sshOptions: [String] var preserveAfterTerminalExit: Bool? var skipDaemonBootstrap: Bool? + var relayPort: Int? = nil + var persistentDaemonSlot: String? = nil } struct WorkspaceRemoteWebSocketDaemonEndpoint: Equatable { @@ -92,6 +119,7 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { static func command( sessionID: String? = nil, foregroundAuth: ForegroundAuth? = nil, + remoteCommand: String? = nil, requireExisting: Bool = true ) -> String { var lines = [ @@ -113,9 +141,25 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { lines += foregroundAuthLines(foregroundAuth) } let requireExistingFlag = requireExisting ? " --require-existing" : "" - let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait\(requireExistingFlag) --workspace \"$CMUX_WORKSPACE_ID\" --session-id \"$cmux_ssh_attach_session_id\" --attachment-id \"${CMUX_SURFACE_ID:-}\"" + let commandB64Flag = normalized(remoteCommand).map { + " --command-b64 \(shellQuote(Data($0.utf8).base64EncodedString()))" + } ?? "" + let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait\(requireExistingFlag) --workspace \"$CMUX_WORKSPACE_ID\" --session-id \"$cmux_ssh_attach_session_id\" --attachment-id \"${CMUX_SURFACE_ID:-}\"\(commandB64Flag)" lines += retryingAttachLines(command: attachCommand) - return lines.joined(separator: "\n") + return "/bin/sh -c \(shellQuote(lines.joined(separator: "\n")))" + } + + static func restoredRemoteShellCommand(relayPort: Int) -> String { + RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: relayPort, + shellFeatures: RemoteInteractiveShellBootstrapBuilder.shellFeatures(), + bundledZshIntegration: RemoteInteractiveShellBootstrapBuilder.bundledShellIntegrationScript( + named: "cmux-zsh-integration.zsh" + ), + bundledBashIntegration: RemoteInteractiveShellBootstrapBuilder.bundledShellIntegrationScript( + named: "cmux-bash-integration.bash" + ) + ) } private static func retryingAttachLines(command: String) -> [String] { @@ -176,7 +220,7 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { return arguments.map(shellQuote).joined(separator: " ") } - static func sshOptionsWithRestoreControlDefaults(_ options: [String]) -> [String] { + static func sshOptionsWithRestoreControlDefaults(_ options: [String], relayPort: Int? = nil) -> [String] { var merged = options.compactMap(normalized) let controlMaster = sshOptionValue(named: "ControlMaster", in: merged) let controlMasterDisabled = sshOptionValueIsDisabled(controlMaster) @@ -188,12 +232,19 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { merged.append("ControlPersist=600") } if !hasSSHOptionKey(merged, key: "ControlPath") { - merged.append("ControlPath=/tmp/cmux-ssh-\(getuid())-%C") + merged.append("ControlPath=\(restoreControlPathTemplate(relayPort: relayPort))") } } return merged } + private static func restoreControlPathTemplate(relayPort: Int?) -> String { + if let relayPort, relayPort > 0 { + return "/tmp/cmux-ssh-\(getuid())-\(relayPort)-%C" + } + return "/tmp/cmux-ssh-\(getuid())-%C" + } + static func sshOptionsSupportReusableForegroundAuth(_ options: [String]) -> Bool { guard !hasSSHOptionKey(options, key: "LocalCommand"), !hasSSHOptionKey(options, key: "PermitLocalCommand") else { @@ -287,6 +338,7 @@ struct WorkspaceRemoteConfiguration: Equatable { let foregroundAuthToken: String? let daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? let preserveAfterTerminalExit: Bool + let persistentDaemonSlot: String? /// True for cloud-VM remotes (Freestyle snapshots) where cmuxd-remote is pre-baked in /// the image and started via systemd. Skip the upload+exec bootstrap entirely and synthesize /// a `DaemonHello`. Reverse-relay still stays off, but SSH-backed VM workspaces can talk to @@ -308,6 +360,7 @@ struct WorkspaceRemoteConfiguration: Equatable { foregroundAuthToken: String? = nil, daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil, preserveAfterTerminalExit: Bool = false, + persistentDaemonSlot: String? = nil, skipDaemonBootstrap: Bool = false ) { self.transport = transport @@ -324,6 +377,9 @@ struct WorkspaceRemoteConfiguration: Equatable { self.foregroundAuthToken = foregroundAuthToken self.daemonWebSocketEndpoint = daemonWebSocketEndpoint self.preserveAfterTerminalExit = preserveAfterTerminalExit + self.persistentDaemonSlot = preserveAfterTerminalExit + ? WorkspaceRemoteSSHOptionFilter.normalizedPersistentDaemonSlot(persistentDaemonSlot) + : nil self.skipDaemonBootstrap = skipDaemonBootstrap } @@ -342,6 +398,7 @@ struct WorkspaceRemoteConfiguration: Equatable { let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") let normalizedWebSocketDaemon = daemonWebSocketEndpoint?.proxyBrokerKeyComponent ?? "" let normalizedRequiredCapabilities = preserveAfterTerminalExit ? "pty.session" : "" + let normalizedPersistentDaemonSlot = persistentDaemonSlot ?? "" return [ normalizedTransport, normalizedBootstrapMode, @@ -352,6 +409,7 @@ struct WorkspaceRemoteConfiguration: Equatable { normalizedLocalProxyPort, normalizedWebSocketDaemon, normalizedRequiredCapabilities, + normalizedPersistentDaemonSlot, ] .joined(separator: "\u{1e}") } @@ -359,10 +417,34 @@ struct WorkspaceRemoteConfiguration: Equatable { private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { WorkspaceRemoteSSHOptionFilter.durableOptions(options) } + + func hasSamePersistentPTYIdentity(as other: WorkspaceRemoteConfiguration) -> Bool { + guard preserveAfterTerminalExit, + other.preserveAfterTerminalExit, + let persistentDaemonSlot, + persistentDaemonSlot == other.persistentDaemonSlot else { + return false + } + + return transport == other.transport + && skipDaemonBootstrap == other.skipDaemonBootstrap + && destination.trimmingCharacters(in: .whitespacesAndNewlines) + == other.destination.trimmingCharacters(in: .whitespacesAndNewlines) + && port == other.port + && relayPort == other.relayPort + && WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(identityFile) + == WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(other.identityFile) + && Self.proxyBrokerSSHOptions(sshOptions) == Self.proxyBrokerSSHOptions(other.sshOptions) + && daemonWebSocketEndpoint?.proxyBrokerKeyComponent == other.daemonWebSocketEndpoint?.proxyBrokerKeyComponent + } } extension SessionRemoteWorkspaceSnapshot { - func workspaceConfiguration() -> WorkspaceRemoteConfiguration? { + func workspaceConfiguration( + localSocketPath: String? = nil, + allowPersistentPTYRestore: Bool = true, + preserveSSHOptions: Bool = false + ) -> WorkspaceRemoteConfiguration? { guard transport == .ssh else { return nil } let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedDestination.isEmpty else { return nil } @@ -370,13 +452,30 @@ extension SessionRemoteWorkspaceSnapshot { (1...65535).contains(port) ? port : nil } - let normalizedOptions = Self.normalizedSSHOptions(sshOptions) - let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults(normalizedOptions) + let normalizedPersistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedPersistentDaemonSlot(persistentDaemonSlot) + let normalizedLocalSocketPath = WorkspaceRemoteSSHOptionFilter.normalizedOptional(localSocketPath) + let normalizedRelayPort = relayPort.flatMap { port in + (1...65535).contains(port) ? port : nil + } + let preservedOptions = preserveSSHOptions + ? WorkspaceRemoteSSHOptionFilter.trimmedOptions(sshOptions) + : Self.normalizedSSHOptions(sshOptions) + let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults( + preservedOptions, + relayPort: normalizedRelayPort + ) + let fallbackSSHOptions = preserveSSHOptions + ? Self.normalizedSSHOptions(preservedOptions) + : preservedOptions let preservePTYSession = + allowPersistentPTYRestore && preserveAfterTerminalExit == true && skipDaemonBootstrap != true && + normalizedPersistentDaemonSlot != nil && + normalizedLocalSocketPath != nil && + normalizedRelayPort != nil && SSHPTYAttachStartupCommandBuilder.sshOptionsSupportReusableForegroundAuth(optionsWithRestoreControlDefaults) - let restoredSSHOptions = preservePTYSession ? optionsWithRestoreControlDefaults : normalizedOptions + let restoredSSHOptions = preservePTYSession ? optionsWithRestoreControlDefaults : fallbackSSHOptions let foregroundAuthToken = preservePTYSession ? UUID().uuidString.lowercased() : nil let foregroundAuth = foregroundAuthToken.map { SSHPTYAttachStartupCommandBuilder.ForegroundAuth( @@ -387,6 +486,15 @@ extension SessionRemoteWorkspaceSnapshot { token: $0 ) } + let restoredRelayID = preservePTYSession + ? UUID().uuidString.lowercased() + : nil + let restoredRelayToken = preservePTYSession + ? Self.restoreRelayTokenHex() + : nil + let restoredRemoteShellCommand = preservePTYSession + ? normalizedRelayPort.map(SSHPTYAttachStartupCommandBuilder.restoredRemoteShellCommand(relayPort:)) + : nil return WorkspaceRemoteConfiguration( transport: transport, destination: normalizedDestination, @@ -394,29 +502,47 @@ extension SessionRemoteWorkspaceSnapshot { identityFile: Self.normalizedIdentityPath(identityFile), sshOptions: restoredSSHOptions, localProxyPort: nil, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, + relayPort: preservePTYSession ? normalizedRelayPort : nil, + relayID: restoredRelayID, + relayToken: restoredRelayToken, + localSocketPath: preservePTYSession ? normalizedLocalSocketPath : nil, terminalStartupCommand: preservePTYSession ? SSHPTYAttachStartupCommandBuilder.command( foregroundAuth: foregroundAuth, + remoteCommand: restoredRemoteShellCommand, + // Restored panels get explicit require-existing attach commands with their + // persisted session IDs; this workspace default is for new panes. requireExisting: false ) : sshReconnectCommand( destination: normalizedDestination, - port: normalizedPort + port: normalizedPort, + sshOptions: restoredSSHOptions ), foregroundAuthToken: foregroundAuthToken, daemonWebSocketEndpoint: nil, preserveAfterTerminalExit: preservePTYSession, + persistentDaemonSlot: preservePTYSession ? normalizedPersistentDaemonSlot : nil, skipDaemonBootstrap: skipDaemonBootstrap == true ) } + private static func restoreRelayTokenHex() -> String { + var bytes = [UInt8](repeating: 0, count: 32) +#if canImport(Security) + if SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess { + return bytes.map { String(format: "%02x", $0) }.joined() + } +#endif + return (UUID().uuidString + UUID().uuidString) + .replacingOccurrences(of: "-", with: "") + .lowercased() + } + private func sshReconnectCommand( destination normalizedDestination: String, - port normalizedPort: Int? + port normalizedPort: Int?, + sshOptions reconnectSSHOptions: [String]? = nil ) -> String? { var arguments = ["ssh"] if let normalizedPort { @@ -425,7 +551,7 @@ extension SessionRemoteWorkspaceSnapshot { if let identityFile = Self.normalizedIdentityPath(identityFile) { arguments += ["-i", identityFile] } - let normalizedOptions = Self.normalizedSSHOptions(sshOptions) + let normalizedOptions = reconnectSSHOptions ?? Self.normalizedSSHOptions(sshOptions) for option in normalizedOptions { arguments += ["-o", option] } @@ -458,7 +584,11 @@ extension SessionRemoteWorkspaceSnapshot { } extension WorkspaceRemoteConfiguration { - func sessionSnapshot() -> SessionRemoteWorkspaceSnapshot? { + static func forkedAgentSSHOptions(_ options: [String]) -> [String] { + WorkspaceRemoteSSHOptionFilter.forkedWorkspaceOptions(options) + } + + func sessionSnapshot(sshOptionsOverride: [String]? = nil) -> SessionRemoteWorkspaceSnapshot? { guard transport == .ssh else { return nil } let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedDestination.isEmpty else { return nil } @@ -468,9 +598,11 @@ extension WorkspaceRemoteConfiguration { destination: normalizedDestination, port: port, identityFile: WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(identityFile), - sshOptions: WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions), + sshOptions: sshOptionsOverride ?? WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions), preserveAfterTerminalExit: preserveAfterTerminalExit ? true : nil, - skipDaemonBootstrap: skipDaemonBootstrap + skipDaemonBootstrap: skipDaemonBootstrap, + relayPort: preserveAfterTerminalExit ? relayPort : nil, + persistentDaemonSlot: preserveAfterTerminalExit ? persistentDaemonSlot : nil ) } } diff --git a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift index bc06e2b665e8..f1e36e0f81c2 100644 --- a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift +++ b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift @@ -10,7 +10,15 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { configuration: WorkspaceRemoteConfiguration, remotePath: String ) -> [String] { - let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" + var serveArguments = ["serve", "--stdio"] + if let slot = configuration.persistentDaemonSlot?.trimmingCharacters(in: .whitespacesAndNewlines), + !slot.isEmpty { + serveArguments += ["--persistent", "--slot", slot] + } + let daemonCommand = ([remotePath] + serveArguments) + .map(shellSingleQuoted) + .joined(separator: " ") + let script = "exec \(daemonCommand)" let command = "sh -c \(shellSingleQuoted(script))" return ["-T"] + batchArguments(configuration: configuration) @@ -49,6 +57,18 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { return args } + static func reverseRelayControlMasterCancelArguments( + configuration: WorkspaceRemoteConfiguration, + relayPort: Int + ) -> [String]? { + guard relayPort > 0 else { return nil } + return reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "cancel", + forwardSpec: "127.0.0.1:\(relayPort)" + ) + } + private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { let effectiveSSHOptions = backgroundSSHOptions(configuration.sshOptions) var args: [String] = [ diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 2b360061d1e7..6c5642d53ee5 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -132,24 +132,19 @@ struct cmuxApp: App { private static func configureGhosttyEnvironment() { let fileManager = FileManager.default - let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" - let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty") - var resolvedResourcesDir: String? - - if getenv("GHOSTTY_RESOURCES_DIR") == nil { - if let bundledGhosttyURL, - fileManager.fileExists(atPath: bundledGhosttyURL.path), - fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) { - resolvedResourcesDir = bundledGhosttyURL.path - } else if fileManager.fileExists(atPath: ghosttyAppResources) { - resolvedResourcesDir = ghosttyAppResources - } else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) { - resolvedResourcesDir = bundledGhosttyURL.path - } + let currentResourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } + if let resolvedResourcesDir = resolvedGhosttyResourcesDirectory( + currentValue: currentResourcesDir, + bundleResourceURL: Bundle.main.resourceURL, + fileManager: fileManager + ) { + setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1) + } - if let resolvedResourcesDir { - setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1) - } + if getenv("TERMINFO") == nil, + let terminfoURL = Bundle.main.resourceURL?.appendingPathComponent("terminfo"), + fileManager.fileExists(atPath: terminfoURL.path) { + setenv("TERMINFO", terminfoURL.path, 1) } if getenv("TERM") == nil { @@ -170,16 +165,49 @@ struct cmuxApp: App { let dataDir = resourcesParent.path let manDir = resourcesParent.appendingPathComponent("man").path - appendEnvPathIfMissing( + prependEnvPathIfMissing( "XDG_DATA_DIRS", path: dataDir, defaultValue: "/usr/local/share:/usr/share" ) - appendEnvPathIfMissing("MANPATH", path: manDir) + prependEnvPathIfMissing("MANPATH", path: manDir) + } + } + + static func resolvedGhosttyResourcesDirectory( + currentValue: String?, + bundleResourceURL: URL?, + ghosttyAppResources: String = "/Applications/Ghostty.app/Contents/Resources/ghostty", + fileManager: FileManager = .default + ) -> String? { + let bundledGhosttyURL = bundleResourceURL?.appendingPathComponent("ghostty") + // Tagged cmux builds may inherit GHOSTTY_RESOURCES_DIR from another running + // cmux instance. Prefer this app's bundled resources when they are present. + if let bundledGhosttyURL, + fileManager.fileExists(atPath: bundledGhosttyURL.path), + fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) { + return bundledGhosttyURL.path } + + if let currentValue = currentValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !currentValue.isEmpty, + fileManager.fileExists(atPath: currentValue) { + return currentValue + } + + if fileManager.fileExists(atPath: ghosttyAppResources) { + return ghosttyAppResources + } + + if let bundledGhosttyURL, + fileManager.fileExists(atPath: bundledGhosttyURL.path) { + return bundledGhosttyURL.path + } + + return nil } - private static func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) { + private static func prependEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) { if path.isEmpty { return } var current = getenv(key).flatMap { String(cString: $0) } ?? "" if current.isEmpty, let defaultValue { @@ -188,7 +216,7 @@ struct cmuxApp: App { if current.split(separator: ":").contains(Substring(path)) { return } - let updated = current.isEmpty ? path : "\(current):\(path)" + let updated = current.isEmpty ? path : "\(path):\(current)" setenv(key, updated, 1) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5c35b929bcbf..f8baaa19110c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -350,6 +350,8 @@ A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */; }; A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; + A5001643 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */; }; + B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */; }; D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */; }; D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; @@ -977,6 +979,7 @@ A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectSchemesTabView.swift; sourceTree = ""; }; A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectTargetsTabView.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; + A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteInteractiveShellBootstrapBuilder.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackProxyAlias.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; @@ -1648,6 +1651,7 @@ A5001661 /* RestorableAgentSession.swift */, C0DEF0C10000000000000002 /* AgentForkSupport.swift */, C0DEF0C20000000000000002 /* SemanticVersion.swift */, + A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */, A5001651 /* CmuxConfig.swift */, @@ -2362,6 +2366,7 @@ A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */, A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, + A5001643 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */, D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, @@ -2531,6 +2536,7 @@ FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, C47110020000000000000003 /* ProcessPipeReader.swift in Sources */, + B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, ); diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 57297f9b79d4..aecc751b5b47 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -2381,18 +2381,24 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let run = try runMockedSSH(arguments: [], jsonOutput: true) let payload = try jsonPayload(from: run.stdout) let sessionID = try XCTUnwrap(payload["ssh_pty_session_id"] as? String) + let persistentDaemonSlot = try XCTUnwrap(payload["persistent_daemon_slot"] as? String) XCTAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") XCTAssertFalse(sessionID.contains("$"), sessionID) XCTAssertFalse(sessionID.contains("{"), sessionID) + XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } func testSSHPersistentPTYJSONResolvesSessionIDWhenWorkspaceCreateOmitsSurfaceID() throws { let run = try runMockedSSH(arguments: [], jsonOutput: true, omitWorkspaceCreateSurfaceID: true) let payload = try jsonPayload(from: run.stdout) let sessionID = try XCTUnwrap(payload["ssh_pty_session_id"] as? String) + let persistentDaemonSlot = try XCTUnwrap(payload["persistent_daemon_slot"] as? String) XCTAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") + XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } private func assertSSHPersistentPTYUsesReusableForegroundAuthControlConnection( @@ -2465,6 +2471,9 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["auto_connect"] as? Bool, false) XCTAssertNotNil(configureParams["foreground_auth_token"] as? String) XCTAssertEqual(configureParams["preserve_after_terminal_exit"] as? Bool, true) + let persistentDaemonSlot = try XCTUnwrap(configureParams["persistent_daemon_slot"] as? String) + XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } func testSSHPersistentPTYFallsBackWhenForegroundAuthCannotBeReused() throws { @@ -2498,6 +2507,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["auto_connect"] as? Bool, true, testCase.name) XCTAssertNil(configureParams["foreground_auth_token"], testCase.name) XCTAssertNil(configureParams["preserve_after_terminal_exit"], testCase.name) + XCTAssertNil(configureParams["persistent_daemon_slot"], testCase.name) } } @@ -3363,6 +3373,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(params["remote_pty_session_id"] as? String, sessionId) XCTAssertEqual(params["focus"] as? Bool, true) let initialCommand = params["initial_command"] as? String ?? "" + XCTAssertTrue(initialCommand.hasPrefix("/bin/sh -c "), initialCommand) XCTAssertTrue(initialCommand.contains("ssh-pty-attach"), initialCommand) XCTAssertTrue(initialCommand.contains("--require-existing"), initialCommand) XCTAssertTrue(initialCommand.contains(sessionId), initialCommand) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 7bb0169899f1..4d01e943f16e 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -51,6 +51,104 @@ final class GhosttyConfigTests: XCTestCase { let blue: Int } + func testLaunchGhosttyResourcesPreferCurrentBundleOverInheritedEnvironment() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-resources-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let inheritedResources = root.appendingPathComponent("inherited/ghostty", isDirectory: true) + let bundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + let bundledGhostty = bundleResources.appendingPathComponent("ghostty", isDirectory: true) + try fileManager.createDirectory( + at: inheritedResources.appendingPathComponent("themes", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory( + at: bundledGhostty.appendingPathComponent("themes", isDirectory: true), + withIntermediateDirectories: true + ) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: inheritedResources.path, + bundleResourceURL: bundleResources, + ghosttyAppResources: root.appendingPathComponent("missing", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, bundledGhostty.path) + } + + func testLaunchGhosttyResourcesKeepInheritedEnvironmentWhenBundleHasNoResources() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-resource-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let inheritedResources = root.appendingPathComponent("inherited/ghostty", isDirectory: true) + let emptyBundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + try fileManager.createDirectory(at: inheritedResources, withIntermediateDirectories: true) + try fileManager.createDirectory(at: emptyBundleResources, withIntermediateDirectories: true) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: inheritedResources.path, + bundleResourceURL: emptyBundleResources, + ghosttyAppResources: root.appendingPathComponent("missing", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, inheritedResources.path) + } + + func testLaunchGhosttyResourcesKeepInheritedEnvironmentWhenBundleLacksThemes() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-incomplete-resource-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let inheritedResources = root.appendingPathComponent("inherited/ghostty", isDirectory: true) + let bundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + let bundledGhostty = bundleResources.appendingPathComponent("ghostty", isDirectory: true) + try fileManager.createDirectory( + at: inheritedResources.appendingPathComponent("themes", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory(at: bundledGhostty, withIntermediateDirectories: true) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: inheritedResources.path, + bundleResourceURL: bundleResources, + ghosttyAppResources: root.appendingPathComponent("missing", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, inheritedResources.path) + } + + func testLaunchGhosttyResourcesUseIncompleteBundleOnlyAsLastFallback() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-incomplete-resource-last-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + let bundledGhostty = bundleResources.appendingPathComponent("ghostty", isDirectory: true) + try fileManager.createDirectory(at: bundledGhostty, withIntermediateDirectories: true) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: root.appendingPathComponent("missing-inherited", isDirectory: true).path, + bundleResourceURL: bundleResources, + ghosttyAppResources: root.appendingPathComponent("missing-app", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, bundledGhostty.path) + } + func testResolveThemeNamePrefersLightEntryForPairedTheme() { let resolved = GhosttyConfig.resolveThemeName( from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", @@ -2428,6 +2526,48 @@ final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase { XCTAssertEqual(first.proxyBrokerTransportKey, second.proxyBrokerTransportKey) } + + func testPersistentPTYIdentityRequiresSameRelayPort() { + let first = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64000-%C", + ], + localProxyPort: nil, + relayPort: 64000, + relayID: "relay-a", + relayToken: "token-a", + localSocketPath: "/tmp/cmux-a.sock", + terminalStartupCommand: "ssh cmux-macmini", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-test-slot" + ) + let second = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64001-%C", + ], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-b", + relayToken: "token-b", + localSocketPath: "/tmp/cmux-b.sock", + terminalStartupCommand: "ssh cmux-macmini", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-test-slot" + ) + + XCTAssertFalse(first.hasSamePersistentPTYIdentity(as: second)) + XCTAssertFalse(second.hasSamePersistentPTYIdentity(as: first)) + } } final class WorkspaceRemoteSSHCleanupTests: XCTestCase { @@ -2435,6 +2575,7 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { let psOutput = """ 101 1 /usr/bin/ssh -N -T -S none -o ControlPath=/tmp/cmux-ssh-501-56080-%C -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini 102 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + 107 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-test'' 103 999 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini 104 1 /usr/bin/ssh -tt cmux-macmini 105 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56082:127.0.0.1:64050 other-host @@ -2446,7 +2587,7 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { psOutput: psOutput, destination: "cmux-macmini" ), - [101, 102] + [101, 102, 107] ) } @@ -2455,8 +2596,20 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { 201 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini 202 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini 203 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + 204 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-test'' + 205 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-other'' """ + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + relayPort: 56081, + persistentDaemonSlot: "ssh-test" + ), + [202, 204] + ) + XCTAssertEqual( WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( psOutput: psOutput, @@ -2466,6 +2619,59 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { [202] ) } + + func testOrphanedCMUXRemoteSSHPIDsMatchesBackslashEscapedPersistentDaemonSlot() { + let psOutput = """ + 211 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec '\''.cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote'\'' '\''serve'\'' '\''--stdio'\'' '\''--persistent'\'' '\''--slot'\'' '\''ssh-test'\''' + 212 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec '\''.cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote'\'' '\''serve'\'' '\''--stdio'\'' '\''--persistent'\'' '\''--slot'\'' '\''ssh-other'\''' + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + relayPort: 56081, + persistentDaemonSlot: "ssh-test" + ), + [211] + ) + } + + func testOrphanedCMUXRemoteSSHPIDsMatchesEqualsQuotedPersistentDaemonSlot() { + let psOutput = """ + 221 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote serve --stdio --persistent --slot='ssh-test'' + 222 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote serve --stdio --persistent --slot="ssh-other"' + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + relayPort: 56081, + persistentDaemonSlot: "ssh-test" + ), + [221] + ) + } + + func testOrphanedCMUXRemoteSSHPIDsWithSlotAndNoRelayKeepsGenericCleanup() { + let psOutput = """ + 301 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini + 302 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + 303 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-test'' + 304 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-other'' + 305 1 /usr/bin/ssh -T -S none -o RequestTTY=no other-host sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + persistentDaemonSlot: "ssh-test" + ), + [301, 302, 303] + ) + } } final class TitlebarDoubleClickPreferenceTests: XCTestCase { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 452d12deedee..0105cc529edf 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -19,6 +19,20 @@ final class TabManagerSessionSnapshotTests: XCTestCase { super.tearDown() } + private func reserveRemoteRestoreSocket() -> String { + TerminalController.shared.stop() + let requestedPath = "/tmp/cmux-restore-\(UUID().uuidString).sock" + let reservedPath = TerminalController.shared.reserveStartupSocketPath(requestedPath) + XCTAssertEqual(TerminalController.shared.currentSocketPathForRemoteRestore(), reservedPath) + return reservedPath + } + + private func cleanupRemoteRestoreSocket(_ path: String) { + TerminalController.shared.stop() + try? FileManager.default.removeItem(atPath: path) + try? FileManager.default.removeItem(atPath: path + ".lock") + } + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { let manager = TabManager() guard let firstWorkspace = manager.selectedWorkspace else { @@ -1740,6 +1754,38 @@ final class TabManagerSessionSnapshotTests: XCTestCase { snapshot: terminalSnapshot, isRunningUnderAutomatedTests: false )) + + let localTerminalPanelId = UUID() + var localTerminal = Self.terminalPanelSnapshot(id: localTerminalPanelId) + localTerminal.terminal?.isRemoteTerminal = false + var browserAndLocalTerminalSnapshot = browserOnlySnapshot + browserAndLocalTerminalSnapshot.panels.append(localTerminal) + if case .pane(var pane) = browserAndLocalTerminalSnapshot.layout { + pane.panelIds.append(localTerminalPanelId) + browserAndLocalTerminalSnapshot.layout = .pane(pane) + } + XCTAssertTrue(Workspace.shouldAutoConnectRestoredRemote( + foregroundAuthToken: "token-a", + snapshot: browserAndLocalTerminalSnapshot, + isRunningUnderAutomatedTests: false + )) + + let restoredAttachPanelId = UUID() + var restoredAttachTerminal = Self.terminalPanelSnapshot(id: restoredAttachPanelId) + restoredAttachTerminal.terminal?.isRemoteTerminal = false + restoredAttachTerminal.terminal?.remotePTYSessionID = " ssh-restored-session " + var browserAndRestoredAttachSnapshot = browserOnlySnapshot + browserAndRestoredAttachSnapshot.panels.append(restoredAttachTerminal) + if case .pane(var pane) = browserAndRestoredAttachSnapshot.layout { + pane.panelIds.append(restoredAttachPanelId) + browserAndRestoredAttachSnapshot.layout = .pane(pane) + } + XCTAssertFalse(Workspace.shouldAutoConnectRestoredRemote( + foregroundAuthToken: "token-a", + snapshot: browserAndRestoredAttachSnapshot, + isRunningUnderAutomatedTests: false + )) + XCTAssertTrue(Workspace.shouldAutoConnectRestoredRemote( foregroundAuthToken: nil, snapshot: terminalSnapshot, @@ -1978,10 +2024,11 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } - func testSessionSnapshotRestoresPersistentSSHPTYWithFreshAttachAfterRelaunch() throws { + func testSessionSnapshotRestoresPersistentSSHPTYSessionAfterRelaunch() throws { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) remoteWorkspace.setCustomTitle("Persistent SSH") + let persistentDaemonSlot = "ssh-persist-test" let configuration = WorkspaceRemoteConfiguration( destination: "dev@example.com", port: 2222, @@ -1995,10 +2042,12 @@ final class TabManagerSessionSnapshotTests: XCTestCase { relayToken: String(repeating: "e", count: 64), localSocketPath: "/tmp/cmux-persist-test.sock", terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot ) remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) let remotePanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + remoteWorkspace.updatePanelDirectory(panelId: remotePanelId, directory: "/home/dev/persistent-project") let expectedSessionID = Workspace.defaultSSHPTYSessionID( workspaceId: remoteWorkspace.id, panelId: remotePanelId @@ -2029,6 +2078,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { persistedTabManager.workspaces.first { $0.customTitle == "Persistent SSH" } ) XCTAssertEqual(persistedWorkspace.remote?.preserveAfterTerminalExit, true) + XCTAssertEqual(persistedWorkspace.remote?.relayPort, 64003) + XCTAssertEqual(persistedWorkspace.remote?.persistentDaemonSlot, persistentDaemonSlot) XCTAssertEqual( persistedWorkspace.panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID, expectedSessionID @@ -2038,45 +2089,826 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) XCTAssertTrue(expectedScrollback.contains("cmux perf synthetic scrollback"), expectedScrollback) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + let restored = TabManager() restored.restoreSessionSnapshot(persistedTabManager) let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Persistent SSH" }) XCTAssertEqual(restoredWorkspace.remoteConfiguration?.preserveAfterTerminalExit, true) + XCTAssertEqual(restoredWorkspace.remoteConfiguration?.relayPort, 64003) + XCTAssertEqual(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot, persistentDaemonSlot) + XCTAssertEqual(restoredWorkspace.remoteConfiguration?.localSocketPath, reservedSocketPath) + XCTAssertTrue( + restoredWorkspace.remoteConfiguration?.sshOptions.contains("ControlPath=/tmp/cmux-ssh-\(getuid())-64003-%C") == true + ) + XCTAssertNotEqual(restoredWorkspace.remoteConfiguration?.relayID, "relay-persist-test") + XCTAssertNotEqual(restoredWorkspace.remoteConfiguration?.relayToken, String(repeating: "e", count: 64)) + let restoredRelayToken = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.relayToken) + XCTAssertEqual(restoredRelayToken.count, 64) + XCTAssertNotNil(restoredRelayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression)) let restoredForegroundAuthToken = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.foregroundAuthToken) XCTAssertFalse(restoredForegroundAuthToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) let terminalStartupCommand = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.terminalStartupCommand) + XCTAssertTrue(terminalStartupCommand.hasPrefix("/bin/sh -c "), terminalStartupCommand) XCTAssertTrue(terminalStartupCommand.contains("ssh-pty-attach"), terminalStartupCommand) XCTAssertTrue(terminalStartupCommand.contains("workspace.remote.foreground_auth_ready"), terminalStartupCommand) XCTAssertTrue(terminalStartupCommand.contains(restoredForegroundAuthToken), terminalStartupCommand) + XCTAssertFalse(terminalStartupCommand.contains(expectedSessionID), terminalStartupCommand) XCTAssertFalse(terminalStartupCommand.contains("--require-existing"), terminalStartupCommand) + XCTAssertTrue(terminalStartupCommand.contains("--command-b64 "), terminalStartupCommand) XCTAssertTrue(terminalStartupCommand.contains("254|255"), terminalStartupCommand) + let restoredDefaultRemoteCommand = try XCTUnwrap( + Self.decodedSSHPTYCommandB64(in: terminalStartupCommand) + ) let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + XCTAssertEqual(restoredWorkspace.panelDirectories[restoredPanelId], "/home/dev/persistent-project") + XCTAssertNil(restoredWorkspace.terminalPanel(for: restoredPanelId)?.requestedWorkingDirectory) + XCTAssertTrue( + restoredDefaultRemoteCommand.contains("export CMUX_SOCKET_PATH=127.0.0.1:64003"), + restoredDefaultRemoteCommand + ) + XCTAssertTrue( + restoredDefaultRemoteCommand.contains("export PATH=\"$HOME/.cmux/bin:$PATH\""), + restoredDefaultRemoteCommand + ) + XCTAssertTrue(restoredDefaultRemoteCommand.contains("CMUX_SHELL_INTEGRATION_DIR"), restoredDefaultRemoteCommand) + XCTAssertTrue( + restoredDefaultRemoteCommand.contains("cmux_workspace_id='__CMUX_WORKSPACE_ID__'"), + restoredDefaultRemoteCommand + ) + XCTAssertTrue( + restoredDefaultRemoteCommand.contains("'__CMUX_''WORKSPACE_ID__'"), + restoredDefaultRemoteCommand + ) + XCTAssertFalse( + restoredDefaultRemoteCommand.contains("[ -n '__CMUX_WORKSPACE_ID__' ]"), + restoredDefaultRemoteCommand + ) + XCTAssertTrue( + restoredDefaultRemoteCommand.contains("cmux_surface_id='__CMUX_SURFACE_ID__'"), + restoredDefaultRemoteCommand + ) + XCTAssertTrue( + restoredDefaultRemoteCommand.contains("'__CMUX_''SURFACE_ID__'"), + restoredDefaultRemoteCommand + ) + XCTAssertFalse( + restoredDefaultRemoteCommand.contains("[ -n '__CMUX_SURFACE_ID__' ]"), + restoredDefaultRemoteCommand + ) + let substitutedRestoredDefaultRemoteCommand = restoredDefaultRemoteCommand + .replacingOccurrences(of: "__CMUX_WORKSPACE_ID__", with: restoredWorkspace.id.uuidString) + .replacingOccurrences(of: "__CMUX_SURFACE_ID__", with: restoredPanelId.uuidString) + XCTAssertTrue( + substitutedRestoredDefaultRemoteCommand.contains( + "cmux_workspace_id='\(restoredWorkspace.id.uuidString)'" + ), + substitutedRestoredDefaultRemoteCommand + ) + XCTAssertTrue( + substitutedRestoredDefaultRemoteCommand.contains("cmux_surface_id='\(restoredPanelId.uuidString)'"), + substitutedRestoredDefaultRemoteCommand + ) + XCTAssertFalse( + substitutedRestoredDefaultRemoteCommand.contains("CMUX_WORKSPACE_ID=__CMUX_WORKSPACE_ID__"), + substitutedRestoredDefaultRemoteCommand + ) + XCTAssertFalse( + substitutedRestoredDefaultRemoteCommand.contains("CMUX_SURFACE_ID=__CMUX_SURFACE_ID__"), + substitutedRestoredDefaultRemoteCommand + ) let restoredInitialCommand = try XCTUnwrap( restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() ) + XCTAssertTrue(restoredInitialCommand.hasPrefix("/bin/sh -c "), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains("ssh-pty-attach"), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains("workspace.remote.foreground_auth_ready"), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains(restoredForegroundAuthToken), restoredInitialCommand) - XCTAssertFalse(restoredInitialCommand.contains("--require-existing"), restoredInitialCommand) + XCTAssertTrue(restoredInitialCommand.contains("--require-existing"), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains("254|255"), restoredInitialCommand) - XCTAssertFalse(restoredInitialCommand.contains(expectedSessionID), restoredInitialCommand) + XCTAssertTrue(restoredInitialCommand.contains(expectedSessionID), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains("CMUX_SURFACE_ID"), restoredInitialCommand) + XCTAssertFalse(restoredInitialCommand.contains("--command-b64 "), restoredInitialCommand) let roundTrip = restoredWorkspace.sessionSnapshot(includeScrollback: false) - let restoredSessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: restoredWorkspace.id, - panelId: restoredPanelId - ) XCTAssertEqual(roundTrip.remote?.preserveAfterTerminalExit, true) - XCTAssertEqual(roundTrip.panels.first?.terminal?.remotePTYSessionID, restoredSessionID) - XCTAssertNotEqual(restoredSessionID, expectedSessionID) + XCTAssertEqual(roundTrip.remote?.relayPort, 64003) + XCTAssertEqual(roundTrip.remote?.persistentDaemonSlot, persistentDaemonSlot) + XCTAssertEqual(roundTrip.panels.first?.terminal?.remotePTYSessionID, expectedSessionID) XCTAssertEqual( persistedWorkspace.panels.first { $0.id == remotePanelId }?.terminal?.scrollback, expectedScrollback ) } + func testSessionSnapshotRestoresSplitPersistentSSHPTYWithoutDefaultAttachScaffold() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Persistent SSH Split") + let persistentDaemonSlot = "ssh-persist-split" + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64008, + relayID: "relay-persist-split", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-persist-split.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let firstPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + let secondPanel = try XCTUnwrap( + remoteWorkspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal, focus: true) + ) + let expectedSessionIDs: Set = [ + Workspace.defaultSSHPTYSessionID(workspaceId: remoteWorkspace.id, panelId: firstPanelId), + Workspace.defaultSSHPTYSessionID(workspaceId: remoteWorkspace.id, panelId: secondPanel.id), + ] + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Persistent SSH Split" }) + XCTAssertEqual(restoredWorkspace.remoteConfiguration?.preserveAfterTerminalExit, true) + XCTAssertEqual(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot, persistentDaemonSlot) + XCTAssertEqual(restoredWorkspace.activeRemoteTerminalSessionCount, 2) + + let restoredSnapshot = restoredWorkspace.sessionSnapshot(includeScrollback: false) + let restoredTerminalPanels = restoredSnapshot.panels.filter { $0.terminal != nil } + XCTAssertEqual(restoredTerminalPanels.count, 2) + XCTAssertEqual( + Set(restoredTerminalPanels.compactMap { $0.terminal?.remotePTYSessionID }), + expectedSessionIDs + ) + + let workspaceDefaultCommand = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.terminalStartupCommand) + XCTAssertTrue(workspaceDefaultCommand.contains("--command-b64 "), workspaceDefaultCommand) + XCTAssertFalse(workspaceDefaultCommand.contains("--require-existing"), workspaceDefaultCommand) + + for panelSnapshot in restoredTerminalPanels { + let panel = try XCTUnwrap(restoredWorkspace.terminalPanel(for: panelSnapshot.id)) + let command = try XCTUnwrap(panel.surface.debugInitialCommand()) + XCTAssertTrue(command.contains("ssh-pty-attach"), command) + XCTAssertTrue(command.contains("--require-existing"), command) + XCTAssertFalse(command.contains("--command-b64 "), command) + XCTAssertTrue( + expectedSessionIDs.contains { command.contains($0) }, + command + ) + } + } + + func testPersistentSSHPTYRestoreRewritesStaleRemoteRelayContextIDs() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Relay Alias SSH") + let persistentDaemonSlot = "ssh-relay-alias" + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64006, + relayID: "relay-alias-test", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-relay-alias.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let originalWorkspaceId = remoteWorkspace.id + let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + let sessionID = Workspace.defaultSSHPTYSessionID( + workspaceId: originalWorkspaceId, + panelId: originalPanelId + ) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Relay Alias SSH" }) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + XCTAssertNotEqual(restoredWorkspace.id, originalWorkspaceId) + XCTAssertNotEqual(restoredPanelId, originalPanelId) + + let request: [String: Any] = [ + "id": "relay-alias-request", + "method": "surface.report_tty", + "params": [ + "workspace_id": originalWorkspaceId.uuidString, + "surface_id": originalPanelId.uuidString, + "panel_id": originalPanelId.uuidString, + "preferred_panel_id": originalPanelId.uuidString, + "target_panel_id": originalPanelId.uuidString, + "created_panel_id": originalPanelId.uuidString, + "tab_id": originalPanelId.uuidString, + "before_panel_id": originalPanelId.uuidString, + "before_surface_id": originalPanelId.uuidString, + "after_panel_id": originalPanelId.uuidString, + "after_surface_id": originalPanelId.uuidString, + "workspace_ids": [originalWorkspaceId.uuidString], + "panel_ids": [originalPanelId.uuidString], + "surface_ids": [originalPanelId.uuidString], + "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], + "tab_id_groups": [[originalWorkspaceId.uuidString, originalPanelId.uuidString]], + "session_id": sessionID, + "caller": [ + "workspace_id": originalWorkspaceId.uuidString, + "surface_id": originalPanelId.uuidString, + "panel_id": originalPanelId.uuidString, + "tab_id": originalWorkspaceId.uuidString, + ], + ], + ] + func decodedParams(from commandLine: Data) throws -> [String: Any] { + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: commandLine, options: []) as? [String: Any] + ) + return try XCTUnwrap(payload["params"] as? [String: Any]) + } + + let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + Data([0x0A]) + let rewrittenData = restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) + let params = try decodedParams(from: rewrittenData) + let requestDataWithoutNewline = try JSONSerialization.data(withJSONObject: request, options: []) + let rewrittenDataWithoutNewline = restoredWorkspace.rewriteRemoteRelayCommandLine(requestDataWithoutNewline) + XCTAssertEqual(rewrittenData.last, UInt8(0x0A)) + XCTAssertNotEqual(rewrittenDataWithoutNewline.last, UInt8(0x0A)) + + XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(params["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["preferred_panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["target_panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["created_panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["tab_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["before_panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["before_surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["after_panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["after_surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) + XCTAssertEqual(params["panel_ids"] as? [String], [restoredPanelId.uuidString]) + XCTAssertEqual(params["surface_ids"] as? [String], [restoredPanelId.uuidString]) + XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspace.id.uuidString, restoredPanelId.uuidString]) + XCTAssertEqual(params["tab_id_groups"] as? [[String]], [[restoredWorkspace.id.uuidString, restoredPanelId.uuidString]]) + XCTAssertEqual(params["session_id"] as? String, sessionID) + + let caller = try XCTUnwrap(params["caller"] as? [String: Any]) + XCTAssertEqual(caller["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(caller["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(caller["panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(caller["tab_id"] as? String, restoredWorkspace.id.uuidString) + + XCTAssertEqual( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, + sessionID + ) + let refreshedRelayConfiguration = WorkspaceRemoteConfiguration( + destination: " dev@example.com ", + port: 2222, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-\(getuid())-64006-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64006, + relayID: "relay-alias-test-refreshed", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-relay-alias-refreshed.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + foregroundAuthToken: "foreground-auth-refreshed", + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot + ) + restoredWorkspace.configureRemoteConnection(refreshedRelayConfiguration, autoConnect: false) + XCTAssertTrue(restoredWorkspace.remotePTYSessionIDMatches(panelId: restoredPanelId, sessionID: sessionID)) + XCTAssertEqual( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, + sessionID + ) + let refreshedRelayParams = try decodedParams( + from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) + ) + XCTAssertEqual(refreshedRelayParams["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(refreshedRelayParams["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(refreshedRelayParams["panel_id"] as? String, restoredPanelId.uuidString) + + restoredWorkspace.disconnectRemoteConnection(clearConfiguration: false) + XCTAssertEqual( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, + sessionID + ) + let preservedDisconnectParams = try decodedParams( + from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) + ) + XCTAssertEqual(preservedDisconnectParams["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(preservedDisconnectParams["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(preservedDisconnectParams["panel_id"] as? String, restoredPanelId.uuidString) + let preservedCaller = try XCTUnwrap(preservedDisconnectParams["caller"] as? [String: Any]) + XCTAssertEqual(preservedCaller["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(preservedCaller["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(preservedCaller["panel_id"] as? String, restoredPanelId.uuidString) + + restoredWorkspace.configureRemoteConnection(configuration, autoConnect: false) + XCTAssertTrue(restoredWorkspace.remotePTYSessionIDMatches(panelId: restoredPanelId, sessionID: sessionID)) + let reconfiguredParams = try decodedParams(from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData)) + XCTAssertEqual(reconfiguredParams["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(reconfiguredParams["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(reconfiguredParams["panel_id"] as? String, restoredPanelId.uuidString) + + restoredWorkspace.disconnectRemoteConnection(clearConfiguration: true) + XCTAssertNil( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID + ) + let clearedParams = try decodedParams(from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData)) + XCTAssertEqual(clearedParams["workspace_id"] as? String, originalWorkspaceId.uuidString) + XCTAssertEqual(clearedParams["surface_id"] as? String, originalPanelId.uuidString) + XCTAssertEqual(clearedParams["panel_id"] as? String, originalPanelId.uuidString) + } + + func testRemoteRelayAmbiguousTabIDAliasesPreferWorkspaceOnCollision() throws { + let staleID = UUID() + let restoredWorkspaceID = UUID() + let restoredPanelID = UUID() + let request: [String: Any] = [ + "id": "relay-ambiguous-alias-request", + "method": "surface.report_tty", + "params": [ + "workspace_id": staleID.uuidString, + "surface_id": staleID.uuidString, + "tab_id": staleID.uuidString, + "tab_ids": [staleID.uuidString], + ], + ] + let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + + let rewrittenData = Workspace.rewriteRemoteRelayCommandLine( + requestData, + workspaceAliases: [staleID: restoredWorkspaceID], + surfaceAliases: [staleID: restoredPanelID] + ) + let rewritten = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData) as? [String: Any]) + let params = try XCTUnwrap(rewritten["params"] as? [String: Any]) + + XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspaceID.uuidString) + XCTAssertEqual(params["surface_id"] as? String, restoredPanelID.uuidString) + XCTAssertEqual(params["tab_id"] as? String, restoredWorkspaceID.uuidString) + XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspaceID.uuidString]) + } + + func testPersistentSSHPTYRestoreRewritesMovedSourceWorkspaceContextID() throws { + let manager = TabManager() + let sourceWorkspace = manager.addWorkspace(select: true) + sourceWorkspace.setCustomTitle("Moved Relay Source") + let destinationWorkspace = manager.addWorkspace(select: false) + destinationWorkspace.setCustomTitle("Moved Relay Destination") + let persistentDaemonSlot = "ssh-relay-moved-alias" + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64008, + relayID: "relay-moved-alias-test", + relayToken: String(repeating: "d", count: 64), + localSocketPath: "/tmp/cmux-relay-moved-alias.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot + ) + sourceWorkspace.configureRemoteConnection(configuration, autoConnect: false) + destinationWorkspace.configureRemoteConnection(configuration, autoConnect: false) + + let sourceWorkspaceId = sourceWorkspace.id + let sourcePanelId = try XCTUnwrap(sourceWorkspace.focusedPanelId) + let sessionID = Workspace.defaultSSHPTYSessionID( + workspaceId: sourceWorkspaceId, + panelId: sourcePanelId + ) + let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: sourcePanelId)) + let destinationPaneId = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) + let movedPanelId = try XCTUnwrap( + destinationWorkspace.attachDetachedSurface( + detached, + inPane: destinationPaneId, + focus: true + ) + ) + XCTAssertEqual(movedPanelId, sourcePanelId) + XCTAssertEqual( + destinationWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == movedPanelId }?.terminal?.remotePTYSessionID, + sessionID + ) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap( + restored.tabs.first { $0.customTitle == "Moved Relay Destination" } + ) + let restoredSnapshot = restoredWorkspace.sessionSnapshot(includeScrollback: false) + let restoredPanelId = try XCTUnwrap( + restoredSnapshot.panels.first { $0.terminal?.remotePTYSessionID == sessionID }?.id + ) + XCTAssertNotEqual(restoredWorkspace.id, destinationWorkspace.id) + XCTAssertNotEqual(restoredWorkspace.id, sourceWorkspaceId) + XCTAssertNotEqual(restoredPanelId, sourcePanelId) + + let request: [String: Any] = [ + "id": "relay-moved-alias-request", + "method": "surface.report_tty", + "params": [ + "workspace_id": sourceWorkspaceId.uuidString, + "surface_id": sourcePanelId.uuidString, + "panel_id": sourcePanelId.uuidString, + "tab_id": sourceWorkspaceId.uuidString, + "session_id": sessionID, + "caller": [ + "workspace_id": sourceWorkspaceId.uuidString, + "surface_id": sourcePanelId.uuidString, + "panel_id": sourcePanelId.uuidString, + "tab_id": sourceWorkspaceId.uuidString, + ], + ], + ] + let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + Data([0x0A]) + let rewrittenData = restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) + let rewritten = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData, options: []) as? [String: Any]) + let params = try XCTUnwrap(rewritten["params"] as? [String: Any]) + + XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(params["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["tab_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(params["session_id"] as? String, sessionID) + + let caller = try XCTUnwrap(params["caller"] as? [String: Any]) + XCTAssertEqual(caller["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(caller["surface_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(caller["panel_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(caller["tab_id"] as? String, restoredWorkspace.id.uuidString) + } + + func testPersistentSSHPTYReattachRewritesStaleRemoteRelayContextIDs() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Relay Alias Reattach SSH") + let persistentDaemonSlot = "ssh-relay-reattach-alias" + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64007, + relayID: "relay-reattach-alias-test", + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-relay-reattach-alias.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let originalWorkspaceId = remoteWorkspace.id + let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + let sessionID = Workspace.defaultSSHPTYSessionID( + workspaceId: originalWorkspaceId, + panelId: originalPanelId + ) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Relay Alias Reattach SSH" }) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + let ended = restoredWorkspace.markRemotePTYAttachEnded(surfaceId: restoredPanelId, sessionID: sessionID) + XCTAssertTrue(ended.clearedRemotePTYSession) + XCTAssertTrue(ended.untrackedRemoteTerminal) + + let paneId = try XCTUnwrap(restoredWorkspace.bonsplitController.allPaneIds.first) + let attachStartupCommand = Workspace.sshPTYAttachStartupCommand(sessionID: sessionID) + XCTAssertTrue(attachStartupCommand.hasPrefix("/bin/sh -c "), attachStartupCommand) + let reattachedPanel = try XCTUnwrap( + restoredWorkspace.newTerminalSurface( + inPane: paneId, + focus: true, + initialCommand: attachStartupCommand, + remotePTYSessionID: sessionID + ) + ) + XCTAssertNotEqual(reattachedPanel.id, restoredPanelId) + + let request: [String: Any] = [ + "id": "relay-reattach-alias-request", + "method": "surface.report_tty", + "params": [ + "workspace_id": originalWorkspaceId.uuidString, + "surface_id": originalPanelId.uuidString, + "panel_id": originalPanelId.uuidString, + "preferred_panel_id": originalPanelId.uuidString, + "target_panel_id": originalPanelId.uuidString, + "created_panel_id": originalPanelId.uuidString, + "tab_id": originalPanelId.uuidString, + "before_panel_id": originalPanelId.uuidString, + "before_surface_id": originalPanelId.uuidString, + "after_panel_id": originalPanelId.uuidString, + "after_surface_id": originalPanelId.uuidString, + "workspace_ids": [originalWorkspaceId.uuidString], + "panel_ids": [originalPanelId.uuidString], + "surface_ids": [originalPanelId.uuidString], + "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], + "session_id": sessionID, + ], + ] + let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + Data([0x0A]) + let rewrittenData = restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) + let rewritten = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData, options: []) as? [String: Any]) + let params = try XCTUnwrap(rewritten["params"] as? [String: Any]) + + XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) + XCTAssertEqual(params["surface_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["panel_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["preferred_panel_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["target_panel_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["created_panel_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["tab_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["before_panel_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["before_surface_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["after_panel_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["after_surface_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) + XCTAssertEqual(params["panel_ids"] as? [String], [reattachedPanel.id.uuidString]) + XCTAssertEqual(params["surface_ids"] as? [String], [reattachedPanel.id.uuidString]) + XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspace.id.uuidString, reattachedPanel.id.uuidString]) + XCTAssertEqual(params["session_id"] as? String, sessionID) + } + + func testPersistentSSHPTYRestoreFallsBackToSnapshotPanelDefaultSessionIDWhenActiveMarkerExists() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Legacy Persistent SSH") + let persistentDaemonSlot = "ssh-legacy-persist" + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64004, + relayID: "relay-legacy-persist", + relayToken: String(repeating: "f", count: 64), + localSocketPath: "/tmp/cmux-legacy-persist.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + let expectedSessionID = Workspace.defaultSSHPTYSessionID( + workspaceId: remoteWorkspace.id, + panelId: originalPanelId + ) + + var legacySnapshot = manager.sessionSnapshot(includeScrollback: false) + let workspaceIndex = try XCTUnwrap( + legacySnapshot.workspaces.firstIndex { $0.customTitle == "Legacy Persistent SSH" } + ) + XCTAssertEqual(legacySnapshot.workspaces[workspaceIndex].workspaceId, remoteWorkspace.id) + let panelIndex = try XCTUnwrap( + legacySnapshot.workspaces[workspaceIndex].panels.firstIndex { $0.id == originalPanelId } + ) + legacySnapshot.workspaces[workspaceIndex].panels[panelIndex].terminal?.remotePTYSessionID = nil + legacySnapshot.workspaces[workspaceIndex].panels[panelIndex].terminal?.isRemoteTerminal = true + + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(legacySnapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Legacy Persistent SSH" }) + XCTAssertNotEqual(restoredWorkspace.id, remoteWorkspace.id) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + let restoredInitialCommand = try XCTUnwrap( + restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() + ) + XCTAssertTrue(restoredInitialCommand.hasPrefix("/bin/sh -c "), restoredInitialCommand) + XCTAssertTrue(restoredInitialCommand.contains("ssh-pty-attach"), restoredInitialCommand) + XCTAssertTrue(restoredInitialCommand.contains("--require-existing"), restoredInitialCommand) + XCTAssertTrue(restoredInitialCommand.contains(expectedSessionID), restoredInitialCommand) + XCTAssertTrue(restoredWorkspace.remotePTYSessionIDMatches(panelId: restoredPanelId, sessionID: expectedSessionID)) + XCTAssertEqual( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, + expectedSessionID + ) + } + + func testPersistentSSHPTYRestoreDoesNotReattachEndedSnapshotPanel() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Ended Persistent SSH") + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64019, + relayID: "relay-ended-persist", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-ended-persist.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-ended-persist" + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + let endedSessionID = Workspace.defaultSSHPTYSessionID( + workspaceId: remoteWorkspace.id, + panelId: originalPanelId + ) + + let ended = remoteWorkspace.markRemotePTYAttachEnded( + surfaceId: originalPanelId, + sessionID: endedSessionID + ) + XCTAssertTrue(ended.clearedRemotePTYSession) + XCTAssertTrue(ended.untrackedRemoteTerminal) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let persistedWorkspace = try XCTUnwrap( + snapshot.workspaces.first { $0.customTitle == "Ended Persistent SSH" } + ) + let persistedPanel = try XCTUnwrap( + persistedWorkspace.panels.first { $0.id == originalPanelId } + ) + XCTAssertEqual(persistedPanel.terminal?.isRemoteTerminal, false) + XCTAssertNil(persistedPanel.terminal?.remotePTYSessionID) + + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Ended Persistent SSH" }) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + let restoredInitialCommand = restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() + XCTAssertNil(restoredInitialCommand) + XCTAssertNil(restoredWorkspace.terminalPanel(for: restoredPanelId)?.requestedWorkingDirectory) + XCTAssertNil( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID + ) + } + + func testPersistentSSHPTYRestorePreservesLocalTerminalWorkingDirectory() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Remote Workspace With Local Terminal") + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64020, + relayID: "relay-local-terminal", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-local-terminal.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-local-terminal" + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first) + let localDirectory = "/tmp/cmux-local-terminal" + let localPanel = try XCTUnwrap( + remoteWorkspace.newTerminalSurface( + inPane: paneId, + focus: true, + workingDirectory: localDirectory, + suppressWorkspaceRemoteStartupCommand: true + ) + ) + remoteWorkspace.setPanelCustomTitle(panelId: localPanel.id, title: "Local Shell") + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let persistedWorkspace = try XCTUnwrap( + snapshot.workspaces.first { $0.customTitle == "Remote Workspace With Local Terminal" } + ) + let persistedLocalPanel = try XCTUnwrap( + persistedWorkspace.panels.first { $0.customTitle == "Local Shell" } + ) + XCTAssertEqual(persistedLocalPanel.terminal?.isRemoteTerminal, false) + XCTAssertEqual(persistedLocalPanel.terminal?.workingDirectory, localDirectory) + + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Remote Workspace With Local Terminal" }) + let restoredLocalPanel = try XCTUnwrap( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.customTitle == "Local Shell" } + ) + let restoredPanel = try XCTUnwrap(restoredWorkspace.terminalPanel(for: restoredLocalPanel.id)) + XCTAssertNil(restoredPanel.surface.debugInitialCommand()) + XCTAssertEqual(restoredPanel.requestedWorkingDirectory, localDirectory) + } + + func testSessionSnapshotFallsBackWhenPersistentSSHPTYRestoreHasNoSocketPath() throws { + TerminalController.shared.stop() + defer { TerminalController.shared.stop() } + + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Persistent SSH Without Socket") + remoteWorkspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: ["StrictHostKeyChecking=accept-new"], + localProxyPort: nil, + relayPort: 64018, + relayID: "relay-no-socket", + relayToken: String(repeating: "f", count: 64), + localSocketPath: "/tmp/cmux-no-socket.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-no-socket" + ), + autoConnect: false + ) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + XCTAssertNil(TerminalController.shared.currentSocketPathForRemoteRestore()) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Persistent SSH Without Socket" }) + XCTAssertEqual(restoredWorkspace.remoteConfiguration?.preserveAfterTerminalExit, false) + XCTAssertNil(restoredWorkspace.remoteConfiguration?.relayPort) + XCTAssertNil(restoredWorkspace.remoteConfiguration?.localSocketPath) + XCTAssertNil(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot) + let terminalStartupCommand = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.terminalStartupCommand) + XCTAssertFalse(terminalStartupCommand.contains("ssh-pty-attach"), terminalStartupCommand) + XCTAssertTrue(terminalStartupCommand.contains("ssh -p 2222"), terminalStartupCommand) + } + func testSessionSnapshotFallsBackFromSkipBootstrapPersistentSSHPTYWithoutDaemonBridge() throws { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) @@ -2146,6 +2978,149 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertNil(roundTrip.panels.first?.terminal?.remotePTYSessionID) } + func testSessionRemoteWorkspaceSnapshotRequiresPersistentDaemonSlotForPTYRestore() throws { + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + "ControlMaster=auto", + "ControlPersist=600", + ], + preserveAfterTerminalExit: true, + skipDaemonBootstrap: nil, + relayPort: 64003, + persistentDaemonSlot: nil + ) + + let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: "/tmp/cmux-restore.sock")) + + XCTAssertEqual(configuration.preserveAfterTerminalExit, false) + XCTAssertNil(configuration.foregroundAuthToken) + XCTAssertNil(configuration.persistentDaemonSlot) + XCTAssertNil(configuration.relayPort) + XCTAssertNil(configuration.localSocketPath) + XCTAssertFalse(configuration.sshOptions.contains { $0.hasPrefix("ControlPath") }) + XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) + XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + + func testSessionRemoteWorkspaceSnapshotRequiresRelayPortForPTYRestore() throws { + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + "ControlMaster=auto", + "ControlPersist=600", + ], + preserveAfterTerminalExit: true, + skipDaemonBootstrap: nil, + relayPort: nil, + persistentDaemonSlot: "ssh-restore-slot" + ) + + let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: "/tmp/cmux-restore.sock")) + + XCTAssertEqual(configuration.preserveAfterTerminalExit, false) + XCTAssertNil(configuration.foregroundAuthToken) + XCTAssertNil(configuration.persistentDaemonSlot) + XCTAssertNil(configuration.relayPort) + XCTAssertNil(configuration.localSocketPath) + XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) + XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + + func testSessionRemoteWorkspaceSnapshotRequiresLocalSocketPathForPTYRestore() throws { + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + "ControlMaster=auto", + "ControlPersist=600", + ], + preserveAfterTerminalExit: true, + skipDaemonBootstrap: nil, + relayPort: 64003, + persistentDaemonSlot: "ssh-restore-slot" + ) + + let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: " ")) + + XCTAssertEqual(configuration.preserveAfterTerminalExit, false) + XCTAssertNil(configuration.foregroundAuthToken) + XCTAssertNil(configuration.persistentDaemonSlot) + XCTAssertNil(configuration.relayPort) + XCTAssertNil(configuration.localSocketPath) + XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) + XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + + func testSessionRemoteWorkspaceSnapshotStripsTransientControlOptionsWhenPreservedRestoreFallsBack() throws { + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-501-64003-%C", + ], + preserveAfterTerminalExit: true, + skipDaemonBootstrap: nil, + relayPort: 64003, + persistentDaemonSlot: "ssh-restore-slot" + ) + + let configuration = try XCTUnwrap( + snapshot.workspaceConfiguration(localSocketPath: nil, preserveSSHOptions: true) + ) + + XCTAssertEqual(configuration.preserveAfterTerminalExit, false) + XCTAssertEqual(configuration.sshOptions, ["StrictHostKeyChecking=accept-new"]) + XCTAssertEqual( + configuration.terminalStartupCommand, + "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com" + ) + } + + func testSessionRemoteWorkspaceSnapshotRequiresValidPersistentDaemonSlotForPTYRestore() throws { + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + "ControlMaster=auto", + "ControlPersist=600", + ], + preserveAfterTerminalExit: true, + skipDaemonBootstrap: nil, + relayPort: 64003, + persistentDaemonSlot: "../bad" + ) + + let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: "/tmp/cmux-restore.sock")) + + XCTAssertEqual(configuration.preserveAfterTerminalExit, false) + XCTAssertNil(configuration.foregroundAuthToken) + XCTAssertNil(configuration.persistentDaemonSlot) + XCTAssertNil(configuration.relayPort) + XCTAssertNil(configuration.localSocketPath) + XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) + XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + func testSessionRemoteWorkspaceSnapshotDropsInvalidSSHPortFromReconnectCommand() throws { let snapshot = SessionRemoteWorkspaceSnapshot( transport: .ssh, @@ -2197,6 +3172,16 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + private static func decodedSSHPTYCommandB64(in command: String) -> String? { + let marker = "--command-b64 " + guard let markerRange = command.range(of: marker) else { return nil } + let suffix = command[markerRange.upperBound...] + guard let token = suffix.split(whereSeparator: { $0.isWhitespace }).first else { return nil } + let encoded = String(token).trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + guard let data = Data(base64Encoded: encoded) else { return nil } + return String(data: data, encoding: .utf8) + } + private func waitForClosedHistoryCount( _ expectedCount: Int, in store: ClosedItemHistoryStore, diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 5818c6d90d64..3722dc58675d 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -236,6 +236,160 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) } + func testChildExitOnLastPersistentRemotePanelKeepsExitedSurfaceVisibleAndClearsPTYState() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64017, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-child-exit-test" + ), + autoConnect: false + ) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) + + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, workspace.id) + XCTAssertEqual(manager.tabs.first?.id, workspace.id) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertNotNil(workspace.panels[remotePanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertEqual(workspace.focusedPanelId, remotePanelId) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertFalse(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID + ) + } + + func testChildExitAfterPersistentAttachEndKeepsExitedSurfaceVisible() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64020, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-attach-end-test.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-child-exit-after-attach-end" + ), + autoConnect: false + ) + let sessionID = Workspace.defaultSSHPTYSessionID(workspaceId: workspace.id, panelId: remotePanelId) + + let outcome = workspace.markRemotePTYAttachEnded(surfaceId: remotePanelId, sessionID: sessionID) + + XCTAssertTrue(outcome.clearedRemotePTYSession) + XCTAssertTrue(outcome.untrackedRemoteTerminal) + XCTAssertFalse(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertTrue(workspace.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(remotePanelId)) + + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) + drainMainQueue() + drainMainQueue() + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertNotNil(workspace.panels[remotePanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertEqual(workspace.focusedPanelId, remotePanelId) + XCTAssertFalse(workspace.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(remotePanelId)) + XCTAssertNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID + ) + } + + func testChildExitOnSplitPersistentRemotePanelKeepsExitedSurfaceVisibleAndClearsOnlyThatPTYState() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64018, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-split-test.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-child-exit-split-test" + ), + autoConnect: false + ) + let siblingPanel = try XCTUnwrap( + workspace.newTerminalSplit(from: remotePanelId, orientation: .horizontal, focus: false) + ) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertTrue(workspace.isRemoteTerminalSurface(siblingPanel.id)) + + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, workspace.id) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertNotNil(workspace.panels[remotePanelId]) + XCTAssertNotNil(workspace.panels[siblingPanel.id]) + XCTAssertEqual(workspace.panels.count, 2) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + XCTAssertFalse(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertTrue(workspace.isRemoteTerminalSurface(siblingPanel.id)) + XCTAssertNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID + ) + XCTAssertNotNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == siblingPanel.id }?.terminal?.remotePTYSessionID + ) + } + func testChildExitAfterRemoteSessionEndKeepsWorkspaceAndDemotesToLocal() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, @@ -2437,11 +2591,50 @@ final class TabManagerSurfaceCreationTests: XCTestCase { bypassRemoteProxy: true ) ) + guard browserPanel.setMuted(true) else { + throw XCTSkip("WKWebView page-audio mute selector is unavailable") + } let duplicate = try XCTUnwrap(workspace.duplicateBrowserToRight(panelId: browserPanel.id, focus: false)) + let duplicateTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(duplicate.id)) + let duplicateTab = try XCTUnwrap(workspace.bonsplitController.tab(duplicateTabId)) XCTAssertFalse(duplicate.isOmnibarVisible) XCTAssertTrue(duplicate.bypassesRemoteWorkspaceProxyForTabDuplication) + XCTAssertTrue(duplicate.isMuted) + XCTAssertTrue(duplicateTab.isAudioMuted) + } + + func testBrowserAudioMuteContextActionTogglesPanelAndTabState() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browserPanel = try XCTUnwrap(workspace.newBrowserSurface(inPane: paneId, focus: true)) + let tabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserPanel.id)) + guard browserPanel.setMuted(false) else { + throw XCTSkip("WKWebView page-audio mute selector is unavailable") + } + + let initialTab = try XCTUnwrap(workspace.bonsplitController.tab(tabId)) + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .toggleAudioMute, + for: initialTab, + inPane: paneId + ) + + XCTAssertTrue(browserPanel.isMuted) + XCTAssertTrue(try XCTUnwrap(workspace.bonsplitController.tab(tabId)).isAudioMuted) + + let mutedTab = try XCTUnwrap(workspace.bonsplitController.tab(tabId)) + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .toggleAudioMute, + for: mutedTab, + inPane: paneId + ) + + XCTAssertFalse(browserPanel.isMuted) + XCTAssertFalse(try XCTUnwrap(workspace.bonsplitController.tab(tabId)).isAudioMuted) } func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 5a6d6e96fa5a..a0a506d79578 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -202,6 +202,81 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(payload["has_ssh_options"] as? Bool, true) } + func testRemoteConfigureRejectsInvalidPersistentDaemonSlot() throws { + let response = try handleV2Request( + method: "workspace.remote.configure", + params: [ + "workspace_id": UUID().uuidString, + "transport": "ssh", + "destination": "example.com", + "persistent_daemon_slot": "../bad", + ] + ) + + XCTAssertEqual(response["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(response)") + let error = try XCTUnwrap(response["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "invalid_params") + XCTAssertEqual( + error["message"] as? String, + "persistent_daemon_slot must contain only letters, numbers, '.', '_' or '-'" + ) + } + + func testRemoteConfigureDefaultsPersistentDaemonSlotForBootstrapSSH() throws { + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + defer { AppDelegate.shared = previousAppDelegate } + + let manager = TabManager() + let workspace = manager.addWorkspace(select: false, eagerLoadTerminal: false) + let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + defer { + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + + let response = try handleV2Request( + method: "workspace.remote.configure", + params: [ + "workspace_id": workspace.id.uuidString, + "transport": "ssh", + "destination": "example.com", + "preserve_after_terminal_exit": true, + "auto_connect": false, + ] + ) + + XCTAssertEqual(response["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(response)") + XCTAssertEqual(workspace.remoteConfiguration?.preserveAfterTerminalExit, true) + XCTAssertEqual( + workspace.remoteConfiguration?.persistentDaemonSlot, + "ssh-\(workspace.id.uuidString.lowercased())" + ) + } + + func testRemoteConfigureRejectsPersistentDaemonSlotWithoutPreserve() throws { + let response = try handleV2Request( + method: "workspace.remote.configure", + params: [ + "workspace_id": UUID().uuidString, + "transport": "ssh", + "destination": "example.com", + "persistent_daemon_slot": "ssh-test-slot", + ] + ) + + XCTAssertEqual(response["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(response)") + let error = try XCTUnwrap(response["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "invalid_params") + XCTAssertEqual( + error["message"] as? String, + "preserve_after_terminal_exit is required when persistent_daemon_slot is set" + ) + } + func testRemotePTYResizeRunsOnSocketWorker() async throws { let socketPath = makeSocketPath("pty-worker") let tabManager = TabManager() diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index f88984119382..72c04d98190d 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -67,6 +67,11 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { .write(to: url, atomically: true, encoding: .utf8) } + private func writeExecutableShellFile(at url: URL, body: String) throws { + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + private func runRelayZshHistfile( configureUserHome: (URL) throws -> URL ) throws -> String { @@ -112,6 +117,146 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { return histfile ?? "" } + private func runGeneratedBashBootstrapMarkers(startupFiles: [String: String]) throws -> [String] { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-bash-\(UUID().uuidString)") + let bin = home.appendingPathComponent("bin") + let markerFile = home.appendingPathComponent("markers.txt") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + for (fileName, marker) in startupFiles { + let startupScript = """ + printf '%s\\n' '\(marker)' >> "$CMUX_BASH_MARKERS" + """ + try startupScript.write(to: home.appendingPathComponent(fileName), atomically: true, encoding: .utf8) + } + try writeExecutableShellFile( + at: bin.appendingPathComponent("bash"), + body: """ + #!/bin/sh + rcfile= + while [ "$#" -gt 0 ]; do + case "$1" in + --rcfile) + shift + rcfile="${1:-}" + ;; + esac + shift || true + done + if [ -n "$rcfile" ]; then + . "$rcfile" + fi + """ + ) + + let script = RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: 0, + shellFeatures: "" + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "SHELL=\(bin.appendingPathComponent("bash").path)", + "PATH=\(bin.path):/usr/bin:/bin", + "TERM=xterm-256color", + "USER=\(NSUserName())", + "CMUX_BASH_MARKERS=\(markerFile.path)", + "/bin/sh", + "-c", + script, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let contents = (try? String(contentsOf: markerFile, encoding: .utf8)) ?? "" + return contents + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + func testGeneratedBashBootstrapSourcesLoginFilesInBashPrecedenceOrder() throws { + XCTAssertEqual( + try runGeneratedBashBootstrapMarkers(startupFiles: [ + ".bash_profile": "bash_profile", + ".bash_login": "bash_login", + ".profile": "profile", + ".bashrc": "bashrc", + ]), + ["bash_profile", "bashrc"] + ) + XCTAssertEqual( + try runGeneratedBashBootstrapMarkers(startupFiles: [ + ".bash_login": "bash_login", + ".profile": "profile", + ".bashrc": "bashrc", + ]), + ["bash_login", "bashrc"] + ) + XCTAssertEqual( + try runGeneratedBashBootstrapMarkers(startupFiles: [ + ".profile": "profile", + ".bashrc": "bashrc", + ]), + ["profile", "bashrc"] + ) + } + + func testGeneratedFallbackShellBootstrapPrependsCmuxBinOnce() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-fallback-shell-bootstrap-\(UUID().uuidString)") + let home = root.appendingPathComponent("home") + let bin = root.appendingPathComponent("bin") + let capturedPath = root.appendingPathComponent("path.txt") + try fileManager.createDirectory(at: home, withIntermediateDirectories: true) + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("fish"), + body: """ + #!/bin/sh + printf '%s\\n' "$PATH" > "$CMUX_CAPTURE_PATH" + """ + ) + + let script = RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: 0, + shellFeatures: "" + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "SHELL=\(bin.appendingPathComponent("fish").path)", + "PATH=/usr/bin:/bin", + "TERM=xterm-256color", + "USER=\(NSUserName())", + "CMUX_CAPTURE_PATH=\(capturedPath.path)", + "/bin/sh", + "-c", + script, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let path = try String(contentsOf: capturedPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + let cmuxBinEntries = path.split(separator: ":") + .filter { $0 == "\(home.path)/.cmux/bin" } + XCTAssertEqual(cmuxBinEntries.count, 1, path) + } + func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { let fileManager = FileManager.default let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") @@ -119,12 +264,14 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") let authURL = relayDir.appendingPathComponent("64008.auth") let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path") + let slotURL = relayDir.appendingPathComponent("64008.slot") let ttyURL = relayDir.appendingPathComponent("64008.tty") XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "slot".write(to: slotURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "ttys001".write(to: ttyURL, atomically: true, encoding: .utf8)) defer { try? fileManager.removeItem(at: home) } @@ -144,6 +291,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: slotURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } @@ -154,12 +302,14 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") let authURL = relayDir.appendingPathComponent("64009.auth") let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path") + let slotURL = relayDir.appendingPathComponent("64009.slot") let ttyURL = relayDir.appendingPathComponent("64009.tty") XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "slot".write(to: slotURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "ttys002".write(to: ttyURL, atomically: true, encoding: .utf8)) defer { try? fileManager.removeItem(at: home) } @@ -179,9 +329,335 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: slotURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } + func testRemoteStaleRelayListenerCleanupScriptKillsMatchingPersistentRelayListener() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-cleanup-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + 34057 33681 /Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote serve --stdio --persistent --slot ssh-c4ba8ab1 + 34058 33681 /bin/zsh + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("cmux_stale_relay_killed pid=33681 children=34057 port=50446"), result.stdout) + + let killOutput = try String(contentsOf: killLog, encoding: .utf8) + XCTAssertTrue(killOutput.contains("-TERM 33681 34057"), killOutput) + XCTAssertTrue(killOutput.contains("-KILL 33681"), killOutput) + XCTAssertTrue(killOutput.contains("-KILL 34057"), killOutput) + } + + func testRemoteStaleRelayListenerCleanupScriptPreservesDifferentPersistentSlot() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-preserve-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + 34057 33681 /Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote serve --stdio --persistent --slot ssh-other + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") + } + + func testRemoteStaleRelayListenerCleanupScriptMatchesPersistentSlotExactly() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-slot-prefix-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + 34057 33681 /Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote serve --stdio --persistent --slot ssh-ab + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-a" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") + } + + func testRemoteStaleRelayListenerCleanupScriptKillsMetadataMatchedListenerWithoutChild() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-metadata-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let relayDir = root.appendingPathComponent(".cmux/relay") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) + try "/Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote".write( + to: relayDir.appendingPathComponent("50446.daemon_path"), + atomically: true, + encoding: .utf8 + ) + try "ssh-c4ba8ab1".write( + to: relayDir.appendingPathComponent("50446.slot"), + atomically: true, + encoding: .utf8 + ) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(root.path)", + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue( + result.stdout.contains("cmux_stale_relay_killed pid=33681 children= port=50446 reason=metadata"), + result.stdout + ) + + let killOutput = try String(contentsOf: killLog, encoding: .utf8) + XCTAssertTrue(killOutput.contains("-TERM 33681"), killOutput) + XCTAssertTrue(killOutput.contains("-KILL 33681"), killOutput) + } + + func testRemoteStaleRelayListenerCleanupScriptPreservesMetadataMatchedDifferentPersistentSlot() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-metadata-preserve-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let relayDir = root.appendingPathComponent(".cmux/relay") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) + try "/Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote".write( + to: relayDir.appendingPathComponent("50446.daemon_path"), + atomically: true, + encoding: .utf8 + ) + try "ssh-other-slot".write( + to: relayDir.appendingPathComponent("50446.slot"), + atomically: true, + encoding: .utf8 + ) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(root.path)", + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") + } + func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws { let histfile = try runRelayZshHistfile { home in try ":\n".write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) @@ -779,7 +1255,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayToken: String(repeating: "b", count: 64), localSocketPath: "/tmp/cmux-debug-test.sock", terminalStartupCommand: "ssh cmux-macmini", - preserveAfterTerminalExit: true + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-persist-end" ) let cleanupRequested = expectation(description: "control master cleanup requested") cleanupRequested.isInverted = true @@ -792,6 +1269,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.configureRemoteConnection(config, autoConnect: false) let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + let expectedSessionID = Workspace.defaultSSHPTYSessionID(workspaceId: workspace.id, panelId: panelID) workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64012) wait(for: [cleanupRequested], timeout: 0.2) @@ -799,6 +1277,12 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) XCTAssertEqual(workspace.remoteConfiguration?.preserveAfterTerminalExit, true) + XCTAssertEqual(workspace.remoteConfiguration?.persistentDaemonSlot, "ssh-persist-end") + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == panelID }?.terminal?.remotePTYSessionID, + expectedSessionID + ) workspace.teardownAllPanels() XCTAssertTrue(workspace.panels.isEmpty) @@ -1473,6 +1957,216 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @MainActor + func testPersistentReverseRelayCancelsStaleControlMasterForwardBeforeReusingRelayPort() throws { + let forwardInvoked = DispatchSemaphore(value: 0) + let lock = NSLock() + var controlOperations: [(command: String, spec: String)] = [] + + WorkspaceRemoteSessionController.runProcessOverrideForTesting = { executable, arguments, _, _ in + guard executable == "/usr/bin/ssh" else { + XCTFail("unexpected executable \(executable)") + return (status: 1, stdout: "", stderr: "unexpected executable") + } + + if let operationIndex = arguments.firstIndex(of: "-O"), + operationIndex + 3 < arguments.count, + arguments[operationIndex + 2] == "-R" { + let operation = arguments[operationIndex + 1] + let spec = arguments[operationIndex + 3] + lock.lock() + controlOperations.append((command: operation, spec: spec)) + lock.unlock() + if operation == "forward" { + forwardInvoked.signal() + } + return (status: 0, stdout: "", stderr: "") + } + + let command = arguments.last ?? "" + if command.contains("uname -s") { + return ( + status: 0, + stdout: """ + __CMUX_REMOTE_HOME__=/home/test + __CMUX_REMOTE_OS__=Linux + __CMUX_REMOTE_ARCH__=x86_64 + __CMUX_REMOTE_EXISTS__=yes + """, + stderr: "" + ) + } + if command.contains("serve --stdio") { + return ( + status: 0, + stdout: #"{"id":1,"ok":true,"result":{"name":"cmuxd-remote","version":"dev","capabilities":["proxy.stream.push","pty.session","pty.session.token","pty.session.persistent_daemon"]}}"# + "\n", + stderr: "" + ) + } + return (status: 0, stdout: "", stderr: "") + } + defer { WorkspaceRemoteSessionController.runProcessOverrideForTesting = nil } + + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "test@hpc.example", + port: 2222, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-\(getuid())-64044-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64044, + relayID: "relay-stale-forward", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-stale-forward-test.sock", + terminalStartupCommand: "ssh-pty-attach", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-stale-forward-test" + ) + defer { workspace.disconnectRemoteConnection(clearConfiguration: true) } + + workspace.configureRemoteConnection(config, autoConnect: true) + + XCTAssertEqual(forwardInvoked.wait(timeout: .now() + 2), .success) + lock.lock() + let operations = controlOperations + lock.unlock() + + XCTAssertGreaterThanOrEqual(operations.count, 2) + XCTAssertEqual(operations[0].command, "cancel") + XCTAssertEqual(operations[0].spec, "127.0.0.1:64044") + XCTAssertEqual(operations[1].command, "forward") + XCTAssertTrue( + operations[1].spec.hasPrefix("127.0.0.1:64044:127.0.0.1:"), + "expected forward to reuse relay port after stale cancel, got \(operations[1].spec)" + ) + } + + @MainActor + func testPersistentReverseRelayCleansStaleRemoteListenerAndRetriesControlMasterForward() throws { + let retryForwardInvoked = DispatchSemaphore(value: 0) + let lock = NSLock() + var controlOperations: [(command: String, spec: String)] = [] + var forwardAttempts = 0 + var cleanupInvoked = false + var cleanupArguments: [String] = [] + + WorkspaceRemoteSessionController.runProcessOverrideForTesting = { executable, arguments, _, _ in + guard executable == "/usr/bin/ssh" else { + XCTFail("unexpected executable \(executable)") + return (status: 1, stdout: "", stderr: "unexpected executable") + } + + if let operationIndex = arguments.firstIndex(of: "-O"), + operationIndex + 3 < arguments.count, + arguments[operationIndex + 2] == "-R" { + let operation = arguments[operationIndex + 1] + let spec = arguments[operationIndex + 3] + lock.lock() + controlOperations.append((command: operation, spec: spec)) + if operation == "forward" { + forwardAttempts += 1 + let attempt = forwardAttempts + lock.unlock() + if attempt == 1 { + return ( + status: 255, + stdout: "", + stderr: "remote port forwarding failed for listen port 64045" + ) + } + retryForwardInvoked.signal() + return (status: 0, stdout: "", stderr: "") + } + lock.unlock() + return (status: 0, stdout: "", stderr: "") + } + + let command = arguments.last ?? "" + if command.contains("cmux_stale_relay_listener_cleanup=1") { + lock.lock() + cleanupInvoked = true + cleanupArguments = arguments + lock.unlock() + return ( + status: 0, + stdout: "cmux_stale_relay_killed pid=33681 children=34057 port=64045\n", + stderr: "" + ) + } + if command.contains("uname -s") { + return ( + status: 0, + stdout: """ + __CMUX_REMOTE_HOME__=/home/test + __CMUX_REMOTE_OS__=Linux + __CMUX_REMOTE_ARCH__=x86_64 + __CMUX_REMOTE_EXISTS__=yes + """, + stderr: "" + ) + } + if command.contains("serve --stdio") { + return ( + status: 0, + stdout: #"{"id":1,"ok":true,"result":{"name":"cmuxd-remote","version":"dev","capabilities":["proxy.stream.push","pty.session","pty.session.token","pty.session.persistent_daemon"]}}"# + "\n", + stderr: "" + ) + } + return (status: 0, stdout: "", stderr: "") + } + defer { WorkspaceRemoteSessionController.runProcessOverrideForTesting = nil } + + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "test@hpc.example", + port: 2222, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-\(getuid())-64045-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64045, + relayID: "relay-stale-forward-retry", + relayToken: String(repeating: "d", count: 64), + localSocketPath: "/tmp/cmux-stale-forward-retry.sock", + terminalStartupCommand: "ssh-pty-attach", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-stale-forward-retry" + ) + defer { workspace.disconnectRemoteConnection(clearConfiguration: true) } + + workspace.configureRemoteConnection(config, autoConnect: true) + + XCTAssertEqual(retryForwardInvoked.wait(timeout: .now() + 2), .success) + lock.lock() + let operations = controlOperations + let cleanupWasInvoked = cleanupInvoked + let capturedCleanupArguments = cleanupArguments + let attempts = forwardAttempts + lock.unlock() + + XCTAssertEqual(attempts, 2) + XCTAssertTrue(cleanupWasInvoked) + XCTAssertTrue(capturedCleanupArguments.contains("-S")) + XCTAssertTrue(capturedCleanupArguments.contains("none")) + XCTAssertFalse(capturedCleanupArguments.contains(where: { $0.hasPrefix("ControlPath=") })) + XCTAssertGreaterThanOrEqual(operations.count, 3) + XCTAssertEqual(operations[0].command, "cancel") + XCTAssertEqual(operations[0].spec, "127.0.0.1:64045") + XCTAssertEqual(operations[1].command, "forward") + XCTAssertEqual(operations[2].command, "forward") + XCTAssertEqual(operations[1].spec, operations[2].spec) + XCTAssertTrue(operations[2].spec.hasPrefix("127.0.0.1:64045:127.0.0.1:")) + } + @MainActor func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws { let workspace = Workspace() @@ -1818,6 +2512,36 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @MainActor + func testPersistentRemoteTerminalSeedsDefaultPTYSessionIDForSnapshot() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64015, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh-pty-attach", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-seeded-default" + ) + workspace.configureRemoteConnection(config, autoConnect: false) + + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + let expectedSessionID = Workspace.defaultSSHPTYSessionID(workspaceId: workspace.id, panelId: panelID) + + XCTAssertTrue(workspace.remotePTYSessionIDMatches(panelId: panelID, sessionID: expectedSessionID)) + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == panelID }?.terminal?.remotePTYSessionID, + expectedSessionID + ) + } + @MainActor func testDetachAttachPreservesSurfaceTTYMetadata() throws { let source = Workspace() @@ -2303,6 +3027,43 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(arguments.contains("cmux-macmini")) } + func testReverseRelayControlMasterCancelArgumentsUseRemoteListenPortOnly() throws { + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64007, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: "ssh cmux-macmini" + ) + + let arguments = try XCTUnwrap( + WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterCancelArguments( + configuration: configuration, + relayPort: 64007 + ) + ) + + XCTAssertFalse(arguments.contains("-S")) + XCTAssertTrue(arguments.contains("ControlMaster=no")) + XCTAssertTrue(arguments.contains("ControlPath=/tmp/cmux-ssh-%C")) + XCTAssertTrue(arguments.contains("-O")) + XCTAssertTrue(arguments.contains("cancel")) + XCTAssertTrue(arguments.contains("-R")) + XCTAssertTrue(arguments.contains("127.0.0.1:64007")) + XCTAssertFalse(arguments.contains(where: { $0.hasPrefix("127.0.0.1:64007:127.0.0.1:") })) + XCTAssertTrue(arguments.contains("cmux-macmini")) + } + func testReverseRelayControlMasterArgumentsReuseWhitespaceConfiguredControlSocket() throws { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index b0272baa83f4..f0317ea05efa 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -5840,6 +5840,65 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNil(launch.remoteConfiguration?.localSocketPath) } + func testForkAgentWorkspaceLaunchFromPersistentSSHPTYDoesNotReuseParentRelayOrDaemonSlot() throws { + let workspace = Workspace() + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/example/.ssh/cmux", + sshOptions: ["ControlMaster=auto", "ControlPersist=600"], + localProxyPort: nil, + relayPort: 64017, + relayID: "relay-fork-persistent", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-fork-persistent.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-parent-slot" + ), + autoConnect: false + ) + let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "019dad34-d218-7943-b81a-eddac5c87951", + workingDirectory: "/Users/cmux/project", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/Users/example/.bun/bin/codex", + arguments: ["/Users/example/.bun/bin/codex"], + workingDirectory: "/Users/cmux/project", + environment: nil, + capturedAt: 123, + source: "process" + ) + ) + + let launch = try XCTUnwrap( + workspace.forkAgentWorkspaceLaunch( + fromPanelId: sourcePanelId, + snapshot: snapshot + ) + ) + + XCTAssertTrue(launch.autoConnectRemoteConfiguration) + XCTAssertEqual(launch.remoteConfiguration?.destination, "cmux-macmini") + XCTAssertEqual(launch.remoteConfiguration?.port, 2222) + XCTAssertEqual(launch.remoteConfiguration?.preserveAfterTerminalExit, false) + XCTAssertNil(launch.remoteConfiguration?.relayPort) + XCTAssertNil(launch.remoteConfiguration?.relayID) + XCTAssertNil(launch.remoteConfiguration?.relayToken) + XCTAssertNil(launch.remoteConfiguration?.localSocketPath) + XCTAssertNil(launch.remoteConfiguration?.persistentDaemonSlot) + let startupCommand = try XCTUnwrap(launch.remoteConfiguration?.terminalStartupCommand) + XCTAssertFalse(startupCommand.contains("ssh-pty-attach"), startupCommand) + XCTAssertEqual( + startupCommand, + "ssh -p 2222 -i /Users/example/.ssh/cmux -tt cmux-macmini" + ) + } + func testForkAgentWorkspaceLaunchInRemoteWorkspaceUsesFallbackDirectoryInForkCommand() throws { let workspace = Workspace() workspace.configureRemoteConnection( diff --git a/daemon/remote/README.md b/daemon/remote/README.md index 537d75bd7590..be65ae6bc4d1 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -6,12 +6,17 @@ Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote pr 1. `cmuxd-remote version` 2. `cmuxd-remote serve --stdio` -3. `cmuxd-remote serve --ws --auth-lease-file [--rpc-auth-lease-file ] [--listen 127.0.0.1:7777]` -4. `cmuxd-remote cli [args...]` — relay cmux commands to the local app over the reverse SSH forward +3. `cmuxd-remote serve --stdio --persistent --slot ` +4. `cmuxd-remote serve --ws --auth-lease-file [--rpc-auth-lease-file ] [--listen 127.0.0.1:7777]` +5. `cmuxd-remote cli [args...]` — relay cmux commands to the local app over the reverse SSH forward `serve --ws` is explicit opt-in for cloud VM images only. The normal `cmux ssh` -code path continues to use `serve --stdio` over an SSH exec channel and does not -open a WebSocket listener. +code path uses `serve --stdio --persistent --slot ` over an SSH exec +channel. That stdio process is only a proxy to an authenticated per-slot daemon +with credentials and logs under `~/.cmux/daemon///`, so remote PTY sessions +can survive local surface close, local reconnect, and app relaunch. The persistent +server never opens a public listener; it accepts only a per-user Unix socket under +`/tmp/cmuxd-remote-/` and the slot token. When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. @@ -30,12 +35,38 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin 11. `session.resize` 12. `session.detach` 13. `session.status` +14. `pty.attach` +15. `pty.write` +16. `pty.resize` +17. `pty.detach` +18. `pty.close` +19. `pty.list` Current integration in cmux: 1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. 2. Client sends `hello` before enabling remote proxy transport. 3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. 4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). +5. Persistent SSH terminals require the `pty.session.persistent_daemon` capability before cmux will restore a saved remote PTY session ID after relaunch. + +## Persistent SSH PTY daemon + +`cmux ssh` uses one persistent daemon slot per CLI-launched SSH workspace. The +slot name is generated locally, validated as `[A-Za-z0-9._-]{1,128}`, and sent +to the remote daemon bootstrap as `--slot`. + +Remote slot files: +1. `/tmp/cmuxd-remote-/cmuxd-.sock` authenticated Unix socket for stdio proxies. +2. `~/.cmux/daemon///auth.token` random 32-byte hex token, mode `0600`. +3. `~/.cmux/daemon///daemon.lock` single-owner lock. +4. `~/.cmux/daemon///daemon.log` startup and crash diagnostics. + +PTY lifecycle: +1. A local attach creates or reuses a named `pty.*` session in the persistent daemon. +2. If the local surface closes, the stdio proxy disconnects and its attachment detaches, but the PTY process and bounded scrollback remain in the daemon. +3. `cmux ssh-session-list` calls `pty.list`; `cmux ssh-session-attach` creates a new local terminal whose startup script calls `ssh-pty-attach --require-existing`. +4. `cmux ssh-session-cleanup` calls `pty.close` to terminate a persisted PTY session explicitly. +5. Sessions with no attachments keep their last-known size and are reaped by the daemon idle TTL. ## Cloud WebSocket PTY transport diff --git a/daemon/remote/TMUX_CORPUS.md b/daemon/remote/TMUX_CORPUS.md index 1e1bfa1ca78b..ee025cbc72a8 100644 --- a/daemon/remote/TMUX_CORPUS.md +++ b/daemon/remote/TMUX_CORPUS.md @@ -2,7 +2,7 @@ Pinned upstream tmux commit: `a9ba7b8ecbe1d107aa716f52d53c99ea1a00cf11`. -This map records how each selected upstream tmux regression or fuzz target is represented in cmux. It is documentation only; CI confidence comes from executable Go tests, Go fuzz targets, and the macOS terminal-renderer tests in `.github/workflows/tmux-corpus.yml`. +This map records how each selected upstream tmux regression or fuzz target is represented in cmux. It is documentation only; CI confidence comes from executable Go tests, Go fuzz targets, and the macOS terminal-renderer tests in `.github/workflows/tmux-corpus.yml`. The daemon and tmux-compat rows marked `pr` run on pull requests touching `daemon/remote/**`; the terminal-renderer rows remain nightly because they need the macOS app and GhosttyKit. | Upstream source | cmux layer | Status | CI lane | Port note | | --- | --- | --- | --- | --- | diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 15a282e66e68..b335defe7a00 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -4,7 +4,11 @@ import ( "bufio" "bytes" "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "flag" @@ -13,11 +17,14 @@ import ( "math" "net" "os" + "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" + "sync/atomic" + "syscall" "time" ) @@ -137,6 +144,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.SetOutput(stderr) stdio := fs.Bool("stdio", false, "serve over stdin/stdout") ws := fs.Bool("ws", false, "serve terminal PTY transport over WebSocket") + persistent := fs.Bool("persistent", false, "proxy stdio to a persistent per-slot daemon") + persistentServer := fs.Bool("persistent-server", false, "run the persistent per-slot daemon") + persistentSlot := fs.String("slot", "", "persistent daemon slot") listen := fs.String("listen", "127.0.0.1:7777", "address for --ws") authLeaseFile := fs.String("auth-lease-file", "", "required lease JSON path for --ws") rpcAuthLeaseFile := fs.String("rpc-auth-lease-file", "", "optional daemon RPC lease JSON path for --ws /rpc") @@ -144,10 +154,33 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if err := fs.Parse(args[1:]); err != nil { return 2 } + if *persistentServer { + if *stdio || *ws || *persistent { + _, _ = fmt.Fprintln(stderr, "serve --persistent-server cannot be combined with --stdio, --ws, or --persistent") + return 2 + } + if strings.TrimSpace(*persistentSlot) == "" { + _, _ = fmt.Fprintln(stderr, "serve --persistent-server requires --slot") + return 2 + } + if err := runPersistentDaemonServer(strings.TrimSpace(*persistentSlot), stderr); err != nil { + _, _ = fmt.Fprintf(stderr, "serve --persistent-server failed: %v\n", err) + return 1 + } + return 0 + } if *stdio == *ws { _, _ = fmt.Fprintln(stderr, "serve requires exactly one of --stdio or --ws") return 2 } + if (*persistent || strings.TrimSpace(*persistentSlot) != "") && !*stdio { + _, _ = fmt.Fprintln(stderr, "serve --persistent requires --stdio") + return 2 + } + if strings.TrimSpace(*persistentSlot) != "" && !*persistent { + _, _ = fmt.Fprintln(stderr, "serve --slot requires --persistent") + return 2 + } if *ws { if strings.TrimSpace(*authLeaseFile) == "" { _, _ = fmt.Fprintln(stderr, "serve --ws requires --auth-lease-file") @@ -164,6 +197,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } return 0 } + if *persistent { + if strings.TrimSpace(*persistentSlot) == "" { + _, _ = fmt.Fprintln(stderr, "serve --persistent requires --slot") + return 2 + } + if err := runPersistentStdioProxy(stdin, stdout, stderr, strings.TrimSpace(*persistentSlot)); err != nil { + _, _ = fmt.Fprintf(stderr, "serve --stdio --persistent failed: %v\n", err) + return 1 + } + return 0 + } if err := runStdioServer(stdin, stdout); err != nil { _, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err) return 1 @@ -181,11 +225,16 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, "Usage:") _, _ = fmt.Fprintln(w, " cmuxd-remote version") _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio") + _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio --persistent --slot ") _, _ = fmt.Fprintln(w, " cmuxd-remote serve --ws --auth-lease-file [--rpc-auth-lease-file ] [--listen 127.0.0.1:7777]") _, _ = fmt.Fprintln(w, " cmuxd-remote cli [args...]") } func runStdioServer(stdin io.Reader, stdout io.Writer) error { + return runRPCServer(stdin, stdout, newWebSocketPTYHub(wsPTYServerConfig{}, io.Discard), true) +} + +func runRPCServer(stdin io.Reader, stdout io.Writer, ptyHub *wsPTYHub, ownsPTYHub bool) error { writer := &stdioFrameWriter{ writer: bufio.NewWriter(stdout), } @@ -194,8 +243,8 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { nextSessionID: 1, streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, - ptyHub: newWebSocketPTYHub(wsPTYServerConfig{}, io.Discard), - ownsPTYHub: true, + ptyHub: ptyHub, + ownsPTYHub: ownsPTYHub, frameWriter: writer, } defer server.closeAll() @@ -250,6 +299,890 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { } } +type persistentDaemonPaths struct { + slot string + root string + socket string + tokenFile string + logFile string + lockFile string +} + +const ( + persistentDaemonAuthMethod = "daemon.auth" + persistentDaemonReadyFDEnv = "CMUX_REMOTE_DAEMON_READY_FD" + persistentDaemonAuthTimeout = 5 * time.Second + persistentDaemonSocketDirFile = "socket-dir" +) + +var errPersistentDaemonAuthFailed = errors.New("persistent daemon authentication failed") + +const ( + persistentDaemonStartupTimeout = 5 * time.Second + persistentDaemonEmptyIdleTimeout = 5 * time.Minute + persistentDaemonEmptyIdlePollStep = time.Second +) + +type persistentDaemonServerConfig struct { + emptyIdleTimeout time.Duration + acceptPollStep time.Duration +} + +func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) { + slot, err := validatePersistentDaemonSlot(rawSlot) + if err != nil { + return persistentDaemonPaths{}, err + } + rootBase := strings.TrimSpace(os.Getenv("CMUX_REMOTE_DAEMON_ROOT")) + if rootBase == "" { + home, homeErr := os.UserHomeDir() + if homeErr != nil || strings.TrimSpace(home) == "" { + return persistentDaemonPaths{}, errors.New("cannot resolve remote home directory") + } + rootBase = filepath.Join(home, ".cmux", "daemon") + } + root := filepath.Join(rootBase, persistentDaemonVersionComponent(), slot) + socketPath := persistentDaemonSocketPath(root, slot) + return persistentDaemonPaths{ + slot: slot, + root: root, + socket: socketPath, + tokenFile: filepath.Join(root, "auth.token"), + logFile: filepath.Join(root, "daemon.log"), + lockFile: filepath.Join(root, "daemon.lock"), + }, nil +} + +func persistentDaemonVersionComponent() string { + trimmed := strings.TrimSpace(version) + if trimmed == "" { + trimmed = "dev" + } + var builder strings.Builder + for _, r := range trimmed { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || + r == '_' || + r == '.' { + builder.WriteRune(r) + } else { + builder.WriteByte('_') + } + } + component := builder.String() + if component == "" || component == "." || component == ".." { + return "dev" + } + if len(component) <= 64 { + return component + } + digest := sha256.Sum256([]byte(trimmed)) + return component[:48] + "-" + hex.EncodeToString(digest[:4]) +} + +func persistentDaemonSocketPath(root string, slot string) string { + socketBase, overrideSet := persistentDaemonSocketBase() + if !overrideSet { + socketBase = filepath.Join("/tmp", fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + } + digest := sha256.Sum256([]byte(root + "\x00" + slot)) + return filepath.Join(socketBase, "cmuxd-"+hex.EncodeToString(digest[:8])+".sock") +} + +func persistentDaemonSocketBase() (string, bool) { + socketBase := strings.TrimSpace(os.Getenv("CMUX_REMOTE_DAEMON_SOCKET_DIR")) + if socketBase == "" { + return "", false + } + return filepath.Join(socketBase, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())), true +} + +func validatePersistentDaemonSlot(rawSlot string) (string, error) { + slot := strings.TrimSpace(rawSlot) + if slot == "" { + return "", errors.New("persistent daemon slot is required") + } + if slot == "." || slot == ".." || len(slot) > 128 { + return "", fmt.Errorf("invalid persistent daemon slot %q", rawSlot) + } + for _, r := range slot { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || + r == '_' || + r == '.' { + continue + } + return "", fmt.Errorf("invalid persistent daemon slot %q", rawSlot) + } + return slot, nil +} + +func ensurePersistentDaemonDirectory(paths persistentDaemonPaths) (persistentDaemonPaths, error) { + if err := os.MkdirAll(paths.root, 0o700); err != nil { + return paths, err + } + if err := verifyPrivateDaemonDirectory(paths.root); err != nil { + return paths, err + } + socketDir := filepath.Dir(paths.socket) + secureSocketDir, err := ensurePersistentDaemonSocketDirectory(paths.root, socketDir) + if err != nil { + return paths, err + } + paths.socket = filepath.Join(secureSocketDir, filepath.Base(paths.socket)) + return paths, nil +} + +func ensurePersistentDaemonSocketDirectory(root string, defaultSocketDir string) (string, error) { + if storedSocketDir, err := readPersistentDaemonSocketDir(root); err == nil { + if verifyErr := ensurePrivateDaemonLeafDirectory(storedSocketDir); verifyErr == nil { + return storedSocketDir, nil + } + if removeErr := removePersistentDaemonSocketDirMetadata(root); removeErr != nil { + return "", removeErr + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + if err := ensurePrivateDaemonLeafDirectory(defaultSocketDir); err == nil { + return defaultSocketDir, nil + } + return createPersistentDaemonFallbackSocketDir(root) +} + +func ensurePrivateDaemonLeafDirectory(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.Mkdir(path, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return err + } + return verifyPrivateDaemonDirectory(path) +} + +func verifyPrivateDaemonDirectory(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("persistent daemon directory %q is a symlink", path) + } + if !info.IsDir() { + return fmt.Errorf("persistent daemon directory %q is not a directory", path) + } + if !daemonDirectoryOwnedByCurrentUser(info) { + return fmt.Errorf("persistent daemon directory %q is not owned by uid %d", path, os.Getuid()) + } + if info.Mode().Perm() != 0o700 { + if err := os.Chmod(path, 0o700); err != nil { + return err + } + info, err = os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || + !info.IsDir() || + !daemonDirectoryOwnedByCurrentUser(info) || + info.Mode().Perm() != 0o700 { + return fmt.Errorf("persistent daemon directory %q is not private", path) + } + } + return nil +} + +func daemonDirectoryOwnedByCurrentUser(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return !ok || int(stat.Uid) == os.Getuid() +} + +func readPersistentDaemonSocketDir(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, persistentDaemonSocketDirFile)) + if err != nil { + return "", err + } + socketDir := strings.TrimSpace(string(data)) + if socketDir == "" { + return "", errors.New("persistent daemon socket directory file is empty") + } + return socketDir, nil +} + +func createPersistentDaemonFallbackSocketDir(root string) (string, error) { + for attempt := 0; attempt < 8; attempt++ { + raw := make([]byte, 8) + if _, err := rand.Read(raw); err != nil { + return "", err + } + socketDir := filepath.Join( + os.TempDir(), + fmt.Sprintf("cmuxd-remote-%d-%s", os.Getuid(), hex.EncodeToString(raw)), + ) + if err := os.Mkdir(socketDir, 0o700); err != nil { + if errors.Is(err, os.ErrExist) { + continue + } + return "", err + } + if err := writePersistentDaemonSocketDir(root, socketDir); err != nil { + _ = os.Remove(socketDir) + if errors.Is(err, os.ErrExist) { + if storedSocketDir, readErr := readPersistentDaemonSocketDir(root); readErr == nil { + if verifyErr := ensurePrivateDaemonLeafDirectory(storedSocketDir); verifyErr == nil { + return storedSocketDir, nil + } + } + if removeErr := removePersistentDaemonSocketDirMetadata(root); removeErr != nil { + return "", removeErr + } + continue + } + return "", err + } + return socketDir, nil + } + return "", errors.New("failed to create private persistent daemon socket directory") +} + +func writePersistentDaemonSocketDir(root string, socketDir string) error { + file, err := os.CreateTemp(root, ".socket-dir.*.tmp") + if err != nil { + return err + } + tmpPath := file.Name() + closeOK := false + defer func() { + if !closeOK { + _ = file.Close() + } + _ = os.Remove(tmpPath) + }() + if err := file.Chmod(0o600); err != nil { + return err + } + if _, err := file.WriteString(socketDir + "\n"); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + closeOK = true + return os.Link(tmpPath, filepath.Join(root, persistentDaemonSocketDirFile)) +} + +func removePersistentDaemonSocketDirMetadata(root string) error { + if err := os.Remove(filepath.Join(root, persistentDaemonSocketDirFile)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { + if token, err := readPersistentDaemonTokenFile(paths.tokenFile); err == nil { + return token, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + + file, err := os.CreateTemp(filepath.Dir(paths.tokenFile), ".auth.token.*.tmp") + if err != nil { + return "", err + } + tmpPath := file.Name() + closeOK := false + defer func() { + if !closeOK { + _ = file.Close() + } + _ = os.Remove(tmpPath) + }() + if _, err := file.WriteString(token + "\n"); err != nil { + return "", err + } + if err := file.Close(); err != nil { + return "", err + } + closeOK = true + if err := os.Link(tmpPath, paths.tokenFile); err != nil { + if errors.Is(err, os.ErrExist) { + return readPersistentDaemonTokenFile(paths.tokenFile) + } + return "", err + } + return token, nil +} + +func readPersistentDaemonTokenFile(tokenFile string) (string, error) { + data, err := os.ReadFile(tokenFile) + if err != nil { + return "", err + } + token := strings.TrimSpace(string(data)) + if token == "" { + return "", errors.New("persistent daemon token file is empty") + } + return token, nil +} + +func runPersistentStdioProxy(stdin io.Reader, stdout, stderr io.Writer, slot string) error { + paths, err := persistentDaemonPathsForSlot(slot) + if err != nil { + return err + } + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + return err + } + token, err := persistentDaemonToken(paths) + if err != nil { + return err + } + if err := ensurePersistentDaemonRunning(paths, token, stderr); err != nil { + return err + } + conn, err := dialPersistentDaemon(paths.socket, token) + if err != nil { + return err + } + return proxyPersistentDaemonConn(stdin, stdout, conn) +} + +type persistentProxyCopyResult struct { + stream string + err error +} + +func proxyPersistentDaemonConn(stdin io.Reader, stdout io.Writer, conn net.Conn) error { + defer conn.Close() + errCh := make(chan persistentProxyCopyResult, 2) + go func() { + _, copyErr := io.Copy(conn, stdin) + if unixConn, ok := conn.(*net.UnixConn); ok { + _ = unixConn.CloseWrite() + } + errCh <- persistentProxyCopyResult{stream: "stdin", err: copyErr} + }() + go func() { + _, copyErr := io.Copy(stdout, conn) + errCh <- persistentProxyCopyResult{stream: "stdout", err: copyErr} + }() + + first := <-errCh + if first.stream == "stdout" { + return persistentProxyCopyError(first.err) + } + second := <-errCh + if firstErr := persistentProxyCopyError(first.err); firstErr != nil { + return firstErr + } + return persistentProxyCopyError(second.err) +} + +func persistentProxyCopyError(err error) error { + if err == nil || + errors.Is(err, net.ErrClosed) || + errors.Is(err, os.ErrClosed) || + errors.Is(err, io.ErrClosedPipe) || + errors.Is(err, syscall.EPIPE) { + return nil + } + return err +} + +func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, stderr io.Writer) error { + if conn, err := dialPersistentDaemon(paths.socket, token); err == nil { + _ = conn.Close() + return nil + } else if shouldRemovePersistentSocketAfterDialError(err) { + _ = os.Remove(paths.socket) + } else { + return err + } + + executable, err := os.Executable() + if err != nil { + return err + } + logFile, err := os.OpenFile(paths.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer logFile.Close() + + readyReader, readyWriter, err := os.Pipe() + if err != nil { + return err + } + defer readyReader.Close() + defer readyWriter.Close() + + cmd := exec.Command(executable, "serve", "--persistent-server", "--slot", paths.slot) + cmd.Stdin = nil + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Env = append(os.Environ(), persistentDaemonReadyFDEnv+"=3") + cmd.ExtraFiles = []*os.File{readyWriter} + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + _ = readyWriter.Close() + _ = cmd.Process.Release() + + if err := waitPersistentDaemonReady(readyReader, paths.logFile); err != nil { + if conn, dialErr := dialPersistentDaemon(paths.socket, token); dialErr == nil { + _ = conn.Close() + return nil + } + if stderr != nil { + _, _ = fmt.Fprintf(stderr, "persistent daemon log: %s\n", paths.logFile) + } + return err + } + + conn, err := dialPersistentDaemon(paths.socket, token) + if err == nil { + _ = conn.Close() + return nil + } + if stderr != nil { + _, _ = fmt.Fprintf(stderr, "persistent daemon log: %s\n", paths.logFile) + } + return err +} + +func shouldRemovePersistentSocketAfterDialError(err error) bool { + return errors.Is(err, os.ErrNotExist) || + errors.Is(err, syscall.ENOENT) || + errors.Is(err, syscall.ECONNREFUSED) +} + +func waitPersistentDaemonReady(reader *os.File, logFile string) error { + done := make(chan error, 1) + go func() { + line, err := bufio.NewReader(reader).ReadString('\n') + if err != nil { + done <- fmt.Errorf("persistent daemon exited before readiness signal; log: %s: %w", logFile, err) + return + } + if strings.TrimSpace(line) != "ready" { + done <- fmt.Errorf("persistent daemon sent unexpected readiness signal %q; log: %s", strings.TrimSpace(line), logFile) + return + } + done <- nil + }() + + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + select { + case err := <-done: + return err + case <-timer.C: + return fmt.Errorf("persistent daemon did not become ready; log: %s", logFile) + } +} + +func runPersistentDaemonServer(slot string, stderr io.Writer) error { + paths, err := persistentDaemonPathsForSlot(slot) + if err != nil { + return err + } + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + return err + } + token, err := persistentDaemonToken(paths) + if err != nil { + return err + } + lockFile, err := os.OpenFile(paths.lockFile, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return fmt.Errorf("persistent daemon slot %q is already running", paths.slot) + } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + + _ = os.Remove(paths.socket) + listener, err := net.Listen("unix", paths.socket) + if err != nil { + return err + } + defer listener.Close() + defer os.Remove(paths.socket) + _ = os.Chmod(paths.socket, 0o600) + + signalPersistentDaemonReady() + return servePersistentDaemonWithVerifierConfig( + listener, + persistentDaemonFileTokenVerifier(token, paths.tokenFile), + stderr, + persistentDaemonServerConfig{emptyIdleTimeout: persistentDaemonEmptyIdleTimeout}, + ) +} + +func signalPersistentDaemonReady() { + rawFD := strings.TrimSpace(os.Getenv(persistentDaemonReadyFDEnv)) + if rawFD == "" { + return + } + fd, err := strconv.Atoi(rawFD) + if err != nil || fd < 3 { + return + } + file := os.NewFile(uintptr(fd), "cmux-persistent-daemon-ready") + if file == nil { + return + } + _, _ = file.WriteString("ready\n") + _ = file.Close() +} + +func servePersistentDaemon(listener net.Listener, token string, stderr io.Writer) error { + return servePersistentDaemonWithVerifier(listener, persistentDaemonFixedTokenVerifier(token), stderr) +} + +type persistentDaemonTokenVerifier func(string) bool + +func persistentDaemonFixedTokenVerifier(token string) persistentDaemonTokenVerifier { + return func(provided string) bool { + return persistentDaemonTokensEqual(provided, token) + } +} + +func persistentDaemonFileTokenVerifier(initialToken string, tokenFile string) persistentDaemonTokenVerifier { + return func(provided string) bool { + token := initialToken + if currentToken, err := readPersistentDaemonTokenFile(tokenFile); err == nil { + token = currentToken + } + return persistentDaemonTokensEqual(provided, token) + } +} + +func persistentDaemonTokensEqual(provided string, token string) bool { + provided = strings.TrimSpace(provided) + token = strings.TrimSpace(token) + return provided != "" && + token != "" && + subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 +} + +func servePersistentDaemonWithVerifier(listener net.Listener, verifier persistentDaemonTokenVerifier, stderr io.Writer) error { + return servePersistentDaemonWithVerifierConfig(listener, verifier, stderr, persistentDaemonServerConfig{}) +} + +func servePersistentDaemonWithVerifierConfig( + listener net.Listener, + verifier persistentDaemonTokenVerifier, + stderr io.Writer, + config persistentDaemonServerConfig, +) error { + hub := newWebSocketPTYHub(wsPTYServerConfig{}, stderr) + defer hub.closeAll() + var activeConnections int64 + var idleSince time.Time + for { + if config.emptyIdleTimeout > 0 { + now := time.Now() + isEmpty := atomic.LoadInt64(&activeConnections) == 0 && hub.activeSessionCount() == 0 + if isEmpty { + if idleSince.IsZero() { + idleSince = now + } + remaining := config.emptyIdleTimeout - now.Sub(idleSince) + if remaining <= 0 { + return nil + } + setPersistentDaemonAcceptDeadline(listener, now.Add(minDuration( + remaining, + persistentDaemonAcceptPollStep(config), + ))) + } else { + idleSince = time.Time{} + setPersistentDaemonAcceptDeadline(listener, now.Add(persistentDaemonAcceptPollStep(config))) + } + } + conn, err := listener.Accept() + if err != nil { + if isTimeoutError(err) { + continue + } + if isClosedListenerError(err) { + return nil + } + return err + } + atomic.AddInt64(&activeConnections, 1) + go func() { + defer atomic.AddInt64(&activeConnections, -1) + handlePersistentDaemonConn(conn, verifier, hub) + }() + } +} + +func persistentDaemonAcceptPollStep(config persistentDaemonServerConfig) time.Duration { + if config.acceptPollStep > 0 { + return config.acceptPollStep + } + return persistentDaemonEmptyIdlePollStep +} + +type deadlineListener interface { + SetDeadline(time.Time) error +} + +func setPersistentDaemonAcceptDeadline(listener net.Listener, deadline time.Time) { + if deadlineListener, ok := listener.(deadlineListener); ok { + _ = deadlineListener.SetDeadline(deadline) + } +} + +func minDuration(a time.Duration, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func isTimeoutError(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +func isClosedListenerError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, net.ErrClosed) { + return true + } + return strings.Contains(err.Error(), "use of closed network connection") +} + +func handlePersistentDaemonConn(conn net.Conn, verifier persistentDaemonTokenVerifier, hub *wsPTYHub) { + handlePersistentDaemonConnWithAuthTimeout(conn, verifier, hub, persistentDaemonAuthTimeout) +} + +func handlePersistentDaemonConnWithAuthTimeout(conn net.Conn, verifier persistentDaemonTokenVerifier, hub *wsPTYHub, timeout time.Duration) { + defer conn.Close() + if timeout > 0 { + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return + } + } + reader := bufio.NewReaderSize(conn, 64*1024) + writer := &stdioFrameWriter{writer: bufio.NewWriter(conn)} + if !authenticatePersistentDaemonConn(reader, writer, verifier) { + return + } + if timeout > 0 { + if err := conn.SetDeadline(time.Time{}); err != nil { + return + } + } + _ = runRPCServerWithReader(reader, writer, hub, false) +} + +func authenticatePersistentDaemonConn(reader *bufio.Reader, writer *stdioFrameWriter, verifier persistentDaemonTokenVerifier) bool { + line, oversized, err := readRPCFrame(reader, maxRPCFrameBytes) + if err != nil || oversized { + _ = writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "unauthorized", + Message: "persistent daemon authentication required", + }, + }) + return false + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + _ = writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }) + return false + } + if req.Method != persistentDaemonAuthMethod { + _ = writer.writeResponse(rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "unauthorized", + Message: "persistent daemon authentication required", + }, + }) + return false + } + provided, _ := getStringParam(req.Params, "token") + if !verifier(provided) { + _ = writer.writeResponse(rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "unauthorized", + Message: "invalid persistent daemon token", + }, + }) + return false + } + _ = writer.writeResponse(rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "authenticated": true, + }, + }) + return true +} + +func runRPCServerWithReader(reader *bufio.Reader, writer *stdioFrameWriter, ptyHub *wsPTYHub, ownsPTYHub bool) error { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{}, + sessions: map[string]*sessionState{}, + ptyHub: ptyHub, + ownsPTYHub: ownsPTYHub, + frameWriter: writer, + } + defer server.closeAll() + defer writer.writer.Flush() + + for { + line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + if oversized { + if err := writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "request frame exceeds maximum size", + }, + }); err != nil { + return err + } + continue + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + if len(line) == 0 { + continue + } + + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + if err := writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }); err != nil { + return err + } + continue + } + + resp := server.handleRequest(req) + if err := writer.writeResponse(resp); err != nil { + return err + } + } +} + +func dialPersistentDaemon(socketPath string, token string) (net.Conn, error) { + conn, err := net.DialTimeout("unix", socketPath, 2*time.Second) + if err != nil { + return nil, err + } + if err := authenticatePersistentDaemonClient(conn, token); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +func authenticatePersistentDaemonClient(conn net.Conn, token string) error { + return authenticatePersistentDaemonClientWithTimeout(conn, token, persistentDaemonAuthTimeout) +} + +func authenticatePersistentDaemonClientWithTimeout(conn net.Conn, token string, timeout time.Duration) error { + if timeout > 0 { + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return err + } + defer conn.SetDeadline(time.Time{}) + } + + writer := bufio.NewWriter(conn) + request := rpcRequest{ + ID: "auth", + Method: persistentDaemonAuthMethod, + Params: map[string]any{ + "token": token, + }, + } + data, err := json.Marshal(request) + if err != nil { + return err + } + if _, err := writer.Write(data); err != nil { + return err + } + if err := writer.WriteByte('\n'); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err + } + reader := bufio.NewReaderSize(conn, 64*1024) + line, oversized, err := readRPCFrame(reader, maxRPCFrameBytes) + if err != nil { + return err + } + if oversized { + return errors.New("persistent daemon auth response exceeded maximum size") + } + var resp rpcResponse + if err := json.Unmarshal(bytes.TrimSpace(line), &resp); err != nil { + return err + } + if !resp.OK { + message := "persistent daemon authentication failed" + if resp.Error != nil && strings.TrimSpace(resp.Error.Message) != "" { + message = strings.TrimSpace(resp.Error.Message) + } + return fmt.Errorf("%w: %s", errPersistentDaemonAuthFailed, message) + } + return nil +} + func setTCPNoDelay(conn net.Conn) { tcpConn, ok := conn.(*net.TCPConn) if !ok { @@ -356,6 +1289,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { "proxy.stream.push", "pty.session", "pty.session.token", + "pty.session.persistent_daemon", }, }, } diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 3530baf3f172..0f4abe94aac6 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -6,6 +6,8 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" + "fmt" "io" "math" "net" @@ -15,6 +17,7 @@ import ( "strconv" "strings" "sync" + "syscall" "testing" "time" ) @@ -25,6 +28,13 @@ type notifyingBuffer struct { notify chan struct{} } +type persistentTestFrameQueue struct { + mu sync.Mutex + frames []map[string]any +} + +var persistentTestPendingFrames sync.Map + func newNotifyingBuffer() *notifyingBuffer { return ¬ifyingBuffer{notify: make(chan struct{}, 1)} } @@ -48,6 +58,158 @@ func (b *notifyingBuffer) String() string { return b.buffer.String() } +func startPersistentDaemonForTest(t *testing.T, token string) (string, func()) { + return startPersistentDaemonWithVerifierForTest(t, persistentDaemonFixedTokenVerifier(token)) +} + +func startPersistentDaemonWithVerifierForTest(t *testing.T, verifier persistentDaemonTokenVerifier) (string, func()) { + t.Helper() + socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-test-*") + if err != nil { + t.Fatalf("create short socket dir: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(socketDir) + }) + socketPath := filepath.Join(socketDir, "rpc.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + done := make(chan error, 1) + go func() { + done <- servePersistentDaemonWithVerifier(listener, verifier, io.Discard) + }() + stop := func() { + _ = listener.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("persistent daemon exited with error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("persistent daemon did not stop") + } + } + return socketPath, stop +} + +func openPersistentTestClient(t *testing.T, socketPath string, token string) (net.Conn, *bufio.Reader, *bufio.Writer) { + t.Helper() + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("dial persistent daemon: %v", err) + } + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + writePersistentTestFrame(t, writer, rpcRequest{ + ID: "auth", + Method: persistentDaemonAuthMethod, + Params: map[string]any{"token": token}, + }) + frame := readPersistentTestFrame(t, conn, reader) + if ok, _ := frame["ok"].(bool); !ok { + _ = conn.Close() + t.Fatalf("persistent daemon auth failed: %v", frame) + } + return conn, reader, writer +} + +func persistentTestRPCCall(t *testing.T, conn net.Conn, reader *bufio.Reader, writer *bufio.Writer, req rpcRequest) map[string]any { + t.Helper() + writePersistentTestFrame(t, writer, req) + for { + frame := readPersistentTestFrame(t, conn, reader) + if _, isEvent := frame["event"]; isEvent { + enqueuePersistentTestFrame(conn, frame) + continue + } + return frame + } +} + +func readPersistentTestEvent(t *testing.T, conn net.Conn, reader *bufio.Reader, matches func(map[string]any) bool) map[string]any { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + var last map[string]any + for time.Now().Before(deadline) { + if frame, ok := dequeuePersistentTestFrame(conn); ok { + last = frame + if _, isEvent := frame["event"]; isEvent && matches(frame) { + return frame + } + continue + } + frame := readPersistentTestFrame(t, conn, reader) + last = frame + if _, isEvent := frame["event"]; isEvent && matches(frame) { + return frame + } + } + t.Fatalf("timed out waiting for persistent daemon event; last=%v", last) + return nil +} + +func enqueuePersistentTestFrame(conn net.Conn, frame map[string]any) { + queue := persistentTestQueue(conn) + queue.mu.Lock() + queue.frames = append(queue.frames, frame) + queue.mu.Unlock() +} + +func dequeuePersistentTestFrame(conn net.Conn) (map[string]any, bool) { + queue := persistentTestQueue(conn) + queue.mu.Lock() + defer queue.mu.Unlock() + if len(queue.frames) == 0 { + return nil, false + } + frame := queue.frames[0] + queue.frames = queue.frames[1:] + return frame, true +} + +func persistentTestQueue(conn net.Conn) *persistentTestFrameQueue { + if queue, ok := persistentTestPendingFrames.Load(conn); ok { + return queue.(*persistentTestFrameQueue) + } + queue := &persistentTestFrameQueue{} + actual, _ := persistentTestPendingFrames.LoadOrStore(conn, queue) + return actual.(*persistentTestFrameQueue) +} + +func writePersistentTestFrame(t *testing.T, writer *bufio.Writer, payload any) { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal test frame: %v", err) + } + if _, err := writer.Write(data); err != nil { + t.Fatalf("write test frame: %v", err) + } + if err := writer.WriteByte('\n'); err != nil { + t.Fatalf("write test newline: %v", err) + } + if err := writer.Flush(); err != nil { + t.Fatalf("flush test frame: %v", err) + } +} + +func readPersistentTestFrame(t *testing.T, conn net.Conn, reader *bufio.Reader) map[string]any { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + line, err := reader.ReadBytes('\n') + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + t.Fatalf("read persistent daemon frame: %v", err) + } + var frame map[string]any + if err := json.Unmarshal(bytes.TrimSpace(line), &frame); err != nil { + t.Fatalf("decode persistent daemon frame %q: %v", string(line), err) + } + return frame +} + type eofWithPayloadConn struct { payload []byte readOnce bool @@ -169,6 +331,16 @@ func TestRunStdioHelloAndPing(t *testing.T) { if !sawPushCapability { t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult) } + var sawPersistentPTYCapability bool + for _, capability := range capabilities { + if capability == "pty.session.persistent_daemon" { + sawPersistentPTYCapability = true + break + } + } + if !sawPersistentPTYCapability { + t.Fatalf("hello should advertise pty.session.persistent_daemon: %v", firstResult) + } var second map[string]any if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { @@ -179,6 +351,665 @@ func TestRunStdioHelloAndPing(t *testing.T) { } } +func TestPersistentDaemonRejectsInvalidSlot(t *testing.T) { + for _, slot := range []string{"", ".", "..", "../nope", "bad/slot", strings.Repeat("a", 129)} { + if _, err := persistentDaemonPathsForSlot(slot); err == nil { + t.Fatalf("persistentDaemonPathsForSlot(%q) succeeded, want error", slot) + } + } +} + +func TestPersistentDaemonPathsUseShortSocketPath(t *testing.T) { + rootBase := filepath.Join( + t.TempDir(), + strings.Repeat("long-path-segment-", 4), + "daemon-root", + ) + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", "") + + paths, err := persistentDaemonPathsForSlot(strings.Repeat("a", 128)) + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if strings.HasPrefix(paths.socket, paths.root) { + t.Fatalf("socket path should not live under long daemon root: socket=%q root=%q", paths.socket, paths.root) + } + if len(paths.socket) >= 100 { + t.Fatalf("socket path length = %d, want < 100: %q", len(paths.socket), paths.socket) + } +} + +func TestPersistentDaemonPathsIncludeDaemonVersion(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", "") + oldVersion := version + defer func() { version = oldVersion }() + + version = "v1.2.3" + first, err := persistentDaemonPathsForSlot("versioned-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if !strings.Contains(first.root, string(filepath.Separator)+"v1.2.3"+string(filepath.Separator)) { + t.Fatalf("root %q should include daemon version", first.root) + } + + version = "v1.2.4" + second, err := persistentDaemonPathsForSlot("versioned-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if first.root == second.root { + t.Fatalf("root should change across versions: %q", first.root) + } + if first.socket == second.socket { + t.Fatalf("socket should change across versions: %q", first.socket) + } + if first.lockFile == second.lockFile { + t.Fatalf("lock file should change across versions: %q", first.lockFile) + } +} + +func TestPersistentDaemonSocketDirOverrideUsesPrivateChild(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + if err := os.Chmod(socketParent, 0o755); err != nil { + t.Fatalf("chmod socket parent: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("override-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + socketDir := filepath.Dir(paths.socket) + if socketDir == socketParent { + t.Fatalf("socket dir should be a private child, got parent %q", socketParent) + } + if filepath.Dir(socketDir) != socketParent { + t.Fatalf("socket dir parent = %q, want %q", filepath.Dir(socketDir), socketParent) + } + + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + parentInfo, err := os.Stat(socketParent) + if err != nil { + t.Fatalf("stat socket parent: %v", err) + } + if parentInfo.Mode().Perm() != 0o755 { + t.Fatalf("socket parent mode = %o, want 755", parentInfo.Mode().Perm()) + } + childInfo, err := os.Stat(socketDir) + if err != nil { + t.Fatalf("stat socket child: %v", err) + } + if childInfo.Mode().Perm() != 0o700 { + t.Fatalf("socket child mode = %o, want 700", childInfo.Mode().Perm()) + } +} + +func TestPersistentDaemonSocketDirFallsBackFromUnsafeSymlink(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + unsafeTarget := filepath.Join(t.TempDir(), "attacker-dir") + if err := os.MkdirAll(unsafeTarget, 0o755); err != nil { + t.Fatalf("create unsafe target: %v", err) + } + unsafeChild := filepath.Join(socketParent, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + if err := os.Symlink(unsafeTarget, unsafeChild); err != nil { + t.Fatalf("create unsafe socket child symlink: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("unsafe-socket-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + unsafeSocketDir := filepath.Dir(paths.socket) + if unsafeSocketDir != unsafeChild { + t.Fatalf("precondition failed: socket dir = %q, want unsafe child %q", unsafeSocketDir, unsafeChild) + } + + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + socketDir := filepath.Dir(paths.socket) + if socketDir == unsafeChild { + t.Fatalf("socket dir still points at unsafe child %q", socketDir) + } + if filepath.Clean(filepath.Dir(socketDir)) != filepath.Clean(os.TempDir()) { + t.Fatalf("fallback socket dir parent = %q, want %q", filepath.Dir(socketDir), os.TempDir()) + } + info, err := os.Lstat(socketDir) + if err != nil { + t.Fatalf("stat fallback socket dir: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + t.Fatalf("fallback socket dir should be a real directory, got mode %v", info.Mode()) + } + if info.Mode().Perm() != 0o700 { + t.Fatalf("fallback socket dir mode = %o, want 700", info.Mode().Perm()) + } + storedSocketDir, err := readPersistentDaemonSocketDir(paths.root) + if err != nil { + t.Fatalf("read stored fallback socket dir: %v", err) + } + if storedSocketDir != socketDir { + t.Fatalf("stored socket dir = %q, want %q", storedSocketDir, socketDir) + } +} + +func TestPersistentDaemonSocketDirReplacesInvalidStoredFallback(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + unsafeTarget := filepath.Join(t.TempDir(), "attacker-dir") + if err := os.MkdirAll(unsafeTarget, 0o755); err != nil { + t.Fatalf("create unsafe target: %v", err) + } + unsafeChild := filepath.Join(socketParent, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + if err := os.Symlink(unsafeTarget, unsafeChild); err != nil { + t.Fatalf("create unsafe socket child symlink: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("invalid-stored-fallback-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if err := os.MkdirAll(paths.root, 0o700); err != nil { + t.Fatalf("create daemon root: %v", err) + } + invalidStoredSocketDir := filepath.Join(t.TempDir(), "invalid-stored-socket-dir") + if err := os.WriteFile(invalidStoredSocketDir, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("create invalid stored socket path: %v", err) + } + if err := os.WriteFile(filepath.Join(paths.root, persistentDaemonSocketDirFile), []byte(invalidStoredSocketDir+"\n"), 0o600); err != nil { + t.Fatalf("write invalid stored socket-dir metadata: %v", err) + } + + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + socketDir := filepath.Dir(paths.socket) + if socketDir == unsafeChild { + t.Fatalf("socket dir still points at unsafe child %q", socketDir) + } + if filepath.Clean(filepath.Dir(socketDir)) != filepath.Clean(os.TempDir()) { + t.Fatalf("fallback socket dir parent = %q, want %q", filepath.Dir(socketDir), os.TempDir()) + } + storedSocketDir, err := readPersistentDaemonSocketDir(paths.root) + if err != nil { + t.Fatalf("read stored replacement socket dir: %v", err) + } + if storedSocketDir != socketDir { + t.Fatalf("stored socket dir = %q, want replacement %q", storedSocketDir, socketDir) + } +} + +func TestPersistentDaemonSocketDirReusesStoredFallback(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + unsafeChild := filepath.Join(socketParent, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + if err := os.WriteFile(unsafeChild, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("create unsafe socket child file: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("stored-fallback-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + firstSocketDir := filepath.Dir(paths.socket) + + nextPaths, err := persistentDaemonPathsForSlot("stored-fallback-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + nextPaths, err = ensurePersistentDaemonDirectory(nextPaths) + if err != nil { + t.Fatalf("second ensurePersistentDaemonDirectory returned error: %v", err) + } + if filepath.Dir(nextPaths.socket) != firstSocketDir { + t.Fatalf("second socket dir = %q, want stored fallback %q", filepath.Dir(nextPaths.socket), firstSocketDir) + } +} + +func TestPersistentDaemonTokenConcurrentCreate(t *testing.T) { + root := t.TempDir() + paths := persistentDaemonPaths{ + root: root, + tokenFile: filepath.Join(root, "auth.token"), + } + if err := os.MkdirAll(filepath.Dir(paths.tokenFile), 0o700); err != nil { + t.Fatalf("mkdir token dir: %v", err) + } + + const workers = 12 + var wg sync.WaitGroup + results := make(chan string, workers) + errorsCh := make(chan error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + token, err := persistentDaemonToken(paths) + if err != nil { + errorsCh <- err + return + } + results <- token + }() + } + wg.Wait() + close(results) + close(errorsCh) + for err := range errorsCh { + t.Fatalf("persistentDaemonToken returned error: %v", err) + } + var first string + for token := range results { + if len(token) != 64 { + t.Fatalf("token length = %d, want 64", len(token)) + } + if first == "" { + first = token + continue + } + if token != first { + t.Fatalf("concurrent token mismatch: got %q want %q", token, first) + } + } + onDisk, err := os.ReadFile(paths.tokenFile) + if err != nil { + t.Fatalf("read token file: %v", err) + } + if strings.TrimSpace(string(onDisk)) != first { + t.Fatalf("token file = %q, want %q", strings.TrimSpace(string(onDisk)), first) + } +} + +func TestPersistentDaemonRejectsBadToken(t *testing.T) { + socketPath, stop := startPersistentDaemonForTest(t, "good-token") + defer stop() + + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("dial persistent daemon: %v", err) + } + defer conn.Close() + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + writePersistentTestFrame(t, writer, rpcRequest{ + ID: 1, + Method: persistentDaemonAuthMethod, + Params: map[string]any{"token": "bad-token"}, + }) + frame := readPersistentTestFrame(t, conn, reader) + if ok, _ := frame["ok"].(bool); ok { + t.Fatalf("bad token auth should fail: %v", frame) + } + errObj, _ := frame["error"].(map[string]any) + if got := errObj["code"]; got != "unauthorized" { + t.Fatalf("bad token error code = %v, want unauthorized; frame=%v", got, frame) + } +} + +func TestDialPersistentDaemonBadTokenWrapsAuthFailure(t *testing.T) { + socketPath, stop := startPersistentDaemonForTest(t, "good-token") + defer stop() + + conn, err := dialPersistentDaemon(socketPath, "bad-token") + if err == nil { + _ = conn.Close() + t.Fatalf("dialPersistentDaemon succeeded with bad token") + } + if !errors.Is(err, errPersistentDaemonAuthFailed) { + t.Fatalf("dialPersistentDaemon error = %v, want errPersistentDaemonAuthFailed", err) + } +} + +func TestPersistentDaemonAcceptsRotatedTokenFile(t *testing.T) { + tokenFile := filepath.Join(t.TempDir(), "auth.token") + if err := os.WriteFile(tokenFile, []byte("old-token\n"), 0o600); err != nil { + t.Fatalf("write initial token: %v", err) + } + socketPath, stop := startPersistentDaemonWithVerifierForTest( + t, + persistentDaemonFileTokenVerifier("old-token", tokenFile), + ) + defer stop() + + if err := os.WriteFile(tokenFile, []byte("new-token\n"), 0o600); err != nil { + t.Fatalf("rotate token: %v", err) + } + conn, _, _ := openPersistentTestClient(t, socketPath, "new-token") + _ = conn.Close() +} + +func TestAuthenticatePersistentDaemonClientReadDeadline(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + requestRead := make(chan error, 1) + go func() { + _, err := bufio.NewReader(server).ReadString('\n') + requestRead <- err + }() + + start := time.Now() + err := authenticatePersistentDaemonClientWithTimeout(client, "token", 50*time.Millisecond) + if err == nil { + t.Fatalf("authenticatePersistentDaemonClientWithTimeout succeeded, want timeout error") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("authenticatePersistentDaemonClientWithTimeout took %s, want bounded deadline", elapsed) + } + select { + case readErr := <-requestRead: + if readErr != nil { + t.Fatalf("server failed to read auth request: %v", readErr) + } + case <-time.After(time.Second): + t.Fatalf("server did not receive auth request") + } +} + +func TestAuthenticatePersistentDaemonServerReadDeadline(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + + hub := newWebSocketPTYHub(wsPTYServerConfig{}, io.Discard) + defer hub.closeAll() + + done := make(chan struct{}, 1) + go func() { + handlePersistentDaemonConnWithAuthTimeout(server, persistentDaemonFixedTokenVerifier("token"), hub, 50*time.Millisecond) + done <- struct{}{} + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatalf("server auth handler did not return after deadline") + } +} + +func TestPersistentStdioProxyReturnsWhenDaemonClosesFirst(t *testing.T) { + client, server := net.Pipe() + stdinReader, stdinWriter := io.Pipe() + defer stdinWriter.Close() + + done := make(chan error, 1) + go func() { + done <- proxyPersistentDaemonConn(stdinReader, io.Discard, client) + }() + + _ = server.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("proxyPersistentDaemonConn returned error: %v", err) + } + case <-time.After(time.Second): + t.Fatalf("proxyPersistentDaemonConn did not return after daemon side closed") + } + _ = stdinWriter.Close() +} + +func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { + socketPath, stop := startPersistentDaemonForTest(t, "reattach-token") + defer stop() + + conn1, reader1, writer1 := openPersistentTestClient(t, socketPath, "reattach-token") + sessionID := "persistent-rpc" + attach1 := persistentTestRPCCall(t, conn1, reader1, writer1, rpcRequest{ + ID: 1, + Method: "pty.attach", + Params: map[string]any{ + "session_id": sessionID, + "attachment_id": "a1", + "client_attachment_token": "token-a1", + "cols": 80, + "rows": 24, + "command": "printf 'persistent-rpc-data\\n'; sleep 60", + }, + }) + if ok, _ := attach1["ok"].(bool); !ok { + t.Fatalf("first pty.attach failed: %v", attach1) + } + readPersistentTestEvent(t, conn1, reader1, func(frame map[string]any) bool { + return frame["event"] == "pty.ready" && frame["attachment_id"] == "a1" + }) + readPersistentTestEvent(t, conn1, reader1, func(frame map[string]any) bool { + if frame["event"] != "pty.data" || frame["attachment_id"] != "a1" { + return false + } + payload, err := base64.StdEncoding.DecodeString(frame["data_base64"].(string)) + return err == nil && strings.Contains(string(payload), "persistent-rpc-data") + }) + _ = conn1.Close() + + conn2, reader2, writer2 := openPersistentTestClient(t, socketPath, "reattach-token") + defer conn2.Close() + attach2 := persistentTestRPCCall(t, conn2, reader2, writer2, rpcRequest{ + ID: 2, + Method: "pty.attach", + Params: map[string]any{ + "session_id": sessionID, + "attachment_id": "a2", + "client_attachment_token": "token-a2", + "cols": 100, + "rows": 30, + "command": "printf 'should-not-run\\n'", + "require_existing": true, + }, + }) + if ok, _ := attach2["ok"].(bool); !ok { + t.Fatalf("second pty.attach failed: %v", attach2) + } + readPersistentTestEvent(t, conn2, reader2, func(frame map[string]any) bool { + return frame["event"] == "pty.ready" && frame["attachment_id"] == "a2" + }) + readPersistentTestEvent(t, conn2, reader2, func(frame map[string]any) bool { + if frame["event"] != "pty.data" || frame["attachment_id"] != "a2" { + return false + } + payload, err := base64.StdEncoding.DecodeString(frame["data_base64"].(string)) + return err == nil && strings.Contains(string(payload), "persistent-rpc-data") + }) + + list := persistentTestRPCCall(t, conn2, reader2, writer2, rpcRequest{ + ID: 3, + Method: "pty.list", + Params: map[string]any{}, + }) + if ok, _ := list["ok"].(bool); !ok { + t.Fatalf("pty.list failed: %v", list) + } + result, _ := list["result"].(map[string]any) + sessions, _ := result["sessions"].([]any) + if len(sessions) != 1 { + t.Fatalf("pty.list sessions = %v, want one", result["sessions"]) + } + session, _ := sessions[0].(map[string]any) + if got := session["session_id"]; got != sessionID { + t.Fatalf("pty.list session_id = %v, want %s", got, sessionID) + } + + closeResp := persistentTestRPCCall(t, conn2, reader2, writer2, rpcRequest{ + ID: 4, + Method: "pty.close", + Params: map[string]any{"session_id": sessionID}, + }) + if ok, _ := closeResp["ok"].(bool); !ok { + t.Fatalf("pty.close failed: %v", closeResp) + } +} + +func TestPersistentDaemonReadySignalAllowsImmediateDial(t *testing.T) { + socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-ready-*") + if err != nil { + t.Fatalf("create short socket dir: %v", err) + } + defer os.RemoveAll(socketDir) + socketPath := filepath.Join(socketDir, "rpc.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + + readyReader, readyWriter, err := os.Pipe() + if err != nil { + _ = listener.Close() + t.Fatalf("create ready pipe: %v", err) + } + defer readyReader.Close() + readyFD, err := syscall.Dup(int(readyWriter.Fd())) + if err != nil { + _ = listener.Close() + _ = readyWriter.Close() + t.Fatalf("duplicate ready fd: %v", err) + } + t.Setenv(persistentDaemonReadyFDEnv, strconv.Itoa(readyFD)) + signalPersistentDaemonReady() + _ = readyWriter.Close() + line, err := bufio.NewReader(readyReader).ReadString('\n') + if err != nil { + _ = listener.Close() + t.Fatalf("read ready signal: %v", err) + } + if strings.TrimSpace(line) != "ready" { + _ = listener.Close() + t.Fatalf("ready signal = %q, want ready", strings.TrimSpace(line)) + } + + done := make(chan error, 1) + go func() { + done <- servePersistentDaemonWithVerifier(listener, persistentDaemonFixedTokenVerifier("ready-token"), io.Discard) + }() + + conn, err := dialPersistentDaemon(socketPath, "ready-token") + if err != nil { + _ = listener.Close() + t.Fatalf("dial persistent daemon after ready signal: %v", err) + } + _ = conn.Close() + + _ = listener.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("persistent daemon exited with error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("persistent daemon did not stop") + } +} + +func TestPersistentDaemonServerExitsAfterEmptySlotIdleTimeout(t *testing.T) { + socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-idle-*") + if err != nil { + t.Fatalf("create short socket dir: %v", err) + } + defer os.RemoveAll(socketDir) + socketPath := filepath.Join(socketDir, "rpc.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + + done := make(chan error, 1) + go func() { + done <- servePersistentDaemonWithVerifierConfig( + listener, + persistentDaemonFixedTokenVerifier("idle-token"), + io.Discard, + persistentDaemonServerConfig{ + emptyIdleTimeout: 500 * time.Millisecond, + acceptPollStep: 25 * time.Millisecond, + }, + ) + }() + + conn, reader, writer := openPersistentTestClient(t, socketPath, "idle-token") + attach := persistentTestRPCCall(t, conn, reader, writer, rpcRequest{ + ID: 1, + Method: "pty.attach", + Params: map[string]any{ + "session_id": "idle-session", + "attachment_id": "idle-attachment", + "client_attachment_token": "idle-attachment-token", + "cols": 80, + "rows": 24, + "command": "sleep 60", + }, + }) + if ok, _ := attach["ok"].(bool); !ok { + t.Fatalf("pty.attach failed: %v", attach) + } + readPersistentTestEvent(t, conn, reader, func(frame map[string]any) bool { + return frame["event"] == "pty.ready" && frame["attachment_id"] == "idle-attachment" + }) + + closeResp := persistentTestRPCCall(t, conn, reader, writer, rpcRequest{ + ID: 2, + Method: "pty.close", + Params: map[string]any{"session_id": "idle-session"}, + }) + if ok, _ := closeResp["ok"].(bool); !ok { + t.Fatalf("pty.close failed: %v", closeResp) + } + _ = conn.Close() + + select { + case err := <-done: + if err != nil { + t.Fatalf("persistent daemon exited with error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("persistent daemon did not stop after empty idle timeout") + } +} + +func TestRunStdioSlotRequiresPersistent(t *testing.T) { + var stderr bytes.Buffer + code := run([]string{"serve", "--stdio", "--slot", "slot-without-persistent"}, strings.NewReader(""), &bytes.Buffer{}, &stderr) + if code != 2 { + t.Fatalf("run serve exit code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "serve --slot requires --persistent") { + t.Fatalf("stderr = %q, want --slot validation error", stderr.String()) + } +} + func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { input := strings.NewReader( `{"id":1,"method":"hello","params":{}` + "\n" + diff --git a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go index 22d9b70690ab..53fcf1d41243 100644 --- a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go +++ b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -639,9 +640,17 @@ func TestWebSocketPTYWriteFailureClosesConnectionAndReapsAttachment(t *testing.T } waitForHubSessionCount(t, hub, 0, 5*time.Second) - _, _, err := conn.Read(ctx) - if err == nil { - t.Fatal("client connection stayed open after server write failure") + closeCtx, cancelClose := context.WithTimeout(ctx, 5*time.Second) + defer cancelClose() + for { + _, _, err := conn.Read(closeCtx) + if err == nil { + continue + } + if errors.Is(err, context.DeadlineExceeded) { + t.Fatal("client connection stayed open after server write failure") + } + break } } diff --git a/docs/cli-contract.md b/docs/cli-contract.md index aec57c4d408d..b8f099feb52e 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -100,6 +100,9 @@ Environment: | `new-workspace` | Create a workspace, optionally with cwd, command, description, and layout. | | `ssh` | Open an SSH-backed workspace. | | `remote-daemon-status` | Print bundled remote daemon version, asset, checksum, and cache status. | +| `ssh-session-list` | List persisted SSH PTY sessions for one remote workspace or all remote workspaces. Supports `--json`. | +| `ssh-session-attach` | Create a local terminal surface that reattaches to an existing persisted SSH PTY session. | +| `ssh-session-cleanup` | Close one or all persisted SSH PTY sessions. Supports `--json`. | | `new-split` | Split from a surface in a direction. | | `list-panes` | List panes in a workspace. | | `list-pane-surfaces` | List surfaces in a pane. | @@ -164,6 +167,7 @@ Environment: | `vm-pty-attach` | Internal VM PTY attach command. | | `vm-ssh-attach` | Hidden compatibility alias for older VM workspaces. | | `vm-pty-connect` | Internal helper that connects to a VM PTY from a config file. | +| `ssh-pty-attach` | Internal helper used by SSH terminal startup scripts to bridge a local terminal surface to a remote PTY session. | | `ssh-session-end` | Internal helper that clears remote SSH session state. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | @@ -425,6 +429,9 @@ the expected text without connecting to a cmux socket. - `cmux new-workspace --help` -> `Usage: cmux new-workspace` - `cmux list-workspaces --help` -> `Usage: cmux list-workspaces` - `cmux ssh --help` -> `Usage: cmux ssh ` +- `cmux ssh-session-list --help` -> `Usage: cmux ssh-session-list` +- `cmux ssh-session-attach --help` -> `Usage: cmux ssh-session-attach --session-id ` +- `cmux ssh-session-cleanup --help` -> `Usage: cmux ssh-session-cleanup` - `cmux new-split --help` -> `Usage: cmux new-split` - `cmux list-panes --help` -> `Usage: cmux list-panes` - `cmux list-pane-surfaces --help` -> `Usage: cmux list-pane-surfaces` diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 3c8bb0c8a81b..55ebd626aed9 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -1,6 +1,6 @@ # Remote SSH Living Spec -Last updated: March 12, 2026 +Last updated: May 26, 2026 Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 Primary PR: https://github.com/manaflow-ai/cmux/pull/1296 CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 @@ -40,6 +40,8 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. - `DONE` bootstrap/probe failures surface actionable details. - `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. +- `DONE` normal `cmux ssh` launches `cmuxd-remote serve --stdio --persistent --slot `, where the stdio process proxies to a long-lived authenticated daemon with slot credentials under `~/.cmux/daemon///` and a short per-user socket path under `/tmp/cmuxd-remote-/`. +- `DONE` persistent daemon slots advertise `pty.session.persistent_daemon`; cmux requires that capability before preserving a saved remote PTY session ID across app relaunch. ### 3.5 CLI Relay (Running cmux Commands From Remote) - `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. @@ -49,6 +51,7 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation. - `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.cmux/socket_addr` a single time to recover from a stale shared address rewrite. - `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:` so parallel sessions pin to their own relay instead of racing on shared socket_addr. +- `DONE` session snapshots persist the relay port for persistent SSH PTYs and mint fresh relay credentials on restore, so a reattached remote shell can keep using its existing `CMUX_SOCKET_PATH=127.0.0.1:` after app relaunch. - `DONE` relay startup writes `~/.cmux/relay/.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. - `DONE` relay startup writes `~/.cmux/relay/.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket. - `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. @@ -135,6 +138,7 @@ Recompute effective size on: | M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | | M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests | | M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | +| M-011 | Detachable persistent `cmux ssh` PTY sessions | IN PROGRESS | Persistent remote daemon slots keep PTY sessions alive across local surface close and app relaunch; coverage includes Go daemon auth/reattach tests, Swift restore tests, CLI contract tests, and `tests_v2/test_ssh_remote_detachable_pty.py` | ## 7. Acceptance Test Matrix (With Status) @@ -178,9 +182,22 @@ Recompute effective size on: |---|---|---| | RZ-001 | two attachments, smallest wins | DONE | | RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE | -| RZ-003 | detach smallest, PTY expands to next smallest | DONE | -| RZ-004 | reconnect preserves session + applies recomputed size | DONE | -| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | +| RZ-003 | detach all attachments, keep last-known PTY size | DONE | +| RZ-004 | reattach existing session, recompute effective size from active attachments | DONE | +| RZ-005 | detach smallest, PTY expands to next smallest | DONE | +| RZ-006 | reconnect preserves session + applies recomputed size | DONE | +| RZ-007 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | + +### 7.5 Detachable SSH PTY + +| ID | Scenario | Status | +|---|---|---| +| DP-001 | `cmux ssh` creates a persistent daemon slot and PTY session ID | IN PROGRESS | +| DP-002 | closing the local SSH surface detaches the attachment without killing the remote shell | IN PROGRESS | +| DP-003 | `cmux ssh-session-list` reports detached persisted sessions with bounded scrollback metadata | IN PROGRESS | +| DP-004 | `cmux ssh-session-attach` reattaches to the same remote shell PID and env | IN PROGRESS | +| DP-005 | app relaunch restores saved remote PTY session IDs only when the snapshot has a persistent daemon slot | IN PROGRESS | +| DP-006 | `cmux ssh-session-cleanup` terminates persisted PTY sessions explicitly | DONE | ## 8. Removal Checklist (Port Mirroring) diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index 8abe3203949d..aed4fbf4ac48 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -16,6 +16,7 @@ EOF SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" GHOSTTY_DIR="$REPO_ROOT/ghostty" +ZIG_REQUIRED="${ZIG_REQUIRED:-0.15.2}" OUTPUT_PATH="" TARGET_TRIPLE="" @@ -33,6 +34,12 @@ target_arch_for_triple() { esac } +zig_has_required_version() { + local zig_path="$1" + [[ -x "$zig_path" ]] || return 1 + [[ "$("$zig_path" version 2>/dev/null || true)" == "$ZIG_REQUIRED" ]] +} + select_zig_for_target() { local target="${1:-}" local desired_arch @@ -43,6 +50,10 @@ select_zig_for_target() { echo "error: CMUX_ZIG is not executable: $CMUX_ZIG" >&2 return 1 fi + if ! zig_has_required_version "$CMUX_ZIG"; then + echo "error: CMUX_ZIG must be zig ${ZIG_REQUIRED}: $CMUX_ZIG" >&2 + return 1 + fi echo "$CMUX_ZIG" return 0 fi @@ -63,6 +74,7 @@ select_zig_for_target() { canonical="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" [[ "$seen" == *" $canonical "* ]] && continue seen="${seen}${canonical} " + zig_has_required_version "$canonical" || continue [[ -z "$fallback" ]] && fallback="$canonical" if [[ -n "$desired_arch" ]]; then arch="$(zig_binary_arch "$canonical")" @@ -78,7 +90,7 @@ select_zig_for_target() { return 0 fi - echo "error: zig is required to build the Ghostty CLI helper" >&2 + echo "error: zig ${ZIG_REQUIRED} is required to build the Ghostty CLI helper" >&2 return 1 } diff --git a/scripts/install-zig-ci.sh b/scripts/install-zig-ci.sh index c49e147181c9..ce8417fb51f8 100755 --- a/scripts/install-zig-ci.sh +++ b/scripts/install-zig-ci.sh @@ -9,13 +9,45 @@ export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" export HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}" export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" -if command -v zig >/dev/null 2>&1; then - INSTALLED_ZIG_VERSION="$(zig version 2>/dev/null || true)" - if [ "$INSTALLED_ZIG_VERSION" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - exit 0 +publish_zig_for_later_steps() { + local zig_path="$1" + local zig_dir + zig_dir="$(cd "$(dirname "$zig_path")" && pwd)" + zig_path="${zig_dir}/$(basename "$zig_path")" + if [ -n "${GITHUB_PATH:-}" ]; then + echo "$zig_dir" >> "$GITHUB_PATH" fi -fi + if [ -n "${GITHUB_ENV:-}" ]; then + echo "CMUX_ZIG=$zig_path" >> "$GITHUB_ENV" + fi +} + +zig_has_required_version() { + local zig_path="$1" + [ -x "$zig_path" ] || return 1 + [ "$("$zig_path" version 2>/dev/null || true)" = "$ZIG_REQUIRED" ] +} + +use_existing_zig_if_available() { + local candidate + local seen=" " + for candidate in "$(command -v zig 2>/dev/null || true)" /opt/homebrew/bin/zig /usr/local/bin/zig; do + [ -n "$candidate" ] || continue + [ -x "$candidate" ] || continue + candidate="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" + case "$seen" in + *" $candidate "*) continue ;; + esac + seen="${seen}${candidate} " + if zig_has_required_version "$candidate"; then + echo "zig ${ZIG_REQUIRED} already installed at $candidate" + publish_zig_for_later_steps "$candidate" + exit 0 + fi + done +} + +use_existing_zig_if_available case "$(uname -m)" in arm64 | aarch64) ZIG_ARCH="aarch64" ;; diff --git a/tests_v2/test_ssh_remote_detachable_pty.py b/tests_v2/test_ssh_remote_detachable_pty.py new file mode 100644 index 000000000000..426acb949950 --- /dev/null +++ b/tests_v2/test_ssh_remote_detachable_pty.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Integration: cmux ssh PTY sessions survive local surface detach and reattach.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _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_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 10.0, step_s: float = 0.15) -> 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 _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _workspace_row(client: cmux, workspace_id: str) -> dict: + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("id") or "") == workspace_id: + return row + raise cmuxError(f"workspace.list missing {workspace_id}: {rows}") + + +def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 18.0) -> str: + token = f"__CMUX_{token_prefix}_{secrets.token_hex(6)}__" + client.send_surface( + surface_id, + ( + f"printf '{token}:START'; echo; " + f"{command}; " + f"printf '{token}:END'; echo" + ), + ) + client.send_key_surface(surface_id, "enter") + deadline = time.time() + timeout_s + last = "" + pattern = re.compile(re.escape(token) + r":START\n(.*?)" + re.escape(token) + r":END", re.S) + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + matches = pattern.findall(last) + if matches: + return matches[-1].replace("\r", "").strip() + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for probe {token!r}: {last[-1200:]!r}") + + +def _open_ssh_workspace(client: cmux, cli: str) -> tuple[str, str, str, str]: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-detachable-pty-{int(time.time())}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + session_id = str(payload.get("ssh_pty_session_id") or "") + persistent_slot = str(payload.get("persistent_daemon_slot") or "") + _must(session_id.startswith("ssh-"), f"cmux ssh did not create a persistent PTY session: {payload}") + _must(persistent_slot.startswith("ssh-"), f"cmux ssh did not create a persistent daemon slot: {payload}") + + surface_id = str(payload.get("surface_id") or "") + if not surface_id: + surfaces = client.list_surfaces(workspace_id) + _must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}") + surface_id = surfaces[0][1] + + _wait_remote_ready(client, workspace_id) + client.select_workspace(workspace_id) + _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) + return workspace_id, surface_id, session_id, persistent_slot + + +def _session_list(cli: str, workspace_id: str) -> list[dict]: + payload = _run_cli_json(cli, ["ssh-session-list", "--workspace", workspace_id]) + return list(payload.get("sessions") or []) + + +def _session_row(cli: str, workspace_id: str, session_id: str) -> dict | None: + for row in _session_list(cli, workspace_id): + if str(row.get("session_id") or "") == session_id: + return row + return None + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh detachable PTY regression") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + session_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id, surface_id, session_id, persistent_slot = _open_ssh_workspace(client, cli) + + marker = f"detachable_{secrets.token_hex(6)}" + first_probe = _run_surface_probe( + client, + surface_id, + f"export CMUX_DETACH_MARK={marker}; printf 'pid=%s marker=%s socket=%s' \"$$\" \"$CMUX_DETACH_MARK\" \"$CMUX_SOCKET_PATH\"", + "SSH_DETACH_FIRST", + ) + match = re.search(r"pid=([0-9]+) marker=(\S+) socket=(\S+)", first_probe) + _must(match is not None, f"initial shell probe did not return expected fields: {first_probe!r}") + original_pid, original_marker, original_socket = match.groups() + _must(original_marker == marker, f"remote marker was not exported: {first_probe!r}") + _must(original_socket.startswith("127.0.0.1:"), f"remote shell should use relay socket, got {original_socket!r}") + + browser_surface = client.new_surface(panel_type="browser", url="about:blank") + _must(browser_surface, "failed to create browser surface guard") + client.close_surface(surface_id) + + def detached_session_is_listed() -> bool: + row = _session_row(cli, workspace_id, session_id) + if row is None: + return False + attachments = row.get("attachments") or [] + return len(attachments) == 0 + + _wait_for(detached_session_is_listed, timeout_s=20.0, step_s=0.25) + row_after_detach = _session_row(cli, workspace_id, session_id) + _must(row_after_detach is not None, f"detached session {session_id} disappeared") + _must( + int(row_after_detach.get("scrollback_bytes") or 0) > 0, + f"detached session should keep bounded scrollback metadata: {row_after_detach}", + ) + + attach_payload = _run_cli_json( + cli, + ["ssh-session-attach", "--workspace", workspace_id, "--session-id", session_id], + ) + reattached_surface = str(attach_payload.get("surface_id") or "") + _must(reattached_surface, f"ssh-session-attach output missing surface_id: {attach_payload}") + + second_probe = _run_surface_probe( + client, + reattached_surface, + "printf 'pid=%s marker=%s socket=%s' \"$$\" \"$CMUX_DETACH_MARK\" \"$CMUX_SOCKET_PATH\"", + "SSH_DETACH_SECOND", + ) + rematch = re.search(r"pid=([0-9]+) marker=(\S+) socket=(\S+)", second_probe) + _must(rematch is not None, f"reattached shell probe did not return expected fields: {second_probe!r}") + reattached_pid, reattached_marker, reattached_socket = rematch.groups() + _must(reattached_pid == original_pid, f"reattach should preserve shell pid {original_pid}, got {reattached_pid}") + _must(reattached_marker == marker, f"reattach should preserve remote shell env marker, got {second_probe!r}") + _must(reattached_socket == original_socket, f"reattach should preserve relay socket {original_socket}, got {reattached_socket}") + + final_row = _workspace_row(client, workspace_id) + final_remote = final_row.get("remote") or {} + _must( + str(final_remote.get("persistent_daemon_slot") or "") == persistent_slot, + f"workspace status should expose persistent daemon slot {persistent_slot}: {final_row}", + ) + finally: + if workspace_id: + try: + if session_id: + _run_cli_json(cli, ["ssh-session-cleanup", "--workspace", workspace_id, "--session-id", session_id]) + except Exception: + pass + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + + print("PASS: cmux ssh PTY survives local detach and reattaches to the same remote shell") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())