Skip to content
Merged
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
275 changes: 241 additions & 34 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,9 +451,159 @@ private enum SocketPasswordResolver {
}
}

private enum CLISocketPathSource {
case explicitFlag
case environment
case implicitDefault
}

private enum CLISocketPathResolver {
static let defaultSocketPath = "/tmp/cmux.sock"
private static let fallbackSocketPath = "/tmp/cmux-debug.sock"
private static let stagingSocketPath = "/tmp/cmux-staging.sock"
private static let lastSocketPathFile = "/tmp/cmux-last-socket-path"

static func resolve(
requestedPath: String,
source: CLISocketPathSource,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String {
guard source == .implicitDefault else {
return requestedPath
}

let candidates = dedupe(candidatePaths(requestedPath: requestedPath, environment: environment))

// Prefer sockets that are currently accepting connections.
for path in candidates where canConnect(to: path) {
return path
}

// If the listener is still starting, prefer existing socket files.
for path in candidates where isSocketFile(path) {
return path
}
Comment on lines +478 to +485

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

CMUX_TAG can still route commands to the wrong instance during startup race.

With CMUX_TAG present (Lines 493-497), Line 478 still returns the first connectable socket across all candidates. If the tagged socket is not yet accepting connections but /tmp/cmux.sock is, the CLI can connect to the wrong instance.

🔧 Proposed fix
     static func resolve(
         requestedPath: String,
         source: CLISocketPathSource,
         environment: [String: String] = ProcessInfo.processInfo.environment
     ) -> String {
         guard source == .implicitDefault else {
             return requestedPath
         }

-        let candidates = dedupe(candidatePaths(requestedPath: requestedPath, environment: environment))
+        let tagged = dedupe(taggedCandidatePaths(environment: environment))
+        let candidates = dedupe(candidatePaths(requestedPath: requestedPath, environment: environment))

+        // When CMUX_TAG is set, prefer tagged sockets first (including startup race fallback).
+        if !tagged.isEmpty {
+            for path in tagged where canConnect(to: path) { return path }
+            for path in tagged where isSocketFile(path) { return path }
+        }
+
         // Prefer sockets that are currently accepting connections.
         for path in candidates where canConnect(to: path) {
             return path
         }
@@
     }
+
+    private static func taggedCandidatePaths(environment: [String: String]) -> [String] {
+        guard let tag = normalized(environment["CMUX_TAG"]) else { return [] }
+        let slug = sanitizeTagSlug(tag)
+        return [
+            "/tmp/cmux-debug-\(slug).sock",
+            "/tmp/cmux-\(slug).sock"
+        ]
+    }

Also applies to: 493-497

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 478 - 485, When CMUX_TAG is set, the selection
logic for candidates must prefer the tagged socket instead of returning the
first connectable path; change the loops that currently iterate over candidates
and call canConnect(to:) / isSocketFile(path) so they first filter candidates to
those matching the CMUX_TAG (the tag-matching predicate used elsewhere in this
file) and only consider connectable or existing socket files within that
filtered list, falling back to the global candidate loops only if no tagged
candidates are found; update both the connectable-check loop (using
canConnect(to:)) and the existing-socket loop (using isSocketFile(path)) to
implement this behavior.


return requestedPath
}

private static func candidatePaths(requestedPath: String, environment: [String: String]) -> [String] {
var candidates: [String] = []

if let tag = normalized(environment["CMUX_TAG"]) {
let slug = sanitizeTagSlug(tag)
candidates.append("/tmp/cmux-debug-\(slug).sock")
candidates.append("/tmp/cmux-\(slug).sock")
}

candidates.append(requestedPath)
candidates.append(fallbackSocketPath)
candidates.append(stagingSocketPath)
candidates.append(contentsOf: discoverTaggedSockets(limit: 12))
if let last = readLastSocketPath() {
candidates.append(last)
}
return candidates
}

private static func readLastSocketPath() -> String? {
guard let data = try? String(contentsOfFile: lastSocketPathFile, encoding: .utf8) else {
return nil
}
return normalized(data)
}

private static func discoverTaggedSockets(limit: Int) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
return []
}

var discovered: [(path: String, mtime: TimeInterval)] = []
discovered.reserveCapacity(min(limit, entries.count))
for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") {
let path = "/tmp/\(name)"
var st = stat()
guard lstat(path, &st) == 0 else { continue }
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
if path == defaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath {
continue
}
let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000
discovered.append((path: path, mtime: modified))
}

