The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
503 lines
21 KiB
Rust
503 lines
21 KiB
Rust
//! The server's persistent state: the enrolled token hashes and the sessions
|
|
//! that exist.
|
|
//!
|
|
//! Written whole and atomically (temp file + rename) rather than appended to:
|
|
//! it is small, and a half-written config would take the server down on next
|
|
//! start with no obvious way to recover from a phone. Every mutation funnels
|
|
//! through `SessionManager`, so in-memory and on-disk state can't come apart.
|
|
//!
|
|
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the same
|
|
//! two house rules as dev-updater's config, because both are read and written
|
|
//! by hand.
|
|
//!
|
|
//! Transcripts do NOT live here: each session's events are an append-only JSONL
|
|
//! file in its own directory.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use wg_app_link::format;
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", default)]
|
|
pub struct Config {
|
|
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak the
|
|
/// credential. A list (of one, today) so per-device tokens with individual
|
|
/// revocation are a config entry later, not a migration.
|
|
pub tokens: Vec<TokenEntry>,
|
|
pub setups: Vec<SetupConfig>,
|
|
pub sessions: Vec<SessionConfig>,
|
|
}
|
|
|
|
/// A machine, and the things it can run.
|
|
///
|
|
/// This is the unit a session is spawned against. Grouping providers under the
|
|
/// machine they exist on is what stops the spawn screen offering combinations
|
|
/// that cannot work; the previous model let any provider be paired with any
|
|
/// host and offered the whole cross-product.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetupConfig {
|
|
/// Stable identifier, minted when the setup is added and never
|
|
/// changed. Sessions reference this rather than the label, so
|
|
/// renaming a machine on the phone does not orphan its sessions --
|
|
/// which is the whole reason the two are separate fields.
|
|
pub id: String,
|
|
pub name: String,
|
|
/// How to reach it, absent for this machine.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ssh: Option<SshConfig>,
|
|
/// What can be spawned here. Names are unique within a setup, and only
|
|
/// within it: two machines may each have a `claude-cli`, which is the point.
|
|
#[serde(default)]
|
|
pub providers: Vec<ProviderConfig>,
|
|
}
|
|
|
|
impl SetupConfig {
|
|
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
|
self.providers.iter().find(|provider| provider.name == name)
|
|
}
|
|
}
|
|
|
|
/// One thing a setup can run: which driver, and how to invoke it.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProviderConfig {
|
|
/// Shown on the spawn screen and stored by sessions that use it.
|
|
pub name: String,
|
|
pub kind: DriverKind,
|
|
/// Override for the executable, for an install that isn't on PATH.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub command: Option<String>,
|
|
/// Models offered on the spawn screen. Free text is always allowed
|
|
/// too; this is a shortcut list, not a restriction.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub models: Vec<String>,
|
|
}
|
|
|
|
/// How to reach a setup that isn't this machine, with the system `ssh` client
|
|
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
|
|
/// one place to configure connections. A remote session is the identical
|
|
/// command with `ssh host …` in front, and nothing downstream knows.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SshConfig {
|
|
/// `user@host`, or a `Host` alias from `~/.ssh/config`.
|
|
pub address: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub port: Option<u16>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub identity_file: Option<PathBuf>,
|
|
/// Extra `-o` settings, each written as `Key=value`.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub options: Vec<String>,
|
|
/// Where a file attached from the phone is put on this machine so the
|
|
/// session can read it. Absent means the session's own working directory,
|
|
/// or the login home for a session that has none. A `~` prefix is the
|
|
/// remote home.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub attachments_dir: Option<PathBuf>,
|
|
}
|
|
|
|
/// Which translator runs a session. A new one is a new driver behind the same
|
|
/// trait -- never a branch in shared code.
|
|
///
|
|
/// Snake case, which is both Rust's and RON's: this is written into a config a
|
|
/// person edits by hand, and a hyphen is not a RON identifier, so kebab case
|
|
/// cost the file a `kind: r#claude-cli` escape. The same string is what the
|
|
/// phone compares against, so the two move together.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DriverKind {
|
|
/// The fake driver: echoes messages back as streamed events, proving the
|
|
/// pipe with no AI involved. Always available as a built-in provider.
|
|
Echo,
|
|
/// A GGUF model served by llama.cpp's `llama-server`. The model is one this
|
|
/// machine has downloaded; the provider's command is the server binary.
|
|
LlamaCpp,
|
|
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
|
|
/// bare "claude" would suggest the credit-billed API, which this is not.
|
|
ClaudeCli,
|
|
}
|
|
|
|
impl DriverKind {
|
|
/// The longest edge, in pixels, an image should have when it reaches this
|
|
/// kind of session -- `None` where nothing here has a limit worth enforcing.
|
|
///
|
|
/// Reported to the phone rather than applied here, so the bytes are made
|
|
/// small before they cross the tunnel instead of after: a modern phone photo
|
|
/// is several megabytes, and every one of them was being uploaded over
|
|
/// WireGuard only to be rejected at the other end. What decides the number
|
|
/// is the provider, which is why it lives beside the kind rather than in the
|
|
/// app.
|
|
///
|
|
/// 1568 for the Claude CLI because that is the longest edge the API itself
|
|
/// resizes to; anything larger is charged the same and spends the upload for
|
|
/// nothing, and far larger is refused outright -- which is what "sending an
|
|
/// image is broken" turned out to be.
|
|
pub fn max_image_edge(self) -> Option<u32> {
|
|
match self {
|
|
DriverKind::ClaudeCli => Some(1568),
|
|
DriverKind::Echo | DriverKind::LlamaCpp => None,
|
|
}
|
|
}
|
|
|
|
/// Whether the conversation exists outside this app, so that deleting the
|
|
/// session here does not end it.
|
|
///
|
|
/// The Claude Code CLI owns its own transcript and is resumable from it
|
|
/// whatever started it, so a session this app spawned is every bit as
|
|
/// recoverable as one it imported. Echo has nothing to keep, and a llama
|
|
/// session's conversation is folded out of *this* app's transcript.
|
|
///
|
|
/// Asked before warning somebody that a deletion cannot be undone, which is
|
|
/// the one sentence that has to be true: said of a session that can in fact
|
|
/// be brought back, it spends the credibility the warning needs.
|
|
pub fn keeps_own_transcript(self) -> bool {
|
|
match self {
|
|
Self::ClaudeCli => true,
|
|
Self::Echo | Self::LlamaCpp => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TokenEntry {
|
|
pub name: String,
|
|
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256 bits
|
|
/// from the OS CSPRNG, so there is nothing to dictionary-attack.
|
|
pub sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionConfig {
|
|
/// Stable identifier; names the session's directory and its routes.
|
|
pub id: String,
|
|
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
|
|
/// so the machine can be renamed without losing its sessions.
|
|
pub setup: String,
|
|
/// Name of the provider within that setup. Both stored by name rather than
|
|
/// resolved, so an edited setup takes effect on the next relaunch; a session
|
|
/// whose setup or provider is gone reports as exited and can still be
|
|
/// deleted.
|
|
pub provider: String,
|
|
pub title: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub cwd: Option<PathBuf>,
|
|
/// Claude permission mode chosen at spawn. Kept as a string because it is
|
|
/// passed straight to `--permission-mode` rather than interpreted here, so
|
|
/// the CLI stays the one authority on which modes exist.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub permission_mode: Option<String>,
|
|
/// Settings the driver interprets, chosen at spawn.
|
|
///
|
|
/// Deliberately untyped: what a temperature or a context size means is the
|
|
/// driver's business, and a field per driver is how a shared model starts
|
|
/// carrying one dialect's vocabulary. `permission_mode` above predates this
|
|
/// and should fold into it. BTreeMap so the file's order is stable.
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub params: BTreeMap<String, String>,
|
|
/// Whether a phone should be told when this session wants attention.
|
|
///
|
|
/// Stored here rather than on the phone because it is a fact about the
|
|
/// session: one that runs unattended overnight should be quiet on every
|
|
/// device.
|
|
///
|
|
/// Defaults to on. Silent-unless-asked makes the feature invisible to
|
|
/// anyone who does not go looking, and a notification nobody wanted is
|
|
/// turned off in one tap where one that never arrived is not diagnosable.
|
|
#[serde(default = "notify_default")]
|
|
pub notify: bool,
|
|
/// Whether this session's process is stopped when the server exits, instead
|
|
/// of being left running for the next start to adopt.
|
|
///
|
|
/// A fact about the session rather than about the run that spawned it, which
|
|
/// is why it is persisted: whichever server is running when the time comes
|
|
/// is the one that has to act on it.
|
|
///
|
|
/// Written by a server started with `--throwaway-sessions`, the default in a
|
|
/// debug build. Under the ordinary rule a test session's `claude` outlives
|
|
/// every server that ever knew about it -- twelve accumulated on this
|
|
/// machine in a day. Absent means false.
|
|
#[serde(default, skip_serializing_if = "not_set")]
|
|
pub throwaway: bool,
|
|
pub created: f64,
|
|
}
|
|
|
|
fn notify_default() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Keeps the ordinary case out of the file entirely -- see
|
|
/// [`SessionConfig::throwaway`].
|
|
fn not_set(flag: &bool) -> bool {
|
|
!*flag
|
|
}
|
|
|
|
/// The name of the echo provider, and of the setup this machine gets on first
|
|
/// run.
|
|
///
|
|
/// Echo is seeded into the config rather than conjured at read time. An
|
|
/// implicit provider is one a person cannot see in the file or edit from the
|
|
/// phone; if somebody deletes it, that was a choice.
|
|
pub const ECHO_PROVIDER: &str = "echo";
|
|
pub const LOCAL_SETUP: &str = "this machine";
|
|
/// The id of the setup a fresh install seeds. Fixed rather than random so a
|
|
/// hand-written config can name it without looking one up.
|
|
pub const LOCAL_SETUP_ID: &str = "local";
|
|
|
|
/// Where `ai-server --enroll-link` leaves a token for the running server to
|
|
/// adopt: beside the config, since it is config in transit.
|
|
pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
|
config_path.with_file_name("pending-enrollments")
|
|
}
|
|
|
|
impl Config {
|
|
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
|
|
self.setups.iter().find(|setup| setup.id == id)
|
|
}
|
|
|
|
/// A setup by the label a person sees, for messages and for the one place a
|
|
/// name still arrives from outside. Nothing else should look one up this
|
|
/// way, since labels are editable and ids are not.
|
|
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
|
self.setups.iter().find(|setup| setup.name == name)
|
|
}
|
|
|
|
/// This machine, offering whatever was found on it.
|
|
///
|
|
/// The providers are passed in rather than written here because they have to
|
|
/// be *discovered*: a hardcoded list is a claim about what is installed, and
|
|
/// this one was wrong -- every fresh install asserted a `claude-cli`
|
|
/// provider whether or not `claude` existed.
|
|
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
|
|
SetupConfig {
|
|
id: LOCAL_SETUP_ID.to_string(),
|
|
name: LOCAL_SETUP.to_string(),
|
|
ssh: None,
|
|
providers,
|
|
}
|
|
}
|
|
|
|
/// The one provider that needs no discovery, and the floor to fall back to
|
|
/// when discovery itself fails. Echo runs in-process, so it exists exactly
|
|
/// where this server does and nowhere else.
|
|
pub fn echo_provider() -> ProviderConfig {
|
|
ProviderConfig {
|
|
name: ECHO_PROVIDER.to_string(),
|
|
kind: DriverKind::Echo,
|
|
command: None,
|
|
models: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn load(path: &Path) -> Result<Self> {
|
|
match std::fs::read_to_string(path) {
|
|
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 => {
|
|
warn_about_a_config_left_behind(path);
|
|
Ok(Self::default())
|
|
}
|
|
Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
|
|
}
|
|
}
|
|
|
|
/// Writes the config, owner-readable only.
|
|
///
|
|
/// The token hashes here are verifiers rather than secrets, but the file
|
|
/// also names every host this backend can reach and every session it is
|
|
/// running. The mode is set on the temporary file *before* the rename, so
|
|
/// the config is never briefly world-readable at its real path.
|
|
pub fn save(&self, path: &Path) -> Result<()> {
|
|
format::write(path, self)
|
|
}
|
|
}
|
|
|
|
/// 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::*;
|
|
|
|
#[test]
|
|
fn round_trips_through_the_config_file() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("config.ron");
|
|
|
|
// A missing file is the ordinary first-run state, not an error.
|
|
// Nothing is conjured to fill it: the seed setup is written by the
|
|
// manager, so the file always says what there is.
|
|
let first_run = Config::load(&path).expect("load");
|
|
assert!(first_run.tokens.is_empty());
|
|
assert!(first_run.setups.is_empty());
|
|
assert!(first_run.sessions.is_empty());
|
|
|
|
let config = Config {
|
|
tokens: vec![TokenEntry {
|
|
name: "phone".to_string(),
|
|
sha256: "ab".repeat(32),
|
|
}],
|
|
setups: vec![
|
|
Config::seed(vec![
|
|
Config::echo_provider(),
|
|
ProviderConfig {
|
|
name: "claude-cli".to_string(),
|
|
kind: DriverKind::ClaudeCli,
|
|
command: Some("/usr/bin/claude".to_string()),
|
|
models: Vec::new(),
|
|
},
|
|
]),
|
|
SetupConfig {
|
|
id: "vm".to_string(),
|
|
name: "the vm".to_string(),
|
|
ssh: Some(SshConfig {
|
|
address: "bob@10.0.2.15".to_string(),
|
|
port: Some(2222),
|
|
identity_file: None,
|
|
options: Vec::new(),
|
|
attachments_dir: None,
|
|
}),
|
|
providers: vec![ProviderConfig {
|
|
name: "claude-cli".to_string(),
|
|
kind: DriverKind::ClaudeCli,
|
|
command: None,
|
|
models: vec!["haiku".to_string()],
|
|
}],
|
|
},
|
|
],
|
|
sessions: vec![SessionConfig {
|
|
id: "abc123".to_string(),
|
|
setup: "vm".to_string(),
|
|
provider: "claude-cli".to_string(),
|
|
title: "test".to_string(),
|
|
model: None,
|
|
cwd: None,
|
|
permission_mode: None,
|
|
params: BTreeMap::new(),
|
|
notify: true,
|
|
throwaway: false,
|
|
created: 1234.5,
|
|
}],
|
|
};
|
|
config.save(&path).expect("save");
|
|
|
|
let loaded = Config::load(&path).expect("reload");
|
|
assert_eq!(loaded.tokens[0].name, "phone");
|
|
assert_eq!(loaded.sessions[0].setup, "vm");
|
|
// The label and the id are separate, and the session holds the id.
|
|
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
|
|
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
|
assert_eq!(
|
|
loaded
|
|
.setup("vm")
|
|
.expect("setup")
|
|
.ssh
|
|
.as_ref()
|
|
.expect("ssh")
|
|
.port,
|
|
Some(2222),
|
|
);
|
|
// The same provider name on two machines is the point, not a
|
|
// collision: names are unique within a setup and only within one.
|
|
assert!(
|
|
loaded
|
|
.setup(LOCAL_SETUP_ID)
|
|
.expect("local")
|
|
.provider("claude-cli")
|
|
.is_some()
|
|
);
|
|
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
|
|
|
|
// 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]
|
|
/// The seed is this machine and nothing more: a name, no ssh, and
|
|
/// exactly the providers it was handed.
|
|
///
|
|
/// It used to assert a `claude-cli` provider here, which is what made
|
|
/// the bug look correct -- the test agreed with the code that every
|
|
/// machine has `claude`, because both were written from the same
|
|
/// assumption. What a machine has is discovered, so the only thing
|
|
/// this can check is that the seed does not invent anything.
|
|
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
|
|
let seed = Config::seed(vec![Config::echo_provider()]);
|
|
assert_eq!(seed.name, LOCAL_SETUP);
|
|
assert!(seed.ssh.is_none());
|
|
assert_eq!(
|
|
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
|
DriverKind::Echo
|
|
);
|
|
assert!(
|
|
seed.provider("claude-cli").is_none(),
|
|
"the seed must not assert a provider nobody looked for",
|
|
);
|
|
|
|
// And it carries through whatever discovery did find.
|
|
let discovered = Config::seed(vec![
|
|
Config::echo_provider(),
|
|
ProviderConfig {
|
|
name: "claude-cli".to_string(),
|
|
kind: DriverKind::ClaudeCli,
|
|
command: Some("/usr/bin/claude".to_string()),
|
|
models: Vec::new(),
|
|
},
|
|
]);
|
|
assert_eq!(
|
|
discovered
|
|
.provider("claude-cli")
|
|
.expect("found")
|
|
.command
|
|
.as_deref(),
|
|
Some("/usr/bin/claude"),
|
|
);
|
|
}
|
|
}
|