diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 20 | ||||
| -rw-r--r-- | src/cli/pane.rs | 16 | ||||
| -rw-r--r-- | src/cli/plugin.rs | 21 | ||||
| -rw-r--r-- | src/cli/server.rs | 4 | ||||
| -rw-r--r-- | src/cli/spec.rs | 1 | ||||
| -rw-r--r-- | src/cli/status.rs | 28 | ||||
| -rw-r--r-- | src/cli/target.rs | 426 | ||||
| -rw-r--r-- | src/cli/worktree.rs | 30 | ||||
| -rw-r--r-- | src/main.rs | 40 | ||||
| -rw-r--r-- | src/remote.rs | 26 | ||||
| -rw-r--r-- | src/remote/attach.rs | 99 | ||||
| -rw-r--r-- | src/remote/saved.rs | 49 |
12 files changed, 705 insertions, 55 deletions
@@ -37,6 +37,7 @@ mod server_not_running; mod spec; mod status; mod tab; +mod target; mod workspace; mod worktree; @@ -93,6 +94,10 @@ pub(super) fn print_read_response(response: &serde_json::Value) -> std::io::Resu Ok(0) } +pub(crate) fn maybe_run_machine(args: &[String]) -> Option<std::io::Result<CommandOutcome>> { + target::maybe_run(args) +} + pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { let Some(command) = args.get(1).map(|arg| arg.as_str()) else { return Ok(CommandOutcome::NotCli); @@ -762,7 +767,7 @@ pub(super) fn send_ok_request(method: Method) -> std::io::Result<i32> { } pub(super) fn send_request(request: &Request) -> std::io::Result<serde_json::Value> { - let client = ApiClient::local(); + let client = target::api_client()?; ensure_server_protocol_compatible(&client, &request.id)?; client .request_value(request) @@ -770,7 +775,7 @@ pub(super) fn send_request(request: &Request) -> std::io::Result<serde_json::Val } pub(super) fn send_request_unchecked(request: &Request) -> std::io::Result<serde_json::Value> { - let client = ApiClient::local(); + let client = target::api_client()?; client .request_value(request) .map_err(|err| map_server_not_running_or_io(err, &request.id, &client)) @@ -783,11 +788,9 @@ fn ensure_server_protocol_compatible(client: &ApiClient, request_id: &str) -> st let server_protocol = status .protocol .ok_or_else(|| std::io::Error::other("server ping did not include a protocol version"))?; - let Some(response) = protocol_guard::mismatch_response( - request_id, - server_protocol, - &crate::session::active_restart_after_update_guidance(), - ) else { + let Some(response) = + protocol_guard::mismatch_response(request_id, server_protocol, &target::restart_guidance()) + else { return Ok(()); }; @@ -834,6 +837,9 @@ fn map_server_not_running_or_io( request_id: &str, client: &ApiClient, ) -> std::io::Error { + if target::is_remote() { + return target::remote_error(api_client_error_to_io(err)); + } match err { ApiClientError::Io(io_err) if server_not_running_error(&io_err) => { server_not_running::reported_error(server_not_running::response( diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 17d3c144..896cee45 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -98,9 +98,7 @@ fn pane_get(args: &[String]) -> std::io::Result<i32> { } fn pane_current(args: &[String]) -> std::io::Result<i32> { - let env_pane_id = std::env::var("HERDR_PANE_ID") - .ok() - .filter(|value| !value.trim().is_empty()); + let env_pane_id = super::target::caller_pane_id(); let caller_pane_id = match parse_pane_current_args(args, env_pane_id.as_deref()) { Ok(caller_pane_id) => caller_pane_id, Err(message) => { @@ -225,9 +223,7 @@ fn pane_resize(args: &[String]) -> std::io::Result<i32> { } fn parse_optional_current_pane_args_from_env(args: &[String]) -> Result<Option<String>, String> { - let env_pane_id = std::env::var("HERDR_PANE_ID") - .ok() - .filter(|value| !value.trim().is_empty()); + let env_pane_id = super::target::caller_pane_id(); parse_optional_current_pane_args(args, env_pane_id.as_deref()) } @@ -541,9 +537,7 @@ fn parse_pane_read_args(args: &[String]) -> Result<PaneReadParams, String> { } fn pane_input(args: &[String]) -> std::io::Result<i32> { - let env_pane_id = std::env::var("HERDR_PANE_ID") - .ok() - .filter(|value| !value.trim().is_empty()); + let env_pane_id = super::target::caller_pane_id(); let params = match parse_pane_input_args(args, env_pane_id.as_deref()) { Ok(params) => params, Err(message) => { @@ -621,9 +615,7 @@ fn parse_right_click_target(value: &str) -> Result<PaneRightClickTarget, String> } fn pane_split(args: &[String]) -> std::io::Result<i32> { - let env_pane_id = std::env::var("HERDR_PANE_ID") - .ok() - .filter(|value| !value.trim().is_empty()); + let env_pane_id = super::target::caller_pane_id(); let params = match parse_pane_split_args(args, env_pane_id.as_deref()) { Ok(params) => params, Err(message) => { diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index efdb9eca..a2c1e6c3 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -701,6 +701,15 @@ fn parse_split_direction(value: &str) -> Option<SplitDirection> { } fn normalize_plugin_path_arg(value: &str) -> std::io::Result<String> { + if super::target::is_remote() { + if super::target::remote_path_is_absolute(value) { + return Ok(value.to_owned()); + } + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "remote plugin paths must be absolute", + )); + } let path = crate::worktree::expand_tilde_path(value); let absolute = if path.is_absolute() { path @@ -1618,6 +1627,9 @@ fn current_unix_ms() -> u64 { } fn is_connection_error(err: &std::io::Error) -> bool { + if super::target::is_remote() { + return false; + } // A `server_not_running` marker is a connect failure for recovery purposes: // treating it as a connection error lets plugin commands fall back to the // offline registry. The marker carries (but does not print) a friendly @@ -1672,6 +1684,15 @@ fn print_plugin_pane_help() { mod tests { use super::*; + #[test] + fn machine_plugin_connection_errors_never_use_local_offline_state() { + crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || { + assert!(!is_connection_error(&std::io::Error::from( + std::io::ErrorKind::ConnectionRefused + ))); + }); + } + fn unique_plugin_id(label: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src/cli/server.rs b/src/cli/server.rs index ff87cab1..2045f420 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -30,6 +30,10 @@ fn server_stop(args: &[String]) -> std::io::Result<i32> { return Ok(2); } + if super::target::is_remote() { + return super::send_ok_request(Method::ServerStop(EmptyParams::default())); + } + match crate::session::stop_active_server() { Ok(()) => Ok(0), Err(err) => { diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 2da0a25c..0d8a58ea 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -12,6 +12,7 @@ pub(super) fn command() -> Command { .disable_version_flag(true) .arg(help_flag()) .arg(option("session", "NAME").help("Use or create a named persistent session")) + .arg(option("machine", "LABEL-OR-ID").help("Run an API command on a saved SSH machine")) .arg(option("remote", "TARGET").help("Attach through SSH to a remote Herdr server")) .arg( option("remote-keybindings", "MODE") diff --git a/src/cli/status.rs b/src/cli/status.rs index c6dda462..fa008bdc 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -1,7 +1,7 @@ use serde::Serialize; use crate::api; -use crate::api::client::{ApiClient, ApiClientError}; +use crate::api::client::ApiClientError; pub(super) fn run_status_command(args: &[String]) -> std::io::Result<i32> { let Some((scope, json)) = parse_status_args(args) else { @@ -164,33 +164,29 @@ fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) { "{indent}private_protocol_compatible: {}", compatibility_label(*protocol) ); - println!("{indent}socket: {}", api::socket_path().display()); + println!("{indent}socket: {}", super::target::socket_label()); } ServerRuntimeStatus::NotRunning => { println!("{indent}status: not running"); - println!("{indent}socket: {}", api::socket_path().display()); + println!("{indent}socket: {}", super::target::socket_label()); } } } fn read_server_runtime_status() -> std::io::Result<ServerRuntimeStatus> { - match ApiClient::local().status() { + match super::target::api_client()?.status() { Ok(status) => Ok(ServerRuntimeStatus::Running { version: status.version, protocol: status.protocol, capabilities: status.capabilities, }), + Err(err) if super::target::is_remote() => Err(super::target::remote_error( + super::api_client_error_to_io(err), + )), Err(ApiClientError::Io(err)) if super::server_not_running_error(&err) => { Ok(ServerRuntimeStatus::NotRunning) } - Err(err) => Err(api_client_error_to_io(err)), - } -} - -fn api_client_error_to_io(err: ApiClientError) -> std::io::Error { - match err { - ApiClientError::Io(err) => err, - err => std::io::Error::other(err), + Err(err) => Err(super::api_client_error_to_io(err)), } } @@ -307,7 +303,7 @@ fn client_status_json() -> ClientStatusJson { } fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson { - match server { + let mut status = match server { ServerRuntimeStatus::Running { version, protocol, @@ -350,7 +346,13 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson { restart_needed: Some(false), server_binary_stale: Some(false), }, + }; + if let Some((_, session)) = super::target::remote_identity() { + status.socket = super::target::socket_label(); + status.session = Some(session); + status.server_binary_stale = None; } + status } fn update_status_json(server: &ServerRuntimeStatus) -> UpdateStatusJson { diff --git a/src/cli/target.rs b/src/cli/target.rs new file mode 100644 index 00000000..64c072f9 --- /dev/null +++ b/src/cli/target.rs @@ -0,0 +1,426 @@ +use std::cell::RefCell; +use std::io; + +use crate::api::client::{ApiClient, ConnectionTarget}; +use crate::client::endpoint::{EndpointCatalog, SavedSshEndpoint}; + +thread_local! { + // CLI dispatch is synchronous. Scope routing to this command, never the runtime or TUI. + static TARGET: RefCell<Option<MachineTarget>> = const { RefCell::new(None) }; +} + +struct MachineTarget { + profile: SavedSshEndpoint, + bridge: Option<crate::remote::SavedSshApiBridge>, + #[cfg(test)] + client_override: Option<ApiClient>, +} + +struct TargetScope(Option<MachineTarget>); + +impl Drop for TargetScope { + fn drop(&mut self) { + TARGET.with(|target| *target.borrow_mut() = self.0.take()); + } +} + +pub(super) fn maybe_run(args: &[String]) -> Option<io::Result<super::CommandOutcome>> { + let (selector, args) = match parse_machine_prefix(args) { + Ok(Some(target)) => target, + Ok(None) => return None, + Err(error) => return Some(usage_error(error)), + }; + Some((|| { + if let Err(error) = validate_machine_command(&args) { + return usage_error(error); + } + if super::spec::print_requested_help(&args)? { + return Ok(super::CommandOutcome::Handled(0)); + } + let profiles = EndpointCatalog::load_profiles().map_err(io::Error::other)?; + let profile = match resolve_machine(&profiles, &selector) { + Ok(profile) => profile.clone(), + Err(error) => return usage_error(error), + }; + let _scope = TARGET.with(|target| { + TargetScope(target.replace(Some(MachineTarget { + profile, + bridge: None, + #[cfg(test)] + client_override: None, + }))) + }); + super::maybe_run(&args) + })()) +} + +fn usage_error(error: String) -> io::Result<super::CommandOutcome> { + eprintln!("error: {error}"); + Ok(super::CommandOutcome::Handled(2)) +} + +pub(super) fn is_remote() -> bool { + TARGET.with(|target| target.borrow().is_some()) +} + +pub(super) fn api_client() -> io::Result<ApiClient> { + TARGET.with(|target| { + let mut target = target.borrow_mut(); + let Some(target) = target.as_mut() else { + return Ok(ApiClient::local()); + }; + #[cfg(test)] + if let Some(client) = &target.client_override { + return Ok(client.clone()); + } + if target.bridge.is_none() { + target.bridge = Some( + crate::remote::SavedSshApiBridge::start( + target.profile.id.as_str(), + &target.profile.target, + &target.profile.session, + ) + .map_err(|error| { + io::Error::new( + error.kind(), + format!("machine '{}': {error}", target.profile.label), + ) + })?, + ); + } + let bridge = target + .bridge + .as_ref() + .ok_or_else(|| io::Error::other("machine bridge unavailable"))?; + Ok(ApiClient::for_target(ConnectionTarget::SocketPath( + bridge.socket_path().to_owned(), + ))) + }) +} + +pub(super) fn remote_error(error: io::Error) -> io::Error { + TARGET.with(|target| { + let target = target.borrow(); + let Some(target) = target.as_ref() else { + return error; + }; + let error = target + .bridge + .as_ref() + .and_then(|bridge| bridge.reported_failure()) + .unwrap_or(error); + io::Error::new( + error.kind(), + format!( + "machine '{}' (session {}): {error}", + target.profile.label, target.profile.session + ), + ) + }) +} + +pub(super) fn restart_guidance() -> String { + TARGET.with(|target| match target.borrow().as_ref() { + Some(target) => format!("Update Herdr and restart the server on machine '{}' (session {}). Stopping the server exits its pane processes.", target.profile.label, target.profile.session), + None => crate::session::active_restart_after_update_guidance(), + }) +} + +pub(super) fn remote_identity() -> Option<(String, String)> { + TARGET.with(|target| { + target.borrow().as_ref().map(|target| { + ( + target.profile.id.to_string(), + target.profile.session.clone(), + ) + }) + }) +} + +pub(super) fn socket_label() -> String { + match remote_identity() { + Some((id, session)) => format!("machine:{id}/{session}"), + None => crate::api::socket_path().display().to_string(), + } +} + +pub(super) fn remote_path_is_absolute(path: &str) -> bool { + let bytes = path.as_bytes(); + path.starts_with('/') + || path.starts_with("\\\\") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')) +} + +pub(super) fn caller_pane_id() -> Option<String> { + if is_remote() { + return None; + } + std::env::var("HERDR_PANE_ID") + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn parse_machine_prefix(args: &[String]) -> Result<Option<(String, Vec<String>)>, String> { + let mut index = 1; + let mut machine = None; + let mut other_prefix = false; + while let Some(arg) = args.get(index) { + if arg == "--machine" || arg.starts_with("--machine=") { + if machine.is_some() { + return Err("--machine can only be specified once".into()); + } + let value = if let Some(value) = arg.strip_prefix("--machine=") { + value.to_owned() + } else { + index += 1; + args.get(index) + .cloned() + .ok_or("missing value for --machine")? + }; + if value.trim().is_empty() || value.starts_with('-') { + return Err("--machine requires a saved machine label or profile ID".into()); + } + machine = Some(value); + } else if arg.starts_with('-') && arg != "--" { + other_prefix = true; + if matches!( + arg.as_str(), + "--session" | "--remote" | "--remote-keybindings" + ) { + index += 1; + } + } else { + break; + } + index += 1; + } + let Some(machine) = machine else { + return Ok(None); + }; + if other_prefix { + return Err("--machine cannot be combined with other launch options; it uses the saved machine's session".into()); + } + if index >= args.len() || args[index] == "--" { + return Err("usage: herdr --machine <label-or-id> <command>".into()); + } + let mut cleaned = vec![args[0].clone()]; + cleaned.extend_from_slice(&args[index..]); + Ok(Some((machine, cleaned))) +} + +fn resolve_machine<'a>( + profiles: &'a [SavedSshEndpoint], + selector: &str, +) -> Result<&'a SavedSshEndpoint, String> { + let profile = if let Some(profile) = profiles + .iter() + .find(|profile| profile.id.as_str() == selector) + { + profile + } else { + let mut matches = profiles.iter().filter(|profile| profile.label == selector); + let profile = matches + .next() + .ok_or_else(|| format!("unknown machine '{selector}'; use `herdr machine list`"))?; + if matches.next().is_some() { + return Err(format!( + "machine label '{selector}' is ambiguous; use its profile ID" + )); + } + profile + }; + if !profile.enabled { + return Err(format!("machine '{selector}' is disabled")); + } + Ok(profile) +} + +fn validate_machine_command(args: &[String]) -> Result<(), String> { + let command = args.get(1).map(String::as_str).unwrap_or_default(); + let subcommand = args.get(2).map(String::as_str).unwrap_or_default(); + let supported = match command { + "workspace" | "worktree" | "tab" | "pane" | "notification" => true, + "agent" => { + subcommand != "attach" + && !(subcommand == "explain" + && args[3..] + .iter() + .any(|arg| arg == "--file" || arg.starts_with("--file="))) + } + "api" => subcommand == "snapshot", + "status" => subcommand == "server", + "plugin" => matches!( + subcommand, + "link" | "unlink" | "enable" | "disable" | "list" | "action" | "log" | "logs" | "pane" + ), + "server" => matches!( + subcommand, + "stop" | "reload-config" | "agent-manifests" | "reload-agent-manifests" + ), + _ => false, + }; + if supported { + Ok(()) + } else { + Err(format!("`{command} {subcommand}` is not an API-backed machine command; --machine does not run local management commands or attach a TUI")) + } +} + +#[cfg(test)] +pub(super) fn with_test_client<T>(client: ApiClient, run: impl FnOnce() -> T) -> T { + let _scope = TARGET.with(|target| { + TargetScope( + target.replace(Some(MachineTarget { + profile: SavedSshEndpoint::new("test-machine", "unused", "remote-session") + .expect("valid test profile"), + bridge: None, + client_override: Some(client), + })), + ) + }); + run() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).into()).collect() + } + + #[test] + fn machine_prefix_routes_without_consuming_command_payload() { + for prefix in [args(&["--machine", "mac"]), args(&["--machine=mac"])] { + let mut input = args(&["herdr"]); + input.extend(prefix); + input.extend(args(&["agent", "prompt", "w4:p1", "--machine"])); + assert_eq!( + parse_machine_prefix(&input).unwrap(), + Some(( + "mac".into(), + args(&["herdr", "agent", "prompt", "w4:p1", "--machine"]) + )) + ); + } + assert_eq!( + parse_machine_prefix(&args(&[ + "herdr", + "agent", + "prompt", + "w4:p1", + "--machine=mac" + ])) + .unwrap(), + None + ); + } + + #[test] + fn machine_prefix_rejects_missing_target_and_conflicting_global_options() { + for input in [ + args(&["herdr", "--machine"]), + args(&["herdr", "--machine="]), + args(&["herdr", "--machine", "--help"]), + args(&["herdr", "--machine", "mac"]), + args(&[ + "herdr", + "--machine", + "mac", + "--machine", + "other", + "agent", + "list", + ]), + args(&[ + "herdr", + "--machine", + "mac", + "--session", + "other", + "agent", + "list", + ]), + args(&[ + "herdr", + "--session", + "other", + "--machine", + "mac", + "agent", + "list", + ]), + args(&[ + "herdr", + "--remote", + "other", + "--machine", + "mac", + "agent", + "list", + ]), + ] { + assert!(parse_machine_prefix(&input).is_err(), "{input:?}"); + } + } + + #[test] + fn machine_resolution_requires_a_unique_enabled_saved_machine() { + let mac = SavedSshEndpoint::new("mac", "mac-ssh", "agents").unwrap(); + let other = SavedSshEndpoint::new("build", "builder", "default").unwrap(); + let profiles = vec![mac.clone(), other]; + assert_eq!(resolve_machine(&profiles, "mac").unwrap(), &mac); + assert_eq!(resolve_machine(&profiles, mac.id.as_str()).unwrap(), &mac); + let shadow = SavedSshEndpoint::new(mac.id.as_str(), "shadow", "default").unwrap(); + assert_eq!( + resolve_machine(&[mac.clone(), shadow], mac.id.as_str()).unwrap(), + &mac + ); + assert!(resolve_machine(&profiles, "mac-ssh").is_err()); + assert!(resolve_machine(&profiles, "missing").is_err()); + let duplicate = SavedSshEndpoint::new("mac", "other", "default").unwrap(); + assert!(resolve_machine(&[mac.clone(), duplicate], "mac").is_err()); + let mut disabled = mac; + disabled.enabled = false; + assert!(resolve_machine(&[disabled], "mac").is_err()); + } + + #[test] + fn machine_commands_reject_local_side_effects_and_tui_attach() { + for command in [ + &["update"][..], + &["config", "reset-keys"], + &["machine", "remove", "mac"], + &["session", "delete", "default"], + &["server", "live-handoff"], + &["agent", "attach", "w4:p1"], + &["terminal", "attach", "w4:p1"], + &["terminal", "session", "control", "w4:p1"], + &["plugin", "install", "./plugin"], + &["integration", "install", "pi"], + &["api", "schema", "--output", "schema.json"], + &["status", "client"], + ] { + let mut input = args(&["herdr"]); + input.extend(args(command)); + assert!(validate_machine_command(&input).is_err(), "{input:?}"); + } + for command in [ + &["agent", "list"][..], + &["agent", "wait", "w4:p1"], + &["pane", "split", "w4:p1", "--direction", "right"], + &["workspace", "list"], + &["worktree", "create", "--branch", "feature"], + &["tab", "list"], + &["api", "snapshot"], + &["server", "stop"], + ] { + let mut input = args(&["herdr"]); + input.extend(args(command)); + assert!(validate_machine_command(&input).is_ok(), "{input:?}"); + } + } +} diff --git a/src/cli/worktree.rs b/src/cli/worktree.rs index 4003eb6a..ba492e97 100644 --- a/src/cli/worktree.rs +++ b/src/cli/worktree.rs @@ -323,6 +323,16 @@ fn print_worktree_help() { } fn normalize_path_arg(value: &str) -> std::io::Result<String> { + if super::target::is_remote() { + if super::target::remote_path_is_absolute(value) || value == "~" || value.starts_with("~/") + { + return Ok(value.to_owned()); + } + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "remote worktree paths must be absolute or start with ~/", + )); + } let path = crate::worktree::expand_tilde_path(value); let absolute = if path.is_absolute() { path @@ -331,3 +341,23 @@ fn normalize_path_arg(value: &str) -> std::io::Result<String> { }; Ok(absolute.display().to_string()) } + +#[cfg(test)] +mod machine_tests { + #[test] + fn remote_worktree_paths_are_not_expanded_on_the_caller_machine() { + crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || { + for path in [ + "~/Projects/herdr", + "/Users/can/Projects/herdr", + r"C:\work\repo", + "C:/work/repo", + r"\\host\share\repo", + ] { + assert_eq!(super::normalize_path_arg(path).unwrap(), path); + } + assert!(super::normalize_path_arg("../other").is_err()); + assert!(super::normalize_path_arg("C:relative").is_err()); + }); + } +} diff --git a/src/main.rs b/src/main.rs index cf7cf5b4..87f3ce0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -482,6 +482,23 @@ where .collect() } +fn finish_cli(outcome: io::Result<cli::CommandOutcome>) -> io::Result<()> { + match outcome { + Ok(cli::CommandOutcome::Handled(code)) => std::process::exit(code), + Ok(cli::CommandOutcome::NotCli) => Ok(()), + Err(err) if cli::protocol_mismatch_was_reported(&err) => std::process::exit(1), + Err(err) if cli::server_not_running_was_reported(&err) => { + if let Some(response) = cli::server_not_running_reported_response(&err) { + if let Ok(json) = serde_json::to_string(response) { + eprintln!("{json}"); + } + } + std::process::exit(1); + } + Err(err) => Err(err), + } +} + fn main() -> io::Result<()> { let raw_args: Vec<String> = match args_as_utf8(std::env::args_os()) { Ok(args) => args, @@ -491,6 +508,9 @@ fn main() -> io::Result<()> { std::process::exit(2); } }; + if let Some(outcome) = cli::maybe_run_machine(&raw_args) { + return finish_cli(outcome); + } let args = match session::configure_from_args(&raw_args) { Ok(args) => args, Err(err) => { @@ -522,19 +542,10 @@ fn main() -> io::Result<()> { std::process::exit(2); } - match cli::maybe_run(&args) { - Ok(cli::CommandOutcome::Handled(code)) => std::process::exit(code), - Ok(cli::CommandOutcome::NotCli) => {} - Err(err) if cli::protocol_mismatch_was_reported(&err) => std::process::exit(1), - Err(err) if cli::server_not_running_was_reported(&err) => { - if let Some(response) = cli::server_not_running_reported_response(&err) { - if let Ok(json) = serde_json::to_string(response) { - eprintln!("{json}"); - } - } - std::process::exit(1); - } - Err(err) => return Err(err), + finish_cli(cli::maybe_run(&args))?; + + if args.get(1).map(String::as_str) == Some("remote-api-bridge") { + return remote::run_remote_api_bridge(&args[2..]); } // Subcommands and flags (no TUI, no logging needed) @@ -585,6 +596,7 @@ fn main() -> io::Result<()> { println!(); println!("Usage: herdr [options]"); println!(" herdr --session <name> [options]"); + println!(" herdr --machine <label-or-id> <command>"); println!(" herdr --remote <ssh-target> [--session <name>]"); println!(" herdr session attach <name>"); println!(" herdr completion zsh"); @@ -678,6 +690,7 @@ fn main() -> io::Result<()> { println!(); println!("Options:"); println!(" --session <name> Use or create a named persistent session"); + println!(" --machine <label-or-id> Run an API command on a saved SSH machine"); println!(" --remote <target> Attach through SSH to a remote Herdr server"); println!(" --remote-keybindings <local|server>"); println!(" Keybindings for --remote app attach (default: local)"); @@ -717,6 +730,7 @@ fn main() -> io::Result<()> { // Reject unknown flags let known_flags = [ "--session", + "--machine", "--remote", "--remote-keybindings", "--version", diff --git a/src/remote.rs b/src/remote.rs index 768d90f1..c1c0f65f 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -10,6 +10,32 @@ pub(crate) use attach::*; pub(crate) use host::run_remote_client_bridge; pub(crate) use saved::*; +pub(crate) fn run_remote_api_bridge(args: &[String]) -> std::io::Result<()> { + match args { + [] => { + let path = crate::api::socket_path(); + let stream = crate::ipc::connect_local_stream(&path).map_err(|error| { + std::io::Error::new( + error.kind(), + format!( + "failed to connect to remote Herdr API socket {}: {error}", + path.display() + ), + ) + })?; + crate::platform::forward_remote_bridge_stdio(stream) + } + [flag] if flag == "--check" => { + println!("herdr-api-bridge-v1"); + Ok(()) + } + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "usage: herdr remote-api-bridge [--check]", + )), + } +} + pub(crate) fn print_saved_ssh_error_hint(err: &std::io::Error, target: &str) { if is_remote_host_key_error(err) { eprintln!( diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 2aee5efe..042676d0 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -957,6 +957,35 @@ fn prepare_windows_remote_herdr( }) } +pub(super) fn find_installed_remote_api_herdr( + ssh: &RemoteSsh, + session: &str, +) -> io::Result<RemoteHerdr> { + let platform = detect_remote_platform(ssh)?; + let remote_herdr = RemoteHerdr::for_platform(platform); + let candidates = if remote_herdr.platform.is_windows() { + vec![remote_herdr] + } else { + remote_binary_candidates(ssh, &remote_herdr)? + }; + for candidate in candidates { + let probe = + ssh.framed_user_shell_output(&remote_api_bridge_command(&candidate, session, true))?; + if probe.status.code() == Some(255) { + return Err(command_failed("remote SSH connection failed", &probe)); + } + if probe.status.success() + && String::from_utf8_lossy(&probe.stdout).trim() == "herdr-api-bridge-v1" + { + return Ok(candidate); + } + } + Err(io::Error::new( + io::ErrorKind::Unsupported, + "remote Herdr does not support machine API forwarding; update Herdr on this machine", + )) +} + fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result<RemotePlatform> { let output = ssh.sh_output("uname -s\nuname -m\n")?; let mut windows_uname_hint = false; @@ -2015,6 +2044,24 @@ fn confirm_remote_install( Ok(()) } +pub(super) fn remote_api_bridge_command( + remote_herdr: &RemoteHerdr, + session_name: &str, + check: bool, +) -> String { + let mut args = vec!["--session", session_name, "remote-api-bridge"]; + if check { + args.push("--check"); + } + match &remote_herdr.executable { + RemoteExecutable::PosixShellPath(_) => { + posix_remote_output_command(&format!("exec {}", remote_herdr.executable.command(&args))) + } + RemoteExecutable::WindowsPath(path) => { + windows_powershell_streaming_application_command(path, &args) + } + } +} fn reattach_command( program: &str, target: &str, @@ -2066,6 +2113,22 @@ impl SshStdioBridge { ssh_options: Option<&ManagedSshOptions>, noninteractive: bool, ) -> io::Result<Self> { + Self::start_command( + target, + remote_herdr.executable.bridge_command(&session_name), + local_socket, + ssh_options, + noninteractive, + ) + } + + pub(super) fn start_command( + target: String, + remote_command: String, + local_socket: PathBuf, + ssh_options: Option<&ManagedSshOptions>, + noninteractive: bool, + ) -> io::Result<Self> { crate::ipc::prepare_socket_path(&local_socket, |path| { format!("remote bridge is already listening at {}", path.display()) })?; @@ -2103,8 +2166,7 @@ impl SshStdioBridge { if let Err(err) = bridge_connection( stream, &target, - &remote_herdr, - &session_name, + &remote_command, thread_ssh_options.as_ref(), noninteractive, &thread_stop, @@ -2142,7 +2204,7 @@ impl SshStdioBridge { }) } - fn reported_failure(&self) -> Option<io::Error> { + pub(super) fn reported_failure(&self) -> Option<io::Error> { self.failure_rx .recv_timeout(BRIDGE_FAILURE_REPORT_TIMEOUT) .ok() @@ -2285,8 +2347,7 @@ pub(crate) fn bridge_upload_cancellation_for_test( fn bridge_connection( mut stream: crate::ipc::LocalStream, target: &str, - remote_herdr: &RemoteHerdr, - session_name: &str, + remote_command: &str, ssh_options: Option<&ManagedSshOptions>, noninteractive: bool, bridge_stop: &Arc<AtomicBool>, @@ -2300,7 +2361,7 @@ fn bridge_connection( command .arg("-T") .arg(target) - .arg(remote_herdr.executable.bridge_command(session_name)) + .arg(remote_command) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(if noninteractive { @@ -3551,6 +3612,16 @@ mod tests { "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", ), ( + "API bridge with explicit default session", + remote_api_bridge_command(&RemoteHerdr::for_platform(RemotePlatform { os: "windows", arch: "x86_64" }), "default", false), + "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session default remote-api-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", + ), + ( + "API bridge capability probe", + remote_api_bridge_command(&RemoteHerdr::for_platform(RemotePlatform { os: "windows", arch: "x86_64" }), "agents", true), + "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-api-bridge --check' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", + ), + ( "saved bridge with closed stdin", executable.saved_bridge_command("agents"), "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", @@ -3665,6 +3736,22 @@ mod tests { } #[test] + fn remote_api_bridge_always_selects_the_saved_session() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + for session in ["default", "agents"] { + assert_eq!( + remote_api_bridge_command(&remote_herdr, session, false), + posix_remote_output_command(&format!( + "exec \"$HOME/.local/bin/herdr\" --session {session} remote-api-bridge" + )) + ); + } + } + + #[test] fn remote_bridge_command_uses_installed_binary() { let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { os: "linux", diff --git a/src/remote/saved.rs b/src/remote/saved.rs index 5b570af4..561ee1a0 100644 --- a/src/remote/saved.rs +++ b/src/remote/saved.rs @@ -17,10 +17,7 @@ pub(crate) fn connect_saved_ssh( target: &str, session: &str, ) -> io::Result<SavedSshStream> { - validate_profile_path_id(profile_id)?; - crate::session::validate_name(session) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let ssh = RemoteSsh::new_noninteractive(target.to_owned()); + let ssh = validated_saved_ssh(profile_id, target, session)?; let remote_herdr = find_installed_remote_herdr(&ssh)?; let path = saved_bridge_path(profile_id); let bridge = SshStdioBridge::start( @@ -38,6 +35,43 @@ pub(crate) fn connect_saved_ssh( }) } +pub(crate) struct SavedSshApiBridge { + path: PathBuf, + bridge: SshStdioBridge, +} + +impl SavedSshApiBridge { + pub(crate) fn start(profile_id: &str, target: &str, session: &str) -> io::Result<Self> { + let ssh = validated_saved_ssh(profile_id, target, session)?; + let remote_herdr = super::attach::find_installed_remote_api_herdr(&ssh, session)?; + let command = super::attach::remote_api_bridge_command(&remote_herdr, session, false); + let path = crate::platform::remote_bridge_endpoint_path( + &format!("herdr-api-ssh-{}-{profile_id}.sock", std::process::id()), + &format!( + "herdr-api-{}-{}.sock", + std::process::id(), + &profile_id[..16] + ), + ); + let bridge = SshStdioBridge::start_command( + target.to_owned(), + command, + path.clone(), + ssh.options(), + true, + )?; + Ok(Self { path, bridge }) + } + + pub(crate) fn socket_path(&self) -> &std::path::Path { + &self.path + } + + pub(crate) fn reported_failure(&self) -> Option<io::Error> { + self.bridge.reported_failure() + } +} + pub(crate) fn saved_ssh_bootstrap_command(target: &str, session: &str) -> String { format!( "herdr --remote {} --session {}", @@ -80,6 +114,13 @@ fn saved_bridge_path(profile_id: &str) -> PathBuf { crate::platform::remote_bridge_endpoint_path(&readable, &short) } +fn validated_saved_ssh(profile_id: &str, target: &str, session: &str) -> io::Result<RemoteSsh> { + validate_profile_path_id(profile_id)?; + crate::session::validate_name(session) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + Ok(RemoteSsh::new_noninteractive(target.to_owned())) +} + fn validate_profile_path_id(profile_id: &str) -> io::Result<()> { if profile_id.len() == 32 && profile_id |
