diff --git a/Sources/CMUXClient/UnixSocketChannel.swift b/Sources/CMUXClient/UnixSocketChannel.swift index 30bfe85..ae8f7f6 100644 --- a/Sources/CMUXClient/UnixSocketChannel.swift +++ b/Sources/CMUXClient/UnixSocketChannel.swift @@ -105,12 +105,27 @@ private func defaultAppSupportDirectory(_ env: [String: String]) -> URL { public func cmuxSocketPassword( _ env: [String: String] = ProcessInfo.processInfo.environment, appSupportDirectory: URL? = nil, + stateDirectory: URL? = nil, fileManager: FileManager = .default ) -> String? { if let p = normalizedSocketPassword(env["CMUX_SOCKET_PASSWORD"]) { return p } + // cmux 0.64+ writes socket-control-password to the XDG state dir + // (~/.local/state/cmux/) rather than Application Support. + // Check state dir first (newer convention), then fall back to App Support. + let stateCmuxDir = (stateDirectory ?? defaultStateDirectory(env)) + .appendingPathComponent("cmux", isDirectory: true) + let statePasswordFile = stateCmuxDir + .appendingPathComponent("socket-control-password", isDirectory: false) + if fileManager.fileExists(atPath: statePasswordFile.path), + let data = try? Data(contentsOf: statePasswordFile), + let raw = String(data: data, encoding: .utf8), + let p = normalizedSocketPassword(raw) { + return p + } + guard let appSupportDirectory = appSupportDirectory ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { diff --git a/Tests/CMUXClientTests/CmuxSocketPathTests.swift b/Tests/CMUXClientTests/CmuxSocketPathTests.swift index d45b4b4..8c87b9d 100644 --- a/Tests/CMUXClientTests/CmuxSocketPathTests.swift +++ b/Tests/CMUXClientTests/CmuxSocketPathTests.swift @@ -133,32 +133,89 @@ final class CmuxSocketPathTests: XCTestCase { } func testSocketPasswordEnvWins() throws { - let temp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let temp = freshTemp() try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: temp.appendingPathComponent("cmux", isDirectory: true), withIntermediateDirectories: true) + try makeCmuxDir(temp) try "file-secret\n".write(to: temp.appendingPathComponent("cmux/socket-control-password"), atomically: true, encoding: .utf8) - let password = cmuxSocketPassword(["CMUX_SOCKET_PASSWORD": " env-secret\n"], appSupportDirectory: temp) + let password = cmuxSocketPassword( + ["CMUX_SOCKET_PASSWORD": " env-secret\n"], + appSupportDirectory: temp, + stateDirectory: freshTemp() + ) XCTAssertEqual(password, "env-secret") } func testSocketPasswordFallsBackToCmuxPasswordFile() throws { - let temp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: temp.appendingPathComponent("cmux", isDirectory: true), withIntermediateDirectories: true) + let temp = freshTemp() + try makeCmuxDir(temp) try "file-secret\n".write(to: temp.appendingPathComponent("cmux/socket-control-password"), atomically: true, encoding: .utf8) - let password = cmuxSocketPassword([:], appSupportDirectory: temp) + let password = cmuxSocketPassword( + [:], + appSupportDirectory: temp, + stateDirectory: freshTemp() + ) XCTAssertEqual(password, "file-secret") } func testSocketPasswordIgnoresMissingOrBlankValues() throws { - let temp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: temp.appendingPathComponent("cmux", isDirectory: true), withIntermediateDirectories: true) + let temp = freshTemp() + try makeCmuxDir(temp) try "\n".write(to: temp.appendingPathComponent("cmux/socket-control-password"), atomically: true, encoding: .utf8) - let password = cmuxSocketPassword(["CMUX_SOCKET_PASSWORD": " "], appSupportDirectory: temp) + let password = cmuxSocketPassword( + ["CMUX_SOCKET_PASSWORD": " "], + appSupportDirectory: temp, + stateDirectory: freshTemp() + ) + + XCTAssertNil(password) + } + + // cmux 0.64+ writes socket-control-password to ~/.local/state/cmux/ (XDG + // state dir) rather than ~/Library/Application Support/cmux/. + + func testSocketPasswordReadsFromStateDirFirst() throws { + let temp = freshTemp() + let stateDir = try makeCmuxDir(temp.appendingPathComponent("state")) + let appSupportDir = try makeCmuxDir(temp.appendingPathComponent("appsupport")) + try "state-secret\n".write(to: stateDir.appendingPathComponent("socket-control-password"), atomically: true, encoding: .utf8) + try "appsupport-secret\n".write(to: appSupportDir.appendingPathComponent("socket-control-password"), atomically: true, encoding: .utf8) + + let password = cmuxSocketPassword( + [:], + appSupportDirectory: temp.appendingPathComponent("appsupport"), + stateDirectory: temp.appendingPathComponent("state") + ) + + XCTAssertEqual(password, "state-secret") + } + + func testSocketPasswordFallsBackToAppSupportWhenStateAbsent() throws { + let temp = freshTemp() + let appSupportDir = try makeCmuxDir(temp.appendingPathComponent("appsupport")) + try "appsupport-secret\n".write(to: appSupportDir.appendingPathComponent("socket-control-password"), atomically: true, encoding: .utf8) + + let password = cmuxSocketPassword( + [:], + appSupportDirectory: temp.appendingPathComponent("appsupport"), + stateDirectory: temp.appendingPathComponent("empty-state") + ) + + XCTAssertEqual(password, "appsupport-secret") + } + + func testSocketPasswordReturnsNilWhenBothAbsent() throws { + let temp = freshTemp() + + let password = cmuxSocketPassword( + [:], + appSupportDirectory: temp.appendingPathComponent("appsupport"), + stateDirectory: temp.appendingPathComponent("state") + ) XCTAssertNil(password) } diff --git a/docs/connection-guide.en.md b/docs/connection-guide.en.md index 996b8c9..9dc37c1 100644 --- a/docs/connection-guide.en.md +++ b/docs/connection-guide.en.md @@ -140,8 +140,41 @@ Depending on the log: |---|---|---| | `cmux event stream unavailable: socketMissing` | cmux is not running | Launch the cmux app, then `launchctl kickstart -k "$SERVICE"` | | Repeated `Connection refused` | cmux restarted and the socket name rotated | `launchctl kickstart -k "$SERVICE"`; if needed, re-run `./scripts/install-launchd.sh` | +| Repeated `cmux event stream attached` then immediately `detached` | cmux socket access denied (cmux 0.64+) | See **③a** below | | Three lines OK but only the app can't attach | network/address issue | Check ④ and ⑤ | +### ③a Socket access denied (cmux 0.64+) + +cmux 0.64 introduced a socket access control mode that defaults to +`cmuxOnly` — only processes launched inside cmux can connect. The relay +runs as a launchd agent outside cmux and is denied. + +Fix: tell cmux to accept password-authenticated external connections by +creating `~/.config/cmux/cmux.json` with: + +```bash +mkdir -p ~/.config/cmux +cat > ~/.config/cmux/cmux.json << 'EOF' +{ + "automation": { + "socketControlMode": "password", + "socketPassword": "$(openssl rand -hex 24)" + } +} +EOF +``` + +Or write the file manually with any strong password you choose, then +reload from **inside a cmux terminal**: + +```bash +cmux reload-config +``` + +The relay reads the password automatically from +`~/.local/state/cmux/socket-control-password` (written by cmux on +reload). No relay restart is needed. + ### ④ Is Tailscale online on both ends? ```bash