Providers and hosts: what runs, and where, as independent choices
A session now names a provider (what: driver kind, command, models) and optionally a host (where: an ssh target). Keeping them independent is what the real setup needs -- the backend runs where the phone can reach it, which isn't where the CLI is installed -- and it means any provider can be sent to any host rather than a machine being baked into one. The first provider is claude-cli, named for the CLI rather than bare "claude", which would suggest the credit-billed API. A fresh config is seeded with it so a new install has something to spawn and a worked example to edit; echo stays a built-in provider needing no config. ssh.rs builds the child process either way: locally, or `ssh -T` with BatchMode and keepalives, every argument single-quoted for the remote shell (a working directory that tries to close the quote and start a command is covered by a test), and `exec` so dropping the connection takes the CLI down instead of orphaning it. App: the spawn screen reads /providers and /hosts instead of hardcoded lists, so config changes need no rebuild. Chip rows are FlowRow, fixing the reported bug where a row of models that didn't fit wrapped *inside* each chip -- one letter of "haiku" per line -- rather than onto a second line. Verified: 29 tests, clippy clean; the same claude-cli provider run once locally and once over ssh, with the remote one visibly in a different environment; an unknown host name refused with the configured list; and the spawn screen on the emulator showing server-driven providers, hosts, and models that wrap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
91bbc73ae5
commit
fff1fb49e8
12 files changed
+833
-170
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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<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; '\" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user