A 401 from the usage endpoint means the stored access token has expired. Refreshing it here is not an option: Anthropic's OAuth rotates the refresh token, so a second refresher invalidates the CLI's copy and forces a re-login on a machine that usually has a live session on it. So run the CLI there instead and re-read what it wrote. `doctor` rather than `auth status`: probed against 2.1.258 with an invalid token, `auth status` answers loggedIn:true from the file alone and never reaches the network. The same probe showed a failed refresh blanks both tokens, which is why this stays on the 401 path. Also gives ProviderConfig one program() so the CLI's default path is not written down twice.
658 lines
28 KiB
Rust
658 lines
28 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>,
|
|
/// What a new session's thinking level is when nothing chose one.
|
|
///
|
|
/// Here rather than on a provider because providers are *discovered*: a
|
|
/// default written onto one would be erased by the next rediscovery, which
|
|
/// is the kind of setting that looks like it stuck until the day it did
|
|
/// not. Here rather than on the phone because a second device would then
|
|
/// spawn sessions the first one's owner did not expect.
|
|
///
|
|
/// `None` is the CLI's own default, and stays reachable: this is a level
|
|
/// somebody chose, not a level this app picked for them.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub default_effort: Option<String>,
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
impl ProviderConfig {
|
|
/// The executable to run for this provider: its override, or its kind's
|
|
/// default.
|
|
pub fn program(&self) -> &str {
|
|
self.command
|
|
.as_deref()
|
|
.unwrap_or(self.kind.default_program())
|
|
}
|
|
}
|
|
|
|
/// 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 this machine keeps the GGUF models it can serve, absent for
|
|
/// the same default this backend uses (`~/.local/share/ai-app/models`
|
|
/// -- `$XDG_DATA_HOME` is not read on the far side, since it is this
|
|
/// machine's environment that would answer). A `~` prefix is the
|
|
/// remote home.
|
|
///
|
|
/// Here rather than on the provider because it is a fact about the
|
|
/// machine, and because a machine reached over ssh is where the model
|
|
/// has to be: a llama.cpp session serves the file from the machine
|
|
/// that runs `llama-server`, and this backend's own downloads are on
|
|
/// whichever machine that is only when they are the same one.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub models_dir: Option<PathBuf>,
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
/// Which paid service meters a session of this kind, and `None` for one
|
|
/// that costs nothing.
|
|
///
|
|
/// What decides which account -- if any -- a rate-limit bar is about is the
|
|
/// provider a session runs, not the machine it runs on: an echo session on
|
|
/// a machine that also has the Claude CLI was drawn with that CLI's
|
|
/// five-hour window, a quota it cannot spend.
|
|
///
|
|
/// Echo names a meter of its own that exists only when a test has asked for
|
|
/// one (`/usage` in `session::echo`), which is how the bar's states are
|
|
/// reached without an account. With none set there is no snapshot, and the
|
|
/// phone draws nothing.
|
|
///
|
|
/// The string is a [`crate::usage::UsageProvider::name`], and it is what
|
|
/// pairs a session with one of `GET /usage`'s snapshots -- so
|
|
/// `usage::providers_for` reads this rather than matching on kinds again.
|
|
pub fn usage_provider(self) -> Option<&'static str> {
|
|
match self {
|
|
Self::ClaudeCli => Some(crate::usage::CLAUDE),
|
|
Self::Echo => Some(crate::usage::ECHO),
|
|
Self::LlamaCpp => None,
|
|
}
|
|
}
|
|
|
|
/// The executable a provider of this kind runs when it names none.
|
|
///
|
|
/// Here rather than at each spawn site because it is not only the spawn
|
|
/// that runs it: `usage` runs the Claude CLI too, to have it refresh its
|
|
/// own OAuth token, and a default that disagreed with the driver's would
|
|
/// ask the wrong binary on a machine with two installs.
|
|
pub fn default_program(self) -> &'static str {
|
|
match self {
|
|
Self::ClaudeCli => "claude",
|
|
Self::LlamaCpp => "llama-server",
|
|
// Echo is translated in-process; nothing is spawned for it.
|
|
Self::Echo => "echo",
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
/// Whether a thinking level means anything to this kind, so the phone can
|
|
/// offer the control only where it does something.
|
|
///
|
|
/// Reported from here rather than decided on the phone, and asked of the
|
|
/// *kind* rather than branched on: the alternative is the session-type
|
|
/// `if` this app does not have anywhere else. `--effort` is the Claude
|
|
/// CLI's; a llama session's sampling is `params`, and echo does not think.
|
|
///
|
|
/// It matters more than a control that would simply do nothing, because
|
|
/// choosing a level stops the process -- so on a session that cannot use
|
|
/// one it is a button whose only effect is the cost.
|
|
pub fn takes_effort(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>,
|
|
/// How hard the model thinks, passed straight to `--effort`. A string for
|
|
/// the same reason `permission_mode` is: the CLI owns which levels exist.
|
|
///
|
|
/// Unlike the model and the mode, there is no control request that changes
|
|
/// one -- checked against 2.1.258, whose only two are `set_model` and
|
|
/// `set_permission_mode` -- so this is settled at launch and `None` means
|
|
/// whatever the CLI's own default is. That is a state the phone has to be
|
|
/// able to *choose*, not just start in, which is why it is an option
|
|
/// rather than a level with a default written here.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub effort: 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 a session stopped by the account's usage limit sends itself a
|
|
/// message once the limit lifts, instead of waiting for a person.
|
|
///
|
|
/// Off unless somebody asked for it. It spends quota the moment it becomes
|
|
/// available and it does so while nobody is looking, which is exactly the
|
|
/// kind of thing that must not happen because a default said so.
|
|
#[serde(default, skip_serializing_if = "not_set")]
|
|
pub auto_resume: bool,
|
|
/// What that message says. `None` is [`DEFAULT_RESUME_MESSAGE`], and stays
|
|
/// reachable: it is this app's word, not one somebody chose, so clearing
|
|
/// the field goes back to it rather than sending an empty message.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub auto_resume_message: Option<String>,
|
|
/// The message this session owes itself once the limit lifts, and when to
|
|
/// try. Written when a limit is hit, moved when the wait turns out to be
|
|
/// wrong, and cleared when the message goes out or auto-resume is turned
|
|
/// off -- see [`ScheduledResume`].
|
|
///
|
|
/// Persisted rather than held in memory because the wait outlives the
|
|
/// process doing it: a five-hour window and a weekly one both routinely
|
|
/// outlast a backend restart, and a resume forgotten across one is a
|
|
/// session that silently never comes back.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub resume: Option<ScheduledResume>,
|
|
/// 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,
|
|
}
|
|
|
|
/// A message owed to a session whose account ran out, and when to try sending
|
|
/// it.
|
|
///
|
|
/// `since` is the whole reason this is a struct: the wait is rescheduled every
|
|
/// time the meter is asked and still says no, so `at` alone cannot say how long
|
|
/// this has been going on -- and something has to, or a machine that can never
|
|
/// be asked is retried until somebody notices. See `crate::resume`.
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ScheduledResume {
|
|
/// Epoch seconds: when the limit is next worth checking. Never a promise
|
|
/// that the message goes out then -- the meter is asked first.
|
|
pub at: f64,
|
|
/// Epoch seconds the limit was hit.
|
|
pub since: f64,
|
|
}
|
|
|
|
/// What an auto-resume says when nothing else was chosen. One word, because
|
|
/// the session already knows what it was doing and this is only the nudge that
|
|
/// lets it carry on.
|
|
pub const DEFAULT_RESUME_MESSAGE: &str = "continue";
|
|
|
|
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(),
|
|
models_dir: None,
|
|
attachments_dir: None,
|
|
}),
|
|
providers: vec![ProviderConfig {
|
|
name: "claude-cli".to_string(),
|
|
kind: DriverKind::ClaudeCli,
|
|
command: None,
|
|
models: vec!["haiku".to_string()],
|
|
}],
|
|
},
|
|
],
|
|
default_effort: Some("low".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,
|
|
effort: None,
|
|
params: BTreeMap::new(),
|
|
notify: true,
|
|
auto_resume: false,
|
|
auto_resume_message: None,
|
|
resume: None,
|
|
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"),
|
|
);
|
|
}
|
|
}
|