Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Sources/CMUXClient/UnixSocketChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
75 changes: 66 additions & 9 deletions Tests/CMUXClientTests/CmuxSocketPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
33 changes: 33 additions & 0 deletions docs/connection-guide.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +155 to +164

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Remove the quoted heredoc here.

Line 157 uses << 'EOF', so Line 161 writes the literal text $(openssl rand -hex 24) into cmux.json instead of a random secret. That makes the documented password predictable.

Suggested fix
 mkdir -p ~/.config/cmux
-cat > ~/.config/cmux/cmux.json << 'EOF'
+cat > ~/.config/cmux/cmux.json <<EOF
 {
   "automation": {
     "socketControlMode": "password",
     "socketPassword": "$(openssl rand -hex 24)"
   }
 }
 EOF
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```bash
mkdir -p ~/.config/cmux
cat > ~/.config/cmux/cmux.json << 'EOF'
{
"automation": {
"socketControlMode": "password",
"socketPassword": "$(openssl rand -hex 24)"
}
}
EOF
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/connection-guide.en.md` around lines 155 - 164, The heredoc in the
connection guide is quoted, so the `$(openssl rand -hex 24)` expression in the
`cmux.json` example is written literally instead of being evaluated. Update the
`cmux.json` generation snippet to use an unquoted heredoc in that example so the
`socketPassword` field is populated with a real random secret, and keep the rest
of the `automation` config unchanged.

```

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.
Comment on lines +174 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Outline the downstream consumer =="
ast-grep outline Sources/RelayCore/CmuxConnection.swift --view expanded || true

echo
echo "== Find CmuxConnection construction sites =="
rg -nC3 '\bCmuxConnection\s*\(' Sources Tests

echo
echo "== Find cmuxSocketPassword call sites =="
rg -nC3 '\bcmuxSocketPassword\s*\(' Sources Tests

Repository: NewTurn2017/cmux-remote

Length of output: 8394


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== CmuxConnection implementation =="
sed -n '1,220p' Sources/RelayCore/CmuxConnection.swift

echo
echo "== CmuxRelay startup flow =="
sed -n '1,180p' Sources/RelayServer/CmuxRelay.swift

echo
echo "== Search for relay reload/recreate paths =="
rg -nC2 'reload-config|reload\(|invalidateEvents\(|connectForEvents\(|observe\(bootInfo\)' Sources Tests

Repository: NewTurn2017/cmux-remote

Length of output: 16507


Drop the “No relay restart is needed” claim
CmuxConnection reads cmuxSocketPassword() once during initialization and reuses that cached value for all reconnects, and the SIGHUP handler only reloads relay config — it does not recreate the connection. If the password file changes after startup, the running relay won’t pick it up until the connection is rebuilt or the process restarts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/connection-guide.en.md` around lines 174 - 176, Remove the “No relay
restart is needed” claim from the connection guide and update the surrounding
text to reflect that the relay only reads the password once via
cmuxSocketPassword() during CmuxConnection initialization; since the SIGHUP path
only reloads relay config and does not rebuild the connection, any password file
change requires reconnecting or restarting the process.


### ④ Is Tailscale online on both ends?

```bash
Expand Down