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
@@ -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/
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+24
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user