//! Finding out what a machine can run, rather than being told. //! //! The phone adds a machine by giving connection details; this asks the machine //! itself which of the known programs it has, and the answer becomes its //! providers. That is a security property, not a convenience: **no route accepts //! a command from the phone.** If it did, the enrolled token could introduce //! arbitrary programs to run on every machine a setup names. //! //! It is also the better interface: nobody wants to type an absolute path on a //! phone keyboard, and a machine that has moved its binaries answers correctly //! on the next probe. //! //! The cost is that a program somewhere unusual is invisible. The escape hatch //! is editing `config.ron` on the backend, which is exactly the authority the //! phone is not being given. use anyhow::Result; use crate::config::{DriverKind, ProviderConfig}; use crate::session::transport::{Launch, Transport}; /// What is looked for, and what finding it makes. Extending this is how a new /// driver becomes discoverable -- one row, not a branch anywhere. The name is /// what the provider gets called, so it is what the phone shows and what a /// session stores. const PROBES: &[(&str, &str, DriverKind)] = &[ ("claude-cli", "claude", DriverKind::ClaudeCli), ("local-llama", "llama-server", DriverKind::LlamaCpp), ]; /// Models offered for a discovered Claude CLI. A shortcut list for the spawn /// screen, not a restriction -- the field stays free text. const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"]; /// Asks `transport`'s machine which of [`PROBES`] it has. /// /// One round trip rather than one per program: over ssh each would be a separate /// connection and handshake. `command -v` is POSIX and a shell builtin, so it /// works whatever is installed -- and `|| true` keeps a missing program from /// ending the loop, since the caller wants the whole answer. pub async fn discover(transport: &Transport) -> Result> { let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect(); let script = format!( "for p in {}; do command -v \"$p\" || true; done", wanted.join(" ") ); let launch = Launch::new("sh", vec!["-c".to_string(), script], None); let found = transport.capture(&launch).await.map_err(explain)?; let mut providers = Vec::new(); // Echo runs inside this server, so it exists exactly where this server does // and nowhere else. Offering it on a remote machine would be a choice that // changes nothing. if matches!(transport, Transport::Here) { providers.push(ProviderConfig { name: crate::config::ECHO_PROVIDER.to_string(), kind: DriverKind::Echo, command: None, models: Vec::new(), }); } for (name, binary, kind) in PROBES { let path = found .lines() .map(str::trim) .find(|line| line.rsplit('/').next() == Some(*binary)); let Some(path) = path else { continue; }; providers.push(ProviderConfig { name: (*name).to_string(), kind: *kind, // The resolved path rather than the bare name: PATH under a // non-interactive ssh session is not the one a person sees when they // log in, so "it is on my PATH" is not enough. command: Some(path.to_string()), models: match kind { DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(), _ => Vec::new(), }, }); } Ok(providers) } /// Adds what to do to failures whose own wording does not say. /// /// ssh's messages are written for someone at a terminal on the backend, which is /// exactly who is not reading this one. Host key verification is the case that /// matters: **every** machine fails it the first time, so without this, adding a /// machine from the phone looks broken rather than unfinished. /// /// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays /// at its default, so a first connection is a decision somebody makes on the /// backend with the key in front of them. fn explain(err: anyhow::Error) -> anyhow::Error { let message = format!("{err:#}"); if message.contains("Host key verification failed") { return anyhow::anyhow!( "{message} This machine has not been connected to before, so its key is not \ trusted yet. Ssh to it once from the backend -- that is where the decision to \ trust a key belongs -- and try again.", ); } if message.contains("Permission denied") { return anyhow::anyhow!( "{message} The key named here has to be authorized on that machine, and the path \ is read on the backend rather than on the phone.", ); } err } /// A short, stable, filename-safe id derived from a label. Derived once when a /// setup is added and then fixed, so the label stays editable. Collisions are /// resolved by the caller, which is the only place that knows what exists. pub fn id_from(label: &str) -> String { let slug: String = label .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' } }) .collect(); let slug = slug.trim_matches('-').replace("--", "-"); if slug.is_empty() { crate::session::random_hex() } else { slug.chars().take(32).collect() } } /// Normalises what a phone keyboard produced: trims, drops blanks, and /// expands a leading `~` the way a shell would. pub fn tidy(value: &str) -> Option { let value = value.trim(); if value.is_empty() { return None; } Some(match value.strip_prefix("~/") { Some(rest) => match std::env::home_dir() { Some(home) => home.join(rest).to_string_lossy().into_owned(), None => value.to_string(), }, None => value.to_string(), }) } /// The inverse of [`tidy`]'s expansion: an absolute path under this machine's /// home, written back as `~/…`, so that a working directory reads on a phone the /// way it is written by hand. /// /// Applied only to paths on **this** machine. `$HOME` here says nothing about /// the home directory of a machine reached over ssh, so a remote path is stored /// exactly as it was typed and the remote shell is what expands it. pub fn shorten_home(path: &str) -> String { let Some(home) = std::env::home_dir() else { return path.to_string(); }; let home = home.to_string_lossy(); // The separator has to be part of the match, or `/home/bobby` would be read // as a path inside `/home/bob`. match path.strip_prefix(home.as_ref()) { Some("") => "~".to_string(), Some(rest) if rest.starts_with('/') => format!("~{rest}"), _ => path.to_string(), } } /// Runs a launch to completion and returns its stdout as text. /// /// The common case of [`Transport::capture_with_input`]: nothing on stdin, a /// failure reported as the machine's own words (ssh's "Permission denied" is the /// useful half of why a setup cannot be reached), and the output read as text /// because every caller here is asking a question whose answer is words. impl Transport { pub async fn capture(&self, launch: &Launch) -> Result { let captured = self .capture_with_input(launch, super::session::transport::Input::None) .await?; Ok(String::from_utf8_lossy(&captured.ok()?).into_owned()) } } #[cfg(test)] mod tests { use super::*; /// The two halves of a home-relative path, which have to be inverses: what /// is stored is what the phone draws, and what the phone sends back is what /// a process is started in. #[test] fn a_home_path_shortens_and_expands_back() { let Some(home) = std::env::home_dir() else { return; }; let full = home.join("repos/ai-app-2"); let full = full.to_string_lossy(); assert_eq!(shorten_home(&full), "~/repos/ai-app-2"); assert_eq!(shorten_home(&home.to_string_lossy()), "~"); assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref())); // Not a prefix match on the characters: a sibling directory whose name // merely starts with the home directory's is not inside it. let sibling = format!("{}-backup/notes", home.to_string_lossy()); assert_eq!(shorten_home(&sibling), sibling); assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); } }