//! 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 would be able to introduce arbitrary programs to run on every //! machine a setup names, and the transport already reaches those over //! ssh. Here the phone's authority is "add this machine", never "run //! this". //! //! 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 without anyone editing anything. //! //! The cost is that a program somewhere unusual is invisible. That is a //! deliberate trade rather than an oversight: the escape hatch is editing //! `config.ron` on the backend, which is exactly the authority the phone //! is not being given. use anyhow::{Context, 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, and a person waiting on "test this /// setup" notices. `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 rather than /// the first failure. 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. Nothing to probe for, and 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, /// because its key is not in `known_hosts` yet -- 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, rather than /// something this app quietly accepts on their behalf. 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 already 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(), }) } /// Runs a launch to completion and returns its stdout. impl Transport { pub async fn capture(&self, launch: &Launch) -> Result { // See `CAPTURE_PERMITS`: caps how many of these run their ssh // connection at once, so a batch doesn't open more than the remote // sshd tolerates before it starts dropping them. let _permit = super::session::transport::CAPTURE_PERMITS .acquire() .await .expect("capture semaphore is never closed"); let child = self.spawn(launch, super::session::transport::Streams::Piped)?; let output = child .wait_with_output() .await .context("waiting for the probe to finish")?; if !output.status.success() { // ssh's own failures land on stderr -- "Permission denied", // "Could not resolve hostname" -- and are the useful half of // why a setup cannot be reached, so they are what comes back. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); anyhow::bail!(if stderr.is_empty() { format!("couldn't reach it ({})", output.status) } else { stderr }); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } }