diff --git a/server/src/config.rs b/server/src/config.rs index d809f5e..f796253 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -104,8 +104,12 @@ pub struct Config { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetupConfig { - /// What the spawn screen shows and sessions store. Unique; renaming - /// one orphans the sessions that reference it. + /// Stable identifier, minted when the setup is added and never + /// changed. Sessions reference this rather than the label, so + /// renaming a machine on the phone does not orphan its sessions -- + /// which is the whole reason the two are separate fields. + pub id: String, + /// The label a person reads and may edit. pub name: String, /// How to reach it, absent for this machine. A setup with no `ssh` is /// where the server itself runs. @@ -202,7 +206,8 @@ pub struct TokenEntry { pub struct SessionConfig { /// Stable identifier; names the session's directory and its routes. pub id: String, - /// Name of the [`SetupConfig`] this session runs on. + /// Id of the [`SetupConfig`] this session runs on -- the id, not the + /// label, so the machine can be renamed without losing its sessions. pub setup: String, /// Name of the provider within that setup. Both stored by name rather /// than resolved, so an edited setup (a new command path, another @@ -247,9 +252,19 @@ pub struct SessionConfig { /// a choice. pub const ECHO_PROVIDER: &str = "echo"; pub const LOCAL_SETUP: &str = "this machine"; +/// The id of the setup a fresh install seeds. Fixed rather than random so +/// a hand-written config can name it without looking one up. +pub const LOCAL_SETUP_ID: &str = "local"; impl Config { - pub fn setup(&self, name: &str) -> Option<&SetupConfig> { + pub fn setup(&self, id: &str) -> Option<&SetupConfig> { + self.setups.iter().find(|setup| setup.id == id) + } + + /// A setup by the label a person sees, for messages and for the one + /// place a name still arrives from outside: nothing else should look + /// one up this way, since labels are editable and ids are not. + pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> { self.setups.iter().find(|setup| setup.name == name) } @@ -261,6 +276,7 @@ impl Config { /// remote machine would be a choice that changes nothing. pub fn seed() -> SetupConfig { SetupConfig { + id: LOCAL_SETUP_ID.to_string(), name: LOCAL_SETUP.to_string(), ssh: None, providers: vec![ @@ -380,7 +396,8 @@ mod tests { setups: vec![ Config::seed(), SetupConfig { - name: "vm".to_string(), + id: "vm".to_string(), + name: "the vm".to_string(), ssh: Some(SshConfig { address: "bob@10.0.2.15".to_string(), port: Some(2222), @@ -412,6 +429,8 @@ mod tests { let loaded = Config::load(&path).expect("reload"); assert_eq!(loaded.tokens[0].name, "phone"); assert_eq!(loaded.sessions[0].setup, "vm"); + // The label and the id are separate, and the session holds the id. + assert_eq!(loaded.setup("vm").expect("setup").name, "the vm"); assert_eq!(loaded.sessions[0].provider, "claude-cli"); assert_eq!( loaded @@ -427,12 +446,12 @@ mod tests { // collision: names are unique within a setup and only within one. assert!( loaded - .setup(LOCAL_SETUP) + .setup(LOCAL_SETUP_ID) .expect("local") .provider("claude-cli") .is_some() ); - assert!(loaded.setup(LOCAL_SETUP).expect("local").ssh.is_none()); + assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none()); // The house rule both halves of `format` depend on: what is written // is the *body* of the struct, with no outer parentheses and diff --git a/server/src/main.rs b/server/src/main.rs index f88bb93..1b55a20 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -21,6 +21,7 @@ mod models; mod private; mod routes; mod session; +mod setups; mod ssh; mod usage; diff --git a/server/src/routes.rs b/server/src/routes.rs index 1a8310c..0ef51c1 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -4,6 +4,11 @@ //! //! ```text //! GET /setups machines, each with what it can run +//! POST /setups add {name, ssh?} -- providers are discovered +//! POST /setups/probe dry run {ssh?}: what would be found there +//! GET /setups/{id} one machine, for refetching after a change +//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?} +//! DELETE /setups/{id} remove, refused while sessions use it //! GET /sessions list (id, provider, title, model, status, last activity) //! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?} //! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live @@ -45,7 +50,12 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; pub fn router(manager: Arc) -> Router { Router::new() - .route("/setups", get(list_setups)) + .route("/setups", get(list_setups).post(add_setup)) + .route("/setups/probe", post(probe_setup)) + .route( + "/setups/{id}", + get(read_setup).put(update_setup).delete(delete_setup), + ) .route("/sessions", get(list_sessions).post(spawn_session)) .route("/sessions/{id}", delete(delete_session)) .route("/sessions/{id}/events", get(events)) @@ -122,6 +132,9 @@ async fn list_sessions(State(manager): State>) -> axum::Json #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct SetupInfo { + /// Stable; what a session stores and what these routes address. + id: String, + /// The editable label. name: String, /// Where it runs, for telling two setups apart. Absent for the one /// that is this machine. @@ -139,25 +152,196 @@ struct ProviderInfo { } async fn list_setups(State(manager): State>) -> axum::Json> { - axum::Json( - manager - .setups() + axum::Json(manager.setups().into_iter().map(info_for).collect()) +} + +fn info_for(setup: crate::config::SetupConfig) -> SetupInfo { + SetupInfo { + id: setup.id, + name: setup.name, + address: setup.ssh.map(|ssh| ssh.address), + providers: setup + .providers .into_iter() - .map(|setup| SetupInfo { - name: setup.name, - address: setup.ssh.map(|ssh| ssh.address), - providers: setup - .providers - .into_iter() - .map(|provider| ProviderInfo { - name: provider.name, - kind: provider.kind, - models: provider.models, - }) - .collect(), + .map(|provider| ProviderInfo { + name: provider.name, + kind: provider.kind, + models: provider.models, }) .collect(), - ) + } +} + +/// How to reach a machine, as the phone describes it. +/// +/// Note what is absent: nothing here names a program. Providers are found +/// by asking the machine (`crate::setups`), never sent, so the enrolled +/// token cannot introduce something to run. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SshRequest { + address: String, + #[serde(default)] + port: Option, + /// A path on the *backend*, not a key itself: private keys do not + /// travel, so this names one that must already be there. + #[serde(default)] + identity_file: Option, + #[serde(default)] + options: Vec, +} + +impl SshRequest { + /// Tidied at the boundary rather than stored as typed -- this came + /// from a phone keyboard, so it may have a stray space or a `~`. + fn into_config(self) -> Result { + let address = crate::setups::tidy(&self.address) + .ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?; + Ok(crate::config::SshConfig { + address, + port: self.port, + identity_file: self + .identity_file + .as_deref() + .and_then(crate::setups::tidy) + .map(std::path::PathBuf::from), + options: self + .options + .iter() + .filter_map(|o| crate::setups::tidy(o)) + .collect(), + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddSetupRequest { + name: String, + /// Absent means this machine. + #[serde(default)] + ssh: Option, +} + +/// What a machine turned out to have, without saving anything. +/// +/// The point of trying before committing: a wrong address or an +/// unauthorised key is caught while the person is still looking at the +/// form that caused it, rather than at the first spawn. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProbeRequest { + #[serde(default)] + ssh: Option, +} + +async fn probe_setup( + axum::Json(body): axum::Json, +) -> Result>, ApiError> { + let ssh = body.ssh.map(SshRequest::into_config).transpose()?; + let providers = probe(ssh, "this setup").await?; + Ok(axum::Json( + providers + .into_iter() + .map(|provider| ProviderInfo { + name: provider.name, + kind: provider.kind, + models: provider.models, + }) + .collect(), + )) +} + +/// Asks the machine an `ssh` block describes -- or this one -- what it has. +/// +/// `label` only ever appears in a failure message, so a probe of an +/// unsaved form can still say which machine would not answer. +async fn probe( + ssh: Option, + label: &str, +) -> Result, ApiError> { + let transport = match ssh { + Some(ssh) => crate::session::transport::Transport::Ssh { + name: label.to_string(), + ssh, + }, + None => crate::session::transport::Transport::Here, + }; + crate::setups::discover(&transport) + .await + .map_err(bad_request) +} + +async fn add_setup( + State(manager): State>, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let ssh = body.ssh.map(SshRequest::into_config).transpose()?; + // Ask the machine being added what it has, before writing anything -- + // so a bad address fails here rather than leaving a setup that can + // never spawn. + let providers = probe(ssh.clone(), &body.name).await?; + let setup = manager + .add_setup(&body.name, ssh, providers) + .map_err(bad_request)?; + Ok(axum::Json(info_for(setup))) +} + +async fn read_setup( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result, ApiError> { + manager + .setups() + .into_iter() + .find(|setup| setup.id == id) + .map(|setup| axum::Json(info_for(setup))) + .ok_or_else(|| ApiError::NotFound(format!("no setup {id}"))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdateSetupRequest { + #[serde(default)] + name: Option, + /// Ask the machine again what it has -- after installing something + /// there, or when a binary moved. + #[serde(default)] + rediscover: bool, +} + +async fn update_setup( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let providers = if body.rediscover { + let existing = manager + .setups() + .into_iter() + .find(|setup| setup.id == id) + .ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))?; + let transport = crate::session::transport::Transport::for_setup(&existing); + Some( + crate::setups::discover(&transport) + .await + .map_err(bad_request)?, + ) + } else { + None + }; + let setup = manager + .update_setup(&id, body.name.as_deref(), providers) + .map_err(bad_request)?; + Ok(axum::Json(info_for(setup))) +} + +async fn delete_setup( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + manager.delete_setup(&id).map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize)] diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 75dd026..f9b1fbc 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -25,7 +25,9 @@ use anyhow::{Context, Result, bail}; use serde::Serialize; use tokio::sync::{broadcast, mpsc}; -use crate::config::{Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, TokenEntry}; +use crate::config::{ + Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry, +}; use claude::ClaudeDriver; use driver::{Driver, Event, ImageRef, SessionStatus}; use echo::EchoDriver; @@ -64,8 +66,12 @@ pub struct SpawnSpec { pub struct SessionInfo { pub id: String, pub provider: String, - /// The machine it runs on. + /// Id of the machine it runs on, which is what the session stored. pub setup: String, + /// That machine's current label, resolved when this row is built -- + /// so renaming a setup renames it everywhere it appears, rather than + /// leaving old sessions showing the old name. + pub setup_name: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -167,11 +173,14 @@ impl LiveSession { Ok(name) } - fn info(&self) -> SessionInfo { + /// `setup_name` is passed in rather than stored: only the manager + /// holds the config, and the label can change under a running session. + fn info(&self, setup_name: &str) -> SessionInfo { SessionInfo { id: self.meta.id.clone(), provider: self.meta.provider.clone(), setup: self.meta.setup.clone(), + setup_name: setup_name.to_string(), title: self.meta.title.clone(), model: self.shared.model.lock().unwrap().clone(), cwd: self.meta.cwd.clone(), @@ -259,6 +268,119 @@ impl SessionManager { Ok(()) } + /// The one path by which the config changes. + /// + /// Clone, apply, save, and only then commit: a failed write leaves + /// what was already there and reports why, so what this server + /// believes and what is on disk cannot come apart. The ordering is + /// the whole trick -- mutating in place and then saving would leave a + /// server that had accepted a change nothing on disk records. + fn update(&self, apply: impl FnOnce(&mut Config) -> Result) -> Result { + let mut inner = self.inner.write().unwrap(); + let mut candidate = inner.config.clone(); + let outcome = apply(&mut candidate)?; + candidate.save(&self.config_path)?; + inner.config = candidate; + Ok(outcome) + } + + /// Adds a machine with the providers it was found to have. + /// + /// `providers` comes from probing rather than from the caller (see + /// `crate::setups`), which is why this takes them as an argument: the + /// probe is async and this is not, so the route does the asking and + /// this does the writing. + pub fn add_setup( + &self, + name: &str, + ssh: Option, + providers: Vec, + ) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + bail!("a setup needs a name"); + } + self.update(|config| { + if config.setup_named(&name).is_some() { + bail!("there is already a setup called \"{name}\""); + } + // Ids are derived once and then fixed, so a label can be + // edited later without orphaning the sessions that named it. + let mut id = crate::setups::id_from(&name); + while config.setup(&id).is_some() { + id = format!("{id}-{}", &random_hex()[..4]); + } + let setup = SetupConfig { + id, + name: name.clone(), + ssh, + providers, + }; + config.setups.push(setup.clone()); + Ok(setup) + }) + } + + /// Renames a machine, or replaces what was discovered on it. + pub fn update_setup( + &self, + id: &str, + name: Option<&str>, + providers: Option>, + ) -> Result { + self.update(|config| { + if let Some(name) = name { + let name = name.trim(); + if name.is_empty() { + bail!("a setup needs a name"); + } + if config.setups.iter().any(|s| s.name == name && s.id != id) { + bail!("there is already a setup called \"{name}\""); + } + } + let setup = config + .setups + .iter_mut() + .find(|setup| setup.id == id) + .with_context(|| format!("no setup with id \"{id}\""))?; + if let Some(name) = name { + setup.name = name.trim().to_string(); + } + if let Some(providers) = providers { + setup.providers = providers; + } + Ok(setup.clone()) + }) + } + + /// Removes a machine, provided nothing is still running on it. + /// + /// Refused rather than cascaded: deleting a machine should not + /// silently kill conversations, and the person asking is better placed + /// to decide which of those sessions they still want. + pub fn delete_setup(&self, id: &str) -> Result<()> { + self.update(|config| { + if config.setup(id).is_none() { + bail!("no setup with id \"{id}\""); + } + let using: Vec<&str> = config + .sessions + .iter() + .filter(|session| session.setup == id) + .map(|session| session.title.as_str()) + .collect(); + if !using.is_empty() { + bail!( + "{} session(s) still run on it: {}. Delete them first.", + using.len(), + using.join(", "), + ); + } + config.setups.retain(|setup| setup.id != id); + Ok(()) + }) + } + pub fn tokens(&self) -> Vec { self.inner.read().unwrap().config.tokens.clone() } @@ -299,10 +421,11 @@ impl SessionManager { .sessions .iter() .map(|meta| match inner.live.get(&meta.id) { - Some(session) => session.info(), + Some(session) => session.info(label_of(&inner.config, &meta.setup)), None => SessionInfo { id: meta.id.clone(), setup: meta.setup.clone(), + setup_name: label_of(&inner.config, &meta.setup).to_string(), provider: meta.provider.clone(), title: meta.title.clone(), model: meta.model.clone(), @@ -357,7 +480,7 @@ impl SessionManager { .unwrap_or_else(|| format!("{} session", provider.name)); let meta = SessionConfig { id: id.clone(), - setup: setup.name.clone(), + setup: setup.id.clone(), provider: provider.name.clone(), title, model: spec.model.or_else(|| provider.models.first().cloned()), @@ -385,7 +508,7 @@ impl SessionManager { return Err(err); } inner.config = candidate; - let info = session.info(); + let info = session.info(&setup.name); inner.live.insert(id, session); Ok(info) } @@ -450,6 +573,13 @@ fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, Provid Ok((setup.clone(), provider.clone())) } +/// A setup's current label, or its id when the setup has been deleted -- +/// which is what a session left behind by a removed machine shows, and is +/// better than an empty column or a guess at what it used to be called. +fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str { + config.setup(id).map_or(id, |setup| setup.name.as_str()) +} + /// Names for a failure message: what there is, so the reader can see what /// they meant instead of only that they were wrong. fn names<'a>(all: impl Iterator) -> String { @@ -577,7 +707,7 @@ mod tests { fn echo_spec() -> SpawnSpec { SpawnSpec { params: Default::default(), - setup: crate::config::LOCAL_SETUP.to_string(), + setup: crate::config::LOCAL_SETUP_ID.to_string(), provider: crate::config::ECHO_PROVIDER.to_string(), title: None, model: None, diff --git a/server/src/setups.rs b/server/src/setups.rs new file mode 100644 index 0000000..951eb1d --- /dev/null +++ b/server/src/setups.rs @@ -0,0 +1,154 @@ +//! 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?; + + 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) +} + +/// 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 { + let child = self.spawn(launch)?; + 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()) + } +}