//! Building the command a driver actually spawns -- locally, or wrapped in //! `ssh` when the session names a host to run on. //! //! A driver speaks JSONL over a child process's stdio and doesn't care what //! that child is, so a remote session is the identical command with `ssh host …` //! in front. //! //! Uses the system `ssh` client rather than a Rust SSH library, so //! `~/.ssh/config`, agents and jump hosts all keep working and there is only //! one place to configure connections. use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::SshConfig; /// A port on the machine a command runs on, and the port that reaches it from /// the backend. /// /// The second half of what a transport is (PLAN.md's SSH section): "run this" /// plus "reach this port". Locally the two numbers are one and nothing is /// forwarded; over ssh the connection carries an `-L` tunnel, so a model server /// binds loopback on the far machine and is never exposed to its network. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Forward { /// What the launched program should listen on, on its own machine. pub there: u16, /// What this machine connects to. The same number as `there` when the /// program runs here. pub here: u16, } /// Options forced onto every connection. `BatchMode` makes a missing key fail /// immediately with a readable message instead of hanging on a password prompt /// nothing can answer; the keepalives turn a silently dropped link into a /// process exit, which the session reports as `exited` rather than hanging. const SSH_OPTIONS: [&str; 3] = [ "BatchMode=yes", "ServerAliveInterval=30", "ServerAliveCountMax=3", ]; /// Builds the child process for `program args…`, run in `cwd`, either on this /// machine (`ssh` absent) or on the machine it describes. /// /// Stdio is left alone: how the streams are connected is the caller's decision /// and differs by more than the transport does -- a probe wants pipes it will /// drain, a session wants files that outlive this server. /// /// A plain [`std::process::Command`], which `tokio` converts from, because not /// every caller is async: the usage fetch is blocking by nature and should not /// have to build an ssh invocation of its own. pub fn command( remote: Option<&SshConfig>, program: &str, args: &[String], cwd: Option<&Path>, forward: Option, ) -> Command { let Some(ssh) = remote else { let mut command = Command::new(program); command.args(args); if let Some(cwd) = cwd { // Expanded here for the same reason `quote_path` expands it on the // far side: a working directory typed as `~/repos/ai-app` has to // mean the same thing whichever machine runs it. There is no shell // in this branch, so nothing else would -- `current_dir` would be // handed the literal one-character directory `~`. command.current_dir(expand_home(cwd)); } return command; }; let mut command = Command::new("ssh"); if let Some(forward) = forward { // A forwarded process is not spoken to over stdio, and that changes how // it is shut down. Everything else here is a CLI reading its stdin, so // killing the ssh client ends it; a `llama-server` never reads its own, // so the same kill left it running on the far machine with the model // loaded -- measured 2026-09-04, an orphan per stopped session. A pty // is what makes sshd hang the far side up. `-tt` because this client // has no terminal to inherit one from. The cost is a log that arrives // through a line discipline, which nothing parses. command.arg("-tt"); // Loopback at both ends: the far side binds 127.0.0.1, so what it // serves is reachable only through this connection. command.args([ "-L", &format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there), ]); // Without this a forward that cannot be set up is a warning on stderr // and a session that runs anyway, answering nothing -- which would // arrive as "the model never became ready". command.args(["-o", "ExitOnForwardFailure=yes"]); } else { // -T: no pty. This carries JSONL, and a pty would rewrite it (echo, // CRLF translation, ^C handling) into something the parser can't read. command.arg("-T"); } for option in SSH_OPTIONS { command.args(["-o", option]); } for option in &ssh.options { command.args(["-o", option]); } if let Some(port) = ssh.port { command.args(["-p", &port.to_string()]); } if let Some(identity) = &ssh.identity_file { command.arg("-i").arg(identity); // Without this, ssh may offer an agent key first and authenticate as // somebody else entirely -- silently, and with different permissions. command.args(["-o", "IdentitiesOnly=yes"]); } command.arg(&ssh.address); command.arg(remote_script(program, args, cwd)); command } /// The single argument handed to the remote login shell. `exec` so the CLI /// replaces that shell: the process the connection is attached to is then the /// CLI itself, and dropping the connection takes it down rather than leaving an /// orphan behind a live wrapper. fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String { let mut script = String::new(); if let Some(cwd) = cwd { script.push_str("cd "); script.push_str("e_path(&cwd.to_string_lossy())); script.push_str(" && "); } script.push_str("exec "); script.push_str("e(program)); for arg in args { script.push(' '); script.push_str("e(arg)); } script } /// A path with a leading `~` replaced by this machine's home directory. /// /// The local half of the rule [`quote_path`] states for the remote one, and /// deliberately the same shape: the tilde is expanded, `~user` is not, and /// nothing else in the path gains a meaning. A machine with no home directory /// leaves the path alone, which fails with the operating system's own message. pub(crate) fn expand_home(path: &Path) -> PathBuf { let Some(rest) = path.to_str().and_then(|p| { if p == "~" { Some("") } else { p.strip_prefix("~/") } }) else { return path.to_path_buf(); }; match std::env::home_dir() { Some(home) => home.join(rest), None => path.to_path_buf(), } } /// Quotes a path, expanding a leading `~` and nothing else. /// /// [`quote`] is right for every other word crossing to the remote side and /// wrong for exactly one character. `~` means "expand me", and single quotes /// are what stop expansion -- so a working directory typed as `~/repos/ai-app` /// arrived as the literal four-character directory `~`, and the remote shell /// said it did not exist, which reads like the path being wrong. /// /// `"$HOME"` rather than handing the tilde to the shell unquoted: the variable /// is expanded, the expansion is not re-split or globbed because it is /// double-quoted, and everything after it stays single-quoted and literal. /// `$HOME` is set by every shell this can land in, including the fish login /// shell on the dev VM, so this does not depend on the remote shell being POSIX. /// /// `~user` is deliberately not handled: there is no portable expansion for it, /// and inventing one would mean guessing another account's home directory. pub(crate) fn quote_path(path: &str) -> String { if path == "~" { return "\"$HOME\"".to_string(); } match path.strip_prefix("~/") { Some(rest) => format!("\"$HOME\"/{}", quote(rest)), None => quote(path), } } /// Single-quotes one word for a POSIX shell. Everything crossing to the remote /// side goes through here: paths, model names and prompts-as-arguments are all /// attacker-adjacent input in a server whose whole job is running commands, and /// unquoted they would be shell syntax rather than data. pub(crate) fn quote(word: &str) -> String { // Inside single quotes every character is literal except `'` itself, which // is closed, escaped, and reopened. format!("'{}'", word.replace('\'', r"'\''")) } #[cfg(test)] mod tests { use super::*; fn args(args: [&str; N]) -> Vec { args.iter().map(|arg| arg.to_string()).collect() } /// The rendered argv, for asserting on what would actually run. fn argv(command: &Command) -> Vec { std::iter::once(command.get_program()) .chain(command.get_args()) .map(|arg| arg.to_string_lossy().into_owned()) .collect() } /// A host with nothing configured but a name to dial, so `~/.ssh/config` /// decides everything else -- the case that proves this adds no flags of its /// own when it was not told to. fn bare_host() -> SshConfig { SshConfig { address: "vm".to_string(), port: None, identity_file: None, options: vec![], models_dir: None, attachments_dir: None, } } #[test] fn a_session_with_no_host_runs_the_command_directly() { let command = command( None, "claude", &args(["-p", "--verbose"]), Some(Path::new("/tmp/x")), None, ); assert_eq!(argv(&command), ["claude", "-p", "--verbose"]); assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/x"))); } #[test] fn a_session_with_a_host_wraps_the_same_command_in_ssh() { let ssh = SshConfig { address: "bob@10.0.2.15".to_string(), port: Some(2222), identity_file: Some("/home/me/.ssh/id_ai".into()), options: vec!["StrictHostKeyChecking=accept-new".to_string()], models_dir: None, attachments_dir: None, }; let rendered = argv(&command( Some(&ssh), "claude", &args(["-p", "--model", "haiku"]), Some(Path::new("/home/bob/work")), None, )); assert_eq!(rendered[0], "ssh"); assert!(rendered.contains(&"-T".to_string())); assert!(rendered.contains(&"BatchMode=yes".to_string())); assert!(rendered.contains(&"StrictHostKeyChecking=accept-new".to_string())); assert!(rendered.contains(&"IdentitiesOnly=yes".to_string())); assert!(rendered.contains(&"2222".to_string())); assert!(rendered.contains(&"/home/me/.ssh/id_ai".to_string())); // The host, then exactly one argument: the remote script. assert_eq!(rendered[rendered.len() - 2], "bob@10.0.2.15"); assert_eq!( rendered[rendered.len() - 1], "cd '/home/bob/work' && exec 'claude' '-p' '--model' 'haiku'", ); } #[test] fn a_remote_command_without_a_cwd_just_execs() { let ssh = bare_host(); let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None)); assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'"); // No -i means no IdentitiesOnly: ~/.ssh/config decides instead. assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string())); } /// The second half of a transport: the connection that runs the command also /// carries the port that reaches it. /// /// Both ends are pinned to loopback, which is what keeps a model server off /// the far machine's network -- asserted rather than trusted, because /// dropping the addresses is a one-word edit that still works on a machine /// nobody else can reach. #[test] fn a_forwarded_port_rides_the_same_connection_as_the_command() { let ssh = bare_host(); let rendered = argv(&command( Some(&ssh), "llama-server", &args(["--port", "24242"]), None, Some(Forward { there: 24242, here: 41000, }), )); let forward = rendered .iter() .position(|arg| arg == "-L") .expect("a forward"); assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242"); assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string())); // The half that is easy to lose: without a pty the far process outlives // the connection, because nothing closes a stdin it never reads. assert!(rendered.contains(&"-tt".to_string())); assert!(!rendered.contains(&"-T".to_string())); // Options come before the host, or ssh reads them as part of the // remote command. assert!(forward < rendered.len() - 2); assert_eq!( rendered.last().unwrap(), "exec 'llama-server' '--port' '24242'" ); // Nothing forwarded is nothing added: every other session is one of // these, and an -L on it would bind a port for no reason. let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None)); assert!(!plain.contains(&"-L".to_string())); // And a session that *is* spoken to over stdio keeps its raw pipe. assert!(plain.contains(&"-T".to_string())); assert!(!plain.contains(&"-tt".to_string())); } /// The one character quoting must not swallow. A working directory typed as /// `~/repos/ai-app` was arriving as the literal directory `~`, and the /// remote shell reported it missing -- which reads as the path being wrong /// rather than the quoting being wrong, and cost an evening. #[test] fn a_leading_tilde_expands_and_nothing_else_does() { assert_eq!(quote_path("~"), "\"$HOME\""); assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'"); // Only leading, and only its own segment: a tilde anywhere else is an // ordinary character in a filename, and `~user` has no portable // expansion so it stays literal. assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'"); assert_eq!(quote_path("~user/x"), "'~user/x'"); // And it reaches the script the remote shell is handed. assert_eq!( remote_script("claude", &args(["-p"]), Some(Path::new("~/repos/ai-app"))), "cd \"$HOME\"/'repos/ai-app' && exec 'claude' '-p'", ); } /// The same character, on the transport with no shell to expand it. The /// local branch runs the program directly, so a working directory of /// `~/repos/ai-app` would reach `current_dir` as the literal one-character /// directory `~`. The two transports have to agree about what a tilde means /// or a path is only portable by accident. #[test] fn a_local_cwd_expands_its_tilde_the_same_way() { let Some(home) = std::env::home_dir() else { return; }; assert_eq!( expand_home(Path::new("~/repos/ai-app")), home.join("repos/ai-app") ); assert_eq!(expand_home(Path::new("~")), home); // Leading only, and its own segment only -- `quote_path`'s rule. assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x")); assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x")); let local = command( None, "claude", &args(["-p"]), Some(Path::new("~/work")), None, ); assert_eq!(local.get_current_dir(), Some(home.join("work").as_path())); } #[test] fn shell_metacharacters_cross_as_data_not_syntax() { // Expanding $HOME must not open a door for anything else: the rest stays // single-quoted, so this remains one absurd path rather than three // commands. assert_eq!( quote_path("~/'; touch /tmp/pwned; '"), r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#, ); assert_eq!(quote("plain"), "'plain'"); assert_eq!(quote("with space"), "'with space'"); assert_eq!(quote("; rm -rf /"), "'; rm -rf /'"); assert_eq!(quote("$(whoami)"), "'$(whoami)'"); assert_eq!(quote("it's"), r"'it'\''s'"); // The end-to-end version of the same worry: a working directory that // tries to close the quote and start a new command. let ssh = bare_host(); let evil = Path::new("/tmp/'; touch /tmp/pwned; '"); let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None)); let script = rendered.last().unwrap(); assert_eq!( script, r"cd '/tmp/'\''; touch /tmp/pwned; '\''' && exec 'claude'" ); assert!(!script.contains("; touch /tmp/pwned; '\" ")); } }