diff --git a/AGENTS.md b/AGENTS.md index 6a01cab..e75fe86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,15 @@ Established 2026-08-25, and it decides more than it looks like: network namespaces inside one machine and drives the server through it — a genuine handshake against 10.66.0.1 with pinned TLS, no router or phone involved. That's the way to verify the wg0-only posture. +- **The `claude` CLI is installed in this VM only, not on the host.** So + the backend reaches it the same way it would any other machine: a + configured host, and a session that names it. For the host to ssh in, + the VM needs an inbound port forward in its launch configuration + (qemu `hostfwd`) — usermode networking has none by default. +- **`config.json` is shared with the host too**, so it is the *production* + config: don't leave test tokens or throwaway hosts in it. Point + development at a scratch one instead: + `--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`. ## Things that have bitten diff --git a/PLAN.md b/PLAN.md index a95f45f..79026ee 100644 --- a/PLAN.md +++ b/PLAN.md @@ -44,6 +44,26 @@ Decisions already made (2026-08-24): ## Architecture +### Providers and hosts (decided 2026-08-25) + +Two independent axes, configured separately and chosen per session: + +- A **provider** is *what* runs: a driver kind, the command to invoke, and + the models worth offering. `claude-cli` is the first — named for the CLI + specifically, since bare "claude" would suggest the credit-billed API, + which this is not. llama.cpp becomes a second provider later. +- A **host** is *where* it runs: an ssh target. Absent means the backend + machine itself. + +Sessions name both. Keeping them independent is what the motivating setup +requires: the backend runs on the machine the phone can reach (where +WireGuard terminates), which is not necessarily where a CLI is installed — +here the Claude CLI lives only in a VM on that machine, while llama.cpp +will be on the host itself. Pinning a host into a provider would make "the +Claude CLI" and "the Claude CLI over there" two things to configure and +choose between, and would stop the same provider from being sent somewhere +else for one session. + ``` Android app (Compose) │ HTTPS (pinned CA) — REST for actions, SSE for live events @@ -52,7 +72,8 @@ backend (Rust/Axum, desktop) ├─ SessionManager ── Session ── Driver (trait) │ ├─ ClaudeDriver (claude stream-json) │ └─ PiDriver (pi --mode rpc) - │ each driver's process is spawned locally or as `ssh host …` + │ each driver's process is spawned locally or as `ssh host …`, + │ decided per session by the host it names ├─ LlamaServerManager (llama-server lifecycle, local + SSH) ├─ UsageMonitor (Anthropic OAuth usage endpoint) └─ config.json + per-session transcript files @@ -214,8 +235,10 @@ the pinned TLS listener. SSE over WebSocket because resume-by-cursor is plain POSTs anyway. ``` -GET /sessions list (id, kind, title, host, model, status, last activity) -POST /sessions spawn {kind, host, model, cwd, permission_mode, title} +GET /providers what can be spawned (name, kind, models) +GET /hosts machines a session can be run on +GET /sessions list (id, provider, host, title, model, status, last activity) +POST /sessions spawn {provider, host, model, cwd, permission_mode, title} GET /sessions/:id/events?after=N SSE: transcript replay from N, then live POST /sessions/:id/message {text, attachment_ids} POST /sessions/:id/answer {question_id, answer} (questions and permissions) @@ -416,7 +439,18 @@ window just fills. up in this VM, so this phase isn't testable here — Claude first; the driver seam is ready when it is.* 5. **SSH** — host config, remote spawn for both kinds, remote llama-server - with port forward, attachment shipping. + with port forward, attachment shipping. *Host config and remote spawn + done 2026-08-25* (any session of any provider can name a host; the + command is the identical one wrapped in `ssh -T`, with every argument + shell-quoted). Attachment shipping turned out to be unnecessary for the + Claude driver — images ride the stdio JSONL as base64 in both + directions, so nothing needs `scp`. Still outstanding: remote + llama-server with its port forward, which comes with phase 4. + Two things learned doing it: a remote session inherits ssh's non-login + PATH, which is narrower than an interactive shell's (point `command` at + an absolute path if a CLI isn't found), and the remote command is run + with `exec` so dropping the connection takes the CLI down rather than + orphaning it. 6. **Polish** — reconnect edges, notification when a session awaits an answer (the "your turn" push), transcript search, whatever daily use surfaces. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index e20c601..8c9fb5f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -88,10 +88,11 @@ fun requestFromServer( } } -// One row of GET /sessions. +// One row of GET /sessions. `provider` is what runs it, `host` where -- +// the two are independent, so a session names both. data class SessionSummary( val id: String, - val kind: String, + val provider: String, val title: String, val host: String?, val model: String?, @@ -99,28 +100,61 @@ data class SessionSummary( val lastActivity: Double, ) +private fun parseSession(session: JSONObject) = SessionSummary( + id = session.getString("id"), + provider = session.getString("provider"), + title = session.getString("title"), + host = session.optString("host").ifEmpty { null }, + model = session.optString("model").ifEmpty { null }, + status = session.getString("status"), + lastActivity = session.getDouble("lastActivity"), +) + fun fetchSessions(settings: ServerSettings): List = requestFromServer(settings, "/sessions") { connection -> val sessions = JSONArray(connection.inputStream.bufferedReader().readText()) - (0 until sessions.length()).map { i -> - val session = sessions.getJSONObject(i) - SessionSummary( - id = session.getString("id"), - kind = session.getString("kind"), - title = session.getString("title"), - host = session.optString("host").ifEmpty { null }, - model = session.optString("model").ifEmpty { null }, - status = session.getString("status"), - lastActivity = session.getDouble("lastActivity"), + (0 until sessions.length()).map { parseSession(sessions.getJSONObject(it)) } + } + +// What the server offers, so the spawn screen has no hardcoded lists: a +// provider or host added to the server's config.json appears here with no +// app rebuild. +data class Provider(val name: String, val kind: String, val models: List) + +data class RemoteHost(val name: String, val address: String) + +fun fetchProviders(settings: ServerSettings): List = + requestFromServer(settings, "/providers") { connection -> + val providers = JSONArray(connection.inputStream.bufferedReader().readText()) + (0 until providers.length()).map { i -> + val provider = providers.getJSONObject(i) + val models = provider.optJSONArray("models") + Provider( + name = provider.getString("name"), + kind = provider.getString("kind"), + models = (0 until (models?.length() ?: 0)).map { models!!.getString(it) }, ) } } -/** Spawns a session and returns it as the list would show it. */ +fun fetchHosts(settings: ServerSettings): List = + requestFromServer(settings, "/hosts") { connection -> + val hosts = JSONArray(connection.inputStream.bufferedReader().readText()) + (0 until hosts.length()).map { i -> + val host = hosts.getJSONObject(i) + RemoteHost(name = host.getString("name"), address = host.getString("address")) + } + } + +/** + * Spawns a session and returns it as the list would show it. [host] is the + * name of a configured host, or null to run on the backend machine itself. + */ fun spawnSession( settings: ServerSettings, - kind: String, + provider: String, title: String, + host: String? = null, model: String? = null, cwd: String? = null, permissionMode: String? = null, @@ -129,22 +163,15 @@ fun spawnSession( settings, "/sessions", method = "POST", - jsonBody = JSONObject().put("kind", kind).put("title", title).apply { + jsonBody = JSONObject().put("provider", provider).put("title", title).apply { + if (!host.isNullOrBlank()) put("host", host) if (!model.isNullOrBlank()) put("model", model) if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) }.toString(), + readTimeoutMs = 30000, ) { connection -> - val session = JSONObject(connection.inputStream.bufferedReader().readText()) - SessionSummary( - id = session.getString("id"), - kind = session.getString("kind"), - title = session.getString("title"), - host = session.optString("host").ifEmpty { null }, - model = session.optString("model").ifEmpty { null }, - status = session.getString("status"), - lastActivity = session.getDouble("lastActivity"), - ) + parseSession(JSONObject(connection.inputStream.bufferedReader().readText())) } fun sendMessage( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index b3e7676..bb840f0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -177,7 +177,13 @@ private fun SessionCard( Spacer(Modifier.height(4.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( - listOfNotNull(session.kind, session.host, session.model).joinToString(" · "), + // Provider, then where it runs -- "on " rather + // than a bare name, so a host isn't mistaken for a model. + listOfNotNull( + session.provider, + session.host?.let { "on $it" }, + session.model, + ).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index fbe4d99..2d5ad54 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -224,7 +224,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () Text(summary.title, style = MaterialTheme.typography.titleMedium) Text( listOfNotNull( - summary.kind, + summary.provider, + summary.host?.let { "on $it" }, summary.model, if (totalTokens > 0) "$totalTokens tok" else null, ).joinToString(" · "), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 8ab3e22..bc9f647 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -2,6 +2,8 @@ package com.example.aiapp import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -11,12 +13,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,19 +33,21 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -// The session kinds this build can spawn; phase 4 adds "pi". A new kind is -// another entry here plus its fields below -- never a parallel screen. -private val KINDS = listOf("claude", "echo") - // Claude Code 2.x permission modes. "manual" asks for everything (each ask // arrives on the phone as a question card); the others are the CLI's own // escalating levels of autonomy. private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") -// Model shortcuts the CLI accepts; free text is also fine (full model ids). -private val CLAUDE_MODELS = listOf("default", "fable", "opus", "sonnet", "haiku") +/** Runs on the backend machine itself -- the "no host" case. */ +private const val LOCAL_HOST_LABEL = "backend" -/** The spawn screen: kind, per-kind fields, go. */ +/** + * The spawn screen: what to run, where to run it, and the per-kind fields. + * + * Providers and hosts both come from the server, so adding either to its + * config.json shows up here with no app rebuild -- and because they are + * independent, any provider can be sent to any host. + */ @Composable fun SpawnScreen( settings: ServerSettings, @@ -49,14 +55,39 @@ fun SpawnScreen( onBack: () -> Unit, ) { val scope = rememberCoroutineScope() - var kind by remember { mutableStateOf(KINDS.first()) } + var providers by remember { mutableStateOf>(emptyList()) } + var hosts by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + + var provider by remember { mutableStateOf(null) } + var host by remember { mutableStateOf(null) } var title by remember { mutableStateOf("") } - var model by remember { mutableStateOf("default") } + var model by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") } var permissionMode by remember { mutableStateOf("manual") } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + try { + val loaded = withContext(Dispatchers.IO) { + fetchProviders(settings) to fetchHosts(settings) + } + providers = loaded.first + hosts = loaded.second + provider = providers.firstOrNull() + } catch (e: ApiException) { + error = e.message + } + loading = false + } + + val current = provider + // Only the Claude CLI has models, a working directory, and permission + // modes; keying the extra fields on the kind rather than the provider + // name keeps a second Claude provider from needing anything here. + val isClaude = current?.kind == "claude-cli" + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( @@ -68,13 +99,32 @@ fun SpawnScreen( } Spacer(Modifier.height(16.dp)) - Text("Kind", style = MaterialTheme.typography.labelLarge) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - KINDS.forEach { candidate -> - FilterChip( - selected = kind == candidate, - onClick = { kind = candidate }, - label = { Text(candidate) }, + if (loading) { + CircularProgressIndicator() + return@Column + } + + ChipGroup( + label = "Provider", + options = providers.map { it.name }, + selected = current?.name, + onSelect = { name -> provider = providers.first { it.name == name } }, + ) + + // Always offered, whatever the provider: where a session runs is + // independent of what runs it. + ChipGroup( + label = "Run on", + options = listOf(LOCAL_HOST_LABEL) + hosts.map { it.name }, + selected = host ?: LOCAL_HOST_LABEL, + onSelect = { name -> host = name.takeIf { it != LOCAL_HOST_LABEL } }, + ) + host?.let { chosen -> + hosts.firstOrNull { it.name == chosen }?.let { + Text( + it.address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -88,18 +138,24 @@ fun SpawnScreen( modifier = Modifier.fillMaxWidth(), ) - if (kind == "claude") { - Spacer(Modifier.height(16.dp)) - Text("Model", style = MaterialTheme.typography.labelLarge) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CLAUDE_MODELS.forEach { candidate -> - FilterChip( - selected = model == candidate, - onClick = { model = candidate }, - label = { Text(candidate) }, - ) - } + if (isClaude) { + if (current.models.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + ChipGroup( + label = "Model", + options = current.models, + selected = model.ifEmpty { null }, + onSelect = { chosen -> model = if (model == chosen) "" else chosen }, + ) } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = model, + onValueChange = { model = it }, + label = { Text("Model (blank = the CLI's default)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) Spacer(Modifier.height(16.dp)) OutlinedTextField( @@ -112,20 +168,12 @@ fun SpawnScreen( ) Spacer(Modifier.height(16.dp)) - Text("Permissions", style = MaterialTheme.typography.labelLarge) - // Two rows rather than horizontal scroll: all the choices stay - // visible, and bypassPermissions shouldn't be pickable blind. - for (chunk in PERMISSION_MODES.chunked(3)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - chunk.forEach { candidate -> - FilterChip( - selected = permissionMode == candidate, - onClick = { permissionMode = candidate }, - label = { Text(candidate) }, - ) - } - } - } + ChipGroup( + label = "Permissions", + options = PERMISSION_MODES, + selected = permissionMode, + onSelect = { permissionMode = it }, + ) } Spacer(Modifier.height(24.dp)) @@ -136,17 +184,19 @@ fun SpawnScreen( Button( onClick = { + val chosen = current ?: return@Button busy = true scope.launch { try { val spawned = withContext(Dispatchers.IO) { spawnSession( settings, - kind, - title.trim(), - model = model.takeIf { kind == "claude" && it != "default" }, - cwd = cwd.takeIf { kind == "claude" }, - permissionMode = permissionMode.takeIf { kind == "claude" }, + provider = chosen.name, + title = title.trim(), + host = host, + model = model.trim().takeIf { isClaude }, + cwd = cwd.trim().takeIf { isClaude }, + permissionMode = permissionMode.takeIf { isClaude }, ) } onSpawned(spawned) @@ -156,7 +206,38 @@ fun SpawnScreen( } } }, - enabled = !busy, + enabled = !busy && current != null, ) { Text(if (busy) "Spawning..." else "Spawn") } } } + +/** + * A labeled row of choices that wraps onto as many lines as it needs. + * + * FlowRow rather than Row: a plain Row gives every chip an equal share of + * a single line, so once the options don't fit, the text inside each one + * wraps to one character per line instead of the row wrapping. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipGroup( + label: String, + options: List, + selected: String?, + onSelect: (String) -> Unit, +) { + Text(label, style = MaterialTheme.typography.labelLarge) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + options.forEach { option -> + FilterChip( + selected = selected == option, + onClick = { onSelect(option) }, + label = { Text(option) }, + ) + } + } +} diff --git a/server/src/config.rs b/server/src/config.rs index 6a44e64..7945f55 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -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, + /// What can be spawned. See [`ProviderConfig`]. + pub providers: Vec, + /// Machines a session can be told to run on. See [`HostConfig`]. + pub hosts: Vec, pub sessions: Vec, } +/// 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, + /// 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, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity_file: Option, + /// Extra `-o` settings, each written as `Key=value`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, +} + +/// 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, + pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// 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 { + 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 { + self.providers().into_iter().find(|p| p.name == name) + } + + pub fn host(&self, name: &str) -> Option { + self.hosts.iter().find(|host| host.name == name).cloned() + } + pub fn load(path: &Path) -> Result { 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::>(), + ["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); } } diff --git a/server/src/main.rs b/server/src/main.rs index 7be7d2d..486fb27 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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 { diff --git a/server/src/routes.rs b/server/src/routes.rs index f43c048..dbb8c0b 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -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) -> 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>) -> 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, +} + +async fn list_providers( + State(manager): State>, +) -> axum::Json> { + 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>) -> axum::Json> { + 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, + provider: String, + /// Name of a configured host; absent runs on the backend machine. #[serde(default)] host: Option, #[serde(default)] + title: Option, + #[serde(default)] model: Option, #[serde(default)] cwd: Option, @@ -128,15 +182,15 @@ async fn spawn_session( ) -> Result, 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)) } diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 21cf3d2..50ecc36 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -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 ` picks @@ -58,36 +56,54 @@ pub struct ClaudeDriver { } impl ClaudeDriver { - pub fn spawn(meta: &SessionConfig, session_dir: &Path, sink: EventSink) -> Result { - 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 { + let mut args: Vec = ["-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}") } ), }); diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index d6572a5..05536c5 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -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, + pub provider: String, + /// Name of a configured host to run on; absent runs on this machine. pub host: Option, + pub title: Option, pub model: Option, pub cwd: Option, pub permission_mode: Option, @@ -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, + pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[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 { @@ -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 { + 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 { + self.inner.read().unwrap().config.hosts.clone() + } + pub fn spawn_session(&self, spec: SpawnSpec) -> Result { 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::>() + .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::>() + .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)> { + 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> { +fn launch( + meta: SessionConfig, + provider: &ProviderConfig, + host: Option<&HostConfig>, + data_dir: &Path, +) -> Result> { 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> { model: Mutex::new(meta.model.clone()), }); - let driver: Box = match meta.kind { - SessionKind::Echo => Box::new(EchoDriver::new(sink.clone())), - SessionKind::Claude => Box::new(ClaudeDriver::spawn(&meta, &dir, sink.clone())?), + let driver: Box = 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); diff --git a/server/src/ssh.rs b/server/src/ssh.rs new file mode 100644 index 0000000..28e7958 --- /dev/null +++ b/server/src/ssh.rs @@ -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("e(&cwd.to_string_lossy())); + script.push_str(" && "); + } + script.push_str("exec "); + script.push_str("e(program)); + for arg in args { + script.push(' '); + script.push_str("e(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(args: [&str; N]) -> Vec { + args.iter().map(|arg| arg.to_string()).collect() + } + + /// The rendered argv, for asserting on what would actually run. + fn argv(command: &Command) -> Vec { + 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; '\" ")); + } +}