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);
}
}
+8 -1
View File
@@ -17,6 +17,7 @@ mod auth;
mod config;
mod routes;
mod session;
mod ssh;
mod usage;
use std::net::{IpAddr, SocketAddr};
@@ -153,8 +154,14 @@ async fn main() -> Result<()> {
.with_context(|| format!("failed to load {}", config_path.display()))?,
);
tracing::info!("config: {}", config_path.display());
for provider in manager.providers() {
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
}
for host in manager.hosts() {
tracing::info!(" host {} -> {}", host.name, host.address);
}
for info in manager.sessions() {
tracing::info!(" session {} ({:?}, {:?})", info.id, info.kind, info.status);
tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status);
}
let bind_ip = match args.bind {
+62 -8
View File
@@ -3,8 +3,10 @@
//! wraps the whole router in.
//!
//! ```text
//! GET /sessions list (id, kind, title, host, model, status, last activity)
//! POST /sessions spawn {kind, title?, host?, model?, cwd?, permissionMode?}
//! GET /providers what can be spawned
//! GET /hosts machines a session can be run on
//! GET /sessions list (id, provider, title, model, status, last activity)
//! POST /sessions spawn {provider, title?, model?, cwd?, permissionMode?}
//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
//! POST /sessions/{id}/message {text, attachmentIds?}
//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions)
@@ -44,6 +46,8 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
pub fn router(manager: Arc<SessionManager>) -> Router {
Router::new()
.route("/providers", get(list_providers))
.route("/hosts", get(list_hosts))
.route("/sessions", get(list_sessions).post(spawn_session))
.route("/sessions/{id}", delete(delete_session))
.route("/sessions/{id}/events", get(events))
@@ -106,15 +110,65 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
axum::Json(manager.sessions())
}
/// What the spawn screen needs to render itself, so the phone holds no
/// hardcoded list: an entry added to `config.json` shows up with no app
/// rebuild. Providers and hosts are listed separately because they are
/// independent choices -- any provider can be run on any host.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ProviderInfo {
name: String,
kind: crate::config::DriverKind,
models: Vec<String>,
}
async fn list_providers(
State(manager): State<Arc<SessionManager>>,
) -> axum::Json<Vec<ProviderInfo>> {
axum::Json(
manager
.providers()
.into_iter()
.map(|provider| ProviderInfo {
name: provider.name,
kind: provider.kind,
models: provider.models,
})
.collect(),
)
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HostInfo {
name: String,
/// Shown under the name so a host can be told apart from its label.
address: String,
}
/// Configured remote machines. Running on the backend itself is always
/// available and deliberately absent here -- it is the "no host" case, not
/// an entry that could be edited away.
async fn list_hosts(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<HostInfo>> {
axum::Json(
manager
.hosts()
.into_iter()
.map(|host| HostInfo { name: host.name, address: host.address })
.collect(),
)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpawnRequest {
kind: crate::config::SessionKind,
#[serde(default)]
title: Option<String>,
provider: String,
/// Name of a configured host; absent runs on the backend machine.
#[serde(default)]
host: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
cwd: Option<PathBuf>,
@@ -128,15 +182,15 @@ async fn spawn_session(
) -> Result<axum::Json<SessionInfo>, ApiError> {
let info = manager
.spawn_session(SpawnSpec {
kind: body.kind,
title: body.title,
provider: body.provider,
host: body.host,
title: body.title,
model: body.model,
cwd: body.cwd,
permission_mode: body.permission_mode,
})
.map_err(bad_request)?;
tracing::info!("spawned {:?} session {} ({})", info.kind, info.id, info.title);
tracing::info!("spawned {} session {} ({})", info.provider, info.id, info.title);
Ok(axum::Json(info))
}
+50 -29
View File
@@ -24,17 +24,15 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
use crate::config::SessionConfig;
use crate::config::{HostConfig, ProviderConfig, SessionConfig};
/// Where the driver remembers its CLI session id between backend runs --
/// the whole crash-recovery story: respawning with `--resume <id>` picks
@@ -58,36 +56,54 @@ pub struct ClaudeDriver {
}
impl ClaudeDriver {
pub fn spawn(meta: &SessionConfig, session_dir: &Path, sink: EventSink) -> Result<Self> {
let mut command = Command::new("claude");
command
.arg("-p")
.arg("--verbose")
.args(["--input-format", "stream-json"])
.args(["--output-format", "stream-json"])
.arg("--include-partial-messages")
// Hidden but load-bearing: without it the CLI resolves
// permissions itself and nothing ever reaches the phone.
.args(["--permission-prompt-tool", "stdio"]);
pub fn spawn(
meta: &SessionConfig,
provider: &ProviderConfig,
host: Option<&HostConfig>,
session_dir: &Path,
sink: EventSink,
) -> Result<Self> {
let mut args: Vec<String> = ["-p", "--verbose"].iter().map(|a| a.to_string()).collect();
let mut push = |flag: &str, value: &str| {
args.push(flag.to_string());
args.push(value.to_string());
};
push("--input-format", "stream-json");
push("--output-format", "stream-json");
// Hidden but load-bearing: without it the CLI resolves permissions
// itself and nothing ever reaches the phone.
push("--permission-prompt-tool", "stdio");
if let Some(model) = &meta.model {
command.args(["--model", model]);
push("--model", model);
}
if let Some(mode) = &meta.permission_mode {
command.args(["--permission-mode", mode]);
push("--permission-mode", mode);
}
if let Some(resume) = read_resume_token(session_dir) {
command.args(["--resume", &resume]);
push("--resume", &resume);
}
if let Some(cwd) = &meta.cwd {
command.current_dir(cwd);
}
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
args.push("--include-partial-messages".to_string());
let program = provider.command.as_deref().unwrap_or("claude");
let cwd = meta.cwd.as_deref();
let where_it_runs = match host {
Some(host) => format!("on {} ({})", host.name, host.address),
None => "on this machine".to_string(),
};
let mut child = crate::ssh::command(host, program, &args, cwd)
.spawn()
.context("spawn claude (is the CLI installed and on PATH?)")?;
.with_context(|| match host {
Some(host) => format!(
"couldn't start ssh to run \"{program}\" on {} -- is the ssh client \
installed here?",
host.name
),
None => format!(
"couldn't run \"{program}\" on this machine -- is it installed and on \
PATH? If it lives on another machine, give the session a host to run on.",
),
})?;
tracing::info!("session {} running {program} {where_it_runs}", meta.id);
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
@@ -119,14 +135,18 @@ impl ClaudeDriver {
));
// stderr is diagnostics only; surface it in the log, and keep the
// last line for the exit report below.
// last line for the exit report below. For a remote provider this
// is also where ssh's own failures arrive ("Permission denied",
// "Could not resolve hostname"), which are the ones a person
// actually needs to see.
let last_stderr = Arc::new(Mutex::new(String::new()));
{
let last_stderr = Arc::clone(&last_stderr);
let label = provider.name.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!("claude stderr: {line}");
tracing::warn!("{label} stderr: {line}");
*last_stderr.lock().unwrap() = line;
}
});
@@ -137,6 +157,7 @@ impl ClaudeDriver {
let (kill_tx, kill_rx) = oneshot::channel::<()>();
{
let sink = sink.clone();
let label = format!("{} {where_it_runs}", provider.name);
tokio::spawn(async move {
let status = tokio::select! {
status = child.wait() => status.ok(),
@@ -151,7 +172,7 @@ impl ClaudeDriver {
let detail = last_stderr.lock().unwrap().clone();
let _ = sink.send(Event::Error {
message: format!(
"claude exited with {status}{}",
"{label} exited with {status}{}",
if detail.is_empty() { String::new() } else { format!(": {detail}") }
),
});
+122 -32
View File
@@ -23,7 +23,7 @@ use anyhow::{Context, Result, bail};
use serde::Serialize;
use tokio::sync::{broadcast, mpsc};
use crate::config::{Config, SessionConfig, SessionKind, TokenEntry};
use crate::config::{Config, DriverKind, HostConfig, ProviderConfig, SessionConfig, TokenEntry};
use claude::ClaudeDriver;
use driver::{Driver, Event, ImageRef, SessionStatus};
use echo::EchoDriver;
@@ -40,9 +40,10 @@ pub fn now() -> f64 {
/// What the phone needs to spawn a session -- the spawn screen's fields.
pub struct SpawnSpec {
pub kind: SessionKind,
pub title: Option<String>,
pub provider: String,
/// Name of a configured host to run on; absent runs on this machine.
pub host: Option<String>,
pub title: Option<String>,
pub model: Option<String>,
pub cwd: Option<PathBuf>,
pub permission_mode: Option<String>,
@@ -53,10 +54,11 @@ pub struct SpawnSpec {
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub id: String,
pub kind: SessionKind,
pub title: String,
pub provider: String,
/// Name of the host it runs on; absent means the backend machine.
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -154,9 +156,9 @@ impl LiveSession {
fn info(&self) -> SessionInfo {
SessionInfo {
id: self.meta.id.clone(),
kind: self.meta.kind,
title: self.meta.title.clone(),
provider: self.meta.provider.clone(),
host: self.meta.host.clone(),
title: self.meta.title.clone(),
model: self.shared.model.lock().unwrap().clone(),
cwd: self.meta.cwd.clone(),
status: *self.shared.status.lock().unwrap(),
@@ -192,10 +194,13 @@ impl SessionManager {
let mut live = HashMap::new();
for meta in &config.sessions {
// One unlaunchable session (e.g. a corrupt transcript) shows as
// exited rather than taking the whole server down with it; it
// can still be deleted from the phone.
match launch(meta.clone(), &data_dir) {
// One unlaunchable session -- a corrupt transcript, an
// unreachable ssh host, a provider that was edited away --
// shows as exited rather than taking the whole server down
// with it, and can still be deleted from the phone.
match resolve(&config, meta)
.and_then(|(provider, host)| launch(meta.clone(), &provider, host.as_ref(), &data_dir))
{
Ok(session) => {
live.insert(meta.id.clone(), session);
}
@@ -204,11 +209,38 @@ impl SessionManager {
}
}
}
Ok(Self {
let manager = Self {
config_path,
data_dir,
inner: RwLock::new(Inner { config, live }),
})
};
manager.seed_providers()?;
Ok(manager)
}
/// Writes a starting `claude-cli` provider into a config that has none,
/// so a fresh install has something to spawn and a worked example of
/// the schema to edit. Runs local by default -- a session is given a
/// host when the CLI lives elsewhere, which is a per-session choice.
fn seed_providers(&self) -> Result<()> {
let mut inner = self.inner.write().unwrap();
if !inner.config.providers.is_empty() {
return Ok(());
}
let mut candidate = inner.config.clone();
candidate.providers.push(ProviderConfig {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: None,
models: ["fable", "opus", "sonnet", "haiku"]
.iter()
.map(|model| model.to_string())
.collect(),
});
candidate.save(&self.config_path)?;
inner.config = candidate;
tracing::info!("no providers configured -- added a default \"claude-cli\" provider");
Ok(())
}
pub fn tokens(&self) -> Vec<TokenEntry> {
@@ -238,9 +270,9 @@ impl SessionManager {
Some(session) => session.info(),
None => SessionInfo {
id: meta.id.clone(),
kind: meta.kind,
title: meta.title.clone(),
provider: meta.provider.clone(),
host: meta.host.clone(),
title: meta.title.clone(),
model: meta.model.clone(),
cwd: meta.cwd.clone(),
status: SessionStatus::Exited,
@@ -255,25 +287,64 @@ impl SessionManager {
self.inner.read().unwrap().live.get(id).cloned()
}
/// Every provider this server offers, built-in echo included.
pub fn providers(&self) -> Vec<ProviderConfig> {
self.inner.read().unwrap().config.providers()
}
/// Every configured host a session can be run on. Running on the
/// backend itself is always available and is not in this list.
pub fn hosts(&self) -> Vec<HostConfig> {
self.inner.read().unwrap().config.hosts.clone()
}
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
let mut inner = self.inner.write().unwrap();
let provider = inner.config.provider(&spec.provider).with_context(|| {
format!(
"no provider named \"{}\" -- configured: {}",
spec.provider,
inner
.config
.providers()
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>()
.join(", "),
)
})?;
let id = unique_id(&inner.config);
let title = spec
.title
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| default_title(spec.kind));
.unwrap_or_else(|| format!("{} session", provider.name));
let host = match &spec.host {
Some(name) => Some(inner.config.host(name).with_context(|| {
format!(
"no host named \"{name}\" -- configured: {}",
inner
.config
.hosts
.iter()
.map(|host| host.name.clone())
.collect::<Vec<_>>()
.join(", "),
)
})?),
None => None,
};
let meta = SessionConfig {
id: id.clone(),
kind: spec.kind,
title,
provider: provider.name.clone(),
host: spec.host,
model: spec.model,
title,
model: spec.model.or_else(|| provider.models.first().cloned()),
cwd: spec.cwd,
permission_mode: spec.permission_mode,
created: now(),
};
let session = launch(meta.clone(), &self.data_dir)?;
let session = launch(meta.clone(), &provider, host.as_ref(), &self.data_dir)?;
let mut candidate = inner.config.clone();
candidate.sessions.push(meta);
if let Err(err) = candidate.save(&self.config_path) {
@@ -334,11 +405,22 @@ impl SessionManager {
}
}
fn default_title(kind: SessionKind) -> String {
match kind {
SessionKind::Echo => "Echo session".to_string(),
SessionKind::Claude => "Claude session".to_string(),
}
/// The provider and host a session's config names, or a message saying
/// which one is missing. Both are looked up fresh at every launch, so
/// editing either takes effect on the next respawn.
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(ProviderConfig, Option<HostConfig>)> {
let provider = config
.provider(&meta.provider)
.with_context(|| format!("no provider named \"{}\"", meta.provider))?;
let host = match &meta.host {
Some(name) => Some(
config
.host(name)
.with_context(|| format!("no host named \"{name}\""))?,
),
None => None,
};
Ok((provider, host))
}
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
@@ -363,7 +445,12 @@ fn unique_id(config: &Config) -> String {
/// Creates the session directory, opens its transcript (continuing the
/// sequence numbering if one exists), starts the driver, and spawns the
/// event pump connecting them.
fn launch(meta: SessionConfig, data_dir: &Path) -> Result<Arc<LiveSession>> {
fn launch(
meta: SessionConfig,
provider: &ProviderConfig,
host: Option<&HostConfig>,
data_dir: &Path,
) -> Result<Arc<LiveSession>> {
let dir = data_dir.join(&meta.id);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let transcript_path = dir.join("transcript.jsonl");
@@ -377,9 +464,11 @@ fn launch(meta: SessionConfig, data_dir: &Path) -> Result<Arc<LiveSession>> {
model: Mutex::new(meta.model.clone()),
});
let driver: Box<dyn Driver> = match meta.kind {
SessionKind::Echo => Box::new(EchoDriver::new(sink.clone())),
SessionKind::Claude => Box::new(ClaudeDriver::spawn(&meta, &dir, sink.clone())?),
let driver: Box<dyn Driver> = match provider.kind {
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
DriverKind::ClaudeCli => {
Box::new(ClaudeDriver::spawn(&meta, provider, host, &dir, sink.clone())?)
}
};
tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone()));
@@ -431,9 +520,9 @@ mod tests {
fn echo_spec() -> SpawnSpec {
SpawnSpec {
kind: SessionKind::Echo,
title: None,
provider: crate::config::ECHO_PROVIDER.to_string(),
host: None,
title: None,
model: None,
cwd: None,
permission_mode: None,
@@ -486,7 +575,8 @@ mod tests {
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
assert_eq!(info.title, "Echo session");
// Untitled sessions are named after the provider that runs them.
assert_eq!(info.title, "echo session");
// Persisted: a fresh load of the config file knows the session.
let persisted = Config::load(&config_path).expect("reload config");
assert_eq!(persisted.sessions.len(), 1);
+205
View File
@@ -0,0 +1,205 @@
//! Building the command a driver actually spawns -- locally, or wrapped in
//! `ssh` when the session names a host to run on.
//!
//! The whole point of the session design is that a driver speaks JSONL over
//! a child process's stdio and doesn't care what that child is. A remote
//! session is therefore the identical command with `ssh host …` in front:
//! stdio doesn't care, so nothing downstream of here changes.
//!
//! Uses the system `ssh` client rather than a Rust SSH library, so
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
//! only one place to configure connections (PLAN.md, rule 23).
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use crate::config::HostConfig;
/// Options forced onto every connection. `BatchMode` makes a missing key
/// fail immediately with a readable message instead of hanging on a
/// password prompt that nothing can answer; the keepalives turn a silently
/// dropped link into a process exit, which the session reports as `exited`
/// rather than appearing to hang forever.
const SSH_OPTIONS: [&str; 3] =
["BatchMode=yes", "ServerAliveInterval=30", "ServerAliveCountMax=3"];
/// Builds the child process for `program args…`, run in `cwd`, either on
/// this machine (`host` absent) or on `host`.
pub fn command(
host: Option<&HostConfig>,
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Command {
let Some(ssh) = host else {
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
return configure(command);
};
let mut command = Command::new("ssh");
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
// CRLF translation, ^C handling) into something the parser can't read.
command.arg("-T");
for option in SSH_OPTIONS {
command.args(["-o", option]);
}
for option in &ssh.options {
command.args(["-o", option]);
}
if let Some(port) = ssh.port {
command.args(["-p", &port.to_string()]);
}
if let Some(identity) = &ssh.identity_file {
command.arg("-i").arg(identity);
// Without this, ssh may offer an agent key first and authenticate
// as somebody else entirely -- silently, and with different
// permissions than intended.
command.args(["-o", "IdentitiesOnly=yes"]);
}
command.arg(&ssh.address);
command.arg(remote_script(program, args, cwd));
configure(command)
}
fn configure(mut command: Command) -> Command {
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
command
}
/// The single argument handed to the remote login shell.
///
/// `exec` so the CLI replaces that shell: the process the connection is
/// attached to is then the CLI itself, and dropping the connection takes
/// it down rather than leaving an orphan behind a live wrapper.
fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
let mut script = String::new();
if let Some(cwd) = cwd {
script.push_str("cd ");
script.push_str(&quote(&cwd.to_string_lossy()));
script.push_str(" && ");
}
script.push_str("exec ");
script.push_str(&quote(program));
for arg in args {
script.push(' ');
script.push_str(&quote(arg));
}
script
}
/// Single-quotes one word for a POSIX shell.
///
/// Everything crossing to the remote side goes through here: paths, model
/// names, and prompts-as-arguments are all attacker-adjacent input in a
/// server whose whole job is running commands, and unquoted they would be
/// shell syntax rather than data.
fn quote(word: &str) -> String {
// Inside single quotes every character is literal except `'` itself,
// which is closed, escaped, and reopened.
format!("'{}'", word.replace('\'', r"'\''"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args<const N: usize>(args: [&str; N]) -> Vec<String> {
args.iter().map(|arg| arg.to_string()).collect()
}
/// The rendered argv, for asserting on what would actually run.
fn argv(command: &Command) -> Vec<String> {
let std = command.as_std();
std::iter::once(std.get_program())
.chain(std.get_args())
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
#[test]
fn a_session_with_no_host_runs_the_command_directly() {
let command = command(None, "claude", &args(["-p", "--verbose"]), Some(Path::new("/tmp/x")));
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
assert_eq!(command.as_std().get_current_dir(), Some(Path::new("/tmp/x")));
}
#[test]
fn a_session_with_a_host_wraps_the_same_command_in_ssh() {
let ssh = HostConfig {
name: "vm".to_string(),
address: "bob@10.0.2.15".to_string(),
port: Some(2222),
identity_file: Some("/home/me/.ssh/id_ai".into()),
options: vec!["StrictHostKeyChecking=accept-new".to_string()],
};
let rendered = argv(&command(
Some(&ssh),
"claude",
&args(["-p", "--model", "haiku"]),
Some(Path::new("/home/bob/work")),
));
assert_eq!(rendered[0], "ssh");
assert!(rendered.contains(&"-T".to_string()));
assert!(rendered.contains(&"BatchMode=yes".to_string()));
assert!(rendered.contains(&"StrictHostKeyChecking=accept-new".to_string()));
assert!(rendered.contains(&"IdentitiesOnly=yes".to_string()));
assert!(rendered.contains(&"2222".to_string()));
assert!(rendered.contains(&"/home/me/.ssh/id_ai".to_string()));
// The host, then exactly one argument: the remote script.
assert_eq!(rendered[rendered.len() - 2], "bob@10.0.2.15");
assert_eq!(
rendered[rendered.len() - 1],
"cd '/home/bob/work' && exec 'claude' '-p' '--model' 'haiku'",
);
}
#[test]
fn a_remote_command_without_a_cwd_just_execs() {
let ssh = HostConfig {
name: "vm".to_string(),
address: "vm".to_string(),
port: None,
identity_file: None,
options: vec![],
};
let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None));
assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'");
// No -i means no IdentitiesOnly: ~/.ssh/config decides instead.
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
}
#[test]
fn shell_metacharacters_cross_as_data_not_syntax() {
assert_eq!(quote("plain"), "'plain'");
assert_eq!(quote("with space"), "'with space'");
assert_eq!(quote("; rm -rf /"), "'; rm -rf /'");
assert_eq!(quote("$(whoami)"), "'$(whoami)'");
assert_eq!(quote("it's"), r"'it'\''s'");
// The end-to-end version of the same worry: a working directory
// that tries to close the quote and start a new command.
let ssh = HostConfig {
name: "vm".to_string(),
address: "vm".to_string(),
port: None,
identity_file: None,
options: vec![],
};
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil)));
let script = rendered.last().unwrap();
assert_eq!(script, r"cd '/tmp/'\''; touch /tmp/pwned; '\''' && exec 'claude'");
assert!(!script.contains("; touch /tmp/pwned; '\" "));
}
}