diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 23:33:27 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 23:33:27 +0300 |
| commit | 043b804c44665b07bd503fdad896bbe0a29311a0 (patch) | |
| tree | 79bae0b64ba1c0e22328b0401196587633399c60 /tests | |
| parent | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (diff) | |
feat: route cli commands to saved ssh machines (#3918)
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/machine_api.rs | 404 |
1 files changed, 404 insertions, 0 deletions
diff --git a/tests/machine_api.rs b/tests/machine_api.rs new file mode 100644 index 00000000..895e440a --- /dev/null +++ b/tests/machine_api.rs @@ -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(); +} |
