Skip to content

Fix SSH relay failure when config has LocalForward/DynamicForward directives#2635

Open
bIackr0se wants to merge 3 commits intomanaflow-ai:mainfrom
bIackr0se:fix/relay-ssh-forward-conflict
Open

Fix SSH relay failure when config has LocalForward/DynamicForward directives#2635
bIackr0se wants to merge 3 commits intomanaflow-ai:mainfrom
bIackr0se:fix/relay-ssh-forward-conflict

Conversation

@bIackr0se
Copy link
Copy Markdown

@bIackr0se bIackr0se commented Apr 6, 2026

Summary

Fixes #2596 - cmux ssh relay fails when SSH config has LocalForward, DynamicForward, or RemoteForward directives.

Problem

The reverse relay SSH fallback (used when ControlMaster dynamic forwarding is unavailable) spawns a standalone connection with -S none. This connection reads ~/.ssh/config and attempts to establish all configured forwards, which conflict with ports already bound by the primary SSH connection. With ExitOnForwardFailure=yes, the relay dies before its own RemoteForward can be established.

Additionally, backgroundSSHOptions() filters controlmaster and controlpersist but not controlpath. This causes -o ControlPath=... to appear after -S none in the relay command, which on macOS can re-enable mux attachment despite the explicit -S none.

Fix

Primary: Bypass SSH config for standalone relay

Use -F /dev/null to prevent the relay SSH process from reading ~/.ssh/config entirely. Transport-critical options are resolved via ssh -G and passed explicitly:

  • hostname, user, port - connection target
  • proxyjump, proxycommand - multi-hop routing
  • identityfile, identitiesonly, certificatefile, identityagent - authentication
  • hostkeyalgorithms, kexalgorithms, ciphers, macs - crypto negotiation
  • hostkeyalias, userknownhostsfile, globalknownhostsfile - host key verification
  • pubkeyacceptedalgorithms, preferredauthentications - auth methods
  • stricthostkeychecking - host key policy (see security note below)

ProxyJump hosts are also resolved via ssh -G so that SSH config Host aliases work even though -F /dev/null is propagated to jump host SSH processes. IPv6 literals are correctly bracketed when a non-default port is present (user@[2001:db8::1]:2222).

Graceful fallback: if ssh -G fails, falls back to using only the explicitly configured port and identity file (same behavior as before, minus the config-file forwards).

Secondary: Filter controlpath from background SSH options

Added controlpath to the batchSSHControlOptionKeys filter set in both the static (WorkspaceRemoteSSHBatchCommandBuilder) and instance (backgroundSSHOptions) methods. This prevents stale ControlPath options from leaking into standalone relay commands.

Security note: StrictHostKeyChecking policy propagation

The previous code hard-coded -o StrictHostKeyChecking=accept-new for all background SSH operations. The sshCommonArguments guard (if !hasSSHOptionKey(...)) only checked configuration.sshOptions (explicit --ssh-option CLI flags), not the full effective SSH config from ~/.ssh/config.

This means users who set StrictHostKeyChecking yes in their SSH config (a deliberate security hardening to reject unknown host keys) had that policy silently downgraded to accept-new (TOFU) for the relay connection. On first connection to a new host, this creates a window for MITM key substitution that the user explicitly opted out of.

This PR resolves the issue by reading StrictHostKeyChecking from the ssh -G effective config output and propagating the users configured policy to the relay. accept-new is only applied as a default when no policy is configured, which matches user expectation for automated background connections.

Testing

  • Swift syntax verification passes (swiftc -parse)
  • Existing orphan cleanup tests unaffected (match on -N/-R patterns which are preserved)
  • The ControlMaster path (preferred) is unchanged - this fix only affects the standalone fallback

Reproduction

See #2596 for detailed reproduction steps. The fix eliminates all three failure modes:

  1. Config-file LocalForward/DynamicForward no longer conflict with bound ports
  2. Config-file RemoteForward no longer duplicates existing forwards
  3. Leaked ControlPath option no longer re-enables mux attachment after -S none

Summary by CodeRabbit

  • Bug Fixes
    • Fixed SSH relay configuration conflicts that interfere with port forwarding operations.
    • Improved handling of complex SSH proxy jump chains.
    • Enhanced relay connection stability with better timeout and keepalive defaults.

…ectives

The reverse relay SSH fallback (used when ControlMaster dynamic forwarding
is unavailable) spawns a standalone SSH connection with -S none. This
connection reads ~/.ssh/config and attempts to establish ALL configured
forwards (LocalForward, DynamicForward, RemoteForward), which conflict
with ports already bound by the primary SSH connection. With
ExitOnForwardFailure=yes, the relay process exits before the
RemoteForward can be established.

Fix: Use -F /dev/null to bypass ~/.ssh/config entirely for the standalone
relay connection. Transport-critical options (ProxyJump, ProxyCommand,
IdentityFile, HostKeyAlgorithms, KexAlgorithms, Ciphers, etc.) are
resolved via ssh -G and passed explicitly, preserving connectivity
without config-file forwards.

ProxyJump hosts are also resolved via ssh -G so that Host aliases work
even though -F /dev/null is propagated to jump host SSH processes.

Additionally, add controlpath to the backgroundSSHOptions filter (both
static and instance versions). Previously, -o ControlPath=... could
appear after -S none in the relay command, which on macOS can re-enable
mux attachment despite the explicit -S none.

Fixes manaflow-ai#2596
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 6, 2026

@bIackr0se is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai bot commented Apr 6, 2026

This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

Added -F /dev/null to the standalone reverse-relay SSH invocation and implemented a probe-resolve flow using ssh -G (5s timeout) to extract transport-critical options (e.g., ProxyJump, IdentityFile, hostkey/KEX/cipher options) and resolve ProxyJump hops; falls back to explicit options on probe failure.

Changes

Cohort / File(s) Summary
SSH Relay Transport Resolution
Sources/Workspace.swift
Relay command now includes -F /dev/null. Adds ssh -G probing (5s timeout) with parsing of non-forwarding transport keys into explicit relay args, hop-by-hop ProxyJump resolution, fallback to explicit port/identity options, stricter StrictHostKeyChecking handling, and added transport keepalive/batch defaults. Also expands background SSH option filtering to exclude controlpath.
Documentation
docs/remote-daemon-spec.md
Updated spec to document -F /dev/null usage and that transport options are derived via ssh -G and passed explicitly to the relay. Minor wording changes only.

Sequence Diagram

sequenceDiagram
    participant Controller as Workspace Controller
    participant SSHProbe as ssh -G Probe
    participant SSHRelay as Reverse Relay SSH
    participant RemoteHost as Remote Host

    Controller->>SSHProbe: Run `ssh -G <host>` (5s timeout) and per ProxyJump hop
    alt Probe succeeds
        SSHProbe-->>Controller: Parsed transport options (ProxyJump, IdentityFile, HostKey/KEX/Cipher, StrictHostKeyChecking...)
        Controller->>Controller: Build explicit relay args, resolve jump chain hop-by-hop
    else Probe fails
        SSHProbe-->>Controller: Error/timeout
        Controller->>Controller: Fall back to explicit port/identity options only
    end
    Controller->>SSHRelay: Spawn `ssh -F /dev/null -S none` + resolved args + `-R` reverse forward
    SSHRelay->>RemoteHost: Establish standalone reverse connection (no user config forwards)
    RemoteHost-->>SSHRelay: Reverse forward established
    SSHRelay-->>Controller: Relay ready
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I sniffed the config, found clutter and null,
-F /dev/null cleared the path for the pull.
I hopped each jump, unwrapped every key,
Now relays bind true and daemons chat with glee,
Hop, hop — sockets hum, remote cmux is full! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: fixing SSH relay failure caused by SSH config directives (LocalForward/DynamicForward).
Description check ✅ Passed The description includes all required sections: Summary (with problem explanation and fix details), Testing (verification approach), and a detailed explanation of the changes and security implications.
Linked Issues check ✅ Passed The PR fully addresses all objectives from #2596: prevents config-file forward conflicts using -F /dev/null, resolves transport options via ssh -G, maintains ProxyJump alias support, filters controlpath from relay options, and preserves ControlMaster path unchanged.
Out of Scope Changes check ✅ Passed All changes in Workspace.swift and docs/remote-daemon-spec.md are directly scoped to fixing the relay SSH failure issue, with no unrelated modifications detected.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 6, 2026

Greptile Summary

