Skip to content

Add SSH agent forwarding support for cmux ssh - #5301

Merged
austinywang merged 16 commits into
mainfrom
issue-5289-ssh-agent-forwarding
Jun 3, 2026
Merged

austinywang merged 16 commits into
mainfrom
issue-5289-ssh-agent-forwarding

Conversation

@austinywang

@austinywang austinywang commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • honor OpenSSH ForwardAgent settings for cmux ssh and add explicit -A/--forward-agent plus -a/--no-forward-agent flags
  • pass the caller's live SSH_AUTH_SOCK through first terminal startup, app-launched SSH transports, and remote workspace forks
  • document the opt-in security behavior and localize cmux ssh help

Closes #5289

Testing

  • Not run locally per task instructions; CI will run tests.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with 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 ssh now 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-agent and -a / --no-forward-agent, resolves ForwardAgent from flags and --ssh-option (last value wins), and centralizes OpenSSH option parsing in CmuxFoundation.SSHAgentSocketResolver. When a usable local socket exists, it passes SSH_AUTH_SOCK via workspace.create initial_env and ssh_auth_sock on workspace.remote.configure.

WorkspaceRemoteConfiguration gains an agentSocketPath (only if the socket file exists), injects it into app-launched ssh/scp environments and remote terminal startup, and re-resolves on session restore from current SSH_AUTH_SOCK when ForwardAgent=yes is 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 empty ssh_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 ssh so remote sessions can use your local keys. Respects ForwardAgent, forwards SSH_AUTH_SOCK into SSH/client processes and terminals, preserves it across forks and restore, and skips when the socket is missing or ask; no longer probes ssh -G to decide forwarding.

  • New Features

    • Adds -A/--forward-agent, -a/--no-forward-agent, and -o ForwardAgent=… handling (last wins); expands ~ and env vars; avoids ssh -G config probes for forwarding.
    • Propagates the agent via initial_env on workspace.create and ssh_auth_sock on workspace.remote.configure; derives from ssh_options when 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

    • Centralizes OpenSSH option parsing and agent socket normalization in 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.

Review in cubic

Summary by CodeRabbit

  • New Features

    • SSH agent forwarding for cmux ssh with -A/--forward-agent and -a/--no-forward-agent; respects CLI flags, explicit ForwardAgent ssh-options, and user SSH config precedence. When enabled, SSH_AUTH_SOCK is injected into created workspaces, remote session environments, and session snapshots/restores.
  • Documentation

    • CLI help, reference docs, and examples updated to document forwarding flags and behavior.
  • Tests

    • New integration and unit tests covering precedence, propagation, session restore, and repeated-option behavior.
  • Localization

    • Added localized CLI help strings for the ssh command.

@vercel

vercel Bot commented Jun 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 3, 2026 9:36pm
cmux-staging Building Building Preview, Comment Jun 3, 2026 9:36pm

@coderabbitai

coderabbitai Bot commented Jun 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds opt-in SSH agent forwarding to cmux ssh: CLI flags (-A/--forward-agent, -a/--no-forward-agent), resolution of ForwardAgent (CLI override, explicit sshOptions, or ssh -G), derivation of agent socket path, and threading of SSH_AUTH_SOCK into workspace create initial_env, remote config, terminal startup env, and ssh/scp subprocess environments. Tests and docs/localization updated.

Changes

SSH Agent Forwarding for cmux ssh

