Add SSH agent forwarding support for cmux ssh - #5301
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds opt-in SSH agent forwarding to ChangesSSH Agent Forwarding for cmux ssh
Sequence Diagram (high-level flow) sequenceDiagram
participant CLI as cmux CLI
participant Resolver as ForwardAgentResolver
participant SSHG as "/usr/bin/ssh -G"
participant WorkspaceAPI as Workspace Service
participant Terminal as TerminalController
participant SSHProc as "/usr/bin/ssh"
CLI->>Resolver: parse flags, sshOptions, env
Resolver->>SSHG: optional read of forwardagent
Resolver->>CLI: resolved ForwardAgent and agentSocketPath
CLI->>WorkspaceAPI: workspace.create (initial_env includes SSH_AUTH_SOCK)
CLI->>WorkspaceAPI: workspace.remote.configure (ssh_auth_sock set)
WorkspaceAPI->>Terminal: launch with initialTerminalEnvironment
Terminal->>SSHProc: spawn SSH with SSH_AUTH_SOCK in environment
Estimated code review effort 🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 17 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (17 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with 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.
Inline comments:
In `@CLI/cmux.swift`:
- Around line 7950-7960: The function defaultSSHAgentSocketPath currently only
handles "$VAR" and yes-like values; update it to also accept literal socket
paths by, after trimming, returning normalizedSSHAgentSocketPath(trimmed) when
trimmed is non-empty and not a "$" variable and not recognized by
Self.isSSHYesValue; keep the existing behavior of returning nil for
empty/invalid values and continue using ProcessInfo.processInfo.environment[...]
when a "$VAR" is detected—modify defaultSSHAgentSocketPath and reuse
normalizedSSHAgentSocketPath for normalization.
In `@Sources/WorkspaceRemoteConfiguration.swift`:
- Around line 594-608: The two computed properties sshTerminalStartupEnvironment
and sshProcessEnvironment currently call
WorkspaceRemoteSSHOptionFilter.normalizedOptional(agentSocketPath) redundantly;
instead, use the already-normalized self.agentSocketPath directly (e.g. guard
let agentSocketPath = self.agentSocketPath) to decide whether to return nil or
set SSH_AUTH_SOCK, and keep the optional return types as-is to preserve the
semantic difference between nil and an empty dictionary.
- Line 402: The code redundantly re-normalizes agentSocketPath; instead of
calling WorkspaceRemoteSSHOptionFilter.normalizedOptional(agentSocketPath)
again, use the already-normalized stored property self.agentSocketPath (e.g.,
self.agentSocketPath ?? "") where normalizedAgentSocketPath is created and
compare against self.agentSocketPath directly in the equality checks around the
current comparison locations (previously lines 443-444). Remove the extra
normalizedOptional calls and update the comparisons to use the stored property
to avoid duplicate normalization.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0420d892-5f9a-4443-a1d6-1b2fedf55edf
📒 Files selected for processing (9)
CLI/cmux.swiftResources/Localizable.xcstringsSources/ContentView+ForkAgentConversation.swiftSources/TerminalController.swiftSources/Workspace.swiftSources/WorkspaceRemoteConfiguration.swiftcmuxTests/CLINotifyProcessIntegrationRegressionTests.swiftdocs/cli-contract.mddocs/remote-daemon-spec.md
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with 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.
Inline comments:
In `@CLI/cmux.swift`:
- Around line 7970-7977: isPathLikeSSHAgentSocketValue allows tilde-prefixed
values but normalizedSSHAgentSocketPath doesn't expand them, so update
normalizedSSHAgentSocketPath to expand leading tildes after trimming (e.g. using
(trimmed as NSString).expandingTildeInPath or NSString.expandingTildeInPath) and
return the expanded path to ensure SSH_AUTH_SOCK points to the real filesystem
location; keep the existing trimming/empty checks and still accept values that
isPathLikeSSHAgentSocketValue recognizes.
In `@cmuxTests/TabManagerSessionSnapshotTests.swift`:
- Around line 1959-1969: Add a companion test in TabManagerSessionSnapshotTests
that persists a snapshot with ForwardAgent=yes using originalAgentSocketPath,
then before calling the restore logic unset SSH_AUTH_SOCK (use unsetenv) to
simulate no agent, perform the restore, and assert the restored session's
agentSocketPath is nil and that SSH_AUTH_SOCK is not present in the restored
startup environments; reference the existing variables originalAgentSocketPath,
restoredAgentSocketPath, getenv/setenv/unsetenv and the restored configuration's
agentSocketPath/ForwardAgent checks to locate where to add this edge-case
assertion.
In `@Sources/WorkspaceRemoteConfiguration.swift`:
- Around line 83-88: The code returns trimmed socket strings unchanged when
isPathLikeSSHAgentSocketValue(trimmed) is true, so tilde-prefixed literals like
"~/..." are never expanded; update the branch that currently returns
normalizedOptional(trimmed) to first expand leading tilde for path-like values
(e.g., use NSString/URL/FileManager tilde expansion) before calling
normalizedOptional, ensuring the expansion happens only when
isPathLikeSSHAgentSocketValue(trimmed) is true and preserving existing behavior
for other cases; reference the trimmed local variable,
isPathLikeSSHAgentSocketValue(_:) check, and normalizedOptional(_:) call to
locate where to perform the expansion.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: d1930496-0dfe-4e0a-b84e-52b1b167a259
📒 Files selected for processing (5)
CLI/cmux.swiftResources/Localizable.xcstringsSources/WorkspaceRemoteConfiguration.swiftcmuxTests/CLINotifyProcessIntegrationRegressionTests.swiftcmuxTests/TabManagerSessionSnapshotTests.swift
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with 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.
Inline comments:
In `@Sources/WorkspaceRemoteConfiguration.swift`:
- Around line 468-470: resolvedAgentSocketPath can incorrectly honor an earlier
ForwardAgent value because
WorkspaceRemoteSSHOptionFilter.sshAgentSocketPath(for:) uses
optionValue(named:in:) which returns the first match; change
sshAgentSocketPath(for:) (or optionValue(named:in:)) to consider the last
matching ForwardAgent entry so later explicit overrides win — e.g. scan
sshOptions in reverse (or add a lastOptionValue(named:in:) helper) and use that
result when determining whether to return a socket path; keep references to
resolvedAgentSocketPath(sshOptions:explicitAgentSocketPath:),
WorkspaceRemoteSSHOptionFilter.normalizedAgentSocketPath(_:) and
sshAgentSocketPath(for:) to locate the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f7e59d4a-3580-4693-87c3-b85365572d18
📒 Files selected for processing (6)
CLI/cmux.swiftSources/TerminalController.swiftSources/WorkspaceRemoteConfiguration.swiftcmuxTests/CLINotifyProcessIntegrationRegressionTests.swiftcmuxTests/TabManagerSessionSnapshotTests.swiftcmuxTests/TerminalControllerSocketSecurityTests.swift
There was a problem hiding this comment.
3 issues found across 13 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/TerminalController.swift">
<violation number="1" location="Sources/TerminalController.swift:7000">
P2: Explicit `ssh_auth_sock` presence is ignored when value normalizes to empty, causing unintended fallback agent forwarding. Respect key presence so callers can explicitly clear/disable socket forwarding.</violation>
</file>
<file name="cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift">
<violation number="1" location="cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift:2509">
P2: New SSH agent forwarding tests do not set a per-test temporary HOME, so the spawned cmux CLI can read/write the real ~/.local/state/cmux instead of a hermetic temp directory.</violation>
</file>
<file name="Sources/WorkspaceRemoteConfiguration.swift">
<violation number="1" location="Sources/WorkspaceRemoteConfiguration.swift:469">
P2: Agent socket path is injected without existence validation. Missing/stale `SSH_AUTH_SOCK` values can override a valid inherited agent and break SSH auth flows.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Greptile SummaryThis PR adds SSH agent forwarding to
Confidence Score: 4/5The core forwarding path works correctly for opt-in cases; the -a/--no-forward-agent disable flag does not fully suppress workspace-level agent socket injection. The forwarding enable path (-A, ForwardAgent=yes, or implicit via SSH_AUTH_SOCK) behaves as intended. The disable path is broken: resolvedSSHAgentForwarding with override=false appends ForwardAgent=no to the SSH options (blocking native OpenSSH forwarding) but still returns the local agent socket path, which is then sent to the workspace via initial_env and ssh_auth_sock and injected into terminal and subprocess environments. CLI/cmux.swift — resolvedSSHAgentForwarding needs an early return when override == false. Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant CLI as CLI/cmux.swift
participant Resolver as SSHAgentSocketResolver
participant Daemon as cmux daemon (Workspace.swift)
participant Remote as Remote Workspace
User->>CLI: "cmux ssh host [-A/-a/--ssh-option ForwardAgent=...]"
CLI->>CLI: resolvedSSHAgentForwarding(sshOptions, override)
CLI->>Resolver: agentSocketPath(forForwardAgentValue:)
Resolver-->>CLI: resolvedSocketPath (or nil)
CLI->>CLI: "fallback to SSH_AUTH_SOCK env if nil (even when override=false)"
CLI->>Daemon: "workspace.create {initial_env: {SSH_AUTH_SOCK: path}}"
CLI->>Daemon: "workspace.remote.configure {ssh_auth_sock: path, ssh_options: [...]}"
Daemon->>Daemon: resolvedAgentSocketPath(sshOptions, explicitAgentSocketPath)
Daemon->>Remote: "ssh subprocess (env: SSH_AUTH_SOCK=path)"
Daemon->>Remote: "terminal startup (env: SSH_AUTH_SOCK=path)"
Reviews (3): Last reviewed commit: "Fix SSH resolver public initializer" | Re-trigger Greptile |
| override: Bool? | ||
| ) -> (sshOptions: [String], agentSocketPath: String?) { | ||
| let forwardAgentValue: String? | ||
| var resolvedOptions = sshOptions | ||
|
|
||
| if let override { | ||
| resolvedOptions = sshOptionsRemovingForwardAgent(resolvedOptions) | ||
| resolvedOptions.append("ForwardAgent=\(override ? "yes" : "no")") | ||
| forwardAgentValue = override ? "yes" : nil | ||
| } else if let explicitForwardAgent = sshForwardAgentValue(in: resolvedOptions) { | ||
| forwardAgentValue = Self.isSSHNoValue(explicitForwardAgent) ? nil : explicitForwardAgent | ||
| } else if let configuredForwardAgent = effectiveSSHConfigForwardAgent( | ||
| destination: destination, | ||
| port: port, | ||
| identityFile: identityFile, | ||
| sshOptions: resolvedOptions | ||
| ), !Self.isSSHNoValue(configuredForwardAgent) { | ||
| resolvedOptions.append("ForwardAgent=\(configuredForwardAgent)") | ||
| forwardAgentValue = configuredForwardAgent | ||
| } else { | ||
| forwardAgentValue = nil | ||
| } | ||
|
|
||
| let agentSocketPath = forwardAgentValue.flatMap(defaultSSHAgentSocketPath(forForwardAgentValue:)) | ||
| return (resolvedOptions, agentSocketPath) | ||
| } | ||
|
|
||
| private func effectiveSSHConfigForwardAgent( | ||
| destination: String, | ||
| port: Int?, | ||
| identityFile: String?, | ||
| sshOptions: [String] | ||
| ) -> String? { | ||
| #if DEBUG | ||
| if let fixture = ProcessInfo.processInfo.environment["CMUX_TEST_SSH_G_OUTPUT"] { | ||
| return sshForwardAgentValue(fromSSHConfigDump: fixture) | ||
| } | ||
| #endif | ||
|
|
||
| var arguments = ["-G"] | ||
| if let port { | ||
| arguments += ["-p", String(port)] | ||
| } | ||
| if let identityFile = normalizedSSHIdentityPath(identityFile) { | ||
| arguments += ["-i", identityFile] | ||
| } | ||
| for option in sshOptions { | ||
| arguments += ["-o", option] | ||
| } | ||
| arguments.append(destination) | ||
|
|
||
| let result = runProcess(executablePath: "/usr/bin/ssh", arguments: arguments, timeout: 3) | ||
| guard result.status == 0 else { | ||
| cliDebugLog( | ||
| "cli.ssh.forwardAgent.resolve.failed target=\(destination) " + | ||
| "status=\(result.status) stderr=\(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))" | ||
| ) | ||
| return nil | ||
| } | ||
|
|
||
| return sshForwardAgentValue(fromSSHConfigDump: result.stdout) | ||
| } | ||
|
|
||
| private func sshForwardAgentValue(fromSSHConfigDump output: String) -> String? { | ||
| for line in output.split(whereSeparator: \.isNewline) { | ||
| let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace) | ||
| guard parts.count == 2, | ||
| parts[0].lowercased() == "forwardagent" else { | ||
| continue | ||
| } | ||
| let value = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| return value.isEmpty ? nil : value | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private func sshOptionsRemovingForwardAgent(_ options: [String]) -> [String] { | ||
| options.filter { option in | ||
| sshOptionKey(option) != "forwardagent" | ||
| } | ||
| } | ||
|
|
||
| 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 sshForwardAgentValue(in options: [String]) -> String? { | ||
| sshOptionValue(named: "ForwardAgent", in: options) | ||
| } | ||
|
|
||
| /// Resolves a supported `ForwardAgent` value into the local socket path to inject into the remote workspace. | ||
| private func defaultSSHAgentSocketPath(forForwardAgentValue value: String) -> String? { | ||
| let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| if trimmed.hasPrefix("$") { | ||
| let variableName = String(trimmed.dropFirst()) | ||
| return normalizedSSHAgentSocketPath(ProcessInfo.processInfo.environment[variableName]) | ||
| } | ||
| if Self.isSSHYesValue(trimmed) { | ||
| return normalizedSSHAgentSocketPath(ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]) | ||
| } | ||
| guard !Self.isSSHNoValue(trimmed) else { | ||
| return nil | ||
| } | ||
| guard isPathLikeSSHAgentSocketValue(trimmed) else { | ||
| return nil | ||
| } | ||
| return normalizedSSHAgentSocketPath(trimmed) | ||
| } | ||
|
|
||
| /// Returns whether a literal `ForwardAgent` value looks like a socket path rather than a mode such as `ask`. | ||
| private func isPathLikeSSHAgentSocketValue(_ value: String) -> Bool { | ||
| value.hasPrefix("/") || value.hasPrefix("~") | ||
| } | ||
|
|
||
| /// Normalizes a candidate SSH agent socket path while preserving the original path spelling. | ||
| private func normalizedSSHAgentSocketPath(_ value: String?) -> String? { | ||
| guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| !trimmed.isEmpty else { | ||
| return nil | ||
| } | ||
| guard trimmed.hasPrefix("~") else { |
There was a problem hiding this comment.
Duplicated SSH option helpers across compilation targets
isSSHYesValue, isSSHNoValue, isPathLikeSSHAgentSocketValue, normalizedSSHAgentSocketPath, and sshOptionKey are near-verbatim copies of the private helpers already implemented in WorkspaceRemoteSSHOptionFilter in the Sources target. Because the CLI binary is a separate compilation unit, sharing them requires a standalone SwiftPM package. Until then, any fix to the parsing semantics (e.g., the ask case in isSSHNoValue) must be applied in both places independently, which is a maintenance footgun given that both paths now influence the agent-socket that gets injected into live SSH subprocesses.
Rule Used: Flag Swift changes that add too much unrelated res... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| resolvedOptions.append("ForwardAgent=\(configuredForwardAgent)") | ||
| forwardAgentValue = configuredForwardAgent | ||
| } else { | ||
| forwardAgentValue = nil | ||
| } | ||
|
|
||
| let agentSocketPath = forwardAgentValue.flatMap(defaultSSHAgentSocketPath(forForwardAgentValue:)) | ||
| return (resolvedOptions, agentSocketPath) | ||
| } | ||
|
|
||
| private func effectiveSSHConfigForwardAgent( | ||
| destination: String, | ||
| port: Int?, | ||
| identityFile: String?, | ||
| sshOptions: [String] | ||
| ) -> String? { | ||
| #if DEBUG | ||
| if let fixture = ProcessInfo.processInfo.environment["CMUX_TEST_SSH_G_OUTPUT"] { | ||
| return sshForwardAgentValue(fromSSHConfigDump: fixture) | ||
| } | ||
| #endif | ||
|
|
||
| var arguments = ["-G"] | ||
| if let port { | ||
| arguments += ["-p", String(port)] | ||
| } | ||
| if let identityFile = normalizedSSHIdentityPath(identityFile) { | ||
| arguments += ["-i", identityFile] | ||
| } | ||
| for option in sshOptions { |
There was a problem hiding this comment.
ssh -G blocking call adds latency on every unannotated cmux ssh invocation
effectiveSSHConfigForwardAgent runs /usr/bin/ssh -G synchronously—up to a 3-second wall-clock wait—on every cmux ssh invocation where neither -A/-a nor an explicit --ssh-option ForwardAgent=… was supplied. Users with Match exec … or Include directives that spawn external processes, or machines where ssh itself is slow to start, will notice the delay on every cold invocation. When the subprocess exceeds the timeout or exits non-zero, the failure is recorded only via cliDebugLog, which is silent in release builds, so the user gets no indication that config-based forwarding was skipped.
|
Addressed the three issues identified by cubic in 3bf87e3: explicit empty ssh_auth_sock now suppresses ForwardAgent fallback, candidate agent socket paths must exist before cmux injects SSH_AUTH_SOCK, and the mocked cmux ssh CLI tests now run with a per-test temporary HOME. |
|
You're iterating quickly on this pull request. To help protect your rate limits, cubic has paused automatic reviews on new pushes for now—when you're ready for another review, comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 59bafdc. Configure here.
| override: Bool? | ||
| ) -> (sshOptions: [String], agentSocketPath: String?) { | ||
| let forwardAgentValue: String? | ||
| var resolvedOptions = sshOptions | ||
|
|
||
| if let override { | ||
| resolvedOptions = sshOptionsRemovingForwardAgent(resolvedOptions) | ||
| resolvedOptions.append("ForwardAgent=\(override ? "yes" : "no")") | ||
| forwardAgentValue = override ? "yes" : nil | ||
| } else if let explicitForwardAgent = sshForwardAgentValue(in: resolvedOptions) { | ||
| forwardAgentValue = explicitForwardAgent | ||
| } else { | ||
| forwardAgentValue = nil | ||
| } | ||
|
|
||
| let resolver = SSHAgentSocketResolver() | ||
| let explicitAgentSocketPath = forwardAgentValue | ||
| .flatMap(resolver.agentSocketPath(forForwardAgentValue:)) | ||
| .flatMap(existingSSHAgentSocketPath) | ||
| let agentSocketPath = explicitAgentSocketPath | ||
| ?? existingSSHAgentSocketPath(ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]) | ||
| return (resolvedOptions, agentSocketPath) |
There was a problem hiding this comment.
-a/--no-forward-agent does not suppress agent socket injection
When override = false (user passed -a), forwardAgentValue is set to nil, but the final assignment still falls through to existingSSHAgentSocketPath(ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]). As a result, whenever SSH_AUTH_SOCK is set in the caller's environment, agentSocketPath is non-nil even after an explicit disable flag — that path is then sent in both workspaceCreateParams["initial_env"] and configureParams["ssh_auth_sock"], so the daemon injects SSH_AUTH_SOCK into ssh subprocesses and the terminal startup environment regardless. The ForwardAgent=no option prevents native OpenSSH agent forwarding, but cmux's own workspace-level injection still delivers the socket, contradicting the documented semantics of the flag. The branch needs to return (resolvedOptions, nil) immediately when override == false.
| override: Bool? | |
| ) -> (sshOptions: [String], agentSocketPath: String?) { | |
| let forwardAgentValue: String? | |
| var resolvedOptions = sshOptions | |
| if let override { | |
| resolvedOptions = sshOptionsRemovingForwardAgent(resolvedOptions) | |
| resolvedOptions.append("ForwardAgent=\(override ? "yes" : "no")") | |
| forwardAgentValue = override ? "yes" : nil | |
| } else if let explicitForwardAgent = sshForwardAgentValue(in: resolvedOptions) { | |
| forwardAgentValue = explicitForwardAgent | |
| } else { | |
| forwardAgentValue = nil | |
| } | |
| let resolver = SSHAgentSocketResolver() | |
| let explicitAgentSocketPath = forwardAgentValue | |
| .flatMap(resolver.agentSocketPath(forForwardAgentValue:)) | |
| .flatMap(existingSSHAgentSocketPath) | |
| let agentSocketPath = explicitAgentSocketPath | |
| ?? existingSSHAgentSocketPath(ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]) | |
| return (resolvedOptions, agentSocketPath) | |
| if let override { | |
| resolvedOptions = sshOptionsRemovingForwardAgent(resolvedOptions) | |
| resolvedOptions.append("ForwardAgent=\(override ? "yes" : "no")") | |
| if !override { | |
| return (resolvedOptions, nil) | |
| } | |
| forwardAgentValue = "yes" | |
| } else if let explicitForwardAgent = sshForwardAgentValue(in: resolvedOptions) { |

Summary
Closes #5289
Testing
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Changes how local SSH credentials reach remote hosts via agent forwarding and environment injection; behavior is opt-in but misconfiguration could expose keys to remote processes while sessions are active.
Overview
cmux sshnow wires SSH agent forwarding end-to-end so remote workspaces can use the caller’s local agent when forwarding is enabled.The CLI adds
-A/--forward-agentand-a/--no-forward-agent, resolvesForwardAgentfrom flags and--ssh-option(last value wins), and centralizes OpenSSH option parsing inCmuxFoundation.SSHAgentSocketResolver. When a usable local socket exists, it passesSSH_AUTH_SOCKviaworkspace.createinitial_envandssh_auth_sockonworkspace.remote.configure.WorkspaceRemoteConfigurationgains anagentSocketPath(only if the socket file exists), injects it into app-launchedssh/scpenvironments and remote terminal startup, and re-resolves on session restore from currentSSH_AUTH_SOCKwhenForwardAgent=yesis durable. Agent forks and new-workspace flows preserve the same env. Ephemeral agent paths stay out of proxy broker / persistent PTY identity keys.Help is localized (
cli.help.ssh) and CLI / remote-daemon docs note opt-in forwarding and security implications. Broad integration tests cover precedence,ask, tilde paths, and explicit emptyssh_auth_sock.Reviewed by Cursor Bugbot for commit 121197d. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Adds SSH agent forwarding to
cmux sshso remote sessions can use your local keys. RespectsForwardAgent, forwardsSSH_AUTH_SOCKinto SSH/client processes and terminals, preserves it across forks and restore, and skips when the socket is missing orask; no longer probesssh -Gto decide forwarding.New Features
-A/--forward-agent,-a/--no-forward-agent, and-o ForwardAgent=…handling (last wins); expands~and env vars; avoidsssh -Gconfig probes for forwarding.initial_envonworkspace.createandssh_auth_sockonworkspace.remote.configure; derives fromssh_optionswhen omitted; injects into SSH, relay, and terminal startup; re-resolves on restore; preserves across remote forks; excludes ephemeral agent paths from persistent PTY identity and proxy broker transport keys.Refactors
CmuxFoundation(SSHAgentSocketResolver) and adopts it across CLI and remote configuration; exposes a public initializer for external use.Written for commit 121197d. Summary will update on new commits.
Summary by CodeRabbit
New Features
Documentation
Tests
Localization