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:
1 parent
91bbc73ae5
commit
fff1fb49e8
12 files changed
+833
-170
No files matched your search
+150
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user