//! The server's persistent state: the enrolled token hashes and the //! sessions that exist. //! //! Written whole and atomically (temp file + rename) rather than appended //! to: it is small, and a half-written config would take the server down on //! next start with no obvious way to recover from a phone. Every mutation //! funnels through `SessionManager` (the registry pattern), so in-memory //! and on-disk state can't come apart. //! //! The file is RON, in the shape [`wg_app_link::format`] describes -- the //! same format, and the same two house rules, as the sibling dev-updater //! project's config, because both are written and read by hand, and both //! now read and write them through the one module. //! //! Transcripts do NOT live here -- each session's events are an append-only //! JSONL file in its own directory (see `session::transcript`); this file //! holds only the metadata needed to list and respawn sessions. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use wg_app_link::format; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Config { /// Enrolled device tokens, hashes only -- a leaked config doesn't leak /// the credential. A list (of one, today) so per-device tokens with /// individual revocation are a config entry later, not a migration. pub tokens: Vec, /// Every machine this server can run something on, and what each of /// them can run. See [`SetupConfig`]. pub setups: Vec, pub sessions: Vec, } /// A machine, and the things it can run. /// /// This is the unit a session is spawned against: pick a setup, then one /// of its providers. Grouping them this way is what stops the spawn /// screen offering combinations that cannot work -- a provider only /// exists on a machine where that program is installed, and the previous /// model, which let any provider be paired with any host, offered the /// whole cross-product including the impossible parts of it. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetupConfig { /// 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. #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, /// What can be spawned here. Names are unique within a setup, and only /// within it: two machines may each have a `claude-cli`, which is the /// point. #[serde(default)] pub providers: Vec, } impl SetupConfig { pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { self.providers.iter().find(|provider| provider.name == name) } } /// One thing a setup can run: which driver, and how to invoke it. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderConfig { /// Shown on the spawn screen and stored by sessions that use it. pub name: String, pub kind: DriverKind, /// Override for the executable, for an install that isn't on PATH. #[serde(default, skip_serializing_if = "Option::is_none")] pub command: Option, /// Models offered on the spawn screen. Free text is always allowed /// too; this is a shortcut list, not a restriction. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, } /// How to reach a setup that isn't this machine, with the system `ssh` /// client -- so `~/.ssh/config`, agents, and jump hosts all keep working, /// and there is one place to configure connections (PLAN.md, rule 23). /// /// A remote session is the identical command with `ssh host …` in front, /// and nothing downstream of the spawn knows the difference. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SshConfig { /// `user@host`, or a `Host` alias from `~/.ssh/config`. pub address: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub port: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identity_file: Option, /// Extra `-o` settings, each written as `Key=value`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, } /// Which translator runs a session. A new one is a new driver behind the /// same trait -- never a branch in shared code. /// /// Snake case, which is both Rust's and RON's: this is written into a /// config a person edits by hand, and a hyphen is not a RON identifier, so /// kebab case cost the file a `kind: r#claude-cli` escape to say a name /// nobody would type that way. The same string is what the phone compares /// against (`SpawnScreen.kt`), so the two move together. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DriverKind { /// The phase-1 fake: echoes messages back as streamed events. Proves /// the pipe (spawn, SSE, transcript cursors, questions) with no AI /// involved, and stays useful as a connectivity check that costs no /// tokens. Always available as a built-in provider. Echo, /// A GGUF model served by llama.cpp's `llama-server` (see /// `session::llama`). The model itself is one this machine has /// downloaded; the provider's command is the server binary. LlamaCpp, /// The Claude Code CLI over stream-json (see `session::claude`). /// Named for the CLI specifically: bare "claude" would suggest the /// credit-billed API, which this is not. ClaudeCli, } impl DriverKind { /// The longest edge, in pixels, an image should have when it reaches /// this kind of session -- `None` where nothing here has a limit worth /// enforcing. /// /// Reported to the phone rather than applied here, so the bytes are made /// small before they cross the tunnel instead of after: a modern phone /// photo is several megabytes and twelve megapixels, and every one of /// those bytes was being uploaded over WireGuard only to be rejected at /// the other end. What decides the number is the provider, which is why /// it lives beside the kind rather than in the app -- a phone that knew /// each provider's limits would be a second place to update when one /// changes. /// /// 1568 for the Claude CLI because that is the longest edge the API /// itself resizes to; anything larger is charged the same and spends the /// upload for nothing, and far larger is refused outright, which is what /// "sending an image is broken" turned out to be. The others take images /// through no path that cares, so they get no limit rather than a made-up /// one. pub fn max_image_edge(self) -> Option { match self { DriverKind::ClaudeCli => Some(1568), DriverKind::Echo | DriverKind::LlamaCpp => None, } } /// Whether the conversation exists outside this app, so that deleting /// the session here does not end it. /// /// The Claude Code CLI owns its own transcript under /// `~/.claude/projects/` and is resumable from it whatever started /// it -- so a session this app spawned is every bit as recoverable as /// one it imported, and the difference between those two is only how /// it got here. Echo has nothing to keep, and a llama session's /// conversation is folded out of *this* app's transcript, so for both /// of those a delete is the end of it. /// /// Asked before warning somebody that a deletion cannot be undone, /// which is the one sentence that has to be true: said of a session /// that can in fact be brought back, it spends the credibility the /// warning needs on the sessions where it is real. pub fn keeps_own_transcript(self) -> bool { match self { Self::ClaudeCli => true, Self::Echo | Self::LlamaCpp => false, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TokenEntry { /// Which device this token belongs to, for the human rotating it. pub name: String, /// Hex SHA-256 of the token. A plain hash is enough: the token is 256 /// bits from the OS CSPRNG, so there is nothing to dictionary-attack /// and no stretching needed. pub sha256: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionConfig { /// Stable identifier; names the session's directory and its routes. pub id: String, /// 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 /// model) takes effect on the next relaunch; a session whose setup or /// provider is gone reports as exited and can still be deleted. pub provider: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// Working directory the session's process runs in. #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, /// Claude permission mode chosen at spawn. Meaningless for other /// kinds, and kept as a string because it is passed straight to the /// CLI's `--permission-mode` rather than interpreted here -- so the /// CLI stays the one authority on which modes exist, and a new one /// needs no change on this side. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, /// Settings the driver interprets, chosen at spawn. /// /// Deliberately untyped here: what a temperature or a context size /// means is the driver's business, and giving this schema a field per /// driver is how a shared model starts carrying one dialect's /// vocabulary. `permission_mode` above predates this and should fold /// into it. A map rather than a list so the phone can send exactly /// what a person changed, and BTreeMap so the file's order is stable /// across writes. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub params: BTreeMap, /// Whether a phone should be told when this session wants attention. /// /// Stored here rather than on the phone because it is a fact about the /// session: one that runs unattended overnight should be quiet on /// every device, and answering that question again on each new phone /// is how two devices come to disagree about which sessions matter. /// /// Defaults to on, and on for a config written before this field /// existed. The alternative -- silent unless asked -- makes the /// feature invisible to anyone who does not go looking for it, and a /// notification nobody wanted is turned off in one tap where one that /// never arrived is not diagnosable at all. #[serde(default = "notify_default")] pub notify: bool, /// Whether this session's process is stopped when the server exits, /// instead of being left running for the next start to adopt. /// /// A fact about the session rather than about the run that spawned it, /// which is why it is persisted: whichever server is running when the /// time comes is the one that has to act on it, and a session nobody /// meant to keep should not depend on the same server still being up /// to clean it away. /// /// Written by a server started with `--throwaway-sessions`, which is /// the default in a debug build. A session spawned while testing is /// one nobody means to keep, and under the ordinary rule its `claude` /// outlives every server that ever knew about it -- twelve of them /// accumulated on this machine in a day, each holding a conversation /// open. /// /// Absent means false: every session written before this existed, and /// every one spawned by a release build. #[serde(default, skip_serializing_if = "not_set")] pub throwaway: bool, /// Epoch seconds when the session was spawned. pub created: f64, } fn notify_default() -> bool { true } /// Keeps the ordinary case out of the file entirely -- see /// [`SessionConfig::throwaway`], which is false for every session a /// production build writes. fn not_set(flag: &bool) -> bool { !*flag } /// The name of the echo provider, and of the setup this machine gets on /// first run. /// /// Echo is seeded into the config rather than conjured at read time the /// way it used to be. An implicit provider is one a person cannot see in /// the file or edit from the phone, and the point of this app is that /// configuration is visible and editable; if somebody deletes it, that was /// 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"; /// Where `ai-server --enroll-link` leaves a token for the running server /// to adopt: beside the config, since it is config in transit. See /// `wg_app_link::enroll::spool_pending`. pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf { config_path.with_file_name("pending-enrollments") } impl Config { 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) } /// This machine, offering whatever was found on it. /// /// The providers are passed in rather than written here because they /// have to be *discovered*: a hardcoded list is a claim about what is /// installed, and this one was wrong -- every fresh install asserted a /// `claude-cli` provider whether or not `claude` existed, which on a /// machine without it is a spawn option that cannot work and a /// statement the server never checked. Providers are discovered by /// asking the machine, here exactly as for any other setup. pub fn seed(providers: Vec) -> SetupConfig { SetupConfig { id: LOCAL_SETUP_ID.to_string(), name: LOCAL_SETUP.to_string(), ssh: None, providers, } } /// The one provider that needs no discovery, and the floor to fall /// back to when discovery itself fails. /// /// Echo runs in-process, so it exists exactly where this server does /// and nowhere else -- there is nothing to probe for, and offering it /// on a remote machine would be a choice that changes nothing. pub fn echo_provider() -> ProviderConfig { ProviderConfig { name: ECHO_PROVIDER.to_string(), kind: DriverKind::Echo, command: None, models: Vec::new(), } } pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { Ok(text) => format::parse(&text) .with_context(|| format!("{} is not valid config RON", path.display())), // A first run has no config -- the normal starting state; a // token is generated and saved on that first start. Err(err) if err.kind() == std::io::ErrorKind::NotFound => { warn_about_a_config_left_behind(path); Ok(Self::default()) } Err(err) => Err(err).with_context(|| format!("read {}", path.display())), } } /// Writes the config, owner-readable only. /// /// The token hashes here are verifiers, not secrets -- a 256-bit /// random token can't be recovered from its SHA-256 -- but the file /// also names every host this backend can reach and every session it /// is running, which is nobody else's business on a shared machine. /// The mode is set on the temporary file *before* the rename, so the /// config is never briefly world-readable at its real path. pub fn save(&self, path: &Path) -> Result<()> { format::write(path, self) } } /// Says so when the only config here is one this server no longer reads. /// /// The format moved from JSON to RON and the switch is outright -- there is /// no reader for the old file. Everywhere else that is invisible, but this /// file holds the enrolled token hashes: starting empty leaves the phone /// unable to talk to this server, and looks from the phone like the config /// having been lost rather than renamed. The old file is named and left /// alone rather than read or deleted, since it is the only record of what /// was configured. fn warn_about_a_config_left_behind(path: &Path) { let old = path.with_extension("json"); if old.is_file() { tracing::warn!( "{} is from an older version and is not read: the config is RON now, at {}. \ Re-enroll the phone with the enrollment QR this start prints, move anything \ else across by hand, then delete it.", old.display(), path.display(), ); } } #[cfg(test)] mod tests { use super::*; #[test] fn round_trips_through_the_config_file() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("config.ron"); // A missing file is the ordinary first-run state, not an error. // Nothing is conjured to fill it: the seed setup is written by the // manager, so the file always says what there is. let first_run = Config::load(&path).expect("load"); assert!(first_run.tokens.is_empty()); assert!(first_run.setups.is_empty()); assert!(first_run.sessions.is_empty()); let config = Config { tokens: vec![TokenEntry { name: "phone".to_string(), sha256: "ab".repeat(32), }], setups: vec![ Config::seed(vec![ Config::echo_provider(), ProviderConfig { name: "claude-cli".to_string(), kind: DriverKind::ClaudeCli, command: Some("/usr/bin/claude".to_string()), models: Vec::new(), }, ]), SetupConfig { id: "vm".to_string(), name: "the vm".to_string(), ssh: Some(SshConfig { address: "bob@10.0.2.15".to_string(), port: Some(2222), identity_file: None, options: Vec::new(), }), providers: vec![ProviderConfig { name: "claude-cli".to_string(), kind: DriverKind::ClaudeCli, command: None, models: vec!["haiku".to_string()], }], }, ], sessions: vec![SessionConfig { id: "abc123".to_string(), setup: "vm".to_string(), provider: "claude-cli".to_string(), title: "test".to_string(), model: None, cwd: None, permission_mode: None, params: BTreeMap::new(), notify: true, throwaway: false, created: 1234.5, }], }; config.save(&path).expect("save"); 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 .setup("vm") .expect("setup") .ssh .as_ref() .expect("ssh") .port, Some(2222), ); // The same provider name on two machines is the point, not a // collision: names are unique within a setup and only within one. assert!( loaded .setup(LOCAL_SETUP_ID) .expect("local") .provider("claude-cli") .is_some() ); 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 // nothing indented for them. Asserted rather than trusted because // `render` strips what `parse` adds back -- if only one of the two // ever changed, every file on disk would still load and only look // wrong. The absent `Some(...)` is the other half of the same // bargain: implicit_some is what lets a person write `port: 2222`, // and only `skip_serializing_if` keeps this from writing it back. let text = std::fs::read_to_string(&path).expect("read back"); assert!(!text.trim_start().starts_with('('), "outer parens: {text}"); assert!( text.starts_with("tokens: ["), "top level should sit at column 0: {text}" ); assert!( text.contains("port: 2222"), "optional written long-hand: {text}" ); } #[test] /// The seed is this machine and nothing more: a name, no ssh, and /// exactly the providers it was handed. /// /// It used to assert a `claude-cli` provider here, which is what made /// the bug look correct -- the test agreed with the code that every /// machine has `claude`, because both were written from the same /// assumption. What a machine has is discovered, so the only thing /// this can check is that the seed does not invent anything. fn the_seed_is_this_machine_and_claims_only_what_it_was_given() { let seed = Config::seed(vec![Config::echo_provider()]); assert_eq!(seed.name, LOCAL_SETUP); assert!(seed.ssh.is_none()); assert_eq!( seed.provider(ECHO_PROVIDER).expect("echo").kind, DriverKind::Echo ); assert!( seed.provider("claude-cli").is_none(), "the seed must not assert a provider nobody looked for", ); // And it carries through whatever discovery did find. let discovered = Config::seed(vec![ Config::echo_provider(), ProviderConfig { name: "claude-cli".to_string(), kind: DriverKind::ClaudeCli, command: Some("/usr/bin/claude".to_string()), models: Vec::new(), }, ]); assert_eq!( discovered .provider("claude-cli") .expect("found") .command .as_deref(), Some("/usr/bin/claude"), ); } }