diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7b821d0..22933f48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,27 +28,6 @@ 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 c175487d..5c46f0a3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,8 +20,6 @@ concurrency: permissions: contents: write - attestations: write - id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -144,11 +142,6 @@ 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 }} @@ -247,7 +240,6 @@ 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" @@ -292,24 +284,6 @@ 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: @@ -453,18 +427,6 @@ 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 @@ -475,12 +437,6 @@ 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 @@ -516,12 +472,6 @@ 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 bce4327c..6a58f07f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,6 @@ on: permissions: contents: write - attestations: write - id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -116,12 +114,6 @@ 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: @@ -142,21 +134,6 @@ 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: | @@ -291,18 +268,6 @@ 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 @@ -310,12 +275,6 @@ 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 8a8e4e0a..0fcbfce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,8 +103,6 @@ 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 6329c5d4..8346d1ab 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,12 +1,5 @@ import Foundation -import CryptoKit import Darwin -#if canImport(LocalAuthentication) -import LocalAuthentication -#endif -#if canImport(Security) -import Security -#endif #if canImport(Sentry) import Sentry #endif @@ -422,22 +415,17 @@ 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?, socketPath: String) -> String? { + static func resolve(explicit: String?) -> String? { if let explicit = normalized(explicit) { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } - if let filePassword = loadFromFile() { - return filePassword - } - return loadFromKeychain(socketPath: socketPath) + return loadFromFile() } private static func normalized(_ value: String?) -> String? { @@ -461,83 +449,6 @@ 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 { @@ -708,10 +619,6 @@ final class SocketClient { self.path = path } - var socketPath: String { - path - } - func connect() throws { if socketFD >= 0 { return } @@ -884,53 +791,6 @@ 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? = { @@ -1031,11 +891,6 @@ 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) @@ -1115,11 +970,7 @@ struct CMUXCLI { } defer { client.close() } - try authenticateClientIfNeeded( - client, - explicitPassword: socketPasswordArg, - socketPath: resolvedSocketPath - ) + try authenticateClientIfNeeded(client, explicitPassword: socketPasswordArg) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) @@ -1264,27 +1115,14 @@ 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)\(remoteTag)\(selTag)") + print("\(prefix)\(handle)\(titlePart)\(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") @@ -1733,6 +1571,109 @@ 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) @@ -2150,32 +2091,17 @@ 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, - socketPath: socketPath - ) + try authenticateClientIfNeeded(pollClient, explicitPassword: explicitPassword) return pollClient } try client.connect() - try authenticateClientIfNeeded( - client, - explicitPassword: explicitPassword, - socketPath: socketPath - ) + try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) return client } - private func authenticateClientIfNeeded( - _ client: SocketClient, - explicitPassword: String?, - socketPath: String - ) throws { - if let socketPassword = SocketPasswordResolver.resolve( - explicit: explicitPassword, - socketPath: socketPath - ) { + private func authenticateClientIfNeeded(_ client: SocketClient, explicitPassword: String?) throws { + if let socketPassword = SocketPasswordResolver.resolve(explicit: explicitPassword) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { @@ -2200,14 +2126,6 @@ struct CMUXCLI { process.waitUntilExit() } - private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { - _ = jsonOutput - if let parsed = try CLIIDFormat.parse(raw) { - return parsed - } - return .refs - } - private func sendV1Command(_ command: String, client: SocketClient) throws -> String { let response = try client.send(command: command) if response.hasPrefix("ERROR:") { @@ -2216,6 +2134,14 @@ struct CMUXCLI { return response } + private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { + _ = jsonOutput + if let parsed = try CLIIDFormat.parse(raw) { + return parsed + } + return .refs + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -2862,807 +2788,6 @@ 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], @@ -3816,6 +2941,7 @@ struct CMUXCLI { return lines.joined(separator: "\n") } + func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } @@ -4000,7 +3126,13 @@ struct CMUXCLI { throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) - output(payload, fallback: "OK") + let fallback: String + if let value = payload["value"] { + fallback = displayBrowserValue(value) + } else { + fallback = "OK" + } + output(payload, fallback: fallback) return } @@ -4733,7 +3865,8 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) - output(payload, fallback: "OK") + let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK" + output(payload, fallback: fallback) return } @@ -4747,7 +3880,8 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) - output(payload, fallback: "OK") + let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK" + output(payload, fallback: fallback) return } @@ -5323,7 +4457,7 @@ struct CMUXCLI { new-terminal-right | new-browser-right reload | duplicate pin | unpin - mark-unread + mark-read | mark-unread Flags: --action Action name (required if not positional) @@ -5342,21 +4476,18 @@ struct CMUXCLI { return """ Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] - 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) + 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 Flags: --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) - --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) + --tab <id|ref> Target tab (accepts tab:<n> or surface:<n>) --surface <id|ref> Alias for --tab - --title <text> Explicit title (or use trailing positional title) + --title <text> New title (or pass trailing title) - Examples: + Example: 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" @@ -5385,35 +4516,6 @@ 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] @@ -6228,6 +5330,20 @@ 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? @@ -7464,11 +6580,7 @@ struct CMUXCLI { do { try client.connect() - try authenticateClientIfNeeded( - client, - explicitPassword: explicitPassword, - socketPath: socketPath - ) + try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) defer { client.close() } let payload = try client.sendV2(method: "system.identify") @@ -8509,7 +7621,7 @@ struct CMUXCLI { let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) print(response) } else { print("OK") @@ -8568,7 +7680,7 @@ struct CMUXCLI { ) } - let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -8803,7 +7915,8 @@ struct CMUXCLI { ] let session = firstString(in: object, keys: ["session_id", "sessionId"]) let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input" - let normalizedMessage = normalizedSingleLine(message) + let dedupedMessage = dedupeBranchContextLines(message) + let normalizedMessage = normalizedSingleLine(dedupedMessage) let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) @@ -8836,6 +7949,42 @@ 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 } @@ -9156,6 +8305,8 @@ 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), @@ -9243,12 +8394,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 @@ -9261,8 +8412,6 @@ 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>] @@ -9295,6 +8444,18 @@ 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 137f9f92..b7c73485 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -25452,57 +25452,6 @@ } } }, - "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": { @@ -42843,40 +42792,6 @@ } } }, - "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": { @@ -61421,227 +61336,6 @@ } } }, - "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 45a99aaf..821f3d19 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -57,102 +57,6 @@ 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). @@ -481,9 +385,6 @@ _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)" @@ -498,8 +399,6 @@ _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 29fdf434..4f3c0725 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -74,47 +74,6 @@ 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) } @@ -1970,7 +1929,6 @@ 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 @@ -7338,7 +7296,6 @@ struct VerticalTabsSidebar: View { #endif draggedTabId = nil } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -9532,7 +9489,6 @@ 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 @@ -9635,84 +9591,12 @@ 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 @@ -9816,7 +9700,7 @@ private struct TabItemView: View, Equatable { .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } - if let subtitle = effectiveSubtitle { + if let subtitle = latestNotificationSubtitle { Text(subtitle) .font(.system(size: 10)) .foregroundColor(activeSecondaryColor(0.8)) @@ -9825,8 +9709,6 @@ private struct TabItemView: View, Equatable { .multilineTextAlignment(.leading) } - remoteWorkspaceSection - if sidebarShowMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() @@ -10086,16 +9968,6 @@ 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"), @@ -10145,24 +10017,6 @@ 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 { @@ -10195,12 +10049,6 @@ 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")) { @@ -10476,62 +10324,6 @@ 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 } @@ -10733,18 +10525,6 @@ 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 5b4db687..65e50eaa 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2333,8 +2333,7 @@ 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 initialCommand: String? - private let initialEnvironmentOverrides: [String: String] + private let additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -2402,8 +2401,6 @@ 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() @@ -2411,12 +2408,7 @@ final class TerminalSurface: Identifiable, ObservableObject { self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) - self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil - self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment( - base: additionalEnvironment, - overrides: initialEnvironmentOverrides - ) + self.additionalEnvironment = additionalEnvironment // 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. @@ -2434,25 +2426,6 @@ 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 } @@ -2811,10 +2784,6 @@ 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 } @@ -2882,8 +2851,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - if !initialEnvironmentOverrides.isEmpty { - for (key, value) in initialEnvironmentOverrides { + if !additionalEnvironment.isEmpty { + for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty { env[key] = value } } @@ -2911,31 +2880,15 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - 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 { + if let workingDirectory, !workingDirectory.isEmpty { + workingDirectory.withCString { cWorkingDir in + surfaceConfig.working_directory = cWorkingDir createSurface() } + } else { + createSurface() } - createWithCommandAndWorkingDirectory() - if surface == nil { surfaceCallbackContext?.release() surfaceCallbackContext = nil @@ -3085,7 +3038,6 @@ 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 { @@ -5685,7 +5637,6 @@ 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 @@ -6293,7 +6244,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.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey") + self.applyFirstResponderIfNeeded() }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, @@ -6316,9 +6267,7 @@ final class GhosttySurfaceScrollView: NSView { #endif } }) - if window.isKeyWindow { - scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow") - } + if window.isKeyWindow { applyFirstResponderIfNeeded() } } func attachSurface(_ terminalSurface: TerminalSurface) { @@ -6735,7 +6684,7 @@ final class GhosttySurfaceScrollView: NSView { window.makeFirstResponder(nil) } } else { - scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") + applyFirstResponderIfNeeded() } } @@ -6762,7 +6711,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif if active { - scheduleAutomaticFirstResponderApply(reason: "setActive") + applyFirstResponderIfNeeded() } else { resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } @@ -7124,20 +7073,6 @@ 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 @@ -7713,15 +7648,35 @@ final class GhosttySurfaceScrollView: NSView { /// regions such as scrollbar space) when telling libghostty the terminal size. @discardableResult private func synchronizeCoreSurface() -> Bool { - // 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 width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) 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, @@ -8235,12 +8190,6 @@ 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 { @@ -8279,7 +8228,6 @@ struct GhosttyTerminalView: NSViewRepresentable { reason: "didMoveToWindow" ) else { return } guard host.window != nil else { return } - guard portalBindingStillLive() else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, @@ -8303,7 +8251,6 @@ 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 || @@ -8333,7 +8280,6 @@ 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) @@ -8344,7 +8290,7 @@ struct GhosttyTerminalView: NSViewRepresentable { previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority - if portalBindingLive && shouldBindNow { + if shouldBindNow { #if DEBUG if portalEntryMissing { dlog( @@ -8364,11 +8310,11 @@ struct GhosttyTerminalView: NSViewRepresentable { ) coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { TerminalWindowPortalRegistry.synchronizeForAnchor(host) coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - } else if hostOwnsPortalNow, portalBindingStillLive() { + } else if hostOwnsPortalNow { // 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. @@ -8398,7 +8344,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isBoundToCurrentHost: isBoundToCurrentHost ) - if portalBindingStillLive() && shouldApplyImmediateHostedState { + if shouldApplyImmediateHostedState { hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) } else { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b2927a8a..b9f1ca1b 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,19 +3,6 @@ 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 { @@ -1268,14 +1255,6 @@ 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() @@ -1794,8 +1773,6 @@ 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? @@ -2038,24 +2015,15 @@ final class BrowserPanel: Panel, ObservableObject { return instanceID == webViewInstanceID } - init( - workspaceId: UUID, - initialURL: URL? = nil, - bypassInsecureHTTPHostOnce: String? = nil, - proxyEndpoint: BrowserProxyEndpoint? = nil, - isRemoteWorkspace: Bool = false - ) { + init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { 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() @@ -2135,40 +2103,6 @@ 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 @@ -2665,7 +2599,6 @@ 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 @@ -2673,35 +2606,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - 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 + browserLoadRequest(request, in: webView) } /// Navigate with smart URL/search detection @@ -3576,16 +3481,6 @@ 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() @@ -4400,13 +4295,6 @@ 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 43a5f32b..f9d197a3 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -84,45 +84,20 @@ final class TerminalPanel: Panel, ObservableObject { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, - portOrdinal: Int = 0, - initialCommand: String? = nil, - initialEnvironmentOverrides: [String: String] = [:], - additionalEnvironment: [String: String] = [:] + additionalEnvironment: [String: String] = [:], + portOrdinal: Int = 0 ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, workingDirectory: workingDirectory, - initialCommand: initialCommand, - initialEnvironmentOverrides: Self.mergedNormalizedEnvironment( - base: additionalEnvironment, - overrides: initialEnvironmentOverrides - ) + additionalEnvironment: additionalEnvironment ) 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 efe8cfa8..6a12a955 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -406,18 +406,6 @@ 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 } @@ -434,9 +422,6 @@ 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" } @@ -469,37 +454,6 @@ 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 2455e8d5..764b15ce 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -30,20 +30,11 @@ 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.") } } } @@ -81,9 +72,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { var displayName: String { switch self { case .leftRail: - return "Left Rail" + return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail") case .solidFill: - return "Solid Fill" + return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill") } } } @@ -741,25 +732,36 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil + if selectedTerminalPanel?.searchState != nil { return true } + if focusedBrowserPanel?.searchState != nil { return true } + return false } var canUseSelectionForFind: Bool { - selectedTerminalPanel?.hasSelection() == true + if focusedBrowserPanel != nil { return false } + return selectedTerminalPanel?.hasSelection() == true } func startSearch() { - 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") + if let browser = focusedBrowserPanel { + browser.startFind() return } - - focusedBrowserPanel?.startFind() + 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") } func searchSelection() { @@ -767,27 +769,27 @@ class TabManager: ObservableObject { if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } - NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) +#if DEBUG + dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))") +#endif NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("search_selection") } func findNext() { - if let panel = selectedTerminalPanel { - _ = panel.performBindingAction("search:next") + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.findNext() return } - - focusedBrowserPanel?.findNext() + _ = selectedTerminalPanel?.performBindingAction("search:next") } func findPrevious() { - if let panel = selectedTerminalPanel { - _ = panel.performBindingAction("search:previous") + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.findPrevious() return } - - focusedBrowserPanel?.findPrevious() + _ = selectedTerminalPanel?.performBindingAction("search:previous") } @discardableResult @@ -797,26 +799,27 @@ class TabManager: ObservableObject { } func hideFind() { - if let panel = selectedTerminalPanel { - panel.searchState = nil + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.hideFind() return } - - focusedBrowserPanel?.hideFind() +#if DEBUG + dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")") +#endif + selectedTerminalPanel?.searchState = nil } @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 workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() + let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) + let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab() let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 @@ -824,9 +827,7 @@ class TabManager: ObservableObject { title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal, - configTemplate: inheritedConfig, - initialTerminalCommand: initialTerminalCommand, - initialTerminalEnvironment: initialTerminalEnvironment + configTemplate: inheritedConfig ) wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex(placementOverride: placementOverride) @@ -835,8 +836,17 @@ 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 { - newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() + requestBackgroundWorkspaceLoad(for: newWorkspace.id) + newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() } if select { selectedTabId = newWorkspace.id @@ -1152,6 +1162,16 @@ 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) } @@ -1164,16 +1184,6 @@ 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 } @@ -1259,23 +1269,22 @@ 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) - workspace.teardownAllPanels() - workspace.teardownRemoteConnection() unwireClosedBrowserTracking(for: workspace) + workspace.teardownAllPanels() - if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { - tabs.remove(at: index) + 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 } } @@ -1284,6 +1293,7 @@ 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) @@ -1345,9 +1355,13 @@ class TabManager: ObservableObject { let count = plan.panelIds.count let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") - let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)" + 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)") + } guard confirmClose( - title: "Close other tabs?", + title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), message: message, acceptCmdD: false ) else { return } @@ -1387,8 +1401,8 @@ class TabManager: ObservableObject { alert.messageText = title alert.informativeText = message alert.alertStyle = .warning - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "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 @@ -1449,15 +1463,15 @@ class TabManager: ObservableObject { if let collapsed, !collapsed.isEmpty { return collapsed } - return "Untitled Tab" + return String(localized: "tab.untitled", defaultValue: "Untitled Tab") } private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) { let willCloseWindow = tabs.count <= 1 if workspaceNeedsConfirmClose(workspace), !confirmClose( - title: "Close workspace?", - message: "This will close the workspace and all of its panels.", + title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), acceptCmdD: willCloseWindow ) { return @@ -1498,8 +1512,8 @@ class TabManager: ObservableObject { let needsConfirm = workspaceNeedsConfirmClose(tab) if needsConfirm { let message = willCloseWindow - ? "This will close the last tab and close the window." - : "This will close the last tab and close its workspace." + ? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.") + : String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.") #if DEBUG dlog( "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + @@ -1507,7 +1521,7 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: "Close tab?", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), message: message, acceptCmdD: willCloseWindow ) else { @@ -1539,8 +1553,8 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: "Close tab?", - message: "This will close the current tab.", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), acceptCmdD: false ) else { #if DEBUG @@ -1578,8 +1592,8 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: surfaceId), terminalPanel.needsConfirmClose() { guard confirmClose( - title: "Close tab?", - message: "This will close the current tab.", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), acceptCmdD: false ) else { return } } @@ -1846,28 +1860,32 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - 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) + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) } @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 let panelId = surfaceId, + if triggerFlash, + let panelId = surfaceId, let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } @@ -2166,9 +2184,24 @@ 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)]) - _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: 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 } /// Create a new browser split from the currently focused panel. @@ -2177,14 +2210,30 @@ 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() - return newBrowserSplit( + let createdPanelId = 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. @@ -2285,12 +2334,21 @@ 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 } - return tab.newTerminalSplit( + let createdPanel = 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 @@ -2891,7 +2949,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh() + terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") } } @@ -3869,6 +3927,15 @@ 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 001a40ba..6a708ae2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -201,28 +201,6 @@ 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) @@ -247,26 +225,27 @@ class TerminalController { return body() } -#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() - ) + 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) + } } -#endif nonisolated static func shouldReplaceStatusEntry( current: SidebarStatusEntry?, @@ -333,6 +312,33 @@ 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)? { @@ -356,36 +362,6 @@ 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?) { @@ -759,7 +735,14 @@ 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 } - workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports + 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.recomputeListeningPorts() } } @@ -1246,7 +1229,7 @@ class TerminalController { defer { close(socket) } // In cmuxOnly mode, verify the connecting process is a descendant of cmux. - // In allowAll mode (env-var only), skip the ancestry check. + // Other modes allow external clients and apply separate auth controls. if accessMode == .cmuxOnly { // Use pre-captured peer PID if available (captured in accept loop before // the peer can disconnect), falling back to live lookup. @@ -1317,7 +1300,11 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - return withSocketCommandPolicy(commandKey: cmd, isV2: false) { + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif + + let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) { switch cmd { case "ping": return "PONG" @@ -1635,13 +1622,25 @@ 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 @@ -1676,7 +1675,11 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - return withSocketCommandPolicy(commandKey: method, isV2: true) { + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif + + let response = withSocketCommandPolicy(commandKey: method, isV2: true) { switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) @@ -1733,16 +1736,6 @@ 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": @@ -2071,10 +2064,22 @@ 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] { @@ -2101,11 +2106,6 @@ 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,42 +2689,6 @@ 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: "_") @@ -2787,40 +2751,6 @@ 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()) @@ -2904,8 +2834,9 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } - // The new window should become key, but setActiveTabManager defensively. - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + // Keep active routing stable unless this command is explicitly focus-intent. + if socketCommandAllowsInAppFocusMutations(), + let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return .ok([ @@ -2946,9 +2877,7 @@ class TerminalController { "index": index, "title": ws.title, "selected": ws.id == tabManager.selectedTabId, - "pinned": ws.isPinned, - "listening_ports": ws.listeningPorts, - "remote": ws.remoteStatusPayload() + "pinned": ws.isPinned ] } } @@ -2965,22 +2894,8 @@ 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 workingDirectory { - cwd = workingDirectory - } else if let raw = params["cwd"] { + 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) } @@ -2991,16 +2906,23 @@ 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) @@ -3024,12 +2946,8 @@ class TerminalController { var success = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - // 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) + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) success = true } } @@ -3052,20 +2970,8 @@ 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) @@ -3075,8 +2981,7 @@ 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": wsPayload ?? NSNull() + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) ]) } private func v2WorkspaceClose(params: [String: Any]) -> V2CallResult { @@ -3115,7 +3020,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 = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -3235,10 +3140,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.selectNextTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3260,10 +3162,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.selectPreviousTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3285,10 +3184,7 @@ 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 } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.navigateBack() guard let after = tabManager.selectedTabId, after != before else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3302,277 +3198,6 @@ 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) @@ -3735,7 +3360,7 @@ class TerminalController { "close_left", "close_right", "close_others", "new_terminal_right", "new_browser_right", "reload", "duplicate", - "pin", "unpin", "mark_unread" + "pin", "unpin", "mark_read", "mark_unread" ] var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ @@ -3748,6 +3373,7 @@ 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 { @@ -3849,6 +3475,10 @@ 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() @@ -3873,7 +3503,7 @@ class TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: true + focus: allowFocusMutation ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return @@ -3894,7 +3524,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3921,7 +3551,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -4032,7 +3662,7 @@ class TerminalController { "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, "type": panel.panelType.rawValue, - "title": panel.displayTitle, + "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, "focused": panel.id == focusedSurfaceId, "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), @@ -4111,15 +3741,8 @@ class TerminalController { return } - 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) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) guard ws.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) @@ -4147,13 +3770,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { @@ -4165,7 +3783,12 @@ class TerminalController { return } - if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { + if let newId = tabManager.newSplit( + tabId: ws.id, + surfaceId: targetSurfaceId, + direction: direction, + focus: v2FocusAllowed() + ) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ @@ -4338,7 +3961,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -4449,16 +4072,15 @@ class TerminalController { ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: true) + _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) } result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) return } if focus { - _ = app.focusMainWindow(windowId: targetWindowId) - setActiveTabManager(targetTabManager) - targetTabManager.selectWorkspace(targetWorkspace) + v2MaybeFocusWindow(for: targetTabManager) + v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace) } result = .ok([ @@ -4645,13 +4267,21 @@ 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), "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), + "queued": queued, + "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager)) + ]) } return result } @@ -4679,7 +4309,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { + guard let surface = terminalPanel.surface.surface else { result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) return } @@ -4799,87 +4429,41 @@ 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" } - 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 - ) + 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() - 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) + guard ghostty_surface_read_text(surface, selection, &text) else { + return "ERROR: Failed to read terminal text" + } + defer { + ghostty_surface_free_text(surface, &text) } - 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" - } + let rawData: Data + if let ptr = text.text, text.text_len > 0 { + rawData = Data(bytes: ptr, count: Int(text.text_len)) } else { - guard let viewport = readSelectionText(pointTag: GHOSTTY_POINT_VIEWPORT) else { - return "ERROR: Failed to read terminal text" - } - output = viewport + rawData = Data() } + var output = String(decoding: rawData, as: UTF8.self) if let lineLimit { output = tailTerminalLines(output, maxLines: lineLimit) } @@ -4888,21 +4472,152 @@ class TerminalController { return "OK \(base64)" } - func readTerminalTextForSessionSnapshot( + 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( 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 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) + 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 } private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { @@ -4917,14 +4632,9 @@ class TerminalController { return } - // 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) - } + // Only explicit focus-intent commands may mutate selection state. + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { @@ -5005,13 +4715,8 @@ class TerminalController { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: 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)]) @@ -5331,7 +5036,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 = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) v2MainSync { @@ -5414,7 +5119,7 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) v2MainSync { @@ -5455,7 +5160,7 @@ class TerminalController { return } - let destinationWorkspace = tabManager.addWorkspace() + let destinationWorkspace = tabManager.addWorkspace(select: focus) guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { if let sourcePaneForRollback { @@ -5463,7 +5168,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: true + focus: focus ) } result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) @@ -5476,16 +5181,12 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: true + focus: focus ) } 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), @@ -6395,11 +6096,16 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) + createdPanel = ws.newBrowserSplit( + from: sourceSurfaceId, + orientation: .horizontal, + url: url, + focus: v2FocusAllowed() + ) } guard let browserPanelId = createdPanel?.id else { @@ -7750,13 +7456,8 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), let browserPanel = ws.browserPanel(for: surfaceId) else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -8795,7 +8496,7 @@ class TerminalController { "id": panel.id.uuidString, "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, - "title": panel.displayTitle, + "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", "focused": panel.id == ws.focusedPanelId, "pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString), @@ -8840,7 +8541,7 @@ class TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -9939,6 +9640,7 @@ 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) @@ -10053,6 +9755,37 @@ 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) @@ -10060,29 +9793,15 @@ class TerminalController { return "ERROR: Usage: set_shortcut <name> <combo|clear>" } - let name = parts[0].lowercased() + let name = parts[0] let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - 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" + guard let action = debugShortcutAction(named: name) else { + return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: defaultsKey) + UserDefaults.standard.removeObject(forKey: action.defaultsKey) return "OK" } @@ -10100,12 +9819,13 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: defaultsKey) + UserDefaults.standard.set(data, forKey: action.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 { @@ -10244,7 +9964,22 @@ class TerminalController { return } - // Fall back to the responder chain insertText action. + // 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. (fr as? NSResponder)?.insertText(text) result = "OK" } @@ -10837,6 +10572,10 @@ 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 @@ -10857,6 +10596,10 @@ 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 } @@ -10995,7 +10738,8 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return "ERROR: Failed to create window" } - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + if socketCommandAllowsInAppFocusMutations(), + let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return "OK \(windowId.uuidString)" @@ -11015,6 +10759,7 @@ 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), @@ -11022,9 +10767,11 @@ class TerminalController { ok = false return } - dstTM.attachWorkspace(ws, select: true) - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(dstTM) + dstTM.attachWorkspace(ws, select: focus) + if focus { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(dstTM) + } ok = true } @@ -11050,10 +10797,19 @@ 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")" } @@ -11097,7 +10853,12 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { + if let newPanelId = tabManager.newSplit( + tabId: tabId, + surfaceId: targetSurface, + direction: direction, + focus: socketCommandAllowsInAppFocusMutations() + ) { result = "OK \(newPanelId.uuidString)" } } @@ -12100,6 +11861,29 @@ 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: @@ -12202,15 +11986,6 @@ 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 @@ -12218,13 +11993,11 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - 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)) + if let surface = terminalPanel.surface.surface { + sendSocketText(unescaped, surface: surface) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } success = true } @@ -12232,29 +12005,6 @@ 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) @@ -12356,20 +12106,18 @@ class TerminalController { var success = false DispatchQueue.main.sync { - guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } + guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - 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)) + if let surface = terminalPanel.surface.surface { + sendSocketText(unescaped, surface: surface) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } success = true } @@ -12390,11 +12138,7 @@ class TerminalController { return } - guard let surface = resolveTerminalSurface( - from: terminalPanel.id.uuidString, - tabManager: tabManager, - waitUpTo: 2.0 - ) else { + guard let surface = terminalPanel.surface.surface else { error = "ERROR: Surface not ready" return } @@ -12416,11 +12160,11 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else { + guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { error = "ERROR: Surface not found" return } - guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else { + guard let surface = terminalPanel.surface.surface else { error = "ERROR: Surface not ready" return } @@ -12438,6 +12182,7 @@ 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 { @@ -12447,7 +12192,12 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: .horizontal, url: url)?.id { + if let browserPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: .horizontal, + url: url, + focus: shouldFocus + )?.id { result = "OK \(browserPanelId.uuidString)" } } @@ -12849,6 +12599,7 @@ class TerminalController { let orientation = direction.orientation let insertFirst = direction.insertFirst + let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create pane" DispatchQueue.main.sync { @@ -12860,9 +12611,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: shouldFocus + )?.id } else { - newPanelId = tab.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = tab.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: shouldFocus + )?.id } if let id = newPanelId { @@ -13439,6 +13201,9 @@ 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 @@ -13451,7 +13216,9 @@ class TerminalController { result = "ERROR: Tab not found" return } - tab.progress = nil + if tab.progress != nil { + tab.progress = nil + } } return result } @@ -13459,7 +13226,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]" + return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" } let isDirty = parsed.options["status"]?.lowercased() == "dirty" @@ -13486,7 +13253,35 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) + 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) } return result } @@ -13510,13 +13305,42 @@ class TerminalController { } return "OK" } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - result = "ERROR: Tab not found" + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - tab.gitBranch = nil + 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) } return result } @@ -13659,6 +13483,7 @@ class TerminalController { } ports.append(port) } + let normalizedPorts = Array(Set(ports)).sorted() var result = "OK" DispatchQueue.main.sync { @@ -13695,20 +13520,43 @@ class TerminalController { return } - tab.surfaceListeningPorts[surfaceId] = ports + guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else { + return + } + + tab.surfaceListeningPorts[surfaceId] = normalizedPorts 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 = parsed.positional.joined(separator: " ") + 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" } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -13775,11 +13623,15 @@ class TerminalController { result = "ERROR: Panel not found '\(surfaceId.uuidString)'" return } - tab.surfaceListeningPorts.removeValue(forKey: surfaceId) + if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil { + tab.recomputeListeningPorts() + } } else { - tab.surfaceListeningPorts.removeAll() + if !tab.surfaceListeningPorts.isEmpty { + tab.surfaceListeningPorts.removeAll() + tab.recomputeListeningPorts() + } } - tab.recomputeListeningPorts() } return result } @@ -13790,6 +13642,17 @@ 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 { @@ -13823,6 +13686,7 @@ class TerminalController { return } + guard tab.surfaceTTYNames[surfaceId] != ttyName else { return } tab.surfaceTTYNames[surfaceId] = ttyName PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) } @@ -13830,15 +13694,22 @@ 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 { @@ -14062,6 +13933,7 @@ 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 { @@ -14106,9 +13978,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: true)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: true)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id } if let id = newPanelId { diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 462b036f..52d9ff26 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: CharacterSet.whitespacesAndNewlines) + let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)" } else { text = "Cmd: —" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 496ebeb2..1bc7e1ed 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,9 +3,6 @@ import SwiftUI import AppKit import Bonsplit import Combine -import CryptoKit -import Darwin -import Network import CoreText func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { @@ -107,49 +104,7 @@ 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) @@ -357,7 +312,7 @@ extension Workspace { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } let capturedScrollback = includeScrollback - ? TerminalController.shared.readTerminalTextForSessionSnapshot( + ? TerminalController.shared.readTerminalTextForSnapshot( terminalPanel: terminalPanel, includeScrollback: true, lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal @@ -381,17 +336,17 @@ extension Workspace { browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForOmnibar(), shouldRenderWebView: browserPanel.shouldRenderWebView, - pageZoom: Double(browserPanel.currentPageZoomFactor()), + pageZoom: Double(browserPanel.webView.pageZoom), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), backHistoryURLStrings: historySnapshot.backHistoryURLStrings, forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) markdownSnapshot = nil case .markdown: - guard let markdownPanel = panel as? MarkdownPanel else { return nil } + guard let mdPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil browserSnapshot = nil - markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) } return SessionPanelSnapshot( @@ -568,7 +523,18 @@ extension Workspace { applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id case .markdown: - return nil + 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 } } @@ -614,7 +580,7 @@ extension Workspace { let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) if pageZoom.isFinite { - _ = browserPanel.setPageZoomFactor(pageZoom) + browserPanel.webView.pageZoom = pageZoom } if browserSnapshot.developerToolsVisible { @@ -647,3239 +613,6 @@ 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 @@ -3905,58 +638,6 @@ 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 @@ -4315,44 +996,10 @@ 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 } @@ -4371,10 +1018,10 @@ final class Workspace: Identifiable, ObservableObject { private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( - 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") + 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")) ) } @@ -4445,18 +1092,24 @@ 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")" + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "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, - initialTerminalCommand: String? = nil, - initialTerminalEnvironment: [String: String] = [:] + configTemplate: ghostty_surface_config_s? = nil ) { self.id = UUID() self.portOrdinal = portOrdinal @@ -4501,9 +1154,7 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, - portOrdinal: portOrdinal, - initialCommand: initialTerminalCommand, - initialEnvironmentOverrides: initialTerminalEnvironment + portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle @@ -4554,11 +1205,6 @@ final class Workspace: Identifiable, ObservableObject { } } - deinit { - activeRemoteSessionControllerID = nil - remoteSessionController?.stop() - } - func refreshSplitButtonTooltips() { let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration @@ -4691,8 +1337,8 @@ final class Workspace: Identifiable, ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self, weak markdownPanel] newTitle in - guard let self, - let markdownPanel, + guard let self = self, + let markdownPanel = markdownPanel, let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } guard let existing = self.bonsplitController.tab(tabId) else { return } @@ -4710,24 +1356,6 @@ 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)? { @@ -5162,7 +1790,7 @@ final class Workspace: Identifiable, ObservableObject { } func recomputeListeningPorts() { - let unique = Set(surfaceListeningPorts.values.flatMap { $0 }).union(remoteForwardedPorts) + let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) let next = unique.sorted() if listeningPorts != next { listeningPorts = next @@ -5246,337 +1874,6 @@ 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( @@ -5763,21 +2060,26 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + + // 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 // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand + workingDirectory: splitWorkingDirectory, + portOrdinal: portOrdinal ) 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 @@ -5803,9 +2105,6 @@ 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 } @@ -5848,7 +2147,6 @@ 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( @@ -5856,15 +2154,11 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: workingDirectory, - portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand, - additionalEnvironment: startupEnvironment + additionalEnvironment: startupEnvironment, + portOrdinal: portOrdinal ) 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 @@ -5878,9 +2172,6 @@ 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 } @@ -5899,12 +2190,6 @@ 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( @@ -5928,12 +2213,7 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } // Create browser panel - let browserPanel = BrowserPanel( - workspaceId: id, - initialURL: url, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace - ) + let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -5977,7 +2257,6 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } @@ -5999,9 +2278,7 @@ final class Workspace: Identifiable, ObservableObject { let browserPanel = BrowserPanel( workspaceId: id, initialURL: url, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -6037,11 +2314,13 @@ 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, @@ -6049,6 +2328,7 @@ 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 { @@ -6061,10 +2341,12 @@ 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, @@ -6076,6 +2358,8 @@ 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 { @@ -6085,6 +2369,7 @@ final class Workspace: Identifiable, ObservableObject { return nil } + // Suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { previousHostedView?.suppressReparentFocus() @@ -6101,9 +2386,11 @@ 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, @@ -6131,6 +2418,8 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = markdownPanel.id + + // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) @@ -6138,6 +2427,7 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) + return markdownPanel } @@ -6165,12 +2455,29 @@ 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) - return bonsplitController.closeTab(tabId) + 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 } // Mapping can transiently drift during split-tree mutations. If the target panel is @@ -6202,12 +2509,38 @@ 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)" + "closed=\(closed ? 1 : 0) " + + "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" ) #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 @@ -6415,6 +2748,7 @@ 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( @@ -6613,8 +2947,6 @@ 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) } @@ -6874,10 +3206,6 @@ 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) @@ -7077,7 +3405,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerFlash() + terminalPanel.triggerNotificationDismissFlash() } func triggerDebugFlash(panelId: UUID) { @@ -7097,10 +3425,16 @@ 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 } - browser.hideBrowserPortalView(source: "workspaceRetire") + BrowserWindowPortalRegistry.hide( + webView: browser.webView, + source: "workspaceRetire" + ) } } @@ -7243,11 +3577,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - hostedView.reconcileGeometryNow() + let geometryChanged = hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh() + if geometryChanged, terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -7545,9 +3879,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 } - panel.hostedView.reconcileGeometryNow() - if panel.surface.surface != nil { - panel.surface.forceRefresh() + let geometryChanged = panel.hostedView.reconcileGeometryNow() + if geometryChanged, panel.surface.surface != nil { + panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -7614,15 +3948,15 @@ final class Workspace: Identifiable, ObservableObject { let panel = panels[panelId] else { return } let alert = NSAlert() - alert.messageText = "Rename Tab" - alert.informativeText = "Enter a custom name for this tab." + 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.") let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle let input = NSTextField(string: currentTitle) - input.placeholderString = "Tab name" + input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name") input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input - alert.addButton(withTitle: "Rename") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { @@ -7651,24 +3985,24 @@ final class Workspace: Identifiable, ObservableObject { ) var options: [(title: String, destination: PanelMoveDestination)] = [ - ("New Workspace in Current Window", .newWorkspaceInCurrentWindow), - ("Selected Workspace in New Window", .selectedWorkspaceInNewWindow), + (String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), + (String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), ] options.append(contentsOf: workspaceTargets.map { target in (target.label, .existingWorkspace(target.workspaceId)) }) let alert = NSAlert() - alert.messageText = "Move Tab" - alert.informativeText = "Choose a destination for this tab." + alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab") + alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "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: "Move") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) guard alert.runModal() == .alertFirstButtonReturn else { return } let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) @@ -7714,9 +4048,9 @@ final class Workspace: Identifiable, ObservableObject { if !moved { let failure = NSAlert() failure.alertStyle = .warning - failure.messageText = "Move Failed" - failure.informativeText = "cmux could not move this tab to the selected destination." - failure.addButton(withTitle: "OK") + 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.runModal() } } @@ -7783,11 +4117,11 @@ extension Workspace: BonsplitDelegate { @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { let alert = NSAlert() - alert.messageText = "Close tab?" - alert.informativeText = "This will close the current tab." + 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.alertStyle = .warning - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) // Prefer a sheet if we can find a window, otherwise fall back to modal. if let window = NSApp.keyWindow ?? NSApp.mainWindow { @@ -8247,7 +4581,11 @@ extension Workspace: BonsplitDelegate { // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG - NSLog("[Workspace] didCloseTab: no panelId for tabId") + 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)" + ) #endif scheduleTerminalGeometryReconcile() if !isDetaching { @@ -8256,11 +4594,15 @@ 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 @@ -8288,7 +4630,6 @@ extension Workspace: BonsplitDelegate { } panels.removeValue(forKey: panelId) - untrackRemoteTerminalSurface(panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) @@ -8306,13 +4647,18 @@ 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 } @@ -8326,6 +4672,13 @@ 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 } @@ -8347,6 +4700,15 @@ 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() @@ -8431,12 +4793,23 @@ 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) @@ -8454,7 +4827,6 @@ 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 { @@ -8468,6 +4840,12 @@ 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 36bc6a05..e2c65a34 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3086,7 +3086,6 @@ 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 @@ -3698,17 +3697,6 @@ 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.") @@ -4394,7 +4382,6 @@ 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 5453b8f5..7538404a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,18 +1,5 @@ # 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). @@ -54,7 +41,7 @@ - [ ] OpenCode integration ## Browser -- [ ] Per-WKWebView proxy observability/inspection once remote proxy path is shipped (URL, method, headers, body, status, timing) +- [ ] Per-WKWebView local proxy for full network request/response inspection (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 bbe59232..580466bd 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4782,74 +4782,6 @@ 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 d7a4b136..26d3a789 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -659,104 +659,6 @@ 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 deleted file mode 100644 index af954ee2..00000000 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index ccf3f116..00000000 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ /dev/null @@ -1,214 +0,0 @@ -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 deleted file mode 100644 index 07a2afaf..00000000 --- a/daemon/remote/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# 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 deleted file mode 100644 index 14d69481..00000000 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ /dev/null @@ -1,721 +0,0 @@ -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 deleted file mode 100644 index 32d08280..00000000 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ /dev/null @@ -1,696 +0,0 @@ -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 deleted file mode 100644 index 22db25a3..00000000 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ /dev/null @@ -1,1034 +0,0 @@ -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 deleted file mode 100644 index 9ee08f07..00000000 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ /dev/null @@ -1,531 +0,0 @@ -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 deleted file mode 100644 index f4b93baa..00000000 --- a/daemon/remote/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 03aaa248..00000000 --- a/docs/remote-daemon-spec.md +++ /dev/null @@ -1,214 +0,0 @@ -# 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 deleted file mode 100755 index a6be6fc6..00000000 --- a/scripts/build_remote_daemon_release_assets.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/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 986b55d2..b3818784 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -3,5 +3,4 @@ # 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 4699b324..d16d328e 100644 --- a/scripts/release_asset_guard.js +++ b/scripts/release_asset_guard.js @@ -1,15 +1,6 @@ "use strict"; -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 IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; 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 39cdcf89..c320cf81 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: [...IMMUTABLE_RELEASE_ASSETS, "notes.txt"], + existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], }); assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); @@ -36,16 +36,12 @@ 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: partialAssets, + existingAssetNames: ["appcast.xml"], }); - assert.deepEqual(result.conflicts, partialAssets); - assert.deepEqual( - result.missingImmutableAssets, - IMMUTABLE_RELEASE_ASSETS.filter((assetName) => !partialAssets.includes(assetName)), - ); + assert.deepEqual(result.conflicts, ["appcast.xml"]); + assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]); 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 5a4f2a6e..4e758a88 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -10,85 +10,6 @@ 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' @@ -358,10 +279,6 @@ 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 @@ -377,21 +294,6 @@ 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 @@ -423,8 +325,6 @@ 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 @@ -445,11 +345,10 @@ 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" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" 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" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${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" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" 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 @@ -477,16 +376,3 @@ 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 deleted file mode 100644 index 470986d8..00000000 --- a/tests/fixtures/ssh-remote/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 9089554f..00000000 --- a/tests/fixtures/ssh-remote/run.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/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 deleted file mode 100644 index 9885b799..00000000 --- a/tests/fixtures/ssh-remote/sshd_config +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 4acb8935..00000000 --- a/tests/fixtures/ssh-remote/ws_echo.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/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 00499ce0..b48419f2 100644 --- a/tests/test_cli_version_flag.py +++ b/tests/test_cli_version_flag.py @@ -32,17 +32,13 @@ def resolve_cmux_cli() -> str: raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") -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" +def run(cli_path: str, *args: str) -> tuple[int, str, str]: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + ) 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 deleted file mode 100755 index 8495d835..00000000 --- a/tests/test_remote_daemon_release_assets.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/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 deleted file mode 100644 index 52b3a6f3..00000000 --- a/tests/test_sidebar_copy_ssh_error_context_menu.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/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 deleted file mode 100644 index e09741fd..00000000 --- a/tests_v2/test_cli_global_flags_and_v1_error_contract.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/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 deleted file mode 100644 index 0eb450d2..00000000 --- a/tests_v2/test_pane_resize_preserves_ls_scrollback.py +++ /dev/null @@ -1,304 +0,0 @@ -#!/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 deleted file mode 100644 index ea175d0c..00000000 --- a/tests_v2/test_pane_resize_preserves_visible_content.py +++ /dev/null @@ -1,263 +0,0 @@ -#!/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 e7ea1b94..a60055fa 100644 --- a/tests_v2/test_rename_tab_cli_parity.py +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -55,6 +55,14 @@ 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) @@ -74,7 +82,7 @@ def main() -> int: _must(bool(surface_id), f"surface.current returned no surface_id: {current}") socket_title = f"socket rename {stamp}" - socket_payload = c._call( + c._call( "tab.action", { "workspace_id": ws_id, @@ -83,20 +91,14 @@ def main() -> int: "title": socket_title, }, ) - _must( - str((socket_payload or {}).get("title") or "") == socket_title, - f"tab.action rename response missing requested title: {socket_payload}", - ) + _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") cli_title = f"cli rename {stamp}" - 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}", - ) + _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") env_title = f"env rename {stamp}" - env_out = _run_cli( + _run_cli( cli, ["rename-tab", env_title], env={ @@ -104,10 +106,7 @@ def main() -> int: "CMUX_TAB_ID": surface_id, }, ) - _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}", - ) + _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") 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 deleted file mode 100644 index 28bdcd67..00000000 --- a/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/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 deleted file mode 100644 index 0b3aabfc..00000000 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ /dev/null @@ -1,630 +0,0 @@ -#!/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 deleted file mode 100644 index 53e01a95..00000000 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/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 deleted file mode 100644 index d11cb845..00000000 --- a/tests_v2/test_ssh_remote_daemon_resize_stdio.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/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 deleted file mode 100644 index 63162e76..00000000 --- a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py +++ /dev/null @@ -1,258 +0,0 @@ -#!/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 deleted file mode 100644 index 6661aa5c..00000000 --- a/tests_v2/test_ssh_remote_docker_forwarding.py +++ /dev/null @@ -1,742 +0,0 @@ -#!/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 deleted file mode 100644 index 43c0e3cd..00000000 --- a/tests_v2/test_ssh_remote_docker_reconnect.py +++ /dev/null @@ -1,612 +0,0 @@ -#!/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 deleted file mode 100644 index 040207d7..00000000 --- a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py +++ /dev/null @@ -1,249 +0,0 @@ -#!/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 deleted file mode 100644 index 91af772d..00000000 --- a/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/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 deleted file mode 100644 index d47e2957..00000000 --- a/tests_v2/test_ssh_remote_proxy_bind_conflict.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/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 deleted file mode 100644 index ff70110e..00000000 --- a/tests_v2/test_ssh_remote_resize_scrollback_regression.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/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 deleted file mode 100644 index c521485c..00000000 --- a/tests_v2/test_ssh_remote_second_session_mux_regression.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/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 deleted file mode 100755 index 3d632b84..00000000 --- a/tests_v2/test_ssh_remote_shell_integration.py +++ /dev/null @@ -1,577 +0,0 @@ -#!/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 deleted file mode 100644 index fa5d9199..00000000 --- a/tests_v2/test_ssh_remote_shortcuts_stay_remote.py +++ /dev/null @@ -1,281 +0,0 @@ -#!/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 deleted file mode 100644 index 33b56c2e..00000000 --- a/tests_v2/test_workspace_create_initial_env.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/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())