aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 23:33:27 +0300
committerGitHub <noreply@github.com>2026-09-10 23:33:27 +0300
commit043b804c44665b07bd503fdad896bbe0a29311a0 (patch)
tree79bae0b64ba1c0e22328b0401196587633399c60 /src
parent425c86179791cd32e2d4ce0ae7f269940b4b0663 (diff)
feat: route cli commands to saved ssh machines (#3918)
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs20
-rw-r--r--src/cli/pane.rs16
-rw-r--r--src/cli/plugin.rs21
-rw-r--r--src/cli/server.rs4
-rw-r--r--src/cli/spec.rs1
-rw-r--r--src/cli/status.rs28
-rw-r--r--src/cli/target.rs426
-rw-r--r--src/cli/worktree.rs30
-rw-r--r--src/main.rs40
-rw-r--r--src/remote.rs26
-rw-r--r--src/remote/attach.rs99
-rw-r--r--src/remote/saved.rs49
12 files changed, 705 insertions, 55 deletions
diff --git a/src/cli.rs b/src/cli.rs
index d4ae2f84..154e0c99 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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