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

+9
View File
@@ -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 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 — 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. 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 ## Things that have bitten
+38 -4
View File
@@ -44,6 +44,26 @@ Decisions already made (2026-08-24):
## Architecture ## 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) Android app (Compose)
│ HTTPS (pinned CA) — REST for actions, SSE for live events │ HTTPS (pinned CA) — REST for actions, SSE for live events
@@ -52,7 +72,8 @@ backend (Rust/Axum, desktop)
├─ SessionManager ── Session ── Driver (trait) ├─ SessionManager ── Session ── Driver (trait)
│ ├─ ClaudeDriver (claude stream-json) │ ├─ ClaudeDriver (claude stream-json)
│ └─ PiDriver (pi --mode rpc) │ └─ 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) ├─ LlamaServerManager (llama-server lifecycle, local + SSH)
├─ UsageMonitor (Anthropic OAuth usage endpoint) ├─ UsageMonitor (Anthropic OAuth usage endpoint)
└─ config.json + per-session transcript files └─ 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. is plain POSTs anyway.
``` ```
GET /sessions list (id, kind, title, host, model, status, last activity) GET /providers what can be spawned (name, kind, models)
POST /sessions spawn {kind, host, model, cwd, permission_mode, title} 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 GET /sessions/:id/events?after=N SSE: transcript replay from N, then live
POST /sessions/:id/message {text, attachment_ids} POST /sessions/:id/message {text, attachment_ids}
POST /sessions/:id/answer {question_id, answer} (questions and permissions) 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 up in this VM, so this phase isn't testable here — Claude first; the
driver seam is ready when it is.* driver seam is ready when it is.*
5. **SSH** — host config, remote spawn for both kinds, remote llama-server 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 6. **Polish** — reconnect edges, notification when a session awaits an answer
(the "your turn" push), transcript search, whatever daily use surfaces. (the "your turn" push), transcript search, whatever daily use surfaces.
@@ -88,10 +88,11 @@ fun <T> 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( data class SessionSummary(
val id: String, val id: String,
val kind: String, val provider: String,
val title: String, val title: String,
val host: String?, val host: String?,
val model: String?, val model: String?,
@@ -99,28 +100,61 @@ data class SessionSummary(
val lastActivity: Double, 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<SessionSummary> = fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { connection -> requestFromServer(settings, "/sessions") { connection ->
val sessions = JSONArray(connection.inputStream.bufferedReader().readText()) val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until sessions.length()).map { i -> (0 until sessions.length()).map { parseSession(sessions.getJSONObject(it)) }
val session = sessions.getJSONObject(i) }
SessionSummary(
id = session.getString("id"), // What the server offers, so the spawn screen has no hardcoded lists: a
kind = session.getString("kind"), // provider or host added to the server's config.json appears here with no
title = session.getString("title"), // app rebuild.
host = session.optString("host").ifEmpty { null }, data class Provider(val name: String, val kind: String, val models: List<String>)
model = session.optString("model").ifEmpty { null },
status = session.getString("status"), data class RemoteHost(val name: String, val address: String)
lastActivity = session.getDouble("lastActivity"),
fun fetchProviders(settings: ServerSettings): List<Provider> =
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<RemoteHost> =
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( fun spawnSession(
settings: ServerSettings, settings: ServerSettings,
kind: String, provider: String,
title: String, title: String,
host: String? = null,
model: String? = null, model: String? = null,
cwd: String? = null, cwd: String? = null,
permissionMode: String? = null, permissionMode: String? = null,
@@ -129,22 +163,15 @@ fun spawnSession(
settings, settings,
"/sessions", "/sessions",
method = "POST", 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 (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
}.toString(), }.toString(),
readTimeoutMs = 30000,
) { connection -> ) { connection ->
val session = JSONObject(connection.inputStream.bufferedReader().readText()) parseSession(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"),
)
} }
fun sendMessage( fun sendMessage(
@@ -177,7 +177,13 @@ private fun SessionCard(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
listOfNotNull(session.kind, session.host, session.model).joinToString(" · "), // Provider, then where it runs -- "on <host>" 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -224,7 +224,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Text(summary.title, style = MaterialTheme.typography.titleMedium) Text(summary.title, style = MaterialTheme.typography.titleMedium)
Text( Text(
listOfNotNull( listOfNotNull(
summary.kind, summary.provider,
summary.host?.let { "on $it" },
summary.model, summary.model,
if (totalTokens > 0) "$totalTokens tok" else null, if (totalTokens > 0) "$totalTokens tok" else null,
).joinToString(" · "), ).joinToString(" · "),
@@ -2,6 +2,8 @@ package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -29,19 +33,21 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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 // 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 // arrives on the phone as a question card); the others are the CLI's own
// escalating levels of autonomy. // escalating levels of autonomy.
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
// Model shortcuts the CLI accepts; free text is also fine (full model ids). /** Runs on the backend machine itself -- the "no host" case. */
private val CLAUDE_MODELS = listOf("default", "fable", "opus", "sonnet", "haiku") 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 @Composable
fun SpawnScreen( fun SpawnScreen(
settings: ServerSettings, settings: ServerSettings,
@@ -49,14 +55,39 @@ fun SpawnScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var kind by remember { mutableStateOf(KINDS.first()) } var providers by remember { mutableStateOf<List<Provider>>(emptyList()) }
var hosts by remember { mutableStateOf<List<RemoteHost>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var provider by remember { mutableStateOf<Provider?>(null) }
var host by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("default") } var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") }
var permissionMode by remember { mutableStateOf("manual") } var permissionMode by remember { mutableStateOf("manual") }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(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)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text( Text(
@@ -68,13 +99,32 @@ fun SpawnScreen(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("Kind", style = MaterialTheme.typography.labelLarge) if (loading) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { CircularProgressIndicator()
KINDS.forEach { candidate -> return@Column
FilterChip( }
selected = kind == candidate,
onClick = { kind = candidate }, ChipGroup(
label = { Text(candidate) }, 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(), modifier = Modifier.fillMaxWidth(),
) )
if (kind == "claude") { if (isClaude) {
Spacer(Modifier.height(16.dp)) if (current.models.isNotEmpty()) {
Text("Model", style = MaterialTheme.typography.labelLarge) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ChipGroup(
CLAUDE_MODELS.forEach { candidate -> label = "Model",
FilterChip( options = current.models,
selected = model == candidate, selected = model.ifEmpty { null },
onClick = { model = candidate }, onSelect = { chosen -> model = if (model == chosen) "" else chosen },
label = { Text(candidate) }, )
)
}
} }
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)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
@@ -112,20 +168,12 @@ fun SpawnScreen(
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("Permissions", style = MaterialTheme.typography.labelLarge) ChipGroup(
// Two rows rather than horizontal scroll: all the choices stay label = "Permissions",
// visible, and bypassPermissions shouldn't be pickable blind. options = PERMISSION_MODES,
for (chunk in PERMISSION_MODES.chunked(3)) { selected = permissionMode,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { onSelect = { permissionMode = it },
chunk.forEach { candidate -> )
FilterChip(
selected = permissionMode == candidate,
onClick = { permissionMode = candidate },
label = { Text(candidate) },
)
}
}
}
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -136,17 +184,19 @@ fun SpawnScreen(
Button( Button(
onClick = { onClick = {
val chosen = current ?: return@Button
busy = true busy = true
scope.launch { scope.launch {
try { try {
val spawned = withContext(Dispatchers.IO) { val spawned = withContext(Dispatchers.IO) {
spawnSession( spawnSession(
settings, settings,
kind, provider = chosen.name,
title.trim(), title = title.trim(),
model = model.takeIf { kind == "claude" && it != "default" }, host = host,
cwd = cwd.takeIf { kind == "claude" }, model = model.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { kind == "claude" }, cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
) )
} }
onSpawned(spawned) onSpawned(spawned)
@@ -156,7 +206,38 @@ fun SpawnScreen(
} }
} }
}, },
enabled = !busy, enabled = !busy && current != null,
) { Text(if (busy) "Spawning..." else "Spawn") } ) { 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<String>,
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) },
)
}
}
}
+150 -22
View File
@@ -23,9 +23,76 @@ pub struct Config {
/// the credential. A list (of one, today) so per-device tokens with /// the credential. A list (of one, today) so per-device tokens with
/// individual revocation are a config entry later, not a migration. /// individual revocation are a config entry later, not a migration.
pub tokens: Vec<TokenEntry>, 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>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TokenEntry { pub struct TokenEntry {
@@ -37,30 +104,21 @@ pub struct TokenEntry {
pub sha256: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SessionConfig { pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes. /// Stable identifier; names the session's directory and its routes.
pub id: String, pub id: String,
pub kind: SessionKind, /// Name of the [`ProviderConfig`] this session runs. Stored rather
pub title: String, /// than the resolved driver so an edited provider (a new command path,
/// Config name of the SSH host to run on; absent means local. Host /// another model) takes effect on the next relaunch; a session whose
/// configs arrive in phase 5. /// provider is gone reports as exited and can still be deleted.
#[serde(skip_serializing_if = "Option::is_none")] 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 host: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// Working directory the session's process runs in. /// Working directory the session's process runs in.
@@ -75,7 +133,37 @@ pub struct SessionConfig {
pub created: f64, 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 { 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> { pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) { match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str(&text) Ok(text) => serde_json::from_str(&text)
@@ -110,21 +198,37 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.json"); 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"); let first_run = Config::load(&path).expect("load");
assert!(first_run.tokens.is_empty()); assert!(first_run.tokens.is_empty());
assert!(first_run.sessions.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 { let config = Config {
tokens: vec![TokenEntry { tokens: vec![TokenEntry {
name: "phone".to_string(), name: "phone".to_string(),
sha256: "ab".repeat(32), 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 { sessions: vec![SessionConfig {
id: "abc123".to_string(), id: "abc123".to_string(),
kind: SessionKind::Echo, provider: "claude-cli".to_string(),
host: Some("vm".to_string()),
title: "test".to_string(), title: "test".to_string(),
host: None,
model: None, model: None,
cwd: None, cwd: None,
permission_mode: None, permission_mode: None,
@@ -136,6 +240,30 @@ mod tests {
let loaded = Config::load(&path).expect("reload"); let loaded = Config::load(&path).expect("reload");
assert_eq!(loaded.tokens[0].name, "phone"); assert_eq!(loaded.tokens[0].name, "phone");
assert_eq!(loaded.sessions[0].id, "abc123"); 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 config;
mod routes; mod routes;
mod session; mod session;
mod ssh;
mod usage; mod usage;
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
@@ -153,8 +154,14 @@ async fn main() -> Result<()> {
.with_context(|| format!("failed to load {}", config_path.display()))?, .with_context(|| format!("failed to load {}", config_path.display()))?,
); );
tracing::info!("config: {}", 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() { 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 { let bind_ip = match args.bind {
+62 -8
View File
@@ -3,8 +3,10 @@
//! wraps the whole router in. //! wraps the whole router in.
//! //!
//! ```text //! ```text
//! GET /sessions list (id, kind, title, host, model, status, last activity) //! GET /providers what can be spawned
//! POST /sessions spawn {kind, title?, host?, model?, cwd?, permissionMode?} //! 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 //! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
//! POST /sessions/{id}/message {text, attachmentIds?} //! POST /sessions/{id}/message {text, attachmentIds?}
//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions) //! 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 { pub fn router(manager: Arc<SessionManager>) -> Router {
Router::new() Router::new()
.route("/providers", get(list_providers))
.route("/hosts", get(list_hosts))
.route("/sessions", get(list_sessions).post(spawn_session)) .route("/sessions", get(list_sessions).post(spawn_session))
.route("/sessions/{id}", delete(delete_session)) .route("/sessions/{id}", delete(delete_session))
.route("/sessions/{id}/events", get(events)) .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()) 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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SpawnRequest { struct SpawnRequest {
kind: crate::config::SessionKind, provider: String,
#[serde(default)] /// Name of a configured host; absent runs on the backend machine.
title: Option<String>,
#[serde(default)] #[serde(default)]
host: Option<String>, host: Option<String>,
#[serde(default)] #[serde(default)]
title: Option<String>,
#[serde(default)]
model: Option<String>, model: Option<String>,
#[serde(default)] #[serde(default)]
cwd: Option<PathBuf>, cwd: Option<PathBuf>,
@@ -128,15 +182,15 @@ async fn spawn_session(
) -> Result<axum::Json<SessionInfo>, ApiError> { ) -> Result<axum::Json<SessionInfo>, ApiError> {
let info = manager let info = manager
.spawn_session(SpawnSpec { .spawn_session(SpawnSpec {
kind: body.kind, provider: body.provider,
title: body.title,
host: body.host, host: body.host,
title: body.title,
model: body.model, model: body.model,
cwd: body.cwd, cwd: body.cwd,
permission_mode: body.permission_mode, permission_mode: body.permission_mode,
}) })
.map_err(bad_request)?; .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)) Ok(axum::Json(info))
} }
+50 -29
View File
@@ -24,17 +24,15 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus}; 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 -- /// Where the driver remembers its CLI session id between backend runs --
/// the whole crash-recovery story: respawning with `--resume <id>` picks /// the whole crash-recovery story: respawning with `--resume <id>` picks
@@ -58,36 +56,54 @@ pub struct ClaudeDriver {
} }
impl ClaudeDriver { impl ClaudeDriver {
pub fn spawn(meta: &SessionConfig, session_dir: &Path, sink: EventSink) -> Result<Self> { pub fn spawn(
let mut command = Command::new("claude"); meta: &SessionConfig,
command provider: &ProviderConfig,
.arg("-p") host: Option<&HostConfig>,
.arg("--verbose") session_dir: &Path,
.args(["--input-format", "stream-json"]) sink: EventSink,
.args(["--output-format", "stream-json"]) ) -> Result<Self> {
.arg("--include-partial-messages") let mut args: Vec<String> = ["-p", "--verbose"].iter().map(|a| a.to_string()).collect();
// Hidden but load-bearing: without it the CLI resolves let mut push = |flag: &str, value: &str| {
// permissions itself and nothing ever reaches the phone. args.push(flag.to_string());
.args(["--permission-prompt-tool", "stdio"]); 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 { if let Some(model) = &meta.model {
command.args(["--model", model]); push("--model", model);
} }
if let Some(mode) = &meta.permission_mode { 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) { if let Some(resume) = read_resume_token(session_dir) {
command.args(["--resume", &resume]); push("--resume", &resume);
} }
if let Some(cwd) = &meta.cwd { args.push("--include-partial-messages".to_string());
command.current_dir(cwd);
} let program = provider.command.as_deref().unwrap_or("claude");
let mut child = command let cwd = meta.cwd.as_deref();
.stdin(Stdio::piped()) let where_it_runs = match host {
.stdout(Stdio::piped()) Some(host) => format!("on {} ({})", host.name, host.address),
.stderr(Stdio::piped()) None => "on this machine".to_string(),
.kill_on_drop(true) };
let mut child = crate::ssh::command(host, program, &args, cwd)
.spawn() .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 stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout"); 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 // 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::new(Mutex::new(String::new()));
{ {
let last_stderr = Arc::clone(&last_stderr); let last_stderr = Arc::clone(&last_stderr);
let label = provider.name.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines(); let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!("claude stderr: {line}"); tracing::warn!("{label} stderr: {line}");
*last_stderr.lock().unwrap() = line; *last_stderr.lock().unwrap() = line;
} }
}); });
@@ -137,6 +157,7 @@ impl ClaudeDriver {
let (kill_tx, kill_rx) = oneshot::channel::<()>(); let (kill_tx, kill_rx) = oneshot::channel::<()>();
{ {
let sink = sink.clone(); let sink = sink.clone();
let label = format!("{} {where_it_runs}", provider.name);
tokio::spawn(async move { tokio::spawn(async move {
let status = tokio::select! { let status = tokio::select! {
status = child.wait() => status.ok(), status = child.wait() => status.ok(),
@@ -151,7 +172,7 @@ impl ClaudeDriver {
let detail = last_stderr.lock().unwrap().clone(); let detail = last_stderr.lock().unwrap().clone();
let _ = sink.send(Event::Error { let _ = sink.send(Event::Error {
message: format!( message: format!(
"claude exited with {status}{}", "{label} exited with {status}{}",
if detail.is_empty() { String::new() } else { format!(": {detail}") } 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 serde::Serialize;
use tokio::sync::{broadcast, mpsc}; 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 claude::ClaudeDriver;
use driver::{Driver, Event, ImageRef, SessionStatus}; use driver::{Driver, Event, ImageRef, SessionStatus};
use echo::EchoDriver; use echo::EchoDriver;
@@ -40,9 +40,10 @@ pub fn now() -> f64 {
/// What the phone needs to spawn a session -- the spawn screen's fields. /// What the phone needs to spawn a session -- the spawn screen's fields.
pub struct SpawnSpec { pub struct SpawnSpec {
pub kind: SessionKind, pub provider: String,
pub title: Option<String>, /// Name of a configured host to run on; absent runs on this machine.
pub host: Option<String>, pub host: Option<String>,
pub title: Option<String>,
pub model: Option<String>, pub model: Option<String>,
pub cwd: Option<PathBuf>, pub cwd: Option<PathBuf>,
pub permission_mode: Option<String>, pub permission_mode: Option<String>,
@@ -53,10 +54,11 @@ pub struct SpawnSpec {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SessionInfo { pub struct SessionInfo {
pub id: String, pub id: String,
pub kind: SessionKind, pub provider: String,
pub title: String, /// Name of the host it runs on; absent means the backend machine.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>, pub host: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -154,9 +156,9 @@ impl LiveSession {
fn info(&self) -> SessionInfo { fn info(&self) -> SessionInfo {
SessionInfo { SessionInfo {
id: self.meta.id.clone(), id: self.meta.id.clone(),
kind: self.meta.kind, provider: self.meta.provider.clone(),
title: self.meta.title.clone(),
host: self.meta.host.clone(), host: self.meta.host.clone(),
title: self.meta.title.clone(),
model: self.shared.model.lock().unwrap().clone(), model: self.shared.model.lock().unwrap().clone(),
cwd: self.meta.cwd.clone(), cwd: self.meta.cwd.clone(),
status: *self.shared.status.lock().unwrap(), status: *self.shared.status.lock().unwrap(),
@@ -192,10 +194,13 @@ impl SessionManager {
let mut live = HashMap::new(); let mut live = HashMap::new();
for meta in &config.sessions { for meta in &config.sessions {
// One unlaunchable session (e.g. a corrupt transcript) shows as // One unlaunchable session -- a corrupt transcript, an
// exited rather than taking the whole server down with it; it // unreachable ssh host, a provider that was edited away --
// can still be deleted from the phone. // shows as exited rather than taking the whole server down
match launch(meta.clone(), &data_dir) { // 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) => { Ok(session) => {
live.insert(meta.id.clone(), session); live.insert(meta.id.clone(), session);
} }
@@ -204,11 +209,38 @@ impl SessionManager {
} }
} }
} }
Ok(Self { let manager = Self {
config_path, config_path,
data_dir, data_dir,
inner: RwLock::new(Inner { config, live }), 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> { pub fn tokens(&self) -> Vec<TokenEntry> {
@@ -238,9 +270,9 @@ impl SessionManager {
Some(session) => session.info(), Some(session) => session.info(),
None => SessionInfo { None => SessionInfo {
id: meta.id.clone(), id: meta.id.clone(),
kind: meta.kind, provider: meta.provider.clone(),
title: meta.title.clone(),
host: meta.host.clone(), host: meta.host.clone(),
title: meta.title.clone(),
model: meta.model.clone(), model: meta.model.clone(),
cwd: meta.cwd.clone(), cwd: meta.cwd.clone(),
status: SessionStatus::Exited, status: SessionStatus::Exited,
@@ -255,25 +287,64 @@ impl SessionManager {
self.inner.read().unwrap().live.get(id).cloned() 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> { pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
let mut inner = self.inner.write().unwrap(); 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 id = unique_id(&inner.config);
let title = spec let title = spec
.title .title
.filter(|title| !title.trim().is_empty()) .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 { let meta = SessionConfig {
id: id.clone(), id: id.clone(),
kind: spec.kind, provider: provider.name.clone(),
title,
host: spec.host, host: spec.host,
model: spec.model, title,
model: spec.model.or_else(|| provider.models.first().cloned()),
cwd: spec.cwd, cwd: spec.cwd,
permission_mode: spec.permission_mode, permission_mode: spec.permission_mode,
created: now(), 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(); let mut candidate = inner.config.clone();
candidate.sessions.push(meta); candidate.sessions.push(meta);
if let Err(err) = candidate.save(&self.config_path) { if let Err(err) = candidate.save(&self.config_path) {
@@ -334,11 +405,22 @@ impl SessionManager {
} }
} }
fn default_title(kind: SessionKind) -> String { /// The provider and host a session's config names, or a message saying
match kind { /// which one is missing. Both are looked up fresh at every launch, so
SessionKind::Echo => "Echo session".to_string(), /// editing either takes effect on the next respawn.
SessionKind::Claude => "Claude session".to_string(), 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 /// 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 /// Creates the session directory, opens its transcript (continuing the
/// sequence numbering if one exists), starts the driver, and spawns the /// sequence numbering if one exists), starts the driver, and spawns the
/// event pump connecting them. /// 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); let dir = data_dir.join(&meta.id);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let transcript_path = dir.join("transcript.jsonl"); 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()), model: Mutex::new(meta.model.clone()),
}); });
let driver: Box<dyn Driver> = match meta.kind { let driver: Box<dyn Driver> = match provider.kind {
SessionKind::Echo => Box::new(EchoDriver::new(sink.clone())), DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
SessionKind::Claude => Box::new(ClaudeDriver::spawn(&meta, &dir, sink.clone())?), DriverKind::ClaudeCli => {
Box::new(ClaudeDriver::spawn(&meta, provider, host, &dir, sink.clone())?)
}
}; };
tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone())); tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone()));
@@ -431,9 +520,9 @@ mod tests {
fn echo_spec() -> SpawnSpec { fn echo_spec() -> SpawnSpec {
SpawnSpec { SpawnSpec {
kind: SessionKind::Echo, provider: crate::config::ECHO_PROVIDER.to_string(),
title: None,
host: None, host: None,
title: None,
model: None, model: None,
cwd: None, cwd: None,
permission_mode: None, permission_mode: None,
@@ -486,7 +575,8 @@ mod tests {
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager"); let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn"); 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. // Persisted: a fresh load of the config file knows the session.
let persisted = Config::load(&config_path).expect("reload config"); let persisted = Config::load(&config_path).expect("reload config");
assert_eq!(persisted.sessions.len(), 1); 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; '\" "));
}
}