discovered.sort { $0.mtime > $1.mtime }
return discovered.prefix(limit).map(\.path)
}

private static func isSocketFile(_ path: String) -> Bool {
var st = stat()
return lstat(path, &st) == 0 && (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK)
}
Comment on lines +539 to +542

@cubic-dev-ai cubic-dev-ai Bot Mar 4, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Socket autodiscovery can pick sockets not owned by the current user, which then fails immediately in SocketClient.connect() and can mask a valid socket candidate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 539:

<comment>Socket autodiscovery can pick sockets not owned by the current user, which then fails immediately in `SocketClient.connect()` and can mask a valid socket candidate.</comment>

<file context>
@@ -451,9 +451,159 @@ private enum SocketPasswordResolver {
+        return discovered.prefix(limit).map(\.path)
+    }
+
+    private static func isSocketFile(_ path: String) -> Bool {
+        var st = stat()
+        return lstat(path, &st) == 0 && (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK)
</file context>
Suggested change
private static func isSocketFile(_ path: String) -> Bool {
var st = stat()
return lstat(path, &st) == 0 && (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK)
}
private static func isSocketFile(_ path: String) -> Bool {
var st = stat()
return lstat(path, &st) == 0 &&
(st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) &&
st.st_uid == getuid()
}
Fix with Cubic


private static func canConnect(to path: String) -> Bool {
guard isSocketFile(path) else { return false }
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return false }
defer { Darwin.close(fd) }

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
Comment on lines +553 to +557

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

head -c 50000 CLI/cmux.swift | tail -c +20000 | wc -l

Repository: manaflow-ai/cmux

Length of output: 63


🏁 Script executed:

wc -l CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 79


🏁 Script executed:

sed -n '540,570p' CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 1317


🏁 Script executed:

sed -n '640,670p' CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 1389


🏁 Script executed:

rg -n -C3 'sockaddr_un|sun_path' CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 1820


🏁 Script executed:

rg -n 'lengthOfBytes|ENAMETOOLONG|Socket path is too long' CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 42


Validate sun_path length before copying socket paths.

Path bytes are copied with strncpy at lines 553 and 655 without validating against sockaddr_un.sun_path capacity. Overlong paths are silently truncated, causing attempts to connect to a different (truncated) socket.

Add a length check before each strncpy call:

Proposed fixes

Line 553 (canConnect method):

         let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
+        guard path.lengthOfBytes(using: .utf8) < maxLength else { return false }
         path.withCString { ptr in

Line 655 (socket connection method):

         let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
+        guard path.lengthOfBytes(using: .utf8) < maxLength else {
+            throw CLIError(message: "Socket path is too long: \(path)")
+        }
         path.withCString { ptr in
📝 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
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
guard path.lengthOfBytes(using: .utf8) < maxLength else { return false }
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 553 - 557, Before calling strncpy on
addr.sun_path, validate the path length against the capacity of
sockaddr_un.sun_path: compute maxLength = MemoryLayout.size(ofValue:
addr.sun_path) (or use MemoryLayout.size(ofValue: addr.sun_path) - 1 if you
reserve a NUL), check path.utf8.count < maxLength (or <= maxLength-1) and if it
exceeds, return/throw an error (or propagate a failure) instead of truncating;
apply this check in the canConnect code path that uses
path.withCString/addr.sun_path/strncpy and in the socket connection method that
does the same, so neither call silently truncates into a different socket path.

}

let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
return result == 0
}

private static func sanitizeTagSlug(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let slug = trimmed
.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression)
.replacingOccurrences(of: "-+", with: "-", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
return slug.isEmpty ? "agent" : slug
}

private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

private static func dedupe(_ paths: [String]) -> [String] {
var seen: Set<String> = []
var ordered: [String] = []
ordered.reserveCapacity(paths.count)
for path in paths where !path.isEmpty {
if seen.insert(path).inserted {
ordered.append(path)
}
}
return ordered
}
}

