Follow dev-updater's own config to RON
The same move, for the same reason: this file is written and read by hand, and JSON has no comments to say why a host is configured the way it is. Both house rules come across with it, in config.rs's `format` module and nowhere else -- a file is the *body* of the config, so no outer parentheses and nothing indented for them, and `Some` is implicit, which is what makes `skip_serializing_if` on every optional field load-bearing rather than tidiness. The switch is outright: there is no reader for the old format. That is invisible everywhere except here, because this file holds the enrolled token hashes -- starting empty leaves the phone unable to talk to the server and looks, from the phone, like the config having been lost. So a config.json left beside the new file is named in the log and left alone, rather than read or deleted. One wart, documented at DriverKind: the kebab-case spelling is the string the phone compares against, so it stays, and the file pays for it with `kind: r#claude-cli` -- a hyphen is not a RON identifier. Renaming the variant would change what an already-installed build is talking to. Verified: cargo test, cargo clippy --all-targets, and a real start against a scratch state directory -- a hand-typed config with comments and a bare `port: 2222` loads, and what the server writes back sits at column 0 with no Some(...) in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
effefdeb03
commit
19de699bfa
13 files changed
+167
-30
No files matched your search
+2
-2
@@ -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<SessionManager> {
|
||||
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
|
||||
|
||||
+108
-6
@@ -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<T: DeserializeOwned>(text: &str) -> Result<T, ron::error::SpannedError> {
|
||||
options().from_str(&format!("({text})"))
|
||||
}
|
||||
|
||||
pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
|
||||
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::<Vec<_>>()
|
||||
.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<Self> {
|
||||
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::<Vec<_>>(),
|
||||
["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]
|
||||
|
||||
+3
-3
@@ -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<IpAddr>,
|
||||
|
||||
/// 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<PathBuf>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> 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)]
|
||||
|
||||
@@ -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 <id>` 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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user