From 1068af4fa261cc4081a2530d25661be89c5e1a8b Mon Sep 17 00:00:00 2001 From: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:14:23 +0000 Subject: [PATCH] fix: clarify remote ssh auth failures refs #1034 --- docs/next/CHANGELOG.md | 1 + .../src/content/docs/persistence-remote.mdx | 9 +++ src/main.rs | 8 +- src/remote.rs | 75 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index fcfe7e07..a12dbef1 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -17,6 +17,7 @@ - Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility. ### Fixed +- `herdr --remote` now prints clean remote attach failures and SSH authentication guidance instead of Rust Debug-formatted I/O errors when SSH authentication is denied. (#1034) - `herdr server stop` now waits until both server sockets are unreachable before returning, avoiding an immediate first-start failure when restarting right after replacing the binary. - Grok Build agent detection now tracks the current Grok Build UI: panes report working while responses, tools, and subagents run, and blocked on permission prompts and question dialogs, instead of falling back to idle mid-turn. (#1017) - Unix local Herdr clients no longer treat empty bracketed paste as a clipboard-image bridge; `herdr --remote` keeps using it for local-desktop image paste over SSH. (#986) diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index f449e2ff..a365ac88 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -69,6 +69,15 @@ Native Windows `herdr --remote` is not part of the Windows beta. From Windows, S By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings and a private per-attach control socket for connection reuse. Existing user keepalive settings win. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket. +Remote attach uses your normal OpenSSH authentication. If the target uses a passphrase-protected key in a non-interactive shell, script, CI job, or mobile terminal that cannot show the passphrase prompt, load the key into ssh-agent first: + +```bash +ssh-add +herdr --remote workbox +``` + +For any remote authentication failure, verify plain SSH access first with `ssh workbox`, then run `herdr --remote workbox` again. + By default, remote attach uses the normal restart/stop flow if it needs to replace or restart a running remote server. To opt into experimental live handoff for a supported running remote server, pass `--handoff`: ```bash diff --git a/src/main.rs b/src/main.rs index 3b4bed1c..deb332b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -661,7 +661,13 @@ fn main() -> io::Result<()> { } if let Some(remote_launch) = remote_launch { - return remote::run_remote(remote_launch); + let remote_target = remote_launch.target.clone(); + if let Err(err) = remote::run_remote(remote_launch) { + eprintln!("error: {err}"); + remote::print_remote_error_hint(&err, &remote_target); + std::process::exit(1); + } + return Ok(()); } let loaded_config = config::Config::load(); diff --git a/src/remote.rs b/src/remote.rs index edfb9269..ce4ca91b 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -146,3 +146,78 @@ pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> { "remote client bridge is not supported on Windows yet", )) } + +pub(crate) fn print_remote_error_hint(err: &std::io::Error, target: &str) { + if is_remote_auth_error(err) { + eprintln!( + "hint: verify SSH access first with `{}`.", + ssh_check_command(target) + ); + eprintln!( + "hint: if your SSH key has a passphrase, load it into ssh-agent with `ssh-add` before running `herdr --remote`." + ); + } +} + +fn is_remote_auth_error(err: &std::io::Error) -> bool { + let message = err.to_string(); + message.contains("Permission denied") + && (message.contains("(publickey") + || message.contains("(keyboard-interactive") + || message.contains("(password")) +} + +fn ssh_check_command(target: &str) -> String { + format!("ssh {}", shell_quote(target)) +} + +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.to_string(); + } + + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_auth_error_matches_ssh_auth_denied() { + let err = std::io::Error::other( + "remote platform detection failed: user@host: Permission denied (publickey).", + ); + + assert!(is_remote_auth_error(&err)); + } + + #[test] + fn remote_auth_error_matches_keyboard_interactive_denied() { + let err = std::io::Error::other( + "remote server status failed: user@host: Permission denied (keyboard-interactive).", + ); + + assert!(is_remote_auth_error(&err)); + } + + #[test] + fn remote_auth_error_ignores_non_auth_errors() { + let err = std::io::Error::other("remote platform detection failed: unsupported platform"); + + assert!(!is_remote_auth_error(&err)); + } + + #[test] + fn ssh_check_command_quotes_remote_target() { + assert_eq!(ssh_check_command("host name"), "ssh 'host name'"); + } +}