This PR fixes SSH relay failures caused by config-file forwarding directives (LocalForward/DynamicForward/RemoteForward) conflicting with ports bound by the primary connection. It bypasses ~/.ssh/config entirely with -F /dev/null and resolves transport-critical options (ProxyJump chain, IdentityFile, crypto algorithms, known-hosts paths) via ssh -G before launching the standalone relay.

  • P1 – Port silently dropped: parseRelayTransportOptions skips the port field when configuration.port is non-nil, but neither resolvedRelayTransportOptions nor reverseRelayArguments re-adds it. Every user with a non-default SSH port whose ssh -G succeeds will get a relay command that defaults to port 22 and fails to connect. The fallback path (explicitRelayTransportOptions) is correct; only the primary path is affected.

Confidence Score: 4/5

Do not merge until the port-dropping regression is fixed; the relay silently connects on port 22 for all users with a non-default port when ssh -G succeeds.

One clear P1 regression in parseRelayTransportOptions: the configured port is skipped but never re-added, breaking any non-default-port setup in the happy path. The rest of the implementation — -F /dev/null bypass, ProxyJump resolution, controlpath filter — is well-structured and directly addresses the reported failure modes.

Sources/Workspace.swift — specifically the port case in parseRelayTransportOptions (line 4101) and the overall resolvedRelayTransportOptions return path.

Important Files Changed

Filename Overview
Sources/Workspace.swift Adds -F /dev/null + ssh -G relay transport resolution; port is silently dropped in the primary path when configuration.port is non-nil
docs/remote-daemon-spec.md Updates spec bullet to document -F /dev/null and ssh -G approach for the standalone relay transport — accurate and complete

Sequence Diagram

sequenceDiagram
    participant App as cmux App
    participant SSHg as ssh -G (probe)
    participant Parse as parseRelayTransportOptions
    participant Relay as ssh Relay Process

    App->>SSHg: ssh -G [-p port] [-i key] destination
    SSHg-->>App: hostname, user, port, proxyjump,<br/>identityfile, hostkeyalgorithms, ...
    App->>Parse: parse ssh -G stdout
    Parse-->>App: [-o Hostname=...] [-J resolved] [-i ...]<br/>[-o HostKeyAlgorithms=...]
    Note over Parse,App: ⚠️ port case skipped when<br/>configuration.port != nil<br/>but never re-added elsewhere
    App->>Relay: ssh -N -T -S none -F /dev/null<br/><resolved options> -o ExitOnForwardFailure=yes<br/>-R 127.0.0.1:PORT:127.0.0.1:LOCAL destination
    Relay-->>App: reverse forward established (or fails on port 22)
Loading

Reviews (1): Last reviewed commit: "Fix SSH relay failure when config has Lo..." | Re-trigger Greptile

Comment on lines +4101 to +4105
case "port":
// Only add if not already set via configuration.port
if configuration.port == nil, let portNum = Int(value), portNum != 22 {
args += ["-p", value]
}
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 Port silently dropped in the happy path when configuration.port is set

The comment says the port is "already set via configuration.port," but it never is — neither resolvedRelayTransportOptions nor reverseRelayArguments re-adds it. Any user with a non-default port (e.g. port 2222) whose ssh -G succeeds will get a relay command that omits -p entirely and defaults to port 22, causing the relay connection to fail. The fallback path (explicitRelayTransportOptions) correctly includes the port, so only the happy path is broken.

Suggested change
case "port":
// Only add if not already set via configuration.port
if configuration.port == nil, let portNum = Int(value), portNum != 22 {
args += ["-p", value]
}
case "port":
// ssh -G reports the effective port. Prefer the explicitly configured port
// (it was already passed to the ssh -G probe), then fall back to the
// resolved value if it differs from the SSH default.
let effectivePort = configuration.port.map(String.init) ?? value
if let portNum = Int(effectivePort), portNum != 22 {
args += ["-p", effectivePort]
}

"-o", "ConnectTimeout=6",
"-o", "ServerAliveInterval=20",
"-o", "ServerAliveCountMax=2",
"-o", "StrictHostKeyChecking=accept-new",
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 StrictHostKeyChecking=accept-new is a silent TOFU downgrade

