451 lines
16 KiB
Rust
451 lines
16 KiB
Rust
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 {
|
|
pub tokens: Vec<TokenEntry>,
|
|
pub setups: Vec<SetupConfig>,
|
|
pub sessions: Vec<SessionConfig>,
|
|
/// 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>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetupConfig {
|
|
pub id: String,
|
|
pub name: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ssh: Option<SshConfig>,
|
|
#[serde(default)]
|
|
pub providers: Vec<ProviderConfig>,
|
|
}
|
|
|
|
impl SetupConfig {
|
|
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
|
self.providers.iter().find(|provider| provider.name == name)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProviderConfig {
|
|
pub name: String,
|
|
pub kind: DriverKind,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub command: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub models: Vec<String>,
|
|
}
|
|
|
|
impl ProviderConfig {
|
|
pub fn program(&self) -> &str {
|
|
self.command
|
|
.as_deref()
|
|
.unwrap_or(self.kind.default_program())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SshConfig {
|
|
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>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub options: Vec<String>,
|
|
/// 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.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DriverKind {
|
|
Echo,
|
|
LlamaCpp,
|
|
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.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// 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.
|
|
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",
|
|
Self::Echo => "echo",
|
|
}
|
|
}
|
|
|
|
pub fn keeps_own_transcript(self) -> bool {
|
|
match self {
|
|
Self::ClaudeCli => true,
|
|
Self::Echo | Self::LlamaCpp => false,
|
|
}
|
|
}
|
|
|
|
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,
|
|
pub sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionConfig {
|
|
pub id: String,
|
|
pub setup: String,
|
|
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>,
|
|
/// 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>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub params: BTreeMap<String, String>,
|
|
/// 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,
|
|
/// 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>,
|
|
/// 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>,
|
|
#[serde(default, skip_serializing_if = "not_set")]
|
|
pub throwaway: bool,
|
|
pub created: f64,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
pub const DEFAULT_RESUME_MESSAGE: &str = "continue";
|
|
|
|
fn notify_default() -> bool {
|
|
true
|
|
}
|
|
|
|
fn not_set(flag: &bool) -> bool {
|
|
!*flag
|
|
}
|
|
|
|
pub const ECHO_PROVIDER: &str = "echo";
|
|
pub const LOCAL_SETUP: &str = "this machine";
|
|
pub const LOCAL_SETUP_ID: &str = "local";
|
|
|
|
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)
|
|
}
|
|
|
|
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
|
self.setups.iter().find(|setup| setup.name == name)
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
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())),
|
|
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())),
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
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");
|
|
|
|
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");
|
|
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),
|
|
);
|
|
assert!(
|
|
loaded
|
|
.setup(LOCAL_SETUP_ID)
|
|
.expect("local")
|
|
.provider("claude-cli")
|
|
.is_some()
|
|
);
|
|
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
|
|
|
|
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]
|
|
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",
|
|
);
|
|
|
|
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"),
|
|
);
|
|
}
|
|
}
|