Providers and hosts: what runs, and where, as independent choices

A session now names a provider (what: driver kind, command, models) and
optionally a host (where: an ssh target). Keeping them independent is
what the real setup needs -- the backend runs where the phone can reach
it, which isn't where the CLI is installed -- and it means any provider
can be sent to any host rather than a machine being baked into one.

The first provider is claude-cli, named for the CLI rather than bare
"claude", which would suggest the credit-billed API. A fresh config is
seeded with it so a new install has something to spawn and a worked
example to edit; echo stays a built-in provider needing no config.

ssh.rs builds the child process either way: locally, or `ssh -T` with
BatchMode and keepalives, every argument single-quoted for the remote
shell (a working directory that tries to close the quote and start a
command is covered by a test), and `exec` so dropping the connection
takes the CLI down instead of orphaning it.

App: the spawn screen reads /providers and /hosts instead of hardcoded
lists, so config changes need no rebuild. Chip rows are FlowRow, fixing
the reported bug where a row of models that didn't fit wrapped *inside*
each chip -- one letter of "haiku" per line -- rather than onto a second
line.

Verified: 29 tests, clippy clean; the same claude-cli provider run once
locally and once over ssh, with the remote one visibly in a different
environment; an unknown host name refused with the configured list; and
the spawn screen on the emulator showing server-driven providers, hosts,
and models that wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 03:34:33 -04:00
1 parent 91bbc73ae5
commit fff1fb49e8
12 files changed
+833 -170

No files matched your search

+150 -22
View File
@@ -23,9 +23,76 @@ 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>,
pub sessions: Vec<SessionConfig>,
}
/// One thing that can be spawned: which driver, and how to invoke it.
///
/// 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.
#[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.
#[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>,
}
/// 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).
///
/// 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.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostConfig {
/// What the spawn screen shows and the session stores.
pub name: String,
/// `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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-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,
/// 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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenEntry {
@@ -37,30 +104,21 @@ pub struct TokenEntry {
pub sha256: String,
}
/// Which driver a session runs. Phase 4 adds `Pi`; a new kind 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 = "lowercase")]
pub enum SessionKind {
/// The phase-1 fake: echoes messages back as streamed events. Proves
/// the whole pipe (spawn, SSE, transcript cursors, questions) with no
/// AI involved, and stays useful as a connectivity check.
Echo,
/// Claude Code over stream-json (see `session::claude`).
Claude,
}
#[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,
pub kind: SessionKind,
pub title: String,
/// Config name of the SSH host to run on; absent means local. Host
/// configs arrive in phase 5.
#[serde(skip_serializing_if = "Option::is_none")]
/// 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
/// 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>,
/// Working directory the session's process runs in.
@@ -75,7 +133,37 @@ 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.
pub const ECHO_PROVIDER: &str = "echo";
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(),
});
}
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) => serde_json::from_str(&text)
@@ -110,21 +198,37 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.json");
// A missing file is the ordinary first-run state, not an error.
// A missing file is the ordinary first-run state, not an error --
// and echo is offered even then, with nothing configured.
let first_run = Config::load(&path).expect("load");
assert!(first_run.tokens.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(),
}],
sessions: vec![SessionConfig {
id: "abc123".to_string(),
kind: SessionKind::Echo,
provider: "claude-cli".to_string(),
host: Some("vm".to_string()),
title: "test".to_string(),
host: None,
model: None,
cwd: None,
permission_mode: None,
@@ -136,6 +240,30 @@ 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].kind, SessionKind::Echo);
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"],
);
}
#[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);
assert_eq!(config.provider(ECHO_PROVIDER).expect("provider").kind, DriverKind::ClaudeCli);
}
}