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:
irisandClaude Opus 5 committed 2026-08-28 02:25:50 -04:00
1 parent effefdeb03
commit 19de699bfa
13 files changed
+167 -30

No files matched your search

+1
View File
@@ -18,5 +18,6 @@ server/target/
# entries stay as a backstop so a stray --config or --certs pointed at the # 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. # checkout can't commit a CA private key, a token hash, or a transcript.
certs/ certs/
config.ron
config.json config.json
sessions/ sessions/
+10 -4
View File
@@ -33,13 +33,19 @@ repo is in PLAN.md's "Backend layout" section.
- `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the - `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the
auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP
table in its module doc comment, `auth.rs` the bearer-token middleware, table in its module doc comment, `auth.rs` the bearer-token middleware,
`config.rs` the persisted schema, `session/` the manager (registry `config.rs` the persisted schema and the RON its file is written in,
pattern), `Driver` trait + event model, `EchoDriver`, and transcripts. `session/` the manager (registry pattern), `Driver` trait + event model,
`EchoDriver`, and transcripts.
- `app/` — Compose Android app, single `:androidApp` module, package - `app/` — Compose Android app, single `:androidApp` module, package
`com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation
`when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the `when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the
event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; event model mirror; `ServerConfig.kt` settings + Keystore-sealed token;
screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. 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 - `server/src/certs.rs` — the TLS certificates, generated in process on
first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf 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 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. (qemu `hostfwd`) — usermode networking has none by default.
- **Nothing secret goes in the repo.** The VM is treated as untrusted (see - **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 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. and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
- Certificates are generated **by the server, on first start**, into - Certificates are generated **by the server, on first start**, into
`$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created `$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 separate throwaway dev CA for emulator work — never install a build
pinning that on the real phone. pinning that on the real phone.
- Point development at a scratch state directory rather than the real one: - 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=…`. `XDG_CONFIG_HOME=… XDG_DATA_HOME=…`.
## Things that have bitten ## Things that have bitten
+7 -7
View File
@@ -76,7 +76,7 @@ backend (Rust/Axum, desktop)
│ decided per session by the host it names │ decided per session by the host it names
├─ LlamaServerManager (llama-server lifecycle, local + SSH) ├─ LlamaServerManager (llama-server lifecycle, local + SSH)
├─ UsageMonitor (Anthropic OAuth usage endpoint) ├─ UsageMonitor (Anthropic OAuth usage endpoint)
└─ config.json + per-session transcript files └─ config.ron + per-session transcript files
``` ```
### Backend layout (`server/`) ### Backend layout (`server/`)
@@ -196,7 +196,7 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`.
### llama-server management ### 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) and **hosts**. The manager runs at most one llama-server per
`(host, model)`, spawned on demand when a session needs it: `(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 ### 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 which capabilities it has (claude / pi / llama-server, with paths if not on
PATH). Key-based auth only, using the system `ssh` client via PATH). Key-based auth only, using the system `ssh` client via
`tokio::process` — no Rust SSH library; this inherits `~/.ssh/config`, `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 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, directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl,
attachments, produced images), owner-only. Deleting a session is the attachments, produced images), owner-only. Deleting a session is the
complete path out of everything spawning one created. complete path out of everything spawning one created.
@@ -300,7 +300,7 @@ complete path out of everything spawning one created.
attacker-writable. Two consequences: attacker-writable. Two consequences:
- **Nothing secret lives in the repo.** Certificates are generated on - **Nothing secret lives in the repo.** Certificates are generated on
the machine that serves them and written to 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 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 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 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, scanner Activity reached through the AndroidX Activity Result API,
fully offline, no Play Services/ML Kit model download) and feeds the fully offline, no Play Services/ML Kit model download) and feeds the
decoded URI to the same `parseEnrollmentUri` (2026-08-25). 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 is enough for high-entropy random input; buys that a leaked config
doesn't leak the credential). No "show token again" — lost means rotate. doesn't leak the credential). No "show token again" — lost means rotate.
Phone side: sealed with an Android Keystore AES-GCM key (a small 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 1. **Skeleton** — *done 2026-08-24.* Repo layout, cert script, TLS + token
auth, wg0-bound listener (fail closed if the interface is missing), 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 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; before any AI is involved. Verified: 10 server tests + clippy clean;
curl end-to-end over pinned TLS (auth rejection, spawn, SSE curl end-to-end over pinned TLS (auth rejection, spawn, SSE
@@ -130,7 +130,7 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
// What the server offers, so the spawn screen has no hardcoded lists: a // 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. // app rebuild.
data class Provider(val name: String, val kind: String, val models: List<String>) data class Provider(val name: String, val kind: String, val models: List<String>)
@@ -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. * 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 * 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. * independent, any provider can be sent to any host.
*/ */
@Composable @Composable
+24
View File
@@ -30,6 +30,7 @@ dependencies = [
"qrcode", "qrcode",
"rand", "rand",
"rcgen", "rcgen",
"ron",
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
@@ -285,6 +286,9 @@ name = "bitflags"
version = "2.13.1" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@@ -1076,6 +1080,20 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rusticata-macros" name = "rusticata-macros"
version = "4.1.0" version = "4.1.0"
@@ -1558,6 +1576,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
+4
View File
@@ -16,6 +16,10 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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"] } clap = { version = "4", features = ["derive"] }
anyhow = "1" anyhow = "1"
thiserror = "2" thiserror = "2"
+2 -2
View File
@@ -40,7 +40,7 @@ pub fn generate_token() -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) 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 /// hash is enough for high-entropy random input, and buys that a leaked
/// config doesn't leak the credential. /// config doesn't leak the credential.
pub fn token_hash_hex(token: &str) -> String { 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> { fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
let manager = Arc::new( let manager = Arc::new(
SessionManager::new(dir.join("config.json"), dir.join("sessions")) SessionManager::new(dir.join("config.ron"), dir.join("sessions"))
.expect("manager"), .expect("manager"),
); );
manager manager
+108 -6
View File
@@ -7,6 +7,10 @@
//! funnels through `SessionManager` (the registry pattern), so in-memory //! funnels through `SessionManager` (the registry pattern), so in-memory
//! and on-disk state can't come apart. //! 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 //! Transcripts do NOT live here -- each session's events are an append-only
//! JSONL file in its own directory (see `session::transcript`); this file //! JSONL file in its own directory (see `session::transcript`); this file
//! holds only the metadata needed to list and respawn sessions. //! holds only the metadata needed to list and respawn sessions.
@@ -18,6 +22,59 @@ use serde::{Deserialize, Serialize};
use crate::private; 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct Config { pub struct Config {
@@ -81,6 +138,13 @@ pub struct HostConfig {
/// Which translator runs a session. A new one is a new driver behind the /// Which translator runs a session. A new one is a new driver behind the
/// same trait -- never a branch in shared code. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum DriverKind { pub enum DriverKind {
@@ -170,11 +234,14 @@ impl Config {
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) { match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str(&text) Ok(text) => format::parse(&text)
.with_context(|| format!("{} is not valid config JSON", path.display())), .with_context(|| format!("{} is not valid config RON", path.display())),
// A first run has no config -- the normal starting state; a // A first run has no config -- the normal starting state; a
// token is generated and saved on that first start. // 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())), Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
} }
} }
@@ -191,8 +258,8 @@ impl Config {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
private::create_dir(parent)?; private::create_dir(parent)?;
} }
let text = serde_json::to_string_pretty(self).context("serialize config")?; let text = format::render(self).context("serialize config")?;
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("ron.tmp");
private::write_file(&tmp, text.as_bytes())?; private::write_file(&tmp, text.as_bytes())?;
std::fs::rename(&tmp, path) std::fs::rename(&tmp, path)
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?; .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -207,7 +296,7 @@ mod tests {
#[test] #[test]
fn round_trips_through_the_config_file() { fn round_trips_through_the_config_file() {
let dir = tempfile::tempdir().expect("tempdir"); 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 -- // A missing file is the ordinary first-run state, not an error --
// and echo is offered even then, with nothing configured. // 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<_>>(), loaded.providers().iter().map(|p| p.name.clone()).collect::<Vec<_>>(),
["echo", "claude-cli"], ["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] #[test]
+3 -3
View File
@@ -36,7 +36,7 @@ use session::SessionManager;
const DEFAULT_PORT: u16 = 8443; const DEFAULT_PORT: u16 = 8443;
const WG_INTERFACE: &str = "wg0"; 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/`. /// and `certs/`.
fn config_home() -> PathBuf { fn config_home() -> PathBuf {
xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config") xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config")
@@ -77,7 +77,7 @@ struct Args {
bind: Option<IpAddr>, bind: Option<IpAddr>,
/// Where the token hashes, providers, hosts, and session list live. /// 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)] #[arg(long)]
config: Option<PathBuf>, config: Option<PathBuf>,
@@ -171,7 +171,7 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt().with_env_filter("info").init(); tracing_subscriber::fmt().with_env_filter("info").init();
let args = Args::parse(); 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 data_dir = args.data_dir.unwrap_or_else(|| data_home().join("sessions"));
let manager = Arc::new( let manager = Arc::new(
SessionManager::new(config_path.clone(), data_dir) SessionManager::new(config_path.clone(), data_dir)
+1 -1
View File
@@ -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 /// 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 /// rebuild. Providers and hosts are listed separately because they are
/// independent choices -- any provider can be run on any host. /// independent choices -- any provider can be run on any host.
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
+1 -1
View File
@@ -37,7 +37,7 @@ use crate::config::{HostConfig, ProviderConfig, SessionConfig};
/// Where the driver remembers its CLI session id between backend runs -- /// Where the driver remembers its CLI session id between backend runs --
/// the whole crash-recovery story: respawning with `--resume <id>` picks /// the whole crash-recovery story: respawning with `--resume <id>` picks
/// the conversation back up from Claude's own session files. Kept in the /// 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. /// free of per-driver state.
const RESUME_FILE: &str = "claude-session.json"; const RESUME_FILE: &str = "claude-session.json";
+4 -4
View File
@@ -1,6 +1,6 @@
//! The live session registry. Every session mutation -- spawn, delete, //! The live session registry. Every session mutation -- spawn, delete,
//! token changes -- funnels through [`SessionManager`] under one lock, so //! 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`). //! dev-updater's `registry.rs`).
//! //!
//! A live session is a driver plus one event pump: the driver reports //! A live session is a driver plus one event pump: the driver reports
@@ -566,7 +566,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn spawn_message_and_delete_round_trip() { async fn spawn_message_and_delete_round_trip() {
let dir = tempfile::tempdir().expect("tempdir"); 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 data_dir = dir.path().join("sessions");
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager"); 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() { async fn questions_round_trip_through_answer() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let manager = SessionManager::new( let manager = SessionManager::new(
dir.path().join("config.json"), dir.path().join("config.ron"),
dir.path().join("sessions"), dir.path().join("sessions"),
) )
.expect("manager"); .expect("manager");
@@ -651,7 +651,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_restart_relaunches_sessions_and_continues_the_numbering() { async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
let dir = tempfile::tempdir().expect("tempdir"); 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 data_dir = dir.path().join("sessions");
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager"); let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");