ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
@@ -0,0 +1,545 @@
|
||||
//! 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` (the registry pattern), 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 format, and the same two house rules, as the sibling dev-updater
|
||||
//! project's config, because both are written and read by hand, and both
|
||||
//! now read and write them through the one module.
|
||||
//!
|
||||
//! Transcripts do NOT live here -- each session's events are an append-only
|
||||
//! JSONL file in its own directory (see `session::transcript`); this file
|
||||
//! holds only the metadata needed to list and respawn sessions.
|
||||
|
||||
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>,
|
||||
/// Every machine this server can run something on, and what each of
|
||||
/// them can run. See [`SetupConfig`].
|
||||
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: pick a setup, then one
|
||||
/// of its providers. Grouping them this way is what stops the spawn
|
||||
/// screen offering combinations that cannot work -- a provider only
|
||||
/// exists on a machine where that program is installed, and the previous
|
||||
/// model, which let any provider be paired with any host, offered the
|
||||
/// whole cross-product including the impossible parts of it.
|
||||
#[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,
|
||||
/// The label a person reads and may edit.
|
||||
pub name: String,
|
||||
/// How to reach it, absent for this machine. A setup with no `ssh` is
|
||||
/// where the server itself runs.
|
||||
#[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 (PLAN.md, rule 23).
|
||||
///
|
||||
/// A remote session is the identical command with `ssh host …` in front,
|
||||
/// and nothing downstream of the spawn knows the difference.
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// 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 to say a name
|
||||
/// nobody would type that way. The same string is what the phone compares
|
||||
/// against (`SpawnScreen.kt`), so the two move together.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriverKind {
|
||||
/// The phase-1 fake: echoes messages back as streamed events. Proves
|
||||
/// the pipe (spawn, SSE, transcript cursors, questions) with no AI
|
||||
/// involved, and stays useful as a connectivity check that costs no
|
||||
/// tokens. Always available as a built-in provider.
|
||||
Echo,
|
||||
/// A GGUF model served by llama.cpp's `llama-server` (see
|
||||
/// `session::llama`). The model itself is one this machine has
|
||||
/// downloaded; the provider's command is the server binary.
|
||||
LlamaCpp,
|
||||
/// The Claude Code CLI over stream-json (see `session::claude`).
|
||||
/// 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 twelve megapixels, and every one of
|
||||
/// those bytes 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 -- a phone that knew
|
||||
/// each provider's limits would be a second place to update when one
|
||||
/// changes.
|
||||
///
|
||||
/// 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. The others take images
|
||||
/// through no path that cares, so they get no limit rather than a made-up
|
||||
/// one.
|
||||
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 under
|
||||
/// `~/.claude/projects/` and is resumable from it whatever started
|
||||
/// it -- so a session this app spawned is every bit as recoverable as
|
||||
/// one it imported, and the difference between those two is only how
|
||||
/// it got here. Echo has nothing to keep, and a llama session's
|
||||
/// conversation is folded out of *this* app's transcript, so for both
|
||||
/// of those a delete is the end of it.
|
||||
///
|
||||
/// 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 on the sessions where it is real.
|
||||
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 {
|
||||
/// Which device this token belongs to, for the human rotating it.
|
||||
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
|
||||
/// and no stretching needed.
|
||||
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 (a new command path, another
|
||||
/// model) 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>,
|
||||
/// Working directory the session's process runs in.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Claude permission mode chosen at spawn. Meaningless for other
|
||||
/// kinds, and kept as a string because it is passed straight to the
|
||||
/// CLI's `--permission-mode` rather than interpreted here -- so the
|
||||
/// CLI stays the one authority on which modes exist, and a new one
|
||||
/// needs no change on this side.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission_mode: Option<String>,
|
||||
/// Settings the driver interprets, chosen at spawn.
|
||||
///
|
||||
/// Deliberately untyped here: what a temperature or a context size
|
||||
/// means is the driver's business, and giving this schema 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. A map rather than a list so the phone can send exactly
|
||||
/// what a person changed, and BTreeMap so the file's order is stable
|
||||
/// across writes.
|
||||
#[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, and answering that question again on each new phone
|
||||
/// is how two devices come to disagree about which sessions matter.
|
||||
///
|
||||
/// Defaults to on, and on for a config written before this field
|
||||
/// existed. The alternative -- silent unless asked -- makes the
|
||||
/// feature invisible to anyone who does not go looking for it, and a
|
||||
/// notification nobody wanted is turned off in one tap where one that
|
||||
/// never arrived is not diagnosable at all.
|
||||
#[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, and a session nobody
|
||||
/// meant to keep should not depend on the same server still being up
|
||||
/// to clean it away.
|
||||
///
|
||||
/// Written by a server started with `--throwaway-sessions`, which is
|
||||
/// the default in a debug build. A session spawned while testing is
|
||||
/// one nobody means to keep, and under the ordinary rule its `claude`
|
||||
/// outlives every server that ever knew about it -- twelve of them
|
||||
/// accumulated on this machine in a day, each holding a conversation
|
||||
/// open.
|
||||
///
|
||||
/// Absent means false: every session written before this existed, and
|
||||
/// every one spawned by a release build.
|
||||
#[serde(default, skip_serializing_if = "not_set")]
|
||||
pub throwaway: bool,
|
||||
/// Epoch seconds when the session was spawned.
|
||||
pub created: f64,
|
||||
}
|
||||
|
||||
fn notify_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Keeps the ordinary case out of the file entirely -- see
|
||||
/// [`SessionConfig::throwaway`], which is false for every session a
|
||||
/// production build writes.
|
||||
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 the
|
||||
/// way it used to be. An implicit provider is one a person cannot see in
|
||||
/// the file or edit from the phone, and the point of this app is that
|
||||
/// configuration is visible and editable; 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";
|
||||
|
||||
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, which on a
|
||||
/// machine without it is a spawn option that cannot work and a
|
||||
/// statement the server never checked. Providers are discovered by
|
||||
/// asking the machine, here exactly as for any other setup.
|
||||
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 -- there is nothing to probe for, and offering it
|
||||
/// on a remote machine would be a choice that changes nothing.
|
||||
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, not secrets -- a 256-bit
|
||||
/// random token can't be recovered from its SHA-256 -- but the file
|
||||
/// also names every host this backend can reach and every session it
|
||||
/// is running, which is nobody else's business on a shared machine.
|
||||
/// 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(),
|
||||
}),
|
||||
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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user