With -F /dev/null the relay falls back to system known-hosts files (or those resolved from ssh -G). For a host that has never been seen before, accept-new auto-trusts the key without any warning. The user's original config may have had StrictHostKeyChecking=yes or ask; this override silently weakens it. Consider propagating the user's StrictHostKeyChecking value from the ssh -G output (it's not in relayTransportKeys), or leaving StrictHostKeyChecking unset so the resolved userknownhostsfile + system policy apply naturally.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
Sources/Workspace.swift (1)

1073-1077: Consider sharing the background SSH control-option denylist.

controlpath now has to stay in sync in both WorkspaceRemoteSSHBatchCommandBuilder and WorkspaceRemoteSessionController. Pulling that set into one shared helper/constant would make the next mux-option fix much harder to miss.

Also applies to: 4288-4297

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

In `@Sources/Workspace.swift` around lines 1073 - 1077, The denylist set
batchSSHControlOptionKeys is duplicated across
WorkspaceRemoteSSHBatchCommandBuilder and WorkspaceRemoteSessionController;
extract this Set<String> into a single shared constant or helper (e.g., a
private static let in a new SSHConstants or Workspace+SSH extension) and replace
both usages to reference that single symbol so controlpath (and other control
options) stay in sync across both builders/controllers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 4112-4117: The current ProxyJump reconstruction (called from the
"proxyjump" case using resolveProxyJumpChain) drops per-hop SSH config because
the parser only extracts hostname, user and port (and uses a default: break),
and it also fails to bracket IPv6 literals when a non-default port is present;
update resolveProxyJumpChain (and the parsing logic that iterates ssh -G output)
to preserve all per-host fields (e.g., IdentityFile, CertificateFile,
ProxyCommand, UserKnownHostsFile, HostKeyAlias, HostKeyAlgorithms, etc.) when
rebuilding each hop instead of discarding unknown keys, and when formatting a
hop include IPv6 brackets around the host part whenever the host contains ":"
and a non-default port is appended (format user@[ipv6]:port); ensure the rebuilt
jump spec passes through hop-specific options so each spawned jump process
receives its per-hop config.
- Around line 4019-4026: The relayTransportKeys whitelist used by
parseRelayTransportOptions must include the missing keys so user SSH config
values pass through: add "stricthostkeychecking", "certificatefile", and
"identityagent" to the relayTransportKeys set/array; update
parseRelayTransportOptions/resolvedRelayTransportOptions usage so these keys are
preserved when filtering ssh -G output. Change the hard-coded "-o
StrictHostKeyChecking=accept-new" in resolvedRelayTransportOptions (or where
args is built) to only append that option if no StrictHostKeyChecking value was
obtained from parseRelayTransportOptions/ssh -G. Also broaden
explicitRelayTransportOptions fallback to consider and return CertificateFile
and IdentityAgent when ssh -G fails so those options are recovered.

---

Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 1073-1077: The denylist set batchSSHControlOptionKeys is
duplicated across WorkspaceRemoteSSHBatchCommandBuilder and
WorkspaceRemoteSessionController; extract this Set<String> into a single shared
constant or helper (e.g., a private static let in a new SSHConstants or
Workspace+SSH extension) and replace both usages to reference that single symbol
so controlpath (and other control options) stay in sync across both
builders/controllers.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98db35f5-8dfb-47ef-9550-5d94b5e33da3

📥 Commits

Reviewing files that changed from the base of the PR and between 179b16c and c62c6d9.

📒 Files selected for processing (2)
  • Sources/Workspace.swift
  • docs/remote-daemon-spec.md

Fix P1 port regression: when configuration.port is set, the ssh -G
resolved port was skipped but never re-added. Now uses configuration.port
as the effective port with ssh -G value as fallback.

Fix P2 StrictHostKeyChecking TOFU downgrade: no longer hard-codes
accept-new when the user's SSH config has an explicit policy. The
resolved value from ssh -G is propagated; accept-new is only applied
as a default when no policy was configured.

Fix IPv6 ProxyJump formatting: bracket IPv6 literals when a non-default
port is appended (user@[2001:db8::1]:2222 instead of the malformed
user@2001:db8::1:2222).

Add missing transport keys: certificatefile, identityagent, and
stricthostkeychecking are now included in the relayTransportKeys
whitelist so they pass through from ssh -G output.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
Sources/Workspace.swift (2)

4036-4039: ⚠️ Potential issue | 🟠 Major

Keep explicit StrictHostKeyChecking on the ssh -G fallback path.

When the probe fails, the fallback only carries port/identity and hard-codes hasStrictHostKeyChecking: false, so an explicit StrictHostKeyChecking=yes|no in configuration.sshOptions gets replaced by accept-new. That silently changes the user's host-key policy on the very path that's supposed to be the conservative fallback.

Also applies to: 4066-4073, 4211-4220

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

In `@Sources/Workspace.swift` around lines 4036 - 4039, The fallback path
currently unconditionally injects "-o StrictHostKeyChecking=accept-new" when
resolved.hasStrictHostKeyChecking is false, which overwrites an explicit user
value from configuration.sshOptions; change the check so you only append the
default if StrictHostKeyChecking was neither resolved nor explicitly provided by
the user (i.e., `if !resolved.hasStrictHostKeyChecking &&
!configuration.sshOptions.containsKey("StrictHostKeyChecking")` or equivalent),
preserving any explicit "yes" or "no" the user set; apply the same fix to the
other fallback sites that use resolved.hasStrictHostKeyChecking (the blocks
around the other two occurrences).

4166-4203: ⚠️ Potential issue | 🟠 Major

ProxyJump hop-specific transport config is still being dropped.

resolveProxyJumpChain(_:) collapses each hop to user@host:port, so the newly whitelisted auth/host-key options only apply to the final destination. If OpenSSH propagates the outer -F /dev/null into the implicit jump SSH processes, hop-specific IdentityFile, CertificateFile, HostKeyAlias, UserKnownHostsFile, ProxyCommand, etc. still vanish here, and bastion chains that rely on per-hop config will keep failing.

In OpenSSH, when the outer command uses `ssh -F /dev/null -J hop1,hop2 target`, do the implicit jump SSH processes also ignore `~/.ssh/config`, and can hop-specific settings like `IdentityFile`, `CertificateFile`, `HostKeyAlias`, or `UserKnownHostsFile` be represented in the `-J` string itself?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 4166 - 4203, resolveProxyJumpChain(_:)
currently collapses each hop to user@host:port after running ssh -G, which drops
hop-specific transport options; change the logic in the hops.map closure (the
block that calls sshExec(arguments: ["-G", hop], timeout: 5) and parses
result.stdout) to detect any presence of per-hop config keys (e.g.
"identityfile", "certificatefile", "hostkeyalias", "userknownhostsfile",
"proxycommand", "localforward", etc.) in the ssh -G output and if any are
present, do not replace the original hop string (return hop) so per-hop config
is preserved; only perform the collapsing to hostname/user/port when ssh -G
yields hostname/user/port and none of those hop-specific keys are present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 1073-1077: The current batchSSHControlOptionKeys set includes
"controlpath", causing batch-mode helpers (batchSSHControlOptionKeys) to strip
ControlPath and breaking reverseRelayControlMasterArguments and related batch
SSH/SCP helpers that need ControlPath to reuse the existing control socket;
remove "controlpath" from the batchSSHControlOptionKeys set and, if the relay
case still requires stripping ControlPath for the standalone relay (-S none),
create a separate relay-only filter (e.g., relaySSHControlOptionKeys) used only
by the relay code path so reverseRelayControlMasterArguments and batch helpers
keep ControlPath intact.

---

Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 4036-4039: The fallback path currently unconditionally injects "-o
StrictHostKeyChecking=accept-new" when resolved.hasStrictHostKeyChecking is
false, which overwrites an explicit user value from configuration.sshOptions;
change the check so you only append the default if StrictHostKeyChecking was
neither resolved nor explicitly provided by the user (i.e., `if
!resolved.hasStrictHostKeyChecking &&
!configuration.sshOptions.containsKey("StrictHostKeyChecking")` or equivalent),
preserving any explicit "yes" or "no" the user set; apply the same fix to the
other fallback sites that use resolved.hasStrictHostKeyChecking (the blocks
around the other two occurrences).
- Around line 4166-4203: resolveProxyJumpChain(_:) currently collapses each hop
to user@host:port after running ssh -G, which drops hop-specific transport
options; change the logic in the hops.map closure (the block that calls
sshExec(arguments: ["-G", hop], timeout: 5) and parses result.stdout) to detect
any presence of per-hop config keys (e.g. "identityfile", "certificatefile",
"hostkeyalias", "userknownhostsfile", "proxycommand", "localforward", etc.) in
the ssh -G output and if any are present, do not replace the original hop string
(return hop) so per-hop config is preserved; only perform the collapsing to
hostname/user/port when ssh -G yields hostname/user/port and none of those
hop-specific keys are present.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: defe3653-136a-4d36-bdd9-8a3413675f1b

📥 Commits

Reviewing files that changed from the base of the PR and between c62c6d9 and 886b0ca.

📒 Files selected for processing (1)
  • Sources/Workspace.swift

…tKeyChecking fallback

Revert controlpath from batchSSHControlOptionKeys (both static and
instance). Adding it there broke the preferred ControlMaster path and
batch SSH/SCP helpers that need ControlPath to attach to the existing
control socket. The original secondary bug (ControlPath leaking into
the standalone relay command) is now moot since the relay uses
-F /dev/null and builds args from ssh -G output, not sshCommonArguments.

Harden StrictHostKeyChecking fallback: when ssh -G fails and we fall
back to explicit options, also check configuration.sshOptions for an
explicit StrictHostKeyChecking value before defaulting to accept-new.
This prevents silently downgrading a user's host-key policy on the
conservative fallback path.

Document ProxyJump per-hop config limitation: the -J string format
cannot carry hop-specific IdentityFile, CertificateFile, or HostKeyAlias.
The preferred ControlMaster path does not have this limitation. The SSH
agent handles authentication for most cases.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
Sources/Workspace.swift (1)

4035-4039: ⚠️ Potential issue | 🟠 Major

Keep relay-safe explicit SSH options in the ssh -G fallback.

When ssh -G fails, explicitRelayTransportOptions() only replays -p/-i. That drops explicit CLI options like User, StrictHostKeyChecking, ProxyJump/ProxyCommand, HostKeyAlias, IdentitiesOnly, and known-hosts overrides, so this fallback no longer preserves the old standalone-relay behavior. The check at Lines 4037-4039 only suppresses accept-new; it still loses the user's actual host-key policy.

Suggested fix
     private func explicitRelayTransportOptions() -> [String] {
         var args: [String] = []
         if let port = configuration.port {
             args += ["-p", String(port)]
         }
         if let identityFile = configuration.identityFile,
            !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
             args += ["-i", identityFile]
         }
+        let relayDisallowedOptionKeys: Set<String> = [
+            "controlmaster",
+            "controlpersist",
+            "controlpath",
+        ]
+        for option in normalizedSSHOptions(configuration.sshOptions) {
+            guard let key = sshOptionKey(option),
+                  !relayDisallowedOptionKeys.contains(key) else { continue }
+            args += ["-o", option]
+        }
         return args
     }

Also applies to: 4067-4074, 4219-4231

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

In `@Sources/Workspace.swift` around lines 4035 - 4039,
explicitRelayTransportOptions() currently only replays -p/-i when ssh -G fails,
losing user-provided SSH options like User, StrictHostKeyChecking,
ProxyJump/ProxyCommand, HostKeyAlias, IdentitiesOnly and known-hosts overrides;
update that function so when resolved.* flags (e.g.,
resolved.hasStrictHostKeyChecking) do not indicate a resolved value, you scan
configuration.sshOptions (using hasSSHOptionKey and the same sshOptions parsing
helpers) and append "-o", "Key=Value" entries for any of these relay-safe keys
(User, StrictHostKeyChecking with actual user value, ProxyJump, ProxyCommand,
HostKeyAlias, IdentitiesOnly, UserKnownHostsFile/GlobalKnownHostsFile, etc.) to
args, and only add the default StrictHostKeyChecking=accept-new if neither
resolved nor configuration.sshOptions supplies it; apply the same fix to the
other analogous code paths (the blocks around explicitRelayTransportOptions
usage mentioned in the comment).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 4133-4138: The fast-path in resolveProxyJumpChain is incorrectly
skipping hops that look like user@host:port even when host is an SSH config
alias; update resolveProxyJumpChain (the code that currently returns hop
unchanged for matches) to parse each hop into user, host, and port, then only
skip resolution when the host is a literal IP (match IPv4 like
\d+\.\d+\.\d+\.\d+ or bracketed IPv6 like [::1]); otherwise pass the extracted
host through the existing SSH-config resolution logic and rebuild the hop
(user@resolvedHost:port) before returning/adding to args (see the "proxyjump"
case and args += ["-J", resolved] for where this resolved value is used).

---

Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 4035-4039: explicitRelayTransportOptions() currently only replays
-p/-i when ssh -G fails, losing user-provided SSH options like User,
StrictHostKeyChecking, ProxyJump/ProxyCommand, HostKeyAlias, IdentitiesOnly and
known-hosts overrides; update that function so when resolved.* flags (e.g.,
resolved.hasStrictHostKeyChecking) do not indicate a resolved value, you scan
configuration.sshOptions (using hasSSHOptionKey and the same sshOptions parsing
helpers) and append "-o", "Key=Value" entries for any of these relay-safe keys
(User, StrictHostKeyChecking with actual user value, ProxyJump, ProxyCommand,
HostKeyAlias, IdentitiesOnly, UserKnownHostsFile/GlobalKnownHostsFile, etc.) to
args, and only add the default StrictHostKeyChecking=accept-new if neither
resolved nor configuration.sshOptions supplies it; apply the same fix to the
other analogous code paths (the blocks around explicitRelayTransportOptions
usage mentioned in the comment).
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 433b5f7d-4309-4a40-ab1e-7485424a9767

📥 Commits

Reviewing files that changed from the base of the PR and between 886b0ca and 2d0298b.

📒 Files selected for processing (1)
  • Sources/Workspace.swift

Comment on lines +4133 to +4138
case "proxyjump":
if value.lowercased() != "none" {
// Resolve the ProxyJump chain so jump hosts don't need config-file
// resolution (since -F /dev/null is propagated to jump connections).
let resolved = resolveProxyJumpChain(value)
args += ["-J", resolved]
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:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT

cat >"$tmp" <<'EOF'
Host jumpalias
  HostName bastion.example.com

Host target
  ProxyJump bob@jumpalias:2222
EOF

echo 'proxyjump emitted for target:'
ssh -F "$tmp" -G target | awk '/^proxyjump /{print}'

echo
echo 'resolved jumpalias host:'
ssh -F "$tmp" -G jumpalias | awk '/^(hostname|user|port) /{print}'

Repository: manaflow-ai/cmux

Length of output: 355


🏁 Script executed:

grep -n "resolveProxyJumpChain" Sources/Workspace.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 201


🏁 Script executed:

sed -n '4100,4200p' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 5003


🏁 Script executed:

ast-grep --pattern 'func resolveProxyJumpChain($$$) {
  $$$
}'

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

sed -n '4171,4220p' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 2262


🏁 Script executed:

sed -n '4176,4180p' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 392


🏁 Script executed:

rg "proxyjump|resolveProxyJumpChain" -n Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 262


The fast-path skips resolving ProxyJump hops that contain SSH config aliases.

The pattern check at lines 4176–4180 returns hop unchanged if it matches user@host:port, assuming it's already fully resolved. However, the host portion can still be an SSH config alias (e.g., bob@jumpalias:2222 where jumpalias is a Host alias). This bypasses the resolution logic below and passes the unresolved alias to -J, causing the spawned jump SSH to fail since it inherits -F /dev/null.

The shell test demonstrates this: ssh -G target reports proxyjump bob@jumpalias:2222, but jumpalias resolves to bastion.example.com when checked separately. Parse and resolve the host portion of the hop, or only skip literal IP addresses (e.g., user@192.0.2.1:2222 or user@[::1]:2222).

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

In `@Sources/Workspace.swift` around lines 4133 - 4138, The fast-path in
resolveProxyJumpChain is incorrectly skipping hops that look like user@host:port
even when host is an SSH config alias; update resolveProxyJumpChain (the code
that currently returns hop unchanged for matches) to parse each hop into user,
host, and port, then only skip resolution when the host is a literal IP (match
IPv4 like \d+\.\d+\.\d+\.\d+ or bracketed IPv6 like [::1]); otherwise pass the
extracted host through the existing SSH-config resolution logic and rebuild the
hop (user@resolvedHost:port) before returning/adding to args (see the
"proxyjump" case and args += ["-J", resolved] for where this resolved value is
used).

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.

cmux ssh relay fails when SSH config has LocalForward/DynamicForward directives

1 participant