diff --git a/PLAN.md b/PLAN.md index 668fdd6..bcca795 100644 --- a/PLAN.md +++ b/PLAN.md @@ -67,10 +67,21 @@ Settled while building it: into `config.ron` on first run rather than conjured at read time — a provider nobody can see in the file is one nobody can edit from the phone, which is the opposite of what this app is for. -- **No migration; a config in the old shape is refused** with instructions. - Unknown fields default away, so `providers:`/`hosts:` would have loaded - as an empty config and then been seeded over, losing everything - silently. +- **Migrated once, then the migration was deleted** (2026-08-28). Unknown + fields default away, so a `providers:`/`hosts:` file would have loaded as + an empty config and then been seeded over, losing everything silently. + The first answer to that was to *refuse* such a file, which was the wrong + trade and proved it: this process is how a phone reaches the backend at + all, so refusing to start stranded the person who would have to fix it, + as a crash loop with nothing reachable to explain it. It was replaced by + a migration that kept the token hashes, backed the old file up, and + rebuilt the rest — which is discoverable now anyway. + That migration has since run on the one host there is, so it is gone + again, per the standing rule that migration code is deleted once the + update carrying it has been received. With one backend and one phone, + nothing is left on the old shape, and a second parsing path nothing + exercises only constrains later changes to the schema. A file in the old + shape now fails to parse, which is correct because no such file exists. - **Still to do: editing setups from the phone.** `GET /setups` exists; writing them is not built, so a new machine is still a hand edit on the backend. That is the remaining gap against the standing preference that diff --git a/server/src/config.rs b/server/src/config.rs index 5250f3d..4544cbd 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -186,66 +186,6 @@ pub struct SessionConfig { pub created: f64, } -/// The shape `config.ron` had before setups (up to 2026-08-28). -/// -/// Here only so an existing install upgrades rather than refusing to -/// start, and **due for deletion as soon as it has run**. There is one -/// backend and one phone, so once they are past this shape nothing -/// anywhere is still on it, and a second parsing path that nothing -/// exercises only constrains later changes to the schema. -/// -/// The reader for the kebab-case driver kind has already gone for that -/// reason. Delete the rest -- this module and the branch in -/// [`Config::load`] that reaches it -- once the host has started on a -/// build containing it. -mod legacy { - use serde::Deserialize; - - use super::{ProviderConfig, TokenEntry}; - - #[derive(Deserialize, Default)] - #[serde(rename_all = "camelCase", default)] - pub struct Config { - pub tokens: Vec, - pub providers: Vec, - pub hosts: Vec, - pub sessions: Vec, - } - - /// A machine, when the name lived on the connection rather than on a - /// setup. - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct Host { - pub name: String, - pub address: String, - #[serde(default)] - pub port: Option, - #[serde(default)] - pub identity_file: Option, - #[serde(default)] - pub options: Vec, - } - - /// A session, when it named a provider and a host independently. - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct Session { - pub id: String, - pub provider: String, - #[serde(default)] - pub host: Option, - pub title: String, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub cwd: Option, - #[serde(default)] - pub permission_mode: Option, - pub created: f64, - } -} - /// The name of the echo provider, and of the setup this machine gets on /// first run. /// @@ -305,26 +245,8 @@ impl Config { pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { - Ok(text) => { - // `Config` defaults unknown fields away, so a file from - // before setups existed would otherwise load as "no setups - // at all" and be re-seeded over, losing what was - // configured without a word. - // - // This used to refuse to start, which was the wrong trade - // and took the server down on the machine it was meant to - // protect: this process is how a phone reaches that - // machine at all, so refusing to run strands the person - // who would have to fix it. Migrating keeps the one thing - // that genuinely cannot be recovered -- the enrolled token - // hashes -- and rebuilds the rest, which is now - // discoverable anyway. - if text.contains("\nproviders:") || text.contains("\nhosts:") { - return Self::migrate_from_pre_setups(path, &text); - } - format::parse(&text) - .with_context(|| format!("{} is not valid config RON", path.display())) - } + 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 => { @@ -335,96 +257,6 @@ impl Config { } } - /// Reads a config written before setups existed and rewrites it in - /// the current shape, keeping a copy of the original. - /// - /// The old model had `providers` and `hosts` as independent lists and - /// a session naming one of each. The mapping is mechanical: each host - /// becomes a setup, the top-level providers belong to the machine this - /// server runs on, and a session's host becomes its setup. - /// - /// Migrated hosts arrive with **no providers**, deliberately. The old - /// file never recorded which machine had which program -- that was the - /// flaw the setups model exists to fix -- so inventing an answer here - /// would recreate exactly the impossible pairings this was meant to - /// end. `Rediscover` on the card asks the machine instead. - /// - /// Writes as a side effect, which a loader would not normally do: this - /// runs once, and leaving the old file in place would mean migrating - /// again on every start and never recording the result. - fn migrate_from_pre_setups(path: &Path, text: &str) -> Result { - let old: legacy::Config = format::parse(text) - .with_context(|| format!("{} is not valid config RON", path.display()))?; - - let mut setups = Vec::new(); - setups.push(SetupConfig { - id: LOCAL_SETUP_ID.to_string(), - name: LOCAL_SETUP.to_string(), - ssh: None, - providers: old.providers, - }); - // Name and id both taken from the old host's name: it is what - // sessions referenced, so reusing it is what lets them keep - // working without a second lookup table. - for host in &old.hosts { - setups.push(SetupConfig { - id: crate::setups::id_from(&host.name), - name: host.name.clone(), - ssh: Some(SshConfig { - address: host.address.clone(), - port: host.port, - identity_file: host.identity_file.clone(), - options: host.options.clone(), - }), - providers: Vec::new(), - }); - } - - let sessions = old - .sessions - .into_iter() - .map(|session| SessionConfig { - id: session.id, - setup: match &session.host { - Some(name) => crate::setups::id_from(name), - None => LOCAL_SETUP_ID.to_string(), - }, - provider: session.provider, - title: session.title, - model: session.model, - cwd: session.cwd, - permission_mode: session.permission_mode, - params: BTreeMap::new(), - created: session.created, - }) - .collect(); - - let migrated = Self { - tokens: old.tokens, - setups, - sessions, - }; - - // The original is kept rather than overwritten in place: this is a - // one-way conversion of the only record of what was configured, - // and it costs one file to make it reversible by hand. - let backup = path.with_extension("ron.pre-setups"); - std::fs::copy(path, &backup) - .with_context(|| format!("back up {} to {}", path.display(), backup.display()))?; - migrated.save(path)?; - tracing::warn!( - "{} was in the pre-setups shape and has been migrated: {} token(s) and {} session(s) \ - kept, {} host(s) became setups with no providers yet -- press Rediscover on each to \ - ask it what it has. The original is at {}.", - path.display(), - migrated.tokens.len(), - migrated.sessions.len(), - old.hosts.len(), - backup.display(), - ); - Ok(migrated) - } - /// Writes the config, owner-readable only. /// /// The token hashes here are verifiers, not secrets -- a 256-bit @@ -562,69 +394,6 @@ mod tests { ); } - #[test] - /// The upgrade path, and the reason it exists: refusing to start took - /// the whole server down on an install that had done nothing wrong. - /// What must survive is the token hashes -- everything else can be - /// rebuilt, and losing those means re-enrolling every device by hand - /// at the terminal, which is the position this server exists to keep - /// people out of. - fn a_config_from_before_setups_is_migrated_rather_than_refused() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("config.ron"); - std::fs::write( - &path, - "tokens: [(name: \"phone\", sha256: \"ab\")],\n\ - providers: [(name: \"claude-cli\", kind: claude_cli, models: [\"opus\"])],\n\ - hosts: [(name: \"dev vm\", address: \"bob@10.0.0.2\", port: 2222)],\n\ - sessions: [\n\ - (id: \"a\", provider: \"claude-cli\", host: \"dev vm\", title: \"remote\", created: 1.0),\n\ - (id: \"b\", provider: \"claude-cli\", title: \"local\", created: 2.0),\n\ - ],\n", - ) - .expect("write"); - - let migrated = Config::load(&path).expect("migrates rather than refusing"); - - // The irreplaceable part. - assert_eq!(migrated.tokens.len(), 1); - assert_eq!(migrated.tokens[0].sha256, "ab"); - - // The old host is a setup, keeping the name sessions referenced. - let vm = migrated.setup("dev-vm").expect("host became a setup"); - assert_eq!(vm.name, "dev vm"); - assert_eq!(vm.ssh.as_ref().expect("ssh").port, Some(2222)); - // Empty on purpose: the old file never recorded what was on which - // machine, so this asks rather than invents. - assert!(vm.providers.is_empty()); - - // Top-level providers belonged to the machine running the server. - let local = migrated.setup(LOCAL_SETUP_ID).expect("local setup"); - assert_eq!( - local.provider("claude-cli").expect("kept").kind, - DriverKind::ClaudeCli - ); - - // Sessions keep working, each pointing at the right machine. - let remote = migrated - .sessions - .iter() - .find(|s| s.id == "a") - .expect("remote"); - assert_eq!(remote.setup, "dev-vm"); - let local_session = migrated - .sessions - .iter() - .find(|s| s.id == "b") - .expect("local"); - assert_eq!(local_session.setup, LOCAL_SETUP_ID); - - // The original is kept, and a second load is an ordinary one. - assert!(path.with_extension("ron.pre-setups").is_file()); - let again = Config::load(&path).expect("reload"); - assert_eq!(again.setups.len(), 2); - } - #[test] /// What a fresh install can do before anybody configures anything: /// echo to prove the pipe, and the Claude CLI to be useful. Both are