final class SocketClient {
private let path: String
private var socketFD: Int32 = -1
private static let connectRetryWindowSeconds: TimeInterval = 2.0
private static let connectRetryIntervalSeconds: TimeInterval = 0.1
private static let retriableConnectErrnos: Set<Int32> = [
ENOENT,
ECONNREFUSED,
EAGAIN,
EINTR
]
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
private static let responseTimeoutSeconds: TimeInterval = {
let env = ProcessInfo.processInfo.environment
Expand All @@ -472,40 +622,66 @@ final class SocketClient {
func connect() throws {
if socketFD >= 0 { return }

// Verify socket is owned by the current user to prevent fake-socket attacks
var st = stat()
guard stat(path, &st) == 0 else {
throw CLIError(message: "Socket not found at \(path)")
}
guard st.st_uid == getuid() else {
throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect")
}
let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds)
var lastError: CLIError?

socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
if socketFD < 0 {
throw CLIError(message: "Failed to create socket")
}
while true {
// Verify socket is owned by the current user to prevent fake-socket attacks.
var st = stat()
guard stat(path, &st) == 0 else {
let error = CLIError(message: "Socket not found at \(path)")
lastError = error
if errno == ENOENT, Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
}
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else {
throw CLIError(message: "Path exists at \(path) but is not a Unix socket")
}
guard st.st_uid == getuid() else {
throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect")
}

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
if socketFD < 0 {
throw CLIError(message: "Failed to create socket")
}
}

let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
}
}
if result != 0 {

let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
if result == 0 {
return
}

let connectErrno = errno
Darwin.close(socketFD)
socketFD = -1
throw CLIError(message: "Failed to connect to socket at \(path)")

let error = CLIError(message: "Failed to connect to socket at \(path)")
lastError = error
if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
}

throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

unreachable code - the while true loop above only exits via return (line 668) or throw (lines 638, 641, 644, 649, 681), so this line can never execute

}

func close() {
Expand Down Expand Up @@ -614,7 +790,19 @@ struct CMUXCLI {
let args: [String]

func run() throws {
var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock"
let processEnv = ProcessInfo.processInfo.environment
let envSocketPath: String? = {
guard let raw = processEnv["CMUX_SOCKET_PATH"] else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}()
var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
var socketPathSource: CLISocketPathSource
if let envSocketPath {
socketPathSource = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
} else {
socketPathSource = .implicitDefault
}
var jsonOutput = false
var idFormatArg: String? = nil
var windowId: String? = nil
Expand All @@ -628,6 +816,7 @@ struct CMUXCLI {
throw CLIError(message: "--socket requires a path")
}
socketPath = args[index + 1]
socketPathSource = .explicitFlag
index += 2
continue
}
Expand Down Expand Up @@ -682,7 +871,12 @@ struct CMUXCLI {
command: command,
commandArgs: commandArgs,
socketPath: socketPath,
processEnv: ProcessInfo.processInfo.environment
processEnv: processEnv
)
let resolvedSocketPath = CLISocketPathResolver.resolve(
requestedPath: socketPath,
source: socketPathSource,
environment: processEnv
)

if command == "version" {
Expand All @@ -692,7 +886,7 @@ struct CMUXCLI {

// If the argument looks like a path (not a known command), open a workspace there.
if looksLikePath(command) {
try openPath(command, socketPath: socketPath)
try openPath(command, socketPath: resolvedSocketPath)
return
}

Expand All @@ -706,16 +900,28 @@ struct CMUXCLI {
return
}

let client = SocketClient(path: socketPath)
let client = SocketClient(path: resolvedSocketPath)
if resolvedSocketPath != socketPath {
cliTelemetry.breadcrumb(
"socket.path.autodiscovered",
data: [
"requested_path": socketPath,
"resolved_path": resolvedSocketPath
]
)
}
cliTelemetry.breadcrumb(
"socket.connect.attempt",
data: ["command": command]
data: [
"command": command,
"path": resolvedSocketPath
]
)
do {
try client.connect()
cliTelemetry.breadcrumb("socket.connect.success")
cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath])
} catch {
cliTelemetry.breadcrumb("socket.connect.failure")
cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath])
cliTelemetry.captureError(stage: "socket_connect", error: error)
throw error
}
Expand Down Expand Up @@ -6470,7 +6676,8 @@ struct CMUXCLI {
ALL commands (send, list-panels, new-split, notify, etc.).
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock).
CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults
to /tmp/cmux.sock and auto-discovers tagged/debug sockets.
CMUX_CLI_SENTRY_DISABLED
Set to 1 to disable CLI Sentry socket diagnostics.
"""
Expand Down
Loading