Files
ai-app/server/src/ssh.rs
T
irisandClaude Opus 5 ecac404fd4 A setup is a machine, and it carries what that machine can run
Providers and hosts were two independent lists, and a session named one of
each. They were never independent: a provider only exists on a machine
where that program is installed, so the spawn screen offered the whole
cross-product, including "the Claude CLI on the box that hasn't got it".
The picker could not know, because nothing in the model said.

Now a setup is a machine -- optional ssh, plus the providers it has -- and
spawning is two choices in order: pick a setup, then one of its providers.
The impossible pairs stop being expressible rather than being validated
against. Provider names are unique within a setup and only within one, so
two machines can each have a `claude-cli`, which was previously either a
name collision or two entries called things like "claude" and "claude on
the vm".

It also settles the "Run on" problem properly. That control was offered for
every provider but honoured only by the Claude driver -- an echo session
sent to a host ran locally and said otherwise. There is no such control
now: the machine is chosen first, and echo is a provider of the setup with
no ssh, where it belongs, since it runs in-process and has no transport to
cross.

The built-in echo provider is gone as a concept. It used to be conjured at
read time and never written to the file, which meant a provider nobody
could see or edit; it is now seeded into the config on first run alongside
claude-cli. What the file says is what there is, and deleting it is a
choice rather than a state to be repaired.

A config in the old shape is refused with instructions rather than loaded.
`Config` defaults unknown fields away, so `providers:` and `hosts:` would
otherwise have vanished into an empty config that was then seeded over --
a migration nobody would notice until their setups were gone.

Verified against a running server and on the emulator: a fresh install
seeds "this machine" with echo and claude-cli and the file reads cleanly;
a two-setup config lists both with their own providers; spawning on a
setup works and the session row names it; asking for a provider a setup
lacks says which it offers, and an unknown setup says which exist. On the
phone, selecting "dev vm" narrows the provider chips to that machine's one
and shows its address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 12:56:14 -04:00

219 lines
7.7 KiB
Rust

//! Building the command a driver actually spawns -- locally, or wrapped in
//! `ssh` when the session names a host to run on.
//!
//! The whole point of the session design is that a driver speaks JSONL over
//! a child process's stdio and doesn't care what that child is. A remote
//! session is therefore the identical command with `ssh host …` in front:
//! stdio doesn't care, so nothing downstream of here changes.
//!
//! 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 (PLAN.md, rule 23).
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use crate::config::SshConfig;
/// Options forced onto every connection. `BatchMode` makes a missing key
/// fail immediately with a readable message instead of hanging on a
/// password prompt that nothing can answer; the keepalives turn a silently
/// dropped link into a process exit, which the session reports as `exited`
/// rather than appearing to hang forever.
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.
pub fn command(
remote: Option<&SshConfig>,
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Command {
let Some(ssh) = remote else {
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
return configure(command);
};
let mut command = Command::new("ssh");
// -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 than intended.
command.args(["-o", "IdentitiesOnly=yes"]);
}
command.arg(&ssh.address);
command.arg(remote_script(program, args, cwd));
configure(command)
}
fn configure(mut command: Command) -> Command {
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
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(&quote(&cwd.to_string_lossy()));
script.push_str(" && ");
}
script.push_str("exec ");
script.push_str(&quote(program));
for arg in args {
script.push(' ');
script.push_str(&quote(arg));
}
script
}
/// 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.
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<const N: usize>(args: [&str; N]) -> Vec<String> {
args.iter().map(|arg| arg.to_string()).collect()
}
/// The rendered argv, for asserting on what would actually run.
fn argv(command: &Command) -> Vec<String> {
let std = command.as_std();
std::iter::once(std.get_program())
.chain(std.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![],
}
}
#[test]
fn a_session_with_no_host_runs_the_command_directly() {
let command = command(
None,
"claude",
&args(["-p", "--verbose"]),
Some(Path::new("/tmp/x")),
);
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
assert_eq!(
command.as_std().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()],
};
let rendered = argv(&command(
Some(&ssh),
"claude",
&args(["-p", "--model", "haiku"]),
Some(Path::new("/home/bob/work")),
));
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));
assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'");
// No -i means no IdentitiesOnly: ~/.ssh/config decides instead.
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
}
#[test]
fn shell_metacharacters_cross_as_data_not_syntax() {
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)));
let script = rendered.last().unwrap();
assert_eq!(
script,
r"cd '/tmp/'\''; touch /tmp/pwned; '\''' && exec 'claude'"
);
assert!(!script.contains("; touch /tmp/pwned; '\" "));
}
}