feat: route cli commands to saved ssh machines (#3918)

This commit is contained in:
Can Celik
2026-09-10 23:33:27 +03:00
committed by GitHub
parent 425c861797
commit 043b804c44
19 changed files with 1160 additions and 61 deletions
@@ -66,7 +66,22 @@ Changes apply automatically to open local Herdr clients, normally within a secon
Automatic connections and reconnects are non-interactive. If a host key, password, key passphrase, MFA step, install, update, or restart needs approval, the machine shows Attention instead of opening a hidden prompt. Run the standalone command printed by Herdr, such as `herdr --remote workbox`, to complete that setup in the foreground, then restart the client. Include `--session <name>` only when the profile targets a named session.
Workspace, tab, pane IDs, and agent names belong to a single server. Selecting a machine in the UI does not change the session or socket inherited by commands running in an existing pane. For remote automation, run the CLI on the intended host against the intended session and discover its IDs there.
Workspace, tab, pane IDs, and agent names belong to a single server. Selecting a machine in the UI does not retarget CLI commands. Use the global **prefix** `--machine <label-or-id>` to route API commands to a saved SSH machine:
```bash
herdr --machine "Build machine" agent list
herdr --machine <profile-id> pane list
herdr --machine "Build machine" agent prompt w1:p1 "review this change"
herdr --machine "Build machine" worktree create --cwd /srv/project --branch review
```
The selector must match an enabled saved profile ID or a unique, case-sensitive label, not an arbitrary SSH hostname. The saved remote session is used; combining `--machine` with `--session` or `--remote` is an error. Without the prefix, commands retain their existing local session/socket behavior.
Supported commands are `workspace`, `worktree`, `tab`, `pane`, `notification`, `agent` (except `attach` and local `explain --file`), `api snapshot`, `status server`, and API-backed plugin commands (`link`, `unlink`, `enable`, `disable`, `list`, `action`, `log`, `pane`). Server commands support `stop`, `reload-config`, `agent-manifests`, and `reload-agent-manifests`. Local installation/configuration commands, plugin installation, session management, and interactive terminal attachment are not forwarded.
Requests and responses travel through the JSON API over non-interactive SSH; API payloads are not interpolated into the SSH shell command. No open TUI is required, and the bridge never installs, starts, or restarts a server. Update the local CLI and remote Herdr installation to support machine forwarding, and keep the running remote server's API protocol compatible. Authentication, unavailable machines, and incompatible versions fail without falling back to Local. Requests are not automatically retried after connection failure.
Local pane IDs are not inherited by remote commands. Use explicit remote IDs; `--current` cannot refer to the caller's local pane. Remote worktree paths must be absolute, `~`, or start with `~/` (expanded on the server); plugin link paths must be absolute. The prefix targets one machine at a time; combined listings and routing through another TUI's connections are not included.
## Shell completions
@@ -93,7 +93,7 @@ The UI uses the client's local theme, sidebar settings, and keybindings by defau
Default agent rows show a `machine` token when multiple machines are present. Existing custom rows are preserved; add `machine` explicitly if you want that label in your layout. [Sidebar row layouts](/docs/configuration/#sidebar-row-layouts) also support conditional colors for machine labels.
Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines may both contain `w1:p1` or an agent named `reviewer`. Selecting a machine in the UI does not retarget CLI commands running in an existing pane: they still use that pane's inherited session and socket. For remote automation, run commands on the intended host against the intended session and read its IDs there.
Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines may both contain `w1:p1` or an agent named `reviewer`. Selecting a machine in the UI does not retarget CLI commands running in an existing pane: they still use that pane's inherited session and socket. For remote automation, use `herdr --machine <label-or-id> agent list`, then pass the same prefix when controlling those remote IDs. The CLI uses the saved profile's SSH target and session directly; it does not need an open TUI. Without `--machine`, existing session/socket routing is unchanged. See [CLI reference](/docs/cli-reference/#saved-ssh-machines) for supported commands, update requirements, and remote path rules.
## Updates and saved data
@@ -64,7 +64,22 @@ herdr machine remove <profile-id>
自動接続と再接続は非対話型です。ホスト鍵、パスワード、鍵のパスフレーズ、MFA、インストール、更新、再起動に承認が必要な場合、隠れたプロンプトを開くのではなく Attention と表示します。`herdr --remote workbox` など、Herdr が表示する単独接続用のコマンドを実行してフォアグラウンドでセットアップを完了し、クライアントを再起動してください。名前付きセッションを選んだプロファイルにだけ `--session <name>` を追加します。
ワークスペース、タブ、ペインの ID とエージェント名は、1 つのサーバー内だけで有効です。UI でマシンを選んでも、既存ペイン内のコマンドが継承するセッションやソケットは変わりません。リモート自動化では対象ホスト上の対象セッションに対して CLI を実行し、そこで ID を取得してください
ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。UI での選択は CLI の接続先を変えません。保存済み SSH マシンへ API コマンドを送るには、グローバルな**プレフィックス** `--machine <label-or-id>` を指定します
```bash
herdr --machine "Build machine" agent list
herdr --machine <profile-id> pane list
herdr --machine "Build machine" agent prompt w1:p1 "review this change"
herdr --machine "Build machine" worktree create --cwd /srv/project --branch review
```
有効なプロファイル ID または一意のラベル(大文字・小文字を区別)を指定します。任意の SSH ホスト名は使えません。保存済みのリモートセッションを使うため、`--session` や `--remote` との併用はエラーです。省略時のローカル動作は変わりません。
対応するのは `workspace`、`worktree`、`tab`、`pane`、`notification`、`agent``attach` とローカルの `explain --file` 以外)、`api snapshot`、`status server`、API ベースのプラグイン操作(`link`、`unlink`、`enable`、`disable`、`list`、`action`、`log`、`pane`)です。サーバー操作は `stop`、`reload-config`、`agent-manifests`、`reload-agent-manifests` に対応します。ローカルのインストール・設定、プラグインのインストール、セッション管理、対話的な端末接続は転送しません。
JSON API を非対話 SSH で転送します。API の内容を SSH のシェルコマンドに埋め込むことはありません。開いた TUI は不要で、サーバーのインストール・起動・再起動も自動では行いません。ローカル CLI とリモート Herdr を更新し、実行中サーバーの API プロトコルとの互換性を保ってください。認証、接続、互換性のエラーで Local にフォールバックしたり、要求を自動再送したりすることはありません。
呼び出し元のローカルペイン ID は継承しません。リモート ID を明示してください。`--current` でローカルペインを指定することはできません。リモート worktree パスには絶対パス、`~`、または `~/` で始まるパスを指定します(サーバー側で展開)。プラグインの link パスは絶対パスが必要です。対象は 1 台ずつで、一覧の統合や別の TUI の接続を使った転送は含みません。
## シェル補完
@@ -87,7 +87,7 @@ UI はデフォルトでクライアントのローカルテーマ、サイド
複数のマシンがある場合、デフォルトの Agent 行に `machine` トークンを表示します。既存のカスタム行は維持されるため、ラベルが必要なら `machine` を追加してください。[サイドバーの行レイアウト](/ja/docs/configuration/)ではマシンラベルの条件付き色も設定できます。
ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。2 台に同じ `w1:p1` や `reviewer` が存在する場合があります。UI でマシンを選んでも、既存ペイン内の CLI は継承したセッションとソケットを使い続けます。リモート自動化では、対象ホストの対象セッションでコマンドを実行し、そこで ID を読み取ってください。
ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。2 台に同じ `w1:p1` や `reviewer` が存在する場合があります。UI でマシンを選んでも、既存ペイン内の CLI は継承したセッションとソケットを使い続けます。リモート自動化では `herdr --machine <label-or-id> agent list` で ID を取得し、操作時にも同じプレフィックスを指定します。CLI は保存済みプロファイルの SSH 接続先とセッションを直接使うため、TUI を開く必要はありません。`--machine` を省略した場合の動作は変わりません。対応コマンド、更新要件、パスの扱いは [CLI リファレンス](/ja/docs/cli-reference/#保存済み-ssh-マシン) を参照してください。
## 更新と保存データ
@@ -64,7 +64,22 @@ herdr machine remove <profile-id>
自动连接和重连均为非交互式。如果主机密钥、密码、密钥口令、MFA、安装、更新或重启需要批准,机器会显示 Attention,而不会打开隐藏的提示。请运行 Herdr 显示的独立连接命令,例如 `herdr --remote workbox`,在前台完成设置后重启客户端。仅当配置指向命名会话时,才加上 `--session <name>`。
工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。UI 中选择机器不会改变已有窗格中命令继承的会话或 socket。远程自动化应在目标主机上针对目标会话运行 CLI,并从那里获取 ID。
工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。UI 中选择不会改变 CLI 的目标。使用全局**前缀** `--machine <label-or-id>` 将 API 命令发送到已保存的 SSH 机器:
```bash
herdr --machine "Build machine" agent list
herdr --machine <profile-id> pane list
herdr --machine "Build machine" agent prompt w1:p1 "review this change"
herdr --machine "Build machine" worktree create --cwd /srv/project --branch review
```
选择器必须是已启用的配置 ID 或唯一标签(区分大小写),不能是任意 SSH 主机名。命令使用保存的远程会话,不能与 `--session` 或 `--remote` 混用。省略前缀时,本地行为保持不变。
支持 `workspace`、`worktree`、`tab`、`pane`、`notification`、`agent`(不含 `attach` 和本地 `explain --file`)、`api snapshot`、`status server`,以及基于 API 的插件操作(`link`、`unlink`、`enable`、`disable`、`list`、`action`、`log`、`pane`)。服务器操作支持 `stop`、`reload-config`、`agent-manifests`、`reload-agent-manifests`。本地安装和配置、插件安装、会话管理及交互式终端连接不会被转发。
请求和响应通过非交互式 SSH 传输 JSON APIAPI 内容不会被拼接到 SSH shell 命令中。不需要打开 TUI,也不会自动安装、启动或重启服务器。请更新本地 CLI 和远程 Herdr,并确保运行中的服务器 API 协议兼容。认证、连接或版本错误不会回退到 Local,连接失败后也不会自动重试请求。
远程命令不会继承调用方的本地窗格 ID,请显式指定远程 ID。`--current` 不能引用本地窗格。远程 worktree 路径必须为绝对路径、`~` 或以 `~/` 开头(由服务器展开);插件 link 路径必须为绝对路径。每次只操作一台机器,不包含合并列表或通过其他 TUI 连接转发的功能。
## Shell 补全
@@ -87,7 +87,7 @@ UI 默认使用客户端本地的主题、侧边栏设置和按键绑定。选
有多台机器时,默认智能体行会显示 `machine` token。已有的自定义行保持不变;需要机器标签时请显式加入 `machine`。[侧边栏行布局](/zh-cn/docs/configuration/)还支持按机器标签设置条件颜色。
工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。两台机器可能都有 `w1:p1` 或名为 `reviewer` 的智能体。在 UI 中选择机器不会改变已有窗格中 CLI 命令的目标,它们仍使用继承的会话和 socket。远程自动化应在目标主机上针对目标会话执行命令,并在那里读取 ID
工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。两台机器可能都有 `w1:p1` 或名为 `reviewer` 的智能体。在 UI 中选择机器不会改变已有窗格中 CLI 命令的目标,它们仍使用继承的会话和 socket。远程自动化可使用 `herdr --machine <label-or-id> agent list` 获取 ID,并在后续操作中使用同一前缀。CLI 直接使用保存的 SSH 目标会话,不需要打开 TUI。省略 `--machine` 时现有行为不变。支持的命令、更新要求和远程路径规则见 [CLI 参考](/zh-cn/docs/cli-reference/)
## 更新与保存的数据
+13 -7
View File
@@ -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(
+4 -12
View File
@@ -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) => {
+21
View File
@@ -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)
+4
View File
@@ -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) => {
+1
View File
@@ -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")
+15 -13
View File
@@ -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 {
+426
View File
@@ -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:?}");
}
}
}
+30
View File
@@ -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());
});
}
}
+27 -13
View File
@@ -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",
+26
View File
@@ -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!(
+93 -6
View File
@@ -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,
@@ -2065,6 +2112,22 @@ impl SshStdioBridge {
session_name: String,
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 {
@@ -3550,6 +3611,16 @@ mod tests {
executable.bridge_command("agents"),
"$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"),
@@ -3664,6 +3735,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 {
+45 -4
View File
@@ -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
+404
View File
@@ -0,0 +1,404 @@
#![cfg(all(unix, not(target_os = "macos")))]
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
const PROFILE_ID: &str = "0123456789abcdef0123456789abcdef";
const SSH: &str = r#"#!/bin/sh
for arg do
last=$arg
printf '%s\n' "$arg" >> "$TEST_ROOT/ssh-args"
done
case "$last" in
*'command -v herdr') printf 'login banner\nherdr-remote-output-ready:1\n%s\n' "$TEST_REMOTE_HERDR" ;;
*'remote-api-bridge --check')
if [ "$TEST_MODE" = old ]; then exit 2; fi
exec /bin/sh -c "$last" ;;
*'remote-api-bridge')
if [ "$TEST_MODE" = offline ]; then echo 'test remote connection failed' >&2; exit 255; fi
exec /bin/sh -c "$last" ;;
'/bin/sh -s')
script=$(cat)
printf 'login banner\nherdr-remote-output-ready:1\n'
case "$script" in
*'uname -s'*) uname -s; uname -m ;;
*'version='*) printf '%s\n' "$TEST_REMOTE_HERDR" ;;
*) echo "unexpected discovery: $script" >&2; exit 2 ;;
esac ;;
*) echo "unexpected command: $last" >&2; exit 2 ;;
esac
"#;
struct Harness {
root: PathBuf,
remote: UnixListener,
local: UnixListener,
protocol: u64,
}
impl Harness {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let root = PathBuf::from(format!(
"/var/tmp/hma-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let app = if cfg!(debug_assertions) {
"herdr-dev"
} else {
"herdr"
};
let state = root.join("state").join(app).join("client");
let session = root.join("config").join(app).join("sessions/fleet");
fs::create_dir_all(&state).unwrap();
fs::create_dir_all(&session).unwrap();
fs::create_dir_all(root.join("bin")).unwrap();
fs::write(root.join("bin/ssh"), SSH).unwrap();
fs::set_permissions(root.join("bin/ssh"), fs::Permissions::from_mode(0o700)).unwrap();
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_herdr"), root.join("remote herdr")).unwrap();
fs::write(state.join("endpoints.json"), serde_json::to_vec(&json!({
"version": 1,
"ssh": [{"id": PROFILE_ID, "label": "mac", "target": "fake-mac", "session": "fleet", "enabled": true}]
})).unwrap()).unwrap();
let remote = UnixListener::bind(session.join("herdr.sock")).unwrap();
remote.set_nonblocking(true).unwrap();
let local = UnixListener::bind(root.join("local.sock")).unwrap();
local.set_nonblocking(true).unwrap();
let status = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["status", "client", "--json"])
.output()
.unwrap();
let status: Value = serde_json::from_slice(&status.stdout).unwrap();
Self {
root,
remote,
local,
protocol: status["protocol"].as_u64().unwrap(),
}
}
fn command(&self, args: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command
.args(args)
.env(
"PATH",
format!("{}:/usr/bin:/bin", self.root.join("bin").display()),
)
.env("HOME", &self.root)
.env("XDG_CONFIG_HOME", self.root.join("config"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", &self.root)
.env("TEST_ROOT", &self.root)
.env("TEST_REMOTE_HERDR", self.root.join("remote herdr"))
.env("HERDR_SOCKET_PATH", self.root.join("local.sock"))
.env(
"HERDR_CLIENT_SOCKET_PATH",
self.root.join("never-client.sock"),
)
.env("HERDR_SESSION", "wrong-inherited-session")
.env("HERDR_PANE_ID", "wrong-local-pane")
.env_remove("HERDR_CONFIG_PATH")
.env_remove("HERDR_REMOTE_BINARY");
command
}
fn serve(&self, result: Value, protocol: u64) -> std::thread::JoinHandle<Value> {
let listener = self.remote.try_clone().unwrap();
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
Instant::now() < deadline,
"remote API request did not arrive"
);
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(error) => panic!("{error}"),
};
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap())
.read_line(&mut line)
.unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
let ping = request["method"] == "ping";
let response = if ping {
json!({"id": request["id"], "result": {"type": "pong", "version": "test", "protocol": protocol}})
} else {
std::thread::sleep(Duration::from_millis(50));
let mut response = result.clone();
response["id"] = request["id"].clone();
response
};
writeln!(stream, "{response}").unwrap();
if !ping || protocol == 0 {
return request;
}
}
})
}
fn assert_local_untouched(&self) {
assert_eq!(
self.local.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
assert!(!self.root.join("never-client.sock").exists());
assert!(!fs::read_dir(&self.root).unwrap().any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("herdr-api-")));
}
}
impl Drop for Harness {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn success(output: Output) -> Value {
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).unwrap()
}
#[test]
fn machine_api_routes_structured_payload_and_remote_errors_without_local_fallback() {
let harness = Harness::new();
let server = harness.serve(
json!({"error":{"code":"test_remote_error","message":"remote rejected prompt"}}),
harness.protocol,
);
let prompt = "quotes ' \" ; $(touch should-not-exist)\n--machine other";
let output = harness
.command(&["--machine", "mac", "agent", "prompt", "w4:p1", prompt])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let error: Value = serde_json::from_slice(&output.stderr)
.unwrap_or_else(|error| panic!("{error}: {}", String::from_utf8_lossy(&output.stderr)));
assert_eq!(error["error"]["code"], "test_remote_error");
let request = server.join().unwrap();
assert_eq!(request["method"], "agent.prompt");
assert_eq!(request["params"]["text"], prompt);
let ssh_args = fs::read_to_string(harness.root.join("ssh-args")).unwrap();
assert!(ssh_args.contains("StrictHostKeyChecking=yes"));
assert!(ssh_args.contains("BatchMode=yes"));
assert!(ssh_args.contains("--session fleet remote-api-bridge"));
assert!(!ssh_args.contains("should-not-exist"));
harness.assert_local_untouched();
}
#[test]
fn machine_api_profile_id_routes_large_list_responses() {
let harness = Harness::new();
let data = "remote data ".repeat(20_000);
let server = harness.serve(
json!({"result":{"type":"agent_list","agents":[],"test_data":data}}),
harness.protocol,
);
let response = success(
harness
.command(&["--machine", PROFILE_ID, "agent", "list"])
.output()
.unwrap(),
);
assert_eq!(response["result"]["test_data"], data);
assert_eq!(server.join().unwrap()["method"], "agent.list");
harness.assert_local_untouched();
}
#[test]
fn machine_api_never_inherits_the_callers_pane() {
let harness = Harness::new();
let server = harness.serve(json!({"result":{"type":"ok"}}), harness.protocol);
success(
harness
.command(&["--machine=mac", "pane", "current"])
.output()
.unwrap(),
);
let request = server.join().unwrap();
assert_eq!(request["method"], "pane.current");
assert!(request["params"]["caller_pane_id"].is_null());
harness.assert_local_untouched();
}
#[test]
fn machine_api_remote_paths_and_wait_parameters_reach_the_server() {
for (args, method, expected) in [
(
vec![
"worktree",
"create",
"--cwd",
"~/Projects/herdr",
"--branch",
"review",
"--path",
"/srv/review",
],
"worktree.create",
json!({"cwd":"~/Projects/herdr", "branch":"review", "path":"/srv/review"}),
),
(
vec![
"agent",
"wait",
"w4:p1",
"--until",
"idle",
"--timeout",
"2000",
],
"agent.wait",
json!({"target":"w4:p1", "until":["idle"], "timeout_ms":2000}),
),
] {
let harness = Harness::new();
let server = harness.serve(json!({"result":{"type":"ok"}}), harness.protocol);
let mut command = harness.command(&["--machine", "mac"]);
success(command.args(args).output().unwrap());
let request = server.join().unwrap();
assert_eq!(request["method"], method);
for (key, value) in expected.as_object().unwrap() {
assert_eq!(&request["params"][key], value);
}
harness.assert_local_untouched();
}
}
#[test]
fn machine_api_status_reports_remote_identity_not_local_installation_state() {
let harness = Harness::new();
let server = harness.serve(json!({}), 0);
let status = success(
harness
.command(&["--machine", "mac", "status", "server", "--json"])
.output()
.unwrap(),
);
assert_eq!(status["session"], "fleet");
assert_eq!(status["socket"], format!("machine:{PROFILE_ID}/fleet"));
assert!(status["server_binary_stale"].is_null());
assert_eq!(server.join().unwrap()["method"], "ping");
harness.assert_local_untouched();
}
#[test]
fn machine_api_usage_errors_do_not_connect() {
let harness = Harness::new();
for args in [
vec!["--machine", "missing", "agent", "list"],
vec!["--machine", "mac", "config", "reset-keys"],
vec![
"--machine",
"mac",
"agent",
"explain",
"--file",
"/any/file",
"--agent",
"pi",
],
vec![
"--machine",
"mac",
"pane",
"split",
"--current",
"--direction",
"right",
],
vec!["--machine", "mac", "--session", "local", "agent", "list"],
] {
assert_eq!(
harness.command(&args).output().unwrap().status.code(),
Some(2)
);
}
assert!(!harness.root.join("ssh-args").exists());
harness.assert_local_untouched();
}
#[test]
fn machine_api_rejects_old_bridges_and_disconnected_machines() {
for (mode, message) in [
("old", "update Herdr"),
("offline", "test remote connection failed"),
] {
let harness = Harness::new();
let output = harness
.command(&["--machine", "mac", "agent", "list"])
.env("TEST_MODE", mode)
.output()
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains(message),
"{}",
String::from_utf8_lossy(&output.stderr)
);
harness.assert_local_untouched();
}
}
#[test]
fn machine_api_server_stop_is_sent_only_to_the_selected_machine() {
let harness = Harness::new();
let server = harness.serve(json!({"result":{"type":"ok"}}), harness.protocol);
let output = harness
.command(&["--machine", "mac", "server", "stop"])
.env("HERDR_SOCKET_PATH", harness.root.join("missing-local.sock"))
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
harness.root.join("ssh-args").exists(),
"server stop bypassed machine routing"
);
assert_eq!(server.join().unwrap()["method"], "server.stop");
harness.assert_local_untouched();
}
#[test]
fn machine_api_protocol_mismatch_never_sends_the_mutation() {
let harness = Harness::new();
let server = harness.serve(json!({}), 0);
let output = harness
.command(&["--machine", "mac", "pane", "close", "w4:p1"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert_eq!(server.join().unwrap()["method"], "ping");
let error = String::from_utf8_lossy(&output.stderr);
assert!(error.contains("protocol_mismatch"), "{error}");
assert!(error.contains("machine 'mac'"), "{error}");
assert!(!error.contains("HERDR_SOCKET_PATH="), "{error}");
harness.assert_local_untouched();
}