A setup is a machine, and it carries what that machine can run
Providers and hosts were two independent lists, and a session named one of each. They were never independent: a provider only exists on a machine where that program is installed, so the spawn screen offered the whole cross-product, including "the Claude CLI on the box that hasn't got it". The picker could not know, because nothing in the model said. Now a setup is a machine -- optional ssh, plus the providers it has -- and spawning is two choices in order: pick a setup, then one of its providers. The impossible pairs stop being expressible rather than being validated against. Provider names are unique within a setup and only within one, so two machines can each have a `claude-cli`, which was previously either a name collision or two entries called things like "claude" and "claude on the vm". It also settles the "Run on" problem properly. That control was offered for every provider but honoured only by the Claude driver -- an echo session sent to a host ran locally and said otherwise. There is no such control now: the machine is chosen first, and echo is a provider of the setup with no ssh, where it belongs, since it runs in-process and has no transport to cross. The built-in echo provider is gone as a concept. It used to be conjured at read time and never written to the file, which meant a provider nobody could see or edit; it is now seeded into the config on first run alongside claude-cli. What the file says is what there is, and deleting it is a choice rather than a state to be repaired. A config in the old shape is refused with instructions rather than loaded. `Config` defaults unknown fields away, so `providers:` and `hosts:` would otherwise have vanished into an empty config that was then seeded over -- a migration nobody would notice until their setups were gone. Verified against a running server and on the emulator: a fresh install seeds "this machine" with echo and claude-cli and the file reads cleanly; a two-setup config lists both with their own providers; spawning on a setup works and the session row names it; asking for a provider a setup lacks says which it offers, and an unknown setup says which exist. On the phone, selecting "dev vm" narrows the provider chips to that machine's one and shows its address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
7cf36005ae
commit
ecac404fd4
10 files changed
+422
-328
No files matched your search
+177
-101
@@ -18,7 +18,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::private;
|
||||
@@ -87,26 +87,48 @@ pub struct Config {
|
||||
/// 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>,
|
||||
/// What can be spawned. See [`ProviderConfig`].
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
/// Machines a session can be told to run on. See [`HostConfig`].
|
||||
pub hosts: Vec<HostConfig>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// One thing that can be spawned: which driver, and how to invoke it.
|
||||
/// A machine, and the things it can run.
|
||||
///
|
||||
/// Deliberately says nothing about *where* it runs -- that is the
|
||||
/// session's [`SessionConfig::host`], because the two are independent.
|
||||
/// The same provider may run locally for one session and over SSH for the
|
||||
/// next, and pinning a machine here would make "the Claude CLI" and "the
|
||||
/// Claude CLI on that box" two different things to configure and pick
|
||||
/// between.
|
||||
/// 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 {
|
||||
/// What the spawn screen shows and sessions store. Unique; renaming
|
||||
/// one orphans the sessions that reference it.
|
||||
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.
|
||||
/// Unique; renaming one orphans the sessions that reference it.
|
||||
pub name: String,
|
||||
pub kind: DriverKind,
|
||||
/// Override for the executable, for an install that isn't on PATH.
|
||||
@@ -118,18 +140,15 @@ pub struct ProviderConfig {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// A machine sessions can be run on, reached 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).
|
||||
/// 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).
|
||||
///
|
||||
/// Applies to any session of any provider: a remote session is the
|
||||
/// identical command with `ssh host …` in front, and nothing downstream of
|
||||
/// the spawn knows the difference.
|
||||
/// 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 HostConfig {
|
||||
/// What the spawn screen shows and the session stores.
|
||||
pub name: String,
|
||||
pub struct SshConfig {
|
||||
/// `user@host`, or a `Host` alias from `~/.ssh/config`.
|
||||
pub address: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -183,15 +202,13 @@ pub struct TokenEntry {
|
||||
pub struct SessionConfig {
|
||||
/// Stable identifier; names the session's directory and its routes.
|
||||
pub id: String,
|
||||
/// Name of the [`ProviderConfig`] this session runs. Stored rather
|
||||
/// than the resolved driver so an edited provider (a new command path,
|
||||
/// another model) takes effect on the next relaunch; a session whose
|
||||
/// Name of the [`SetupConfig`] this session runs on.
|
||||
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,
|
||||
/// Name of the [`HostConfig`] to run it on. Absent means the backend
|
||||
/// machine itself. Independent of the provider by design.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host: Option<String>,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
@@ -220,41 +237,71 @@ pub struct SessionConfig {
|
||||
pub created: f64,
|
||||
}
|
||||
|
||||
/// The name of the built-in echo provider. Always present, never written
|
||||
/// to the config file: it needs no configuration and gives every install a
|
||||
/// working session type to test the pipe with.
|
||||
/// 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";
|
||||
|
||||
impl Config {
|
||||
/// Every provider, built-in first. A configured provider named `echo`
|
||||
/// wins, so the built-in can be redefined but never silently
|
||||
/// duplicated.
|
||||
pub fn providers(&self) -> Vec<ProviderConfig> {
|
||||
let mut providers = Vec::new();
|
||||
if !self.providers.iter().any(|p| p.name == ECHO_PROVIDER) {
|
||||
providers.push(ProviderConfig {
|
||||
name: ECHO_PROVIDER.to_string(),
|
||||
kind: DriverKind::Echo,
|
||||
command: None,
|
||||
models: Vec::new(),
|
||||
});
|
||||
pub fn setup(&self, name: &str) -> Option<&SetupConfig> {
|
||||
self.setups.iter().find(|setup| setup.name == name)
|
||||
}
|
||||
|
||||
/// What a fresh install starts with: this machine, offering the echo
|
||||
/// driver to prove the pipe and the Claude CLI to be useful.
|
||||
///
|
||||
/// Echo runs in-process, so it belongs to the setup with no ssh --
|
||||
/// there is nothing for a transport to wrap, and offering it on a
|
||||
/// remote machine would be a choice that changes nothing.
|
||||
pub fn seed() -> SetupConfig {
|
||||
SetupConfig {
|
||||
name: LOCAL_SETUP.to_string(),
|
||||
ssh: None,
|
||||
providers: vec![
|
||||
ProviderConfig {
|
||||
name: ECHO_PROVIDER.to_string(),
|
||||
kind: DriverKind::Echo,
|
||||
command: None,
|
||||
models: Vec::new(),
|
||||
},
|
||||
ProviderConfig {
|
||||
name: "claude-cli".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: None,
|
||||
models: ["fable", "opus", "sonnet", "haiku"]
|
||||
.iter()
|
||||
.map(|m| (*m).to_string())
|
||||
.collect(),
|
||||
},
|
||||
],
|
||||
}
|
||||
providers.extend(self.providers.iter().cloned());
|
||||
providers
|
||||
}
|
||||
|
||||
pub fn provider(&self, name: &str) -> Option<ProviderConfig> {
|
||||
self.providers().into_iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
pub fn host(&self, name: &str) -> Option<HostConfig> {
|
||||
self.hosts.iter().find(|host| host.name == name).cloned()
|
||||
}
|
||||
|
||||
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())),
|
||||
Ok(text) => {
|
||||
// `Config` defaults unknown fields away, so a file from
|
||||
// before setups existed would load as "no setups at all"
|
||||
// and be re-seeded over -- losing every configured
|
||||
// provider and host without a word. Say so instead.
|
||||
if text.contains("\nproviders:") || text.contains("\nhosts:") {
|
||||
bail!(
|
||||
"{} is in the old shape: `providers` and `hosts` were separate lists, \
|
||||
and are now `setups`, each carrying the providers that machine has. \
|
||||
Rewrite it as `setups: [(name: \"...\", providers: [...])]` -- a \
|
||||
setup with no `ssh` is this machine.",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
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 => {
|
||||
@@ -317,39 +364,41 @@ mod tests {
|
||||
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 --
|
||||
// and echo is offered even then, with nothing configured.
|
||||
// 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());
|
||||
assert_eq!(first_run.providers().len(), 1);
|
||||
assert_eq!(
|
||||
first_run.provider(ECHO_PROVIDER).expect("built-in").kind,
|
||||
DriverKind::Echo
|
||||
);
|
||||
|
||||
let config = Config {
|
||||
tokens: vec![TokenEntry {
|
||||
name: "phone".to_string(),
|
||||
sha256: "ab".repeat(32),
|
||||
}],
|
||||
providers: vec![ProviderConfig {
|
||||
name: "claude-cli".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: None,
|
||||
models: vec!["haiku".to_string()],
|
||||
}],
|
||||
hosts: vec![HostConfig {
|
||||
name: "vm".to_string(),
|
||||
address: "bob@10.0.2.15".to_string(),
|
||||
port: Some(2222),
|
||||
identity_file: None,
|
||||
options: Vec::new(),
|
||||
}],
|
||||
setups: vec![
|
||||
Config::seed(),
|
||||
SetupConfig {
|
||||
name: "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(),
|
||||
host: Some("vm".to_string()),
|
||||
title: "test".to_string(),
|
||||
model: None,
|
||||
cwd: None,
|
||||
@@ -362,20 +411,28 @@ mod tests {
|
||||
|
||||
let loaded = Config::load(&path).expect("reload");
|
||||
assert_eq!(loaded.tokens[0].name, "phone");
|
||||
assert_eq!(loaded.sessions[0].id, "abc123");
|
||||
assert_eq!(loaded.sessions[0].setup, "vm");
|
||||
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
||||
assert_eq!(loaded.sessions[0].host.as_deref(), Some("vm"));
|
||||
assert_eq!(loaded.host("vm").expect("host").port, Some(2222));
|
||||
// Built-in echo plus the configured one; any provider can run on
|
||||
// any host, so they are listed independently.
|
||||
assert_eq!(
|
||||
loaded
|
||||
.providers()
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
["echo", "claude-cli"],
|
||||
.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)
|
||||
.expect("local")
|
||||
.provider("claude-cli")
|
||||
.is_some()
|
||||
);
|
||||
assert!(loaded.setup(LOCAL_SETUP).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
|
||||
@@ -398,21 +455,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_echo_provider_replaces_the_built_in_one() {
|
||||
let config = Config {
|
||||
providers: vec![ProviderConfig {
|
||||
name: ECHO_PROVIDER.to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: Some("/opt/claude".to_string()),
|
||||
models: Vec::new(),
|
||||
}],
|
||||
..Config::default()
|
||||
};
|
||||
// One entry, not two: the built-in is skipped rather than shadowed.
|
||||
assert_eq!(config.providers().len(), 1);
|
||||
/// A config from before setups existed must not load as an empty one.
|
||||
/// `Config` defaults unknown fields away, so without this check the
|
||||
/// old `providers` and `hosts` would vanish and be silently re-seeded
|
||||
/// over -- the worst kind of migration, the sort nobody notices.
|
||||
fn a_config_in_the_old_shape_is_refused_rather_than_emptied() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("config.ron");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"tokens: [],\nproviders: [\n (name: \"claude-cli\", kind: claude_cli),\n],\nhosts: [],\nsessions: [],\n",
|
||||
)
|
||||
.expect("write");
|
||||
let err = Config::load(&path).expect_err("should refuse");
|
||||
let message = format!("{err:#}");
|
||||
assert!(message.contains("old shape"), "{message}");
|
||||
assert!(message.contains("setups"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// What a fresh install can do before anybody configures anything:
|
||||
/// echo to prove the pipe, and the Claude CLI to be useful. Both are
|
||||
/// on the machine the server runs on, and echo belongs there because
|
||||
/// it runs in-process -- there is no transport for it to cross.
|
||||
fn the_seed_setup_is_this_machine_and_can_spawn_something() {
|
||||
let seed = Config::seed();
|
||||
assert_eq!(seed.name, LOCAL_SETUP);
|
||||
assert!(seed.ssh.is_none());
|
||||
assert_eq!(
|
||||
config.provider(ECHO_PROVIDER).expect("provider").kind,
|
||||
DriverKind::ClaudeCli
|
||||
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
||||
DriverKind::Echo
|
||||
);
|
||||
assert_eq!(
|
||||
seed.provider("claude-cli").expect("claude").kind,
|
||||
DriverKind::ClaudeCli,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user