From 95d389e2b8b9daa3b33c99901f8324d2012a3b56 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 24 Aug 2026 21:31:50 -0400 Subject: [PATCH] Phase 2 core: ClaudeDriver over stream-json, permissions and questions on the phone The second driver behind the same trait: claude -p with stream-json both ways, the hidden --permission-prompt-tool stdio flag (without which no permission ever reaches a client), text deltas streamed from raw API events, tool_use/tool_result mapped to tool events, and can_use_tool control requests surfaced as Question events -- plain permissions as Allow/Deny, AskUserQuestion as one Question per sub-question with the chosen labels sent back in updatedInput.answers keyed by question text (wire shapes pinned by live probes against CLI 2.1.237, recorded in the module doc). The CLI session id is persisted per session dir, so a backend restart respawns with --resume and loses nothing. set_model rides the control protocol and persists through the manager; the spawn screen grows model/cwd/permission-mode fields. Also: the dev CA now carries proper keyUsage/basicConstraints extensions (strict verifiers reject it otherwise) -- regenerated and re-pinned before any real phone has installed the app. Verified: 20 unit tests + clippy clean; scripted end-to-end over the HTTP API (AskUserQuestion round trip, Bash permission allow, streaming, restart with --resume remembering earlier work, delete); and on the emulator, a live haiku session asking Tea-or-coffee and acknowledging the tapped answer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- .../src/main/kotlin/com/example/aiapp/Api.kt | 15 +- .../kotlin/com/example/aiapp/PinnedCert.kt | 16 +- .../kotlin/com/example/aiapp/SpawnScreen.kt | 79 +- gen-dev-cert.sh | 10 +- server/Cargo.lock | 11 + server/Cargo.toml | 2 +- server/src/config.rs | 7 +- server/src/routes.rs | 4 +- server/src/session/claude.rs | 736 ++++++++++++++++++ server/src/session/mod.rs | 39 +- 10 files changed, 882 insertions(+), 37 deletions(-) create mode 100644 server/src/session/claude.rs diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index dba837d..cb2a296 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -111,12 +111,23 @@ fun fetchSessions(settings: ServerSettings): List = } /** Spawns a session and returns it as the list would show it. */ -fun spawnSession(settings: ServerSettings, kind: String, title: String): SessionSummary = +fun spawnSession( + settings: ServerSettings, + kind: String, + title: String, + model: String? = null, + cwd: String? = null, + permissionMode: String? = null, +): SessionSummary = requestFromServer( settings, "/sessions", method = "POST", - jsonBody = JSONObject().put("kind", kind).put("title", title).toString(), + jsonBody = JSONObject().put("kind", kind).put("title", title).apply { + if (!model.isNullOrBlank()) put("model", model) + if (!cwd.isNullOrBlank()) put("cwd", cwd) + if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) + }.toString(), ) { connection -> val session = JSONObject(connection.inputStream.bufferedReader().readText()) SessionSummary( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt index 9407cc4..a3cf058 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt @@ -23,16 +23,16 @@ import javax.net.ssl.X509TrustManager * the APK, so photographing the terminal leaks only the (rotatable) token. */ const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE----- -MIIBrjCCAVWgAwIBAgIUGomDFgIrkwxX9504lixc8kM83I0wCgYIKoZIzj0EAwIw +MIIBvzCCAWWgAwIBAgIUGCxNZPJIfzGQipZGxWVZ8vMYZXgwCgYIKoZIzj0EAwIw LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe -Fw0yNjA4MjUwMDQ5NDBaFw0zNjA4MjIwMDQ5NDBaMC0xEzARBgNVBAoMCmFpLWFw +Fw0yNjA4MjUwMTI2MDRaFw0zNjA4MjIwMTI2MDRaMC0xEzARBgNVBAoMCmFpLWFw cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO -PQMBBwNCAARW8deDZhiVxUDo1TyGMIpOpvu45vei8Vd5rWFNgSOl80h8TQ8/v8fI -tcacAGiPK0OUDOPb6iSaSMS8QEtfPB8+o1MwUTAdBgNVHQ4EFgQUFnEtKeV8et8Z -O7/ihnMOckS45qwwHwYDVR0jBBgwFoAUFnEtKeV8et8ZO7/ihnMOckS45qwwDwYD -VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiBppx+X09AjEJ8X24KxHYaX -4NRE+8OlxqJCrkuAM7tLagIgND6JvA5K0WjlfZxomla45C5Vd91j2jdIPmM+UkEa -Wb4= +PQMBBwNCAARE6qRKz1HeCzcvmdT6ztwTR2w4DGP97aaYJhp3z+es6dceNXdpP1qx +3DlazArgYLjOcNOHTqonj4H5NwHfeP4So2MwYTAdBgNVHQ4EFgQUL9VAISmEPDhJ +dHnl5iS10qLbcekwHwYDVR0jBBgwFoAUL9VAISmEPDhJdHnl5iS10qLbcekwDwYD +VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIh +AL7Z0wfT0pXD08J6GNbPVfy/PB3EoUtIwA9z9rYGPEhZAiBeLPiXCvIbWWTpsjRr +Uk5VTHJchx0xXCdTRSJo2BEh7Q== -----END CERTIFICATE----- """ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 82e3b6c..8ab3e22 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -1,5 +1,6 @@ package com.example.aiapp +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme @@ -26,13 +29,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -// The session kinds this build can spawn. Phase 2 adds "claude" (with -// model / working directory / permission-mode fields), phase 4 "pi" -- -// extending this list and the per-kind fields, not adding a parallel -// screen. -private val KINDS = listOf("echo") +// 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") -/** The spawn screen: kind, title, go. */ +// Claude Code 2.x permission modes. "manual" asks for everything (each ask +// arrives on the phone as a question card); the others are the CLI's own +// escalating levels of autonomy. +private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") + +// Model shortcuts the CLI accepts; free text is also fine (full model ids). +private val CLAUDE_MODELS = listOf("default", "fable", "opus", "sonnet", "haiku") + +/** The spawn screen: kind, per-kind fields, go. */ @Composable fun SpawnScreen( settings: ServerSettings, @@ -42,10 +51,13 @@ fun SpawnScreen( val scope = rememberCoroutineScope() var kind by remember { mutableStateOf(KINDS.first()) } var title by remember { mutableStateOf("") } + var model by remember { mutableStateOf("default") } + var cwd by remember { mutableStateOf("") } + var permissionMode by remember { mutableStateOf("manual") } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - Column(Modifier.fillMaxSize().padding(16.dp)) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( "New session", @@ -57,14 +69,13 @@ fun SpawnScreen( Spacer(Modifier.height(16.dp)) Text("Kind", style = MaterialTheme.typography.labelLarge) - Row { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { KINDS.forEach { candidate -> FilterChip( selected = kind == candidate, onClick = { kind = candidate }, label = { Text(candidate) }, ) - Spacer(Modifier.height(0.dp)) } } Spacer(Modifier.height(16.dp)) @@ -73,10 +84,49 @@ fun SpawnScreen( value = title, onValueChange = { title = it }, label = { Text("Title") }, - placeholder = { Text("Echo session") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) + + if (kind == "claude") { + Spacer(Modifier.height(16.dp)) + Text("Model", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CLAUDE_MODELS.forEach { candidate -> + FilterChip( + selected = model == candidate, + onClick = { model = candidate }, + label = { Text(candidate) }, + ) + } + } + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = cwd, + onValueChange = { cwd = it }, + label = { Text("Working directory") }, + placeholder = { Text("/home/…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + Text("Permissions", style = MaterialTheme.typography.labelLarge) + // Two rows rather than horizontal scroll: all the choices stay + // visible, and bypassPermissions shouldn't be pickable blind. + for (chunk in PERMISSION_MODES.chunked(3)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + chunk.forEach { candidate -> + FilterChip( + selected = permissionMode == candidate, + onClick = { permissionMode = candidate }, + label = { Text(candidate) }, + ) + } + } + } + } Spacer(Modifier.height(24.dp)) error?.let { @@ -90,7 +140,14 @@ fun SpawnScreen( scope.launch { try { val spawned = withContext(Dispatchers.IO) { - spawnSession(settings, kind, title.trim()) + spawnSession( + settings, + kind, + title.trim(), + model = model.takeIf { kind == "claude" && it != "default" }, + cwd = cwd.takeIf { kind == "claude" }, + permissionMode = permissionMode.takeIf { kind == "claude" }, + ) } onSpawned(spawned) } catch (e: ApiException) { diff --git a/gen-dev-cert.sh b/gen-dev-cert.sh index de6f4af..ec0ba06 100755 --- a/gen-dev-cert.sh +++ b/gen-dev-cert.sh @@ -61,8 +61,13 @@ if [ -f ca.pem ]; then else echo "==> Generating CA key + self-signed CA certificate" openssl ecparam -name prime256v1 -genkey -noout -out ca-key.pem + # Explicit keyUsage: strict verifiers (e.g. Python 3.14's ssl) reject a + # CA without it, and Android could follow -- cheap to be proper now, + # expensive to regenerate after phones have pinned it. openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 \ - -subj "/O=ai-app dev/CN=ai-app dev CA" + -subj "/O=ai-app dev/CN=ai-app dev CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" fi echo "==> Generating leaf key + CSR for $SERVER_IP (+ dev addresses)" @@ -73,6 +78,9 @@ openssl req -new -key leaf-key.pem -out leaf.csr \ echo "==> Signing leaf certificate with the dev CA" cat > leaf.ext <>, UrlPath(id): UrlPath, axum::Json(body): axum::Json, ) -> Result { - lookup(&manager, &id)?.set_model(&body.model); + manager.set_session_model(&id, &body.model).map_err(bad_request)?; Ok(StatusCode::NO_CONTENT) } diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs new file mode 100644 index 0000000..ad42565 --- /dev/null +++ b/server/src/session/claude.rs @@ -0,0 +1,736 @@ +//! The Claude Code driver: `claude -p` speaking stream-json on stdio, +//! translated into the common event model. +//! +//! Wire format pinned against CLI 2.1.237 by probing (2026-08-24; scripts +//! summarized here since they live outside the repo): +//! +//! - Outbound: `system/init` (carries `session_id`, the `--resume` token), +//! `stream_event` (raw API deltas; `text_delta` is the streaming text), +//! consolidated `assistant` messages (their `tool_use` blocks have the +//! complete input), `user` messages with `tool_result` blocks, a `result` +//! per turn (usage + cost), `control_request` for anything needing a +//! human, `control_response` answering ours. +//! - Permission prompts require the hidden `--permission-prompt-tool stdio` +//! flag; they arrive as `control_request{subtype:can_use_tool}` and are +//! answered with `{behavior:"allow",updatedInput}` or +//! `{behavior:"deny",message}`. `AskUserQuestion` uses the same shape, +//! with the chosen labels added to `updatedInput` as +//! `answers:{: