aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/persistence-remote.mdx9
-rw-r--r--src/main.rs8
-rw-r--r--src/remote.rs75
4 files changed, 92 insertions, 1 deletions
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'");
+ }
+}