Providers and hosts: what runs, and where, as independent choices
A session now names a provider (what: driver kind, command, models) and optionally a host (where: an ssh target). Keeping them independent is what the real setup needs -- the backend runs where the phone can reach it, which isn't where the CLI is installed -- and it means any provider can be sent to any host rather than a machine being baked into one. The first provider is claude-cli, named for the CLI rather than bare "claude", which would suggest the credit-billed API. A fresh config is seeded with it so a new install has something to spawn and a worked example to edit; echo stays a built-in provider needing no config. ssh.rs builds the child process either way: locally, or `ssh -T` with BatchMode and keepalives, every argument single-quoted for the remote shell (a working directory that tries to close the quote and start a command is covered by a test), and `exec` so dropping the connection takes the CLI down instead of orphaning it. App: the spawn screen reads /providers and /hosts instead of hardcoded lists, so config changes need no rebuild. Chip rows are FlowRow, fixing the reported bug where a row of models that didn't fit wrapped *inside* each chip -- one letter of "haiku" per line -- rather than onto a second line. Verified: 29 tests, clippy clean; the same claude-cli provider run once locally and once over ssh, with the remote one visibly in a different environment; an unknown host name refused with the configured list; and the spawn screen on the emulator showing server-driven providers, hosts, and models that wrap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
91bbc73ae5
commit
fff1fb49e8
12 files changed
+833
-170
No files matched your search
@@ -0,0 +1,205 @@
|
||||
//! 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::HostConfig;
|
||||
|
||||
/// 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 (`host` absent) or on `host`.
|
||||
pub fn command(
|
||||
host: Option<&HostConfig>,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: Option<&Path>,
|
||||
) -> Command {
|
||||
let Some(ssh) = host 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("e(&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
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
#[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 = HostConfig {
|
||||
name: "vm".to_string(),
|
||||
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 = HostConfig {
|
||||
name: "vm".to_string(),
|
||||
address: "vm".to_string(),
|
||||
port: None,
|
||||
identity_file: None,
|
||||
options: vec![],
|
||||
};
|
||||
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 = HostConfig {
|
||||
name: "vm".to_string(),
|
||||
address: "vm".to_string(),
|
||||
port: None,
|
||||
identity_file: None,
|
||||
options: vec![],
|
||||
};
|
||||
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; '\" "));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user