diff --git a/.gitignore b/.gitignore index a456b32..d3f8a85 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,6 @@ server/target/ # entries stay as a backstop so a stray --config or --certs pointed at the # checkout can't commit a CA private key, a token hash, or a transcript. certs/ +config.ron config.json sessions/ diff --git a/AGENTS.md b/AGENTS.md index 34da7fa..9abb4f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,13 +33,19 @@ repo is in PLAN.md's "Backend layout" section. - `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP table in its module doc comment, `auth.rs` the bearer-token middleware, - `config.rs` the persisted schema, `session/` the manager (registry - pattern), `Driver` trait + event model, `EchoDriver`, and transcripts. + `config.rs` the persisted schema and the RON its file is written in, + `session/` the manager (registry pattern), `Driver` trait + event model, + `EchoDriver`, and transcripts. - `app/` — Compose Android app, single `:androidApp` module, package `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation `when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. +- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: + the backend (built in `server/`, installed and controlled through + `server/service`) and then the APK (built in `app/`), in that order. The + project it serves is the repository, not either half of it, which is why + this sits at the root rather than in `app/`. - `server/src/certs.rs` — the TLS certificates, generated in process on first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf reissued every start covering every local IPv4 plus 127.0.0.1 and @@ -121,7 +127,7 @@ Established 2026-08-25, and it decides more than it looks like: (qemu `hostfwd`) — usermode networking has none by default. - **Nothing secret goes in the repo.** The VM is treated as untrusted (see PLAN.md's security section), and the repo is shared read-write with the - host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.json` + host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.ron` and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only. - Certificates are generated **by the server, on first start**, into `$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created @@ -130,7 +136,7 @@ Established 2026-08-25, and it decides more than it looks like: separate throwaway dev CA for emulator work — never install a build pinning that on the real phone. - Point development at a scratch state directory rather than the real one: - `--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`, or + `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`, or `XDG_CONFIG_HOME=… XDG_DATA_HOME=…`. ## Things that have bitten diff --git a/PLAN.md b/PLAN.md index f790c63..c395a62 100644 --- a/PLAN.md +++ b/PLAN.md @@ -76,7 +76,7 @@ backend (Rust/Axum, desktop) │ decided per session by the host it names ├─ LlamaServerManager (llama-server lifecycle, local + SSH) ├─ UsageMonitor (Anthropic OAuth usage endpoint) - └─ config.json + per-session transcript files + └─ config.ron + per-session transcript files ``` ### Backend layout (`server/`) @@ -196,7 +196,7 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`. ### llama-server management -`config.json` lists **models** (name → GGUF path or llama-server args, per +`config.ron` lists **models** (name → GGUF path or llama-server args, per host) and **hosts**. The manager runs at most one llama-server per `(host, model)`, spawned on demand when a session needs it: @@ -215,7 +215,7 @@ host) and **hosts**. The manager runs at most one llama-server per ### SSH -- Host entries in `config.json`: name, `user@host`, optional ssh options, +- Host entries in `config.ron`: name, `user@host`, optional ssh options, which capabilities it has (claude / pi / llama-server, with paths if not on PATH). Key-based auth only, using the system `ssh` client via `tokio::process` — no Rust SSH library; this inherits `~/.ssh/config`, @@ -267,7 +267,7 @@ GET /usage cached usage windows GET/PUT /hosts, /models config editing from the phone ``` -Sessions live in `config.json` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session +Sessions live in `config.ron` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl, attachments, produced images), owner-only. Deleting a session is the complete path out of everything spawning one created. @@ -300,7 +300,7 @@ complete path out of everything spawning one created. attacker-writable. Two consequences: - **Nothing secret lives in the repo.** Certificates are generated on the machine that serves them and written to - `$XDG_CONFIG_HOME/ai-app/certs` (0700, keys 0600); `config.json` and + `$XDG_CONFIG_HOME/ai-app/certs` (0700, keys 0600); `config.ron` and session transcripts go to the XDG config and data directories, per machine. A CA private key the VM could read would let it mint a leaf the pinned app accepts, which is precisely the attack pinning exists @@ -344,7 +344,7 @@ complete path out of everything spawning one created. scanner Activity reached through the AndroidX Activity Result API, fully offline, no Play Services/ML Kit model download) and feeds the decoded URI to the same `parseEnrollmentUri` (2026-08-25). - - **Storage**: server keeps only the SHA-256 in `config.json` (plain hash + - **Storage**: server keeps only the SHA-256 in `config.ron` (plain hash is enough for high-entropy random input; buys that a leaked config doesn't leak the credential). No "show token again" — lost means rotate. Phone side: sealed with an Android Keystore AES-GCM key (a small @@ -475,7 +475,7 @@ window just fills. 1. **Skeleton** — *done 2026-08-24.* Repo layout, cert script, TLS + token auth, wg0-bound listener (fail closed if the interface is missing), - config.json, session registry with a fake `EchoDriver`, session list + + config.ron, session registry with a fake `EchoDriver`, session list + session screen in the app end-to-end over SSE. Proves the whole pipe before any AI is involved. Verified: 10 server tests + clippy clean; curl end-to-end over pinned TLS (auth rejection, spawn, SSE diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 6ee4479..536e517 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -130,7 +130,7 @@ fun fetchSessions(settings: ServerSettings): List = requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } // What the server offers, so the spawn screen has no hardcoded lists: a -// provider or host added to the server's config.json appears here with no +// provider or host added to the server's config.ron appears here with no // app rebuild. data class Provider(val name: String, val kind: String, val models: List) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index bc9f647..ad9af15 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -45,7 +45,7 @@ private const val LOCAL_HOST_LABEL = "backend" * The spawn screen: what to run, where to run it, and the per-kind fields. * * Providers and hosts both come from the server, so adding either to its - * config.json shows up here with no app rebuild -- and because they are + * config.ron shows up here with no app rebuild -- and because they are * independent, any provider can be sent to any host. */ @Composable diff --git a/server/Cargo.lock b/server/Cargo.lock index bc66b4c..bfd919a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -30,6 +30,7 @@ dependencies = [ "qrcode", "rand", "rcgen", + "ron", "rustls", "serde", "serde_json", @@ -285,6 +286,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -1076,6 +1080,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -1558,6 +1576,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index e84db2a..d96a9ba 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,6 +16,10 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# The config file's format. Not JSON, because this file is written and read +# by hand and RON says a sum type as syntax -- the same choice, and the same +# house rules, as the sibling dev-updater project's config. +ron = "0.12.2" clap = { version = "4", features = ["derive"] } anyhow = "1" thiserror = "2" diff --git a/server/src/auth.rs b/server/src/auth.rs index 260ff08..011ad0c 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -40,7 +40,7 @@ pub fn generate_token() -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } -/// What `config.json` stores instead of the token: hex SHA-256. A plain +/// What `config.ron` stores instead of the token: hex SHA-256. A plain /// hash is enough for high-entropy random input, and buys that a leaked /// config doesn't leak the credential. pub fn token_hash_hex(token: &str) -> String { @@ -104,7 +104,7 @@ mod tests { fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc { let manager = Arc::new( - SessionManager::new(dir.join("config.json"), dir.join("sessions")) + SessionManager::new(dir.join("config.ron"), dir.join("sessions")) .expect("manager"), ); manager diff --git a/server/src/config.rs b/server/src/config.rs index 33657b9..2f22362 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -7,6 +7,10 @@ //! funnels through `SessionManager` (the registry pattern), so in-memory //! and on-disk state can't come apart. //! +//! The file is RON, in the shape the [`format`] module 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. +//! //! 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. @@ -18,6 +22,59 @@ use serde::{Deserialize, Serialize}; use crate::private; +/// Reading and writing the RON this file is in. +/// +/// Two things are house rules rather than plain RON, and they are here +/// together because they are inverses of each other -- change one and the +/// other stops round-tripping. +/// +/// **No outer parentheses.** A file *is* the body of the struct, so nothing +/// in it is indented for the sake of a wrapper. RON has no implicit +/// top-level struct (`de/mod.rs` requires the `(`), so [`parse`] adds it +/// and [`render`] takes it back off. The opening paren is not followed by a +/// newline, so a parse error's line number still points at the real line. +/// +/// **`Some` is implicit.** Enabled on the deserializer rather than by a +/// `#![enable(implicit_some)]` header the file would have to carry, and +/// matched on the writing side by `skip_serializing_if` on every optional +/// field so nothing writes back a `Some(...)` a person didn't type. +pub(crate) mod format { + use serde::{Serialize, de::DeserializeOwned}; + + fn options() -> ron::Options { + ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME) + } + + pub fn parse(text: &str) -> Result { + options().from_str(&format!("({text})")) + } + + pub fn render(value: &T) -> Result { + let pretty = ron::ser::PrettyConfig::new(); + let text = options().to_string_pretty(value, pretty)?; + Ok(unwrap_outer(&text)) + } + + /// Strips the outer `(`/`)` the writer always emits and removes the + /// indent level they cost. Deliberately narrow: it accepts only the + /// exact shape `PrettyConfig` produces, and leaves anything else alone + /// rather than guessing -- a file with stray parentheses is better than + /// one silently mangled. `parse` round-trips either way, since a + /// wrapped body parses the same as an unwrapped one re-wrapped. + fn unwrap_outer(text: &str) -> String { + let Some(body) = text.strip_prefix("(\n").and_then(|rest| rest.strip_suffix("\n)")) else { + return text.to_string(); + }; + let mut out: String = body + .lines() + .map(|line| line.strip_prefix(" ").unwrap_or(line)) + .collect::>() + .join("\n"); + out.push('\n'); + out + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Config { @@ -81,6 +138,13 @@ pub struct HostConfig { /// Which translator runs a session. A new one is a new driver behind the /// same trait -- never a branch in shared code. +/// +/// The kebab-case spelling is the one the phone compares against +/// (`SpawnScreen.kt`), so it is the HTTP surface's, not a formatting +/// choice. The cost lands on the config file, where a hyphen is not an +/// identifier: RON writes and reads it as `kind: r#claude-cli`. Left that +/// way rather than renaming the variant, because the string is a contract +/// with whatever build is installed on the phone and the file is not. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum DriverKind { @@ -170,11 +234,14 @@ impl Config { pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { - Ok(text) => serde_json::from_str(&text) - .with_context(|| format!("{} is not valid config JSON", 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 => Ok(Self::default()), + 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())), } } @@ -191,8 +258,8 @@ impl Config { if let Some(parent) = path.parent() { private::create_dir(parent)?; } - let text = serde_json::to_string_pretty(self).context("serialize config")?; - let tmp = path.with_extension("json.tmp"); + let text = format::render(self).context("serialize config")?; + let tmp = path.with_extension("ron.tmp"); private::write_file(&tmp, text.as_bytes())?; std::fs::rename(&tmp, path) .with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?; @@ -200,6 +267,28 @@ impl Config { } } +/// 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::*; @@ -207,7 +296,7 @@ mod tests { #[test] fn round_trips_through_the_config_file() { let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("config.json"); + let path = dir.path().join("config.ron"); // A missing file is the ordinary first-run state, not an error -- // and echo is offered even then, with nothing configured. @@ -260,6 +349,19 @@ mod tests { loaded.providers().iter().map(|p| p.name.clone()).collect::>(), ["echo", "claude-cli"], ); + + // 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] diff --git a/server/src/main.rs b/server/src/main.rs index d188838..2ba2ab0 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -36,7 +36,7 @@ use session::SessionManager; const DEFAULT_PORT: u16 = 8443; const WG_INTERFACE: &str = "wg0"; -/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.json` +/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.ron` /// and `certs/`. fn config_home() -> PathBuf { xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config") @@ -77,7 +77,7 @@ struct Args { bind: Option, /// Where the token hashes, providers, hosts, and session list live. - /// Defaults to `$XDG_CONFIG_HOME/ai-app/config.json`. + /// Defaults to `$XDG_CONFIG_HOME/ai-app/config.ron`. #[arg(long)] config: Option, @@ -171,7 +171,7 @@ async fn main() -> Result<()> { tracing_subscriber::fmt().with_env_filter("info").init(); let args = Args::parse(); - let config_path = args.config.unwrap_or_else(|| config_home().join("config.json")); + let config_path = args.config.unwrap_or_else(|| config_home().join("config.ron")); let data_dir = args.data_dir.unwrap_or_else(|| data_home().join("sessions")); let manager = Arc::new( SessionManager::new(config_path.clone(), data_dir) diff --git a/server/src/routes.rs b/server/src/routes.rs index 1435e29..fab6abe 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -111,7 +111,7 @@ async fn list_sessions(State(manager): State>) -> axum::Json } /// What the spawn screen needs to render itself, so the phone holds no -/// hardcoded list: an entry added to `config.json` shows up with no app +/// hardcoded list: an entry added to `config.ron` shows up with no app /// rebuild. Providers and hosts are listed separately because they are /// independent choices -- any provider can be run on any host. #[derive(serde::Serialize)] diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 2f912e7..b046caa 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -37,7 +37,7 @@ use crate::config::{HostConfig, ProviderConfig, SessionConfig}; /// Where the driver remembers its CLI session id between backend runs -- /// the whole crash-recovery story: respawning with `--resume ` picks /// the conversation back up from Claude's own session files. Kept in the -/// session directory rather than config.json so the shared schema stays +/// session directory rather than config.ron so the shared schema stays /// free of per-driver state. const RESUME_FILE: &str = "claude-session.json"; diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 3a8086a..98c8ae1 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -1,6 +1,6 @@ //! The live session registry. Every session mutation -- spawn, delete, //! token changes -- funnels through [`SessionManager`] under one lock, so -//! in-memory state and `config.json` can't come apart (the same pattern as +//! in-memory state and `config.ron` can't come apart (the same pattern as //! dev-updater's `registry.rs`). //! //! A live session is a driver plus one event pump: the driver reports @@ -566,7 +566,7 @@ mod tests { #[tokio::test] async fn spawn_message_and_delete_round_trip() { let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join("config.json"); + let config_path = dir.path().join("config.ron"); let data_dir = dir.path().join("sessions"); let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager"); @@ -619,7 +619,7 @@ mod tests { async fn questions_round_trip_through_answer() { let dir = tempfile::tempdir().expect("tempdir"); let manager = SessionManager::new( - dir.path().join("config.json"), + dir.path().join("config.ron"), dir.path().join("sessions"), ) .expect("manager"); @@ -651,7 +651,7 @@ mod tests { #[tokio::test] async fn a_restart_relaunches_sessions_and_continues_the_numbering() { let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join("config.json"); + let config_path = dir.path().join("config.ron"); let data_dir = dir.path().join("sessions"); let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");