Skip to content

feat(ssh): make SSH connection robust and self-diagnosing#192

Merged
matt1398 merged 1 commit into
mainfrom
feat/ssh-connection-robustness
May 6, 2026
Merged

feat(ssh): make SSH connection robust and self-diagnosing#192
matt1398 merged 1 commit into
mainfrom
feat/ssh-connection-robustness

Conversation

@matt1398
Copy link
Copy Markdown
Owner

@matt1398 matt1398 commented May 6, 2026

Reduces auth dropdown to two conventional options (SSH Config + Password) and rebuilds the connect path so failures surface a concrete cause within seconds instead of opaque 25s timeouts.

Key changes:

  • SshHostResolver: defer host resolution to system ssh -G so we inherit Host blocks, Match, Include, IdentityAgent — exactly what ssh <host> from a terminal does.
  • authHandler-driven auth chain: ONE TCP/SSH session, every candidate (multiple agents, IdentityFile entries, default keys) tried in turn the way OpenSSH does. No reconnect-per-key budget burn.
  • TCP probe before ssh2: catches per-app VPN routing in 5s with an actionable error instead of a 25s "host unreachable".
  • SFTP open timeout: 8s cap with a server-config diagnostic, since SFTP hangs are a server problem (missing Subsystem sftp, restricted shell) that no client retry can fix.
  • HostName→alias fallback: typing the resolved IP picks up the matching Host block automatically.
  • Multi-agent enumeration: 1Password, launchctl, env, ~/.ssh/agent.sock all fed to authHandler so launchctl returning the empty system agent doesn't dead-end the chain.
  • Per-step timing diagnostics in every error: resolve / buildCandidates / tcp probe / tcp+handshake / open sftp, including in-flight steps — the error tells you which step burned the budget.
  • Encrypted-key detection skips passphrase-protected keys with a clear reason instead of letting ssh2 hang on them.
  • Robust host-listing parser replaces the ssh-config library which was silently dropping hosts on configs with mixed indentation, comment blocks, or Includes — every Host alias now appears in the dropdown.
  • Dropdown filter no longer hides other hosts when the input matches an alias exactly (pre-fill case).

Persisted profiles with legacy auth values (auto, agent, privateKey) are normalized to sshConfig on read so existing configs round-trip cleanly.

Summary by CodeRabbit

  • New Features

    • SSH Config-based authentication now available as the primary method with password fallback
  • Improvements

    • Streamlined SSH authentication options for simplified configuration
    • Enhanced SSH connection diagnostics and improved resilience to network conditions
    • Better SSH host resolution and alias matching support

Reduces auth dropdown to two conventional options (SSH Config + Password)
and rebuilds the connect path so failures surface a concrete cause within
seconds instead of opaque 25s timeouts.

Key changes:
- SshHostResolver: defer host resolution to system `ssh -G` so we inherit
  Host blocks, Match, Include, IdentityAgent — exactly what `ssh <host>`
  from a terminal does.
- authHandler-driven auth chain: ONE TCP/SSH session, every candidate
  (multiple agents, IdentityFile entries, default keys) tried in turn
  the way OpenSSH does. No reconnect-per-key budget burn.
- TCP probe before ssh2: catches per-app VPN routing in 5s with an
  actionable error instead of a 25s "host unreachable".
- SFTP open timeout: 8s cap with a server-config diagnostic, since SFTP
  hangs are a server problem (missing `Subsystem sftp`, restricted shell)
  that no client retry can fix.
- HostName→alias fallback: typing the resolved IP picks up the matching
  Host block automatically.
- Multi-agent enumeration: 1Password, launchctl, env, ~/.ssh/agent.sock
  all fed to authHandler so launchctl returning the empty system agent
  doesn't dead-end the chain.
- Per-step timing diagnostics in every error: resolve / buildCandidates
  / tcp probe / tcp+handshake / open sftp, including in-flight steps —
  the error tells you which step burned the budget.
- Encrypted-key detection skips passphrase-protected keys with a clear
  reason instead of letting ssh2 hang on them.
- Robust host-listing parser replaces the ssh-config library which was
  silently dropping hosts on configs with mixed indentation, comment
  blocks, or Includes — every Host alias now appears in the dropdown.
- Dropdown filter no longer hides other hosts when the input matches an
  alias exactly (pre-fill case).

Persisted profiles with legacy auth values (`auto`, `agent`, `privateKey`)
are normalized to `sshConfig` on read so existing configs round-trip
cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 10:13
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors SSH authentication to delegate host resolution and credential discovery to the system's OpenSSH client, consolidating several authentication methods into a single sshConfig option. It introduces a more resilient line-based parser for SSH configuration files and adds a TCP reachability probe to improve connection diagnostics. Review feedback identifies a bug in the key-value parser regarding whitespace around equals signs, an issue with relative path resolution in Include directives, and a missing bounds check when parsing cipher lengths in OpenSSH private keys.

@@ -156,7 +161,6 @@ export class SshConfigParser {
const expandedPattern = pattern.replace(/^~/, os.homedir());
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.

high

SSH Include directives with relative paths should be resolved relative to ~/.ssh/ (or /etc/ssh/ for root), but the current implementation resolves them relative to the process's current working directory. This can cause includes to fail depending on how the application was launched.

      let expandedPattern = pattern.replace(/^~/, os.homedir());
      if (!path.isAbsolute(expandedPattern)) {
        expandedPattern = path.join(path.dirname(this.configPath), expandedPattern);
      }

Comment on lines +292 to +308
function parseKeyValue(line: string): { key: string; value: string } | null {
// Both `Key Value ...` and `Key=Value ...` are valid in OpenSSH config.
// Avoid regex to keep this linear-time on long lines.
const eqIdx = line.indexOf('=');
const wsIdx = findWhitespace(line);
if (eqIdx !== -1 && (wsIdx === -1 || eqIdx < wsIdx)) {
const key = line.slice(0, eqIdx).trim();
const value = line.slice(eqIdx + 1).trim();
if (!key) return null;
return { key, value };
}
if (wsIdx === -1) return null;
const key = line.slice(0, wsIdx);
const value = line.slice(wsIdx + 1).trim();
if (!key || !value) return null;
return { key, 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.

high

The parseKeyValue logic incorrectly handles cases where the key and value are separated by an equals sign with a preceding space (e.g., Host = myhost). In such cases, the = is mistakenly included in the value, which will break host resolution and dropdown population. OpenSSH allows whitespace around the = separator.

function parseKeyValue(line: string): { key: string; value: string } | null {
  // Both `Key Value ...` and `Key=Value ...` are valid in OpenSSH config.
  // We need to find the first separator which can be '=' or whitespace.
  const eqIdx = line.indexOf('=');
  const wsIdx = findWhitespace(line);

  let sepIdx = -1;
  if (eqIdx !== -1 && wsIdx !== -1) {
    sepIdx = Math.min(eqIdx, wsIdx);
  } else if (eqIdx !== -1) {
    sepIdx = eqIdx;
  } else if (wsIdx !== -1) {
    sepIdx = wsIdx;
  } else {
    return null;
  }

  const key = line.slice(0, sepIdx).trim();
  let value = line.slice(sepIdx + 1).trim();

  // If we split on whitespace, check if the value starts with '=' (e.g., "Key = Value")
  if (sepIdx === wsIdx && value.startsWith('=')) {
    value = value.slice(1).trim();
  }

  if (!key || !value) return null;
  return { key, value };
}

Comment on lines +908 to +909
const cipherLen = buf.readUInt32BE(magic.length);
const cipher = buf.subarray(magic.length + 4, magic.length + 4 + cipherLen).toString();
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.

medium

When parsing the OpenSSH private key format, it's important to verify that the buffer contains enough data for the declared cipher name length. This prevents potential issues with truncated or malformed key files during the heuristic check.

Suggested change
const cipherLen = buf.readUInt32BE(magic.length);
const cipher = buf.subarray(magic.length + 4, magic.length + 4 + cipherLen).toString();
const cipherLen = buf.readUInt32BE(magic.length);
if (buf.length < magic.length + 4 + cipherLen) return false;
const cipher = buf.subarray(magic.length + 4, magic.length + 4 + cipherLen).toString();

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42d25f8450

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

connectConfig.agent = resolved.agent;
return {
host: resolved?.hostname ?? config.host,
port: resolved?.port ?? config.port,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve explicit SSH port overrides

When the user/profile supplies a non-default port for a plain host that has no Port in ssh_config, ssh -G still emits the default port 22 (verified with ssh -G example.com), so this line now prefers 22 over config.port. That regresses the existing port field and saved profiles for hosts reachable only on e.g. 2222; keep the previous precedence of an explicit non-22 UI/profile port over the resolver's default.

Useful? React with 👍 / 👎.

Comment on lines +99 to +100
if (value.toLowerCase() !== 'none') {
result.identityAgent = expandTilde(value);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor IdentityAgent none

When a Host block sets IdentityAgent none, ssh -G emits identityagent none; the OpenSSH ssh_config docs state that setting the socket name to none disables use of an authentication agent (Debian manpage). Dropping that sentinel means buildAuthCandidates later falls back to SSH_AUTH_SOCK/1Password agents anyway, so hosts that intentionally disable agents can fail before their IdentityFile is tried, especially with low MaxAuthTries.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot added the feature request New feature or request label May 6, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR overhauls SSH connectivity to better match OpenSSH behavior and to surface actionable failure causes quickly, while simplifying the UI/auth model to just SSH Config and Password.

Changes:

  • Simplifies persisted/UI auth methods to sshConfig | password and introduces a legacy→current normalization helper.
  • Rebuilds the main-process SSH connect flow with OpenSSH-based host resolution (ssh -G), multi-candidate auth, TCP probing, capped SFTP open timeouts, and richer diagnostics.
  • Replaces SSH host dropdown enumeration with a more robust line-based parser that tolerates real-world ssh_config formatting/includes.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/shared/types/notifications.ts Updates persisted config type unions to the new auth method set.
src/shared/types/index.ts Re-exports normalizeSshAuthMethod for shared consumption.
src/shared/types/api.ts Narrows SshAuthMethod union and adds normalizeSshAuthMethod; documents legacy behavior.
src/renderer/store/slices/connectionSlice.ts Stops persisting privateKeyPath in last-connection state.
src/renderer/components/settings/sections/WorkspaceSection.tsx Updates profile editor UI to the new auth options and normalizes legacy values when editing.
src/renderer/components/settings/sections/ConnectionSection.tsx Updates connect/test UI for new auth options, improves host filtering and error display, normalizes legacy values on prefill/selection.
src/main/services/infrastructure/SshHostResolver.ts New helper to resolve host settings via ssh -G and parse key directives.
src/main/services/infrastructure/SshConnectionManager.ts Major connect-path redesign: TCP probe, auth-candidate chain via authHandler, SFTP timeout, and diagnostic enrichment.
src/main/services/infrastructure/SshConfigParser.ts Reworks getHosts() to use a tolerant line scanner while keeping resolveHost() using ssh-config.
src/main/services/infrastructure/index.ts Exports the new SshHostResolver.
src/main/services/infrastructure/ConfigManager.ts Updates SSH persisted config type for new auth methods.
src/main/ipc/ssh.ts Stops persisting privateKeyPath for last-connection saves via IPC.
src/main/ipc/configValidation.ts Allows legacy auth method values for validation (with intent to normalize).
src/main/http/ssh.ts Stops persisting privateKeyPath for last-connection saves via HTTP.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +9 to +16
* 3. Try each candidate in its own ssh2 connection. Auth failure on one
* candidate moves to the next (matching how OpenSSH behaves). Network
* errors abort the chain immediately.
*
* Two safety nets:
* - `tryKeyboard: false` on every attempt so the server can't trap us in
* keyboard-interactive prompts that the GUI can't answer.
* - A single 20s outer timeout wraps the entire chain (including SFTP open),
attempts: AuthAttempt[]
): Promise<AuthCandidate[]> {
if (config.authMethod === 'password') {
return [{ kind: 'password', password: config.password ?? '', label: 'password' }];
Comment on lines +468 to +470
'Verify with `sftp ' +
(this.connectedHost ?? '<host>') +
'` from your terminal — if that also hangs, fix the server config.'
Comment on lines +379 to 381
// configs round-trip; legacy values are normalized to 'sshConfig' on read.
const validMethods = ['password', 'sshConfig', 'auto', 'agent', 'privateKey'];
if (!validMethods.includes(profile.authMethod as string)) return false;
username: formUsername.trim(),
authMethod: formAuthMethod,
privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined,
privateKeyPath: undefined,
@matt1398 matt1398 merged commit 6f67187 into main May 6, 2026
10 of 11 checks passed
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

SSH authentication is refactored to delegate identity resolution to the system SSH client. The authMethod field is narrowed to 'sshConfig' and 'password', privateKeyPath is deprecated, and a new SshHostResolver uses ssh -G for host resolution. SshConnectionManager gains a multi-stage connectChain with TCP probing and improved error diagnostics. UI components and state management are updated to remove private key handling.

Changes

SSH Config-Based Authentication Migration

Layer / File(s) Summary
Type System & Data Shape
src/shared/types/api.ts, src/shared/types/notifications.ts, src/shared/types/index.ts
SshAuthMethod narrowed from 'password' | 'privateKey' | 'agent' | 'auto' to 'sshConfig' | 'password'. New normalizeSshAuthMethod helper added to coerce raw values. Deprecation notes added to privateKeyPath fields in SshConnectionConfig, SshConnectionProfile, and SshLastConnection.
Persistent Config Types
src/main/services/infrastructure/ConfigManager.ts
SshPersistConfig.lastConnection.authMethod type narrowed to 'password' | 'sshConfig'.
New SSH Host Resolver
src/main/services/infrastructure/SshHostResolver.ts
Introduces ResolvedSshHost interface and SshHostResolver class that invokes ssh -G to extract hostname, port, user, identity files, and identity agent from system SSH config, with tilde expansion and error handling.
Infrastructure Exports
src/main/services/infrastructure/index.ts
New export added for SshHostResolver.
Core Connection Orchestration
src/main/services/infrastructure/SshConnectionManager.ts
Refactored connect() flow into multi-stage connectChain with TCP reachability probing, per-candidate authentication handling, outer timeout, and diagnostic timing. Added hostResolver field and integrated alias/config resolution. Enhanced remote project path discovery and auth candidate building (password, agent, privateKey, auto modes). Public API: SshAuthMethod re-exported.
SSH Config Parsing
src/main/services/infrastructure/SshConfigParser.ts
Refactored to read expanded SSH config with Include directive support; delegated host enumeration to new line-based parseHostListing parser instead of ssh-config library. Improved readability of identity-file handling and added robust ENOENT handling.
Config Validation
src/main/ipc/configValidation.ts
isValidSshProfile expanded to allow 'password', 'sshConfig', 'auto', 'agent', and 'privateKey' in profile.authMethod, with comments on legacy normalization to 'sshConfig'.
IPC/HTTP Wiring
src/main/http/ssh.ts, src/main/ipc/ssh.ts
SSH save-last-connection handlers updated to persist host, port, username, and authMethod; privateKeyPath field removed.
Renderer Components
src/renderer/components/settings/sections/ConnectionSection.tsx, src/renderer/components/settings/sections/WorkspaceSection.tsx
Removed private key path UI and handling. Auth method options reduced to sshConfig and password. Added normalizeSshAuthMethod integration on profile load and selection. Enhanced host filtering logic. Added explanatory text for SSH Config usage.
State Management
src/renderer/store/slices/connectionSlice.ts
Removed privateKeyPath from saved lastSshConfig in the connect flow.

Possibly related PRs

  • matt1398/claude-devtools#159: Modifies SSH config parsing (SshConfigParser) around identity-file handling and tilde expansion, directly related to Include directive and host enumeration refactoring.

Suggested labels

feature request

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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


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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature request New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants