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 { pub tokens: Vec, pub setups: Vec, pub sessions: Vec, /// Here rather than on a provider because providers are *discovered*: a /// default written onto one would be erased by the next rediscovery, which /// is the kind of setting that looks like it stuck until the day it did /// not. Here rather than on the phone because a second device would then /// spawn sessions the first one's owner did not expect. /// /// `None` is the CLI's own default, and stays reachable: this is a level /// somebody chose, not a level this app picked for them. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_effort: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetupConfig { pub id: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, #[serde(default)] pub providers: Vec, } impl SetupConfig { pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { self.providers.iter().find(|provider| provider.name == name) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderConfig { pub name: String, pub kind: DriverKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub command: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, } impl ProviderConfig { pub fn program(&self) -> &str { self.command .as_deref() .unwrap_or(self.kind.default_program()) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SshConfig { 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, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, /// Here rather than on the provider because it is a fact about the /// machine, and because a machine reached over ssh is where the model /// has to be: a llama.cpp session serves the file from the machine /// that runs `llama-server`, and this backend's own downloads are on /// whichever machine that is only when they are the same one. #[serde(default, skip_serializing_if = "Option::is_none")] pub models_dir: Option, /// Where a file attached from the phone is put on this machine so the /// session can read it. Absent means the session's own working directory, /// or the login home for a session that has none. A `~` prefix is the /// remote home. #[serde(default, skip_serializing_if = "Option::is_none")] pub attachments_dir: Option, } /// Which translator runs a session. A new one is a new driver behind the same /// trait -- never a branch in shared code. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DriverKind { Echo, LlamaCpp, 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. /// /// 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. pub fn max_image_edge(self) -> Option { match self { DriverKind::ClaudeCli => Some(1568), DriverKind::Echo | DriverKind::LlamaCpp => None, } } /// Which paid service meters a session of this kind, and `None` for one /// that costs nothing. /// /// Echo names a meter of its own that exists only when a test has asked for /// one (`/usage` in `session::echo`), which is how the bar's states are /// reached without an account. With none set there is no snapshot, and the /// phone draws nothing. pub fn usage_provider(self) -> Option<&'static str> { match self { Self::ClaudeCli => Some(crate::usage::CLAUDE), Self::Echo => Some(crate::usage::ECHO), Self::LlamaCpp => None, } } /// The executable a provider of this kind runs when it names none. /// /// Here rather than at each spawn site because it is not only the spawn /// that runs it: `usage` runs the Claude CLI too, to have it refresh its /// own OAuth token, and a default that disagreed with the driver's would /// ask the wrong binary on a machine with two installs. pub fn default_program(self) -> &'static str { match self { Self::ClaudeCli => "claude", Self::LlamaCpp => "llama-server", Self::Echo => "echo", } } pub fn keeps_own_transcript(self) -> bool { match self { Self::ClaudeCli => true, Self::Echo | Self::LlamaCpp => false, } } pub fn takes_effort(self) -> bool { match self { Self::ClaudeCli => true, Self::Echo | Self::LlamaCpp => false, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TokenEntry { pub name: String, pub sha256: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionConfig { pub id: String, pub setup: String, pub provider: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, /// Claude permission mode chosen at spawn. Kept as a string because it is /// passed straight to `--permission-mode` rather than interpreted here, so /// the CLI stays the one authority on which modes exist. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, /// Unlike the model and the mode, there is no control request that changes /// one -- checked against 2.1.258, whose only two are `set_model` and /// `set_permission_mode` -- so this is settled at launch and `None` means /// whatever the CLI's own default is. That is a state the phone has to be /// able to *choose*, not just start in, which is why it is an option /// rather than a level with a default written here. #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub params: BTreeMap, /// 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. /// /// Defaults to on. Silent-unless-asked makes the feature invisible to /// anyone who does not go looking, and a notification nobody wanted is /// turned off in one tap where one that never arrived is not diagnosable. #[serde(default = "notify_default")] pub notify: bool, /// Off unless somebody asked for it. It spends quota the moment it becomes /// available and it does so while nobody is looking, which is exactly the /// kind of thing that must not happen because a default said so. #[serde(default, skip_serializing_if = "not_set")] pub auto_resume: bool, /// What that message says. `None` is [`DEFAULT_RESUME_MESSAGE`], and stays /// reachable: it is this app's word, not one somebody chose, so clearing /// the field goes back to it rather than sending an empty message. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_resume_message: Option, /// Persisted rather than held in memory because the wait outlives the /// process doing it: a five-hour window and a weekly one both routinely /// outlast a backend restart, and a resume forgotten across one is a /// session that silently never comes back. #[serde(default, skip_serializing_if = "Option::is_none")] pub resume: Option, #[serde(default, skip_serializing_if = "not_set")] pub throwaway: bool, pub created: f64, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScheduledResume { /// Epoch seconds: when the limit is next worth checking. Never a promise /// that the message goes out then -- the meter is asked first. pub at: f64, /// Epoch seconds the limit was hit. pub since: f64, } pub const DEFAULT_RESUME_MESSAGE: &str = "continue"; fn notify_default() -> bool { true } fn not_set(flag: &bool) -> bool { !*flag } pub const ECHO_PROVIDER: &str = "echo"; pub const LOCAL_SETUP: &str = "this machine"; pub const LOCAL_SETUP_ID: &str = "local"; 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) } pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> { self.setups.iter().find(|setup| setup.name == name) } /// 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. pub fn seed(providers: Vec) -> SetupConfig { SetupConfig { id: LOCAL_SETUP_ID.to_string(), name: LOCAL_SETUP.to_string(), ssh: None, providers, } } 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())), 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())), } } /// The token hashes here are verifiers rather than secrets, but the file /// also names every host this backend can reach and every session it is /// running. 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) } } 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"); 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(), models_dir: None, attachments_dir: None, }), providers: vec![ProviderConfig { name: "claude-cli".to_string(), kind: DriverKind::ClaudeCli, command: None, models: vec!["haiku".to_string()], }], }, ], default_effort: Some("low".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, effort: None, params: BTreeMap::new(), notify: true, auto_resume: false, auto_resume_message: None, resume: None, 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"); 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), ); assert!( loaded .setup(LOCAL_SETUP_ID) .expect("local") .provider("claude-cli") .is_some() ); assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none()); 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] 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", ); 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"), ); } }