Layer / File(s) Summary
CLI entry point: ForwardAgent flag parsing and resolution
CLI/cmux.swift
CLI parsing recognizes -A/--forward-agent and -a/--no-forward-agent. New resolver computes effective ForwardAgent from CLI override, explicit sshOptions, or ssh -G output and derives agentSocketPath; help/usage text updated.
Workspace create & remote configure threading
CLI/cmux.swift
When agentSocketPath is resolved, workspace.create initial_env receives SSH_AUTH_SOCK and remote workspace.remote.configure parameters receive ssh_auth_sock.
WorkspaceRemoteConfiguration: agent socket and env helpers
Sources/WorkspaceRemoteConfiguration.swift
Adds agentSocketPath (normalized), derives agent socket from ForwardAgent options, includes it in transport identity, and exposes sshTerminalStartupEnvironment and sshProcessEnvironment dictionaries used by launches and snapshots.
Workspace.swift terminal env helper and SSH process injection
Sources/Workspace.swift
Adds terminalStartupEnvironment(base:remoteStartupCommand:), computes effectiveStartupEnvironment and initialTerminalEnvironment, injects configuration.sshProcessEnvironment into /usr/bin/ssh and /usr/bin/scp processes, and passes startup env into terminal panel configuration and snapshot/restore paths.
TerminalController: accept and relay ssh_auth_sock
Sources/TerminalController.swift
TerminalController parses ssh_auth_sock into agentSocketPath, includes an sshAuthSock indicator in generated arguments, and threads the optional socket path into the connection payload.
ContentView fork path integration
Sources/ContentView+ForkAgentConversation.swift
forkFocusedAgentConversation now passes launch.initialTerminalEnvironment into tabManager.addWorkspace(...) for the .newWorkspace path.
Integration and unit tests
cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift, cmuxTests/WorkspaceUnitTests.swift, cmuxTests/TabManagerSessionSnapshotTests.swift, cmuxTests/TerminalControllerSocketSecurityTests.swift, cmuxTests/GhosttyConfigTests.swift
Adds integration/regression tests for agent-forwarding precedence and socket propagation, extends runMockedSSH to accept environmentOverrides, and updates tests to assert agentSocketPath and SSH_AUTH_SOCK are propagated and restored or omitted as appropriate.
Documentation and localization
Resources/Localizable.xcstrings, docs/cli-contract.md, docs/remote-daemon-spec.md
Adds localized cli.help.ssh (en/ja), reformats localization blocks, updates CLI help and synopsis to include forwarding flags, and documents opt-in forwarding behavior in CLI contract and daemon spec (updated last-modified date).

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hop with keys across the wire,

Flags and sockets spark the night,
Forwarded agent, swift and bright,
Remote shells now hold my light,
A tiny rabbit cheers tonight!

🚥 Pre-merge checks | ✅ 17 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding SSH agent forwarding support for the cmux ssh command.
Linked Issues check ✅ Passed All coding objectives from #5289 are met: honor OpenSSH ForwardAgent precedence, provide -A/-a CLI flags, propagate SSH_AUTH_SOCK through workspaces/environments, and maintain opt-in security behavior.
Out of Scope Changes check ✅ Passed All changes are directly aligned with SSH agent forwarding support; no unrelated modifications to other features or systems were introduced.
Cmux Swift Actor Isolation ✅ Passed PR adds immutable value types and computed properties safely accessing ProcessInfo.processInfo.environment, with all usages within @MainActor-isolated Workspace context.
Cmux Swift Blocking Runtime ✅ Passed No blocking/timing-based synchronization patterns (semaphores, waits, sleeps, locks, main-queue sync) were introduced in production code. New code is purely synchronous and deterministic.
Cmux No Hacky Sleeps ✅ Passed PR modifies only Swift code and resources; no TypeScript/JavaScript/shell runtime scripts changed. Custom check scope only covers those runtime language types.
Cmux Algorithmic Complexity ✅ Passed All SSH agent forwarding code follows the algorithmic complexity rules. SSH options are bounded (< 20 items), linear scans are not nested, and the computed property is not called in loops.
Cmux Swift Concurrency ✅ Passed No new legacy async patterns introduced; SSH agent forwarding uses synchronous property injection without DispatchQueue.global, fire-and-forget Tasks, or Combine state.
Cmux Swift @Concurrent ✅ Passed No new async functions or @concurrent annotations in SSH agent forwarding files. Changes are synchronous utilities. confirmClosePanel properly marked @MainActor.
Cmux Swift File And Package Boundaries ✅ Passed Additions under 250-line threshold for oversized files: CLI +195, Workspace +44, TerminalController +7. WorkspaceRemoteConfiguration 718 lines, single responsibility. No package violations.
Cmux Swift Logging ✅ Passed No new problematic logging (print/debugPrint/dump/NSLog) was added to production Swift code. All agent forwarding implementation is free of logging violations.
Cmux User-Facing Error Privacy ✅ Passed PR adds SSH agent forwarding with generic OpenSSH help text. No user-facing errors, alerts, or recovery copy expose environment variable names, credentials, tokens, or provider details.
Cmux Full Internationalization ✅ Passed SSH help text uses String(localized:defaultValue:); cli.help.ssh has complete translations for all 20 catalog locales; docs/test changes are exempt per i18n rules.
Cmux Swiftui State Layout ✅ Passed PR introduces no new @Observable/@Published/@StateObject state declarations, GeometryReader measurements, lazy-list store references, or render-time state mutations; only touches existing model code.
Cmux Architecture Rethink ✅ Passed PR makes pure data-flow changes to propagate SSH agent socket path with no new timing, locks, observers, or split lifecycle ownership; adds only immutable property and read-only computed properties.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed SSH agent PR does not add or change user-visible NSWindow, NSPanel, NSWindowController, SwiftUI Window/WindowGroup; only threads SSH_AUTH_SOCK through terminal infrastructure and test fixtures.
Description check ✅ Passed PR description includes summary and reasoning; testing section notes CI will run tests; missing demo video and resolution checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-5289-ssh-agent-forwarding

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 81e409c and 9c386f7.

📒 Files selected for processing (9)
  • CLI/cmux.swift
  • Resources/Localizable.xcstrings
  • Sources/ContentView+ForkAgentConversation.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceRemoteConfiguration.swift
  • cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
  • docs/cli-contract.md
  • docs/remote-daemon-spec.md

Comment thread CLI/cmux.swift Outdated
Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated
Comment thread Sources/WorkspaceRemoteConfiguration.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated
Comment thread CLI/cmux.swift Outdated
Comment thread Sources/TerminalController.swift Outdated
Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 921aab7 and e156be2.

📒 Files selected for processing (5)
  • CLI/cmux.swift
  • Resources/Localizable.xcstrings
  • Sources/WorkspaceRemoteConfiguration.swift
  • cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
  • cmuxTests/TabManagerSessionSnapshotTests.swift

Comment thread CLI/cmux.swift Outdated
Comment thread cmuxTests/TabManagerSessionSnapshotTests.swift
Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated
Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated
Comment thread CLI/cmux.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between e156be2 and fddf977.

📒 Files selected for processing (6)
  • CLI/cmux.swift
  • Sources/TerminalController.swift
  • Sources/WorkspaceRemoteConfiguration.swift
  • cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
  • cmuxTests/TabManagerSessionSnapshotTests.swift
  • cmuxTests/TerminalControllerSocketSecurityTests.swift

Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread Sources/TerminalController.swift
Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated
Comment thread cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
@greptile-apps

greptile-apps Bot commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds SSH agent forwarding to cmux ssh via new -A/--forward-agent and -a/--no-forward-agent flags, propagating the caller's SSH_AUTH_SOCK into workspace creation, remote configuration, ssh subprocesses, and terminal startup. It also centralizes OpenSSH option parsing into a new SSHAgentSocketResolver type in CmuxFoundation, ships fully-translated help text for all 19 supported locales, and re-resolves the agent socket on session restore and fork.

  • SSHAgentSocketResolver correctly handles yes/no/path/$VAR ForwardAgent values and is well-tested.
  • resolvedSSHAgentForwarding in CLI/cmux.swift has a logic gap: when override = false (-a), forwardAgentValue is set to nil but the final agentSocketPath assignment still falls through to existingSSHAgentSocketPath(ProcessInfo.processInfo.environment[\"SSH_AUTH_SOCK\"]), injecting the socket into the workspace despite the explicit disable flag.
  • All 19 locales have matching translations for the new cli.help.ssh key.

Confidence Score: 4/5

The 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

Filename Overview
CLI/cmux.swift Adds -A/-a flags and resolvedSSHAgentForwarding; the no-forward-agent branch fails to suppress SSH_AUTH_SOCK injection because the fallback executes unconditionally when override=false.
Packages/CmuxFoundation/Sources/CmuxFoundation/SSHAgentSocketResolver.swift New SSHAgentSocketResolver struct centralizes SSH option parsing and agent socket normalization; well-structured and correctly handles yes/no/path/variable ForwardAgent values.
Sources/WorkspaceRemoteConfiguration.swift Adds agentSocketPath field, resolvedAgentSocketPath static, sshProcessEnvironment, and sshTerminalStartupEnvironment; logic is correct for the session-restore and daemon-configure paths.
Sources/Workspace.swift Injects SSH_AUTH_SOCK into ssh subprocesses and terminal startup environments via sshProcessEnvironment/sshTerminalStartupEnvironment; propagates agentSocketPath through forked workspaces.
Resources/Localizable.xcstrings Adds fully translated cli.help.ssh key across all 19 supported locales; all entries are present and consistent.

Sequence Diagram

sequenceDiagram
    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)"
Loading

Reviews (3): Last reviewed commit: "Fix SSH resolver public initializer" | Re-trigger Greptile

Comment thread CLI/cmux.swift Outdated
Comment on lines +7854 to +7980
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 {

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.

P2 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!

Comment thread CLI/cmux.swift Outdated
Comment on lines +7871 to +7900
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 {

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.

P2 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.

@austinywang

Copy link
Copy Markdown
Contributor Author

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.

@cubic-dev-ai

cubic-dev-ai Bot commented Jun 3, 2026

Copy link
Copy Markdown

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 @cubic-dev-ai review.

Comment thread Sources/WorkspaceRemoteConfiguration.swift Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift
Comment on lines +7849 to +7870
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)

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.

P1 -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.

Suggested change
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) {

This branch was successfully deployed

1 active deployment
Preview – cmux — 121197d3 Deployed Jun 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSH agent forwarding support

1 participant