diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22933f48..e7b821d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,27 @@ jobs: - name: Validate GhosttyKit checksum verification run: ./tests/test_ci_ghosttykit_checksum_verification.sh + - name: Validate release asset guard + run: node scripts/release_asset_guard.test.js + + remote-daemon-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + + - name: Run remote daemon tests + working-directory: daemon/remote + run: go test ./... + + - name: Validate remote daemon release assets + run: ./tests/test_remote_daemon_release_assets.sh + web-typecheck: runs-on: ubuntu-latest defaults: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5c46f0a3..c175487d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,6 +20,8 @@ concurrency: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -142,6 +144,11 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} @@ -240,6 +247,7 @@ jobs: NIGHTLY_BUILD="${NIGHTLY_DATE}000000" fi echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" + echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV" ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg" @@ -284,6 +292,24 @@ jobs: echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" + - name: Build remote daemon nightly assets and inject manifest + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + run: | + set -euo pipefail + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$NIGHTLY_REMOTE_DAEMON_VERSION" \ + --release-tag "nightly" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + for APP_PLIST in \ + "build-arm/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" \ + "build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" + do + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + done + - name: Import signing cert if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' env: @@ -427,6 +453,18 @@ jobs: ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml + - name: Attest remote daemon nightly assets + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload branch nightly artifacts if: needs.decide.outputs.should_publish != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -437,6 +475,12 @@ jobs: cmux-nightly-universal-macos*.dmg appcast.xml appcast-universal.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json if-no-files-found: error - name: Move nightly tag to built commit @@ -472,6 +516,12 @@ jobs: cmux-nightly-universal-macos.dmg appcast.xml appcast-universal.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json overwrite_files: true - name: Cleanup keychain diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a58f07f..bce4327c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -114,6 +116,12 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key if: steps.guard_release_assets.outputs.skip_all != 'true' env: @@ -134,6 +142,21 @@ jobs: -clonedSourcePackagesDirPath .spm-cache \ CODE_SIGNING_ALLOWED=NO build + - name: Build remote daemon release assets and inject manifest + if: steps.guard_release_assets.outputs.skip_all != 'true' + run: | + set -euo pipefail + APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist" + APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$APP_VERSION" \ + --release-tag "$GITHUB_REF_NAME" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + - name: Run CLI version memory guard regression if: steps.guard_release_assets.outputs.skip_all != 'true' run: | @@ -268,6 +291,18 @@ jobs: fi ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml + - name: Attest remote daemon release assets + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -275,6 +310,12 @@ jobs: files: | cmux-macos.dmg appcast.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json generate_release_notes: true overwrite_files: false diff --git a/CLAUDE.md b/CLAUDE.md index 0fcbfce3..8a8e4e0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,8 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Untagged Debug app: `/tmp/cmux-debug.log` - Tagged Debug app (`./scripts/reload.sh --tag `): `/tmp/cmux-debug-.log` - `reload.sh` writes the current path to `/tmp/cmux-last-debug-log-path` +- `reload.sh` writes the selected dev CLI path to `/tmp/cmux-last-cli-path` +- `reload.sh` updates `/tmp/cmux-cli` and `$HOME/.local/bin/cmux-dev` to that CLI - Implementation: `vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift` - Free function `dlog("message")` — logs with timestamp and appends to file in real time diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8346d1ab..6329c5d4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,12 @@ import Foundation +import CryptoKit import Darwin +#if canImport(LocalAuthentication) +import LocalAuthentication +#endif +#if canImport(Security) +import Security +#endif #if canImport(Sentry) import Sentry #endif @@ -415,17 +422,22 @@ enum CLIIDFormat: String { } private enum SocketPasswordResolver { + private static let service = "com.cmuxterm.app.socket-control" + private static let account = "local-socket-password" private static let directoryName = "cmux" private static let fileName = "socket-control-password" - static func resolve(explicit: String?) -> String? { + static func resolve(explicit: String?, socketPath: String) -> String? { if let explicit = normalized(explicit) { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } - return loadFromFile() + if let filePassword = loadFromFile() { + return filePassword + } + return loadFromKeychain(socketPath: socketPath) } private static func normalized(_ value: String?) -> String? { @@ -449,6 +461,83 @@ private enum SocketPasswordResolver { } return normalized(value) } + + private static func keychainServices(socketPath: String) -> [String] { + guard let scope = keychainScope(socketPath: socketPath) else { + return [service] + } + return ["\(service).\(scope)"] + } + + private static func keychainScope(socketPath: String) -> String? { + if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } + } + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start.. String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func loadFromKeychain(socketPath: String) -> String? { + for service in keychainServices(socketPath: socketPath) { + let authContext = LAContext() + authContext.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + // Never trigger keychain UI from CLI commands; fail fast instead. + kSecUseAuthenticationContext as String: authContext, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { + continue + } + guard status == errSecSuccess else { + continue + } + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + continue + } + return password + } + return nil + } } private enum CLISocketPathSource { @@ -619,6 +708,10 @@ final class SocketClient { self.path = path } + var socketPath: String { + path + } + func connect() throws { if socketFD >= 0 { return } @@ -791,6 +884,53 @@ final class SocketClient { struct CMUXCLI { let args: [String] + private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path" + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func pathIsSocket(_ path: String) -> Bool { + var st = stat() + guard lstat(path, &st) == 0 else { return false } + return (st.st_mode & S_IFMT) == S_IFSOCK + } + + private static func debugSocketPathFromHintFile() -> String? { +#if DEBUG + guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { + return nil + } + guard let hinted = normalizedEnvValue(raw), + hinted.hasPrefix("/tmp/cmux-debug"), + hinted.hasSuffix(".sock"), + pathIsSocket(hinted) else { + return nil + } + return hinted +#else + return nil +#endif + } + + private static func defaultSocketPath(environment: [String: String]) -> String { + if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) { + return explicit + } +#if DEBUG + if let hinted = debugSocketPathFromHintFile() { + return hinted + } + return "/tmp/cmux-debug.sock" +#else + return "/tmp/cmux.sock" +#endif + } + func run() throws { let processEnv = ProcessInfo.processInfo.environment let envSocketPath: String? = { @@ -891,6 +1031,11 @@ struct CMUXCLI { return } + if command == "remote-daemon-status" { + try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput) + return + } + // If the argument looks like a path (not a known command), open a workspace there. if looksLikePath(command) { try openPath(command, socketPath: resolvedSocketPath) @@ -970,7 +1115,11 @@ struct CMUXCLI { } defer { client.close() } - try authenticateClientIfNeeded(client, explicitPassword: socketPasswordArg) + try authenticateClientIfNeeded( + client, + explicitPassword: socketPasswordArg, + socketPath: resolvedSocketPath + ) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) @@ -1115,14 +1264,27 @@ struct CMUXCLI { let selected = (ws["selected"] as? Bool) == true let handle = textHandle(ws, idFormat: idFormat) let title = (ws["title"] as? String) ?? "" + let remoteTag: String = { + guard let remote = ws["remote"] as? [String: Any], + (remote["enabled"] as? Bool) == true else { + return "" + } + let state = (remote["state"] as? String) ?? "unknown" + return " [ssh:\(state)]" + }() let prefix = selected ? "* " : " " let selTag = selected ? " [selected]" : "" let titlePart = title.isEmpty ? "" : " \(title)" - print("\(prefix)\(handle)\(titlePart)\(selTag)") + print("\(prefix)\(handle)\(titlePart)\(remoteTag)\(selTag)") } } } + case "ssh": + try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "ssh-session-end": + try runSSHSessionEnd(commandArgs: commandArgs, client: client) + case "new-workspace": let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") let (cwdOpt, remaining) = parseOption(rem0, name: "--cwd") @@ -1571,109 +1733,6 @@ struct CMUXCLI { throw error } - case "set-status": - let (icon, r1) = parseOption(commandArgs, name: "--icon") - let (color, r2) = parseOption(r1, name: "--color") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - guard r3.count >= 2 else { - throw CLIError(message: "set-status requires and ") - } - let key = r3[0] - let value = r3.dropFirst().joined(separator: " ") - guard !value.isEmpty else { - throw CLIError(message: "set-status requires a non-empty value") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_status \(key) \(socketQuote(value))" - if let icon { socketCmd += " --icon=\(socketQuote(icon))" } - if let color { socketCmd += " --color=\(socketQuote(color))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-status": - let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace") - guard let key = csRemaining.first else { - throw CLIError(message: "clear-status requires a ") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client) - print(response) - - case "list-status": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("list_status --tab=\(wsId)", client: client) - print(response) - - case "set-progress": - let (label, spR1) = parseOption(commandArgs, name: "--label") - let (wsFlag, spR2) = parseOption(spR1, name: "--workspace") - guard let valueStr = spR2.first else { - throw CLIError(message: "set-progress requires a progress value (0.0-1.0)") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_progress \(valueStr)" - if let label { socketCmd += " --label=\(socketQuote(label))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-progress": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client) - print(response) - - case "log": - let (level, r1) = parseOption(commandArgs, name: "--level") - let (source, r2) = parseOption(r1, name: "--source") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - // Strip leading "--" separator if present - let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3 - let message = positional.joined(separator: " ") - guard !message.isEmpty else { - throw CLIError(message: "log requires a message") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "log" - if let level { socketCmd += " --level=\(level)" } - if let source { socketCmd += " --source=\(socketQuote(source))" } - socketCmd += " --tab=\(wsId) -- \(socketQuote(message))" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-log": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_log --tab=\(wsId)", client: client) - print(response) - - case "list-log": - let (limitStr, r1) = parseOption(commandArgs, name: "--limit") - let (wsFlag, _) = parseOption(r1, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "list_log" - if let limitStr { socketCmd += " --limit=\(limitStr)" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "sidebar-state": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client) - print(response) - case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try sendV1Command("set_app_focus \(value)", client: client) @@ -2091,17 +2150,32 @@ struct CMUXCLI { guard connected else { throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") } - try authenticateClientIfNeeded(pollClient, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + pollClient, + explicitPassword: explicitPassword, + socketPath: socketPath + ) return pollClient } try client.connect() - try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) return client } - private func authenticateClientIfNeeded(_ client: SocketClient, explicitPassword: String?) throws { - if let socketPassword = SocketPasswordResolver.resolve(explicit: explicitPassword) { + private func authenticateClientIfNeeded( + _ client: SocketClient, + explicitPassword: String?, + socketPath: String + ) throws { + if let socketPassword = SocketPasswordResolver.resolve( + explicit: explicitPassword, + socketPath: socketPath + ) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { @@ -2126,14 +2200,6 @@ struct CMUXCLI { process.waitUntilExit() } - private func sendV1Command(_ command: String, client: SocketClient) throws -> String { - let response = try client.send(command: command) - if response.hasPrefix("ERROR:") { - throw CLIError(message: response) - } - return response - } - private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { @@ -2142,6 +2208,14 @@ struct CMUXCLI { return .refs } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -2788,6 +2862,807 @@ struct CMUXCLI { windowOverride: windowOverride ) } + private struct SSHCommandOptions { + let destination: String + let port: Int? + let identityFile: String? + let workspaceName: String? + let sshOptions: [String] + let extraArguments: [String] + let localSocketPath: String + let remoteRelayPort: Int + } + + private struct RemoteDaemonManifest: Decodable { + struct Entry: Decodable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } + } + + private func generateRemoteRelayPort() -> Int { + // Random port in the ephemeral range (49152-65535) + Int.random(in: 49152...65535) + } + + private func randomHex(byteCount: Int) throws -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw CLIError(message: "failed to generate SSH relay credential") + } + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private func runSSH( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + // Use the socket path from this invocation (supports --socket overrides). + let localSocketPath = client.socketPath + let remoteRelayPort = generateRemoteRelayPort() + let relayID = UUID().uuidString.lowercased() + let relayToken = try randomHex(byteCount: 32) + let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) + prepareSSHTerminfoIfNeeded(sshOptions) + let sshCommand = buildSSHCommandText(sshOptions) + let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + let sshStartupCommand = buildSSHStartupCommand( + sshCommand: sshCommand, + shellFeatures: shellFeaturesValue, + remoteRelayPort: sshOptions.remoteRelayPort + ) + let remoteSSHOptions = effectiveSSHOptions( + sshOptions.sshOptions, + remoteRelayPort: sshOptions.remoteRelayPort + ) + + cliDebugLog( + "cli.ssh.start target=\(sshOptions.destination) port=\(sshOptions.port.map(String.init) ?? "nil") " + + "relayPort=\(sshOptions.remoteRelayPort) localSocket=\(sshOptions.localSocketPath) " + + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + + "workspaceName=\(sshOptions.workspaceName?.replacingOccurrences(of: " ", with: "_") ?? "nil") " + + "extraArgs=\(sshOptions.extraArguments.count)" + ) + + let workspaceCreateParams: [String: Any] = [ + "initial_command": sshStartupCommand, + ] + + let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) + guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { + throw CLIError(message: "workspace.create did not return workspace_id") + } + let workspaceWindowId = (workspaceCreate["window_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + cliDebugLog( + "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " + + "window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" + ) + let configuredPayload: [String: Any] + do { + if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceName.isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": workspaceName, + ]) + } + + var configureParams: [String: Any] = [ + "workspace_id": workspaceId, + "destination": sshOptions.destination, + "auto_connect": true, + ] + if let port = sshOptions.port { + configureParams["port"] = port + } + if let identityFile = normalizedSSHIdentityPath(sshOptions.identityFile) { + configureParams["identity_file"] = identityFile + } + if !remoteSSHOptions.isEmpty { + configureParams["ssh_options"] = remoteSSHOptions + } + if sshOptions.remoteRelayPort > 0 { + configureParams["relay_port"] = sshOptions.remoteRelayPort + configureParams["relay_id"] = relayID + configureParams["relay_token"] = relayToken + configureParams["local_socket_path"] = sshOptions.localSocketPath + } + configureParams["terminal_startup_command"] = sshStartupCommand + + cliDebugLog( + "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + + "target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + + "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" + ) + configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) + var selectParams: [String: Any] = ["workspace_id": workspaceId] + if let workspaceWindowId, !workspaceWindowId.isEmpty { + selectParams["window_id"] = workspaceWindowId + } + _ = try client.sendV2(method: "workspace.select", params: selectParams) + let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown" + cliDebugLog( + "cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" + ) + } catch { + cliDebugLog( + "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" + ) + do { + _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + } catch { + let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n" + FileHandle.standardError.write(Data(warning.utf8)) + } + throw error + } + + var payload = configuredPayload + + payload["ssh_command"] = sshCommand + payload["ssh_startup_command"] = sshStartupCommand + payload["ssh_env_overrides"] = [ + "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, + ] + payload["remote_relay_port"] = remoteRelayPort + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? workspaceId + let remote = payload["remote"] as? [String: Any] + let state = (remote?["state"] as? String) ?? "unknown" + print("OK workspace=\(workspaceHandle) target=\(sshOptions.destination) state=\(state)") + } + } + + private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { + var destination: String? + var port: Int? + var identityFile: String? + var workspaceName: String? + var sshOptions: [String] = [] + var extraArguments: [String] = [] + + var passthrough = false + var index = 0 + while index < commandArgs.count { + let arg = commandArgs[index] + if passthrough { + extraArguments.append(arg) + index += 1 + continue + } + + switch arg { + case "--": + passthrough = true + index += 1 + case "--port": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --port requires a value") + } + guard let parsed = Int(commandArgs[index + 1]), parsed > 0, parsed <= 65535 else { + throw CLIError(message: "ssh: --port must be 1-65535") + } + port = parsed + index += 2 + case "--identity": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --identity requires a path") + } + identityFile = commandArgs[index + 1] + index += 2 + case "--name": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --name requires a workspace title") + } + workspaceName = commandArgs[index + 1] + index += 2 + case "--ssh-option": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --ssh-option requires a value") + } + let value = commandArgs[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + sshOptions.append(value) + } + index += 2 + default: + if arg.hasPrefix("--") { + throw CLIError(message: "ssh: unknown flag '\(arg)'") + } + if destination == nil { + if arg.hasPrefix("-") { + throw CLIError( + message: "ssh: destination must be . Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." + ) + } + destination = arg + } else { + extraArguments.append(arg) + } + index += 1 + } + } + + guard let destination else { + throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)") + } + return SSHCommandOptions( + destination: destination, + port: port, + identityFile: identityFile, + workspaceName: workspaceName, + sshOptions: sshOptions, + extraArguments: extraArguments, + localSocketPath: localSocketPath, + remoteRelayPort: remoteRelayPort + ) + } + + private func buildSSHCommandText(_ options: SSHCommandOptions) -> String { + var parts = baseSSHArguments(options) + let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + + if options.extraArguments.isEmpty { + // No explicit remote command provided. Use RemoteCommand to bootstrap + // the relay wrapper and then hand off to an interactive shell. + if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { + parts.append("-tt") + } + if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { + parts += [ + "-o", + "RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))", + ] + } + parts.append(options.destination) + } else { + parts.append(options.destination) + parts.append(contentsOf: options.extraArguments) + } + return parts.map(shellQuote).joined(separator: " ") + } + + private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] { + var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort) + if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") { + merged.append("StrictHostKeyChecking=accept-new") + } + return merged + } + + private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { + let relayExport = remoteRelayPort > 0 + ? "export CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort)" + : nil + let remoteEnvExports = interactiveRemoteShellExports(shellFeatures: shellFeatures) + let innerCommand = [ + remoteEnvExports, + "export PATH=\"$HOME/.cmux/bin:$PATH\"", + relayExport, + "exec \"${SHELL:-/bin/zsh}\" -i", + ] + .compactMap { $0 } + .joined(separator: "; ") + + let outerCommand = [ + "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", + "case \"${CMUX_LOGIN_SHELL##*/}\" in", + " zsh|bash)", + " exec \"$CMUX_LOGIN_SHELL\" -lc \(shellQuote(innerCommand))", + " ;;", + " *)", + remoteEnvExports, + " export PATH=\"$HOME/.cmux/bin:$PATH\"", + relayExport, + " exec \"$CMUX_LOGIN_SHELL\" -i", + " ;;", + "esac", + ] + .compactMap { $0 } + .joined(separator: "; ") + + return outerCommand + } + + private func interactiveRemoteShellExports(shellFeatures: String) -> String { + let environment = ProcessInfo.processInfo.environment + let term = Self.normalizedEnvValue(environment["TERM"]) ?? "xterm-ghostty" + let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" + let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" + let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + ?? "" + let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + + var exports: [String] = [ + "export TERM=\(shellQuote(term))", + "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.joined(separator: "; ") + } + + private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { + let effectiveSSHOptions = effectiveSSHOptions( + options.sshOptions, + remoteRelayPort: options.remoteRelayPort + ) + var parts: [String] = ["ssh"] + if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") { + parts += ["-o", "SetEnv COLORTERM=truecolor"] + } + if !hasSSHOptionKey(effectiveSSHOptions, key: "SendEnv") { + parts += ["-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] + } + if let port = options.port { + parts += ["-p", String(port)] + } + if let identityFile = normalizedSSHIdentityPath(options.identityFile) { + parts += ["-i", identityFile] + } + for option in effectiveSSHOptions { + parts += ["-o", option] + } + return parts + } + + private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) { + guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return } + + var args = baseSSHArguments(options) + args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination] + let installScript = """ + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + """ + args.append(installScript) + + _ = runProcess( + executablePath: "/usr/bin/ssh", + arguments: Array(args.dropFirst()), + stdinText: terminfoSource + ) + } + + private func localXtermGhosttyTerminfoSource() -> String? { + let result = runProcess( + executablePath: "/usr/bin/infocmp", + arguments: ["-0", "-x", "xterm-ghostty"] + ) + guard result.status == 0 else { return nil } + let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + return output.isEmpty ? nil : output + } + + private func sshOptionsWithControlSocketDefaults( + _ options: [String], + remoteRelayPort: Int? = nil + ) -> [String] { + var merged: [String] = [] + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + merged.append(trimmed) + } + if !hasSSHOptionKey(merged, key: "ControlMaster") { + merged.append("ControlMaster=auto") + } + if !hasSSHOptionKey(merged, key: "ControlPersist") { + merged.append("ControlPersist=600") + } + if !hasSSHOptionKey(merged, key: "ControlPath") { + merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))") + } + return merged + } + + private func scopedGhosttyShellFeaturesValue() -> String { + let rawExisting = ProcessInfo.processInfo.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: ",") + } + + private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String { + let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty + ? "" + : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" + let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) + let script = [ + shellFeaturesBootstrap, + "CMUX_SSH_SESSION_ENDED=0", + "cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", + "trap 'cmux_ssh_session_end' EXIT HUP INT TERM", + "command \(sshCommand)", + "trap - EXIT HUP INT TERM", + "cmux_ssh_session_end", + "exec ${SHELL:-/bin/zsh} -l", + ] + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .joined(separator: "\n") + return "/bin/zsh -ilc \(shellQuote(script))" + } + + private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { + [ + "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", + "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", + "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "elif command -v cmux >/dev/null 2>&1", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "fi", + ].joined(separator: " ") + } + + private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { + guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), + let relayPort = Int(relayPortRaw), + relayPort > 0 else { + throw CLIError(message: "ssh-session-end requires --relay-port ") + } + let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + guard let workspaceRaw, + let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), + !workspaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID") + } + guard let surfaceRaw, + let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), + !surfaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID") + } + _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "relay_port": relayPort, + ]) + } + + private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { + let requestedOS = optionValue(commandArgs, name: "--os")?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestedArch = optionValue(commandArgs, name: "--arch")?.trimmingCharacters(in: .whitespacesAndNewlines) + let info = resolvedVersionInfo() + let manifest = remoteDaemonManifest() + let platform = defaultRemoteDaemonPlatform(requestedOS: requestedOS, requestedArch: requestedArch) + let cacheURL = remoteDaemonCacheURL(version: manifest?.appVersion ?? remoteDaemonVersionString(from: info), goOS: platform.goOS, goArch: platform.goArch) + let cacheExists = FileManager.default.fileExists(atPath: cacheURL.path) + let cacheSHA = cacheExists ? try? sha256Hex(forFile: cacheURL) : nil + let entry = manifest?.entry(goOS: platform.goOS, goArch: platform.goArch) + let cacheVerified = (entry != nil && cacheSHA?.lowercased() == entry?.sha256.lowercased()) + let releaseTag = manifest?.releaseTag ?? "unknown" + let assetName = entry?.assetName ?? "unknown" + let downloadURL = entry?.downloadURL ?? "unknown" + let checksumsAssetName = manifest?.checksumsAssetName ?? "unknown" + let checksumsURL = manifest?.checksumsURL ?? "unknown" + let downloadCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(assetName)" + let downloadChecksumsCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(checksumsAssetName)" + let checksumVerifyCommand = "shasum -a 256 -c \(checksumsAssetName) --ignore-missing" + let signerWorkflow = releaseTag == "nightly" + ? "manaflow-ai/cmux/.github/workflows/nightly.yml" + : "manaflow-ai/cmux/.github/workflows/release.yml" + let verifyCommand = "gh attestation verify ./\(assetName) --repo manaflow-ai/cmux --signer-workflow \(signerWorkflow)" + + let payload: [String: Any] = [ + "app_version": remoteDaemonVersionString(from: info), + "build": info["CFBundleVersion"] ?? NSNull(), + "commit": info["CMUXCommit"] ?? NSNull(), + "manifest_present": manifest != nil, + "release_tag": releaseTag, + "release_url": manifest?.releaseURL ?? NSNull(), + "target_goos": platform.goOS, + "target_goarch": platform.goArch, + "asset_name": assetName, + "download_url": downloadURL, + "checksums_asset_name": checksumsAssetName, + "checksums_url": checksumsURL, + "expected_sha256": entry?.sha256 ?? NSNull(), + "cache_path": cacheURL.path, + "cache_exists": cacheExists, + "cache_sha256": cacheSHA ?? NSNull(), + "cache_verified": cacheVerified, + "dev_local_build_fallback": ProcessInfo.processInfo.environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1", + "download_command": downloadCommand, + "download_checksums_command": downloadChecksumsCommand, + "checksum_verify_command": checksumVerifyCommand, + "attestation_verify_command": verifyCommand, + ] + + if jsonOutput { + print(jsonString(payload)) + return + } + + print("app version: \(payload["app_version"] as? String ?? "unknown")") + if let build = payload["build"] as? String { + print("build: \(build)") + } + if let commit = payload["commit"] as? String { + print("commit: \(commit)") + } + print("manifest: \(manifest != nil ? "present" : "missing")") + print("platform: \(platform.goOS)/\(platform.goArch)") + print("release: \(releaseTag)") + print("asset: \(assetName)") + print("download url: \(downloadURL)") + print("checksums asset: \(checksumsAssetName)") + print("checksums: \(checksumsURL)") + if let expectedSHA = entry?.sha256 { + print("expected sha256: \(expectedSHA)") + } + print("cache: \(cacheURL.path)") + print("cache exists: \(cacheExists ? "yes" : "no")") + if let cacheSHA { + print("cache sha256: \(cacheSHA)") + } + print("cache verified: \(cacheVerified ? "yes" : "no")") + print("download command: \(downloadCommand)") + print("download checksums: \(downloadChecksumsCommand)") + print("verify checksum: \(checksumVerifyCommand)") + print("attestation verify: \(verifyCommand)") + if manifest == nil { + print("note: this build has no embedded remote daemon manifest. Set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 only for dev builds.") + } + } + + private func defaultRemoteDaemonPlatform(requestedOS: String?, requestedArch: String?) -> (goOS: String, goArch: String) { + let normalizedOS = requestedOS? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let normalizedArch = requestedArch? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let goOS = (normalizedOS?.isEmpty == false ? normalizedOS! : hostGoOS()) + let goArch = (normalizedArch?.isEmpty == false ? normalizedArch! : hostGoArch()) + return (goOS, goArch) + } + + private func hostGoOS() -> String { +#if os(macOS) + return "darwin" +#elseif os(Linux) + return "linux" +#else + return "unknown" +#endif + } + + private func hostGoArch() -> String { +#if arch(arm64) + return "arm64" +#elseif arch(x86_64) + return "amd64" +#else + return "unknown" +#endif + } + + private func remoteDaemonManifest() -> RemoteDaemonManifest? { + for plistURL in candidateInfoPlistURLs() { + guard let raw = NSDictionary(contentsOf: plistURL) as? [String: Any], + let rawManifest = raw["CMUXRemoteDaemonManifestJSON"] as? String, + let data = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8), + let manifest = try? JSONDecoder().decode(RemoteDaemonManifest.self, from: data) else { + continue + } + return manifest + } + return nil + } + + private func remoteDaemonVersionString(from info: [String: String]) -> String { + info["CFBundleShortVersionString"] ?? "dev" + } + + private func remoteDaemonCacheURL(version: String, goOS: String, goArch: String) -> URL { + let root: URL + do { + root = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } catch { + return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + return root + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() + if token == loweredKey { + return true + } + } + return false + } + + private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { + if let remoteRelayPort, remoteRelayPort > 0 { + return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C" + } + return "/tmp/cmux-ssh-\(getuid())-%C" + } + + private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? { + guard let rawPath else { return nil } + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("~") { + let expanded = (trimmed as NSString).expandingTildeInPath + if !expanded.isEmpty { + return expanded + } + } + return trimmed + } + + private 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: "'\"'\"'") + "'" + } + + private func sshOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + private func cliDebugLog(_ message: @autoclosure () -> String) { +#if DEBUG + let trimmedExplicit = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let path: String? = { + if let trimmedExplicit, !trimmedExplicit.isEmpty { + return trimmedExplicit + } + guard let marker = try? String(contentsOfFile: "/tmp/cmux-last-debug-log-path", encoding: .utf8) else { + return nil + } + let trimmedMarker = marker.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedMarker.isEmpty ? nil : trimmedMarker + }() + guard let path else { return } + let timestamp = ISO8601DateFormatter().string(from: Date()) + let line = "\(timestamp) [cmux-cli] \(message())\n" + guard let data = line.data(using: .utf8) else { return } + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + guard let handle = FileHandle(forWritingAtPath: path) else { return } + defer { try? handle.close() } + do { + try handle.seekToEnd() + try handle.write(contentsOf: data) + } catch { + return + } +#endif + } + + private func runProcess( + executablePath: String, + arguments: [String], + stdinText: String? = nil + ) -> (status: Int32, stdout: String, stderr: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdinPipe: Pipe? + if stdinText != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } else { + stdinPipe = nil + } + + do { + try process.run() + } catch { + return (1, "", String(describing: error)) + } + + if let stdinText, let stdinPipe { + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + } + + process.waitUntilExit() + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, stdout, stderr) + } private func runBrowserCommand( commandArgs: [String], @@ -2941,7 +3816,6 @@ struct CMUXCLI { return lines.joined(separator: "\n") } - func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } @@ -3126,13 +4000,7 @@ struct CMUXCLI { throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) - let fallback: String - if let value = payload["value"] { - fallback = displayBrowserValue(value) - } else { - fallback = "OK" - } - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -3865,8 +4733,7 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) - let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK" - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -3880,8 +4747,7 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) - let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK" - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -4457,7 +5323,7 @@ struct CMUXCLI { new-terminal-right | new-browser-right reload | duplicate pin | unpin - mark-read | mark-unread + mark-unread Flags: --action Action name (required if not positional) @@ -4476,18 +5342,21 @@ struct CMUXCLI { return """ Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] - Rename a tab (surface). Defaults to the focused tab, using: - 1) explicit --tab/--surface - 2) $CMUX_TAB_ID / $CMUX_SURFACE_ID - 3) focused tab in the resolved workspace context + Compatibility alias for tab-action rename. + + Resolution order for target tab: + 1) --tab + 2) --surface + 3) $CMUX_TAB_ID / $CMUX_SURFACE_ID + 4) currently focused tab (optionally within --workspace) Flags: --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) - --tab <id|ref> Target tab (accepts tab:<n> or surface:<n>) + --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) --surface <id|ref> Alias for --tab - --title <text> New title (or pass trailing title) + --title <text> Explicit title (or use trailing positional title) - Example: + Examples: cmux rename-tab "build logs" cmux rename-tab --tab tab:3 "staging server" cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" @@ -4516,6 +5385,35 @@ struct CMUXCLI { Example: cmux list-workspaces """ + case "ssh": + return """ + Usage: cmux ssh <destination> [flags] [-- <remote-command-args>] + + Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. + cmux will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. + + Flags: + --name <title> Optional workspace title + --port <n> SSH port + --identity <path> SSH identity file path + --ssh-option <opt> Extra SSH -o option (repeatable) + + Example: + cmux ssh dev@my-host + cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 + cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no + """ + case "remote-daemon-status": + return """ + Usage: cmux remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] + + Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state, + and the GitHub attestation verification command for a target platform. + + Example: + cmux remote-daemon-status + cmux remote-daemon-status --os linux --arch arm64 + """ case "new-split": return """ Usage: cmux new-split <left|right|up|down> [flags] @@ -5330,20 +6228,6 @@ struct CMUXCLI { return true } - /// Escape and quote a string for safe embedding in a v1 socket command. - /// The socket tokenizer treats `\` and `"` as special inside quoted strings, - /// so both must be escaped before wrapping in double quotes. Newlines and - /// carriage returns must also be escaped since the socket protocol uses - /// newline as the message terminator. - private func socketQuote(_ s: String) -> String { - let escaped = s - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - return "\"\(escaped)\"" - } - private func parseOption(_ args: [String], name: String) -> (String?, [String]) { var remaining: [String] = [] var value: String? @@ -6580,7 +7464,11 @@ struct CMUXCLI { do { try client.connect() - try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) defer { client.close() } let payload = try client.sendV2(method: "system.identify") @@ -7621,7 +8509,7 @@ struct CMUXCLI { let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") print(response) } else { print("OK") @@ -7680,7 +8568,7 @@ struct CMUXCLI { ) } - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -7915,8 +8803,7 @@ struct CMUXCLI { ] let session = firstString(in: object, keys: ["session_id", "sessionId"]) let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input" - let dedupedMessage = dedupeBranchContextLines(message) - let normalizedMessage = normalizedSingleLine(dedupedMessage) + let normalizedMessage = normalizedSingleLine(message) let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) @@ -7949,42 +8836,6 @@ struct CMUXCLI { return ("Attention", body) } - private func dedupeBranchContextLines(_ value: String) -> String { - let lines = value.components(separatedBy: .newlines) - guard lines.count > 1 else { return value } - - var lastIndexByPath: [String: Int] = [:] - for (index, line) in lines.enumerated() { - guard let path = branchContextPath(from: line) else { continue } - lastIndexByPath[path] = index - } - guard !lastIndexByPath.isEmpty else { return value } - - let deduped = lines.enumerated().compactMap { index, line -> String? in - guard let path = branchContextPath(from: line) else { return line } - return lastIndexByPath[path] == index ? line : nil - } - return deduped.joined(separator: "\n") - } - - private func branchContextPath(from line: String) -> String? { - let parts = line.split(separator: "•", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2 else { return nil } - - let branch = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let path = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard !branch.isEmpty, !path.isEmpty else { return nil } - - let looksLikePath = path.hasPrefix("/") || path.hasPrefix("~") || path.hasPrefix(".") || path.contains("/") - guard looksLikePath else { return nil } - - let trimmedQuotes = path.trimmingCharacters(in: CharacterSet(charactersIn: "`'\"")) - let expanded = NSString(string: trimmedQuotes).expandingTildeInPath - let standardized = NSString(string: expanded).standardizingPath - let normalized = standardized.trimmingCharacters(in: .whitespacesAndNewlines) - return normalized.isEmpty ? nil : normalized - } - private func firstString(in object: [String: Any], keys: [String]) -> String? { for key in keys { guard let value = object[key] else { continue } @@ -8305,8 +9156,6 @@ struct CMUXCLI { appendIfExisting(current.appendingPathComponent("Info.plist")) } - // Local dev fallback: resolve version from the repo's app Info.plist - // when running a standalone cmux-cli binary from build/Debug. let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), @@ -8394,12 +9243,12 @@ struct CMUXCLI { --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. Commands: - version welcome shortcuts feedback [--email <email> --body <text> [--image <path> ...]] claude-teams [claude-args...] ping + version capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows @@ -8412,6 +9261,8 @@ struct CMUXCLI { workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] list-workspaces new-workspace [--cwd <path>] [--command <text>] + ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [-- <remote-command-args>] + remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] @@ -8444,18 +9295,6 @@ struct CMUXCLI { list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] - - # sidebar metadata commands - set-status <key> <value> [--icon <name>] [--color <#hex>] [--workspace <id|ref>] - clear-status <key> [--workspace <id|ref>] - list-status [--workspace <id|ref>] - set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>] - clear-progress [--workspace <id|ref>] - log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message> - clear-log [--workspace <id|ref>] - list-log [--limit <n>] [--workspace <id|ref>] - sidebar-state [--workspace <id|ref>] - set-app-focus <active|inactive|clear> simulate-app-active diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b7c73485..137f9f92 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -25452,6 +25452,57 @@ } } }, + "contextMenu.copyError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "contextMenu.copyErrors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Errors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "contextMenu.copySshError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy SSH Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSHエラーをコピー" + } + } + } + }, "contextMenu.moveDown": { "extractionState": "manual", "localizations": { @@ -42792,6 +42843,40 @@ } } }, + "settings.app.showSSH": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show SSH in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにSSHを表示" + } + } + } + }, + "settings.app.showSSH.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display the SSH target for remote workspaces in its own row." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートワークスペースのSSHターゲットを専用の行に表示します。" + } + } + } + }, "settings.app.showPorts.subtitle": { "extractionState": "manual", "localizations": { @@ -61336,6 +61421,227 @@ } } }, + "sidebar.remote.badge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + } + } + }, + "remote.status.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続済み" + } + } + } + }, + "remote.status.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続中" + } + } + } + }, + "remote.status.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切断済み" + } + } + } + }, + "remote.status.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + } + } + }, + "sidebar.remote.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + } + } + }, + "sidebar.remote.subtitleFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH ワークスペース" + } + } + } + }, + "sidebar.remote.help.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH connected to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ に接続済み" + } + } + } + }, + "sidebar.remote.help.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH connecting to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ に接続中" + } + } + } + }, + "sidebar.remote.help.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の SSH エラー" + } + } + } + }, + "sidebar.remote.help.errorWithDetail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error for %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の SSH エラー: %@" + } + } + } + }, + "sidebar.remote.help.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH disconnected from %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ から切断済み" + } + } + } + }, + "sidebar.remote.help.targetFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートホスト" + } + } + } + }, "sidebar.workspace.moveDownAction": { "extractionState": "manual", "localizations": { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 821f3d19..45a99aaf 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -57,6 +57,102 @@ typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 +typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 +typeset -g _CMUX_WINCH_GUARD_INSTALLED=0 + +_cmux_ensure_ghostty_preexec_strips_both_marks() { + local fn_name="$1" + (( $+functions[$fn_name] )) || return 0 + + local old_strip new_strip updated + old_strip=$'PS1=${PS1//$\'%{\\e]133;A;cl=line\\a%}\'}' + new_strip=$'PS1=${PS1//$\'%{\\e]133;A;redraw=last;cl=line\\a%}\'}' + updated="${functions[$fn_name]}" + + if [[ "$updated" == *"$new_strip"* && "$updated" != *"$old_strip"* ]]; then + updated="${updated/$new_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + return 0 + fi + if [[ "$updated" == *"$old_strip"* && "$updated" != *"$new_strip"* ]]; then + updated="${updated/$old_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi +} + +_cmux_patch_ghostty_semantic_redraw() { + (( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) && return 0 + + local old_frag new_frag + old_frag='133;A;cl=line' + new_frag='133;A;redraw=last;cl=line' + + # Patch both deferred and live hook definitions, depending on init timing. + if (( $+functions[_ghostty_deferred_init] )); then + functions[_ghostty_deferred_init]="${functions[_ghostty_deferred_init]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_precmd] )); then + functions[_ghostty_precmd]="${functions[_ghostty_precmd]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_preexec] )); then + functions[_ghostty_preexec]="${functions[_ghostty_preexec]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + + # Keep legacy + redraw-aware strip lines so prompts created before patching + # are still cleared by preexec. + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_deferred_init + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_preexec +} +_cmux_patch_ghostty_semantic_redraw + +_cmux_prompt_wrap_guard() { + local cmd_start="$1" + local pwd="$2" + [[ -n "$cmd_start" && "$cmd_start" != 0 ]] || return 0 + + local cols="${COLUMNS:-0}" + (( cols > 0 )) || return 0 + + local budget=$(( cols - 24 )) + (( budget < 20 )) && budget=20 + (( ${#pwd} >= budget )) || return 0 + + # Keep a spacer line between command output and a wrapped prompt so + # resize-driven prompt redraw cannot overwrite the command tail. + builtin print -r -- "" +} + +_cmux_install_winch_guard() { + (( _CMUX_WINCH_GUARD_INSTALLED )) && return 0 + + # Respect user-defined WINCH handlers (function-based or trap-based). + local existing_winch_trap="" + existing_winch_trap="$(trap -p WINCH 2>/dev/null || true)" + if (( $+functions[TRAPWINCH] )) || [[ -n "$existing_winch_trap" ]]; then + _CMUX_WINCH_GUARD_INSTALLED=1 + return 0 + fi + + TRAPWINCH() { + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + # Keep a spacer line so prompt redraw during resize cannot clobber the + # tail of command output that was rendered immediately above the prompt. + builtin print -r -- "" + return 0 + } + + _CMUX_WINCH_GUARD_INSTALLED=1 +} +_cmux_install_winch_guard _cmux_git_resolve_head_path() { # Resolve the HEAD file path without invoking git (fast; works for worktrees). @@ -385,6 +481,9 @@ _cmux_precmd() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 + # Handle cases where Ghostty integration initializes after this file. + _cmux_patch_ghostty_semantic_redraw + if [[ -z "$_CMUX_TTY_NAME" ]]; then local t t="$(tty 2>/dev/null || true)" @@ -399,6 +498,8 @@ _cmux_precmd() { local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + _cmux_prompt_wrap_guard "$cmd_start" "$pwd" + # Post-wake socket writes can occasionally leave a probe process wedged. # If one probe is stale, clear the guard so fresh async probes can resume. if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4f3c0725..29fdf434 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -74,6 +74,47 @@ func cmuxAccentColor() -> Color { Color(nsColor: cmuxAccentNSColor()) } +struct SidebarRemoteErrorCopyEntry: Equatable { + let workspaceTitle: String + let target: String + let detail: String +} + +enum SidebarRemoteErrorCopySupport { + static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1 { + return String(localized: "contextMenu.copyError", defaultValue: "Copy Error") + } + return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors") + } + + static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1, let entry = entries.first { + return "SSH error (\(entry.target)): \(entry.detail)" + } + + return entries.enumerated().map { index, entry in + "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" + }.joined(separator: "\n") + } + + static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("SSH error") else { return nil } + + if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) { + return (String(match.1), String(match.2)) + } + if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) { + guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil } + return (fallbackTarget, String(match.1)) + } + return nil + } +} + func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { cmuxAccentNSColor(for: colorScheme) } @@ -1929,6 +1970,7 @@ struct ContentView: View { lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) + .frame(maxHeight: .infinity, alignment: .topLeading) } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -7296,6 +7338,7 @@ struct VerticalTabsSidebar: View { #endif draggedTabId = nil } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -9489,6 +9532,7 @@ private struct TabItemView: View, Equatable { @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -9591,12 +9635,84 @@ private struct TabItemView: View, Equatable { ) } + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions else { return nil } + let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTarget, !trimmedTarget.isEmpty { + return trimmedTarget + } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + } + + private var copyableSidebarSSHError: String? { + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let target = tab.remoteDisplayTarget ?? "unknown" + return "SSH error (\(target)): \(trimmedDetail)" + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + return statusValue + } + return nil + } + + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "sidebar.remote.badge", defaultValue: "SSH")) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(activeSecondaryColor(0.62)) + .textCase(.uppercase) + + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + var body: some View { let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest) ? tab.sidebarOrderedPanelIds() : nil @@ -9700,7 +9816,7 @@ private struct TabItemView: View, Equatable { .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } - if let subtitle = latestNotificationSubtitle { + if let subtitle = effectiveSubtitle { Text(subtitle) .font(.system(size: 10)) .foregroundColor(activeSecondaryColor(0.8)) @@ -9709,6 +9825,8 @@ private struct TabItemView: View, Equatable { .multilineTextAlignment(.leading) } + remoteWorkspaceSection + if sidebarShowMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() @@ -9968,6 +10086,16 @@ private struct TabItemView: View, Equatable { let isMulti = targetIds.count > 1 let tabColorPalette = WorkspaceTabColorSettings.palette() let shouldPin = !tab.isPinned + let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } + let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) let pinLabel = shouldPin ? contextMenuLabel( multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), @@ -10017,6 +10145,24 @@ private struct TabItemView: View, Equatable { } } + if !remoteTargetWorkspaces.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.reconnectRemoteConnection() + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) + + Button(disconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) + } + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { if tab.customColor != nil { Button { @@ -10049,6 +10195,12 @@ private struct TabItemView: View, Equatable { } } + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + Divider() Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { @@ -10324,6 +10476,62 @@ private struct TabItemView: View, Equatable { } } + private var remoteStateHelpText: String { + let target = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch tab.remoteConnectionState { + case .connected: + return String( + format: String( + localized: "sidebar.remote.help.connected", + defaultValue: "SSH connected to %@" + ), + locale: .current, + target + ) + case .connecting: + return String( + format: String( + localized: "sidebar.remote.help.connecting", + defaultValue: "SSH connecting to %@" + ), + locale: .current, + target + ) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "SSH error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return String( + format: String( + localized: "sidebar.remote.help.error", + defaultValue: "SSH error for %@" + ), + locale: .current, + target + ) + case .disconnected: + return String( + format: String( + localized: "sidebar.remote.help.disconnected", + defaultValue: "SSH disconnected from %@" + ), + locale: .current, + target + ) + } + } private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { guard let app = AppDelegate.shared else { return } let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } @@ -10525,6 +10733,18 @@ private struct TabItemView: View, Equatable { } } + private func shortenPath(_ path: String, home: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == home { + return "~" + } + if trimmed.hasPrefix(home + "/") { + return "~" + trimmed.dropFirst(home.count) + } + return trimmed + } + private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 65e50eaa..5b4db687 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2333,7 +2333,8 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? - private let additionalEnvironment: [String: String] + private let initialCommand: String? + private let initialEnvironmentOverrides: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -2401,6 +2402,8 @@ final class TerminalSurface: Identifiable, ObservableObject { context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, workingDirectory: String? = nil, + initialCommand: String? = nil, + initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { self.id = UUID() @@ -2408,7 +2411,12 @@ final class TerminalSurface: Identifiable, ObservableObject { self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) - self.additionalEnvironment = additionalEnvironment + let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) + self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil + self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment( + base: additionalEnvironment, + overrides: initialEnvironmentOverrides + ) // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. @@ -2426,6 +2434,25 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceView.tabId = newTabId } + private static func mergedNormalizedEnvironment( + base: [String: String], + overrides: [String: String] + ) -> [String: String] { + var merged: [String: String] = [:] + merged.reserveCapacity(base.count + overrides.count) + for (rawKey, value) in base { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + return merged + } + func isAttached(to view: GhosttyNSView) -> Bool { attachedView === view && surface != nil } @@ -2784,6 +2811,10 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path, + !bundledCLIPath.isEmpty { + env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath + } if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { env["CMUX_BUNDLE_ID"] = bundleId } @@ -2851,8 +2882,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - if !additionalEnvironment.isEmpty { - for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty { + if !initialEnvironmentOverrides.isEmpty { + for (key, value) in initialEnvironmentOverrides { env[key] = value } } @@ -2880,15 +2911,31 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - if let workingDirectory, !workingDirectory.isEmpty { - workingDirectory.withCString { cWorkingDir in - surfaceConfig.working_directory = cWorkingDir + let createWithCommandAndWorkingDirectory = { [self] in + if let initialCommand, !initialCommand.isEmpty { + initialCommand.withCString { cCommand in + surfaceConfig.command = cCommand + if let workingDirectory, !workingDirectory.isEmpty { + workingDirectory.withCString { cWorkingDir in + surfaceConfig.working_directory = cWorkingDir + createSurface() + } + } else { + createSurface() + } + } + } else if let workingDirectory, !workingDirectory.isEmpty { + workingDirectory.withCString { cWorkingDir in + surfaceConfig.working_directory = cWorkingDir + createSurface() + } + } else { createSurface() } - } else { - createSurface() } + createWithCommandAndWorkingDirectory() + if surface == nil { surfaceCallbackContext?.release() surfaceCallbackContext = nil @@ -3038,6 +3085,7 @@ final class TerminalSurface: Identifiable, ObservableObject { dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, + let surface, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { @@ -5637,6 +5685,7 @@ final class GhosttySurfaceScrollView: NSView { private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 + private var pendingAutomaticFirstResponderApply = false // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. /// Tracks whether keyboard focus should go to the search field or the terminal @@ -6244,7 +6293,7 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") #endif - self.applyFirstResponderIfNeeded() + self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey") }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, @@ -6267,7 +6316,9 @@ final class GhosttySurfaceScrollView: NSView { #endif } }) - if window.isKeyWindow { applyFirstResponderIfNeeded() } + if window.isKeyWindow { + scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow") + } } func attachSurface(_ terminalSurface: TerminalSurface) { @@ -6684,7 +6735,7 @@ final class GhosttySurfaceScrollView: NSView { window.makeFirstResponder(nil) } } else { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") } } @@ -6711,7 +6762,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif if active { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setActive") } else { resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } @@ -7073,6 +7124,20 @@ final class GhosttySurfaceScrollView: NSView { return fr === surfaceView || fr.isDescendant(of: surfaceView) } + private func scheduleAutomaticFirstResponderApply(reason: String) { + guard !pendingAutomaticFirstResponderApply else { return } + pendingAutomaticFirstResponderApply = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pendingAutomaticFirstResponderApply = false +#if DEBUG + let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)") +#endif + self.applyFirstResponderIfNeeded() + } + } + private func reassertTerminalSurfaceFocus(reason: String) { guard let terminalSurface = surfaceView.terminalSurface else { return } #if DEBUG @@ -7648,35 +7713,15 @@ final class GhosttySurfaceScrollView: NSView { /// regions such as scrollbar space) when telling libghostty the terminal size. @discardableResult private func synchronizeCoreSurface() -> Bool { - let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) + // Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight + // over terminal columns during split churn. The width can flap by one scrollbar gutter, + // which redraws the shell prompt multiple times on Cmd+D. Favor stable columns. + let width = max(0, scrollView.contentSize.width) let height = surfaceView.frame.height guard width > 0, height > 0 else { return false } return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } - /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. - private func overlayScrollbarInsetWidth() -> CGFloat { - guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 } - - // If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction. - let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width) - if alreadyReserved > 0.5 { return 0 } - - let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay) - guard let verticalScroller = scrollView.verticalScroller else { return fallback } - - let measuredWidth = verticalScroller.frame.width - if measuredWidth > 0 { - return max(measuredWidth, fallback) - } - - let controlSizeWidth = NSScroller.scrollerWidth( - for: verticalScroller.controlSize, - scrollerStyle: .overlay - ) - return max(controlSizeWidth, fallback) - } - private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, @@ -8190,6 +8235,12 @@ struct GhosttyTerminalView: NSViewRepresentable { } let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() + func portalBindingStillLive() -> Bool { + terminalSurface.canAcceptPortalBinding( + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + } let forwardedDropZone = isVisibleInUI ? paneDropZone : nil #if DEBUG if coordinator.lastPaneDropZone != paneDropZone { @@ -8228,6 +8279,7 @@ struct GhosttyTerminalView: NSViewRepresentable { reason: "didMoveToWindow" ) else { return } guard host.window != nil else { return } + guard portalBindingStillLive() else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, @@ -8251,6 +8303,7 @@ struct GhosttyTerminalView: NSViewRepresentable { bounds: host.bounds, reason: "geometryChanged" ) else { return } + guard portalBindingStillLive() else { return } let hostId = ObjectIdentifier(host) if host.window != nil, (coordinator.lastBoundHostId != hostId || @@ -8280,6 +8333,7 @@ struct GhosttyTerminalView: NSViewRepresentable { } if host.window != nil, hostOwnsPortalNow { + let portalBindingLive = portalBindingStillLive() let hostId = ObjectIdentifier(host) let geometryRevision = host.geometryRevision let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) @@ -8290,7 +8344,7 @@ struct GhosttyTerminalView: NSViewRepresentable { previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority - if shouldBindNow { + if portalBindingLive && shouldBindNow { #if DEBUG if portalEntryMissing { dlog( @@ -8310,11 +8364,11 @@ struct GhosttyTerminalView: NSViewRepresentable { ) coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { TerminalWindowPortalRegistry.synchronizeForAnchor(host) coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - } else if hostOwnsPortalNow { + } else if hostOwnsPortalNow, portalBindingStillLive() { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. @@ -8344,7 +8398,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isBoundToCurrentHost: isBoundToCurrentHost ) - if shouldApplyImmediateHostedState { + if portalBindingStillLive() && shouldApplyImmediateHostedState { hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) } else { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b9f1ca1b..b2927a8a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,6 +3,19 @@ import Combine import WebKit import AppKit import Bonsplit +import Network + +struct BrowserProxyEndpoint: Equatable { + let host: String + let port: Int +} + +struct BrowserRemoteWorkspaceStatus: Equatable { + let target: String + let connectionState: WorkspaceRemoteConnectionState + let heartbeatCount: Int + let lastHeartbeatAt: Date? +} enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { @@ -1255,6 +1268,14 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { + private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + private static let remoteLoopbackHosts: Set<String> = [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + ] + /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() @@ -1773,6 +1794,8 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 + private var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? private var developerToolsTransitionTargetVisible: Bool? @@ -2015,15 +2038,24 @@ final class BrowserPanel: Panel, ObservableObject { return instanceID == webViewInstanceID } - init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { + init( + workspaceId: UUID, + initialURL: URL? = nil, + bypassInsecureHTTPHostOnce: String? = nil, + proxyEndpoint: BrowserProxyEndpoint? = nil, + isRemoteWorkspace: Bool = false + ) { self.id = UUID() self.workspaceId = workspaceId self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") + self.remoteProxyEndpoint = proxyEndpoint self.browserThemeMode = BrowserThemeSettings.mode() let webView = Self.makeWebView() self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } + let _ = isRemoteWorkspace + applyRemoteProxyConfigurationIfAvailable() // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() @@ -2103,6 +2135,40 @@ final class BrowserPanel: Panel, ObservableObject { } } + func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + guard remoteProxyEndpoint != endpoint else { return } + remoteProxyEndpoint = endpoint + applyRemoteProxyConfigurationIfAvailable() + } + + func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { + guard remoteWorkspaceStatus != status else { return } + remoteWorkspaceStatus = status + } + + private func applyRemoteProxyConfigurationIfAvailable() { + guard #available(macOS 14.0, *) else { return } + + let store = webView.configuration.websiteDataStore + guard let endpoint = remoteProxyEndpoint else { + store.proxyConfigurations = [] + return + } + + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, + endpoint.port > 0 && endpoint.port <= 65535, + let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { + store.proxyConfigurations = [] + return + } + + let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort) + let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) + let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) + store.proxyConfigurations = [socks, connect] + } + private func beginDownloadActivity() { let apply = { self.activeDownloadCount += 1 @@ -2599,6 +2665,7 @@ final class BrowserPanel: Panel, ObservableObject { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } + let effectiveRequest = remoteProxyPreparedNavigationRequest(from: request) // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -2606,7 +2673,35 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - browserLoadRequest(request, in: webView) + browserLoadRequest(effectiveRequest, in: webView) + } + + private func remoteProxyPreparedNavigationRequest(from request: URLRequest) -> URLRequest { + guard remoteProxyEndpoint != nil else { return request } + guard let url = request.url else { return request } + guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request } + + var rewrittenRequest = request + rewrittenRequest.url = rewrittenURL +#if DEBUG + dlog( + "browser.remoteProxy.rewrite " + + "panel=\(id.uuidString.prefix(5)) " + + "from=\(url.absoluteString) " + + "to=\(rewrittenURL.absoluteString)" + ) +#endif + return rewrittenRequest + } + + private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } + guard remoteLoopbackHosts.contains(host) else { return nil } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = remoteLoopbackProxyAliasHost + return components?.url } /// Navigate with smart URL/search detection @@ -3481,6 +3576,16 @@ extension BrowserPanel { applyPageZoom(1.0) } + func currentPageZoomFactor() -> CGFloat { + webView.pageZoom + } + + @discardableResult + func setPageZoomFactor(_ pageZoom: CGFloat) -> Bool { + let clamped = max(minPageZoom, min(maxPageZoom, pageZoom)) + return applyPageZoom(clamped) + } + /// Take a snapshot of the web view func takeSnapshot(completion: @escaping (NSImage?) -> Void) { let config = WKSnapshotConfiguration() @@ -4295,6 +4400,13 @@ extension BrowserPanel { return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" } + func hideBrowserPortalView(source: String) { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: source + ) + } + } #endif diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index f9d197a3..43a5f32b 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -84,20 +84,45 @@ final class TerminalPanel: Panel, ObservableObject { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, - additionalEnvironment: [String: String] = [:], - portOrdinal: Int = 0 + portOrdinal: Int = 0, + initialCommand: String? = nil, + initialEnvironmentOverrides: [String: String] = [:], + additionalEnvironment: [String: String] = [:] ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, workingDirectory: workingDirectory, - additionalEnvironment: additionalEnvironment + initialCommand: initialCommand, + initialEnvironmentOverrides: Self.mergedNormalizedEnvironment( + base: additionalEnvironment, + overrides: initialEnvironmentOverrides + ) ) surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) } + private static func mergedNormalizedEnvironment( + base: [String: String], + overrides: [String: String] + ) -> [String: String] { + var merged: [String: String] = [:] + merged.reserveCapacity(base.count + overrides.count) + for (rawKey, value) in base { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + return merged + } + func updateTitle(_ newTitle: String) { let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty && title != trimmed { diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 6a12a955..efe8cfa8 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -406,6 +406,18 @@ struct SocketControlSettings { ) -> String { let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild) + if let taggedDebugPath = taggedDebugSocketPath( + bundleIdentifier: bundleIdentifier, + environment: environment + ) { + if isTruthy(environment[allowSocketPathOverrideKey]), + let override = environment["CMUX_SOCKET_PATH"], + !override.isEmpty { + return override + } + return taggedDebugPath + } + guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else { return fallback } @@ -422,6 +434,9 @@ struct SocketControlSettings { } static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String { + if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) { + return taggedDebugPath + } if bundleIdentifier == "com.cmuxterm.app.nightly" { return "/tmp/cmux-nightly.sock" } @@ -454,6 +469,37 @@ struct SocketControlSettings { || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") } + static func taggedDebugSocketPath( + bundleIdentifier: String?, + environment: [String: String] + ) -> String? { + let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") { + let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1)) + let slug = suffix + .replacingOccurrences(of: ".", with: "-") + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + if !slug.isEmpty { + return "/tmp/cmux-debug-\(slug).sock" + } + } + + let tag = launchTag(environment: environment)? + .lowercased() + .replacingOccurrences(of: ".", with: "-") + .replacingOccurrences(of: "_", with: "-") + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + + guard bundleId == baseDebugBundleIdentifier, + let tag, + !tag.isEmpty else { + return nil + } + return "/tmp/cmux-debug-\(tag).sock" + } + static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier == "com.cmuxterm.app.staging" diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 764b15ce..2455e8d5 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -30,11 +30,20 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { var description: String { switch self { case .top: - return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.") + return String( + localized: "workspace.placement.top.description", + defaultValue: "Insert new workspaces at the top of the list." + ) case .afterCurrent: - return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.") + return String( + localized: "workspace.placement.afterCurrent.description", + defaultValue: "Insert new workspaces directly after the active workspace." + ) case .end: - return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.") + return String( + localized: "workspace.placement.end.description", + defaultValue: "Append new workspaces to the bottom of the list." + ) } } } @@ -72,9 +81,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { var displayName: String { switch self { case .leftRail: - return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail") + return "Left Rail" case .solidFill: - return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill") + return "Solid Fill" } } } @@ -732,36 +741,25 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - if selectedTerminalPanel?.searchState != nil { return true } - if focusedBrowserPanel?.searchState != nil { return true } - return false + selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil } var canUseSelectionForFind: Bool { - if focusedBrowserPanel != nil { return false } - return selectedTerminalPanel?.hasSelection() == true + selectedTerminalPanel?.hasSelection() == true } func startSearch() { - if let browser = focusedBrowserPanel { - browser.startFind() + if let panel = selectedTerminalPanel { + if panel.searchState == nil { + panel.searchState = TerminalSurface.SearchState() + } + NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) + NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) + _ = panel.performBindingAction("start_search") return } - guard let panel = selectedTerminalPanel else { -#if DEBUG - dlog("find.startSearch SKIPPED no selectedTerminalPanel") -#endif - return - } - let wasNil = panel.searchState == nil - if wasNil { - panel.searchState = TerminalSurface.SearchState() - } -#if DEBUG - dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))") -#endif - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("start_search") + + focusedBrowserPanel?.startFind() } func searchSelection() { @@ -769,27 +767,27 @@ class TabManager: ObservableObject { if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } -#if DEBUG - dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))") -#endif + NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("search_selection") } func findNext() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findNext() + if let panel = selectedTerminalPanel { + _ = panel.performBindingAction("search:next") return } - _ = selectedTerminalPanel?.performBindingAction("search:next") + + focusedBrowserPanel?.findNext() } func findPrevious() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findPrevious() + if let panel = selectedTerminalPanel { + _ = panel.performBindingAction("search:previous") return } - _ = selectedTerminalPanel?.performBindingAction("search:previous") + + focusedBrowserPanel?.findPrevious() } @discardableResult @@ -799,27 +797,26 @@ class TabManager: ObservableObject { } func hideFind() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.hideFind() + if let panel = selectedTerminalPanel { + panel.searchState = nil return } -#if DEBUG - dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")") -#endif - selectedTerminalPanel?.searchState = nil + + focusedBrowserPanel?.hideFind() } @discardableResult func addWorkspace( workingDirectory overrideWorkingDirectory: String? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:], select: Bool = true, eagerLoadTerminal: Bool = false, placementOverride: NewWorkspacePlacement? = nil, autoWelcomeIfNeeded: Bool = true ) -> Workspace { sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) - let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) - let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab() + let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 @@ -827,7 +824,9 @@ class TabManager: ObservableObject { title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal, - configTemplate: inheritedConfig + configTemplate: inheritedConfig, + initialTerminalCommand: initialTerminalCommand, + initialTerminalEnvironment: initialTerminalEnvironment ) wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex(placementOverride: placementOverride) @@ -836,17 +835,8 @@ class TabManager: ObservableObject { } else { tabs.append(newWorkspace) } - if let explicitWorkingDirectory, - let terminalPanel = newWorkspace.focusedTerminalPanel { - scheduleInitialWorkspaceGitMetadataRefresh( - workspaceId: newWorkspace.id, - panelId: terminalPanel.id, - directory: explicitWorkingDirectory - ) - } if eagerLoadTerminal { - requestBackgroundWorkspaceLoad(for: newWorkspace.id) - newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() + newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() } if select { selectedTabId = newWorkspace.id @@ -1162,16 +1152,6 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } - func moveTabToTopForNotification(_ tabId: UUID) { - guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } - let pinnedCount = tabs.filter { $0.isPinned }.count - guard index != pinnedCount else { return } - let tab = tabs[index] - guard !tab.isPinned else { return } - tabs.remove(at: index) - tabs.insert(tab, at: pinnedCount) - } - func moveTabsToTop(_ tabIds: Set<UUID>) { guard !tabIds.isEmpty else { return } let selectedTabs = tabs.filter { tabIds.contains($0.id) } @@ -1184,6 +1164,16 @@ class TabManager: ObservableObject { tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned } + func moveTabToTopForNotification(_ tabId: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + let pinnedCount = tabs.filter { $0.isPinned }.count + guard index != pinnedCount else { return } + let tab = tabs[index] + guard !tab.isPinned else { return } + tabs.remove(at: index) + tabs.insert(tab, at: pinnedCount) + } + @discardableResult func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool { guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } @@ -1269,22 +1259,23 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } - guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) - clearInitialWorkspaceGitProbe(workspaceId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) - unwireClosedBrowserTracking(for: workspace) workspace.teardownAllPanels() + workspace.teardownRemoteConnection() + unwireClosedBrowserTracking(for: workspace) - tabs.remove(at: index) + if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { + tabs.remove(at: index) - if selectedTabId == workspace.id { - // Keep the "focused index" stable when possible: - // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). - // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). - let newIndex = min(index, max(0, tabs.count - 1)) - selectedTabId = tabs[newIndex].id + if selectedTabId == workspace.id { + // Keep the "focused index" stable when possible: + // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). + // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). + let newIndex = min(index, max(0, tabs.count - 1)) + selectedTabId = tabs[newIndex].id + } } } @@ -1293,7 +1284,6 @@ class TabManager: ObservableObject { @discardableResult func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } - clearInitialWorkspaceGitProbe(workspaceId: tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) @@ -1355,13 +1345,9 @@ class TabManager: ObservableObject { let count = plan.panelIds.count let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") - let message = if count == 1 { - String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)") - } else { - String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)") - } + let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)" guard confirmClose( - title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + title: "Close other tabs?", message: message, acceptCmdD: false ) else { return } @@ -1401,8 +1387,8 @@ class TabManager: ObservableObject { alert.messageText = title alert.informativeText = message alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") // macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save"). // We only opt into this for the "close last workspace => close window" path to avoid @@ -1463,15 +1449,15 @@ class TabManager: ObservableObject { if let collapsed, !collapsed.isEmpty { return collapsed } - return String(localized: "tab.untitled", defaultValue: "Untitled Tab") + return "Untitled Tab" } private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) { let willCloseWindow = tabs.count <= 1 if workspaceNeedsConfirmClose(workspace), !confirmClose( - title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), - message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), + title: "Close workspace?", + message: "This will close the workspace and all of its panels.", acceptCmdD: willCloseWindow ) { return @@ -1512,8 +1498,8 @@ class TabManager: ObservableObject { let needsConfirm = workspaceNeedsConfirmClose(tab) if needsConfirm { let message = willCloseWindow - ? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.") - : String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.") + ? "This will close the last tab and close the window." + : "This will close the last tab and close its workspace." #if DEBUG dlog( "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + @@ -1521,7 +1507,7 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + title: "Close tab?", message: message, acceptCmdD: willCloseWindow ) else { @@ -1553,8 +1539,8 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), + title: "Close tab?", + message: "This will close the current tab.", acceptCmdD: false ) else { #if DEBUG @@ -1592,8 +1578,8 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: surfaceId), terminalPanel.needsConfirmClose() { guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), + title: "Close tab?", + message: "This will close the current tab.", acceptCmdD: false ) else { return } } @@ -1860,32 +1846,28 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + guard AppFocusState.isAppActive() else { return } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } + if let tab = tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) + } + notificationStore.markRead(forTabId: tabId, surfaceId: panelId) } @discardableResult func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { - dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true) - } - - @discardableResult - private func dismissNotificationIfActive( - tabId: UUID, - surfaceId: UUID?, - triggerFlash: Bool - ) -> Bool { guard selectedTabId == tabId else { return false } guard AppFocusState.isAppActive() else { return false } guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } - if triggerFlash, - let panelId = surfaceId, + if let panelId = surfaceId, let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } @@ -2184,24 +2166,9 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=terminal dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif tab.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) -#if DEBUG - dlog( - "split.create.result kind=terminal dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif + _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } /// Create a new browser split from the currently focused panel. @@ -2210,30 +2177,14 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=browser dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif tab.clearSplitZoom() - let createdPanelId = newBrowserSplit( + return newBrowserSplit( tabId: selectedTabId, fromPanelId: focusedPanelId, orientation: direction.orientation, insertFirst: direction.insertFirst, url: url ) -#if DEBUG - dlog( - "split.create.result kind=browser dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif - return createdPanelId } /// Refresh Bonsplit right-side action button tooltips for all workspaces. @@ -2334,21 +2285,12 @@ class TabManager: ObservableObject { /// Returns the new panel's ID (which is also the surface ID for terminals) func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - let createdPanel = tab.newTerminalSplit( + return tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, insertFirst: direction.insertFirst, focus: focus )?.id -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.newSurface result dir=\(directionLabel) " + - "tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " + - "created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)" - ) -#endif - return createdPanel } /// Move focus in the specified direction @@ -2949,7 +2891,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") + terminal.surface.forceRefresh() } } @@ -3927,15 +3869,6 @@ enum SplitDirection { var insertFirst: Bool { self == .left || self == .up } - - var debugLabel: String { - switch self { - case .left: return "left" - case .right: return "right" - case .up: return "up" - case .down: return "down" - } - } } /// Resize direction for backwards compatibility diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6a708ae2..001a40ba 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -201,6 +201,28 @@ class TerminalController { return socketCommandFocusAllowanceStack.last ?? false } + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { if isV2 { return focusIntentV2Methods.contains(commandKey) @@ -225,27 +247,26 @@ class TerminalController { return body() } - private func socketCommandAllowsInAppFocusMutations() -> Bool { - Self.allowsInAppFocusMutationsForActiveSocketCommand() - } - - private func v2FocusAllowed(requested: Bool = true) -> Bool { - requested && socketCommandAllowsInAppFocusMutations() - } - - private func v2MaybeFocusWindow(for tabManager: TabManager) { - guard socketCommandAllowsInAppFocusMutations(), - let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - - private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { - guard socketCommandAllowsInAppFocusMutations() else { return } - if tabManager.selectedTabId != workspace.id { - tabManager.selectWorkspace(workspace) +#if DEBUG + static func debugSocketCommandPolicySnapshot( + commandKey: String, + isV2: Bool + ) -> (insideSuppressed: Bool, insideAllowsFocus: Bool, outsideSuppressed: Bool, outsideAllowsFocus: Bool) { + var insideSuppressed = false + var insideAllowsFocus = false + _ = Self.shared.withSocketCommandPolicy(commandKey: commandKey, isV2: isV2) { + insideSuppressed = Self.shouldSuppressSocketCommandActivation() + insideAllowsFocus = Self.socketCommandAllowsInAppFocusMutations() + return 0 } + return ( + insideSuppressed: insideSuppressed, + insideAllowsFocus: insideAllowsFocus, + outsideSuppressed: Self.shouldSuppressSocketCommandActivation(), + outsideAllowsFocus: Self.socketCommandAllowsInAppFocusMutations() + ) } +#endif nonisolated static func shouldReplaceStatusEntry( current: SidebarStatusEntry?, @@ -312,33 +333,6 @@ class TerminalController { return currentSorted != nextSorted } - private struct SocketSurfaceKey: Hashable { - let workspaceId: UUID - let panelId: UUID - } - - private final class SocketFastPathState: @unchecked Sendable { - private let queue = DispatchQueue(label: "com.cmux.socket-fast-path") - private var lastReportedDirectories: [SocketSurfaceKey: String] = [:] - private let maxTrackedDirectories = 4096 - - func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool { - let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) - return queue.sync { - if lastReportedDirectories[key] == directory { - return false - } - if lastReportedDirectories.count >= maxTrackedDirectories { - lastReportedDirectories.removeAll(keepingCapacity: true) - } - lastReportedDirectories[key] = directory - return true - } - } - } - - private static let socketFastPathState = SocketFastPathState() - nonisolated static func explicitSocketScope( options: [String: String] ) -> (workspaceId: UUID, panelId: UUID)? { @@ -362,6 +356,36 @@ class TerminalController { return trimmed } + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenFile( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let standardizedFile = fileURL.standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return standardizedFile.path.hasPrefix(temporary.path + "/") + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -735,14 +759,7 @@ class TerminalController { guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } let validSurfaceIds = Set(workspace.panels.keys) guard validSurfaceIds.contains(panelId) else { return } - let nextPorts = Array(Set(ports)).sorted() - let currentPorts = workspace.surfaceListeningPorts[panelId] ?? [] - guard currentPorts != nextPorts else { return } - if nextPorts.isEmpty { - workspace.surfaceListeningPorts.removeValue(forKey: panelId) - } else { - workspace.surfaceListeningPorts[panelId] = nextPorts - } + workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports workspace.recomputeListeningPorts() } } @@ -1229,7 +1246,7 @@ class TerminalController { defer { close(socket) } // In cmuxOnly mode, verify the connecting process is a descendant of cmux. - // Other modes allow external clients and apply separate auth controls. + // In allowAll mode (env-var only), skip the ancestry check. if accessMode == .cmuxOnly { // Use pre-captured peer PID if available (captured in accept loop before // the peer can disconnect), falling back to live lookup. @@ -1300,11 +1317,7 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { switch cmd { case "ping": return "PONG" @@ -1622,25 +1635,13 @@ class TerminalController { case "refresh_surfaces": return refreshSurfaces() - case "surface_health": - return surfaceHealth(args) + case "surface_health": + return surfaceHealth(args) - default: - return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + default: + return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + } } - } - - #if DEBUG - if cmd == "new_workspace" || cmd == "send" || cmd == "send_surface" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.hasPrefix("OK") ? "ok" : "err" - dlog( - "socket.v1 cmd=\(cmd) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } // MARK: - V2 JSON Socket Protocol @@ -1675,11 +1676,7 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: method, isV2: true) { + return withSocketCommandPolicy(commandKey: method, isV2: true) { switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) @@ -1736,6 +1733,16 @@ class TerminalController { return v2Result(id: id, self.v2WorkspacePrevious(params: params)) case "workspace.last": return v2Result(id: id, self.v2WorkspaceLast(params: params)) + case "workspace.remote.configure": + return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) + case "workspace.remote.reconnect": + return v2Result(id: id, self.v2WorkspaceRemoteReconnect(params: params)) + case "workspace.remote.disconnect": + return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) + case "workspace.remote.status": + return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) + case "workspace.remote.terminal_session_end": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) // Settings case "settings.open": @@ -2064,22 +2071,10 @@ class TerminalController { return v2Result(id: id, self.v2DebugScreenshot(params: params)) #endif - default: - return v2Error(id: id, code: "method_not_found", message: "Unknown method") + default: + return v2Error(id: id, code: "method_not_found", message: "Unknown method") + } } - } - - #if DEBUG - if method == "workspace.create" || method == "surface.send_text" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.contains("\"ok\":true") ? "ok" : "err" - dlog( - "socket.v2 method=\(method) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } private func v2Capabilities() -> [String: Any] { @@ -2106,6 +2101,11 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", + "workspace.remote.configure", + "workspace.remote.reconnect", + "workspace.remote.disconnect", + "workspace.remote.status", + "workspace.remote.terminal_session_end", "settings.open", "feedback.open", "feedback.submit", @@ -2689,6 +2689,42 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } + private func v2StringArray(_ params: [String: Any], _ key: String) -> [String]? { + if let raw = params[key] as? [String] { + let normalized = raw + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized + } + if let raw = params[key] as? [Any] { + let normalized = raw + .compactMap { $0 as? String } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized + } + if let single = v2String(params, key) { + return [single] + } + return nil + } + + private func v2StringMap(_ params: [String: Any], _ key: String) -> [String: String]? { + guard let raw = params[key] else { return nil } + if let dict = raw as? [String: String] { + return dict + } + if let anyDict = raw as? [String: Any] { + var out: [String: String] = [:] + for (k, value) in anyDict { + guard let stringValue = value as? String else { continue } + out[k] = stringValue + } + return out + } + return nil + } + private func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? { guard let action = v2String(params, key) else { return nil } return action.lowercased().replacingOccurrences(of: "-", with: "_") @@ -2751,6 +2787,40 @@ class TerminalController { return nil } + private func v2HasNonNullParam(_ params: [String: Any], _ key: String) -> Bool { + guard let raw = params[key] else { return false } + return !(raw is NSNull) + } + + private func v2StrictInt(_ params: [String: Any], _ key: String) -> Int? { + v2StrictIntAny(params[key]) + } + + private func v2StrictIntAny(_ raw: Any?) -> Int? { + guard let raw else { return nil } + + if let numberValue = raw as? NSNumber { + if CFGetTypeID(numberValue) == CFBooleanGetTypeID() { + return nil + } + let doubleValue = numberValue.doubleValue + guard doubleValue.isFinite, floor(doubleValue) == doubleValue else { + return nil + } + return Int(exactly: doubleValue) + } + + if let intValue = raw as? Int { + return intValue + } + + if let stringValue = raw as? String { + return Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + return nil + } + private func v2PanelType(_ params: [String: Any], _ key: String) -> PanelType? { guard let s = v2String(params, key) else { return nil } return PanelType(rawValue: s.lowercased()) @@ -2834,9 +2904,8 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } - // Keep active routing stable unless this command is explicitly focus-intent. - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + // The new window should become key, but setActiveTabManager defensively. + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return .ok([ @@ -2877,7 +2946,9 @@ class TerminalController { "index": index, "title": ws.title, "selected": ws.id == tabManager.selectedTabId, - "pinned": ws.isPinned + "pinned": ws.isPinned, + "listening_ports": ws.listeningPorts, + "remote": ws.remoteStatusPayload() ] } } @@ -2894,8 +2965,22 @@ class TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } + let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) + let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil + + let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) + let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil + + let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] + let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + result[key] = pair.value + } let cwd: String? - if let raw = params["cwd"] { + if let workingDirectory { + cwd = workingDirectory + } else if let raw = params["cwd"] { guard let str = raw as? String else { return .err(code: "invalid_params", message: "cwd must be a string", data: nil) } @@ -2906,23 +2991,16 @@ class TerminalController { var newId: UUID? let shouldFocus = v2FocusAllowed() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif v2MainSync { let ws = tabManager.addWorkspace( workingDirectory: cwd, + initialTerminalCommand: initialCommand, + initialTerminalEnvironment: initialEnv, select: shouldFocus, eagerLoadTerminal: !shouldFocus ) newId = ws.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.workspace.create focus=\(shouldFocus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif guard let newId else { return .err(code: "internal_error", message: "Failed to create workspace", data: nil) @@ -2946,8 +3024,12 @@ class TerminalController { var success = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + // If this workspace belongs to another window, bring it forward so focus is visible. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectWorkspace(ws) success = true } } @@ -2970,8 +3052,20 @@ class TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } var wsId: UUID? + var wsPayload: [String: Any]? v2MainSync { wsId = tabManager.selectedTabId + if let wsId, let workspace = tabManager.tabs.first(where: { $0.id == wsId }) { + wsPayload = [ + "id": workspace.id.uuidString, + "ref": v2Ref(kind: .workspace, uuid: workspace.id), + "title": workspace.title, + "selected": true, + "pinned": workspace.isPinned, + "listening_ports": workspace.listeningPorts, + "remote": workspace.remoteStatusPayload(), + ] + } } guard let wsId else { return .err(code: "not_found", message: "No workspace selected", data: nil) @@ -2981,7 +3075,8 @@ class TerminalController { "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), + "workspace": wsPayload ?? NSNull() ]) } private func v2WorkspaceClose(params: [String: Any]) -> V2CallResult { @@ -3020,7 +3115,7 @@ class TerminalController { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -3140,7 +3235,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectNextTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3162,7 +3260,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectPreviousTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3184,7 +3285,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) v2MainSync { guard let before = tabManager.selectedTabId else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.navigateBack() guard let after = tabManager.selectedTabId, after != before else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3198,6 +3302,277 @@ class TerminalController { return result } + private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + guard let destination = v2String(params, "destination") else { + return .err(code: "invalid_params", message: "Missing destination", data: nil) + } + + var sshPort: Int? + if v2HasNonNullParam(params, "port") { + guard let parsedPort = v2StrictInt(params, "port"), + parsedPort > 0, + parsedPort <= 65535 else { + return .err(code: "invalid_params", message: "port must be 1-65535", data: nil) + } + sshPort = parsedPort + } + + // Internal deterministic test hook: pin the local proxy listener port to force bind conflicts. + var localProxyPort: Int? + if v2HasNonNullParam(params, "local_proxy_port") { + guard let parsedLocalProxyPort = v2StrictInt(params, "local_proxy_port"), + parsedLocalProxyPort > 0, + parsedLocalProxyPort <= 65535 else { + return .err(code: "invalid_params", message: "local_proxy_port must be 1-65535", data: nil) + } + localProxyPort = parsedLocalProxyPort + } + + let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) + let sshOptions = v2StringArray(params, "ssh_options") ?? [] + let autoConnect = v2Bool(params, "auto_connect") ?? true + var relayPort: Int? + if v2HasNonNullParam(params, "relay_port") { + guard let parsedRelayPort = v2StrictInt(params, "relay_port"), + parsedRelayPort > 0, + parsedRelayPort <= 65535 else { + return .err(code: "invalid_params", message: "relay_port must be 1-65535", data: nil) + } + relayPort = parsedRelayPort + } + let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) + let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) + let localSocketPath = v2RawString(params, "local_socket_path") + let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? + .trimmingCharacters(in: .whitespacesAndNewlines) + 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) + } + guard let relayToken, + relayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + return .err(code: "invalid_params", message: "relay_token must be 64 lowercase hex characters when relay_port is set", data: nil) + } + } + +#if DEBUG + dlog( + "workspace.remote.configure.request workspace=\(workspaceId.uuidString.prefix(8)) " + + "target=\(destination) port=\(sshPort.map(String.init) ?? "nil") " + + "autoConnect=\(autoConnect ? 1 : 0) relayPort=\(relayPort.map(String.init) ?? "nil") " + + "localSocket=\(localSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? localSocketPath! : "nil") " + + "sshOptions=\(sshOptions.joined(separator: "|"))" + ) +#endif + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because Workspace.configureRemoteConnection mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + let config = WorkspaceRemoteConfiguration( + destination: destination, + port: sshPort, + identityFile: identityFile?.isEmpty == true ? nil : identityFile, + sshOptions: sshOptions, + localProxyPort: localProxyPort, + relayPort: relayPort, + relayID: relayID?.isEmpty == true ? nil : relayID, + relayToken: relayToken?.isEmpty == true ? nil : relayToken, + localSocketPath: localSocketPath, + terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand + ) + workspace.configureRemoteConnection(config, autoConnect: autoConnect) + + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteDisconnect(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + let clearConfiguration = v2Bool(params, "clear") ?? false + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because disconnect mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + workspace.disconnectRemoteConnection(clearConfiguration: clearConfiguration) + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because reconnect mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + guard workspace.remoteConfiguration != nil else { + result = .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + return + } + + workspace.reconnectRemoteConnection() + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because Workspace.remoteStatusPayload reads TabManager/UI-owned state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let relayPort = v2StrictInt(params, "relay_port"), + relayPort > 0, + relayPort <= 65535 else { + return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + ]) + + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -3360,7 +3735,7 @@ class TerminalController { "close_left", "close_right", "close_others", "new_terminal_right", "new_browser_right", "reload", "duplicate", - "pin", "unpin", "mark_read", "mark_unread" + "pin", "unpin", "mark_unread" ] var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ @@ -3373,7 +3748,6 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - let allowFocusMutation = v2FocusAllowed() let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId guard let surfaceId else { @@ -3475,10 +3849,6 @@ class TerminalController { workspace.setPanelPinned(panelId: surfaceId, pinned: false) finish(["pinned": false]) - case "mark_read", "mark_as_read": - workspace.markPanelRead(surfaceId) - finish() - case "mark_unread", "mark_as_unread": workspace.markPanelUnread(surfaceId) finish() @@ -3503,7 +3873,7 @@ class TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: allowFocusMutation + focus: true ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return @@ -3524,7 +3894,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3551,7 +3921,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3662,7 +4032,7 @@ class TerminalController { "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, "type": panel.panelType.rawValue, - "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "title": panel.displayTitle, "focused": panel.id == focusedSurfaceId, "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), @@ -3741,8 +4111,15 @@ class TerminalController { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + // Make sure the workspace is selected so focus effects apply to the visible UI. + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } guard ws.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) @@ -3770,8 +4147,13 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { @@ -3783,12 +4165,7 @@ class TerminalController { return } - if let newId = tabManager.newSplit( - tabId: ws.id, - surfaceId: targetSurfaceId, - direction: direction, - focus: v2FocusAllowed() - ) { + if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ @@ -3961,7 +4338,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -4072,15 +4449,16 @@ class TerminalController { ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) + _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: true) } result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) return } if focus { - v2MaybeFocusWindow(for: targetTabManager) - v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace) + _ = app.focusMainWindow(windowId: targetWindowId) + setActiveTabManager(targetTabManager) + targetTabManager.selectWorkspace(targetWorkspace) } result = .ok([ @@ -4267,21 +4645,13 @@ class TerminalController { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() queued = true } - #if DEBUG +#if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 dlog( "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" ) - #endif - result = .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "queued": queued, - "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager)) - ]) +#endif + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result } @@ -4309,7 +4679,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) return } @@ -4429,41 +4799,87 @@ class TerminalController { private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" } - let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT - let topLeft = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 - ) - let bottomRight = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ) - let selection = ghostty_selection_s( - top_left: topLeft, - bottom_right: bottomRight, - rectangle: true - ) - var text = ghostty_text_s() + func readSelectionText(pointTag: ghostty_point_tag_e) -> String? { + let topLeft = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ) + let selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: false + ) - guard ghostty_surface_read_text(surface, selection, &text) else { - return "ERROR: Failed to read terminal text" - } - defer { - ghostty_surface_free_text(surface, &text) + var text = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &text) else { + return nil + } + defer { + ghostty_surface_free_text(surface, &text) + } + + guard let ptr = text.text, text.text_len > 0 else { + return "" + } + let rawData = Data(bytes: ptr, count: Int(text.text_len)) + return String(decoding: rawData, as: UTF8.self) } - let rawData: Data - if let ptr = text.text, text.text_len > 0 { - rawData = Data(bytes: ptr, count: Int(text.text_len)) + var output: String + if includeScrollback { + func candidateScore(_ text: String) -> (lines: Int, bytes: Int) { + let lines = text.isEmpty ? 0 : text.split(separator: "\n", omittingEmptySubsequences: false).count + return (lines, text.utf8.count) + } + + // Read all available regions and pick the most complete candidate. + // Different point tags can lose different rows around resize/reflow boundaries. + let screen = readSelectionText(pointTag: GHOSTTY_POINT_SCREEN) + let history = readSelectionText(pointTag: GHOSTTY_POINT_SURFACE) + let active = readSelectionText(pointTag: GHOSTTY_POINT_ACTIVE) + + var candidates: [String] = [] + if let screen { + candidates.append(screen) + } + if history != nil || active != nil { + var merged = history ?? "" + if let active { + if !merged.isEmpty, !merged.hasSuffix("\n"), !active.isEmpty { + merged.append("\n") + } + merged.append(active) + } + candidates.append(merged) + } + + if let best = candidates.max(by: { lhs, rhs in + let left = candidateScore(lhs) + let right = candidateScore(rhs) + if left.lines != right.lines { + return left.lines < right.lines + } + return left.bytes < right.bytes + }) { + output = best + } else { + return "ERROR: Failed to read terminal text" + } } else { - rawData = Data() + guard let viewport = readSelectionText(pointTag: GHOSTTY_POINT_VIEWPORT) else { + return "ERROR: Failed to read terminal text" + } + output = viewport } - var output = String(decoding: rawData, as: UTF8.self) if let lineLimit { output = tailTerminalLines(output, maxLines: lineLimit) } @@ -4472,152 +4888,21 @@ class TerminalController { return "OK \(base64)" } - private struct PasteboardItemSnapshot { - let representations: [(type: NSPasteboard.PasteboardType, data: Data)] - } - - nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if let url = URL(string: trimmed), - url.isFileURL, - !url.path.isEmpty { - return url.path - } - return trimmed.hasPrefix("/") ? trimmed : nil - } - - nonisolated static func shouldRemoveExportedScreenFile( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let standardizedFile = fileURL.standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return standardizedFile.path.hasPrefix(temporary.path + "/") - } - - nonisolated static func shouldRemoveExportedScreenDirectory( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let directory = fileURL.deletingLastPathComponent().standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return directory.path.hasPrefix(temporary.path + "/") - } - - private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { - guard let items = pasteboard.pasteboardItems else { return [] } - return items.map { item in - let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in - guard let data = item.data(forType: type) else { return nil } - return (type: type, data: data) - } - return PasteboardItemSnapshot(representations: representations) - } - } - - private func restorePasteboardItems( - _ snapshots: [PasteboardItemSnapshot], - to pasteboard: NSPasteboard - ) { - _ = pasteboard.clearContents() - guard !snapshots.isEmpty else { return } - - let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in - guard !snapshot.representations.isEmpty else { return nil } - let item = NSPasteboardItem() - for representation in snapshot.representations { - item.setData(representation.data, forType: representation.type) - } - return item - } - guard !restoredItems.isEmpty else { return } - _ = pasteboard.writeObjects(restoredItems) - } - - private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? { - if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], - let firstURL = urls.first, - firstURL.isFileURL { - return firstURL.path - } - if let value = pasteboard.string(forType: .string) { - return value - } - return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text")) - } - - private func readTerminalTextFromVTExportForSnapshot( - terminalPanel: TerminalPanel, - lineLimit: Int? - ) -> String? { - // read_text strips style state; VT export keeps ANSI escape sequences. - let pasteboard = NSPasteboard.general - let snapshot = snapshotPasteboardItems(pasteboard) - defer { - restorePasteboardItems(snapshot, to: pasteboard) - } - - let initialChangeCount = pasteboard.changeCount - guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else { - return nil - } - guard pasteboard.changeCount != initialChangeCount else { - return nil - } - guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { - return nil - } - - let fileURL = URL(fileURLWithPath: exportedPath) - defer { - if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL) - if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) - } - } - } - - guard let data = try? Data(contentsOf: fileURL), - var output = String(data: data, encoding: .utf8) else { - return nil - } - if let lineLimit { - output = tailTerminalLines(output, maxLines: lineLimit) - } - return output - } - - func readTerminalTextForSnapshot( + func readTerminalTextForSessionSnapshot( terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil ) -> String? { - if includeScrollback, - let vtOutput = readTerminalTextFromVTExportForSnapshot( - terminalPanel: terminalPanel, - lineLimit: lineLimit - ) { - return vtOutput - } - let response = readTerminalTextBase64( terminalPanel: terminalPanel, includeScrollback: includeScrollback, lineLimit: lineLimit ) guard response.hasPrefix("OK ") else { return nil } - let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - if base64.isEmpty { - return "" - } - guard let data = Data(base64Encoded: base64), - let decoded = String(data: data, encoding: .utf8) else { - return nil - } - return decoded + let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !payload.isEmpty else { return "" } + guard let data = Data(base64Encoded: payload) else { return nil } + return String(decoding: data, as: UTF8.self) } private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { @@ -4632,9 +4917,14 @@ class TerminalController { return } - // Only explicit focus-intent commands may mutate selection state. - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + // Ensure the flash is visible in the active UI. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { @@ -4715,8 +5005,13 @@ class TerminalController { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } ws.bonsplitController.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) @@ -5036,7 +5331,7 @@ class TerminalController { if sourcePaneUUID == targetPaneUUID { return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) v2MainSync { @@ -5119,7 +5414,7 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) v2MainSync { @@ -5160,7 +5455,7 @@ class TerminalController { return } - let destinationWorkspace = tabManager.addWorkspace(select: focus) + let destinationWorkspace = tabManager.addWorkspace() guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { if let sourcePaneForRollback { @@ -5168,7 +5463,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) @@ -5181,12 +5476,16 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) return } + + if !focus { + tabManager.selectWorkspace(sourceWorkspace) + } let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), @@ -6096,16 +6395,11 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit( - from: sourceSurfaceId, - orientation: .horizontal, - url: url, - focus: v2FocusAllowed() - ) + createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) } guard let browserPanelId = createdPanel?.id else { @@ -7456,8 +7750,13 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), let browserPanel = ws.browserPanel(for: surfaceId) else { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -8496,7 +8795,7 @@ class TerminalController { "id": panel.id.uuidString, "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, - "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "title": panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", "focused": panel.id == ws.focusedPanelId, "pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString), @@ -8541,7 +8840,7 @@ class TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -9640,7 +9939,6 @@ class TerminalController { Available commands: ping - Check if server is running - auth <password> - Authenticate this connection (required in password mode) list_workspaces - List all workspaces with IDs new_workspace - Create a new workspace select_workspace <id|index> - Select workspace by ID or index (0-based) @@ -9755,37 +10053,6 @@ class TerminalController { } #if DEBUG - private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String { - let snakeCase = action.rawValue.replacingOccurrences( - of: "([a-z0-9])([A-Z])", - with: "$1_$2", - options: .regularExpression - ) - return snakeCase.lowercased() - } - - private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? { - let normalized = rawName - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "-", with: "_") - - for action in KeyboardShortcutSettings.Action.allCases { - let snakeCaseName = debugShortcutName(for: action) - if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") { - return action - } - } - return nil - } - - private func debugShortcutSupportedNames() -> String { - KeyboardShortcutSettings.Action.allCases - .map(debugShortcutName(for:)) - .sorted() - .joined(separator: ", ") - } - private func setShortcut(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) @@ -9793,15 +10060,29 @@ class TerminalController { return "ERROR: Usage: set_shortcut <name> <combo|clear>" } - let name = parts[0] + let name = parts[0].lowercased() let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard let action = debugShortcutAction(named: name) else { - return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" + let defaultsKey: String? + switch name { + case "focus_left", "focusleft": + defaultsKey = KeyboardShortcutSettings.focusLeftKey + case "focus_right", "focusright": + defaultsKey = KeyboardShortcutSettings.focusRightKey + case "focus_up", "focusup": + defaultsKey = KeyboardShortcutSettings.focusUpKey + case "focus_down", "focusdown": + defaultsKey = KeyboardShortcutSettings.focusDownKey + default: + defaultsKey = nil + } + + guard let defaultsKey else { + return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: action.defaultsKey) + UserDefaults.standard.removeObject(forKey: defaultsKey) return "OK" } @@ -9819,13 +10100,12 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: action.defaultsKey) + UserDefaults.standard.set(data, forKey: defaultsKey) return "OK" } private func prepareWindowForSyntheticInput(_ window: NSWindow?) { guard let window else { return } - // Keep socket-driven input simulation focused on the intended window without // paying repeated activation/order-front costs for every synthetic key event. if !NSApp.isActive { @@ -9964,22 +10244,7 @@ class TerminalController { return } - // If workspace handoff temporarily leaves a non-terminal first responder, - // route debug typing to the selected terminal's focused panel directly. - if let tabManager, - let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }), - let panelId = tab.focusedPanelId, - let terminalPanel = tab.terminalPanel(for: panelId), - !terminalPanel.hostedView.isSurfaceViewFirstResponder() { - // Match Enter semantics expected by tests/debug tooling when bypassing AppKit. - let directText = text.replacingOccurrences(of: "\n", with: "\r") - terminalPanel.surface.sendText(directText) - result = "OK" - return - } - - // Fall back to the responder-chain insertText action. + // Fall back to the responder chain insertText action. (fr as? NSResponder)?.insertText(text) result = "OK" } @@ -10572,10 +10837,6 @@ class TerminalController { let charactersIgnoringModifiers: String switch keyToken.lowercased() { - case "esc", "escape": - storedKey = "\u{1b}" - keyCode = UInt16(kVK_Escape) - charactersIgnoringModifiers = storedKey case "left": storedKey = "←" keyCode = 123 @@ -10596,10 +10857,6 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey - case "backspace", "delete", "del": - storedKey = "\u{7f}" - keyCode = UInt16(kVK_Delete) - charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } @@ -10738,8 +10995,7 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return "ERROR: Failed to create window" } - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return "OK \(windowId.uuidString)" @@ -10759,7 +11015,6 @@ class TerminalController { guard let windowId = UUID(uuidString: parts[1]) else { return "ERROR: Invalid window id" } var ok = false - let focus = socketCommandAllowsInAppFocusMutations() v2MainSync { guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId), let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId), @@ -10767,11 +11022,9 @@ class TerminalController { ok = false return } - dstTM.attachWorkspace(ws, select: focus) - if focus { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(dstTM) - } + dstTM.attachWorkspace(ws, select: true) + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(dstTM) ok = true } @@ -10797,19 +11050,10 @@ class TerminalController { var newTabId: UUID? let focus = socketCommandAllowsInAppFocusMutations() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif DispatchQueue.main.sync { let workspace = tabManager.addTab(select: focus, eagerLoadTerminal: !focus) newTabId = workspace.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.new_workspace focus=\(focus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -10853,12 +11097,7 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit( - tabId: tabId, - surfaceId: targetSurface, - direction: direction, - focus: socketCommandAllowsInAppFocusMutations() - ) { + if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { result = "OK \(newPanelId.uuidString)" } } @@ -11861,29 +12100,6 @@ class TerminalController { } } - private func sendSocketText(_ text: String, surface: ghostty_surface_t) { - let chunks = Self.socketTextChunks(text) - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - for chunk in chunks { - switch chunk { - case .text(let value): - sendTextEvent(surface: surface, text: value) - case .control(let scalar): - _ = handleControlScalar(scalar, surface: surface) - } - } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - if elapsedMs >= 8 || chunks.count > 1 { - dlog( - "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" - ) - } - #endif - } - private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { switch scalar.value { case 0x0A, 0x0D: @@ -11986,6 +12202,15 @@ class TerminalController { return } + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { + error = "ERROR: Surface not ready" + return + } + // Unescape common escape sequences // Note: \n is converted to \r for terminal (Enter key sends \r) let unescaped = text @@ -11993,11 +12218,13 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -12005,6 +12232,29 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func sendSocketText(_ text: String, surface: ghostty_surface_t) { + let chunks = Self.socketTextChunks(text) +#if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime +#endif + for chunk in chunks { + switch chunk { + case .text(let value): + sendTextEvent(surface: surface, text: value) + case .control(let scalar): + _ = handleControlScalar(scalar, surface: surface) + } + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + if elapsedMs >= 8 || chunks.count > 1 { + dlog( + "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" + ) + } +#endif + } + private func sendInputToWorkspace(_ args: String) -> String { guard let tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) @@ -12106,18 +12356,20 @@ class TerminalController { var success = false DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } + guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -12138,7 +12390,11 @@ class TerminalController { return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { error = "ERROR: Surface not ready" return } @@ -12160,11 +12416,11 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { + guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else { error = "ERROR: Surface not found" return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else { error = "ERROR: Surface not ready" return } @@ -12182,7 +12438,6 @@ class TerminalController { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed) - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create browser panel" DispatchQueue.main.sync { @@ -12192,12 +12447,7 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit( - from: focusedPanelId, - orientation: .horizontal, - url: url, - focus: shouldFocus - )?.id { + if let browserPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: .horizontal, url: url)?.id { result = "OK \(browserPanelId.uuidString)" } } @@ -12599,7 +12849,6 @@ class TerminalController { let orientation = direction.orientation let insertFirst = direction.insertFirst - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create pane" DispatchQueue.main.sync { @@ -12611,20 +12860,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit( - from: focusedPanelId, - orientation: orientation, - insertFirst: insertFirst, - url: url, - focus: shouldFocus - )?.id + newPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id } else { - newPanelId = tab.newTerminalSplit( - from: focusedPanelId, - orientation: orientation, - insertFirst: insertFirst, - focus: shouldFocus - )?.id + newPanelId = tab.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id } if let id = newPanelId { @@ -13201,9 +13439,6 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - guard Self.shouldReplaceProgress(current: tab.progress, value: clamped, label: label) else { - return - } tab.progress = SidebarProgressState(value: clamped, label: label) } return result @@ -13216,9 +13451,7 @@ class TerminalController { result = "ERROR: Tab not found" return } - if tab.progress != nil { - tab.progress = nil - } + tab.progress = nil } return result } @@ -13226,7 +13459,7 @@ class TerminalController { private func reportGitBranch(_ args: String) -> String { let parsed = parseOptions(args) guard let branch = parsed.positional.first else { - return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" + return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]" } let isDirty = parsed.options["status"]?.lowercased() == "dirty" @@ -13253,35 +13486,7 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty) + tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) } return result } @@ -13305,42 +13510,13 @@ class TerminalController { } return "OK" } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + result = "ERROR: Tab not found" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.clearPanelGitBranch(panelId: surfaceId) + tab.gitBranch = nil } return result } @@ -13483,7 +13659,6 @@ class TerminalController { } ports.append(port) } - let normalizedPorts = Array(Set(ports)).sorted() var result = "OK" DispatchQueue.main.sync { @@ -13520,43 +13695,20 @@ class TerminalController { return } - guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else { - return - } - - tab.surfaceListeningPorts[surfaceId] = normalizedPorts + tab.surfaceListeningPorts[surfaceId] = ports tab.recomputeListeningPorts() } return result } private func reportPwd(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]" } - let directory = Self.normalizeReportedDirectory(parsed.positional.joined(separator: " ")) - - // Shell integration provides explicit UUID handles for cwd updates. - // Keep this hot path off-main and drop no-op reports before scheduling UI work. - if let scope = Self.explicitSocketScope(options: parsed.options) { - guard Self.socketFastPathState.shouldPublishDirectory( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - directory: directory - ) else { - return "OK" - } - DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } - tabManager.updateSurfaceDirectory(tabId: scope.workspaceId, surfaceId: scope.panelId, directory: directory) - } - return "OK" - } - - guard let tabManager else { return "ERROR: TabManager not available" } - + let directory = parsed.positional.joined(separator: " ") var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -13623,15 +13775,11 @@ class TerminalController { result = "ERROR: Panel not found '\(surfaceId.uuidString)'" return } - if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil { - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeValue(forKey: surfaceId) } else { - if !tab.surfaceListeningPorts.isEmpty { - tab.surfaceListeningPorts.removeAll() - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeAll() } + tab.recomputeListeningPorts() } return result } @@ -13642,17 +13790,6 @@ class TerminalController { return "ERROR: Missing tty name — usage: report_tty <tty_name> [--tab=X] [--panel=Y]" } - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to avoid sync-hopping on every report. - if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.registerTTY( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - ttyName: ttyName - ) - return "OK" - } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -13686,7 +13823,6 @@ class TerminalController { return } - guard tab.surfaceTTYNames[surfaceId] != ttyName else { return } tab.surfaceTTYNames[surfaceId] = ttyName PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) } @@ -13694,22 +13830,15 @@ class TerminalController { } private func portsKick(_ args: String) -> String { - let parsed = parseOptions(args) - - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to keep prompt hooks from blocking UI work. - if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) - return "OK" - } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { + let parsed = parseOptions(args) result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } + let parsed = parseOptions(args) let panelArg = parsed.options["panel"] ?? parsed.options["surface"] let surfaceId: UUID if let panelArg { @@ -13933,7 +14062,6 @@ class TerminalController { var panelType: PanelType = .terminal var paneArg: String? = nil var url: URL? = nil - let shouldFocus = socketCommandAllowsInAppFocusMutations() let parts = args.split(separator: " ") for part in parts { @@ -13978,9 +14106,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: true)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: true)?.id } if let id = newPanelId { diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 52d9ff26..462b036f 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -94,7 +94,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { let text: String if let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) { - let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) + let title = tab.title.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)" } else { text = "Cmd: —" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1bc7e1ed..496ebeb2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,9 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CryptoKit +import Darwin +import Network import CoreText func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { @@ -104,7 +107,49 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } +struct WorkspaceRemoteDaemonManifest: Decodable, Equatable { + struct Entry: Decodable, Equatable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } +} + extension Workspace { + nonisolated static let remoteDaemonManifestInfoKey = WorkspaceRemoteSessionController.remoteDaemonManifestInfoKey + + nonisolated static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) + } + + nonisolated static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( + version: version, + goOS: goOS, + goArch: goArch, + fileManager: fileManager + ) + } + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) @@ -312,7 +357,7 @@ extension Workspace { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } let capturedScrollback = includeScrollback - ? TerminalController.shared.readTerminalTextForSnapshot( + ? TerminalController.shared.readTerminalTextForSessionSnapshot( terminalPanel: terminalPanel, includeScrollback: true, lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal @@ -336,17 +381,17 @@ extension Workspace { browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForOmnibar(), shouldRenderWebView: browserPanel.shouldRenderWebView, - pageZoom: Double(browserPanel.webView.pageZoom), + pageZoom: Double(browserPanel.currentPageZoomFactor()), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), backHistoryURLStrings: historySnapshot.backHistoryURLStrings, forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) markdownSnapshot = nil case .markdown: - guard let mdPanel = panel as? MarkdownPanel else { return nil } + guard let markdownPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil browserSnapshot = nil - markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) } return SessionPanelSnapshot( @@ -523,18 +568,7 @@ extension Workspace { applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id case .markdown: - guard let filePath = snapshot.markdown?.filePath else { - return nil - } - guard let markdownPanel = newMarkdownSurface( - inPane: paneId, - filePath: filePath, - focus: false - ) else { - return nil - } - applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) - return markdownPanel.id + return nil } } @@ -580,7 +614,7 @@ extension Workspace { let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) if pageZoom.isFinite { - browserPanel.webView.pageZoom = pageZoom + _ = browserPanel.setPageZoomFactor(pageZoom) } if browserSnapshot.developerToolsVisible { @@ -613,6 +647,3239 @@ extension Workspace { } } +final class WorkspaceRemoteDaemonPendingCallRegistry { + final class PendingCall { + let id: Int + fileprivate let semaphore = DispatchSemaphore(value: 0) + fileprivate var response: [String: Any]? + fileprivate var failureMessage: String? + + fileprivate init(id: Int) { + self.id = id + } + } + + enum WaitOutcome { + case response([String: Any]) + case failure(String) + case missing + case timedOut + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") + private var nextRequestID = 1 + private var pendingCalls: [Int: PendingCall] = [:] + + func reset() { + queue.sync { + nextRequestID = 1 + pendingCalls.removeAll(keepingCapacity: false) + } + } + + func register() -> PendingCall { + queue.sync { + let call = PendingCall(id: nextRequestID) + nextRequestID += 1 + pendingCalls[call.id] = call + return call + } + } + + @discardableResult + func resolve(id: Int, payload: [String: Any]) -> Bool { + queue.sync { + guard let pendingCall = pendingCalls[id] else { return false } + pendingCall.response = payload + pendingCall.semaphore.signal() + return true + } + } + + func failAll(_ message: String) { + queue.sync { + let calls = Array(pendingCalls.values) + for call in calls { + guard call.response == nil, call.failureMessage == nil else { continue } + call.failureMessage = message + call.semaphore.signal() + } + } + } + + func remove(_ call: PendingCall) { + queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + } + + func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { + if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { + queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + // A response can win the race immediately before timeout cleanup removes the call. + // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. + _ = call.semaphore.wait(timeout: .now()) + return .timedOut + } + + return queue.sync { + guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { + return .missing + } + if let failure = pendingCall.failureMessage { + return .failure(failure) + } + guard let response = pendingCall.response else { + return .missing + } + return .response(response) + } + } +} + +private final class WorkspaceRemoteDaemonRPCClient { + private static let maxStdoutBufferBytes = 256 * 1024 + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let onUnexpectedTermination: (String) -> Void + private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") + private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") + private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() + + private var process: Process? + private var stdinHandle: FileHandle? + private var stdoutHandle: FileHandle? + private var stderrHandle: FileHandle? + private var isClosed = true + private var shouldReportTermination = true + + private var stdoutBuffer = Data() + private var stderrBuffer = "" + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUnexpectedTermination: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.onUnexpectedTermination = onUnexpectedTermination + } + + func start() throws { + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStdoutData(data) + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStderrData(data) + } + } + process.terminationHandler = { [weak self] terminated in + self?.stateQueue.async { + self?.handleProcessTermination(terminated) + } + } + + do { + try process.run() + } catch { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", + ]) + } + + stateQueue.sync { + self.process = process + self.stdinHandle = stdinPipe.fileHandleForWriting + self.stdoutHandle = stdoutPipe.fileHandleForReading + self.stderrHandle = stderrPipe.fileHandleForReading + self.isClosed = false + self.shouldReportTermination = true + self.stdoutBuffer = Data() + self.stderrBuffer = "" + } + pendingCalls.reset() + + do { + let hello = try call(method: "hello", params: [:], timeout: 8.0) + let capabilities = (hello["capabilities"] as? [String]) ?? [] + guard capabilities.contains("proxy.stream") else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + ]) + } + } catch { + stop(suppressTerminationCallback: true) + throw error + } + } + + func stop() { + stop(suppressTerminationCallback: true) + } + + func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { + let result = try call( + method: "proxy.open", + params: [ + "host": host, + "port": port, + "timeout_ms": timeoutMs, + ], + timeout: 12.0 + ) + let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !streamID.isEmpty else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "proxy.open missing stream_id", + ]) + } + return streamID + } + + func writeStream(streamID: String, data: Data) throws { + _ = try call( + method: "proxy.write", + params: [ + "stream_id": streamID, + "data_base64": data.base64EncodedString(), + ], + timeout: 8.0 + ) + } + + func readStream(streamID: String, maxBytes: Int = 32768, timeoutMs: Int = 250) throws -> (data: Data, eof: Bool) { + let result = try call( + method: "proxy.read", + params: [ + "stream_id": streamID, + "max_bytes": maxBytes, + "timeout_ms": timeoutMs, + ], + timeout: max(2.0, TimeInterval(timeoutMs) / 1000.0 + 2.0) + ) + let encoded = (result["data_base64"] as? String) ?? "" + let decoded = encoded.isEmpty ? Data() : (Data(base64Encoded: encoded) ?? Data()) + let eof = (result["eof"] as? Bool) ?? false + return (decoded, eof) + } + + func closeStream(streamID: String) { + _ = try? call( + method: "proxy.close", + params: ["stream_id": streamID], + timeout: 4.0 + ) + } + + private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { + let pendingCall = pendingCalls.register() + let requestID = pendingCall.id + + let payload: Data + do { + payload = try Self.encodeJSON([ + "id": requestID, + "method": method, + "params": params, + ]) + } catch { + pendingCalls.remove(pendingCall) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", + ]) + } + + do { + try writeQueue.sync { + try writePayload(payload) + } + } catch { + pendingCalls.remove(pendingCall) + throw error + } + + let response: [String: Any] + switch pendingCalls.wait(for: pendingCall, timeout: timeout) { + case .timedOut: + stop(suppressTerminationCallback: false) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", + ]) + case .failure(let failure): + throw NSError(domain: "cmux.remote.daemon.rpc", code: 12, userInfo: [ + NSLocalizedDescriptionKey: failure, + ]) + case .missing: + throw NSError(domain: "cmux.remote.daemon.rpc", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", + ]) + case .response(let pendingResponse): + response = pendingResponse + } + + let ok = (response["ok"] as? Bool) ?? false + if ok { + return (response["result"] as? [String: Any]) ?? [:] + } + + let errorObject = (response["error"] as? [String: Any]) ?? [:] + let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" + let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" + throw NSError(domain: "cmux.remote.daemon.rpc", code: 14, userInfo: [ + NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", + ]) + } + + private func writePayload(_ payload: Data) throws { + let stdinHandle: FileHandle = stateQueue.sync { + self.stdinHandle ?? FileHandle.nullDevice + } + if stdinHandle === FileHandle.nullDevice { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 15, userInfo: [ + NSLocalizedDescriptionKey: "daemon transport is not connected", + ]) + } + do { + try stdinHandle.write(contentsOf: payload) + try stdinHandle.write(contentsOf: Data([0x0A])) + } catch { + stop(suppressTerminationCallback: false) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 16, userInfo: [ + NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", + ]) + } + } + + private func consumeStdoutData(_ data: Data) { + guard !data.isEmpty else { + signalPendingFailureLocked("daemon transport closed stdout") + return + } + + stdoutBuffer.append(data) + if stdoutBuffer.count > Self.maxStdoutBufferBytes { + stdoutBuffer.removeAll(keepingCapacity: false) + signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") + process?.terminate() + return + } + while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { + var lineData = Data(stdoutBuffer[..<newlineIndex]) + stdoutBuffer.removeSubrange(...newlineIndex) + + if let carriageIndex = lineData.lastIndex(of: 0x0D), carriageIndex == lineData.index(before: lineData.endIndex) { + lineData.remove(at: carriageIndex) + } + guard !lineData.isEmpty else { continue } + + guard let payload = try? JSONSerialization.jsonObject(with: lineData, options: []) as? [String: Any] else { + continue + } + + let responseID: Int = { + if let intValue = payload["id"] as? Int { + return intValue + } + if let numberValue = payload["id"] as? NSNumber { + return numberValue.intValue + } + return -1 + }() + guard responseID >= 0 else { continue } + _ = pendingCalls.resolve(id: responseID, payload: payload) + } + } + + private func consumeStderrData(_ data: Data) { + guard !data.isEmpty else { return } + guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } + stderrBuffer.append(chunk) + if stderrBuffer.count > 8192 { + stderrBuffer.removeFirst(stderrBuffer.count - 8192) + } + } + + private func handleProcessTermination(_ process: Process) { + let shouldNotify: Bool = { + guard self.process === process else { return false } + return !isClosed && shouldReportTermination + }() + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" + + isClosed = true + self.process = nil + stdinHandle = nil + stdoutHandle?.readabilityHandler = nil + stdoutHandle = nil + stderrHandle?.readabilityHandler = nil + stderrHandle = nil + signalPendingFailureLocked(detail) + + guard shouldNotify else { return } + onUnexpectedTermination(detail) + } + + private func stop(suppressTerminationCallback: Bool) { + let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" + let shouldNotify = !suppressTerminationCallback && !isClosed + shouldReportTermination = !suppressTerminationCallback + if isClosed { + return (nil, nil, nil, nil, false, detail) + } + + isClosed = true + signalPendingFailureLocked("daemon transport stopped") + let capturedProcess = process + let capturedStdin = stdinHandle + let capturedStdout = stdoutHandle + let capturedStderr = stderrHandle + + process = nil + stdinHandle = nil + stdoutHandle = nil + stderrHandle = nil + return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) + } + + captured.2?.readabilityHandler = nil + captured.3?.readabilityHandler = nil + try? captured.1?.close() + try? captured.2?.close() + try? captured.3?.close() + if let process = captured.0, process.isRunning { + process.terminate() + } + if captured.4 { + onUnexpectedTermination(captured.5) + } + } + + private func signalPendingFailureLocked(_ message: String) { + pendingCalls.failAll(message) + } + + private static func encodeJSON(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: []) + } + + private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { + let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" + // Use non-login sh so remote ~/.profile noise does not interfere with daemon transport startup. + let command = "sh -c \(shellSingleQuoted(script))" + return sshCommonArguments(configuration: configuration, batchMode: true) + [configuration.destination, command] + } + + private static let batchSSHControlOptionKeys: Set<String> = [ + "controlmaster", + "controlpersist", + ] + + private static func sshCommonArguments(configuration: WorkspaceRemoteConfiguration, batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + if batchMode { + args += ["-o", "BatchMode=yes"] + // Batch helpers should reuse an existing ControlPath if one was configured, + // but must never try to negotiate a new master connection. + args += ["-o", "ControlMaster=no"] + } + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let token = sshOptionKey(option) + if token == loweredKey { + return true + } + } + return false + } + + private static func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private static func backgroundSSHOptions(_ options: [String]) -> [String] { + normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !batchSSHControlOptionKeys.contains(key) + } + } + + private static func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private static func bestErrorLine(stderr: String) -> String? { + let lines = stderr + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } +} + +private final class WorkspaceRemoteDaemonProxyTunnel { + private final class ProxySession { + private static let maxHandshakeBytes = 64 * 1024 + private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + + private enum HandshakeProtocol { + case undecided + case socks5 + case connect + } + + private enum SocksStage { + case greeting + case request + } + + private struct SocksRequest { + let host: String + let port: Int + let command: UInt8 + let consumedBytes: Int + } + + let id = UUID() + + private let connection: NWConnection + private let rpcClient: WorkspaceRemoteDaemonRPCClient + private let queue: DispatchQueue + private let readQueue: DispatchQueue + private let onClose: (UUID) -> Void + + private var isClosed = false + private var protocolKind: HandshakeProtocol = .undecided + private var socksStage: SocksStage = .greeting + private var handshakeBuffer = Data() + private var streamID: String? + private var localInputEOF = false + + init( + connection: NWConnection, + rpcClient: WorkspaceRemoteDaemonRPCClient, + queue: DispatchQueue, + onClose: @escaping (UUID) -> Void + ) { + self.connection = connection + self.rpcClient = rpcClient + self.queue = queue + self.readQueue = DispatchQueue( + label: "com.cmux.remote-ssh.daemon-tunnel.proxy-read.\(UUID().uuidString)", + qos: .utility + ) + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .failed(let error): + self.close(reason: "proxy client connection failed: \(error)") + case .cancelled: + self.close(reason: nil) + default: + break + } + } + connection.start(queue: queue) + receiveNext() + } + + func stop() { + close(reason: nil) + } + + private func receiveNext() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in + guard let self, !self.isClosed else { return } + + if let data, !data.isEmpty { + if self.streamID == nil { + if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { + self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") + return + } + self.handshakeBuffer.append(data) + self.processHandshakeBuffer() + } else { + self.forwardToRemote(data) + } + } + + if isComplete { + // Treat local EOF as a half-close: keep remote read loop alive so we can + // drain upstream response bytes (for example curl closing write-side after + // sending an HTTP request through SOCKS/CONNECT). + self.localInputEOF = true + if self.streamID == nil { + self.close(reason: nil) + } + return + } + if let error { + self.close(reason: "proxy client receive error: \(error)") + return + } + + self.receiveNext() + } + } + + private func processHandshakeBuffer() { + guard !isClosed else { return } + while streamID == nil { + switch protocolKind { + case .undecided: + guard let first = handshakeBuffer.first else { return } + protocolKind = (first == 0x05) ? .socks5 : .connect + case .socks5: + if !processSocksHandshakeStep() { + return + } + case .connect: + if !processConnectHandshakeStep() { + return + } + } + } + } + + private func processSocksHandshakeStep() -> Bool { + switch socksStage { + case .greeting: + guard handshakeBuffer.count >= 2 else { return false } + let methodCount = Int(handshakeBuffer[1]) + let total = 2 + methodCount + guard handshakeBuffer.count >= total else { return false } + + let methods = [UInt8](handshakeBuffer[2..<total]) + handshakeBuffer = Data(handshakeBuffer.dropFirst(total)) + socksStage = .request + + if !methods.contains(0x00) { + sendAndClose(Data([0x05, 0xFF])) + return false + } + sendLocal(Data([0x05, 0x00])) + return true + + case .request: + let request: SocksRequest + do { + guard let parsed = try parseSocksRequest(from: handshakeBuffer) else { return false } + request = parsed + } catch { + sendAndClose(Data([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + let pending = handshakeBuffer.count > request.consumedBytes + ? Data(handshakeBuffer[request.consumedBytes...]) + : Data() + handshakeBuffer = Data() + guard request.command == 0x01 else { + sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + openRemoteStream( + host: request.host, + port: request.port, + successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + pendingPayload: pending + ) + return false + } + } + + private func parseSocksRequest(from data: Data) throws -> SocksRequest? { + let bytes = [UInt8](data) + guard bytes.count >= 4 else { return nil } + guard bytes[0] == 0x05 else { + throw NSError(domain: "cmux.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) + } + + let command = bytes[1] + let addressType = bytes[3] + var cursor = 4 + let host: String + + switch addressType { + case 0x01: + guard bytes.count >= cursor + 4 + 2 else { return nil } + let octets = bytes[cursor..<(cursor + 4)].map { String($0) } + host = octets.joined(separator: ".") + cursor += 4 + + case 0x03: + guard bytes.count >= cursor + 1 else { return nil } + let length = Int(bytes[cursor]) + cursor += 1 + guard bytes.count >= cursor + length + 2 else { return nil } + let hostData = Data(bytes[cursor..<(cursor + length)]) + host = String(data: hostData, encoding: .utf8) ?? "" + cursor += length + + case 0x04: + guard bytes.count >= cursor + 16 + 2 else { return nil } + var address = in6_addr() + withUnsafeMutableBytes(of: &address) { target in + for i in 0..<16 { + target[i] = bytes[cursor + i] + } + } + var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + let pointer = withUnsafePointer(to: &address) { + inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) + } + host = pointer != nil ? String(cString: text) : "" + cursor += 16 + + default: + throw NSError(domain: "cmux.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) + } + + guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw NSError(domain: "cmux.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) + } + guard bytes.count >= cursor + 2 else { return nil } + let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) + cursor += 2 + + guard port > 0 && port <= 65535 else { + throw NSError(domain: "cmux.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) + } + + return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) + } + + private func processConnectHandshakeStep() -> Bool { + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard let headerRange = handshakeBuffer.range(of: marker) else { return false } + + let headerData = Data(handshakeBuffer[..<headerRange.upperBound]) + let pending = headerRange.upperBound < handshakeBuffer.count + ? Data(handshakeBuffer[headerRange.upperBound...]) + : Data() + handshakeBuffer = Data() + guard let headerText = String(data: headerData, encoding: .utf8) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + let firstLine = headerText.components(separatedBy: "\r\n").first ?? "" + let parts = firstLine.split(whereSeparator: \.isWhitespace).map(String.init) + guard parts.count >= 2, parts[0].uppercased() == "CONNECT" else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + openRemoteStream( + host: host, + port: port, + successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), + failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), + pendingPayload: pending + ) + return false + } + + private func openRemoteStream( + host: String, + port: Int, + successResponse: Data, + failureResponse: Data, + pendingPayload: Data + ) { + guard !isClosed else { return } + do { + let targetHost = Self.normalizedProxyTargetHost(host) + let streamID = try rpcClient.openStream(host: targetHost, port: port) + self.streamID = streamID + connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if !pendingPayload.isEmpty { + self.forwardToRemote(pendingPayload, allowAfterEOF: true) + } + self.scheduleRemoteReadLoop() + }) + } catch { + sendAndClose(failureResponse) + } + } + + private func forwardToRemote(_ data: Data, allowAfterEOF: Bool = false) { + guard !isClosed else { return } + guard !localInputEOF || allowAfterEOF else { return } + guard let streamID else { return } + do { + try rpcClient.writeStream(streamID: streamID, data: data) + } catch { + close(reason: "proxy.write failed: \(error.localizedDescription)") + } + } + + private func scheduleRemoteReadLoop() { + guard let streamID else { return } + readQueue.async { [weak self] in + self?.pollRemoteOnce(streamID: streamID) + } + } + + private func pollRemoteOnce(streamID: String) { + let readResult: Result<(data: Data, eof: Bool), Error> + do { + readResult = .success(try rpcClient.readStream(streamID: streamID, maxBytes: 32768, timeoutMs: 250)) + } catch { + readResult = .failure(error) + } + + queue.async { [weak self] in + self?.handleRemoteReadResult(streamID: streamID, result: readResult) + } + } + + private func handleRemoteReadResult(streamID: String, result: Result<(data: Data, eof: Bool), Error>) { + guard !isClosed else { return } + guard self.streamID == streamID else { return } + + let readResult: (data: Data, eof: Bool) + switch result { + case .success(let value): + readResult = value + case .failure(let error): + close(reason: "proxy.read failed: \(error.localizedDescription)") + return + } + + if !readResult.data.isEmpty { + connection.send(content: readResult.data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if readResult.eof { + self.close(reason: nil) + } else { + self.scheduleRemoteReadLoop() + } + }) + return + } + + if readResult.eof { + close(reason: nil) + } else { + scheduleRemoteReadLoop() + } + } + + private func close(reason: String?) { + guard !isClosed else { return } + isClosed = true + + let streamID = self.streamID + self.streamID = nil + + if let streamID { + rpcClient.closeStream(streamID: streamID) + } + connection.cancel() + onClose(id) + } + + private func sendLocal(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + } + }) + } + + private func sendAndClose(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] _ in + self?.close(reason: nil) + }) + } + + private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { + let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("[") { + guard let closing = trimmed.firstIndex(of: "]") else { return nil } + let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing]) + let portStart = trimmed.index(after: closing) + guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil } + let portString = String(trimmed[trimmed.index(after: portStart)...]) + guard let port = Int(portString), port > 0, port <= 65535 else { return nil } + return (host, port) + } + + guard let colon = trimmed.lastIndex(of: ":") else { return nil } + let host = String(trimmed[..<colon]) + let portString = String(trimmed[trimmed.index(after: colon)...]) + guard !host.isEmpty else { return nil } + guard let port = Int(portString), port > 0, port <= 65535 else { return nil } + return (host, port) + } + + private static func normalizedProxyTargetHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. + // Resolve it back to true loopback before dialing from the remote daemon. + if normalized == remoteLoopbackProxyAliasHost { + return "127.0.0.1" + } + return host + } + + private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { + var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" + if closeAfterResponse { + text += "Connection: close\r\n" + } + text += "\r\n" + return Data(text.utf8) + } + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let localPort: Int + private let onFatalError: (String) -> Void + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var rpcClient: WorkspaceRemoteDaemonRPCClient? + private var sessions: [UUID: ProxySession] = [:] + private var isStopped = false + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + localPort: Int, + onFatalError: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.localPort = localPort + self.onFatalError = onFatalError + } + + func start() throws { + var capturedError: Error? + queue.sync { + guard !isStopped else { + capturedError = NSError(domain: "cmux.remote.proxy", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "proxy tunnel already stopped", + ]) + return + } + do { + let client = WorkspaceRemoteDaemonRPCClient( + configuration: configuration, + remotePath: remotePath + ) { [weak self] detail in + self?.queue.async { + self?.failLocked("Remote daemon transport failed: \(detail)") + } + } + try client.start() + + let listener = try Self.makeLoopbackListener(port: localPort) + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleListenerStateLocked(state) + } + } + + self.rpcClient = client + self.listener = listener + listener.start(queue: queue) + } catch { + capturedError = error + stopLocked(notify: false) + } + } + if let capturedError { + throw capturedError + } + } + + func stop() { + queue.sync { + stopLocked(notify: false) + } + } + + private func handleListenerStateLocked(_ state: NWListener.State) { + guard !isStopped else { return } + switch state { + case .failed(let error): + failLocked("Local proxy listener failed: \(error)") + default: + break + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + guard let rpcClient else { + connection.cancel() + return + } + + let session = ProxySession( + connection: connection, + rpcClient: rpcClient, + queue: queue + ) { [weak self] id in + self?.queue.async { + self?.sessions.removeValue(forKey: id) + } + } + sessions[session.id] = session + session.start() + } + + private func failLocked(_ detail: String) { + guard !isStopped else { return } + stopLocked(notify: false) + onFatalError(detail) + } + + private func stopLocked(notify: Bool) { + guard !isStopped else { return } + isStopped = true + + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listener = nil + + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + + rpcClient?.stop() + rpcClient = nil + } + + private static func makeLoopbackListener(port: Int) throws -> NWListener { + guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + throw NSError(domain: "cmux.remote.proxy", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "invalid local proxy port \(port)", + ]) + } + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) + return try NWListener(using: parameters) + } +} + +private final class WorkspaceRemoteProxyBroker { + enum Update { + case connecting + case ready(BrowserProxyEndpoint) + case error(String) + } + + final class Lease { + private let key: String + private let subscriberID: UUID + private weak var broker: WorkspaceRemoteProxyBroker? + private var isReleased = false + + fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { + self.key = key + self.subscriberID = subscriberID + self.broker = broker + } + + func release() { + guard !isReleased else { return } + isReleased = true + broker?.release(key: key, subscriberID: subscriberID) + } + + deinit { + release() + } + } + + private final class Entry { + let configuration: WorkspaceRemoteConfiguration + var remotePath: String + var tunnel: WorkspaceRemoteDaemonProxyTunnel? + var endpoint: BrowserProxyEndpoint? + var restartWorkItem: DispatchWorkItem? + var subscribers: [UUID: (Update) -> Void] = [:] + + init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { + self.configuration = configuration + self.remotePath = remotePath + } + } + + static let shared = WorkspaceRemoteProxyBroker() + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) + private var entries: [String: Entry] = [:] + + func acquire( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUpdate: @escaping (Update) -> Void + ) -> Lease { + queue.sync { + let key = Self.transportKey(for: configuration) + let subscriberID = UUID() + let entry: Entry + if let existing = entries[key] { + entry = existing + if existing.remotePath != remotePath { + existing.remotePath = remotePath + if existing.tunnel != nil { + stopEntryRuntimeLocked(existing) + notifyLocked(existing, update: .connecting) + } + } + } else { + entry = Entry(configuration: configuration, remotePath: remotePath) + entries[key] = entry + } + + entry.subscribers[subscriberID] = onUpdate + if let endpoint = entry.endpoint { + onUpdate(.ready(endpoint)) + } else { + onUpdate(.connecting) + } + + if entry.tunnel == nil, entry.restartWorkItem == nil { + startEntryLocked(key: key, entry: entry) + } + + return Lease(key: key, subscriberID: subscriberID, broker: self) + } + } + + private func release(key: String, subscriberID: UUID) { + queue.async { [weak self] in + guard let self, let entry = self.entries[key] else { return } + entry.subscribers.removeValue(forKey: subscriberID) + guard entry.subscribers.isEmpty else { return } + self.teardownEntryLocked(key: key, entry: entry) + } + } + + private func startEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + + let localPort: Int + if let forcedLocalPort = entry.configuration.localProxyPort { + // Internal deterministic test hook used by docker regressions to force bind conflicts. + localPort = forcedLocalPort + } else { + guard let allocatedPort = Self.allocateLoopbackPort() else { + notifyLocked( + entry, + update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: 3.0))") + ) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + return + } + localPort = allocatedPort + } + + do { + let tunnel = WorkspaceRemoteDaemonProxyTunnel( + configuration: entry.configuration, + remotePath: entry.remotePath, + localPort: localPort + ) { [weak self] detail in + self?.queue.async { + self?.handleTunnelFailureLocked(key: key, detail: detail) + } + } + try tunnel.start() + entry.tunnel = tunnel + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) + entry.endpoint = endpoint + notifyLocked(entry, update: .ready(endpoint)) + } catch { + stopEntryRuntimeLocked(entry) + let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))")) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + } + } + + private func handleTunnelFailureLocked(key: String, detail: String) { + guard let entry = entries[key], entry.tunnel != nil else { return } + stopEntryRuntimeLocked(entry) + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))")) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + } + + private func scheduleRestartLocked(key: String, entry: Entry, delay: TimeInterval) { + guard !entry.subscribers.isEmpty else { + teardownEntryLocked(key: key, entry: entry) + return + } + guard entry.restartWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + guard let self, let currentEntry = self.entries[key] else { return } + currentEntry.restartWorkItem = nil + guard !currentEntry.subscribers.isEmpty else { + self.teardownEntryLocked(key: key, entry: currentEntry) + return + } + self.notifyLocked(currentEntry, update: .connecting) + self.startEntryLocked(key: key, entry: currentEntry) + } + + entry.restartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func teardownEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + stopEntryRuntimeLocked(entry) + entries.removeValue(forKey: key) + } + + private func stopEntryRuntimeLocked(_ entry: Entry) { + entry.tunnel?.stop() + entry.tunnel = nil + entry.endpoint = nil + } + + private func notifyLocked(_ entry: Entry, update: Update) { + for callback in entry.subscribers.values { + callback(update) + } + } + + private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { + let destination = configuration.destination.trimmingCharacters(in: .whitespacesAndNewlines) + let port = configuration.port.map(String.init) ?? "" + let identity = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let localProxyPort = configuration.localProxyPort.map(String.init) ?? "" + let options = configuration.sshOptions + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\u{1f}") + return [destination, port, identity, options, localProxyPort].joined(separator: "\u{1e}") + } + + private static func allocateLoopbackPort() -> Int? { + for _ in 0..<8 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size)) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(0) + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size)) + } + } + guard bindResult == 0 else { continue } + + var bound = sockaddr_in() + var len = socklen_t(MemoryLayout<sockaddr_in>.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + getsockname(fd, sockaddrPtr, &len) + } + } + guard nameResult == 0 else { continue } + + let port = Int(UInt16(bigEndian: bound.sin_port)) + if port > 0 && port <= 65535 { + return port + } + } + return nil + } + + private static func retrySuffix(delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry in \(seconds)s)" + } +} + +private final class WorkspaceRemoteCLIRelayServer { + private final class Session { + private enum Phase { + case awaitingAuth + case awaitingCommand + case forwarding + case closed + } + + private let connection: NWConnection + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue: DispatchQueue + private let onClose: () -> Void + private let challengeProtocol = "cmux-relay-auth" + private let challengeVersion = 1 + private let minimumFailureDelay: TimeInterval = 0.05 + private let maximumFrameBytes = 16 * 1024 + + private var buffer = Data() + private var phase: Phase = .awaitingAuth + private var challengeNonce = "" + private var challengeSentAt = Date() + private var isClosed = false + + init( + connection: NWConnection, + localSocketPath: String, + relayID: String, + relayToken: Data, + queue: DispatchQueue, + onClose: @escaping () -> Void + ) { + self.connection = connection + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleState(state) + } + } + connection.start(queue: queue) + } + + func stop() { + close() + } + + private func handleState(_ state: NWConnection.State) { + guard !isClosed else { return } + switch state { + case .ready: + sendChallenge() + receive() + case .failed, .cancelled: + close() + default: + break + } + } + + private func sendChallenge() { + challengeSentAt = Date() + challengeNonce = Self.randomHex(byteCount: 16) + let challenge: [String: Any] = [ + "protocol": challengeProtocol, + "version": challengeVersion, + "relay_id": relayID, + "nonce": challengeNonce, + ] + sendJSONLine(challenge) { _ in } + } + + private func receive() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.queue.async { + if error != nil { + self.close() + return + } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.buffer.count > self.maximumFrameBytes { + self.sendFailureAndClose() + return + } + self.processBufferedLines() + } + if isComplete { + self.close() + return + } + if !self.isClosed { + self.receive() + } + } + } + } + + private func processBufferedLines() { + while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(...newlineIndex) + let line = String(data: lineData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch phase { + case .awaitingAuth: + handleAuthLine(line) + case .awaitingCommand: + handleCommandLine(Data(lineData) + Data([0x0A])) + case .forwarding, .closed: + return + } + } + } + + private func handleAuthLine(_ line: String) { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let receivedRelayID = object["relay_id"] as? String, + receivedRelayID == relayID, + let macHex = object["mac"] as? String, + let receivedMAC = Self.hexData(from: macHex) + else { + sendFailureAndClose() + return + } + + let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) + let expectedMAC = Self.authMAC(token: relayToken, message: message) + guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { + sendFailureAndClose() + return + } + + phase = .awaitingCommand + sendJSONLine(["ok": true]) { [weak self] _ in + self?.queue.async { + self?.processBufferedLines() + } + } + } + + private func handleCommandLine(_ commandLine: Data) { + guard !commandLine.isEmpty else { + sendFailureAndClose() + return + } + phase = .forwarding + DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in + let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + queue.async { [weak self] in + guard let self else { return } + switch result { + case .success(let response): + self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in + self?.queue.async { + self?.close() + } + }) + case .failure: + self.sendFailureAndClose() + } + } + } + } + + private func sendFailureAndClose() { + let elapsed = Date().timeIntervalSince(challengeSentAt) + let delay = max(0, minimumFailureDelay - elapsed) + phase = .closed + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendJSONLine(["ok": false]) { [weak self] _ in + self?.queue.async { + self?.close() + } + } + } + } + + private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { + guard !isClosed else { + completion(nil) + return + } + guard let payload = try? JSONSerialization.data(withJSONObject: object) else { + completion(nil) + return + } + connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) + } + + private func close() { + guard !isClosed else { return } + isClosed = true + phase = .closed + connection.stateUpdateHandler = nil + connection.cancel() + onClose() + } + + private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { + Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) + } + + private static func authMAC(token: Data, message: Data) -> Data { + let key = SymmetricKey(data: token) + let code = HMAC<SHA256>.authenticationCode(for: message, using: key) + return Data(code) + } + + private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var diff: UInt8 = 0 + for index in lhs.indices { + diff |= lhs[index] ^ rhs[index] + } + return diff == 0 + } + + fileprivate static func hexData(from string: String) -> Data? { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } + var data = Data(capacity: normalized.count / 2) + var cursor = normalized.startIndex + while cursor < normalized.endIndex { + let next = normalized.index(cursor, offsetBy: 2) + guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { return nil } + data.append(byte) + cursor = next + } + return data + } + + private static func randomHex(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "cmux.remote.relay", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "failed to create local relay socket", + ]) + } + defer { Darwin.close(fd) } + + var timeout = timeval(tv_sec: 15, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw NSError(domain: "cmux.remote.relay", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "local relay socket path is too long", + ]) + } + let sunPathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0 + withUnsafeMutableBytes(of: &address) { rawBuffer in + let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) + pathBytes.withUnsafeBytes { pathBuffer in + destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) + } + } + + let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) + let connectResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + guard connectResult == 0 else { + throw NSError(domain: "cmux.remote.relay", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "failed to connect to local cmux socket", + ]) + } + + try request.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var bytesRemaining = rawBuffer.count + var pointer = baseAddress + while bytesRemaining > 0 { + let written = Darwin.write(fd, pointer, bytesRemaining) + if written <= 0 { + throw NSError(domain: "cmux.remote.relay", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "failed to write relay request", + ]) + } + bytesRemaining -= written + pointer = pointer.advanced(by: written) + } + } + _ = shutdown(fd, SHUT_WR) + + var response = Data() + var scratch = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(fd, &scratch, scratch.count) + if count > 0 { + response.append(scratch, count: count) + continue + } + if count == 0 { + break + } + + if errno == EAGAIN || errno == EWOULDBLOCK { + if !response.isEmpty { + break + } + throw NSError(domain: "cmux.remote.relay", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local cmux response", + ]) + } + throw NSError(domain: "cmux.remote.relay", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "failed to read local cmux response", + ]) + } + return response + } + } + + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var sessions: [UUID: Session] = [:] + private var isStopped = false + private(set) var localPort: Int? + + 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", + ]) + } + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + } + + func start() throws -> Int { + var capturedError: Error? + var boundPort: Int = 0 + queue.sync { + do { + if let localPort { + boundPort = localPort + return + } + let listener = try Self.makeLoopbackListener() + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { _ in } + listener.start(queue: queue) + guard let tcpPort = listener.port?.rawValue else { + throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "failed to bind local relay listener", + ]) + } + self.listener = listener + self.localPort = Int(tcpPort) + boundPort = Int(tcpPort) + } catch { + capturedError = error + } + } + if let capturedError { + throw capturedError + } + return boundPort + } + + func stop() { + queue.sync { + guard !isStopped else { return } + isStopped = true + listener?.newConnectionHandler = nil + listener?.stateUpdateHandler = nil + listener?.cancel() + listener = nil + localPort = nil + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + let sessionID = UUID() + let session = Session( + connection: connection, + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken, + queue: queue + ) { [weak self] in + self?.sessions.removeValue(forKey: sessionID) + } + sessions[sessionID] = session + session.start() + } + + private static func makeLoopbackListener() throws -> NWListener { + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) + return try NWListener(using: parameters) + } +} + +private final class WorkspaceRemoteSessionController { + private struct CommandResult { + let status: Int32 + let stdout: String + let stderr: String + } + + private struct RemotePlatform { + let goOS: String + let goArch: String + } + + private struct DaemonHello { + let name: String + let version: String + let capabilities: [String] + let remotePath: String + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) + private let queueKey = DispatchSpecificKey<Void>() + private weak var workspace: Workspace? + private let configuration: WorkspaceRemoteConfiguration + private let controllerID: UUID + + private var isStopping = false + private var proxyLease: WorkspaceRemoteProxyBroker.Lease? + private var proxyEndpoint: BrowserProxyEndpoint? + private var daemonReady = false + private var daemonBootstrapVersion: String? + private var daemonRemotePath: String? + private var reverseRelayProcess: Process? + private var cliRelayServer: WorkspaceRemoteCLIRelayServer? + private var reverseRelayStderrPipe: Pipe? + private var reverseRelayRestartWorkItem: DispatchWorkItem? + private var reverseRelayStderrBuffer = "" + private var reconnectRetryCount = 0 + private var reconnectWorkItem: DispatchWorkItem? + private var heartbeatWorkItem: DispatchWorkItem? + private var heartbeatCount: Int = 0 + + private static let heartbeatInterval: TimeInterval = 3.0 + + init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { + self.workspace = workspace + self.configuration = configuration + self.controllerID = controllerID + queue.setSpecific(key: queueKey, value: ()) + } + + func start() { + debugLog("remote.session.start \(debugConfigSummary())") + queue.async { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + self.beginConnectionAttemptLocked() + } + } + + func stop() { + if DispatchQueue.getSpecific(key: queueKey) != nil { + stopAllLocked() + return + } + queue.async { [self] in + stopAllLocked() + } + } + + private func stopAllLocked() { + debugLog("remote.session.stop \(debugConfigSummary())") + isStopping = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + stopHeartbeatLocked(reset: true) + stopReverseRelayLocked() + + proxyLease?.release() + proxyLease = nil + proxyEndpoint = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + } + + private func beginConnectionAttemptLocked() { + guard !isStopping else { return } + + debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") + reconnectWorkItem = nil + stopHeartbeatLocked(reset: true) + let connectDetail: String + let bootstrapDetail: String + if reconnectRetryCount > 0 { + connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))" + } else { + connectDetail = "Connecting to \(configuration.displayTarget)" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)" + } + publishState(.connecting, detail: connectDetail) + publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) + do { + let hello = try bootstrapDaemonLocked() + guard hello.capabilities.contains("proxy.stream") else { + throw NSError(domain: "cmux.remote.daemon", code: 43, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + ]) + } + daemonReady = true + daemonBootstrapVersion = hello.version + daemonRemotePath = hello.remotePath + publishDaemonStatus( + .ready, + detail: "Remote daemon ready", + version: hello.version, + name: hello.name, + capabilities: hello.capabilities, + remotePath: hello.remotePath + ) + prepareRemoteCLISessionLocked(remotePath: hello.remotePath) + startReverseRelayLocked(remotePath: hello.remotePath) + startProxyLocked() + } catch { + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + let nextRetry = scheduleReconnectLocked(delay: 4.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0) + let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + } + } + + private func startProxyLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease == nil else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let nextRetry = scheduleReconnectLocked(delay: 4.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0) + let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + return + } + + let lease = WorkspaceRemoteProxyBroker.shared.acquire( + configuration: configuration, + remotePath: remotePath + ) { [weak self] update in + self?.queue.async { + self?.handleProxyBrokerUpdateLocked(update) + } + } + proxyLease = lease + } + + private func prepareRemoteCLISessionLocked(remotePath: String) { + createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) + } + + private func startReverseRelayLocked(remotePath: String) { + guard !isStopping else { return } + guard daemonReady else { return } + guard let relayPort = configuration.relayPort, relayPort > 0, + let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayID.isEmpty, + let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayToken.isEmpty, + let localSocketPath = configuration.localSocketPath? + .trimmingCharacters(in: .whitespacesAndNewlines), + !localSocketPath.isEmpty else { + return + } + guard reverseRelayProcess == nil else { return } + + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + do { + let relayServer = try ensureCLIRelayServerLocked( + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken + ) + let localRelayPort = try relayServer.start() + Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination) + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTerminationLocked(process: terminated) + } + } + + try process.run() + reverseRelayProcess = process + cliRelayServer = relayServer + reverseRelayStderrPipe = stderrPipe + reverseRelayStderrBuffer = "" + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget)" + ) + + queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + guard self.reverseRelayProcess === process, process.isRunning else { return } + self.writeRemoteRelayDaemonPathLocked(remotePath: remotePath) + do { + try self.writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken) + } catch { + self.debugLog("remote.relay.auth.error \(error.localizedDescription)") + self.stopReverseRelayLocked() + self.scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + self.writeRemoteSocketAddrLocked(relayPort: relayPort) + } + } catch { + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(error.localizedDescription)" + ) + cliRelayServer?.stop() + cliRelayServer = nil + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + } + + private func handleReverseRelayTerminationLocked(process: Process) { + guard reverseRelayProcess === process else { return } + let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + + guard !isStopping else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let detail = stderrDetail ?? "status=\(process.terminationStatus)" + debugLog("remote.relay.exit \(detail)") + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + + private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { + guard !isStopping else { return } + reverseRelayRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reverseRelayRestartWorkItem = nil + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + guard self.daemonReady else { return } + self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) + } + reverseRelayRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func stopReverseRelayLocked() { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if let reverseRelayProcess, reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + reverseRelayStderrBuffer = "" + cliRelayServer?.stop() + cliRelayServer = nil + removeRemoteRelayMetadataLocked() + } + + private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { + guard !isStopping else { return } + switch update { + case .connecting: + debugLog("remote.proxy.connecting \(debugConfigSummary())") + if proxyEndpoint == nil { + publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") + } + case .ready(let endpoint): + debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + guard proxyEndpoint != endpoint else { + startHeartbeatLocked() + return + } + proxyEndpoint = endpoint + publishProxyEndpoint(endpoint) + publishPortsSnapshotLocked() + publishState( + .connected, + detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" + ) + startHeartbeatLocked() + case .error(let detail): + debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") + proxyEndpoint = nil + stopHeartbeatLocked(reset: false) + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") + guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } + + proxyLease?.release() + proxyLease = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + + let nextRetry = scheduleReconnectLocked(delay: 2.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 2.0) + publishDaemonStatus( + .error, + detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" + ) + } + } + + @discardableResult + private func scheduleReconnectLocked(delay: TimeInterval) -> Int { + guard !isStopping else { return reconnectRetryCount } + reconnectWorkItem?.cancel() + reconnectRetryCount += 1 + let retryNumber = reconnectRetryCount + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reconnectWorkItem = nil + guard !self.isStopping else { return } + guard self.proxyLease == nil else { return } + self.beginConnectionAttemptLocked() + } + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + return retryNumber + } + + private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteConnectionStateUpdate( + state, + detail: detail, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func publishDaemonStatus( + _ state: WorkspaceRemoteDaemonState, + detail: String?, + version: String? = nil, + name: String? = nil, + capabilities: [String] = [], + remotePath: String? = nil + ) { + let controllerID = self.controllerID + let status = WorkspaceRemoteDaemonStatus( + state: state, + detail: detail, + version: version, + name: name, + capabilities: capabilities, + remotePath: remotePath + ) + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteDaemonStatusUpdate( + status, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteProxyEndpointUpdate(endpoint) + } + } + + private func publishPortsSnapshotLocked() { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemotePortsSnapshot( + detected: [], + forwarded: [], + conflicts: [], + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func startHeartbeatLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease != nil else { return } + guard heartbeatWorkItem == nil else { return } + + heartbeatCount += 1 + publishHeartbeat(count: heartbeatCount, at: Date()) + scheduleNextHeartbeatLocked() + } + + private func scheduleNextHeartbeatLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease != nil else { return } + + heartbeatWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.heartbeatWorkItem = nil + guard !self.isStopping else { return } + guard self.daemonReady else { return } + guard self.proxyLease != nil else { return } + self.heartbeatCount += 1 + self.publishHeartbeat(count: self.heartbeatCount, at: Date()) + self.scheduleNextHeartbeatLocked() + } + heartbeatWorkItem = workItem + queue.asyncAfter(deadline: .now() + Self.heartbeatInterval, execute: workItem) + } + + private func stopHeartbeatLocked(reset: Bool) { + heartbeatWorkItem?.cancel() + heartbeatWorkItem = nil + if reset { + heartbeatCount = 0 + publishHeartbeat(count: 0, at: nil) + } + } + + private func publishHeartbeat(count: Int, at date: Date?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) + } + } + + private func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { + // `-o ControlPath=none` is not enough on macOS OpenSSH, the client can still + // attach to an existing master and exit immediately with its status. + // `-S none` forces a standalone transport for the reverse relay. + var args: [String] = ["-N", "-T", "-S", "none"] + args += sshCommonArguments(batchMode: true) + args += [ + "-o", "ExitOnForwardFailure=no", + "-o", "RequestTTY=no", + "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", + configuration.destination, + ] + return args + } + + private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__=" + private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__=" + + private func sshCommonArguments(batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + if batchMode { + args += ["-o", "BatchMode=yes"] + args += ["-o", "ControlMaster=no"] + } + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let token = sshOptionKey(option) + if token == loweredKey { + return true + } + } + return false + } + + private func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private func backgroundSSHOptions(_ options: [String]) -> [String] { + let batchSSHControlOptionKeys: Set<String> = [ + "controlmaster", + "controlpersist", + ] + return normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !batchSSHControlOptionKeys.contains(key) + } + } + + private func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/ssh", + arguments: arguments, + stdin: stdin, + timeout: timeout + ) + } + + private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/scp", + arguments: arguments, + stdin: nil, + timeout: timeout + ) + } + + private func runProcess( + executable: String, + arguments: [String], + environment: [String: String]? = nil, + currentDirectory: URL? = nil, + stdin: Data?, + timeout: TimeInterval + ) throws -> CommandResult { + debugLog( + "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let environment { + process.environment = environment + } + if let currentDirectory { + process.currentDirectoryURL = currentDirectory + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + if stdin != nil { + process.standardInput = Pipe() + } else { + process.standardInput = FileHandle.nullDevice + } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + let captureQueue = DispatchQueue(label: "cmux.remote.process.capture") + var stdoutData = Data() + var stderrData = Data() + let captureGroup = DispatchGroup() + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stdoutHandle.readDataToEndOfFile() + captureQueue.sync { + stdoutData = data + } + captureGroup.leave() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stderrHandle.readDataToEndOfFile() + captureQueue.sync { + stderrData = data + } + captureGroup.leave() + } + + do { + try process.run() + } catch { + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + debugLog( + "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "error=\(error.localizedDescription)" + ) + throw NSError(domain: "cmux.remote.process", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", + ]) + } + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + + if let stdin, let pipe = process.standardInput as? Pipe { + pipe.fileHandleForWriting.write(stdin) + try? pipe.fileHandleForWriting.close() + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + process.terminate() + let terminateDeadline = Date().addingTimeInterval(2.0) + while process.isRunning && Date() < terminateDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + debugLog( + "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", + ]) + } + + _ = captureGroup.wait(timeout: .now() + 2.0) + try? stdoutHandle.close() + try? stderrHandle.close() + let stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + debugLog( + "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + + "stderr=\(Self.debugLogSnippet(stderr))" + ) + return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) + } + + private func bootstrapDaemonLocked() throws -> DaemonHello { + debugLog("remote.bootstrap.begin \(debugConfigSummary())") + let platform = try resolveRemotePlatformLocked() + let version = Self.remoteDaemonVersion() + let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) + debugLog( + "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + + "version=\(version) remotePath=\(remotePath)" + ) + + let hadExistingBinary = try remoteDaemonExistsLocked(remotePath: remotePath) + debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") + if !hadExistingBinary { + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + } + + var hello = try helloRemoteDaemonLocked(remotePath: remotePath) + if hadExistingBinary, !hello.capabilities.contains("proxy.stream") { + debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + + debugLog( + "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + + "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" + ) + return hello + } + + private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRemotePath.isEmpty else { return } + + let script = """ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + ln -sf "$HOME/.cmux/bin/cmuxd-remote-current" "$HOME/.cmux/bin/cmux" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.wrapper.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.wrapper.error \(error.localizedDescription)") + } + } + + private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { + if let cliRelayServer { + return cliRelayServer + } + let relayServer = try WorkspaceRemoteCLIRelayServer( + localSocketPath: localSocketPath, + relayID: relayID, + relayTokenHex: relayToken + ) + cliRelayServer = relayServer + return relayServer + } + + private func writeRemoteSocketAddrLocked(relayPort: Int) { + let script = """ + mkdir -p "$HOME/.cmux" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.socketAddr.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.socketAddr.error \(error.localizedDescription)") + } + } + + private func writeRemoteRelayDaemonPathLocked(remotePath: String) { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let trimmedRemotePath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRemotePath.isEmpty else { return } + + let script = """ + mkdir -p "$HOME/.cmux/relay" + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.daemonPath.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.daemonPath.error \(error.localizedDescription)") + } + } + + private func writeRemoteRelayAuthLocked(relayPort: Int, relayID: String, relayToken: String) throws { + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + let script = """ + umask 077 + mkdir -p "$HOME/.cmux/relay" + chmod 700 "$HOME/.cmux/relay" + cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' + \(authPayload) + CMUXRELAYAUTH + chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: 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)" + throw NSError(domain: "cmux.remote.relay", code: 70, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote relay auth: \(detail)", + ]) + } + } + + private func removeRemoteRelayMetadataLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let script = """ + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + } catch { + debugLog("remote.relay.cleanup.error \(error.localizedDescription)") + } + } + + private func resolveRemotePlatformLocked() throws -> RemotePlatform { + let script = """ + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$(uname -s)" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$(uname -m)" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", + ]) + } + + let lines = result.stdout + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } + let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } + guard let unameOS, let unameArch else { + throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "remote platform probe returned invalid output", + ]) + } + + guard let goOS = Self.mapUnameOS(unameOS), + let goArch = Self.mapUnameArch(unameArch) else { + throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", + ]) + } + + return RemotePlatform(goOS: goOS, goArch: goArch) + } + + private func remoteDaemonExistsLocked(remotePath: String) throws -> Bool { + let script = "if [ -x \(Self.shellSingleQuoted(remotePath)) ]; then echo yes; else echo no; fi" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { return false } + return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" + } + + static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" + + static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } + let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { + remoteDaemonManifest(from: Bundle.main.infoDictionary) + } + + private static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { + let appSupportRoot = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let cacheRoot = appSupportRoot + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + return cacheRoot + } + + static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try remoteDaemonCacheRoot(fileManager: fileManager) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private static func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" + } + + private static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { + guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } + guard let path = environment["CMUX_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + } + + private static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemon-build", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String) throws -> URL { + guard let url = URL(string: entry.downloadURL) else { + throw NSError(domain: "cmux.remote.daemon", code: 25, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", + ]) + } + + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) + let fileManager = FileManager.default + try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let request = NSMutableURLRequest(url: url) + request.timeoutInterval = 60 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + + let semaphore = DispatchSemaphore(value: 0) + var downloadedURL: URL? + var downloadError: Error? + session.downloadTask(with: request as URLRequest) { localURL, response, error in + defer { semaphore.signal() } + if let error { + downloadError = error + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + downloadError = NSError(domain: "cmux.remote.daemon", code: 26, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", + ]) + return + } + downloadedURL = localURL + }.resume() + _ = semaphore.wait(timeout: .now() + 75.0) + session.finishTasksAndInvalidate() + + if let downloadError { + throw downloadError + } + guard let downloadedURL else { + throw NSError(domain: "cmux.remote.daemon", code: 27, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download did not produce a file", + ]) + } + + let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) + guard downloadedSHA == entry.sha256.lowercased() else { + throw NSError(domain: "cmux.remote.daemon", code: 28, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", + ]) + } + + let tempURL = cacheURL.deletingLastPathComponent() + .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.moveItem(at: downloadedURL, to: tempURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: cacheURL) + try fileManager.moveItem(at: tempURL, to: cacheURL) + return cacheURL + } + + private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { + if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), + FileManager.default.isExecutableFile(atPath: explicitBinary.path) { + debugLog("remote.build.explicit path=\(explicitBinary.path)") + return explicitBinary + } + + if let manifest = Self.remoteDaemonManifest(), + manifest.appVersion == version, + let entry = manifest.entry(goOS: goOS, goArch: goArch) { + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) + if FileManager.default.fileExists(atPath: cacheURL.path) { + let cachedSHA = try Self.sha256Hex(forFile: cacheURL) + if cachedSHA == entry.sha256.lowercased(), + FileManager.default.isExecutableFile(atPath: cacheURL.path) { + debugLog("remote.build.cached path=\(cacheURL.path)") + return cacheURL + } + try? FileManager.default.removeItem(at: cacheURL) + } + let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion) + debugLog("remote.build.downloaded path=\(downloadedURL.path)") + return downloadedURL + } + + guard Self.allowLocalDaemonBuildFallback() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "this build does not include a verified cmuxd-remote manifest for \(goOS)-\(goArch). Use a release/nightly build, or set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", + ]) + } + + guard let repoRoot = Self.findRepoRoot() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only cmuxd-remote build fallback", + ]) + } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + let goModPath = daemonRoot.appendingPathComponent("go.mod").path + guard FileManager.default.fileExists(atPath: goModPath) else { + throw NSError(domain: "cmux.remote.daemon", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", + ]) + } + guard let goBinary = Self.which("go") else { + throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [ + NSLocalizedDescriptionKey: "go is required for the dev-only cmuxd-remote build fallback", + ]) + } + + let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + + var env = ProcessInfo.processInfo.environment + env["GOOS"] = goOS + env["GOARCH"] = goArch + env["CGO_ENABLED"] = "0" + let ldflags = "-s -w -X main.version=\(version)" + let result = try runProcess( + executable: goBinary, + arguments: ["build", "-trimpath", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"], + environment: env, + currentDirectory: daemonRoot, + stdin: nil, + timeout: 90 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 23, userInfo: [ + NSLocalizedDescriptionKey: "failed to build cmuxd-remote: \(detail)", + ]) + } + guard FileManager.default.isExecutableFile(atPath: output.path) else { + throw NSError(domain: "cmux.remote.daemon", code: 24, userInfo: [ + NSLocalizedDescriptionKey: "cmuxd-remote build output is not executable", + ]) + } + debugLog("remote.build.output path=\(output.path)") + return output + } + + private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { + let remoteDirectory = (remotePath as NSString).deletingLastPathComponent + let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" + debugLog( + "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" + ) + + let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" + let mkdirCommand = "sh -c \(Self.shellSingleQuoted(mkdirScript))" + let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) + guard mkdirResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [ + NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", + ]) + } + + let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) + var scpArgs: [String] = ["-q"] + if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { + scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] + } + scpArgs += ["-o", "ControlMaster=no"] + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] + let scpResult = try scpExec(arguments: scpArgs, timeout: 45) + guard scpResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [ + NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)", + ]) + } + + let finalizeScript = """ + chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \ + mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath)) + """ + let finalizeCommand = "sh -c \(Self.shellSingleQuoted(finalizeScript))" + let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) + guard finalizeResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", + ]) + } + } + + private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { + let request = #"{"id":1,"method":"hello","params":{}}"# + let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 40, userInfo: [ + NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", + ]) + } + + let responseLine = result.stdout + .split(separator: "\n") + .map(String.init) + .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? "" + guard !responseLine.isEmpty, + let data = responseLine.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + throw NSError(domain: "cmux.remote.daemon", code: 41, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON", + ]) + } + + if let ok = payload["ok"] as? Bool, !ok { + let errorMessage: String = { + if let errorObject = payload["error"] as? [String: Any], + let message = errorObject["message"] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return message + } + return "hello call failed" + }() + throw NSError(domain: "cmux.remote.daemon", code: 42, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)", + ]) + } + + let resultObject = payload["result"] as? [String: Any] ?? [:] + let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let capabilities = (resultObject["capabilities"] as? [String]) ?? [] + return DaemonHello( + name: (name?.isEmpty == false ? name! : "cmuxd-remote"), + version: (version?.isEmpty == false ? version! : "dev"), + capabilities: capabilities, + remotePath: remotePath + ) + } + + private func debugLog(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + private func debugConfigSummary() -> String { + let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" + return + "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + + "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + + "localSocket=\(configuration.localSocketPath ?? "nil") " + + "controlPath=\(controlPath)" + } + + private func debugShellCommand(executable: String, arguments: [String]) -> String { + ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) + .map(Self.shellSingleQuoted) + .joined(separator: " ") + } + + private static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + private static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { + let normalized = text + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return "\"\"" } + if normalized.count <= limit { + return normalized + } + return String(normalized.prefix(limit)) + "..." + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private static func mapUnameOS(_ raw: String) -> String? { + switch raw.lowercased() { + case "linux": + return "linux" + case "darwin": + return "darwin" + case "freebsd": + return "freebsd" + default: + return nil + } + } + + private static func mapUnameArch(_ raw: String) -> String? { + switch raw.lowercased() { + case "x86_64", "amd64": + return "amd64" + case "aarch64", "arm64": + return "arm64" + case "armv7l": + return "arm" + default: + return nil + } + } + + private static func remoteDaemonVersion() -> String { + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let bundleVersion, !bundleVersion.isEmpty { + return bundleVersion + } + return "dev" + } + + private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { + ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" + } + + private static func killOrphanedRelayProcesses(relayPort: Int, destination: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):127\\.0\\.0\\.1:[0-9]+.*\(destination)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + } catch { + // Best effort cleanup only. + } + } + + private static func which(_ executable: String) -> String? { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "" + for component in path.split(separator: ":") { + let candidate = String(component) + "/" + executable + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private static func findRepoRoot() -> URL? { + var candidates: [URL] = [] + let compileTimeRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Sources + .deletingLastPathComponent() // repo root + candidates.append(compileTimeRoot) + let environment = ProcessInfo.processInfo.environment + if let envRoot = environment["CMUX_REMOTE_DAEMON_SOURCE_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + if let envRoot = environment["CMUXTERM_REPO_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) + if let executable = Bundle.main.executableURL?.deletingLastPathComponent() { + candidates.append(executable) + candidates.append(executable.deletingLastPathComponent()) + candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent()) + } + + let fm = FileManager.default + for base in candidates { + var cursor = base.standardizedFileURL + for _ in 0..<10 { + let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path + if fm.fileExists(atPath: marker) { + return cursor + } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { + break + } + cursor = parent + } + } + return nil + } + + private static func bestErrorLine(stderr: String, stdout: String = "") -> String? { + if let stderrLine = meaningfulErrorLine(in: stderr) { + return stderrLine + } + if let stdoutLine = meaningfulErrorLine(in: stdout) { + return stdoutLine + } + return nil + } + + private static func meaningfulErrorLine(in text: String) -> String? { + let lines = text + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } + + private static func retrySuffix(retry: Int, delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry \(retry) in \(seconds)s)" + } + + private static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote daemon transport failed") + || lowered.contains("daemon transport closed stdout") + || lowered.contains("daemon transport exited") + || lowered.contains("daemon transport is not connected") + || lowered.contains("daemon transport stopped") + } + +} + enum SidebarLogLevel: String { case info case progress @@ -638,6 +3905,58 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum WorkspaceRemoteConnectionState: String { + case disconnected + case connecting + case connected + case error +} + +enum WorkspaceRemoteDaemonState: String { + case unavailable + case bootstrapping + case ready + case error +} + +struct WorkspaceRemoteDaemonStatus: Equatable { + var state: WorkspaceRemoteDaemonState = .unavailable + var detail: String? + var version: String? + var name: String? + var capabilities: [String] = [] + var remotePath: String? + + func payload() -> [String: Any] { + [ + "state": state.rawValue, + "detail": detail ?? NSNull(), + "version": version ?? NSNull(), + "name": name ?? NSNull(), + "capabilities": capabilities, + "remote_path": remotePath ?? NSNull(), + ] + } +} + +struct WorkspaceRemoteConfiguration: Equatable { + let destination: String + let port: Int? + let identityFile: String? + let sshOptions: [String] + let localProxyPort: Int? + let relayPort: Int? + let relayID: String? + let relayToken: String? + let localSocketPath: String? + let terminalStartupCommand: String? + + var displayTarget: String { + guard let port else { return destination } + return "\(destination):\(port)" + } +} + enum SidebarPullRequestStatus: String { case open case merged @@ -996,10 +4315,44 @@ final class Workspace: Identifiable, ObservableObject { @Published var pullRequest: SidebarPullRequestState? @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] + @Published var remoteConfiguration: WorkspaceRemoteConfiguration? + @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected + @Published var remoteConnectionDetail: String? + @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() + @Published var remoteDetectedPorts: [Int] = [] + @Published var remoteForwardedPorts: [Int] = [] + @Published var remotePortConflicts: [Int] = [] + @Published var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published var remoteHeartbeatCount: Int = 0 + @Published var remoteLastHeartbeatAt: Date? @Published var listeningPorts: [Int] = [] + @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 var surfaceTTYNames: [UUID: String] = [:] + private var remoteSessionController: WorkspaceRemoteSessionController? + fileprivate var activeRemoteSessionControllerID: UUID? + private var remoteLastErrorFingerprint: String? + private var remoteLastDaemonErrorFingerprint: String? + private var remoteLastPortConflictFingerprint: String? + private var activeRemoteTerminalSurfaceIds: Set<UUID> = [] + + private static let remoteErrorStatusKey = "remote.error" + private static let remotePortConflictStatusKey = "remote.port_conflicts" + private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] + private static func isProxyOnlyRemoteError(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote proxy") + || lowered.contains("proxy_unavailable") + || lowered.contains("local daemon proxy") + || lowered.contains("proxy failure") + || lowered.contains("daemon transport") + } + var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { get { panelDirectories } @@ -1018,10 +4371,10 @@ final class Workspace: Identifiable, ObservableObject { private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( - newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")), - newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")), - splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")), - splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down")) + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") ) } @@ -1092,24 +4445,18 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" ) } } - func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { - applyGhosttyChrome( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundColor.alphaComponent, - reason: reason - ) - } - init( title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0, - configTemplate: ghostty_surface_config_s? = nil + configTemplate: ghostty_surface_config_s? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:] ) { self.id = UUID() self.portOrdinal = portOrdinal @@ -1154,7 +4501,9 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: initialTerminalCommand, + initialEnvironmentOverrides: initialTerminalEnvironment ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle @@ -1205,6 +4554,11 @@ final class Workspace: Identifiable, ObservableObject { } } + deinit { + activeRemoteSessionControllerID = nil + remoteSessionController?.stop() + } + func refreshSplitButtonTooltips() { let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration @@ -1337,8 +4691,8 @@ final class Workspace: Identifiable, ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self, weak markdownPanel] newTitle in - guard let self = self, - let markdownPanel = markdownPanel, + guard let self, + let markdownPanel, let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } guard let existing = self.bonsplitController.tab(tabId) else { return } @@ -1356,6 +4710,24 @@ final class Workspace: Identifiable, ObservableObject { panelSubscriptions[markdownPanel.id] = subscription } + private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { + guard let target = remoteDisplayTarget else { return nil } + return BrowserRemoteWorkspaceStatus( + target: target, + connectionState: remoteConnectionState, + heartbeatCount: remoteHeartbeatCount, + lastHeartbeatAt: remoteLastHeartbeatAt + ) + } + + private func applyBrowserRemoteWorkspaceStatusToPanels() { + let snapshot = browserRemoteWorkspaceStatusSnapshot() + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteWorkspaceStatus(snapshot) + } + } + // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { @@ -1790,7 +5162,7 @@ final class Workspace: Identifiable, ObservableObject { } func recomputeListeningPorts() { - let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) + let unique = Set(surfaceListeningPorts.values.flatMap { $0 }).union(remoteForwardedPorts) let next = unique.sorted() if listeningPorts != next { listeningPorts = next @@ -1874,6 +5246,337 @@ final class Workspace: Identifiable, ObservableObject { } } + var isRemoteWorkspace: Bool { + remoteConfiguration != nil + } + + var remoteDisplayTarget: String? { + remoteConfiguration?.displayTarget + } + + var hasActiveRemoteTerminalSessions: Bool { + activeRemoteTerminalSessionCount > 0 + } + + func remoteStatusPayload() -> [String: Any] { + let heartbeatAgeSeconds: Any = { + guard let last = remoteLastHeartbeatAt else { return NSNull() } + return max(0, Date().timeIntervalSince(last)) + }() + let heartbeatTimestamp: Any = { + guard let last = remoteLastHeartbeatAt else { return NSNull() } + return Self.remoteHeartbeatDateFormatter.string(from: last) + }() + var payload: [String: Any] = [ + "enabled": remoteConfiguration != nil, + "state": remoteConnectionState.rawValue, + "connected": remoteConnectionState == .connected, + "active_terminal_sessions": activeRemoteTerminalSessionCount, + "daemon": remoteDaemonStatus.payload(), + "detected_ports": remoteDetectedPorts, + "forwarded_ports": remoteForwardedPorts, + "conflicted_ports": remotePortConflicts, + "detail": remoteConnectionDetail ?? NSNull(), + "heartbeat": [ + "count": remoteHeartbeatCount, + "last_seen_at": heartbeatTimestamp, + "age_seconds": heartbeatAgeSeconds, + ], + ] + if let endpoint = remoteProxyEndpoint { + payload["proxy"] = [ + "state": "ready", + "host": endpoint.host, + "port": endpoint.port, + "schemes": ["socks5", "http_connect"], + "url": "socks5://\(endpoint.host):\(endpoint.port)", + ] + } else { + let proxyState: String + switch remoteConnectionState { + case .connecting: + proxyState = "connecting" + case .error: + proxyState = "error" + default: + proxyState = "unavailable" + } + payload["proxy"] = [ + "state": proxyState, + "host": NSNull(), + "port": NSNull(), + "schemes": ["socks5", "http_connect"], + "url": NSNull(), + "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), + ] + } + if let remoteConfiguration { + payload["destination"] = remoteConfiguration.destination + payload["port"] = remoteConfiguration.port ?? NSNull() + payload["identity_file"] = remoteConfiguration.identityFile ?? NSNull() + payload["ssh_options"] = remoteConfiguration.sshOptions + payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() + } else { + payload["destination"] = NSNull() + payload["port"] = NSNull() + payload["identity_file"] = NSNull() + payload["ssh_options"] = [] + payload["local_proxy_port"] = NSNull() + } + return payload + } + + func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { + remoteConfiguration = configuration + seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + recomputeListeningPorts() + + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + remoteSessionController = nil + previousController?.stop() + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + + guard autoConnect else { + remoteConnectionState = .disconnected + applyBrowserRemoteWorkspaceStatusToPanels() + return + } + + remoteConnectionState = .connecting + applyBrowserRemoteWorkspaceStatusToPanels() + let controllerID = UUID() + let controller = WorkspaceRemoteSessionController( + workspace: self, + configuration: configuration, + controllerID: controllerID + ) + activeRemoteSessionControllerID = controllerID + remoteSessionController = controller + controller.start() + } + + func reconnectRemoteConnection() { + guard let configuration = remoteConfiguration else { return } + configureRemoteConnection(configuration, autoConnect: true) + } + + func disconnectRemoteConnection(clearConfiguration: Bool = false) { + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + remoteSessionController = nil + previousController?.stop() + activeRemoteTerminalSurfaceIds.removeAll() + activeRemoteTerminalSessionCount = 0 + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionState = .disconnected + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + if clearConfiguration { + remoteConfiguration = nil + } + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + recomputeListeningPorts() + } + + private func clearRemoteConfigurationIfWorkspaceBecameLocal() { + guard panels.isEmpty, remoteConfiguration != nil else { return } + disconnectRemoteConnection(clearConfiguration: true) + } + + private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return + } + guard activeRemoteTerminalSurfaceIds.isEmpty else { return } + let terminalIds = panels.compactMap { panelId, panel in + panel is TerminalPanel ? panelId : nil + } + guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + trackRemoteTerminalSurface(initialPanelId) + } + + private func trackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + } + + private func untrackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() + } + + private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { + guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } + let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } + if !hasBrowserPanels { + disconnectRemoteConnection(clearConfiguration: true) + } + } + + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return + } + untrackRemoteTerminalSurface(surfaceId) + } + + func teardownRemoteConnection() { + disconnectRemoteConnection(clearConfiguration: true) + } + + fileprivate func applyRemoteConnectionStateUpdate( + _ state: WorkspaceRemoteConnectionState, + detail: String?, + target: String + ) { + remoteConnectionState = state + remoteConnectionDetail = detail + applyBrowserRemoteWorkspaceStatusToPanels() + + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + if state == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let proxyOnlyError = Self.isProxyOnlyRemoteError(trimmedDetail) + let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" + let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" + let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" + let logSource = proxyOnlyError ? "remote-proxy" : "remote" + statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( + key: Self.remoteErrorStatusKey, + value: "\(statusPrefix) (\(target)): \(trimmedDetail)", + icon: statusIcon, + color: nil, + timestamp: Date() + ) + + let fingerprint = "connection:\(trimmedDetail)" + if remoteLastErrorFingerprint != fingerprint { + remoteLastErrorFingerprint = fingerprint + appendSidebarLog( + message: "\(statusPrefix) (\(target)): \(trimmedDetail)", + level: .error, + source: logSource + ) + AppDelegate.shared?.notificationStore?.addNotification( + tabId: id, + surfaceId: nil, + title: notificationTitle, + subtitle: target, + body: trimmedDetail + ) + } + return + } + + if state != .error { + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + remoteLastErrorFingerprint = nil + } + } + + fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { + remoteDaemonStatus = status + applyBrowserRemoteWorkspaceStatusToPanels() + guard status.state == .error else { + remoteLastDaemonErrorFingerprint = nil + return + } + let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" + let fingerprint = "daemon:\(trimmedDetail)" + guard remoteLastDaemonErrorFingerprint != fingerprint else { return } + remoteLastDaemonErrorFingerprint = fingerprint + appendSidebarLog( + message: "Remote daemon error (\(target)): \(trimmedDetail)", + level: .error, + source: "remote-daemon" + ) + } + + fileprivate func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { + remoteProxyEndpoint = endpoint + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteProxyEndpoint(endpoint) + } + applyBrowserRemoteWorkspaceStatusToPanels() + } + + fileprivate func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { + remoteHeartbeatCount = max(0, count) + remoteLastHeartbeatAt = lastSeenAt + applyBrowserRemoteWorkspaceStatusToPanels() + } + + fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) { + remoteDetectedPorts = detected + remoteForwardedPorts = forwarded + remotePortConflicts = conflicts + recomputeListeningPorts() + + if conflicts.isEmpty { + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastPortConflictFingerprint = nil + return + } + + let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") + statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( + key: Self.remotePortConflictStatusKey, + value: "SSH port conflicts (\(target)): \(conflictsList)", + icon: "exclamationmark.triangle.fill", + color: nil, + timestamp: Date() + ) + + let fingerprint = conflicts.map(String.init).joined(separator: ",") + guard remoteLastPortConflictFingerprint != fingerprint else { return } + remoteLastPortConflictFingerprint = fingerprint + appendSidebarLog( + message: "Port conflicts while forwarding \(target): \(conflictsList)", + level: .warning, + source: "remote-forward" + ) + } + + private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) + let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 + let limit = max(1, min(500, configuredLimit)) + if logEntries.count > limit { + logEntries.removeFirst(logEntries.count - limit) + } + } + // MARK: - Panel Operations private func seedTerminalInheritanceFontPoints( @@ -2060,26 +5763,21 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) - - // Inherit working directory: prefer the source panel's reported cwd, - // fall back to the workspace's current directory. - let splitWorkingDirectory: String? = panelDirectories[panelId] - ?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil : currentDirectory) -#if DEBUG - dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") -#endif + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - workingDirectory: splitWorkingDirectory, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit @@ -2105,6 +5803,9 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -2147,6 +5848,7 @@ final class Workspace: Identifiable, ObservableObject { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create new terminal panel let newPanel = TerminalPanel( @@ -2154,11 +5856,15 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: workingDirectory, - additionalEnvironment: startupEnvironment, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand, + additionalEnvironment: startupEnvironment ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit @@ -2172,6 +5878,9 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -2190,6 +5899,12 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } + private func remoteTerminalStartupCommand() -> String? { + guard hasActiveRemoteTerminalSessions else { return nil } + return remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + /// Create a new browser panel split @discardableResult func newBrowserSplit( @@ -2213,7 +5928,12 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } // Create browser panel - let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) + let browserPanel = BrowserPanel( + workspaceId: id, + initialURL: url, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace + ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -2257,6 +5977,7 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } @@ -2278,7 +5999,9 @@ final class Workspace: Identifiable, ObservableObject { let browserPanel = BrowserPanel( workspaceId: id, initialURL: url, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -2314,13 +6037,11 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } - // MARK: - Markdown Panel Creation - - /// Create a new markdown panel split from an existing panel. func newMarkdownSplit( from panelId: UUID, orientation: SplitOrientation, @@ -2328,7 +6049,6 @@ final class Workspace: Identifiable, ObservableObject { filePath: String, focus: Bool = true ) -> MarkdownPanel? { - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? for paneId in bonsplitController.allPaneIds { @@ -2341,12 +6061,10 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } - // Create markdown panel let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel panelTitles[markdownPanel.id] = markdownPanel.displayTitle - // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( title: markdownPanel.displayTitle, icon: markdownPanel.displayIcon, @@ -2358,8 +6076,6 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = markdownPanel.id let previousFocusedPanelId = focusedPanelId - // Create the split with the markdown tab already present in the new pane. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { @@ -2369,7 +6085,6 @@ final class Workspace: Identifiable, ObservableObject { return nil } - // Suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { previousHostedView?.suppressReparentFocus() @@ -2386,11 +6101,9 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } - /// Create a new markdown surface (tab) in the specified pane. @discardableResult func newMarkdownSurface( inPane paneId: PaneID, @@ -2418,8 +6131,6 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = markdownPanel.id - - // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) @@ -2427,7 +6138,6 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } @@ -2455,29 +6165,12 @@ final class Workspace: Identifiable, ObservableObject { /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { -#if DEBUG - let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId) - dlog( - "surface.close.request panel=\(panelId.uuidString.prefix(5)) " + - "force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + - "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) } // Close the tab in bonsplit (this triggers delegate callback) - let closed = bonsplitController.closeTab(tabId) -#if DEBUG - dlog( - "surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " + - "tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)" - ) -#endif - return closed + return bonsplitController.closeTab(tabId) } // Mapping can transiently drift during split-tree mutations. If the target panel is @@ -2509,38 +6202,12 @@ final class Workspace: Identifiable, ObservableObject { dlog( "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + "selectedTab=\(String(describing: selected.id).prefix(5)) " + - "closed=\(closed ? 1 : 0) " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" + "closed=\(closed ? 1 : 0)" ) #endif return closed } -#if DEBUG - private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String { - guard let panel else { return "panelState=missing" } - if let terminal = panel as? TerminalPanel { - let hosted = terminal.hostedView - let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height) - let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height) - let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0 - return - "panelState=terminal panel=\(panelId.uuidString.prefix(5)) " + - "surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " + - "inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " + - "hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" - } - if let browser = panel as? BrowserPanel { - let webView = browser.webView - let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height) - return - "panelState=browser panel=\(panelId.uuidString.prefix(5)) " + - "webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)" - } - return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))" - } -#endif - func paneId(forPanelId panelId: UUID) -> PaneID? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } return bonsplitController.allPaneIds.first { paneId in @@ -2748,7 +6415,6 @@ final class Workspace: Identifiable, ObservableObject { in: bonsplitController.treeSnapshot() ) let resolvedURL = browserPanel.currentURL - ?? browserPanel.webView.url ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( @@ -2947,6 +6613,8 @@ final class Workspace: Identifiable, ObservableObject { terminalPanel.updateWorkspaceId(id) } else if let browserPanel = detached.panel as? BrowserPanel { browserPanel.updateWorkspaceId(id) + browserPanel.setRemoteProxyEndpoint(remoteProxyEndpoint) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) installBrowserPanelSubscription(browserPanel) } @@ -3206,6 +6874,10 @@ final class Workspace: Identifiable, ObservableObject { ) } + if let browserPanel = panels[panelId] as? BrowserPanel { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + } + if trigger == .terminalFirstResponder, panels[panelId] is TerminalPanel { scheduleTerminalFirstResponderReassert(panelId: panelId) @@ -3405,7 +7077,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerNotificationDismissFlash() + terminalPanel.triggerFlash() } func triggerDebugFlash(panelId: UUID) { @@ -3425,16 +7097,10 @@ final class Workspace: Identifiable, ObservableObject { } } - /// Hide all browser portal views for this workspace. - /// Called before the workspace is unmounted so a portal-hosted WKWebView - /// cannot remain visible after this workspace stops being selected. func hideAllBrowserPortalViews() { for panel in panels.values { guard let browser = panel as? BrowserPanel else { continue } - BrowserWindowPortalRegistry.hide( - webView: browser.webView, - source: "workspaceRetire" - ) + browser.hideBrowserPortalView(source: "workspaceRetire") } } @@ -3577,11 +7243,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - let geometryChanged = hostedView.reconcileGeometryNow() + hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if geometryChanged, terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") + if terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh() } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -3879,9 +7545,9 @@ final class Workspace: Identifiable, ObservableObject { let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard let self, let panel = self.terminalPanel(for: panelId) else { return } - let geometryChanged = panel.hostedView.reconcileGeometryNow() - if geometryChanged, panel.surface.surface != nil { - panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") + panel.hostedView.reconcileGeometryNow() + if panel.surface.surface != nil { + panel.surface.forceRefresh() } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -3948,15 +7614,15 @@ final class Workspace: Identifiable, ObservableObject { let panel = panels[panelId] else { return } let alert = NSAlert() - alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab") - alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.") + alert.messageText = "Rename Tab" + alert.informativeText = "Enter a custom name for this tab." let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle let input = NSTextField(string: currentTitle) - input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name") + input.placeholderString = "Tab name" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input - alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { @@ -3985,24 +7651,24 @@ final class Workspace: Identifiable, ObservableObject { ) var options: [(title: String, destination: PanelMoveDestination)] = [ - (String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), - (String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), + ("New Workspace in Current Window", .newWorkspaceInCurrentWindow), + ("Selected Workspace in New Window", .selectedWorkspaceInNewWindow), ] options.append(contentsOf: workspaceTargets.map { target in (target.label, .existingWorkspace(target.workspaceId)) }) let alert = NSAlert() - alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab") - alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.") + alert.messageText = "Move Tab" + alert.informativeText = "Choose a destination for this tab." let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) for option in options { popup.addItem(withTitle: option.title) } popup.selectItem(at: 0) alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Move") + alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) @@ -4048,9 +7714,9 @@ final class Workspace: Identifiable, ObservableObject { if !moved { let failure = NSAlert() failure.alertStyle = .warning - failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed") - failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.") - failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + failure.messageText = "Move Failed" + failure.informativeText = "cmux could not move this tab to the selected destination." + failure.addButton(withTitle: "OK") _ = failure.runModal() } } @@ -4117,11 +7783,11 @@ extension Workspace: BonsplitDelegate { @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { let alert = NSAlert() - alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") - alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") + alert.messageText = "Close tab?" + alert.informativeText = "This will close the current tab." alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") // Prefer a sheet if we can find a window, otherwise fall back to modal. if let window = NSApp.keyWindow ?? NSApp.mainWindow { @@ -4581,11 +8247,7 @@ extension Workspace: BonsplitDelegate { // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG - dlog( - "surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " + - "panels=\(panels.count) panes=\(controller.allPaneIds.count)" - ) + NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif scheduleTerminalGeometryReconcile() if !isDetaching { @@ -4594,15 +8256,11 @@ extension Workspace: BonsplitDelegate { return } + #if DEBUG + NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") + #endif + let panel = panels[panelId] -#if DEBUG - dlog( - "surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + - "isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panel))" - ) -#endif if isDetaching, let panel { let browserPanel = panel as? BrowserPanel @@ -4630,6 +8288,7 @@ extension Workspace: BonsplitDelegate { } panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) @@ -4647,18 +8306,13 @@ extension Workspace: BonsplitDelegate { if lastTerminalConfigInheritancePanelId == panelId { lastTerminalConfigInheritancePanelId = nil } + clearRemoteConfigurationIfWorkspaceBecameLocal() // Keep the workspace invariant for normal close paths. // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can // prune the source workspace/window after the tab is attached elsewhere. if panels.isEmpty { if isDetaching { -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace" - ) -#endif scheduleTerminalGeometryReconcile() return } @@ -4672,13 +8326,6 @@ extension Workspace: BonsplitDelegate { } scheduleTerminalGeometryReconcile() scheduleFocusReconcile() -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " + - "replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)" - ) -#endif return } @@ -4700,15 +8347,6 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } -#if DEBUG - let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " + - "focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)" - ) -#endif scheduleTerminalGeometryReconcile() if !isDetaching { scheduleFocusReconcile() @@ -4793,23 +8431,12 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] let shouldScheduleFocusReconcile = !isDetachingCloseTransaction -#if DEBUG - dlog( - "surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " + - "closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)" - ) -#endif if !closedPanelIds.isEmpty { for panelId in closedPanelIds { -#if DEBUG - dlog( - "surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif panels[panelId]?.close() panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) panelPullRequests.removeValue(forKey: panelId) @@ -4827,6 +8454,7 @@ extension Workspace: BonsplitDelegate { let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() + clearRemoteConfigurationIfWorkspaceBecameLocal() if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { @@ -4840,12 +8468,6 @@ extension Workspace: BonsplitDelegate { if shouldScheduleFocusReconcile { scheduleFocusReconcile() } -#if DEBUG - dlog( - "surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " + - "remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)" - ) -#endif } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e2c65a34..36bc6a05 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3086,6 +3086,7 @@ struct SettingsView: View { private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -3697,6 +3698,17 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), + subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") + ) { + Toggle("", isOn: $sidebarShowSSH) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") @@ -4382,6 +4394,7 @@ struct SettingsView: View { sidebarShowPullRequest = true openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + sidebarShowSSH = true sidebarShowPorts = true sidebarShowLog = true sidebarShowProgress = true diff --git a/TODO.md b/TODO.md index 7538404a..5453b8f5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,18 @@ # TODO +## Issue 151: Remote SSH (Living Execution) +- [x] `cmux ssh` creates remote workspace metadata and does not require `--name` +- [x] Remote daemon bootstrap/upload/start path with `cmuxd-remote serve --stdio` +- [x] Reconnect/disconnect controls (CLI/API/context menu) + improved error surfacing +- [x] Retry count/time surfaced in remote daemon/probe error details +- [ ] Remove automatic remote service port mirroring (`ssh -L` from detected remote listening ports) +- [ ] Add transport-scoped proxy broker (SOCKS5 + HTTP CONNECT) for remote traffic +- [ ] Extend `cmuxd-remote` RPC beyond `hello/ping` with proxy stream methods (`proxy.open|close`) +- [ ] Auto-wire WKWebView in remote workspaces to proxy via `WKWebsiteDataStore.proxyConfigurations` +- [ ] Add browser proxy e2e tests (remote egress IP, websocket, reconnect continuity) +- [ ] Implement PTY resize coordinator with tmux semantics (`smallest screen wins`) +- [ ] Add resize tests for multi-attachment sessions (attach/detach/reconnect transitions) + ## Socket API / Agent - [x] Add window handles + `window.list/current/focus/create/close` for multi-window socket control (v2) + v1 equivalents (`list_windows`, etc) + CLI support. - [x] Add surface move/reorder commands (move between panes, reorder within pane, move across workspaces/windows). @@ -41,7 +54,7 @@ - [ ] OpenCode integration ## Browser -- [ ] Per-WKWebView local proxy for full network request/response inspection (URL, method, headers, body, status, timing) +- [ ] Per-WKWebView proxy observability/inspection once remote proxy path is shipped (URL, method, headers, body, status, timing) ## Bugs - [ ] **P0** Terminal title updates are suppressed when workspace is not focused (e.g. Claude Code loading indicator doesn't update in sidebar until you switch to that tab) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 580466bd..bbe59232 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4782,6 +4782,74 @@ final class UpdateChannelSettingsTests: XCTestCase { } } +final class SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } + + func testParsedTargetAndDetailParsesCanonicalStatusValue() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error (devbox:22): failed to bootstrap daemon" + ) + XCTAssertEqual(parsed?.target, "devbox:22") + XCTAssertEqual(parsed?.detail, "failed to bootstrap daemon") + } + + func testParsedTargetAndDetailUsesFallbackTargetWhenStatusOmitsTarget() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error: connection refused", + fallbackTarget: "fallback-host" + ) + XCTAssertEqual(parsed?.target, "fallback-host") + XCTAssertEqual(parsed?.detail, "connection refused") + } + + func testParsedTargetAndDetailIgnoresNonSSHStatusValues() { + XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good")) + } +} + final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 26d3a789..d7a4b136 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -659,6 +659,104 @@ final class WindowTransparencyDecisionTests: XCTestCase { } } +final class WorkspaceRemoteDaemonManifestTests: XCTestCase { + func testParsesEmbeddedRemoteDaemonManifestJSON() throws { + let manifestJSON = """ + { + "schemaVersion": 1, + "appVersion": "0.62.0", + "releaseTag": "v0.62.0", + "releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0", + "checksumsAssetName": "cmuxd-remote-checksums.txt", + "checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-checksums.txt", + "entries": [ + { + "goOS": "linux", + "goArch": "amd64", + "assetName": "cmuxd-remote-linux-amd64", + "downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-linux-amd64", + "sha256": "abc123" + } + ] + } + """ + + let manifest = Workspace.remoteDaemonManifest(from: [ + Workspace.remoteDaemonManifestInfoKey: manifestJSON, + ]) + + XCTAssertEqual(manifest?.releaseTag, "v0.62.0") + XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "cmuxd-remote-linux-amd64") + } + + func testRemoteDaemonCachePathIsVersionedByPlatform() throws { + let url = try Workspace.remoteDaemonCachedBinaryURL( + version: "0.62.0", + goOS: "linux", + goArch: "arm64" + ) + + XCTAssertTrue(url.path.contains("/Application Support/cmux/remote-daemons/0.62.0/linux-arm64/")) + XCTAssertEqual(url.lastPathComponent, "cmuxd-remote") + } +} + +final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { + func testSupportsMultiplePendingCallsResolvedOutOfOrder() { + let registry = WorkspaceRemoteDaemonPendingCallRegistry() + let first = registry.register() + let second = registry.register() + + XCTAssertTrue(registry.resolve(id: second.id, payload: [ + "ok": true, + "result": ["stream_id": "second"], + ])) + + switch registry.wait(for: second, timeout: 0.1) { + case .response(let response): + XCTAssertEqual(response["ok"] as? Bool, true) + XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "second") + default: + XCTFail("second pending call should complete independently") + } + + XCTAssertTrue(registry.resolve(id: first.id, payload: [ + "ok": true, + "result": ["stream_id": "first"], + ])) + + switch registry.wait(for: first, timeout: 0.1) { + case .response(let response): + XCTAssertEqual(response["ok"] as? Bool, true) + XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "first") + default: + XCTFail("first pending call should remain pending until its own response arrives") + } + } + + func testFailAllSignalsEveryPendingCall() { + let registry = WorkspaceRemoteDaemonPendingCallRegistry() + let first = registry.register() + let second = registry.register() + + registry.failAll("daemon transport stopped") + + switch registry.wait(for: first, timeout: 0.1) { + case .failure(let message): + XCTAssertEqual(message, "daemon transport stopped") + default: + XCTFail("first pending call should receive shared failure") + } + + switch registry.wait(for: second, timeout: 0.1) { + case .failure(let message): + XCTAssertEqual(message, "daemon transport stopped") + default: + XCTFail("second pending call should receive shared failure") + } + } +} + final class WindowBackgroundSelectionGateTests: XCTestCase { func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { let tabId = UUID() diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift new file mode 100644 index 00000000..af954ee2 --- /dev/null +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TabManagerSessionSnapshotTests: XCTestCase { + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { + let manager = TabManager() + guard let firstWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + firstWorkspace.setCustomTitle("First") + + let secondWorkspace = manager.addWorkspace(select: true) + secondWorkspace.setCustomTitle("Second") + XCTAssertEqual(manager.tabs.count, 2) + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.workspaces.count, 2) + XCTAssertEqual(snapshot.selectedWorkspaceIndex, 1) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(restored.tabs.count, 2) + XCTAssertEqual(restored.selectedTabId, restored.tabs[1].id) + XCTAssertEqual(restored.tabs[0].customTitle, "First") + XCTAssertEqual(restored.tabs[1].customTitle, "Second") + } + + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { + let manager = TabManager() + let emptySnapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ) + + manager.restoreSessionSnapshot(emptySnapshot) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertNotNil(manager.selectedTabId) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift new file mode 100644 index 00000000..ccf3f116 --- /dev/null +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -0,0 +1,214 @@ +import XCTest +import AppKit +import Darwin + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalControllerSocketSecurityTests: XCTestCase { + private func makeSocketPath(_ name: String) -> String { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock") + .path + } + + override func setUp() { + super.setUp() + TerminalController.shared.stop() + } + + override func tearDown() { + TerminalController.shared.stop() + super.tearDown() + } + + func testSocketPermissionsFollowAccessMode() throws { + let tabManager = TabManager() + + let allowAllPath = makeSocketPath("allow-all") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: allowAllPath, + accessMode: .allowAll + ) + try waitForSocket(at: allowAllPath) + XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + + TerminalController.shared.stop() + + let restrictedPath = makeSocketPath("cmux-only") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: restrictedPath, + accessMode: .cmuxOnly + ) + try waitForSocket(at: restrictedPath) + XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) + } + + func testPasswordModeRejectsUnauthenticatedCommands() throws { + let socketPath = makeSocketPath("password-mode") + let tabManager = TabManager() + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .password + ) + try waitForSocket(at: socketPath) + + let pingOnly = try sendCommands(["ping"], to: socketPath) + XCTAssertEqual(pingOnly.count, 1) + XCTAssertTrue(pingOnly[0].hasPrefix("ERROR:")) + XCTAssertFalse(pingOnly[0].localizedCaseInsensitiveContains("PONG")) + + let wrongAuthThenPing = try sendCommands( + ["auth not-the-password", "ping"], + to: socketPath + ) + XCTAssertEqual(wrongAuthThenPing.count, 2) + XCTAssertTrue(wrongAuthThenPing[0].hasPrefix("ERROR:")) + XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) + } + + func testSocketCommandPolicyDistinguishesFocusIntent() throws { +#if DEBUG + let nonFocus = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "ping", + isV2: false + ) + XCTAssertTrue(nonFocus.insideSuppressed) + XCTAssertFalse(nonFocus.insideAllowsFocus) + XCTAssertFalse(nonFocus.outsideSuppressed) + XCTAssertFalse(nonFocus.outsideAllowsFocus) + + let focusV1 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "focus_window", + isV2: false + ) + XCTAssertTrue(focusV1.insideSuppressed) + XCTAssertTrue(focusV1.insideAllowsFocus) + XCTAssertFalse(focusV1.outsideSuppressed) + + let focusV2 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.select", + isV2: true + ) + XCTAssertTrue(focusV2.insideSuppressed) + XCTAssertTrue(focusV2.insideAllowsFocus) + XCTAssertFalse(focusV2.outsideSuppressed) +#else + throw XCTSkip("Socket command policy snapshot helper is debug-only.") +#endif + } + + private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return + } + usleep(20_000) + } + XCTFail("Timed out waiting for socket at \(path)") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) + } + + private func socketMode(at path: String) throws -> UInt16 { + var fileInfo = stat() + guard lstat(path, &fileInfo) == 0 else { + throw posixError("lstat(\(path))") + } + return UInt16(fileInfo.st_mode & 0o777) + } + + private func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket(AF_UNIX)") + } + defer { Darwin.close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let bytes = Array(socketPath.utf8) + let maxPathLen = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < maxPathLen else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let cPath = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + cPath.initialize(repeating: 0, count: maxPathLen) + for (index, byte) in bytes.enumerated() { + cPath[index] = CChar(bitPattern: byte) + } + } + + let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + bytes.count + 1) + let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { + throw posixError("connect(\(socketPath))") + } + + var responses: [String] = [] + for command in commands { + try writeLine(command, to: fd) + responses.append(try readLine(from: fd)) + } + return responses + } + + private func writeLine(_ command: String, to fd: Int32) throws { + let payload = Array((command + "\n").utf8) + var offset = 0 + while offset < payload.count { + let wrote = payload.withUnsafeBytes { raw in + Darwin.write(fd, raw.baseAddress!.advanced(by: offset), payload.count - offset) + } + guard wrote >= 0 else { + throw posixError("write(\(command))") + } + offset += wrote + } + } + + private func readLine(from fd: Int32) throws -> String { + var buffer = [UInt8](repeating: 0, count: 1) + var data = Data() + + while true { + let count = Darwin.read(fd, &buffer, 1) + guard count >= 0 else { + throw posixError("read") + } + if count == 0 { break } + if buffer[0] == 0x0A { break } + data.append(buffer[0]) + } + + guard let line = String(data: data, encoding: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Invalid UTF-8 response from socket" + ]) + } + return line + } + + private func posixError(_ operation: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))"] + ) + } +} diff --git a/daemon/remote/README.md b/daemon/remote/README.md new file mode 100644 index 00000000..07a2afaf --- /dev/null +++ b/daemon/remote/README.md @@ -0,0 +1,82 @@ +# cmuxd-remote (Go) + +Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote proxy RPC. It is not in the terminal keystroke hot path. + +## Commands + +1. `cmuxd-remote version` +2. `cmuxd-remote serve --stdio` +3. `cmuxd-remote cli <command> [args...]` — relay cmux commands to the local app over the reverse SSH forward + +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. + +## RPC methods (newline-delimited JSON over stdio) + +1. `hello` +2. `ping` +3. `proxy.open` +4. `proxy.close` +5. `proxy.write` +6. `proxy.read` +7. `session.open` +8. `session.close` +9. `session.attach` +10. `session.resize` +11. `session.detach` +12. `session.status` + +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`. +4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). + +`workspace.remote.configure` contract notes: +1. `port` / `local_proxy_port` accept integer values and numeric strings; explicit `null` clears each field. +2. Out-of-range values and invalid types return `invalid_params`. +3. `local_proxy_port` is an internal deterministic test hook used by bind-conflict regressions. +4. SSH option precedence checks are case-insensitive; user overrides for `StrictHostKeyChecking` and control-socket keys prevent default injection. + +## Distribution + +Release and nightly builds publish prebuilt `cmuxd-remote` binaries on GitHub Releases for: +1. `darwin/arm64` +2. `darwin/amd64` +3. `linux/arm64` +4. `linux/amd64` + +The app embeds a compact manifest in `Info.plist` with: +1. exact release asset URLs +2. pinned SHA-256 digests +3. release tag and checksums asset URL + +Release and nightly apps download and cache the matching binary locally, verify its SHA-256, then upload it to the remote host if needed. Dev builds can opt into a local `go build` fallback with `CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1`. + +To inspect what a given app build trusts, run: +1. `cmux remote-daemon-status` +2. `cmux remote-daemon-status --os linux --arch amd64` + +The command prints the exact release asset URL, expected SHA-256, local cache status, and a copy-pasteable `gh attestation verify` command for the selected platform. + +## CLI relay + +The `cli` subcommand (or `cmux` wrapper/symlink) connects to the local cmux app through an SSH reverse forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. + +Socket discovery order: +1. `--socket <path>` flag +2. `CMUX_SOCKET_PATH` environment variable +3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) + +For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports. + +Authenticated relay details: +1. Each SSH workspace gets its own relay ID and relay token. +2. The app runs a local loopback relay server that requires an HMAC-SHA256 challenge-response before forwarding a command to the real local Unix socket. +3. The remote shell never gets direct access to the local app socket. It only gets the reverse-forwarded relay port plus `~/.cmux/relay/<port>.auth`, which is written with `0600` permissions and removed when the relay stops. + +Integration additions for the relay path: + +1. Bootstrap installs `~/.cmux/bin/cmux` wrapper and keeps a default daemon target (`~/.cmux/bin/cmuxd-remote-current`). +2. A background `ssh -N -R` process reverse-forwards a TCP port to the authenticated local relay server. The relay address is written to `~/.cmux/socket_addr` on the remote. +3. Relay startup writes `~/.cmux/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local cmux instances or versions coexist. +4. Relay startup writes `~/.cmux/relay/<port>.auth` with the relay ID and token needed for HMAC authentication. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go new file mode 100644 index 00000000..14d69481 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -0,0 +1,721 @@ +package main + +import ( + "bufio" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +type relayAuthState struct { + RelayID string `json:"relay_id"` + RelayToken string `json:"relay_token"` +} + +// protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol. +type protocolVersion int + +const ( + protoV1 protocolVersion = iota + protoV2 +) + +// commandSpec describes a single CLI command and how to relay it. +type commandSpec struct { + name string // CLI command name (e.g. "ping", "new-window") + proto protocolVersion // v1 text or v2 JSON-RPC + v1Cmd string // v1: literal command string sent over the socket + v2Method string // v2: JSON-RPC method name + // flagKeys lists parameter keys this command accepts. + // They are extracted from --key flags and added to params. + flagKeys []string + // noParams means the command takes no parameters at all. + noParams bool +} + +var commands = []commandSpec{ + // V1 text protocol commands + {name: "ping", proto: protoV1, v1Cmd: "ping", noParams: true}, + {name: "new-window", proto: protoV1, v1Cmd: "new_window", noParams: true}, + {name: "current-window", proto: protoV1, v1Cmd: "current_window", noParams: true}, + {name: "close-window", proto: protoV1, v1Cmd: "close_window", flagKeys: []string{"window"}}, + {name: "focus-window", proto: protoV1, v1Cmd: "focus_window", flagKeys: []string{"window"}}, + {name: "list-windows", proto: protoV1, v1Cmd: "list_windows", noParams: true}, + + // V2 JSON-RPC commands + {name: "capabilities", proto: protoV2, v2Method: "system.capabilities", noParams: true}, + {name: "list-workspaces", proto: protoV2, v2Method: "workspace.list", noParams: true}, + {name: "new-workspace", proto: protoV2, v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}}, + {name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}}, + {name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}}, + {name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true}, + {name: "list-panels", proto: protoV2, v2Method: "panel.list", flagKeys: []string{"workspace"}}, + {name: "focus-panel", proto: protoV2, v2Method: "panel.focus", flagKeys: []string{"panel", "workspace"}}, + {name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}}, + {name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}}, + {name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace"}}, + {name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane"}}, + {name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}}, + {name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}}, + {name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}}, + {name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}}, + {name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}}, + {name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true}, +} + +var commandIndex map[string]*commandSpec + +func init() { + commandIndex = make(map[string]*commandSpec, len(commands)) + for i := range commands { + commandIndex[commands[i].name] = &commands[i] + } +} + +// runCLI is the entry point for the "cli" subcommand (or busybox "cmux" invocation). +func runCLI(args []string) int { + socketPath := os.Getenv("CMUX_SOCKET_PATH") + + // Parse global flags + var jsonOutput bool + var remaining []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--socket": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "cmux: --socket requires a path") + return 2 + } + socketPath = args[i+1] + i++ + case "--json": + jsonOutput = true + case "--help", "-h": + cliUsage() + return 0 + default: + remaining = append(remaining, args[i:]...) + goto doneFlags + } + } +doneFlags: + + if len(remaining) == 0 { + cliUsage() + return 2 + } + cmdName := remaining[0] + cmdArgs := remaining[1:] + if cmdName == "help" { + cliUsage() + return 0 + } + + // refreshAddr is set when the address came from socket_addr file (not env/flag), + // allowing retry loops to pick up updated relay ports. + var refreshAddr func() string + if socketPath == "" { + socketPath = readSocketAddrFile() + refreshAddr = readSocketAddrFile + } + if socketPath == "" { + fmt.Fprintln(os.Stderr, "cmux: CMUX_SOCKET_PATH not set and --socket not provided") + return 1 + } + + // Special case: "rpc" passthrough + if cmdName == "rpc" { + return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + // Browser subcommand delegation + if cmdName == "browser" { + return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + spec, ok := commandIndex[cmdName] + if !ok { + fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName) + return 2 + } + + switch spec.proto { + case protoV1: + return execV1(socketPath, spec, cmdArgs, refreshAddr) + case protoV2: + return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr) + default: + fmt.Fprintf(os.Stderr, "cmux: internal error: unknown protocol for %q\n", cmdName) + return 1 + } +} + +// execV1 sends a v1 text command over the socket. +func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr func() string) int { + cmd := spec.v1Cmd + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + cmd += " " + val + } + } + } + + resp, err := socketRoundTrip(socketPath, cmd, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Print(resp) + if !strings.HasSuffix(resp, "\n") { + fmt.Println() + } + return 0 +} + +// execV2 sends a v2 JSON-RPC request over the socket. +func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int { + params := make(map[string]any) + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + // Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + // First positional arg is used as initial_command if --command wasn't given + if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { + params["initial_command"] = parsed.positional[0] + } + + // Fall back to env vars for common IDs + if _, ok := params["workspace_id"]; !ok { + if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" { + params["workspace_id"] = envWs + } + } + if _, ok := params["surface_id"]; !ok { + if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" { + params["surface_id"] = envSf + } + } + } + + resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println(defaultRelayOutput(resp)) + } + return 0 +} + +// runRPC sends an arbitrary JSON-RPC method with optional JSON params. +func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux rpc: requires a method name") + return 2 + } + method := args[0] + var params map[string]any + if len(args) > 1 { + if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil { + fmt.Fprintf(os.Stderr, "cmux rpc: invalid JSON params: %v\n", err) + return 2 + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Println(resp) + return 0 +} + +// runBrowserRelay handles "cmux browser <subcommand>" by mapping to browser.* v2 methods. +func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux browser: requires a subcommand (open, navigate, back, forward, reload, get-url)") + return 2 + } + + sub := args[0] + subArgs := args[1:] + + var method string + var flagKeys []string + switch sub { + case "open", "open-split", "new": + method = "browser.open" + flagKeys = []string{"url", "workspace", "surface"} + case "navigate": + method = "browser.navigate" + flagKeys = []string{"url", "surface"} + case "back": + method = "browser.back" + flagKeys = []string{"surface"} + case "forward": + method = "browser.forward" + flagKeys = []string{"surface"} + case "reload": + method = "browser.reload" + flagKeys = []string{"surface"} + case "get-url": + method = "browser.get_url" + flagKeys = []string{"surface"} + default: + fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub) + return 2 + } + + params := make(map[string]any) + parsed := parseFlags(subArgs, flagKeys) + for _, key := range flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println(defaultRelayOutput(resp)) + } + return 0 +} + +func defaultRelayOutput(resp string) string { + var result any + if err := json.Unmarshal([]byte(resp), &result); err != nil { + trimmed := strings.TrimSpace(resp) + if trimmed == "" { + return "OK" + } + return trimmed + } + + if relayResultIsEmpty(result) { + return "OK" + } + + switch typed := result.(type) { + case string: + return typed + default: + encoded, err := json.MarshalIndent(typed, "", " ") + if err != nil { + return "OK" + } + return string(encoded) + } +} + +func relayResultIsEmpty(result any) bool { + switch typed := result.(type) { + case nil: + return true + case map[string]any: + return len(typed) == 0 + case []any: + return len(typed) == 0 + case string: + return typed == "" + default: + return false + } +} + +// flagToParamKey maps a CLI flag name to its JSON-RPC param key. +func flagToParamKey(key string) string { + switch key { + case "workspace": + return "workspace_id" + case "surface": + return "surface_id" + case "panel": + return "panel_id" + case "pane": + return "pane_id" + case "window": + return "window_id" + case "command": + return "initial_command" + case "name": + return "title" + case "working-directory": + return "working_directory" + default: + return key + } +} + +// parsedFlags holds the results of flag parsing. +type parsedFlags struct { + flags map[string]string // --key value pairs + positional []string // non-flag arguments +} + +// parseFlags extracts --key value pairs from args for the given allowed keys. +// Non-flag arguments are collected in positional. +func parseFlags(args []string, keys []string) parsedFlags { + allowed := make(map[string]bool, len(keys)) + for _, k := range keys { + allowed[k] = true + } + + result := parsedFlags{flags: make(map[string]string)} + for i := 0; i < len(args); i++ { + if !strings.HasPrefix(args[i], "--") { + result.positional = append(result.positional, args[i]) + continue + } + key := strings.TrimPrefix(args[i], "--") + if !allowed[key] { + continue + } + if i+1 < len(args) { + result.flags[key] = args[i+1] + i++ + } + } + return result +} + +// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback +// when CMUX_SOCKET_PATH is not set. Written by the cmux app after the relay establishes. +func readSocketAddrFile() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "socket_addr")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func readRelayAuthFile(socketPath string) *relayAuthState { + if strings.Contains(socketPath, ":") && !strings.HasPrefix(socketPath, "/") { + _, port, err := net.SplitHostPort(socketPath) + if err != nil || port == "" { + return nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "relay", port+".auth")) + if err != nil { + return nil + } + var state relayAuthState + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + if state.RelayID == "" || state.RelayToken == "" { + return nil + } + return &state + } + return nil +} + +func currentRelayAuth(socketPath string) *relayAuthState { + relayID := strings.TrimSpace(os.Getenv("CMUX_RELAY_ID")) + relayToken := strings.TrimSpace(os.Getenv("CMUX_RELAY_TOKEN")) + if relayID != "" && relayToken != "" { + return &relayAuthState{RelayID: relayID, RelayToken: relayToken} + } + return readRelayAuthFile(socketPath) +} + +// dialSocket connects to the cmux socket. If addr contains a colon and doesn't +// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. +// For TCP connections, it retries briefly to allow the SSH reverse forward to establish. +// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. +func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { + if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { + conn, err := dialTCPRetry(addr, 15*time.Second, refreshAddr) + if err != nil { + return nil, err + } + if auth := currentRelayAuth(addr); auth != nil { + if err := authenticateRelayConn(conn, auth); err != nil { + conn.Close() + return nil, err + } + } + return conn, nil + } + return net.Dial("unix", addr) +} + +// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout. +// This handles the case where the SSH reverse relay hasn't finished establishing yet. +// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses +// (e.g. when socket_addr is rewritten by a new relay process). +func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, error) { + deadline := time.Now().Add(timeout) + interval := 250 * time.Millisecond + printed := false + for { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + return conn, nil + } + if time.Now().After(deadline) { + return nil, err + } + // Only retry on connection refused (relay not ready yet) + if !isConnectionRefused(err) { + return nil, err + } + if !printed { + fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr) + printed = true + } + time.Sleep(interval) + // Re-read socket_addr in case the relay port has changed + if refreshAddr != nil { + if newAddr := refreshAddr(); newAddr != "" && newAddr != addr { + addr = newAddr + fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr) + } + } + } +} + +func isConnectionRefused(err error) bool { + if opErr, ok := err.(*net.OpError); ok { + return strings.Contains(opErr.Err.Error(), "connection refused") + } + return strings.Contains(err.Error(), "connection refused") +} + +func authenticateRelayConn(conn net.Conn, auth *relayAuthState) error { + reader := bufio.NewReader(conn) + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + var challenge struct { + Protocol string `json:"protocol"` + Version int `json:"version"` + RelayID string `json:"relay_id"` + Nonce string `json:"nonce"` + } + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth challenge: %w", err) + } + if err := json.Unmarshal([]byte(line), &challenge); err != nil { + return fmt.Errorf("invalid relay auth challenge") + } + if challenge.Protocol != "cmux-relay-auth" || challenge.Version != 1 || challenge.RelayID != auth.RelayID || challenge.Nonce == "" { + return fmt.Errorf("relay auth challenge mismatch") + } + + tokenBytes, err := hex.DecodeString(auth.RelayToken) + if err != nil { + return fmt.Errorf("invalid relay auth token") + } + mac := computeRelayMAC(tokenBytes, auth.RelayID, challenge.Nonce, challenge.Version) + payload, err := json.Marshal(map[string]any{ + "relay_id": auth.RelayID, + "mac": hex.EncodeToString(mac), + }) + if err != nil { + return fmt.Errorf("failed to encode relay auth response: %w", err) + } + if _, err := conn.Write(append(payload, '\n')); err != nil { + return fmt.Errorf("failed to send relay auth response: %w", err) + } + + line, err = reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth result: %w", err) + } + var result struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal([]byte(line), &result); err != nil { + return fmt.Errorf("invalid relay auth result") + } + if !result.OK { + return fmt.Errorf("relay auth rejected") + } + _ = conn.SetDeadline(time.Time{}) + return nil +} + +func computeRelayMAC(token []byte, relayID, nonce string, version int) []byte { + mac := hmac.New(sha256.New, token) + _, _ = io.WriteString(mac, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, version)) + return mac.Sum(nil) +} + +// socketRoundTrip sends a raw text line and reads a raw text response (v1). +func socketRoundTrip(socketPath, command string, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + if _, err := fmt.Fprintf(conn, "%s\n", command); err != nil { + return "", fmt.Errorf("failed to send command: %w", err) + } + + // V1 handlers may return multiple lines (e.g. list_windows). Read until + // the stream goes idle briefly after seeing at least one newline. + reader := bufio.NewReader(conn) + var response strings.Builder + sawNewline := false + + for { + readTimeout := 15 * time.Second + if sawNewline { + readTimeout = 120 * time.Millisecond + } + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + + chunk, err := reader.ReadString('\n') + if chunk != "" { + response.WriteString(chunk) + if strings.Contains(chunk, "\n") { + sawNewline = true + } + } + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if sawNewline { + break + } + return "", fmt.Errorf("failed to read response: timeout waiting for response") + } + if errors.Is(err, io.EOF) { + break + } + return "", fmt.Errorf("failed to read response: %w", err) + } + } + + return strings.TrimRight(response.String(), "\n"), nil +} + +// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. +func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + id := randomHex(8) + req := map[string]any{ + "id": id, + "method": method, + } + if params != nil { + req["params"] = params + } else { + req["params"] = map[string]any{} + } + + payload, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(append(payload, '\n')); err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Parse the response to check for errors + var resp map[string]any + if err := json.Unmarshal([]byte(line), &resp); err != nil { + return strings.TrimRight(line, "\n"), nil + } + + if ok, _ := resp["ok"].(bool); !ok { + if errObj, _ := resp["error"].(map[string]any); errObj != nil { + code, _ := errObj["code"].(string) + msg, _ := errObj["message"].(string) + return "", fmt.Errorf("server error [%s]: %s", code, msg) + } + return "", fmt.Errorf("server returned error response") + } + + // Return the result portion as JSON + if result, ok := resp["result"]; ok { + resultJSON, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + return string(resultJSON), nil + } + + return "{}", nil +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func cliUsage() { + fmt.Fprintln(os.Stderr, "Usage: cmux [--socket <path>] [--json] <command> [args...]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Commands:") + fmt.Fprintln(os.Stderr, " ping Check connectivity") + fmt.Fprintln(os.Stderr, " capabilities List server capabilities") + fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces") + fmt.Fprintln(os.Stderr, " new-window Create a new window") + fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace") + fmt.Fprintln(os.Stderr, " new-surface Create a new surface") + fmt.Fprintln(os.Stderr, " new-split Split an existing surface") + fmt.Fprintln(os.Stderr, " close-surface Close a surface") + fmt.Fprintln(os.Stderr, " close-workspace Close a workspace") + fmt.Fprintln(os.Stderr, " select-workspace Select a workspace") + fmt.Fprintln(os.Stderr, " send Send text to a surface") + fmt.Fprintln(os.Stderr, " send-key Send a key to a surface") + fmt.Fprintln(os.Stderr, " notify Create a notification") + fmt.Fprintln(os.Stderr, " browser <sub> Browser commands (open, navigate, back, forward, reload, get-url)") + fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC") +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go new file mode 100644 index 00000000..32d08280 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -0,0 +1,696 @@ +package main + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdout: %v", err) + } + os.Stdout = writer + defer func() { + os.Stdout = original + }() + + fn() + + if err := writer.Close(); err != nil { + t.Fatalf("close stdout writer: %v", err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + if err := reader.Close(); err != nil { + t.Fatalf("close stdout reader: %v", err) + } + return string(output) +} + +// startMockSocket creates a Unix socket that accepts one connection, +// reads a line, and responds with the given canned response. +func startMockSocket(t *testing.T, response string) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n // consume request + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return sockPath +} + +// startMockV2Socket creates a Unix socket that echoes the received request's method +// back as a successful JSON-RPC response with the method name in the result. +func startMockV2Socket(t *testing.T) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n > 0 { + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err == nil { + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": map[string]any{"method": req["method"], "params": req["params"]}, + } + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + } else { + conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + } + } + conn.Close() + } + }() + + return sockPath +} + +func startMockV2TCPSocketWithResult(t *testing.T, result any) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n == 0 { + return + } + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err != nil { + _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + return + } + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": result, + } + payload, _ := json.Marshal(resp) + _, _ = conn.Write(append(payload, '\n')) + }(conn) + } + }() + + return ln.Addr().String() +} + +// startMockTCPSocket creates a TCP listener that responds with a canned response. +func startMockTCPSocket(t *testing.T, response string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return ln.Addr().String() +} + +func startMockAuthenticatedTCPSocket(t *testing.T, relayID, relayToken, response string) string { + t.Helper() + relayTokenBytes := mustHex(t, relayToken) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + nonce := "testnonce" + challenge, _ := json.Marshal(map[string]any{ + "protocol": "cmux-relay-auth", + "version": 1, + "relay_id": relayID, + "nonce": nonce, + }) + _, _ = conn.Write(append(challenge, '\n')) + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return + } + var authResp map[string]any + if err := json.Unmarshal([]byte(line), &authResp); err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + macHex, _ := authResp["mac"].(string) + receivedMAC, err := hex.DecodeString(macHex) + if err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + h := hmac.New(sha256.New, relayTokenBytes) + _, _ = io.WriteString(h, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, 1)) + expectedMAC := h.Sum(nil) + if !hmac.Equal(receivedMAC, expectedMAC) { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + _, _ = conn.Write([]byte(`{"ok":true}` + "\n")) + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _, _ = conn.Write([]byte(response)) + if n > 0 && !strings.HasSuffix(response, "\n") { + _, _ = conn.Write([]byte("\n")) + } + }(conn) + } + }() + + return ln.Addr().String() +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + data, err := hex.DecodeString(value) + if err != nil { + t.Fatalf("decode hex: %v", err) + } + return data +} + +func TestDialTCPRetrySuccess(t *testing.T) { + // Get a free port, then close the listener so connection is refused initially. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + // Start a listener after a delay so the retry logic finds it. + go func() { + time.Sleep(400 * time.Millisecond) + ln2, err := net.Listen("tcp", addr) + if err != nil { + return + } + defer ln2.Close() + conn, err := ln2.Accept() + if err != nil { + return + } + conn.Close() + }() + + conn, err := dialTCPRetry(addr, 3*time.Second, nil) + if err != nil { + t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err) + } + conn.Close() +} + +func TestDialTCPRetryTimeout(t *testing.T) { + // Get a free port and close it — nothing will ever listen. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + start := time.Now() + _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) + elapsed := time.Since(start) + if err == nil { + t.Fatal("dialTCPRetry should fail when nothing is listening") + } + if elapsed < 500*time.Millisecond { + t.Fatalf("should have retried for ~600ms, only took %v", elapsed) + } +} + +func TestCLIPingV1(t *testing.T) { + sockPath := startMockSocket(t, "pong") + code := runCLI([]string{"--socket", sockPath, "ping"}) + if code != 0 { + t.Fatalf("ping should return 0, got %d", code) + } +} + +func TestCLIPingV1OverTCP(t *testing.T) { + addr := startMockTCPSocket(t, "pong") + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over TCP should return 0, got %d", code) + } +} + +func TestCLIPingV1OverAuthenticatedTCPWithEnv(t *testing.T) { + relayID := "relay-1" + relayToken := strings.Repeat("a1", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + t.Setenv("CMUX_RELAY_ID", relayID) + t.Setenv("CMUX_RELAY_TOKEN", relayToken) + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP should return 0, got %d", code) + } +} + +func TestCLIPingV1OverAuthenticatedTCPWithRelayFile(t *testing.T) { + relayID := "relay-2" + relayToken := strings.Repeat("b2", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + _, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host port: %v", err) + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CMUX_RELAY_ID", "") + t.Setenv("CMUX_RELAY_TOKEN", "") + relayDir := filepath.Join(home, ".cmux", "relay") + if err := os.MkdirAll(relayDir, 0o700); err != nil { + t.Fatalf("mkdir relay dir: %v", err) + } + authPayload, _ := json.Marshal(relayAuthState{RelayID: relayID, RelayToken: relayToken}) + if err := os.WriteFile(filepath.Join(relayDir, port+".auth"), authPayload, 0o600); err != nil { + t.Fatalf("write auth file: %v", err) + } + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP file relay should return 0, got %d", code) + } +} + +func TestDialSocketDetection(t *testing.T) { + // Unix socket paths should attempt Unix dial + for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} { + conn, err := dialSocket(path, nil) + if conn != nil { + conn.Close() + } + // We expect a connection error (not found), not a panic + if err == nil { + t.Fatalf("dialSocket(%q) should fail for non-existent path", path) + } + } + + // TCP addresses should attempt TCP dial + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, _ := ln.Accept() + if conn != nil { + conn.Close() + } + }() + + conn, err := dialSocket(ln.Addr().String(), nil) + if err != nil { + t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err) + } + conn.Close() +} + +func TestCLINewWindowV1(t *testing.T) { + sockPath := startMockSocket(t, "OK window_id=abc123") + code := runCLI([]string{"--socket", sockPath, "new-window"}) + if code != 0 { + t.Fatalf("new-window should return 0, got %d", code) + } +} + +func TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) { + addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma") + resp, err := socketRoundTrip(addr, "list_windows", nil) + if err != nil { + t.Fatalf("socketRoundTrip should succeed, got error: %v", err) + } + want := "window:alpha\nwindow:beta\nwindow:gamma" + if resp != want { + t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want) + } +} + +func TestCLICloseWindowV1(t *testing.T) { + // Verify that the flag value is appended to the v1 command + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var received string + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + received = strings.TrimSpace(string(buf[:n])) + conn.Write([]byte("OK\n")) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"}) + if code != 0 { + t.Fatalf("close-window should return 0, got %d", code) + } + if received != "close_window win-42" { + t.Fatalf("expected 'close_window win-42', got %q", received) + } +} + +func TestCLIListWorkspacesV2(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } +} + +func TestCLIListWorkspacesV2DefaultOutputShowsResult(t *testing.T) { + sockPath := startMockV2TCPSocketWithResult(t, map[string]any{"method": "workspace.list", "params": map[string]any{}}) + output := captureStdout(t, func() { + code := runCLI([]string{"--socket", sockPath, "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } + }) + if !strings.Contains(output, "\"method\": \"workspace.list\"") { + t.Fatalf("expected default output to include result payload, got %q", output) + } +} + +func TestCLINotifyDefaultOutputPrintsOKForEmptyResult(t *testing.T) { + sockPath := startMockV2TCPSocketWithResult(t, map[string]any{}) + output := captureStdout(t, func() { + code := runCLI([]string{"--socket", sockPath, "notify", "--body", "hi"}) + if code != 0 { + t.Fatalf("notify should return 0, got %d", code) + } + }) + if strings.TrimSpace(output) != "OK" { + t.Fatalf("expected empty-result command to print OK, got %q", output) + } +} + +func TestCLIRPCPassthrough(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"}) + if code != 0 { + t.Fatalf("rpc should return 0, got %d", code) + } +} + +func TestCLIRPCWithParams(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`}) + if code != 0 { + t.Fatalf("rpc with params should return 0, got %d", code) + } +} + +func TestCLIUnknownCommand(t *testing.T) { + code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"}) + if code != 2 { + t.Fatalf("unknown command should return 2, got %d", code) + } +} + +func TestCLINoSocket(t *testing.T) { + // Without CMUX_SOCKET_PATH set, should fail + os.Unsetenv("CMUX_SOCKET_PATH") + code := runCLI([]string{"ping"}) + if code != 1 { + t.Fatalf("missing socket should return 1, got %d", code) + } +} + +func TestCLISocketEnvVar(t *testing.T) { + sockPath := startMockSocket(t, "pong") + os.Setenv("CMUX_SOCKET_PATH", sockPath) + defer os.Unsetenv("CMUX_SOCKET_PATH") + + code := runCLI([]string{"ping"}) + if code != 0 { + t.Fatalf("ping with env socket should return 0, got %d", code) + } +} + +func TestCLIV2FlagMapping(t *testing.T) { + // Verify that --workspace gets mapped to workspace_id in params + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"}) + if code != 0 { + t.Fatalf("close-workspace should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "ws-abc" { + t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + } +} + +func TestBusyboxArgv0Detection(t *testing.T) { + // Verify that when argv[0] base is "cmux", we enter CLI mode + base := filepath.Base("cmux") + if base != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base) + } + base2 := filepath.Base("/home/user/.cmux/bin/cmux") + if base2 != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base2) + } + base3 := filepath.Base("cmuxd-remote") + if base3 == "cmux" { + t.Fatalf("cmuxd-remote should not match cmux") + } +} + +func TestCLIBrowserSubcommand(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"}) + if code != 0 { + t.Fatalf("browser open should return 0, got %d", code) + } +} + +func TestCLINoArgs(t *testing.T) { + code := runCLI([]string{}) + if code != 2 { + t.Fatalf("no args should return 2, got %d", code) + } +} + +func TestCLIHelpFlag(t *testing.T) { + code := runCLI([]string{"--help"}) + if code != 0 { + t.Fatalf("--help should return 0, got %d", code) + } +} + +func TestCLIHelpCommand(t *testing.T) { + code := runCLI([]string{"help"}) + if code != 0 { + t.Fatalf("help should return 0, got %d", code) + } +} + +func TestFlagToParamKey(t *testing.T) { + tests := []struct { + input, expected string + }{ + {"workspace", "workspace_id"}, + {"surface", "surface_id"}, + {"panel", "panel_id"}, + {"pane", "pane_id"}, + {"window", "window_id"}, + {"command", "initial_command"}, + {"name", "title"}, + {"working-directory", "working_directory"}, + {"title", "title"}, + {"url", "url"}, + {"direction", "direction"}, + } + for _, tc := range tests { + got := flagToParamKey(tc.input) + if got != tc.expected { + t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +func TestParseFlags(t *testing.T) { + args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"} + result := parseFlags(args, []string{"workspace", "surface"}) + if result.flags["workspace"] != "ws-1" { + t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"]) + } + if result.flags["surface"] != "sf-2" { + t.Errorf("expected surface=sf-2, got %q", result.flags["surface"]) + } + if _, ok := result.flags["unknown"]; ok { + t.Errorf("unknown flag should not be parsed") + } + if len(result.positional) == 0 || result.positional[0] != "positional-cmd" { + t.Errorf("expected first positional=positional-cmd, got %v", result.positional) + } +} + +func TestCLIEnvVarDefaults(t *testing.T) { + // Test that CMUX_WORKSPACE_ID and CMUX_SURFACE_ID are used as defaults + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + os.Setenv("CMUX_WORKSPACE_ID", "env-ws-id") + os.Setenv("CMUX_SURFACE_ID", "env-sf-id") + defer os.Unsetenv("CMUX_WORKSPACE_ID") + defer os.Unsetenv("CMUX_SURFACE_ID") + + code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"}) + if code != 0 { + t.Fatalf("close-surface should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "env-ws-id" { + t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) + } + if receivedParams["surface_id"] != "env-sf-id" { + t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go new file mode 100644 index 00000000..22db25a3 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -0,0 +1,1034 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "math" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "sync" + "time" +) + +var version = "dev" + +type rpcRequest struct { + ID any `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +type rpcError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type rpcResponse struct { + ID any `json:"id,omitempty"` + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcServer struct { + mu sync.Mutex + nextStreamID uint64 + nextSessionID uint64 + streams map[string]net.Conn + sessions map[string]*sessionState +} + +type sessionAttachment struct { + Cols int + Rows int + UpdatedAt time.Time +} + +type sessionState struct { + attachments map[string]sessionAttachment + effectiveCols int + effectiveRows int + lastKnownCols int + lastKnownRows int +} + +const maxRPCFrameBytes = 4 * 1024 * 1024 + +func main() { + // Busybox-style: if invoked as "cmux" (via symlink), act as CLI relay. + base := filepath.Base(os.Args[0]) + if base == "cmux" { + os.Exit(runCLI(os.Args[1:])) + } + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return 2 + } + + switch args[0] { + case "version": + _, _ = fmt.Fprintln(stdout, version) + return 0 + case "serve": + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs.SetOutput(stderr) + stdio := fs.Bool("stdio", false, "serve over stdin/stdout") + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + if !*stdio { + _, _ = fmt.Fprintln(stderr, "serve requires --stdio") + return 2 + } + if err := runStdioServer(stdin, stdout); err != nil { + _, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err) + return 1 + } + return 0 + case "cli": + return runCLI(args[1:]) + default: + usage(stderr) + return 2 + } +} + +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 cli <command> [args...]") +} + +func runStdioServer(stdin io.Reader, stdout io.Writer) error { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + reader := bufio.NewReaderSize(stdin, 64*1024) + writer := bufio.NewWriter(stdout) + defer 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 := writeResponse(writer, 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 := writeResponse(writer, rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }); err != nil { + return err + } + continue + } + + resp := server.handleRequest(req) + if err := writeResponse(writer, resp); err != nil { + return err + } + } +} + +func readRPCFrame(reader *bufio.Reader, maxBytes int) ([]byte, bool, error) { + frame := make([]byte, 0, 1024) + for { + chunk, err := reader.ReadSlice('\n') + if len(chunk) > 0 { + if len(frame)+len(chunk) > maxBytes { + if errors.Is(err, bufio.ErrBufferFull) { + if drainErr := discardUntilNewline(reader); drainErr != nil && !errors.Is(drainErr, io.EOF) { + return nil, false, drainErr + } + } + return nil, true, nil + } + frame = append(frame, chunk...) + } + + if err == nil { + return frame, false, nil + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) { + if len(frame) == 0 { + return nil, false, io.EOF + } + return frame, false, nil + } + return nil, false, err + } +} + +func discardUntilNewline(reader *bufio.Reader) error { + for { + _, err := reader.ReadSlice('\n') + if err == nil || errors.Is(err, io.EOF) { + return err + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + return err + } +} + +func writeResponse(w *bufio.Writer, resp rpcResponse) error { + payload, err := json.Marshal(resp) + if err != nil { + return err + } + if _, err := w.Write(payload); err != nil { + return err + } + if err := w.WriteByte('\n'); err != nil { + return err + } + return w.Flush() +} + +func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { + if req.Method == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "method is required", + }, + } + } + + switch req.Method { + case "hello": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "name": "cmuxd-remote", + "version": version, + "capabilities": []string{ + "session.basic", + "session.resize.min", + "proxy.http_connect", + "proxy.socks5", + "proxy.stream", + }, + }, + } + case "ping": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "pong": true, + }, + } + case "proxy.open": + return s.handleProxyOpen(req) + case "proxy.close": + return s.handleProxyClose(req) + case "proxy.write": + return s.handleProxyWrite(req) + case "proxy.read": + return s.handleProxyRead(req) + case "session.open": + return s.handleSessionOpen(req) + case "session.close": + return s.handleSessionClose(req) + case "session.attach": + return s.handleSessionAttach(req) + case "session.resize": + return s.handleSessionResize(req) + case "session.detach": + return s.handleSessionDetach(req) + case "session.status": + return s.handleSessionStatus(req) + default: + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "method_not_found", + Message: fmt.Sprintf("unknown method %q", req.Method), + }, + } + } +} + +func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { + host, ok := getStringParam(req.Params, "host") + if !ok || host == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires host", + }, + } + } + port, ok := getIntParam(req.Params, "port") + if !ok || port <= 0 || port > 65535 { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires port in range 1-65535", + }, + } + } + + timeoutMs := 10000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { + timeoutMs = parsed + } + + conn, err := net.DialTimeout( + "tcp", + net.JoinHostPort(host, strconv.Itoa(port)), + time.Duration(timeoutMs)*time.Millisecond, + ) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "open_failed", + Message: err.Error(), + }, + } + } + + s.mu.Lock() + streamID := fmt.Sprintf("s-%d", s.nextStreamID) + s.nextStreamID++ + s.streams[streamID] = conn + s.mu.Unlock() + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "stream_id": streamID, + }, + } +} + +func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.close requires stream_id", + }, + } + } + + s.mu.Lock() + conn, exists := s.streams[streamID] + if exists { + delete(s.streams, streamID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + + _ = conn.Close() + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } +} + +func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires stream_id", + }, + } + } + dataBase64, ok := getStringParam(req.Params, "data_base64") + if !ok { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires data_base64", + }, + } + } + payload, err := base64.StdEncoding.DecodeString(dataBase64) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "data_base64 must be valid base64", + }, + } + } + + conn, found := s.getStream(streamID) + if !found { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + + timeoutMs := 8000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { + timeoutMs = parsed + } + if timeoutMs > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: err.Error(), + }, + } + } + defer conn.SetWriteDeadline(time.Time{}) + } + + total := 0 + for total < len(payload) { + written, writeErr := conn.Write(payload[total:]) + if written == 0 && writeErr == nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: "write made no progress", + }, + } + } + total += written + if writeErr != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: writeErr.Error(), + }, + } + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "written": total, + }, + } +} + +func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.read requires stream_id", + }, + } + } + + maxBytes := 32768 + if parsed, hasMax := getIntParam(req.Params, "max_bytes"); hasMax { + maxBytes = parsed + } + if maxBytes <= 0 || maxBytes > 262144 { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "max_bytes must be in range 1-262144", + }, + } + } + + timeoutMs := 50 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { + timeoutMs = parsed + } + + conn, found := s.getStream(streamID) + if !found { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + + _ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)) + buffer := make([]byte, maxBytes) + n, readErr := conn.Read(buffer) + data := buffer[:max(0, n)] + + if readErr != nil { + if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "data_base64": "", + "eof": false, + }, + } + } + if readErr == io.EOF { + s.dropStream(streamID) + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "data_base64": base64.StdEncoding.EncodeToString(data), + "eof": true, + }, + } + } + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: readErr.Error(), + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "data_base64": base64.StdEncoding.EncodeToString(data), + "eof": false, + }, + } +} + +func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { + sessionID, _ := getStringParam(req.Params, "session_id") + + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID == "" { + sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) + s.nextSessionID++ + } + + session, exists := s.sessions[sessionID] + if !exists { + session = &sessionState{ + attachments: map[string]sessionAttachment{}, + } + s.sessions[sessionID] = session + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.close requires session_id", + }, + } + } + + s.mu.Lock() + _, exists := s.sessions[sessionID] + if exists { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "session_id": sessionID, + "closed": true, + }, + } +} + +func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires session_id", + }, + } + } + attachmentID, ok := getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires attachment_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + delete(session.attachments, attachmentID) + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.status requires session_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires session_id", + }, + } + return "", "", 0, 0, &resp + } + attachmentID, ok = getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires attachment_id", + }, + } + return "", "", 0, 0, &resp + } + + cols, ok = getIntParam(req.Params, "cols") + if !ok || cols <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires cols > 0", + }, + } + return "", "", 0, 0, &resp + } + rows, ok = getIntParam(req.Params, "rows") + if !ok || rows <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires rows > 0", + }, + } + return "", "", 0, 0, &resp + } + + return sessionID, attachmentID, cols, rows, nil +} + +func recomputeSessionSize(session *sessionState) { + if len(session.attachments) == 0 { + session.effectiveCols = session.lastKnownCols + session.effectiveRows = session.lastKnownRows + return + } + + minCols := 0 + minRows := 0 + for _, attachment := range session.attachments { + if minCols == 0 || attachment.Cols < minCols { + minCols = attachment.Cols + } + if minRows == 0 || attachment.Rows < minRows { + minRows = attachment.Rows + } + } + + session.effectiveCols = minCols + session.effectiveRows = minRows + session.lastKnownCols = minCols + session.lastKnownRows = minRows +} + +func sessionSnapshot(sessionID string, session *sessionState) map[string]any { + attachmentIDs := make([]string, 0, len(session.attachments)) + for attachmentID := range session.attachments { + attachmentIDs = append(attachmentIDs, attachmentID) + } + sort.Strings(attachmentIDs) + + attachments := make([]map[string]any, 0, len(attachmentIDs)) + for _, attachmentID := range attachmentIDs { + attachment := session.attachments[attachmentID] + attachments = append(attachments, map[string]any{ + "attachment_id": attachmentID, + "cols": attachment.Cols, + "rows": attachment.Rows, + "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), + }) + } + + return map[string]any{ + "session_id": sessionID, + "attachments": attachments, + "effective_cols": session.effectiveCols, + "effective_rows": session.effectiveRows, + "last_known_cols": session.lastKnownCols, + "last_known_rows": session.lastKnownRows, + } +} + +func (s *rpcServer) getStream(streamID string) (net.Conn, bool) { + s.mu.Lock() + defer s.mu.Unlock() + conn, ok := s.streams[streamID] + return conn, ok +} + +func (s *rpcServer) dropStream(streamID string) { + s.mu.Lock() + conn, ok := s.streams[streamID] + if ok { + delete(s.streams, streamID) + } + s.mu.Unlock() + if ok { + _ = conn.Close() + } +} + +func (s *rpcServer) closeAll() { + s.mu.Lock() + streams := make([]net.Conn, 0, len(s.streams)) + for id, conn := range s.streams { + delete(s.streams, id) + streams = append(streams, conn) + } + for id := range s.sessions { + delete(s.sessions, id) + } + s.mu.Unlock() + for _, conn := range streams { + _ = conn.Close() + } +} + +func getStringParam(params map[string]any, key string) (string, bool) { + if params == nil { + return "", false + } + raw, ok := params[key] + if !ok || raw == nil { + return "", false + } + value, ok := raw.(string) + return value, ok +} + +func getIntParam(params map[string]any, key string) (int, bool) { + if params == nil { + return 0, false + } + raw, ok := params[key] + if !ok || raw == nil { + return 0, false + } + switch value := raw.(type) { + case int: + return value, true + case int8: + return int(value), true + case int16: + return int(value), true + case int32: + return int(value), true + case int64: + return int(value), true + case uint: + return int(value), true + case uint8: + return int(value), true + case uint16: + return int(value), true + case uint32: + return int(value), true + case uint64: + return int(value), true + case float64: + if math.Trunc(value) != value { + return 0, false + } + return int(value), true + case json.Number: + n, err := value.Int64() + if err != nil { + return 0, false + } + return int(n), true + default: + return 0, false + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go new file mode 100644 index 00000000..9ee08f07 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -0,0 +1,531 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "math" + "net" + "strconv" + "strings" + "testing" + "time" +) + +func TestRunVersion(t *testing.T) { + var out bytes.Buffer + code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run version exit code = %d, want 0", code) + } + if strings.TrimSpace(out.String()) == "" { + t.Fatalf("version output should not be empty") + } +} + +func TestRunStdioHelloAndPing(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}}` + "\n" + + `{"id":2,"method":"ping","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); !ok { + t.Fatalf("first response should be ok=true: %v", first) + } + firstResult, _ := first["result"].(map[string]any) + if firstResult == nil { + t.Fatalf("first response missing result object: %v", first) + } + capabilities, _ := firstResult["capabilities"].([]any) + if len(capabilities) < 2 { + t.Fatalf("hello should return capabilities: %v", firstResult) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should be ok=true: %v", second) + } +} + +func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}` + "\n" + + `{"id":2,"method":"unknown","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be ok=false for invalid JSON: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("invalid JSON should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); ok { + t.Fatalf("second response should be ok=false for unknown method: %v", second) + } + secondError, _ := second["error"].(map[string]any) + if got := secondError["code"]; got != "method_not_found" { + t.Fatalf("unknown method should return method_not_found; got=%v payload=%v", got, second) + } +} + +func TestRunStdioSessionResizeFlow(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"session.open","params":{"session_id":"sess-stdio"}}` + "\n" + + `{"id":2,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a1","cols":120,"rows":40}}` + "\n" + + `{"id":3,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a2","cols":90,"rows":30}}` + "\n" + + `{"id":4,"method":"session.status","params":{"session_id":"sess-stdio"}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 4 { + t.Fatalf("got %d response lines, want 4: %q", len(lines), out.String()) + } + + var status map[string]any + if err := json.Unmarshal([]byte(lines[3]), &status); err != nil { + t.Fatalf("failed to decode status response: %v", err) + } + if ok, _ := status["ok"].(bool); !ok { + t.Fatalf("session.status should be ok=true: %v", status) + } + result, _ := status["result"].(map[string]any) + if result == nil { + t.Fatalf("session.status missing result object: %v", status) + } + effectiveCols, _ := result["effective_cols"].(float64) + effectiveRows, _ := result["effective_rows"].(float64) + if int(effectiveCols) != 90 || int(effectiveRows) != 30 { + t.Fatalf("session smallest-wins effective size mismatch: got=%vx%v payload=%v", effectiveCols, effectiveRows, result) + } +} + +func TestProxyStreamRoundTrip(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer listener.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + + buffer := make([]byte, 4) + if _, readErr := io.ReadFull(conn, buffer); readErr != nil { + return + } + if string(buffer) != "ping" { + return + } + _, _ = conn.Write([]byte("pong")) + }() + + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + port := listener.Addr().(*net.TCPAddr).Port + openResp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.open", + Params: map[string]any{ + "host": "127.0.0.1", + "port": port, + "timeout_ms": 1000, + }, + }) + if !openResp.OK { + t.Fatalf("proxy.open failed: %+v", openResp) + } + openResult, _ := openResp.Result.(map[string]any) + streamID, _ := openResult["stream_id"].(string) + if streamID == "" { + t.Fatalf("proxy.open missing stream_id: %+v", openResp) + } + + writeResp := server.handleRequest(rpcRequest{ + ID: 2, + Method: "proxy.write", + Params: map[string]any{ + "stream_id": streamID, + "data_base64": base64.StdEncoding.EncodeToString([]byte("ping")), + }, + }) + if !writeResp.OK { + t.Fatalf("proxy.write failed: %+v", writeResp) + } + + readResp := server.handleRequest(rpcRequest{ + ID: 3, + Method: "proxy.read", + Params: map[string]any{ + "stream_id": streamID, + "max_bytes": 8, + "timeout_ms": 1000, + }, + }) + if !readResp.OK { + t.Fatalf("proxy.read failed: %+v", readResp) + } + readResult, _ := readResp.Result.(map[string]any) + dataBase64, _ := readResult["data_base64"].(string) + data, decodeErr := base64.StdEncoding.DecodeString(dataBase64) + if decodeErr != nil { + t.Fatalf("proxy.read returned invalid base64: %v", decodeErr) + } + if string(data) != "pong" { + t.Fatalf("proxy.read payload=%q, want %q", string(data), "pong") + } + + closeResp := server.handleRequest(rpcRequest{ + ID: 4, + Method: "proxy.close", + Params: map[string]any{ + "stream_id": streamID, + }, + }) + if !closeResp.OK { + t.Fatalf("proxy.close failed: %+v", closeResp) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("proxy test server goroutine did not finish") + } +} + +func TestGetIntParamRejectsFractionalFloat64(t *testing.T) { + params := map[string]any{ + "port": 80.9, + "timeout_ms": 100.0, + } + + if _, ok := getIntParam(params, "port"); ok { + t.Fatalf("fractional float64 should be rejected") + } + + timeout, ok := getIntParam(params, "timeout_ms") + if !ok { + t.Fatalf("integral float64 should be accepted") + } + if timeout != 100 { + t.Fatalf("timeout_ms = %d, want 100", timeout) + } +} + +func TestRunStdioOversizedFrameContinuesServing(t *testing.T) { + oversized := `{"id":1,"method":"ping","params":{"blob":"` + strings.Repeat("a", maxRPCFrameBytes) + `"}}` + input := strings.NewReader(oversized + "\n" + `{"id":2,"method":"ping","params":{}}` + "\n") + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be oversized-frame error: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("oversized frame should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should still be handled after oversized frame: %v", second) + } +} + +func TestProxyOpenInvalidParams(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + resp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.open", + Params: map[string]any{ + "host": "127.0.0.1", + "port": strconv.Itoa(8080), + }, + }) + if resp.OK { + t.Fatalf("proxy.open with invalid port type should fail: %+v", resp) + } + errObj, _ := resp.Error, resp.Error + if errObj == nil || errObj.Code != "invalid_params" { + t.Fatalf("proxy.open invalid params should return invalid_params: %+v", resp) + } +} + +func TestSessionResizeCoordinator(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + openResp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "session.open", + Params: map[string]any{ + "session_id": "sess-rz", + }, + }) + if !openResp.OK { + t.Fatalf("session.open failed: %+v", openResp) + } + + attachSmall := server.handleRequest(rpcRequest{ + ID: 2, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-small", + "cols": 90, + "rows": 30, + }, + }) + assertEffectiveSize(t, attachSmall, 90, 30) + + attachLarge := server.handleRequest(rpcRequest{ + ID: 3, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + "cols": 120, + "rows": 40, + }, + }) + assertEffectiveSize(t, attachLarge, 90, 30) // RZ-001: smallest wins + + resizeLarge := server.handleRequest(rpcRequest{ + ID: 4, + Method: "session.resize", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + "cols": 200, + "rows": 60, + }, + }) + assertEffectiveSize(t, resizeLarge, 90, 30) // RZ-002: still bounded by smallest + + detachSmall := server.handleRequest(rpcRequest{ + ID: 5, + Method: "session.detach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-small", + }, + }) + assertEffectiveSize(t, detachSmall, 200, 60) // RZ-003: expands to next smallest + + detachLarge := server.handleRequest(rpcRequest{ + ID: 6, + Method: "session.detach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + }, + }) + assertEffectiveSize(t, detachLarge, 200, 60) // no attachments: keep last-known size + assertAttachmentCount(t, detachLarge, 0) + + reattach := server.handleRequest(rpcRequest{ + ID: 7, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-reconnect", + "cols": 110, + "rows": 50, + }, + }) + assertEffectiveSize(t, reattach, 110, 50) // RZ-004: recompute from active attachments on reattach +} + +func TestSessionInvalidParamsAndNotFound(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + missingSession := server.handleRequest(rpcRequest{ + ID: 1, + Method: "session.attach", + Params: map[string]any{ + "session_id": "missing", + "attachment_id": "a1", + "cols": 80, + "rows": 24, + }, + }) + if missingSession.OK || missingSession.Error == nil || missingSession.Error.Code != "not_found" { + t.Fatalf("session.attach on missing session should return not_found: %+v", missingSession) + } + + badSize := server.handleRequest(rpcRequest{ + ID: 2, + Method: "session.attach", + Params: map[string]any{ + "session_id": "missing", + "attachment_id": "a1", + "cols": 0, + "rows": 24, + }, + }) + if badSize.OK || badSize.Error == nil || badSize.Error.Code != "invalid_params" { + t.Fatalf("session.attach with cols=0 should return invalid_params: %+v", badSize) + } +} + +func assertEffectiveSize(t *testing.T, resp rpcResponse, wantCols, wantRows int) { + t.Helper() + if !resp.OK { + t.Fatalf("expected ok response, got error: %+v", resp) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("response missing result map: %+v", resp) + } + gotCols := asInt(t, result["effective_cols"], "effective_cols") + gotRows := asInt(t, result["effective_rows"], "effective_rows") + if gotCols != wantCols || gotRows != wantRows { + t.Fatalf("effective size = %dx%d, want %dx%d payload=%+v", gotCols, gotRows, wantCols, wantRows, result) + } +} + +func assertAttachmentCount(t *testing.T, resp rpcResponse, want int) { + t.Helper() + if !resp.OK { + t.Fatalf("expected ok response, got error: %+v", resp) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("response missing result map: %+v", resp) + } + attachments, ok := result["attachments"].([]map[string]any) + if ok { + if len(attachments) != want { + t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachments), want, result) + } + return + } + attachmentsAny, ok := result["attachments"].([]any) + if !ok { + t.Fatalf("attachments field has unexpected type (%T) payload=%+v", result["attachments"], result) + } + if len(attachmentsAny) != want { + t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachmentsAny), want, result) + } +} + +func asInt(t *testing.T, value any, field string) int { + t.Helper() + switch typed := value.(type) { + case int: + return typed + case int8: + return int(typed) + case int16: + return int(typed) + case int32: + return int(typed) + case int64: + return int(typed) + case uint: + return int(typed) + case uint8: + return int(typed) + case uint16: + return int(typed) + case uint32: + return int(typed) + case uint64: + return int(typed) + case float64: + if typed != math.Trunc(typed) { + t.Fatalf("%s should be integer-valued, got %v", field, typed) + } + return int(typed) + default: + t.Fatalf("%s has unexpected type %T (%v)", field, value, value) + return 0 + } +} diff --git a/daemon/remote/go.mod b/daemon/remote/go.mod new file mode 100644 index 00000000..f4b93baa --- /dev/null +++ b/daemon/remote/go.mod @@ -0,0 +1,3 @@ +module github.com/manaflow-ai/cmux/daemon/remote + +go 1.22 diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md new file mode 100644 index 00000000..03aaa248 --- /dev/null +++ b/docs/remote-daemon-spec.md @@ -0,0 +1,214 @@ +# Remote SSH Living Spec + +Last updated: March 12, 2026 +Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 +Primary PR: https://github.com/manaflow-ai/cmux/pull/239 +CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 + +This document is the working source of truth for: +1. what is implemented now +2. what is intentionally temporary +3. what must be built next + +## 1. Document Type + +This is a **living implementation spec** (also called an **execution spec**): a spec-level document with status tracking (`DONE`, `IN PROGRESS`, `TODO`) and acceptance tests. + +## 2. Objective + +`cmux ssh` should provide: +1. durable remote terminals with reconnect/reuse +2. browser traffic that egresses from the remote host via proxying +3. tmux-style PTY resize semantics (`smallest screen wins`) + +## 3. Current State (Implemented) + +### 3.1 Remote Workspace + Reconnect UX +- `DONE` `cmux ssh` creates remote-tagged workspaces and does not require `--name`. +- `DONE` scoped shell niceties are applied only for `cmux ssh` launches. +- `DONE` context menu actions exist for remote workspaces (`Reconnect Workspace(s)`, `Disconnect Workspace(s)`). +- `DONE` socket API includes `workspace.remote.reconnect`. + +### 3.2 Bootstrap + Daemon +- `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`. +- `DONE` daemon `hello` handshake is enforced. +- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). +- `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`. +- `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`). +- `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller. +- `DONE` SOCKS handshake parsing now preserves pipelined post-connect payload bytes instead of dropping request-prefix bytes. +- `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. + +### 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. +- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. +- `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. +- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). +- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. +- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. +- `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.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/<relay_port>.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. +- `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. +- `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. + +### 3.6 Artifact Trust +- `DONE` release and nightly workflows publish `cmuxd-remote` assets for `darwin/linux × arm64/amd64`. +- `DONE` release and nightly apps embed a compact `CMUXRemoteDaemonManifestJSON` in `Info.plist` with exact asset URLs and SHA-256 digests. +- `DONE` `cmux remote-daemon-status` exposes the current manifest entry, local cache verification state, release download command, and GitHub attestation verification command. + +### 3.3 Error Surfacing +- `DONE` remote errors are surfaced in sidebar status + logs + notifications. +- `DONE` reconnect retry count/time is included in surfaced error text (for example, `retry 1 in 4s`). + +### 3.4 Removed Temporary Behavior +- `DONE` removed remote listening-port probe loop and per-port SSH `-L` mirroring. +- `DONE` remote browser routing now uses a single shared local proxy endpoint instead of detected-port mirroring. +- `DONE` remote status now includes structured proxy metadata (`remote.proxy`) and `proxy_unavailable` error code when proxy setup fails. + +## 4. Target Architecture (No Port Mirroring) + +### 4.1 Browser Networking Path +1. `DONE` one local proxy endpoint is created per SSH transport/session key (not per detected port). +2. `DONE` endpoint is provided by a local broker that supports SOCKS5 + HTTP CONNECT and tunnels via daemon stream RPC. +3. `DONE` browser panels in remote workspaces are auto-wired to the workspace proxy endpoint. +4. `DONE` browser panels in local workspaces are not force-proxied. +5. `DONE` identical SSH transports share one endpoint via a transport-scoped broker. + +### 4.2 WKWebView Wiring +1. `DONE` use workspace-scoped `WKWebsiteDataStore(forIdentifier:)`. +2. `DONE` apply workspace/browser scoped `proxyConfigurations`. +3. `DONE` prefer SOCKS5 proxy config. +4. `DONE` keep HTTP CONNECT proxy config as fallback. +5. `DONE` re-apply proxy config on reconnect/state updates. + +### 4.3 Remote Daemon + Transport +1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). +2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC. +3. `DONE` removed remote service-port discovery/probing from browser routing path. + +### 4.4 Explicit Non-Goal +1. Automatic mirroring of every remote listening port to local loopback is not a goal for browser support. + +## 5. PTY Resize Semantics (tmux-style) + +### 5.1 Core Rule +For each session with multiple attachments, the effective PTY size is: +1. `cols = min(cols_i over attached clients)` +2. `rows = min(rows_i over attached clients)` + +This is the `smallest screen wins` rule. + +### 5.2 State Model +Per session track: +1. set of active attachments `{attachment_id -> cols, rows, updated_at}` +2. effective size currently applied to PTY +3. last-known size when temporarily unattached + +### 5.3 Recompute Triggers +Recompute effective size on: +1. attachment create +2. attachment detach +3. resize event from any attachment +4. reconnect reattach + +### 5.4 Correctness Requirements +1. Never shrink history because of UI relayout noise; only PTY viewport changes. +2. On reconnect, reuse persisted session and recompute from active attachments. +3. If no attachments remain, keep last-known PTY size (do not force 80x24 reset). + +## 6. Milestones (Living Status) + +| ID | Milestone | Status | Notes | +|---|---|---|---| +| M-001 | `cmux ssh` workspace creation + metadata + optional `--name` | DONE | Covered by `tests_v2/test_ssh_remote_cli_metadata.py` | +| M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Includes daemon capability handshake + status surfacing | +| M-003 | Reconnect/disconnect UX + API + improved error surfacing | DONE | Includes retry count in surfaced errors | +| M-004 | Docker e2e for bootstrap/reconnect shell niceties | DONE | Docker suites validate proxy-path bootstrap and reconnect behavior | +| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper | +| M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint | +| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | +| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented | +| 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 | + +## 7. Acceptance Test Matrix (With Status) + +### 7.1 Terminal + Reconnect + +| ID | Scenario | Status | +|---|---|---| +| T-001 | baseline remote connect | DONE | +| T-002 | identical host reuse semantics | DONE | +| T-003 | no `--name` | DONE | +| T-004 | reconnect API success/error paths | DONE | +| T-005 | retry count visible in daemon error detail | DONE | + +### 7.2 CLI Relay + +| ID | Scenario | Status | +|---|---|---| +| C-001 | `cmux ping` from remote session | DONE | +| C-002 | `cmux list-workspaces --json` from remote | DONE | +| C-003 | `cmux new-workspace` from remote | DONE | +| C-004 | `cmux rpc system.capabilities` passthrough | DONE | +| C-005 | TCP retry handles relay not yet established | DONE | +| C-006 | multi-workspace port conflict silent skip | DONE | +| C-007 | ephemeral port filtering excludes relay ports | DONE | + +### 7.3 Browser Proxy (Target) + +| ID | Scenario | Status | +|---|---|---| +| W-001 | remote workspace browser auto-proxied | DONE | +| W-002 | browser egress equals remote network path | DONE | +| W-003 | websocket via SOCKS5/CONNECT through remote daemon | DONE | +| W-004 | reconnect restores browser proxy path automatically | DONE | +| W-005 | local proxy bind conflict yields structured `proxy_unavailable` | DONE | +| W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE | +| W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE | + +### 7.4 Resize + +| ID | Scenario | Status | +|---|---|---| +| 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 | + +## 8. Removal Checklist (Port Mirroring) + +Before declaring browser proxying complete: +1. `DONE` remove remote port probe loop and `-L` auto-forward orchestration +2. `DONE` remove mirror-specific routing behavior as default remote behavior +3. `DONE` replace mirroring docker assertions with proxy egress assertions +4. `DONE` keep optional explicit user-driven forwarding out of this path; no automatic mirroring remains in browser routing + +## 9. Open Decisions + +1. Proxy auth policy for local broker (`none` vs optional credentials). +2. Reconnect backoff profile and max retry budget. + +## 10. Socket API Contract Notes + +### 10.1 `workspace.remote.configure` Port Fields +1. `port` and `local_proxy_port` accept integer values and numeric strings. +2. Explicit `null` clears each field. +3. Out-of-range values and invalid types (for example booleans/non-numeric strings/fractional numbers) return `invalid_params`. +4. `local_proxy_port` is an internal deterministic test hook to force local bind conflicts in regression coverage. + +### 10.2 SSH Option Precedence +1. `StrictHostKeyChecking` default (`accept-new`) is only injected when no user override is present. +2. Control-socket defaults (`ControlMaster`, `ControlPersist`, `ControlPath`) are only injected when missing. +3. SSH option key matching is case-insensitive for precedence checks in both CLI-built commands and remote configure payloads. + +### 10.3 SSH Docker E2E Harness Knobs +1. `CMUX_SSH_TEST_DOCKER_HOST` sets the SSH destination host/IP used by docker-backed SSH fixtures (default `127.0.0.1`). +2. `CMUX_SSH_TEST_DOCKER_BIND_ADDR` sets the bind address used in fixture container publish mappings (default `127.0.0.1`). +3. Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack). diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh new file mode 100755 index 00000000..a6be6fc6 --- /dev/null +++ b/scripts/build_remote_daemon_release_assets.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/build_remote_daemon_release_assets.sh \ + --version <app-version> \ + --release-tag <tag> \ + --repo <owner/repo> \ + --output-dir <dir> + +Builds cmuxd-remote release assets for the supported remote platforms and emits: + cmuxd-remote-<goos>-<goarch> + cmuxd-remote-checksums.txt + cmuxd-remote-manifest.json +EOF +} + +VERSION="" +RELEASE_TAG="" +REPO="" +OUTPUT_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --release-tag) + RELEASE_TAG="${2:-}" + shift 2 + ;; + --repo) + REPO="${2:-}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$VERSION" || -z "$RELEASE_TAG" || -z "$REPO" || -z "$OUTPUT_DIR" ]]; then + echo "error: --version, --release-tag, --repo, and --output-dir are required" >&2 + usage + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "error: go is required to build cmuxd-remote release assets" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +DAEMON_ROOT="${REPO_ROOT}/daemon/remote" +mkdir -p "$OUTPUT_DIR" +rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json + +RELEASE_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}" +CHECKSUMS_ASSET_NAME="cmuxd-remote-checksums.txt" +CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}" +MANIFEST_PATH="${OUTPUT_DIR}/cmuxd-remote-manifest.json" + +TARGETS=( + "darwin arm64" + "darwin amd64" + "linux arm64" + "linux amd64" +) + +declare -a manifest_entries=() +: > "$CHECKSUMS_PATH" + +for target in "${TARGETS[@]}"; do + read -r GOOS GOARCH <<<"$target" + ASSET_NAME="cmuxd-remote-${GOOS}-${GOARCH}" + OUTPUT_PATH="${OUTPUT_DIR}/${ASSET_NAME}" + + ( + cd "$DAEMON_ROOT" + GOOS="$GOOS" \ + GOARCH="$GOARCH" \ + CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$OUTPUT_PATH" \ + ./cmd/cmuxd-remote + ) + chmod 755 "$OUTPUT_PATH" + + SHA256="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')" + printf '%s %s\n' "$SHA256" "$ASSET_NAME" >> "$CHECKSUMS_PATH" + + manifest_entries+=("{\"goOS\":\"${GOOS}\",\"goArch\":\"${GOARCH}\",\"assetName\":\"${ASSET_NAME}\",\"downloadURL\":\"${RELEASE_URL}/${ASSET_NAME}\",\"sha256\":\"${SHA256}\"}") +done + +ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")" +trap 'rm -f "$ENTRIES_FILE"' EXIT +printf '%s\n' "${manifest_entries[@]}" > "$ENTRIES_FILE" +ENTRIES_JSON="$(python3 - <<'PY' "$ENTRIES_FILE" +import json +import sys +from pathlib import Path + +entries = [json.loads(line) for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() if line.strip()] +print(json.dumps(entries, separators=(",", ":"))) +PY +)" + +python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$RELEASE_URL" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_JSON" +import json +import sys +from pathlib import Path + +version, release_tag, release_url, checksums_asset_name, checksums_path, manifest_path, entries_json = sys.argv[1:] +checksums_url = f"{release_url}/{checksums_asset_name}" +manifest = { + "schemaVersion": 1, + "appVersion": version, + "releaseTag": release_tag, + "releaseURL": release_url, + "checksumsAssetName": checksums_asset_name, + "checksumsURL": checksums_url, + "entries": json.loads(entries_json), +} +Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + +echo "Built cmuxd-remote assets in ${OUTPUT_DIR}" diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index b3818784..986b55d2 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -3,4 +3,5 @@ # Format: <ghostty_sha> <sha256> 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d +c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js index d16d328e..4699b324 100644 --- a/scripts/release_asset_guard.js +++ b/scripts/release_asset_guard.js @@ -1,6 +1,15 @@ "use strict"; -const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; +const IMMUTABLE_RELEASE_ASSETS = [ + "cmux-macos.dmg", + "appcast.xml", + "cmuxd-remote-darwin-arm64", + "cmuxd-remote-darwin-amd64", + "cmuxd-remote-linux-arm64", + "cmuxd-remote-linux-amd64", + "cmuxd-remote-checksums.txt", + "cmuxd-remote-manifest.json", +]; const RELEASE_ASSET_GUARD_STATE = Object.freeze({ CLEAR: "clear", PARTIAL: "partial", diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js index c320cf81..39cdcf89 100644 --- a/scripts/release_asset_guard.test.js +++ b/scripts/release_asset_guard.test.js @@ -11,7 +11,7 @@ const { test("marks guard as complete and skips build/upload when all immutable assets already exist", () => { const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], + existingAssetNames: [...IMMUTABLE_RELEASE_ASSETS, "notes.txt"], }); assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); @@ -36,12 +36,16 @@ test("marks guard as clear when immutable assets are not present", () => { }); test("marks guard as partial when only some immutable assets exist", () => { + const partialAssets = ["appcast.xml", "cmuxd-remote-manifest.json"]; const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["appcast.xml"], + existingAssetNames: partialAssets, }); - assert.deepEqual(result.conflicts, ["appcast.xml"]); - assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]); + assert.deepEqual(result.conflicts, partialAssets); + assert.deepEqual( + result.missingImmutableAssets, + IMMUTABLE_RELEASE_ASSETS.filter((assetName) => !partialAssets.includes(assetName)), + ); assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL); assert.equal(result.hasPartialConflict, true); assert.equal(result.shouldSkipBuildAndUpload, false); diff --git a/scripts/reload.sh b/scripts/reload.sh index 4e758a88..5a4f2a6e 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -10,6 +10,85 @@ BUNDLE_SET=0 DERIVED_SET=0 TAG="" CMUX_DEBUG_LOG="" +CLI_PATH="" + +write_dev_cli_shim() { + local target="$1" + local fallback_bin="$2" + mkdir -p "$(dirname "$target")" + cat > "$target" <<EOF +#!/usr/bin/env bash +# cmux dev shim (managed by scripts/reload.sh) +set -euo pipefail + +CLI_PATH_FILE="/tmp/cmux-last-cli-path" +CLI_PATH_OWNER="\$(stat -f '%u' "\$CLI_PATH_FILE" 2>/dev/null || stat -c '%u' "\$CLI_PATH_FILE" 2>/dev/null || echo -1)" +if [[ -r "\$CLI_PATH_FILE" ]] && [[ ! -L "\$CLI_PATH_FILE" ]] && [[ "\$CLI_PATH_OWNER" == "\$(id -u)" ]]; then + CLI_PATH="\$(cat "\$CLI_PATH_FILE")" + if [[ -x "\$CLI_PATH" ]]; then + exec "\$CLI_PATH" "\$@" + fi +fi + +if [[ -x "$fallback_bin" ]]; then + exec "$fallback_bin" "\$@" +fi + +echo "error: no reload-selected dev cmux CLI found. Run ./scripts/reload.sh --tag <name> first." >&2 +exit 1 +EOF + chmod +x "$target" +} + +select_cmux_shim_target() { + local app_cli_dir="/Applications/cmux.app/Contents/Resources/bin" + local marker="cmux dev shim (managed by scripts/reload.sh)" + local target="" + local path_entry="" + local candidate="" + + IFS=':' read -r -a path_entries <<< "${PATH:-}" + for path_entry in "${path_entries[@]}"; do + [[ -z "$path_entry" ]] && continue + if [[ "$path_entry" == "~/"* ]]; then + path_entry="$HOME/${path_entry#~/}" + fi + if [[ "$path_entry" == "$app_cli_dir" ]]; then + break + fi + [[ -d "$path_entry" && -w "$path_entry" ]] || continue + candidate="$path_entry/cmux" + if [[ ! -e "$candidate" ]]; then + target="$candidate" + break + fi + if [[ -f "$candidate" ]] && grep -q "$marker" "$candidate" 2>/dev/null; then + target="$candidate" + break + fi + done + + if [[ -n "$target" ]]; then + echo "$target" + return 0 + fi + + # Fallback for PATH layouts where app CLI isn't listed or no earlier entries were writable. + for path_entry in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do + [[ -d "$path_entry" && -w "$path_entry" ]] || continue + candidate="$path_entry/cmux" + if [[ ! -e "$candidate" ]]; then + echo "$candidate" + return 0 + fi + if [[ -f "$candidate" ]] && grep -q "$marker" "$candidate" 2>/dev/null; then + echo "$candidate" + return 0 + fi + done + + return 1 +} usage() { cat <<'EOF' @@ -279,6 +358,10 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXTERM_REPO_ROOT string \"${PWD}\"" "$INFO_PLIST" if [[ -S "$CMUXD_SOCKET" ]]; then for PID in $(lsof -t "$CMUXD_SOCKET" 2>/dev/null); do kill "$PID" 2>/dev/null || true @@ -294,6 +377,21 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then APP_PATH="$TAG_APP_PATH" fi +CLI_PATH="$(dirname "$APP_PATH")/cmux" +if [[ -x "$CLI_PATH" ]]; then + (umask 077; printf '%s\n' "$CLI_PATH" > /tmp/cmux-last-cli-path) || true + ln -sfn "$CLI_PATH" /tmp/cmux-cli || true + + # Stable shim that always follows the last reload-selected dev CLI. + DEV_CLI_SHIM="$HOME/.local/bin/cmux-dev" + write_dev_cli_shim "$DEV_CLI_SHIM" "/Applications/cmux.app/Contents/Resources/bin/cmux" + + CMUX_SHIM_TARGET="$(select_cmux_shim_target || true)" + if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then + write_dev_cli_shim "$CMUX_SHIM_TARGET" "/Applications/cmux.app/Contents/Resources/bin/cmux" + fi +fi + # Ensure any running instance is fully terminated, regardless of DerivedData path. /usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true sleep 0.3 @@ -325,6 +423,8 @@ fi OPEN_CLEAN_ENV=( env -u CMUX_SOCKET_PATH + -u CMUX_WORKSPACE_ID + -u CMUX_SURFACE_ID -u CMUX_TAB_ID -u CMUX_PANEL_ID -u CMUXD_UNIX_PATH @@ -345,10 +445,11 @@ OPEN_CLEAN_ENV=( if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" else + echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true "${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH" fi @@ -376,3 +477,16 @@ fi if [[ -n "${TAG_SLUG:-}" ]]; then print_tag_cleanup_reminder "$TAG_SLUG" fi + +if [[ -x "${CLI_PATH:-}" ]]; then + echo + echo "CLI path:" + echo " $CLI_PATH" + echo "CLI helpers:" + echo " /tmp/cmux-cli ..." + echo " $HOME/.local/bin/cmux-dev ..." + if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then + echo " $CMUX_SHIM_TARGET ..." + fi + echo "If your shell still resolves the old cmux, run: rehash" +fi diff --git a/tests/fixtures/ssh-remote/Dockerfile b/tests/fixtures/ssh-remote/Dockerfile new file mode 100644 index 00000000..470986d8 --- /dev/null +++ b/tests/fixtures/ssh-remote/Dockerfile @@ -0,0 +1,20 @@ +FROM alpine:3.20 + +RUN apk add --no-cache openssh python3 iproute2 net-tools ncurses + +RUN adduser -D -s /bin/sh dev \ + && mkdir -p /home/dev/.ssh /run/sshd /srv/www \ + && chown -R dev:dev /home/dev/.ssh \ + && chmod 700 /home/dev/.ssh \ + && echo "cmux-ssh-forward-ok" > /srv/www/index.html + +RUN ssh-keygen -A + +COPY sshd_config /etc/ssh/sshd_config +COPY run.sh /usr/local/bin/run.sh +COPY ws_echo.py /usr/local/bin/ws_echo.py +RUN chmod +x /usr/local/bin/run.sh + +EXPOSE 22 + +CMD ["/usr/local/bin/run.sh"] diff --git a/tests/fixtures/ssh-remote/run.sh b/tests/fixtures/ssh-remote/run.sh new file mode 100644 index 00000000..9089554f --- /dev/null +++ b/tests/fixtures/ssh-remote/run.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +if [ -z "${AUTHORIZED_KEY:-}" ]; then + echo "AUTHORIZED_KEY is required" >&2 + exit 1 +fi + +REMOTE_HTTP_PORT="${REMOTE_HTTP_PORT:-43173}" +REMOTE_WS_PORT="${REMOTE_WS_PORT:-43174}" + +mkdir -p /home/dev/.ssh /root/.ssh /run/sshd +printf '%s\n' "$AUTHORIZED_KEY" > /home/dev/.ssh/authorized_keys +printf '%s\n' "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys +chown -R dev:dev /home/dev/.ssh +chmod 700 /home/dev/.ssh +chmod 600 /home/dev/.ssh/authorized_keys +chmod 700 /root/.ssh +chmod 600 /root/.ssh/authorized_keys + +python3 -m http.server "$REMOTE_HTTP_PORT" --bind 127.0.0.1 --directory /srv/www >/tmp/http.log 2>&1 & +HTTP_PID=$! +python3 /usr/local/bin/ws_echo.py --host 127.0.0.1 --port "$REMOTE_WS_PORT" >/tmp/ws.log 2>&1 & +WS_PID=$! + +sleep 0.2 +if ! kill -0 "$HTTP_PID" 2>/dev/null; then + echo "HTTP fixture failed to start (see /tmp/http.log)" >&2 + cat /tmp/http.log >&2 || true + exit 1 +fi +if ! kill -0 "$WS_PID" 2>/dev/null; then + echo "WebSocket fixture failed to start (see /tmp/ws.log)" >&2 + cat /tmp/ws.log >&2 || true + exit 1 +fi + +exec /usr/sbin/sshd -D -e diff --git a/tests/fixtures/ssh-remote/sshd_config b/tests/fixtures/ssh-remote/sshd_config new file mode 100644 index 00000000..9885b799 --- /dev/null +++ b/tests/fixtures/ssh-remote/sshd_config @@ -0,0 +1,31 @@ +Port 22 +Protocol 2 +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: + +HostKey /etc/ssh/ssh_host_rsa_key +HostKey /etc/ssh/ssh_host_ecdsa_key +HostKey /etc/ssh/ssh_host_ed25519_key + +PermitRootLogin yes +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM no +AuthorizedKeysFile .ssh/authorized_keys +PermitEmptyPasswords no +AcceptEnv TERM_PROGRAM TERM_PROGRAM_VERSION COLORTERM + +X11Forwarding no +AllowTcpForwarding yes +AllowStreamLocalForwarding yes +StreamLocalBindUnlink yes +GatewayPorts no +PermitTunnel no +ClientAliveInterval 30 +ClientAliveCountMax 2 +PrintMotd no +PidFile /run/sshd.pid +Subsystem sftp /usr/lib/ssh/sftp-server diff --git a/tests/fixtures/ssh-remote/ws_echo.py b/tests/fixtures/ssh-remote/ws_echo.py new file mode 100644 index 00000000..4acb8935 --- /dev/null +++ b/tests/fixtures/ssh-remote/ws_echo.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Tiny WebSocket echo server for SSH proxy integration tests.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import socket +import struct +import threading + + +GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +def _recv_exact(conn: socket.socket, n: int) -> bytes: + data = bytearray() + while len(data) < n: + chunk = conn.recv(n - len(data)) + if not chunk: + raise ConnectionError("unexpected EOF") + data.extend(chunk) + return bytes(data) + + +def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> bytes: + data = bytearray() + while marker not in data: + chunk = conn.recv(1024) + if not chunk: + raise ConnectionError("unexpected EOF while reading headers") + data.extend(chunk) + if len(data) > limit: + raise ValueError("header too large") + return bytes(data) + + +def _read_frame(conn: socket.socket) -> tuple[int, bytes]: + first, second = _recv_exact(conn, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(conn, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(conn, 8))[0] + + mask_key = _recv_exact(conn, 4) if masked else b"" + payload = _recv_exact(conn, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + return opcode, payload + + +def _send_frame(conn: socket.socket, opcode: int, payload: bytes) -> None: + first = 0x80 | (opcode & 0x0F) + length = len(payload) + if length < 126: + header = bytes([first, length]) + elif length <= 0xFFFF: + header = bytes([first, 126]) + struct.pack("!H", length) + else: + header = bytes([first, 127]) + struct.pack("!Q", length) + conn.sendall(header + payload) + + +def handle_client(conn: socket.socket) -> None: + try: + request = _recv_until(conn, b"\r\n\r\n") + headers_raw = request.decode("utf-8", errors="replace").split("\r\n") + header_map: dict[str, str] = {} + for line in headers_raw[1:]: + if not line or ":" not in line: + continue + k, v = line.split(":", 1) + header_map[k.strip().lower()] = v.strip() + + key = header_map.get("sec-websocket-key", "") + upgrade = header_map.get("upgrade", "").lower() + connection_hdr = header_map.get("connection", "").lower() + if not key or upgrade != "websocket" or "upgrade" not in connection_hdr: + conn.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") + return + + accept = base64.b64encode(hashlib.sha1((key + GUID).encode("utf-8")).digest()).decode("ascii") + response = ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n" + "\r\n" + ) + conn.sendall(response.encode("utf-8")) + + while True: + opcode, payload = _read_frame(conn) + if opcode == 0x8: # close + _send_frame(conn, 0x8, b"") + return + if opcode == 0x9: # ping + _send_frame(conn, 0xA, payload) + continue + if opcode == 0x1: # text + _send_frame(conn, 0x1, payload) + continue + # ignore all other opcodes + finally: + try: + conn.close() + except Exception: + pass + + +def main() -> int: + parser = argparse.ArgumentParser(description="WebSocket echo server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=43174) + args = parser.parse_args() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((args.host, args.port)) + server.listen(16) + while True: + conn, _ = server.accept() + thread = threading.Thread(target=handle_client, args=(conn,), daemon=True) + thread.start() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_version_flag.py b/tests/test_cli_version_flag.py index b48419f2..00499ce0 100644 --- a/tests/test_cli_version_flag.py +++ b/tests/test_cli_version_flag.py @@ -32,13 +32,17 @@ def resolve_cmux_cli() -> str: raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") -def run(cli_path: str, *args: str) -> tuple[int, str, str]: - proc = subprocess.run( - [cli_path, *args], - text=True, - capture_output=True, - check=False, - ) +def run(cli_path: str, *args: str, timeout: float = 5.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout:.1f}s" return proc.returncode, proc.stdout.strip(), proc.stderr.strip() diff --git a/tests/test_remote_daemon_release_assets.sh b/tests/test_remote_daemon_release_assets.sh new file mode 100755 index 00000000..8495d835 --- /dev/null +++ b/tests/test_remote_daemon_release_assets.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUTPUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-assets-test.XXXXXX")" +trap 'rm -rf "$OUTPUT_DIR"' EXIT + +"$ROOT_DIR/scripts/build_remote_daemon_release_assets.sh" \ + --version "0.62.0-test" \ + --release-tag "v0.62.0-test" \ + --repo "manaflow-ai/cmux" \ + --output-dir "$OUTPUT_DIR" >/dev/null + +for asset in \ + cmuxd-remote-darwin-arm64 \ + cmuxd-remote-darwin-amd64 \ + cmuxd-remote-linux-arm64 \ + cmuxd-remote-linux-amd64 \ + cmuxd-remote-checksums.txt \ + cmuxd-remote-manifest.json +do + if [[ ! -f "$OUTPUT_DIR/$asset" ]]; then + echo "FAIL: missing asset $asset" >&2 + exit 1 + fi +done + +python3 - <<'PY' "$OUTPUT_DIR/cmuxd-remote-manifest.json" "$OUTPUT_DIR/cmuxd-remote-checksums.txt" +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +checksums_path = Path(sys.argv[2]) +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + +expected_targets = { + ("darwin", "arm64"), + ("darwin", "amd64"), + ("linux", "arm64"), + ("linux", "amd64"), +} +actual_targets = {(entry["goOS"], entry["goArch"]) for entry in manifest["entries"]} +if actual_targets != expected_targets: + raise SystemExit(f"FAIL: manifest targets {sorted(actual_targets)} != {sorted(expected_targets)}") + +if manifest["appVersion"] != "0.62.0-test": + raise SystemExit(f"FAIL: unexpected appVersion {manifest['appVersion']}") +if manifest["releaseTag"] != "v0.62.0-test": + raise SystemExit(f"FAIL: unexpected releaseTag {manifest['releaseTag']}") +if not manifest["checksumsURL"].endswith("/cmuxd-remote-checksums.txt"): + raise SystemExit(f"FAIL: unexpected checksumsURL {manifest['checksumsURL']}") + +checksum_lines = [line for line in checksums_path.read_text(encoding="utf-8").splitlines() if line.strip()] +if len(checksum_lines) != 4: + raise SystemExit(f"FAIL: expected 4 checksum lines, got {len(checksum_lines)}") + +for entry in manifest["entries"]: + if not entry["downloadURL"].endswith("/" + entry["assetName"]): + raise SystemExit(f"FAIL: downloadURL mismatch for {entry['assetName']}") + if len(entry["sha256"]) != 64: + raise SystemExit(f"FAIL: invalid sha256 for {entry['assetName']}") + +print("PASS: remote daemon release assets include all targets and manifest entries") +PY diff --git a/tests/test_sidebar_copy_ssh_error_context_menu.py b/tests/test_sidebar_copy_ssh_error_context_menu.py new file mode 100644 index 00000000..52b3a6f3 --- /dev/null +++ b/tests/test_sidebar_copy_ssh_error_context_menu.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Regression test: sidebar context menu shows Copy SSH Error only when an SSH error exists.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + if not content_view_path.exists(): + print(f"FAIL: missing expected file: {content_view_path}") + return 1 + + content = content_view_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private var copyableSidebarSSHError: String?", + "Missing sidebar SSH error extraction helper", + failures, + ) + require( + content, + 'tab.statusEntries["remote.error"]?.value', + "Missing remote.error status fallback for copyable SSH error text", + failures, + ) + require( + content, + "if let copyableSidebarSSHError {", + "Copy SSH Error menu entry is no longer conditionally gated", + failures, + ) + require( + content, + 'Button("Copy SSH Error")', + "Missing Copy SSH Error context menu button", + failures, + ) + require( + content, + "copyTextToPasteboard(copyableSidebarSSHError)", + "Copy SSH Error button no longer writes the resolved error text", + failures, + ) + + if failures: + print("FAIL: sidebar copy SSH error context-menu regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: sidebar Copy SSH Error context menu wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py new file mode 100644 index 00000000..e09741fd --- /dev/null +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + + +def _merged_output(proc: subprocess.CompletedProcess[str]) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def main() -> int: + cli = _find_cli_binary() + + # Global --version should be handled before socket command dispatch. + version_proc = _run([cli, "--version"]) + version_out = _merged_output(version_proc).lower() + _must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}") + _must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}") + + # Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path + # when CMUX_SOCKET_PATH is not set. + hint_backup: str | None = None + hint_had_file = LAST_SOCKET_HINT_PATH.exists() + if hint_had_file: + hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8") + try: + LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") + auto_env = dict(os.environ) + auto_env.pop("CMUX_SOCKET_PATH", None) + auto_ping = _run([cli, "ping"], env=auto_env) + auto_ping_out = _merged_output(auto_ping).lower() + _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") + _must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}") + finally: + try: + if hint_had_file: + LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8") + else: + LAST_SOCKET_HINT_PATH.unlink(missing_ok=True) + except OSError: + pass + + # Global --password should parse as a flag (not a command name) and still allow non-password sockets. + ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"]) + ping_out = _merged_output(ping_proc).lower() + _must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}") + _must("pong" in ping_out, f"ping should still return pong: {ping_out!r}") + + # V1 errors must produce non-zero exit codes for automation correctness. + bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"]) + bad_out = _merged_output(bad_focus).lower() + _must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}") + _must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}") + + print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_ls_scrollback.py b/tests_v2/test_pane_resize_preserves_ls_scrollback.py new file mode 100644 index 00000000..0eb450d2 --- /dev/null +++ b/tests_v2/test_pane_resize_preserves_ls_scrollback.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Regression: `ls` output remains in scrollback after pane.resize.""" + +from __future__ import annotations + +import os +import re +import secrets +import shlex +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + out: list[tuple[str, bool, int]] = [] + for row in payload.get("panes") or []: + out.append(( + str(row.get("id") or ""), + bool(row.get("focused")), + int(row.get("surface_count") or 0), + )) + return out + + +def _focused_pane_id(client: cmux, workspace_id: str) -> str: + for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id): + if focused: + return pane_id + raise cmuxError("No focused pane found") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + text = _surface_scrollback_text(client, workspace_id, surface_id) + lines = [_clean_line(raw) for raw in text.splitlines()] + return token in lines + + +def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + _wait_for( + lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") + + +def _has_exact_marker_lines( + client: cmux, + workspace_id: str, + surface_id: str, + start_marker: str, + end_marker: str, +) -> bool: + text = _surface_scrollback_text(client, workspace_id, surface_id) + lines = [_clean_line(raw) for raw in text.splitlines()] + return start_marker in lines and end_marker in lines + + +def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" + + +def _extract_segment_lines( + text: str, + start_marker: str, + end_marker: str, + *, + require_end: bool = True, +) -> list[str]: + lines = text.splitlines() + saw_start = False + saw_end = False + out: list[str] = [] + for raw in lines: + line = _clean_line(raw) + if not saw_start: + if line == start_marker: + saw_start = True + continue + if line == end_marker: + saw_end = True + break + if line: + out.append(line) + + if not saw_start: + raise cmuxError(f"start marker not found in scrollback: {start_marker}") + if require_end and not saw_end: + raise cmuxError(f"end marker not found in scrollback: {end_marker}") + return out + + +def _run_once(socket_path: str) -> int: + workspace_id = "" + fixture_dir = Path(tempfile.mkdtemp(prefix="cmux-ls-resize-regression-")) + try: + with cmux(socket_path) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) + + expected_names = [f"entry-{index:04d}.txt" for index in range(1, 241)] + for name in expected_names: + (fixture_dir / name).write_text(name + "\n", encoding="utf-8") + + start_marker = f"CMUX_LS_SCROLLBACK_START_{secrets.token_hex(4)}" + end_marker = f"CMUX_LS_SCROLLBACK_END_{secrets.token_hex(4)}" + fixture_arg = shlex.quote(str(fixture_dir)) + run_ls = ( + f"cd {fixture_arg}; " + f"echo {start_marker}; " + f"LC_ALL=C CLICOLOR=0 ls -1; " + f"echo {end_marker}" + ) + client.send_surface(surface_id, run_ls + "\n") + _wait_for( + lambda: _has_exact_marker_lines(client, workspace_id, surface_id, start_marker, end_marker), + timeout_s=12.0, + ) + + pre_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id) + pre_lines = _extract_segment_lines(pre_resize_scrollback, start_marker, end_marker) + expected_set = set(expected_names) + pre_found = [line for line in pre_lines if line in expected_set] + _must( + len(set(pre_found)) == len(expected_set), + f"pre-resize ls output incomplete: found={len(set(pre_found))} expected={len(expected_set)}", + ) + + split_payload = client._call( + "surface.split", + {"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"}, + ) or {} + _must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}") + _wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0) + + client.focus_surface(surface_id) + time.sleep(0.1) + panes = _workspace_panes(client, workspace_id) + pane_ids = [pid for pid, _focused, _surface_count in panes] + pane_id = _focused_pane_id(client, workspace_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + pre_extent = _pane_extent(client, pane_id, resize_axis) + + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": resize_direction, + "amount": 120, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=6.0) + + post_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id) + # Prompt redraw after resize may repaint over trailing marker rows. + # The regression condition is loss of ls output entries. + post_lines = _extract_segment_lines( + post_resize_scrollback, + start_marker, + end_marker, + require_end=False, + ) + post_found = [line for line in post_lines if line in expected_set] + _must( + len(set(post_found)) == len(expected_set), + "post-resize ls output lost entries from scrollback", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print("PASS: ls output remains fully present in scrollback after pane.resize") + return 0 + finally: + if workspace_id: + try: + with cmux(socket_path) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + shutil.rmtree(fixture_dir, ignore_errors=True) + + +def main() -> int: + env_socket = os.environ.get("CMUX_SOCKET") + if env_socket: + return _run_once(env_socket) + + last_error: Exception | None = None + for socket_path in DEFAULT_SOCKET_PATHS: + try: + return _run_once(socket_path) + except cmuxError as exc: + text = str(exc) + recoverable = ( + "Failed to connect", + "Socket not found", + ) + if not any(token in text for token in recoverable): + raise + last_error = exc + continue + + if last_error is not None: + raise last_error + raise cmuxError("No socket candidates configured") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_visible_content.py b/tests_v2/test_pane_resize_preserves_visible_content.py new file mode 100644 index 00000000..ea175d0c --- /dev/null +++ b/tests_v2/test_pane_resize_preserves_visible_content.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Regression: pane.resize preserves terminal content drawn before resize.""" + +from __future__ import annotations + +import os +import re +import secrets +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + out: list[tuple[str, bool, int]] = [] + for row in payload.get("panes") or []: + out.append(( + str(row.get("id") or ""), + bool(row.get("focused")), + int(row.get("surface_count") or 0), + )) + return out + + +def _focused_pane_id(client: cmux, workspace_id: str) -> str: + for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id): + if focused: + return pane_id + raise cmuxError("No focused pane found") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + text = _surface_scrollback_text(client, workspace_id, surface_id) + return [_clean_line(raw) for raw in text.splitlines()] + + +def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + return token in _surface_scrollback_lines(client, workspace_id, surface_id) + + +def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + _wait_for( + lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") + + +def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" + + +def _run_once(socket_path: str) -> int: + workspace_id = "" + try: + with cmux(socket_path) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) + + stamp = secrets.token_hex(4) + resize_lines = [f"CMUX_LOCAL_RESIZE_LINE_{stamp}_{index:02d}" for index in range(1, 33)] + clear_and_draw = ( + "clear; " + f"for i in $(seq 1 {len(resize_lines)}); do " + "n=$(printf '%02d' \"$i\"); " + f"echo CMUX_LOCAL_RESIZE_LINE_{stamp}_$n; " + "done" + ) + client.send_surface(surface_id, f"{clear_and_draw}\n") + _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, resize_lines[-1]), timeout_s=8.0) + pre_resize_scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + pre_resize_anchors = [line for line in (resize_lines[0], resize_lines[-1]) if line in pre_resize_scrollback_lines] + _must( + len(pre_resize_anchors) == 2, + f"pre-resize scrollback missing anchor lines: anchors={pre_resize_anchors}", + ) + + pre_resize_visible = client.read_terminal_text(surface_id) + pre_visible_lines = [line for line in resize_lines if line in pre_resize_visible] + _must( + len(pre_visible_lines) >= 4, + f"pre-resize viewport did not contain enough lines: {pre_visible_lines}", + ) + + split_payload = client._call( + "surface.split", + {"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"}, + ) or {} + _must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}") + _wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0) + + client.focus_surface(surface_id) + time.sleep(0.1) + panes = _workspace_panes(client, workspace_id) + pane_ids = [pid for pid, _focused, _surface_count in panes] + pane_id = _focused_pane_id(client, workspace_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + pre_extent = _pane_extent(client, pane_id, resize_axis) + + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": resize_direction, + "amount": 80, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=5.0) + + post_resize_visible = client.read_terminal_text(surface_id) + visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] + _must( + bool(visible_overlap), + f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}", + ) + + post_token = f"CMUX_LOCAL_RESIZE_POST_{stamp}" + client.send_surface(surface_id, f"echo {post_token}\n") + _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, post_token), timeout_s=8.0) + + scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in scrollback_lines for anchor in pre_resize_anchors), + "terminal scrollback lost pre-resize lines after pane resize", + ) + _must( + post_token in scrollback_lines, + "terminal scrollback missing post-resize token after pane resize", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print("PASS: pane.resize preserves pre-resize visible content and scrollback anchors") + return 0 + finally: + if workspace_id: + try: + with cmux(socket_path) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + +def main() -> int: + env_socket = os.environ.get("CMUX_SOCKET") + if env_socket: + return _run_once(env_socket) + + last_error: Exception | None = None + for socket_path in DEFAULT_SOCKET_PATHS: + try: + return _run_once(socket_path) + except cmuxError as exc: + text = str(exc) + recoverable = ( + "Failed to connect", + "Socket not found", + ) + if not any(token in text for token in recoverable): + raise + last_error = exc + continue + + if last_error is not None: + raise last_error + raise cmuxError("No socket candidates configured") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py index a60055fa..e7ea1b94 100644 --- a/tests_v2/test_rename_tab_cli_parity.py +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> return proc.stdout.strip() -def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str: - payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} - for row in payload.get("surfaces") or []: - if str(row.get("id") or "") == surface_id: - return str(row.get("title") or "") - raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") - - def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -82,7 +74,7 @@ def main() -> int: _must(bool(surface_id), f"surface.current returned no surface_id: {current}") socket_title = f"socket rename {stamp}" - c._call( + socket_payload = c._call( "tab.action", { "workspace_id": ws_id, @@ -91,14 +83,20 @@ def main() -> int: "title": socket_title, }, ) - _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") + _must( + str((socket_payload or {}).get("title") or "") == socket_title, + f"tab.action rename response missing requested title: {socket_payload}", + ) cli_title = f"cli rename {stamp}" - _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) - _must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title") + cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) + _must( + "action=rename" in cli_out.lower() and "tab=" in cli_out.lower(), + f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}", + ) env_title = f"env rename {stamp}" - _run_cli( + env_out = _run_cli( cli, ["rename-tab", env_title], env={ @@ -106,7 +104,10 @@ def main() -> int: "CMUX_TAB_ID": surface_id, }, ) - _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") + _must( + "action=rename" in env_out.lower() and "tab=" in env_out.lower(), + f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}", + ) invalid = subprocess.run( [cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id], diff --git a/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py b/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py new file mode 100644 index 00000000..28bdcd67 --- /dev/null +++ b/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Regression: moving a browser surface into an SSH workspace must rebind remote proxy state.""" + +from __future__ import annotations + +import glob +import json +import os +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", "/tmp/cmux.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 = 8.0, step_s: float = 0.1) -> 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 _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 _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 60.0) -> dict: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.get("remote") or {} + daemon = remote.get("daemon") or {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last + time.sleep(0.25) + raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for remote terminal token: {token}") + + +def _browser_body_text(client: cmux, surface_id: str) -> str: + payload = client._call( + "browser.eval", + { + "surface_id": surface_id, + "script": "document.body ? (document.body.innerText || '') : ''", + }, + ) or {} + return str(payload.get("value") or "") + + +def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") + + +def _assert_browser_does_not_contain(client: cmux, surface_id: str, token: str, sample_window_s: float = 6.0) -> str: + deadline = time.time() + sample_window_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + raise cmuxError( + f"browser unexpectedly loaded remote marker before SSH proxy rebind; token={token!r} body={last_text[:240]!r}" + ) + time.sleep(0.2) + return last_text + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote browser move/proxy regression") + return 0 + + cli = _find_cli_binary() + remote_workspace_id = "" + remote_surface_id = "" + + stamp = secrets.token_hex(4) + marker_file = f"CMUX_REMOTE_PROXY_MOVE_{stamp}.txt" + marker_body = f"CMUX_REMOTE_PROXY_BODY_{stamp}" + ready_token = f"CMUX_HTTP_READY_{stamp}" + default_web_port = 20000 + (os.getpid() % 5000) + ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port))) + url = f"http://localhost:{ssh_web_port}/{marker_file}" + + try: + with cmux(SOCKET_PATH) as client: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + browser_surface_id = client.open_browser("about:blank") + _must(bool(browser_surface_id), "browser.open_split returned no surface") + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-move-proxy-{stamp}"] + 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) + remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + remote_status = _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0) + remote_payload = remote_status.get("remote") or {} + forwarded_ports = remote_payload.get("forwarded_ports") or [] + _must( + forwarded_ports == [], + f"remote workspace should rely on proxy endpoint, not explicit forwarded ports: {forwarded_ports!r}", + ) + + surfaces = client.list_surfaces(remote_workspace_id) + _must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}") + remote_surface_id = str(surfaces[0][1]) + + server_script = ( + f"printf '%s\\n' {marker_body} > /tmp/{marker_file}; " + f"python3 -m http.server {ssh_web_port} --directory /tmp >/tmp/cmux-remote-browser-proxy-{stamp}.log 2>&1 & " + "for _ in $(seq 1 30); do " + f" if curl -fsS http://localhost:{ssh_web_port}/{marker_file} | grep -q {marker_body}; then " + f" echo {ready_token}; " + " break; " + " fi; " + " sleep 0.2; " + "done" + ) + client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script}, + ) + client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + _wait_surface_contains(client, remote_workspace_id, remote_surface_id, ready_token, timeout_s=12.0) + + browser_surface_id = str(client._resolve_surface_id(browser_surface_id)) + client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) + local_body = _assert_browser_does_not_contain(client, browser_surface_id, marker_body, sample_window_s=5.0) + _must( + marker_body not in local_body, + f"browser should not reach remote localhost before moving into ssh workspace: {local_body[:240]!r}", + ) + + client.move_surface(browser_surface_id, workspace=remote_workspace_id, focus=True) + + def _browser_in_remote_workspace() -> bool: + for _idx, sid, _focused in client.list_surfaces(remote_workspace_id): + if str(sid) == browser_surface_id: + return True + return False + + _wait_for(_browser_in_remote_workspace, timeout_s=10.0, step_s=0.15) + + client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) + _wait_browser_contains(client, browser_surface_id, marker_body, timeout_s=20.0) + + body = _browser_body_text(client, browser_surface_id) + _must(marker_body in body, f"browser did not load remote localhost content over SSH proxy: {body[:240]!r}") + _must("Can't reach this page" not in body, f"browser rendered local error page instead of remote content: {body[:240]!r}") + + print( + "PASS: browser proxy stays scoped to SSH workspace surfaces, uses proxy endpoint without explicit forwarded ports, " + "and reaches remote localhost after move" + ) + return 0 + finally: + if remote_surface_id and remote_workspace_id: + try: + cleanup = f"pkill -f 'python3 -m http.server {ssh_web_port}' >/dev/null 2>&1 || true" + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup}, + ) + cleanup_client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + except Exception: # noqa: BLE001 + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py new file mode 100644 index 00000000..0b3aabfc --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -0,0 +1,630 @@ +#!/usr/bin/env python3 +"""Regression: `cmux ssh` creates a remote-tagged workspace with remote metadata.""" + +from __future__ import annotations + +import glob +import json +import os +import re +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", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str], *, json_output: bool, extra_env: dict[str, str] | None = None) -> str: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + if extra_env: + env.update(extra_env) + + cmd = [cli, "--socket", SOCKET_PATH] + if json_output: + cmd.append("--json") + cmd.extend(args) + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def _run_cli_json(cli: str, args: list[str], *, extra_env: dict[str, str] | None = None) -> dict: + output = _run_cli(cli, args, json_output=True, extra_env=extra_env) + try: + return json.loads(output or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})") + + +def _extract_control_path(ssh_command: str) -> str: + match = re.search(r"ControlPath=([^\s]+)", ssh_command) + return match.group(1) if match else "" + + +def _has_ssh_option_key(options: list[str], key: str) -> bool: + lowered_key = key.lower() + for option in options: + token = re.split(r"[=\s]+", str(option).strip(), maxsplit=1)[0].strip().lower() + if token == lowered_key: + return True + return False + + +def _read_any_terminal_text(client: cmux, workspace_id: str, timeout: float = 8.0) -> str | None: + deadline = time.time() + timeout + last_exc: Exception | None = None + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + for _, surface_id, _ in surfaces: + try: + return client.read_terminal_text(surface_id) + except cmuxError as exc: + text = str(exc).lower() + if "terminal surface not found" in text: + last_exc = exc + continue + raise + time.sleep(0.1) + print(f"WARN: readable terminal surface unavailable in workspace {workspace_id}; skipping transcript assertion ({last_exc})") + return None + + +def _resolve_workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_ref.startswith("workspace:"): + return "" + + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _append_workspace_to_cleanup(workspaces_to_close: list[str], workspace_id: str) -> str: + if workspace_id: + workspaces_to_close.append(workspace_id) + return workspace_id + + +def main() -> int: + cli = _find_cli_binary() + help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) + _must("cmux ssh" in help_text, "ssh --help output should include command header") + _must("Create a new workspace" in help_text, "ssh --help output should describe workspace creation") + + workspace_id = "" + workspace_id_without_name = "" + workspace_id_strict_override = "" + workspace_id_case_override = "" + workspace_id_invalid_proxy_port = "" + workspaces_to_close: list[str] = [] + with cmux(SOCKET_PATH) as client: + try: + payload = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-test"], + ) + workspace_id = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload), + ) + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + selected_workspace_id = "" + deadline_select = time.time() + 5.0 + while time.time() < deadline_select: + try: + selected_workspace_id = client.current_workspace() + except cmuxError: + time.sleep(0.05) + continue + if selected_workspace_id == workspace_id: + break + time.sleep(0.05) + _must( + selected_workspace_id == workspace_id, + f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}", + ) + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}" + ssh_command = str(payload.get("ssh_command") or "") + _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") + _must( + ssh_command.startswith("ssh "), + f"cmux ssh should emit plain ssh command text (env is passed via workspace.create initial_env): {ssh_command!r}", + ) + ssh_startup_command = str(payload.get("ssh_startup_command") or "") + _must( + ssh_startup_command.startswith("/bin/zsh -ilc "), + f"cmux ssh should launch startup command via interactive zsh for shell integration: {ssh_startup_command!r}", + ) + ssh_env_overrides = payload.get("ssh_env_overrides") or {} + _must( + str(ssh_env_overrides.get("GHOSTTY_SHELL_FEATURES") or "").endswith("ssh-env,ssh-terminfo"), + f"cmux ssh should pass shell niceties via ssh_env_overrides: {payload}", + ) + _must(not ssh_command.startswith("env "), f"ssh command should not include env prefix: {ssh_command!r}") + _must("-o StrictHostKeyChecking=accept-new" in ssh_command, f"ssh command prefix mismatch: {ssh_command!r}") + _must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}") + _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") + _must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") + _must( + ( + f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; " + f"export CMUX_SOCKET_PATH={remote_socket_addr}; " + "exec \"${SHELL:-/bin/zsh}\" -l" + ) in ssh_command, + f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {ssh_command!r}", + ) + + listed_row = None + deadline = time.time() + 8.0 + while time.time() < deadline: + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + listed_row = row + break + if listed_row is not None: + break + time.sleep(0.1) + + _must(listed_row is not None, f"workspace.list did not include {workspace_id}") + remote = listed_row.get("remote") or {} + _must(bool(remote.get("enabled")) is True, f"workspace should be marked remote-enabled: {listed_row}") + _must(str(remote.get("destination") or "") == "127.0.0.1", f"remote destination mismatch: {remote}") + _must(str(listed_row.get("title") or "") == "ssh-meta-test", f"workspace title mismatch: {listed_row}") + _must( + str(remote.get("state") or "") in {"connecting", "connected", "error", "disconnected"}, + f"unexpected remote state: {remote}", + ) + proxy = remote.get("proxy") or {} + _must( + str(proxy.get("state") or "") in {"connecting", "ready", "error", "unavailable"}, + f"remote payload should include proxy state metadata: {remote}", + ) + remote_ssh_options = [str(item) for item in (remote.get("ssh_options") or [])] + _must( + _has_ssh_option_key(remote_ssh_options, "ControlMaster"), + f"workspace.remote.configure should include ControlMaster default: {remote}", + ) + _must( + _has_ssh_option_key(remote_ssh_options, "ControlPersist"), + f"workspace.remote.configure should include ControlPersist default: {remote}", + ) + _must( + _has_ssh_option_key(remote_ssh_options, "ControlPath"), + f"workspace.remote.configure should include ControlPath default: {remote}", + ) + # Regression: cmux ssh should launch through initial_command, not visibly type a giant command into the shell. + terminal_text = _read_any_terminal_text(client, workspace_id) + if terminal_text is not None: + _must("ControlPersist=600" not in terminal_text, f"cmux ssh should not inject raw ssh command text: {terminal_text!r}") + _must("GHOSTTY_SHELL_FEATURES=" not in terminal_text, f"cmux ssh should not inject env assignment text: {terminal_text!r}") + + status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + status_remote = status.get("remote") or {} + _must(bool(status_remote.get("enabled")) is True, f"workspace.remote.status should report enabled remote: {status}") + daemon = status_remote.get("daemon") or {} + _must( + str(daemon.get("state") or "") in {"unavailable", "bootstrapping", "ready", "error"}, + f"workspace.remote.status should include daemon state metadata: {status_remote}", + ) + # Fail-fast regression: unreachable SSH target should surface bootstrap error explicitly. + deadline_daemon = time.time() + 12.0 + last_status = status + while time.time() < deadline_daemon: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + if str(last_daemon.get("state") or "") == "error": + break + time.sleep(0.2) + else: + raise cmuxError(f"unreachable host should drive daemon state to error: {last_status}") + + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + detail = str(last_daemon.get("detail") or "") + _must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}") + _must(re.search(r"retry\s+\d+", detail.lower()) is not None, f"daemon error should include retry count: {last_status}") + + # Lifecycle regression: disconnect with clear should reset remote/daemon metadata. + disconnected = client._call( + "workspace.remote.disconnect", + {"workspace_id": workspace_id, "clear": True}, + ) or {} + disconnected_remote = disconnected.get("remote") or {} + disconnected_daemon = disconnected_remote.get("daemon") or {} + _must(bool(disconnected_remote.get("enabled")) is False, f"remote config should be cleared: {disconnected}") + _must(str(disconnected_remote.get("state") or "") == "disconnected", f"remote state should be disconnected: {disconnected}") + _must(str(disconnected_daemon.get("state") or "") == "unavailable", f"daemon state should reset to unavailable: {disconnected}") + try: + client._call("workspace.remote.reconnect", {"workspace_id": workspace_id}) + raise cmuxError("workspace.remote.reconnect should fail when remote config was cleared") + except cmuxError as exc: + text = str(exc).lower() + _must("invalid_state" in text, f"workspace.remote.reconnect missing invalid_state for cleared config: {exc}") + _must("not configured" in text, f"workspace.remote.reconnect should explain missing remote config: {exc}") + + # Regression: --name is optional. + payload2 = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1"], + ) + workspace_id_without_name = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload2), + ) + ssh_command_without_name = str(payload2.get("ssh_command") or "") + + _must(bool(workspace_id_without_name), f"cmux ssh without --name should still create workspace: {payload2}") + _must( + "ControlPath=/tmp/cmux-ssh-" in ssh_command_without_name, + f"cmux ssh without --name should still include control path defaults: {ssh_command_without_name!r}", + ) + _must( + _extract_control_path(ssh_command) != _extract_control_path(ssh_command_without_name), + f"distinct cmux ssh workspaces should get distinct control paths: {ssh_command!r} vs {ssh_command_without_name!r}", + ) + row2 = None + listed2 = client._call("workspace.list", {}) or {} + for row in listed2.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id_without_name: + row2 = row + break + _must(row2 is not None, f"workspace created without --name missing from workspace.list: {workspace_id_without_name}") + _must(bool(str((row2 or {}).get("title") or "").strip()), f"workspace title should not be empty without --name: {row2}") + reconnected = client._call("workspace.remote.reconnect", {"workspace_id": workspace_id_without_name}) or {} + reconnected_remote = reconnected.get("remote") or {} + _must(bool(reconnected_remote.get("enabled")) is True, f"workspace.remote.reconnect should keep remote enabled: {reconnected}") + _must( + str(reconnected_remote.get("state") or "") in {"connecting", "connected", "error"}, + f"workspace.remote.reconnect should transition into an active state: {reconnected}", + ) + + payload_strict_override = _run_cli_json( + cli, + [ + "ssh", + "127.0.0.1", + "--port", + "1", + "--name", + "ssh-meta-strict-override", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id_strict_override = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload_strict_override), + ) + _must( + bool(workspace_id_strict_override), + f"cmux ssh with StrictHostKeyChecking override should create workspace: {payload_strict_override}", + ) + ssh_command_strict_override = str(payload_strict_override.get("ssh_command") or "") + _must( + "-o StrictHostKeyChecking=no" in ssh_command_strict_override, + f"ssh command should include user StrictHostKeyChecking override: {ssh_command_strict_override!r}", + ) + _must( + "-o StrictHostKeyChecking=accept-new" not in ssh_command_strict_override, + f"ssh command should not force default StrictHostKeyChecking when override is supplied: {ssh_command_strict_override!r}", + ) + strict_override_remote = payload_strict_override.get("remote") or {} + strict_override_options = [str(item) for item in (strict_override_remote.get("ssh_options") or [])] + _must( + any(item.lower() == "stricthostkeychecking=no" for item in strict_override_options), + f"workspace.remote.configure should preserve explicit StrictHostKeyChecking override: {strict_override_remote}", + ) + + payload_case_override = _run_cli_json( + cli, + [ + "ssh", + "127.0.0.1", + "--port", + "1", + "--name", + "ssh-meta-case-override", + "--ssh-option", + "stricthostkeychecking=no", + "--ssh-option", + "controlmaster=no", + "--ssh-option", + "controlpersist=0", + "--ssh-option", + "controlpath=/tmp/cmux-ssh-%C-custom", + ], + ) + workspace_id_case_override = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload_case_override), + ) + _must( + bool(workspace_id_case_override), + f"cmux ssh with lowercase SSH option overrides should create workspace: {payload_case_override}", + ) + ssh_command_case_override = str(payload_case_override.get("ssh_command") or "") + ssh_command_case_override_lower = ssh_command_case_override.lower() + _must( + "-o stricthostkeychecking=no" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase StrictHostKeyChecking override: {ssh_command_case_override!r}", + ) + _must( + "stricthostkeychecking=accept-new" not in ssh_command_case_override_lower, + f"ssh command should not force default StrictHostKeyChecking when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "-o controlmaster=no" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlMaster override: {ssh_command_case_override!r}", + ) + _must( + "controlmaster=auto" not in ssh_command_case_override_lower, + f"ssh command should not force default ControlMaster when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "-o controlpersist=0" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlPersist override: {ssh_command_case_override!r}", + ) + _must( + "controlpersist=600" not in ssh_command_case_override_lower, + f"ssh command should not force default ControlPersist when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "controlpath=/tmp/cmux-ssh-%c-custom" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlPath override value: {ssh_command_case_override!r}", + ) + _must( + ssh_command_case_override_lower.count("controlpath=") == 1, + f"ssh command should include exactly one ControlPath when lowercase override is supplied: {ssh_command_case_override!r}", + ) + case_override_remote = payload_case_override.get("remote") or {} + case_override_options = [str(item) for item in (case_override_remote.get("ssh_options") or [])] + _must( + any(item.lower() == "stricthostkeychecking=no" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase StrictHostKeyChecking override: {case_override_remote}", + ) + _must( + not any(item.lower() == "stricthostkeychecking=accept-new" for item in case_override_options), + f"workspace.remote.configure should not inject default StrictHostKeyChecking when lowercase override is supplied: {case_override_remote}", + ) + _must( + any(item.lower() == "controlmaster=no" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase ControlMaster override: {case_override_remote}", + ) + _must( + not any(item.lower() == "controlmaster=auto" for item in case_override_options), + f"workspace.remote.configure should not inject default ControlMaster when lowercase override is supplied: {case_override_remote}", + ) + _must( + any(item.lower() == "controlpersist=0" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase ControlPersist override: {case_override_remote}", + ) + _must( + not any(item.lower() == "controlpersist=600" for item in case_override_options), + f"workspace.remote.configure should not inject default ControlPersist when lowercase override is supplied: {case_override_remote}", + ) + _must( + any(item.lower() == "controlpath=/tmp/cmux-ssh-%c-custom" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase ControlPath override: {case_override_remote}", + ) + _must( + sum(1 for item in case_override_options if item.lower().startswith("controlpath=")) == 1, + f"workspace.remote.configure should include exactly one ControlPath when lowercase override is supplied: {case_override_remote}", + ) + + payload3 = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-features"], + extra_env={"GHOSTTY_SHELL_FEATURES": "cursor,title"}, + ) + payload3_env = payload3.get("ssh_env_overrides") or {} + merged_features = str(payload3_env.get("GHOSTTY_SHELL_FEATURES") or "") + _must( + merged_features == "cursor,title,ssh-env,ssh-terminfo", + f"cmux ssh should merge existing shell features when present: {payload3!r}", + ) + workspace_id3 = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload3), + ) + if workspace_id3: + try: + client.close_workspace(workspace_id3) + except Exception: + pass + + invalid_proxy_port_workspace = client._call("workspace.create", {"initial_command": "echo invalid-local-proxy-port"}) or {} + workspace_id_invalid_proxy_port = str(invalid_proxy_port_workspace.get("workspace_id") or "") + if workspace_id_invalid_proxy_port: + workspaces_to_close.append(workspace_id_invalid_proxy_port) + _must(bool(workspace_id_invalid_proxy_port), f"workspace.create missing workspace_id: {invalid_proxy_port_workspace}") + + configured_with_string_ports = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": "2222", + "local_proxy_port": "31338", + "auto_connect": False, + }, + ) or {} + configured_with_string_ports_remote = configured_with_string_ports.get("remote") or {} + _must( + int(configured_with_string_ports_remote.get("port") or 0) == 2222, + f"workspace.remote.configure should parse numeric string port values: {configured_with_string_ports}", + ) + _must( + int(configured_with_string_ports_remote.get("local_proxy_port") or 0) == 31338, + f"workspace.remote.configure should parse numeric string local_proxy_port values: {configured_with_string_ports}", + ) + + valid_local_proxy_port = 31337 + configured_with_local_proxy_port = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": 2222, + "local_proxy_port": valid_local_proxy_port, + "auto_connect": False, + }, + ) or {} + configured_remote = configured_with_local_proxy_port.get("remote") or {} + _must( + int(configured_remote.get("port") or 0) == 2222, + f"workspace.remote.configure should echo explicit port in remote payload: {configured_with_local_proxy_port}", + ) + _must( + int(configured_remote.get("local_proxy_port") or 0) == valid_local_proxy_port, + f"workspace.remote.configure should echo local_proxy_port in remote payload: {configured_with_local_proxy_port}", + ) + + configured_with_null_ports = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": None, + "local_proxy_port": None, + "auto_connect": False, + }, + ) or {} + configured_with_null_ports_remote = configured_with_null_ports.get("remote") or {} + _must( + configured_with_null_ports_remote.get("port") is None, + f"workspace.remote.configure should allow null to clear port: {configured_with_null_ports}", + ) + _must( + configured_with_null_ports_remote.get("local_proxy_port") is None, + f"workspace.remote.configure should allow null to clear local_proxy_port: {configured_with_null_ports}", + ) + status_after_null_ports = client._call( + "workspace.remote.status", + {"workspace_id": workspace_id_invalid_proxy_port}, + ) or {} + status_after_null_ports_remote = status_after_null_ports.get("remote") or {} + _must( + status_after_null_ports_remote.get("port") is None, + f"workspace.remote.status should reflect cleared port: {status_after_null_ports}", + ) + _must( + status_after_null_ports_remote.get("local_proxy_port") is None, + f"workspace.remote.status should reflect cleared local_proxy_port: {status_after_null_ports}", + ) + + for invalid_local_proxy_port in [0, 65536, "abc", True, 22.5]: + try: + client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "local_proxy_port": invalid_local_proxy_port, + "auto_connect": False, + }, + ) + raise cmuxError( + f"workspace.remote.configure should reject local_proxy_port={invalid_local_proxy_port!r}" + ) + except cmuxError as exc: + text = str(exc) + lowered = text.lower() + _must( + "invalid_params" in lowered, + f"workspace.remote.configure should return invalid_params for local_proxy_port={invalid_local_proxy_port!r}: {exc}", + ) + _must( + "local_proxy_port must be 1-65535" in text, + f"workspace.remote.configure should include validation hint for local_proxy_port={invalid_local_proxy_port!r}: {exc}", + ) + + for invalid_port in [0, 65536, "abc", True, 22.5]: + try: + client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": invalid_port, + "auto_connect": False, + }, + ) + raise cmuxError( + f"workspace.remote.configure should reject port={invalid_port!r}" + ) + except cmuxError as exc: + text = str(exc) + lowered = text.lower() + _must( + "invalid_params" in lowered, + f"workspace.remote.configure should return invalid_params for port={invalid_port!r}: {exc}", + ) + _must( + "port must be 1-65535" in text, + f"workspace.remote.configure should include validation hint for port={invalid_port!r}: {exc}", + ) + + try: + client.close_workspace(workspace_id_invalid_proxy_port) + except Exception: + pass + else: + workspace_id_invalid_proxy_port = "" + finally: + for workspace_id_to_close in dict.fromkeys(workspaces_to_close): + if not workspace_id_to_close: + continue + try: + client.close_workspace(workspace_id_to_close) + except Exception: + pass + + print("PASS: cmux ssh marks workspace as remote, exposes remote metadata, and does not require --name") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py new file mode 100644 index 00000000..53e01a95 --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +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", "/tmp/cmux-debug.sock") +# Keep the fixture's extra HTTP server below 1024 so there are no eligible +# (>1023) ports to auto-forward. This guards the "connecting forever" regression. +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81")) + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(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 _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + # Ensure --socket is what drives the relay path during tests. + env.pop("CMUX_SOCKET_PATH", None) + 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", "--id-format", "both", *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 _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=5", + "-p", str(host_port), + "-i", str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + 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 {} + state = str(remote.get("state") or "") + daemon_state = str(daemon.get("state") or "") + if state == "connected" and daemon_state == "ready": + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote daemon did not become ready: {last_status}") + + +def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None: + ping_result = _ssh_run( + host, host_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", + check=False, + ) + _must( + ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), + f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + ) + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" + workspace_id = "" + workspace_id_2 = "" + + try: + # Generate SSH key pair + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + # Build and start Docker container + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", "127.0.0.1::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = "root@127.0.0.1" + _wait_for_ssh(host, host_ssh_port, key_path) + + with cmux(SOCKET_PATH) as client: + # Create SSH workspace (this sets up the reverse socket forward) + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and 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: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_relay_port = int(remote_relay_port) + _must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}") + remote_socket_addr = f"127.0.0.1:{remote_relay_port}" + startup_cmd = str(payload.get("ssh_startup_command") or "") + _must( + 'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd, + f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}", + ) + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr}" in startup_cmd, + f"ssh startup command should pin CMUX_SOCKET_PATH to workspace relay: {startup_cmd!r}", + ) + workspace_window_id = payload.get("window_id") + current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} + current = client._call("workspace.current", current_params) or {} + current_workspace_id = str(current.get("workspace_id") or "") + _must( + current_workspace_id == workspace_id, + f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}", + ) + + # Wait for daemon to be ready + first_status = _wait_for_remote_ready(client, workspace_id) + first_remote = first_status.get("remote") or {} + # Regression: should transition to connected even with no eligible + # (>1023, non-ephemeral) remote ports. + _must( + not (first_remote.get("detected_ports") or []), + f"expected no eligible detected ports in fixture: {first_status}", + ) + _must( + not (first_remote.get("forwarded_ports") or []), + f"expected no forwarded ports when none are eligible: {first_status}", + ) + + # Verify remote cmux wrapper + relay-specific daemon mapping were installed. + wrapper_check = None + wrapper_deadline = time.time() + 10.0 + while time.time() < wrapper_deadline: + wrapper_check = _ssh_run( + host, host_ssh_port, key_path, + f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && " + f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && " + "daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && " + "test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok", + check=False, + ) + if "wrapper-ok" in (wrapper_check.stdout or ""): + break + time.sleep(0.4) + _must( + wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""), + f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}", + ) + + # Start a second SSH workspace to the same destination and verify both + # relays remain healthy (regression: same-host workspaces killed each other). + payload_2 = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay-2", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id_2 = str(payload_2.get("workspace_id") or "") + workspace_ref_2 = str(payload_2.get("workspace_ref") or "") + if not workspace_id_2 and workspace_ref_2.startswith("workspace:"): + listed_2 = client._call("workspace.list", {}) or {} + for row in listed_2.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_2: + workspace_id_2 = str(row.get("id") or "") + break + _must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}") + + remote_relay_port_2 = payload_2.get("remote_relay_port") + _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") + remote_relay_port_2 = int(remote_relay_port_2) + _must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}") + _must( + remote_relay_port_2 != remote_relay_port, + f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", + ) + remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" + startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "") + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2, + f"second ssh startup command should pin CMUX_SOCKET_PATH to second relay: {startup_cmd_2!r}", + ) + _ = _wait_for_remote_ready(client, workspace_id_2) + + stability_deadline = time.time() + 8.0 + while time.time() < stability_deadline: + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay") + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay") + time.sleep(0.5) + + # Test 1: cmux ping (v1) + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux") + + # Test 2: cmux list-workspaces --json (v2) + list_ws_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces", + check=False, + ) + _must( + list_ws_result.returncode == 0, + f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}", + ) + try: + ws_data = json.loads(list_ws_result.stdout.strip()) + _must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}") + + # Test 3: cmux new-window (v1) + new_win_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window", + check=False, + ) + _must( + new_win_result.returncode == 0, + f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}", + ) + + # Test 4: cmux rpc system.capabilities (v2 passthrough) + rpc_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities", + check=False, + ) + _must( + rpc_result.returncode == 0, + f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}", + ) + try: + caps_data = json.loads(rpc_result.stdout.strip()) + _must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}") + + # Cleanup + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + if workspace_id_2: + try: + client.close_workspace(workspace_id_2) + except Exception: + pass + workspace_id_2 = "" + + print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + if workspace_id_2: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_2) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_daemon_resize_stdio.py b/tests_v2/test_ssh_remote_daemon_resize_stdio.py new file mode 100644 index 00000000..d11cb845 --- /dev/null +++ b/tests_v2/test_ssh_remote_daemon_resize_stdio.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Process-level integration: cmuxd-remote stdio session resize coordinator.""" + +from __future__ import annotations + +import json +import select +import shutil +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _daemon_module_dir() -> Path: + return Path(__file__).resolve().parents[1] / "daemon" / "remote" + + +def _rpc( + proc: subprocess.Popen[str], + req_id: int, + method: str, + params: dict, + *, + timeout_s: float = 5.0, +) -> dict: + if proc.stdin is None or proc.stdout is None: + raise cmuxError("daemon subprocess stdio pipes are not available") + + payload = {"id": req_id, "method": method, "params": params} + proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + proc.stdin.flush() + + deadline = time.time() + timeout_s + while time.time() < deadline: + wait_s = max(0.0, min(0.2, deadline - time.time())) + ready, _, _ = select.select([proc.stdout], [], [], wait_s) + if not ready: + continue + line = proc.stdout.readline() + if line == "": + stderr = "" + if proc.stderr is not None: + try: + stderr = proc.stderr.read().strip() + except Exception: + stderr = "" + raise cmuxError(f"cmuxd-remote exited while waiting for {method} response: {stderr}") + try: + resp = json.loads(line) + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON response for {method}: {line!r} ({exc})") + _must(resp.get("id") == req_id, f"Response id mismatch for {method}: {resp}") + return resp + + raise cmuxError(f"Timed out waiting for cmuxd-remote response: {method}") + + +def _as_int(value: object, field: str) -> int: + if isinstance(value, bool): + raise cmuxError(f"{field} should be numeric, got bool") + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + raise cmuxError(f"{field} has unexpected type {type(value).__name__}: {value!r}") + + +def _assert_effective(resp: dict, want_cols: int, want_rows: int, label: str) -> None: + _must(resp.get("ok") is True, f"{label} should return ok=true: {resp}") + result = resp.get("result") or {} + got_cols = _as_int(result.get("effective_cols"), "effective_cols") + got_rows = _as_int(result.get("effective_rows"), "effective_rows") + _must( + got_cols == want_cols and got_rows == want_rows, + f"{label} effective size mismatch: got {got_cols}x{got_rows}, want {want_cols}x{want_rows} ({resp})", + ) + + +def main() -> int: + if shutil.which("go") is None: + print("SKIP: go is not available") + return 0 + + daemon_dir = _daemon_module_dir() + _must(daemon_dir.is_dir(), f"Missing daemon module directory: {daemon_dir}") + + proc = subprocess.Popen( + ["go", "run", "./cmd/cmuxd-remote", "serve", "--stdio"], + cwd=str(daemon_dir), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + try: + hello = _rpc(proc, 1, "hello", {}) + _must(hello.get("ok") is True, f"hello should return ok=true: {hello}") + capabilities = {str(item) for item in ((hello.get("result") or {}).get("capabilities") or [])} + _must("session.basic" in capabilities, f"hello missing session.basic capability: {hello}") + _must("session.resize.min" in capabilities, f"hello missing session.resize.min capability: {hello}") + + open_resp = _rpc(proc, 2, "session.open", {"session_id": "sess-e2e"}) + _assert_effective(open_resp, 0, 0, "session.open") + + attach_small = _rpc( + proc, + 3, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-small", "cols": 90, "rows": 30}, + ) + _assert_effective(attach_small, 90, 30, "session.attach(a-small)") + + attach_large = _rpc( + proc, + 4, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 140, "rows": 50}, + ) + _assert_effective(attach_large, 90, 30, "session.attach(a-large)") + + resize_large = _rpc( + proc, + 5, + "session.resize", + {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 200, "rows": 80}, + ) + _assert_effective(resize_large, 90, 30, "session.resize(a-large)") + + detach_small = _rpc( + proc, + 6, + "session.detach", + {"session_id": "sess-e2e", "attachment_id": "a-small"}, + ) + _assert_effective(detach_small, 200, 80, "session.detach(a-small)") + + detach_large = _rpc( + proc, + 7, + "session.detach", + {"session_id": "sess-e2e", "attachment_id": "a-large"}, + ) + _assert_effective(detach_large, 200, 80, "session.detach(a-large)") + + reattach = _rpc( + proc, + 8, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-reconnect", "cols": 110, "rows": 40}, + ) + _assert_effective(reattach, 110, 40, "session.attach(a-reconnect)") + + status = _rpc(proc, 9, "session.status", {"session_id": "sess-e2e"}) + _assert_effective(status, 110, 40, "session.status") + attachments = (status.get("result") or {}).get("attachments") or [] + _must(len(attachments) == 1, f"session.status should report one active attachment after reattach: {status}") + + print("PASS: cmuxd-remote stdio session.resize coordinator enforces smallest-screen-wins semantics") + return 0 + finally: + try: + if proc.stdin is not None: + proc.stdin.close() + except Exception: + pass + try: + proc.terminate() + proc.wait(timeout=2.0) + except Exception: + try: + proc.kill() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py new file mode 100644 index 00000000..63162e76 --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Docker integration: remote daemon bootstrap must not depend on login-shell startup files.""" + +from __future__ import annotations + +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +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", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +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 _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + return int(text.split(":")[-1]) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + last_status: dict = {} + 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 {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not converge to connected/ready under slow login profile: {last_status}") + + +def _heartbeat_count(status: dict) -> int: + remote = status.get("remote") or {} + heartbeat = remote.get("heartbeat") or {} + raw = heartbeat.get("count") + try: + return int(raw or 0) + except Exception: # noqa: BLE001 + return 0 + + +def _wait_for_heartbeat_advance(client: cmux, workspace_id: str, minimum_count: int, timeout: float = 20.0) -> dict: + deadline = time.time() + timeout + last_status: dict = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + if _heartbeat_count(last_status) >= minimum_count: + return last_status + time.sleep(0.5) + raise cmuxError( + f"Remote heartbeat did not advance to >= {minimum_count} within {timeout:.1f}s: {last_status}" + ) + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-bootstrap-nonlogin-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-bootstrap-nonlogin-{secrets.token_hex(4)}" + workspace_id = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-p", + f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ] + ) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + # Regression fixture: a slow login profile that should not block non-interactive daemon bootstrap. + _ssh_run( + host, + host_ssh_port, + key_path, + """ +cat > "$HOME/.profile" <<'EOF' +sleep 15 +echo profile-sourced >&2 +EOF +chmod 0644 "$HOME/.profile" +""", + check=True, + ) + + with cmux(SOCKET_PATH) as client: + created = client._call("workspace.create", {"initial_command": "echo ssh-bootstrap-nonlogin"}) + workspace_id = str((created or {}).get("workspace_id") or "") + _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") + + configured = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id, + "destination": host, + "port": host_ssh_port, + "identity_file": str(key_path), + "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], + "auto_connect": True, + }, + ) + _must(bool(configured), "workspace.remote.configure returned empty response") + + status = _wait_for_remote_connected(client, workspace_id, timeout=45.0) + remote = status.get("remote") or {} + detail = str(remote.get("detail") or "").lower() + _must("timed out" not in detail, f"remote detail should not report bootstrap timeout: {status}") + + baseline_heartbeat = _heartbeat_count(status) + status = _wait_for_heartbeat_advance( + client, + workspace_id, + minimum_count=max(1, baseline_heartbeat + 1), + timeout=15.0, + ) + + opened = client._call("browser.open_split", {"workspace_id": workspace_id}) or {} + browser_surface_id = str(opened.get("surface_id") or "") + _must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {opened}") + + after_open_heartbeat = _heartbeat_count(status) + status_after_blank_tab = _wait_for_heartbeat_advance( + client, + workspace_id, + minimum_count=after_open_heartbeat + 2, + timeout=20.0, + ) + remote_after_blank_tab = status_after_blank_tab.get("remote") or {} + _must( + str(remote_after_blank_tab.get("state") or "") == "connected", + f"remote should remain connected after blank browser open: {status_after_blank_tab}", + ) + heartbeat_payload = remote_after_blank_tab.get("heartbeat") or {} + _must( + heartbeat_payload.get("last_seen_at") is not None, + f"remote heartbeat should expose last_seen_at after bootstrap: {status_after_blank_tab}", + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: remote daemon bootstrap remains healthy even when ~/.profile is slow") + return 0 + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_forwarding.py b/tests_v2/test_ssh_remote_docker_forwarding.py new file mode 100644 index 00000000..6661aa5c --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_forwarding.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +"""Docker integration: remote SSH proxy endpoint via `cmux ssh`.""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import secrets +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +from base64 import b64encode +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174")) +MAX_REMOTE_DAEMON_SIZE_BYTES = int(os.environ.get("CMUX_SSH_TEST_MAX_DAEMON_SIZE_BYTES", "15000000")) +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(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 _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 _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + # docker port output form: "127.0.0.1:49154\n" or ":::\d+". + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _curl_via_socks(proxy_port: int, target_url: str) -> str: + if shutil.which("curl") is None: + raise cmuxError("curl is required for SOCKS proxy verification") + proc = _run( + [ + "curl", + "--silent", + "--show-error", + "--max-time", + "5", + "--socks5-hostname", + f"127.0.0.1:{proxy_port}", + target_url, + ], + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"curl via SOCKS proxy failed: {merged}") + return proc.stdout + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + out = bytearray() + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise cmuxError("unexpected EOF while reading socket") + out.extend(chunk) + return bytes(out) + + +def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: + out = bytearray() + while marker not in out: + chunk = sock.recv(1024) + if not chunk: + raise cmuxError("unexpected EOF while reading response headers") + out.extend(chunk) + if len(out) > limit: + raise cmuxError("response headers too large") + return bytes(out) + + +def _read_socks5_connect_reply(sock: socket.socket) -> None: + head = _recv_exact(sock, 4) + if len(head) != 4 or head[0] != 0x05: + raise cmuxError(f"invalid SOCKS5 reply: {head!r}") + if head[1] != 0x00: + raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") + + atyp = head[3] + if atyp == 0x01: + _ = _recv_exact(sock, 4) + elif atyp == 0x03: + ln = _recv_exact(sock, 1)[0] + _ = _recv_exact(sock, ln) + elif atyp == 0x04: + _ = _recv_exact(sock, 16) + else: + raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{atyp:02x}") + _ = _recv_exact(sock, 2) # bound port + + +def _read_http_response_from_connected_socket(sock: socket.socket) -> str: + response = _recv_until(sock, b"\r\n\r\n") + header_end = response.index(b"\r\n\r\n") + 4 + header_blob = response[:header_end] + body = bytearray(response[header_end:]) + header_text = header_blob.decode("utf-8", errors="replace") + + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") + + content_length: int | None = None + for line in header_text.split("\r\n")[1:]: + if line.lower().startswith("content-length:"): + try: + content_length = int(line.split(":", 1)[1].strip()) + except Exception: # noqa: BLE001 + content_length = None + break + + if content_length is not None: + while len(body) < content_length: + chunk = sock.recv(4096) + if not chunk: + break + body.extend(chunk) + else: + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + body.extend(chunk) + + return bytes(body).decode("utf-8", errors="replace") + + +def _http_get_on_connected_socket(sock: socket.socket, host: str, port: int, path: str = "/") -> str: + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + return _read_http_response_from_connected_socket(sock) + + +def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + + # greeting: no-auth only + sock.sendall(b"\x05\x01\x00") + greeting = _recv_exact(sock, 2) + if greeting != b"\x05\x00": + sock.close() + raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") + + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" # IPv4 + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + sock.close() + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" # domain + addr = bytes([len(host_encoded)]) + host_encoded + + req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + sock.sendall(req) + + try: + _read_socks5_connect_reply(sock) + except Exception: + sock.close() + raise + return sock + + +def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + try: + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + greeting = b"\x05\x01\x00" + connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + http_get = ( + "GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + + # Send greeting + CONNECT + first upstream payload in one write to exercise + # SOCKS request parsing when pending bytes already exist in the handshake buffer. + sock.sendall(greeting + connect_req + http_get) + + greeting_reply = _recv_exact(sock, 2) + if greeting_reply != b"\x05\x00": + raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") + _read_socks5_connect_reply(sock) + return _read_http_response_from_connected_socket(sock) + finally: + try: + sock.close() + except Exception: + pass + + +def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + request = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Proxy-Connection: Keep-Alive\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + sock.close() + raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") + return sock + + +def _encode_client_text_frame(payload: str) -> bytes: + data = payload.encode("utf-8") + first = 0x81 # FIN + text + mask = secrets.token_bytes(4) + length = len(data) + if length < 126: + header = bytes([first, 0x80 | length]) + elif length <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + return header + mask + masked + + +def _read_server_text_frame(sock: socket.socket) -> str: + first, second = _recv_exact(sock, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(sock, 8))[0] + mask = _recv_exact(sock, 4) if masked else b"" + payload = _recv_exact(sock, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + + if opcode != 0x1: + raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") + try: + return payload.decode("utf-8") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") + + +def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: + ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + "GET /echo HTTP/1.1\r\n" + f"Host: {ws_host}:{ws_port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "101" not in status_line: + raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") + + expected_accept = b64encode( + hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() + ).decode("ascii") + lowered_headers = { + line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() + for line in header_text.split("\r\n")[1:] + if ":" in line + } + if lowered_headers.get("sec-websocket-accept", "") != expected_accept: + raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") + + sock.sendall(_encode_client_text_frame(message)) + return _read_server_text_frame(sock) + + +def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _remote_binary_size_bytes(host: str, host_port: int, key_path: Path, remote_path: str) -> int: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +wc -c < "$full" +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + text = proc.stdout.strip().splitlines()[-1].strip() + return int(text) + + +def _extract_daemon_version_platform(remote_path: str) -> tuple[str, str]: + parts = [segment for segment in remote_path.strip().split("/") if segment] + try: + marker_index = parts.index("cmuxd-remote") + except ValueError as exc: + raise cmuxError(f"remote daemon path missing cmuxd-remote marker: {remote_path!r}") from exc + + required_len = marker_index + 4 + _must( + len(parts) >= required_len, + f"remote daemon path should include version/platform/binary: {remote_path!r}", + ) + version = parts[marker_index + 1] + platform = parts[marker_index + 2] + binary_name = parts[marker_index + 3] + _must(binary_name == "cmuxd-remote", f"unexpected daemon binary name in remote path: {remote_path!r}") + _must(bool(version), f"daemon version should not be empty in remote path: {remote_path!r}") + _must(bool(platform), f"daemon platform should not be empty in remote path: {remote_path!r}") + return version, platform + + +def _local_cached_daemon_binary(version: str, platform: str) -> Path: + return Path(tempfile.gettempdir()) / "cmux-remote-daemon-build" / version / platform / "cmuxd-remote" + + +def _local_file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _local_binary_contains_version_marker(path: Path, version: str) -> bool: + marker = version.encode("utf-8") + tail = b"" + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + return False + haystack = tail + chunk + if marker in haystack: + return True + tail = haystack[-max(len(marker) - 1, 0) :] + + +def _remote_binary_sha256(host: str, host_port: int, key_path: Path, remote_path: str) -> str: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$full" | awk '{{print $1}}' +elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$full" | awk '{{print $1}}' +else + openssl dgst -sha256 "$full" | awk '{{print $NF}}' +fi +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + digest = proc.stdout.strip().splitlines()[-1].strip().lower() + _must(len(digest) == 64 and all(ch in "0123456789abcdef" for ch in digest), f"invalid remote SHA256 digest: {digest!r}") + return digest + + +def _wait_connected_proxy_port(client: cmux, workspace_id: str, timeout: float = 45.0) -> tuple[dict, int]: + deadline = time.time() + timeout + last_status = {} + proxy_port: int | None = None + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + state = str(remote.get("state") or "") + proxy = remote.get("proxy") or {} + port_value = proxy.get("port") + if isinstance(port_value, int): + proxy_port = port_value + elif isinstance(port_value, str) and port_value.isdigit(): + proxy_port = int(port_value) + if state == "connected" and proxy_port is not None: + return last_status, proxy_port + time.sleep(0.5) + raise cmuxError(f"Remote proxy did not converge to connected state: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-docker-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-test-{secrets.token_hex(4)}" + workspace_id = "" + workspace_id_shared = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + fresh_check = _ssh_run( + host, + host_ssh_port, + key_path, + "test ! -e \"$HOME/.cmux/bin/cmuxd-remote\" && echo fresh", + check=True, + ) + _must("fresh" in fresh_check.stdout, "Fresh container should not have preinstalled cmuxd-remote") + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-ssh-forward", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and 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: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + last_status, proxy_port = _wait_connected_proxy_port(client, workspace_id) + + daemon = ((last_status.get("remote") or {}).get("daemon") or {}) + _must(str(daemon.get("state") or "") == "ready", f"daemon should be ready in connected state: {last_status}") + capabilities = daemon.get("capabilities") or [] + _must("proxy.stream" in capabilities, f"daemon hello capabilities missing proxy.stream: {daemon}") + _must("proxy.socks5" in capabilities, f"daemon hello capabilities missing proxy.socks5: {daemon}") + _must("session.basic" in capabilities, f"daemon hello capabilities missing session.basic: {daemon}") + _must("session.resize.min" in capabilities, f"daemon hello capabilities missing session.resize.min: {daemon}") + remote_path = str(daemon.get("remote_path") or "").strip() + _must(bool(remote_path), f"daemon ready state should include remote_path: {daemon}") + + binary_size_bytes = _remote_binary_size_bytes(host, host_ssh_port, key_path, remote_path) + _must(binary_size_bytes > 0, f"uploaded daemon binary should be non-empty: {binary_size_bytes}") + _must( + binary_size_bytes <= MAX_REMOTE_DAEMON_SIZE_BYTES, + f"uploaded daemon binary too large: {binary_size_bytes} bytes > {MAX_REMOTE_DAEMON_SIZE_BYTES}", + ) + daemon_version, daemon_platform = _extract_daemon_version_platform(remote_path) + local_cached_binary = _local_cached_daemon_binary(daemon_version, daemon_platform) + _must( + local_cached_binary.is_file(), + f"expected local daemon cache artifact at {local_cached_binary} after bootstrap upload", + ) + _must( + os.access(local_cached_binary, os.X_OK), + f"local daemon cache artifact must be executable: {local_cached_binary}", + ) + _must( + _local_binary_contains_version_marker(local_cached_binary, daemon_version), + f"local cached daemon binary should embed daemon version marker {daemon_version!r}: {local_cached_binary}", + ) + local_sha256 = _local_file_sha256(local_cached_binary) + remote_sha256 = _remote_binary_sha256(host, host_ssh_port, key_path, remote_path) + _must( + local_sha256 == remote_sha256, + "uploaded daemon binary hash should match local cached build artifact " + f"(local={local_sha256}, remote={remote_sha256})", + ) + + body = "" + deadline_http = time.time() + 15.0 + while time.time() < deadline_http: + try: + body = _curl_via_socks(proxy_port, f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in body: + break + time.sleep(0.3) + + _must("cmux-ssh-forward-ok" in body, f"Forwarded HTTP endpoint returned unexpected body: {body[:120]!r}") + pipelined_body = _socks5_http_get_pipelined("127.0.0.1", proxy_port, "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in pipelined_body, + f"SOCKS pipelined greeting/connect+payload path returned unexpected body: {pipelined_body[:120]!r}", + ) + + ws_message = "cmux-ws-over-socks-ok" + echoed_message = _websocket_echo_via_socks(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_message) + _must( + echoed_message == ws_message, + f"WebSocket echo over SOCKS proxy mismatch: {echoed_message!r} != {ws_message!r}", + ) + + ws_connect_message = "cmux-ws-over-connect-ok" + echoed_connect = _websocket_echo_via_connect(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_connect_message) + _must( + echoed_connect == ws_connect_message, + f"WebSocket echo over CONNECT proxy mismatch: {echoed_connect!r} != {ws_connect_message!r}", + ) + + payload_shared = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-ssh-forward-shared", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id_shared = str(payload_shared.get("workspace_id") or "") + workspace_ref_shared = str(payload_shared.get("workspace_ref") or "") + if not workspace_id_shared and workspace_ref_shared.startswith("workspace:"): + listed_shared = client._call("workspace.list", {}) or {} + for row in listed_shared.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_shared: + workspace_id_shared = str(row.get("id") or "") + break + _must(bool(workspace_id_shared), f"cmux ssh output missing workspace_id for shared transport test: {payload_shared}") + + _, shared_proxy_port = _wait_connected_proxy_port(client, workspace_id_shared) + _must( + shared_proxy_port == proxy_port, + f"identical SSH transports should share one local proxy endpoint: {proxy_port} vs {shared_proxy_port}", + ) + + try: + client.close_workspace(workspace_id_shared) + workspace_id_shared = "" + except Exception: + pass + + try: + client.close_workspace(workspace_id) + workspace_id = "" + except Exception: + pass + + print( + "PASS: docker SSH proxy endpoint is reachable, handles HTTP + WebSocket egress over SOCKS and CONNECT through remote host, and is shared across identical transports; " + f"uploaded cmuxd-remote size={binary_size_bytes} bytes, version={daemon_version}, platform={daemon_platform}" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + if workspace_id_shared: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_shared) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_reconnect.py b/tests_v2/test_ssh_remote_docker_reconnect.py new file mode 100644 index 00000000..43c0e3cd --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_reconnect.py @@ -0,0 +1,612 @@ +#!/usr/bin/env python3 +"""Docker integration: remote SSH reconnect after host restart.""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import secrets +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +from base64 import b64encode +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174")) +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(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 _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 _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _curl_via_socks(proxy_port: int, target_url: str) -> str: + if shutil.which("curl") is None: + raise cmuxError("curl is required for SOCKS proxy verification") + proc = _run( + [ + "curl", + "--silent", + "--show-error", + "--max-time", + "5", + "--socks5-hostname", + f"127.0.0.1:{proxy_port}", + target_url, + ], + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"curl via SOCKS proxy failed: {merged}") + return proc.stdout + + +def _find_free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + out = bytearray() + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise cmuxError("unexpected EOF while reading socket") + out.extend(chunk) + return bytes(out) + + +def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: + out = bytearray() + while marker not in out: + chunk = sock.recv(1024) + if not chunk: + raise cmuxError("unexpected EOF while reading response headers") + out.extend(chunk) + if len(out) > limit: + raise cmuxError("response headers too large") + return bytes(out) + + +def _read_socks5_connect_reply(sock: socket.socket) -> None: + head = _recv_exact(sock, 4) + if len(head) != 4 or head[0] != 0x05: + raise cmuxError(f"invalid SOCKS5 reply: {head!r}") + if head[1] != 0x00: + raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") + + reply_atyp = head[3] + if reply_atyp == 0x01: + _ = _recv_exact(sock, 4) + elif reply_atyp == 0x03: + ln = _recv_exact(sock, 1)[0] + _ = _recv_exact(sock, ln) + elif reply_atyp == 0x04: + _ = _recv_exact(sock, 16) + else: + raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{reply_atyp:02x}") + _ = _recv_exact(sock, 2) + + +def _read_http_response_from_connected_socket(sock: socket.socket) -> str: + response = _recv_until(sock, b"\r\n\r\n") + header_end = response.index(b"\r\n\r\n") + 4 + header_blob = response[:header_end] + body = bytearray(response[header_end:]) + header_text = header_blob.decode("utf-8", errors="replace") + + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") + + content_length: int | None = None + for line in header_text.split("\r\n")[1:]: + if line.lower().startswith("content-length:"): + try: + content_length = int(line.split(":", 1)[1].strip()) + except Exception: # noqa: BLE001 + content_length = None + break + + if content_length is not None: + while len(body) < content_length: + chunk = sock.recv(4096) + if not chunk: + break + body.extend(chunk) + else: + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + body.extend(chunk) + + return bytes(body).decode("utf-8", errors="replace") + + +def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + + sock.sendall(b"\x05\x01\x00") + greeting = _recv_exact(sock, 2) + if greeting != b"\x05\x00": + sock.close() + raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") + + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + sock.close() + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + sock.sendall(req) + + try: + _read_socks5_connect_reply(sock) + except Exception: + sock.close() + raise + return sock + + +def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + try: + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + greeting = b"\x05\x01\x00" + connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + http_get = ( + "GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + + sock.sendall(greeting + connect_req + http_get) + + greeting_reply = _recv_exact(sock, 2) + if greeting_reply != b"\x05\x00": + raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") + _read_socks5_connect_reply(sock) + return _read_http_response_from_connected_socket(sock) + finally: + try: + sock.close() + except Exception: + pass + + +def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + request = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Proxy-Connection: Keep-Alive\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + sock.close() + raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") + return sock + + +def _encode_client_text_frame(payload: str) -> bytes: + data = payload.encode("utf-8") + first = 0x81 + mask = secrets.token_bytes(4) + length = len(data) + if length < 126: + header = bytes([first, 0x80 | length]) + elif length <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + return header + mask + masked + + +def _read_server_text_frame(sock: socket.socket) -> str: + first, second = _recv_exact(sock, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(sock, 8))[0] + mask = _recv_exact(sock, 4) if masked else b"" + payload = _recv_exact(sock, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + + if opcode != 0x1: + raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") + try: + return payload.decode("utf-8") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") + + +def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: + ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + "GET /echo HTTP/1.1\r\n" + f"Host: {ws_host}:{ws_port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "101" not in status_line: + raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") + + expected_accept = b64encode( + hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() + ).decode("ascii") + lowered_headers = { + line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() + for line in header_text.split("\r\n")[1:] + if ":" in line + } + if lowered_headers.get("sec-websocket-accept", "") != expected_accept: + raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") + + sock.sendall(_encode_client_text_frame(message)) + return _read_server_text_frame(sock) + + +def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _start_container(image_tag: str, container_name: str, pubkey: str, host_ssh_port: int) -> None: + for _ in range(20): + proc = _run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-e", + f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-e", + f"REMOTE_WS_PORT={REMOTE_WS_PORT}", + "-p", + f"{DOCKER_PUBLISH_ADDR}:{host_ssh_port}:22", + image_tag, + ], + check=False, + ) + if proc.returncode == 0: + return + time.sleep(0.5) + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Failed to start ssh test container on fixed port {host_ssh_port}: {merged}") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + proxy = remote.get("proxy") or {} + port_value = proxy.get("port") + proxy_port: int | None + if isinstance(port_value, int): + proxy_port = port_value + elif isinstance(port_value, str) and port_value.isdigit(): + proxy_port = int(port_value) + else: + proxy_port = None + if str(remote.get("state") or "") == "connected" and proxy_port is not None: + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not reach connected+proxy-ready state: {last_status}") + + +def _wait_remote_degraded(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + state = str(remote.get("state") or "") + if state in {"error", "connecting", "disconnected"}: + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not enter reconnecting/degraded state: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-reconnect-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-reconnect-{secrets.token_hex(4)}" + host_ssh_port = _find_free_loopback_port() + workspace_id = "" + container_running = False + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _start_container(image_tag, container_name, pubkey, host_ssh_port) + container_running = True + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + f"root@{DOCKER_SSH_HOST}", + "--name", + "docker-ssh-reconnect", + "--port", + str(host_ssh_port), + "--identity", + str(key_path), + "--ssh-option", + "UserKnownHostsFile=/dev/null", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and 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: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + first_status = _wait_remote_connected(client, workspace_id, timeout=45.0) + first_daemon = ((first_status.get("remote") or {}).get("daemon") or {}) + _must(str(first_daemon.get("state") or "") == "ready", f"daemon should be ready after first connect: {first_status}") + first_capabilities = {str(item) for item in (first_daemon.get("capabilities") or [])} + _must("proxy.stream" in first_capabilities, f"daemon should advertise proxy.stream: {first_status}") + _must("proxy.socks5" in first_capabilities, f"daemon should advertise proxy.socks5: {first_status}") + _must("proxy.http_connect" in first_capabilities, f"daemon should advertise proxy.http_connect: {first_status}") + first_proxy = ((first_status.get("remote") or {}).get("proxy") or {}) + first_proxy_port = first_proxy.get("port") + if isinstance(first_proxy_port, str) and first_proxy_port.isdigit(): + first_proxy_port = int(first_proxy_port) + _must(isinstance(first_proxy_port, int), f"connected status should include proxy port: {first_status}") + + first_body = "" + first_deadline_http = time.time() + 15.0 + while time.time() < first_deadline_http: + try: + first_body = _curl_via_socks(int(first_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in first_body: + break + time.sleep(0.3) + _must("cmux-ssh-forward-ok" in first_body, f"Forwarded HTTP endpoint failed before reconnect: {first_body[:120]!r}") + first_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(first_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in first_pipelined_body, + f"SOCKS pipelined greeting/connect+payload failed before reconnect: {first_pipelined_body[:120]!r}", + ) + + first_ws_socks_message = "cmux-reconnect-before-over-socks" + echoed_before_socks = _websocket_echo_via_socks(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_socks_message) + _must( + echoed_before_socks == first_ws_socks_message, + f"WebSocket echo over SOCKS proxy failed before reconnect: {echoed_before_socks!r} != {first_ws_socks_message!r}", + ) + + first_ws_connect_message = "cmux-reconnect-before-over-connect" + echoed_before_connect = _websocket_echo_via_connect(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_connect_message) + _must( + echoed_before_connect == first_ws_connect_message, + f"WebSocket echo over CONNECT proxy failed before reconnect: {echoed_before_connect!r} != {first_ws_connect_message!r}", + ) + + _run(["docker", "rm", "-f", container_name], check=False) + container_running = False + _wait_remote_degraded(client, workspace_id, timeout=20.0) + + _start_container(image_tag, container_name, pubkey, host_ssh_port) + container_running = True + + second_status = _wait_remote_connected(client, workspace_id, timeout=60.0) + second_daemon = ((second_status.get("remote") or {}).get("daemon") or {}) + _must(str(second_daemon.get("state") or "") == "ready", f"daemon should be ready after reconnect: {second_status}") + second_capabilities = {str(item) for item in (second_daemon.get("capabilities") or [])} + _must("proxy.stream" in second_capabilities, f"daemon should advertise proxy.stream after reconnect: {second_status}") + _must("proxy.socks5" in second_capabilities, f"daemon should advertise proxy.socks5 after reconnect: {second_status}") + _must("proxy.http_connect" in second_capabilities, f"daemon should advertise proxy.http_connect after reconnect: {second_status}") + second_proxy = ((second_status.get("remote") or {}).get("proxy") or {}) + second_proxy_port = second_proxy.get("port") + if isinstance(second_proxy_port, str) and second_proxy_port.isdigit(): + second_proxy_port = int(second_proxy_port) + _must(isinstance(second_proxy_port, int), f"reconnected status should include proxy port: {second_status}") + + second_body = "" + deadline_http = time.time() + 15.0 + while time.time() < deadline_http: + try: + second_body = _curl_via_socks(int(second_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in second_body: + break + time.sleep(0.3) + _must("cmux-ssh-forward-ok" in second_body, f"Forwarded HTTP endpoint failed after reconnect: {second_body[:120]!r}") + second_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(second_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in second_pipelined_body, + f"SOCKS pipelined greeting/connect+payload failed after reconnect: {second_pipelined_body[:120]!r}", + ) + + second_ws_socks_message = "cmux-reconnect-after-over-socks" + echoed_after_socks = _websocket_echo_via_socks(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_socks_message) + _must( + echoed_after_socks == second_ws_socks_message, + f"WebSocket echo over SOCKS proxy failed after reconnect: {echoed_after_socks!r} != {second_ws_socks_message!r}", + ) + + second_ws_connect_message = "cmux-reconnect-after-over-connect" + echoed_after_connect = _websocket_echo_via_connect(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_connect_message) + _must( + echoed_after_connect == second_ws_connect_message, + f"WebSocket echo over CONNECT proxy failed after reconnect: {echoed_after_connect!r} != {second_ws_connect_message!r}", + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: docker SSH remote reconnects and re-establishes HTTP + WebSocket egress over SOCKS and CONNECT") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + if container_running: + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py new file mode 100644 index 00000000..040207d7 --- /dev/null +++ b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Regression: interactive `cmux ssh` shells must resolve `cmux` to the relay wrapper.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_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) + + import subprocess + + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + if proc.returncode != 0: + raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") + 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 _workspace_id_from_payload(client: cmux, payload: dict) -> 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:"): + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 25.0) -> None: + deadline = time.time() + timeout + 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 _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: + deadline = time.time() + timeout + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + if surfaces: + return str(surfaces[0][1]) + time.sleep(0.1) + raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") + + +def _wait_text(client: cmux, surface_id: str, token: str, timeout: float = 12.0) -> str: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + if token in last: + return last + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for {token!r} in surface {surface_id}: {last[-1200:]!r}") + + +def _wait_shell_ready(client: cmux, surface_id: str, timeout: float = 20.0) -> None: + token = f"__CMUX_SHELL_READY_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"printf '{token}'; echo") + client.send_key_surface(surface_id, "enter") + _wait_text(client, surface_id, token, timeout=timeout) + + +def _run_remote_shell_command(client: cmux, surface_id: str, command: str, timeout: float = 12.0) -> tuple[int, str, str]: + token = f"__CMUX_REMOTE_CMD_{secrets.token_hex(6)}__" + start_marker = f"{token}:START" + status_marker = f"{token}:STATUS" + end_marker = f"{token}:END" + client.send_surface( + surface_id, + ( + f"printf '{start_marker}'; echo; " + f"{command}; " + "__cmux_status=$?; " + f"printf '{status_marker}:%s' \"$__cmux_status\"; echo; " + f"printf '{end_marker}'; echo" + ), + ) + client.send_key_surface(surface_id, "enter") + deadline = time.time() + timeout + text = "" + while time.time() < deadline: + text = client.read_terminal_text(surface_id) + if ( + text.count(start_marker) >= 2 + and text.count(status_marker) >= 2 + and text.count(end_marker) >= 2 + ): + break + time.sleep(0.15) + pattern = re.compile( + re.escape(start_marker) + r"\n(.*?)" + re.escape(status_marker) + r":(\d+)\n" + re.escape(end_marker), + re.S, + ) + matches = pattern.findall(text) + if not matches: + raise cmuxError(f"Missing command result token for {command!r}: {text[-1200:]!r}") + output, status_raw = matches[-1] + return int(status_raw), output, text + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run interactive ssh cmux command regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + try: + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json(cli, ["ssh", SSH_HOST]) + workspace_id = _workspace_id_from_payload(client, payload) + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + workspace_ids.append(workspace_id) + + _wait_remote_ready(client, workspace_id) + surface_id = _wait_surface_id(client, workspace_id) + _wait_shell_ready(client, surface_id) + + which_status, which_output, which_text = _run_remote_shell_command(client, surface_id, "command -v cmux") + _must(which_status == 0, f"`command -v cmux` failed: output={which_output!r} tail={which_text[-1200:]!r}") + _must( + "/.cmux/bin/cmux" in which_output, + f"interactive ssh shell should resolve cmux to relay wrapper, got {which_output!r}", + ) + + ping_status, ping_output, ping_text = _run_remote_shell_command(client, surface_id, "cmux ping") + _must(ping_status == 0, f"`cmux ping` failed in interactive shell: output={ping_output!r} tail={ping_text[-1200:]!r}") + _must("pong" in ping_output.lower(), f"`cmux ping` should return pong, got {ping_output!r}") + _must( + "Socket not found at 127.0.0.1:" not in ping_text, + f"interactive ssh shell still routed cmux to a unix-socket-only binary: {ping_text[-1200:]!r}", + ) + _must( + "waiting for relay on 127.0.0.1:" not in ping_text and "failed to connect to 127.0.0.1:" not in ping_text, + f"`cmux ping` hit a dead ssh relay instead of the local app socket: {ping_text[-1200:]!r}", + ) + + notify_status, notify_output, notify_text = _run_remote_shell_command( + client, + surface_id, + "cmux notify --body interactive-ssh-regression", + ) + _must( + notify_status == 0, + f"`cmux notify` failed in interactive shell: output={notify_output!r} tail={notify_text[-1200:]!r}", + ) + _must( + "Socket not found at 127.0.0.1:" not in notify_text, + f"`cmux notify` still failed via wrong cmux binary: {notify_text[-1200:]!r}", + ) + _must( + "waiting for relay on 127.0.0.1:" not in notify_text and "failed to connect to 127.0.0.1:" not in notify_text, + f"`cmux notify` still failed because the ssh relay listener was not running: {notify_text[-1200:]!r}", + ) + + shell_status, shell_output, shell_text = _run_remote_shell_command( + client, + surface_id, + r'''printf 'TERM=%s\n' "${TERM:-}"; printf 'TERM_PROGRAM=%s\n' "${TERM_PROGRAM:-}"; printf 'TERM_PROGRAM_VERSION=%s\n' "${TERM_PROGRAM_VERSION:-}"; printf 'GHOSTTY_SHELL_FEATURES=%s\n' "${GHOSTTY_SHELL_FEATURES:-}"; bindkey "^A"; bindkey "^K"; bindkey "^[^?"; bindkey "^[b"; bindkey "^[f"''', + ) + _must(shell_status == 0, f"ssh shell env/bindkey probe failed: output={shell_output!r} tail={shell_text[-1200:]!r}") + _must("TERM=xterm-ghostty" in shell_output, f"ssh shell lost TERM=xterm-ghostty: {shell_output!r}") + _must("TERM_PROGRAM=ghostty" in shell_output, f"ssh shell lost TERM_PROGRAM=ghostty: {shell_output!r}") + _must("GHOSTTY_SHELL_FEATURES=" in shell_output, f"ssh shell lost GHOSTTY_SHELL_FEATURES: {shell_output!r}") + _must("ssh-env" in shell_output, f"ssh shell missing ssh-env feature: {shell_output!r}") + _must("ssh-terminfo" in shell_output, f"ssh shell missing ssh-terminfo feature: {shell_output!r}") + _must('"^A" beginning-of-line' in shell_output, f"Ctrl-A binding regressed in ssh shell: {shell_output!r}") + _must('"^K" kill-line' in shell_output, f"Ctrl-K binding regressed in ssh shell: {shell_output!r}") + _must('"^[^?" backward-kill-word' in shell_output, f"Opt-Backspace binding regressed in ssh shell: {shell_output!r}") + _must('"^[b" backward-word' in shell_output, f"Opt-Left binding regressed in ssh shell: {shell_output!r}") + _must('"^[f" forward-word' in shell_output, f"Opt-Right binding regressed in ssh shell: {shell_output!r}") + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: interactive ssh shell resolves cmux to relay wrapper and remote cmux commands succeed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py b/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py new file mode 100644 index 00000000..91af772d --- /dev/null +++ b/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Regression: closing the last SSH surface should clear remote workspace state.""" + +from __future__ import annotations + +import glob +import json +import os +import re +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", "/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 = 8.0, step_s: float = 0.1) -> 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 _remote_session_count(client: cmux, workspace_id: str) -> int: + row = _workspace_row(client, workspace_id) + remote = row.get("remote") or {} + return int(remote.get("active_terminal_sessions") or 0) + + +def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 12.0) -> str: + token = f"__CMUX_{token_prefix}_{int(time.time() * 1000)}__" + 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] + 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, *, name: str) -> str: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", name] + 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) + _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 + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh last-surface remote state regression") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-last-surface-{int(time.time())}", + ) + + row = _workspace_row(client, workspace_id) + remote = row.get("remote") or {} + _must(bool(remote.get("enabled")) is True, f"workspace should start as remote-enabled: {row}") + _must(int(remote.get("active_terminal_sessions") or 0) == 1, f"workspace should start with one active ssh terminal session: {row}") + + surfaces = client.list_surfaces(workspace_id) + _must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}") + + split_surface_id = client.new_split("right") + _wait_for(lambda: len(client.list_surfaces(workspace_id)) == 2, timeout_s=10.0, step_s=0.1) + _wait_for(lambda: _remote_session_count(client, workspace_id) == 2, timeout_s=10.0, step_s=0.1) + + client.send_surface(split_surface_id, "exit") + client.send_key_surface(split_surface_id, "enter") + _wait_for(lambda: _remote_session_count(client, workspace_id) == 1, timeout_s=15.0, step_s=0.15) + + row_after_first_exit = _workspace_row(client, workspace_id) + remote_after_first_exit = row_after_first_exit.get("remote") or {} + _must(bool(remote_after_first_exit.get("enabled")) is True, f"workspace should stay remote while one ssh terminal remains: {row_after_first_exit}") + + remaining_surface_id = next( + surface_id + for _index, surface_id, _focused in client.list_surfaces(workspace_id) + if surface_id != split_surface_id + ) + client.send_surface(remaining_surface_id, "exit") + client.send_key_surface(remaining_surface_id, "enter") + + def _remote_cleared() -> bool: + row_now = _workspace_row(client, workspace_id) + remote_now = row_now.get("remote") or {} + if bool(remote_now.get("enabled")): + return False + surfaces_now = client.list_surfaces(workspace_id) + return len(surfaces_now) == 2 + + _wait_for(_remote_cleared, timeout_s=15.0, step_s=0.15) + + final_row = _workspace_row(client, workspace_id) + final_remote = final_row.get("remote") or {} + _must(bool(final_remote.get("enabled")) is False, f"workspace remote metadata should clear after last ssh surface closes: {final_row}") + _must(str(final_remote.get("state") or "") == "disconnected", f"workspace should end disconnected after remote metadata clears: {final_row}") + _must(int(final_remote.get("active_terminal_sessions") or 0) == 0, f"workspace should report zero active ssh terminal sessions after last ssh surface closes: {final_row}") + + local_surface_ids = [surface_id for _index, surface_id, _focused in client.list_surfaces(workspace_id)] + _must(len(local_surface_ids) == 2, f"expected both panes to remain as local terminals after ssh exits, got {local_surface_ids}") + for idx, surface_id in enumerate(local_surface_ids): + socket_output = _run_surface_probe( + client, + surface_id, + r'''printf '%s' "${CMUX_SOCKET_PATH:-}"''', + f"SSH_LAST_SURFACE_SOCKET_{idx}", + ).strip() + _must( + not socket_output.startswith("127.0.0.1:"), + f"surface {surface_id} should be local after clearing remote state, got CMUX_SOCKET_PATH={socket_output!r}", + ) + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + + print("PASS: exiting all ssh panes clears remote workspace state while fallback local panes remain local") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_proxy_bind_conflict.py b/tests_v2/test_ssh_remote_proxy_bind_conflict.py new file mode 100644 index 00000000..d47e2957 --- /dev/null +++ b/tests_v2/test_ssh_remote_proxy_bind_conflict.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Docker integration: local proxy bind conflict surfaces proxy_unavailable.""" + +from __future__ import annotations + +import glob +import os +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +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", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(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 _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _find_free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_proxy_conflict_status(client: cmux, workspace_id: str, expected_local_proxy_port: int, timeout: float = 30.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + proxy = remote.get("proxy") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "error" and str(proxy.get("state") or "") == "error": + detail = str(remote.get("detail") or "") + _must( + proxy.get("error_code") == "proxy_unavailable", + f"proxy error should be proxy_unavailable under bind conflict: {last_status}", + ) + _must( + int(remote.get("local_proxy_port") or 0) == expected_local_proxy_port, + f"remote status should retain configured local_proxy_port under bind conflict: {last_status}", + ) + _must( + ( + "Failed to start local daemon proxy" in detail + or "Local proxy listener failed" in detail + ), + f"remote detail should surface local proxy bind failure: {last_status}", + ) + _must( + "Address already in use" in detail, + f"remote detail should preserve bind-conflict root cause: {last_status}", + ) + _must( + str(daemon.get("state") or "") == "ready", + f"daemon should remain ready for local-only bind conflicts: {last_status}", + ) + return last_status + time.sleep(0.5) + + raise cmuxError(f"Remote did not reach structured proxy_unavailable status for bind conflict: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + _ = _find_cli_binary() # enforce same test prerequisites as other SSH remote suites + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-proxy-conflict-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-proxy-conflict-{secrets.token_hex(4)}" + workspace_id = "" + conflict_listener: socket.socket | None = None + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-p", f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + conflict_port = _find_free_loopback_port() + conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + conflict_listener.bind(("127.0.0.1", conflict_port)) + conflict_listener.listen(1) + + with cmux(SOCKET_PATH) as client: + created = client._call("workspace.create", {"initial_command": "echo ssh-proxy-conflict"}) + workspace_id = str((created or {}).get("workspace_id") or "") + _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") + + configured = client._call("workspace.remote.configure", { + "workspace_id": workspace_id, + "destination": host, + "port": host_ssh_port, + "identity_file": str(key_path), + "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], + "auto_connect": True, + "local_proxy_port": conflict_port, + }) + _must(bool(configured), "workspace.remote.configure returned empty response") + + _ = _wait_for_proxy_conflict_status( + client, + workspace_id, + expected_local_proxy_port=conflict_port, + timeout=30.0, + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: local proxy bind conflict surfaces structured proxy_unavailable without degrading daemon readiness") + return 0 + + finally: + if conflict_listener is not None: + try: + conflict_listener.close() + except Exception: + pass + + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_resize_scrollback_regression.py b/tests_v2/test_ssh_remote_resize_scrollback_regression.py new file mode 100644 index 00000000..ff70110e --- /dev/null +++ b/tests_v2/test_ssh_remote_resize_scrollback_regression.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Regression: ssh workspace keeps large pre-resize scrollback across split resize churn.""" + +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", "/tmp/cmux.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() +LS_ENTRY_COUNT = int(os.environ.get("CMUX_SSH_TEST_LS_COUNT", "320")) +RESIZE_ITERATIONS = int(os.environ.get("CMUX_SSH_TEST_RESIZE_ITERATIONS", "48")) + +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _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 = 8.0, step_s: float = 0.1) -> 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_connected(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.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 reach connected+ready state: {last}") + + +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 _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + return [_clean_line(raw) for raw in _surface_scrollback_text(client, workspace_id, surface_id).splitlines()] + + +def _wait_surface_contains( + client: cmux, + workspace_id: str, + surface_id: str, + token: str, + *, + exact_line: bool = False, + timeout_s: float = 25.0, +) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if exact_line: + if token in _surface_scrollback_lines(client, workspace_id, surface_id): + return + elif token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _pane_for_surface(client: cmux, surface_id: str) -> str: + target_id = str(client._resolve_surface_id(surface_id)) + for _idx, pane_id, _count, _focused in client.list_panes(): + rows = client.list_pane_surfaces(pane_id) + for _row_idx, sid, _title, _selected in rows: + try: + candidate_id = str(client._resolve_surface_id(sid)) + except cmuxError: + continue + if candidate_id == target_id: + return pane_id + raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") + + +def _valid_resize_directions(client: cmux, workspace_id: str, pane_id: str) -> list[str]: + valid: list[str] = [] + for direction in ("left", "right", "up", "down"): + try: + client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 10, + }, + ) + valid.append(direction) + except cmuxError: + pass + return valid + + +def _choose_resize_pair(client: cmux, workspace_id: str, pane_ids: list[str]) -> list[tuple[str, str]]: + by_pane: dict[str, list[str]] = {} + for pane_id in pane_ids: + by_pane[pane_id] = _valid_resize_directions(client, workspace_id, pane_id) + + for pane_a, directions_a in by_pane.items(): + if "right" not in directions_a: + continue + for pane_b, directions_b in by_pane.items(): + if pane_b == pane_a: + continue + if "left" in directions_b: + return [(pane_a, "right"), (pane_b, "left")] + + for pane_a, directions_a in by_pane.items(): + if "down" not in directions_a: + continue + for pane_b, directions_b in by_pane.items(): + if pane_b == pane_a: + continue + if "up" in directions_b: + return [(pane_a, "down"), (pane_b, "up")] + + raise cmuxError(f"Could not find oscillating resize pair across panes: {by_pane}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote resize scrollback regression") + return 0 + if LS_ENTRY_COUNT < 64: + print("SKIP: CMUX_SSH_TEST_LS_COUNT must be >= 64 for meaningful scrollback coverage") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-resize-regression-{secrets.token_hex(4)}"] + 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) + _wait_remote_connected(client, workspace_id, timeout_s=50.0) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + + stamp = secrets.token_hex(4) + ls_entries = [f"CMUX_REMOTE_RESIZE_LS_{stamp}_{index:04d}.txt" for index in range(1, LS_ENTRY_COUNT + 1)] + ls_start = f"CMUX_REMOTE_RESIZE_LS_START_{stamp}" + ls_end = f"CMUX_REMOTE_RESIZE_LS_END_{stamp}" + + ls_prefix = f"CMUX_REMOTE_RESIZE_LS_{stamp}_" + ls_script = ( + "tmpdir=$(mktemp -d); " + f"echo {ls_start}; " + f"for i in $(seq 1 {LS_ENTRY_COUNT}); do " + "n=$(printf '%04d' \"$i\"); " + f"touch \"$tmpdir/{ls_prefix}$n.txt\"; " + "done; " + "LC_ALL=C CLICOLOR=0 ls -1 \"$tmpdir\"; " + f"echo {ls_end}; " + "rm -rf \"$tmpdir\"" + ) + client.send_surface(surface_id, f"{ls_script}\n") + _wait_surface_contains( + client, + workspace_id, + surface_id, + ls_end, + exact_line=True, + timeout_s=45.0, + ) + + pre_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(entry in pre_resize_lines for entry in ls_entries), + "pre-resize scrollback missing ls fixture lines in ssh workspace", + ) + pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] + + client.select_workspace(workspace_id) + client.activate_app() + pane_count_before_split = len(client.list_panes()) + client.simulate_shortcut("cmd+d") + _wait_for(lambda: len(client.list_panes()) >= pane_count_before_split + 1, timeout_s=10.0) + + # Ensure the original surface remains selected before resize churn. + client.focus_surface(surface_id) + pane_ids = [pid for _idx, pid, _count, _focused in client.list_panes()] + _must(len(pane_ids) >= 2, f"expected split workspace with >=2 panes: {pane_ids}") + _ = _pane_for_surface(client, surface_id) + resize_pair = _choose_resize_pair(client, workspace_id, pane_ids) + + for iteration in range(1, RESIZE_ITERATIONS + 1): + pane_id, direction = resize_pair[(iteration - 1) % len(resize_pair)] + _ = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 80, + }, + ) + if iteration % 8 == 0: + sampled_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in sampled_lines for anchor in pre_resize_anchors), + f"resize iteration {iteration} lost pre-resize anchor lines in ssh workspace", + ) + + post_token = f"CMUX_REMOTE_RESIZE_POST_{secrets.token_hex(6)}" + client.send_surface(surface_id, f"echo {post_token}\n") + _wait_surface_contains( + client, + workspace_id, + surface_id, + post_token, + exact_line=True, + timeout_s=25.0, + ) + + post_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(entry in post_resize_lines for entry in ls_entries), + "post-resize scrollback lost ls fixture lines in ssh workspace", + ) + _must( + post_token in post_resize_lines, + f"post-resize scrollback missing post token: {post_token}", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print( + "PASS: cmux ssh split+resize churn preserved large pre-resize scrollback " + f"(entries={LS_ENTRY_COUNT}, iterations={RESIZE_ITERATIONS})" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_second_session_mux_regression.py b/tests_v2/test_ssh_remote_second_session_mux_regression.py new file mode 100644 index 00000000..c521485c --- /dev/null +++ b/tests_v2/test_ssh_remote_second_session_mux_regression.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Regression: opening a second `cmux ssh` workspace to the same host must not mux-refuse.""" + +from __future__ import annotations + +import glob +import json +import os +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", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_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) + + import subprocess + + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + if proc.returncode != 0: + raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") + 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_remote_ready(client: cmux, workspace_id: str, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + 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 _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: + deadline = time.time() + timeout + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + if surfaces: + return str(surfaces[0][1]) + time.sleep(0.1) + raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") + + +def _workspace_id_from_payload(client: cmux, payload: dict) -> 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:"): + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _wait_text_contains(client: cmux, surface_id: str, needle: str, timeout: float = 8.0) -> str: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + if needle in last: + return last + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in surface {surface_id}: {last[-800:]!r}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run second-session ssh mux regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + try: + with cmux(SOCKET_PATH) as client: + first = _run_cli_json(cli, ["ssh", SSH_HOST]) + first_workspace_id = _workspace_id_from_payload(client, first) + _must(bool(first_workspace_id), f"first cmux ssh output missing workspace_id: {first}") + workspace_ids.append(first_workspace_id) + _wait_remote_ready(client, first_workspace_id) + first_surface_id = _wait_surface_id(client, first_workspace_id) + _wait_text_contains(client, first_surface_id, "cmux in ~", timeout=12.0) + + second = _run_cli_json(cli, ["ssh", SSH_HOST]) + second_workspace_id = _workspace_id_from_payload(client, second) + _must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}") + workspace_ids.append(second_workspace_id) + _wait_remote_ready(client, second_workspace_id) + + second_surface_id = _wait_surface_id(client, second_workspace_id) + text = _wait_text_contains(client, second_surface_id, "cmux in ~", timeout=12.0) + + refusal_markers = [ + "mux_client_request_session: session request failed: Session open refused by peer", + "ControlSocket ", + "disabling multiplexing", + ] + hits = [marker for marker in refusal_markers if marker in text] + _must( + not hits, + "second cmux ssh session printed mux refusal text instead of starting cleanly: " + f"markers={hits!r} tail={text[-1200:]!r}", + ) + + client.send_surface(second_surface_id, "printf '__SECOND_SESSION_OK__\\n'") + text = _wait_text_contains(client, second_surface_id, "__SECOND_SESSION_OK__", timeout=6.0) + _must( + "command not found" not in text, + f"second cmux ssh session accepted corrupted input after startup: {text[-1200:]!r}", + ) + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: second cmux ssh session opens cleanly without mux refusal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py new file mode 100755 index 00000000..3d632b84 --- /dev/null +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +"""Docker integration: prove cmux ssh applies Ghostty ssh-env/ssh-terminfo niceties.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +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", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _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(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 _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 _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + return int(text.split(":")[-1]) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + 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 last_status + time.sleep(0.4) + raise cmuxError(f"Remote did not reach connected+ready state: {last_status}") + + +def _is_terminal_surface_not_found(exc: Exception) -> bool: + return "terminal surface not found" in str(exc).lower() + + +def _read_probe_value(client: cmux, surface_id: str, command: str, timeout: float = 20.0) -> str: + token = f"__CMUX_PROBE_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"{command}; printf '{token}%s\\n' $?\\n") + + pattern = re.compile(re.escape(token) + r"([^\r\n]*)") + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + text = client.read_terminal_text(surface_id) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + matches = pattern.findall(text) + for raw in reversed(matches): + value = raw.strip() + if value and value != "%s" and "$(" not in value and "printf" not in value: + return value + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for probe token for command: {command}") + + +def _read_probe_payload(client: cmux, surface_id: str, payload_command: str, timeout: float = 20.0) -> str: + token = f"__CMUX_PAYLOAD_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"printf '{token}%s\\n' \"$({payload_command})\"\\n") + + pattern = re.compile(re.escape(token) + r"([^\r\n]*)") + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + text = client.read_terminal_text(surface_id) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + matches = pattern.findall(text) + for raw in reversed(matches): + value = raw.strip() + if value and value != "%s" and "$(" not in value and "printf" not in value: + return value + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for payload token for command: {payload_command}") + + +def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_for_pane_count(client: cmux, minimum_count: int, timeout: float = 8.0) -> list[str]: + deadline = time.time() + timeout + last: list[str] = [] + while time.time() < deadline: + last = [pid for _idx, pid, _count, _focused in client.list_panes()] + if len(last) >= minimum_count: + return last + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for pane count >= {minimum_count}; saw {len(last)} panes: {last}") + + +def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()] + + +def _scrollback_has_all_lines( + client: cmux, + workspace_id: str, + surface_id: str, + lines: list[str], +) -> bool: + available = set(_surface_text_scrollback_lines(client, workspace_id, surface_id)) + return all(line in available for line in lines) + + +def _wait_surface_contains( + client: cmux, + workspace_id: str, + surface_id: str, + token: str, + *, + timeout: float = 20.0, +) -> None: + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + if token in _surface_text_scrollback(client, workspace_id, surface_id): + return + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _pane_for_surface(client: cmux, surface_id: str) -> str: + target_id = str(client._resolve_surface_id(surface_id)) + for _idx, pane_id, _count, _focused in client.list_panes(): + rows = client.list_pane_surfaces(pane_id) + for _row_idx, sid, _title, _selected in rows: + try: + candidate_id = str(client._resolve_surface_id(sid)) + except cmuxError: + continue + if candidate_id == target_id: + return pane_id + raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") + + +def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + if shutil.which("infocmp") is None: + print("SKIP: local infocmp is not available (required for ssh-terminfo)") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-shell-integration-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-shell-{secrets.token_hex(4)}" + workspace_id = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-p", + f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + if shutil.which("ghostty") is not None: + _run(["ghostty", "+ssh-cache", f"--remove={host}"], check=False) + _wait_for_ssh(host, host_ssh_port, key_path) + + pre = _ssh_run(host, host_ssh_port, key_path, "if infocmp xterm-ghostty >/dev/null 2>&1; then echo present; else echo missing; fi") + _must("missing" in pre.stdout, f"Fresh container should not have xterm-ghostty terminfo preinstalled: {pre.stdout!r}") + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", + "docker-ssh-shell-integration", + "--port", + str(host_ssh_port), + "--identity", + str(key_path), + "--ssh-option", + "UserKnownHostsFile=/dev/null", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and 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: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + _wait_remote_connected(client, workspace_id, timeout=45.0) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + terminal_text = client.read_terminal_text(surface_id) + _must( + "Reconstructed via infocmp" not in terminal_text, + "ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell", + ) + _must( + "Warning: Failed to install terminfo." not in terminal_text, + "ssh shell bootstrap should not show a false terminfo failure warning", + ) + + try: + term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"") + terminfo_state = _read_probe_value(client, surface_id, "infocmp xterm-ghostty >/dev/null 2>&1") + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + print("SKIP: terminal surface unavailable for shell integration probes") + return 0 + raise + _must(terminfo_state in {"0", "1"}, f"unexpected terminfo probe exit status: {terminfo_state!r}") + if terminfo_state == "0": + _must( + term_value == "xterm-ghostty", + f"when terminfo install succeeds, TERM should remain xterm-ghostty (got {term_value!r})", + ) + else: + _must( + term_value == "xterm-256color", + f"when terminfo is unavailable, ssh-env fallback should use TERM=xterm-256color (got {term_value!r})", + ) + + colorterm_value = _read_probe_payload(client, surface_id, "printf '%s' \"${COLORTERM:-}\"") + _must( + colorterm_value == "truecolor", + f"ssh-env should propagate COLORTERM=truecolor, got: {colorterm_value!r}", + ) + + term_program = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM:-}\"") + _must( + term_program == "ghostty", + f"ssh-env should propagate TERM_PROGRAM=ghostty when AcceptEnv allows it, got: {term_program!r}", + ) + + term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"") + _must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION") + + ls_stamp = secrets.token_hex(4) + ls_entries = [f"CMUX_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)] + ls_start = f"CMUX_RESIZE_LS_START_{ls_stamp}" + ls_end = f"CMUX_RESIZE_LS_END_{ls_stamp}" + names = " ".join(ls_entries) + ls_script = ( + "tmpdir=$(mktemp -d); " + f"echo {ls_start}; " + f"for name in {names}; do touch \"$tmpdir/$name\"; done; " + "ls -1 \"$tmpdir\"; " + f"echo {ls_end}; " + "rm -rf \"$tmpdir\"" + ) + client.send_surface(surface_id, f"{ls_script}\n") + _wait_surface_contains(client, workspace_id, surface_id, ls_end) + pre_resize_scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) + _must( + all(line in pre_resize_scrollback_lines for line in ls_entries), + "pre-resize scrollback missing ls output fixture lines", + ) + pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] + _must( + len(pre_resize_anchors) == 3, + f"pre-resize scrollback missing anchor lines: {pre_resize_anchors}", + ) + pre_resize_visible = client.read_terminal_text(surface_id) + pre_visible_lines = [line for line in ls_entries if line in pre_resize_visible] + _must( + len(pre_visible_lines) >= 2, + "pre-resize viewport did not contain enough reference lines for continuity checks", + ) + + client.select_workspace(workspace_id) + client.activate_app() + pane_count_before_split = len(client.list_panes()) + client.simulate_shortcut("cmd+d") + pane_ids = _wait_for_pane_count(client, pane_count_before_split + 1, timeout=8.0) + + pane_id = _pane_for_surface(client, surface_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + opposite_direction = { + "left": "right", + "right": "left", + "up": "down", + "down": "up", + }[resize_direction] + expected_sign_by_direction = { + resize_direction: +1, + opposite_direction: -1, + } + + resize_sequence = [resize_direction, opposite_direction] * 8 + current_extent = _pane_extent(client, pane_id, resize_axis) + for index, direction in enumerate(resize_sequence, start=1): + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 80, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + if expected_sign_by_direction[direction] > 0: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > current_extent + 1.0, timeout_s=5.0) + else: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) < current_extent - 1.0, timeout_s=5.0) + current_extent = _pane_extent(client, pane_id, resize_axis) + _must( + _scrollback_has_all_lines(client, workspace_id, surface_id, pre_resize_anchors), + f"resize iteration {index} lost pre-resize scrollback anchors", + ) + + post_resize_visible = client.read_terminal_text(surface_id) + visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] + _must( + bool(visible_overlap), + f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}", + ) + + resize_post_token = f"CMUX_RESIZE_POST_{secrets.token_hex(6)}" + client.send_surface(surface_id, f"echo {resize_post_token}\n") + _wait_surface_contains(client, workspace_id, surface_id, resize_post_token) + + scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in scrollback_lines for anchor in pre_resize_anchors), + "terminal scrollback lost pre-resize lines after pane resize", + ) + _must( + resize_post_token in scrollback_lines, + f"terminal scrollback missing post-resize token after pane resize: {resize_post_token}", + ) + + try: + client.close_workspace(workspace_id) + workspace_id = "" + except Exception: + pass + + print( + "PASS: cmux ssh enables Ghostty shell integration niceties and preserves pre-resize terminal content " + f"(TERM={term_value}, COLORTERM={colorterm_value}, TERM_PROGRAM={term_program})" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shortcuts_stay_remote.py b/tests_v2/test_ssh_remote_shortcuts_stay_remote.py new file mode 100644 index 00000000..fa5d9199 --- /dev/null +++ b/tests_v2/test_ssh_remote_shortcuts_stay_remote.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Regression: new tabs and splits from an ssh terminal must stay on the remote shell.""" + +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", "/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 = 8.0, step_s: float = 0.1) -> 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 _focused_surface_id(client: cmux) -> str: + ident = client.identify() + focused = ident.get("focused") or {} + surface_id = str(focused.get("surface_id") or "") + if not surface_id: + raise cmuxError(f"Missing focused surface in identify payload: {ident}") + return surface_id + + +def _run_remote_shell_probe(client: cmux, surface_id: str, probe_label: str) -> str: + token = f"__CMUX_REMOTE_SOCKET_{probe_label}_{secrets.token_hex(4)}__" + client.send_surface( + surface_id, + ( + f"__cmux_socket_path=\"${{CMUX_SOCKET_PATH:-}}\"; " + f"printf '{token}:%s:__CMUX_REMOTE_SOCKET_END__\\n' \"$__cmux_socket_path\"\n" + ), + ) + deadline = time.time() + 15.0 + last = "" + pattern = re.compile(re.escape(token) + r":(.*?):__CMUX_REMOTE_SOCKET_END__") + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + matches = pattern.findall(last) + if matches: + for candidate in reversed(matches): + cleaned = candidate.strip() + if cleaned and cleaned != "%s": + return cleaned + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for socket token {token!r}: {last[-1200:]!r}") + + +def _assert_remote_socket_path(client: cmux, surface_id: str, shortcut_name: str) -> None: + socket_path = _run_remote_shell_probe(client, surface_id, shortcut_name) + _must( + socket_path.startswith("127.0.0.1:"), + f"{shortcut_name} should keep the new terminal on the ssh relay, got CMUX_SOCKET_PATH={socket_path!r}", + ) + + +def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", name] + 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) + _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 + + +def _assert_shortcut_creates_remote_terminal( + client: cmux, + workspace_id: str, + shortcut: str, + shortcut_name: str, + *, + expect_new_pane: bool, +) -> None: + before_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} + before_pane_count = len(client.list_panes()) + + client.activate_app() + client.simulate_app_active() + client.simulate_shortcut(shortcut) + + _wait_for( + lambda: len({sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - before_surfaces) == 1, + timeout_s=12.0, + ) + + if expect_new_pane: + _wait_for(lambda: len(client.list_panes()) >= before_pane_count + 1, timeout_s=12.0) + + after_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} + new_surface_ids = sorted(after_surfaces - before_surfaces) + _must(len(new_surface_ids) == 1, f"{shortcut_name} should create exactly one new surface: {new_surface_ids}") + + focused_surface_id = _focused_surface_id(client) + _must( + focused_surface_id == new_surface_ids[0], + f"{shortcut_name} should focus the new terminal surface: focused={focused_surface_id!r} new={new_surface_ids[0]!r}", + ) + _assert_remote_socket_path(client, focused_surface_id, shortcut_name) + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh shortcut inheritance regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdt-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+t", + "cmd+t", + expect_new_pane=False, + ) + + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdd-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+d", + "cmd+d", + expect_new_pane=True, + ) + + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdshiftd-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+shift+d", + "cmd+shift+d", + expect_new_pane=True, + ) + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: cmd+t/cmd+d/cmd+shift+d keep ssh terminals on the remote relay") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_workspace_create_initial_env.py b/tests_v2/test_workspace_create_initial_env.py new file mode 100644 index 00000000..33b56c2e --- /dev/null +++ b/tests_v2/test_workspace_create_initial_env.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Regression: workspace.create must apply initial_env to the initial terminal.""" + +import os +import sys +import time +import base64 +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + payload = c._call( + "surface.read_text", + {"workspace_id": workspace_id}, + ) or {} + if "text" in payload: + last_text = str(payload.get("text") or "") + else: + b64 = str(payload.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + last_text = raw.decode("utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + try: + token = f"tok_{int(time.time() * 1000)}" + payload = c._call( + "workspace.create", + { + "initial_env": {"CMUX_INITIAL_ENV_TOKEN": token}, + }, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus") + + # Terminal surfaces in background workspaces may not be attached/render-ready yet. + # Select it before reading text so the initial command output is available. + c.select_workspace(created_workspace) + listed = c._call("surface.list", {"workspace_id": created_workspace}) or {} + rows = list(listed.get("surfaces") or []) + _must(bool(rows), "Expected at least one surface in the created workspace") + terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None) + _must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}") + + c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n") + text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}") + _must( + f"CMUX_ENV_CHECK={token}" in text, + f"initial_env token missing from terminal output: {text!r}", + ) + c.select_workspace(baseline_workspace) + finally: + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create applies initial_env to initial terminal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())