diff --git a/.gitignore b/.gitignore index b748bd4..a456b32 100644 --- a/.gitignore +++ b/.gitignore @@ -7,14 +7,16 @@ local.properties .idea/ .DS_Store server/target/ -server/ai-server.log -# Private key material, regenerated by ./gen-dev-cert.sh. +# Server logs from a development run (ai-server.log by convention, +# wg-test.log from ./test-wg-tunnel.sh). +*.log + +# The state and key material below all live outside the repo now -- under +# $XDG_CONFIG_HOME/ai-app and $XDG_DATA_HOME/ai-app, because this repo is +# a mount shared with a VM the host doesn't trust (see AGENTS.md). These +# entries stay as a backstop so a stray --config or --certs pointed at the +# checkout can't commit a CA private key, a token hash, or a transcript. certs/ - -# Machine-local state: token hashes and the session list. Nothing here is -# shareable, and the token hashes shouldn't be. config.json - -# Per-session transcripts, attachments, and produced images. sessions/ diff --git a/PLAN.md b/PLAN.md index a92f7f7..092b52b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -95,9 +95,24 @@ clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit. - `session/transcript.rs` — append-only JSONL event log per session, with monotonically increasing sequence numbers (the phone's resume cursor). - `llama.rs` — `LlamaServerManager`. -- `hosts.rs` — host configs and the ssh command builder. +- `ssh.rs` — the ssh command builder (host configs ended up in `config.rs` + with the rest of the schema, so this module is only the wrapping; named + for what it does rather than `hosts.rs` as first sketched). - `usage.rs` — Anthropic usage polling. - `config.rs` — persisted schema. +- `certs.rs` — the TLS certificates, generated in process on first start + (added 2026-08-25, replacing a `gen-dev-cert.sh` that shelled out to + openssl). +- `private.rs` — creating files and directories owner-only. One module + owns the modes so "nothing this server writes is readable by anyone + else" is checkable in one place instead of re-argued at each `create` + (added 2026-08-25; config, certs, and session dirs had three copies). +- `media.rs` — the image media-type/extension table, shared by the four + places that have to agree on it: storing an upload, serving it back, + handing one to a driver's dialect, and saving one a tool produced. + +`session/pi.rs` and `llama.rs` are phase 4 and not built yet; everything +else above exists. ### The common event model 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 8c9fb5f..6ee4479 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -12,7 +12,10 @@ import java.net.URL // carrying the server's own explanation where it sent one, since those // messages are written to be read on this screen. -private const val CONNECT_TIMEOUT_MS = 5000 +// Shared with EventStream.kt, which connects the same way but then reads +// without a deadline. +const val CONNECT_TIMEOUT_MS = 5000 +private const val READ_TIMEOUT_MS = 5000 class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause) @@ -32,7 +35,7 @@ fun requestFromServer( jsonBody: String? = null, /** Raw request body as content-type to bytes -- the upload path. */ binaryBody: Pair? = null, - readTimeoutMs: Int = 5000, + readTimeoutMs: Int = READ_TIMEOUT_MS, readBody: (HttpURLConnection) -> T, ): T { val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection @@ -88,6 +91,19 @@ fun requestFromServer( } } +/** The response body as one JSON object. */ +private fun HttpURLConnection.jsonObject(): JSONObject = + JSONObject(inputStream.bufferedReader().readText()) + +/** The response body as a JSON array of objects, each mapped through [parse]. */ +private fun HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List = + JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse) + +private fun JSONArray.mapObjects(parse: (JSONObject) -> T): List = + (0 until length()).map { parse(getJSONObject(it)) } + +private fun JSONArray.strings(): List = (0 until length()).map { getString(it) } + // One row of GET /sessions. `provider` is what runs it, `host` where -- // the two are independent, so a session names both. data class SessionSummary( @@ -111,10 +127,7 @@ private fun parseSession(session: JSONObject) = SessionSummary( ) fun fetchSessions(settings: ServerSettings): List = - requestFromServer(settings, "/sessions") { connection -> - val sessions = JSONArray(connection.inputStream.bufferedReader().readText()) - (0 until sessions.length()).map { parseSession(sessions.getJSONObject(it)) } - } + requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } // What the server offers, so the spawn screen has no hardcoded lists: a // provider or host added to the server's config.json appears here with no @@ -125,23 +138,19 @@ data class RemoteHost(val name: String, val address: String) fun fetchProviders(settings: ServerSettings): List = requestFromServer(settings, "/providers") { connection -> - val providers = JSONArray(connection.inputStream.bufferedReader().readText()) - (0 until providers.length()).map { i -> - val provider = providers.getJSONObject(i) - val models = provider.optJSONArray("models") + connection.jsonObjects { provider -> Provider( name = provider.getString("name"), kind = provider.getString("kind"), - models = (0 until (models?.length() ?: 0)).map { models!!.getString(it) }, + // Omitted entirely when the provider offers none. + models = provider.optJSONArray("models")?.strings().orEmpty(), ) } } fun fetchHosts(settings: ServerSettings): List = requestFromServer(settings, "/hosts") { connection -> - val hosts = JSONArray(connection.inputStream.bufferedReader().readText()) - (0 until hosts.length()).map { i -> - val host = hosts.getJSONObject(i) + connection.jsonObjects { host -> RemoteHost(name = host.getString("name"), address = host.getString("address")) } } @@ -171,7 +180,7 @@ fun spawnSession( }.toString(), readTimeoutMs = 30000, ) { connection -> - parseSession(JSONObject(connection.inputStream.bufferedReader().readText())) + parseSession(connection.jsonObject()) } fun sendMessage( @@ -212,7 +221,7 @@ fun uploadAttachment( binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail), readTimeoutMs = 60000, ) { connection -> - JSONObject(connection.inputStream.bufferedReader().readText()).getString("id") + connection.jsonObject().getString("id") } } @@ -240,16 +249,12 @@ data class UsageSnapshot( /** The backend caches; refreshing more often than its poll interval just re-reads the cache. */ fun fetchUsage(settings: ServerSettings): List = requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection -> - val snapshots = JSONArray(connection.inputStream.bufferedReader().readText()) - (0 until snapshots.length()).map { i -> - val snapshot = snapshots.getJSONObject(i) - val windows = snapshot.getJSONArray("windows") + connection.jsonObjects { snapshot -> UsageSnapshot( provider = snapshot.getString("provider"), available = snapshot.getBoolean("available"), error = snapshot.optString("error").ifEmpty { null }, - windows = (0 until windows.length()).map { j -> - val window = windows.getJSONObject(j) + windows = snapshot.getJSONArray("windows").mapObjects { window -> UsageWindow( label = window.getString("label"), percent = window.getDouble("percent"), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index fb38c9a..f94e1fd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -49,6 +49,18 @@ fun AppRoot(settingsVersion: Int) { return } + // The one way back, whichever screen is showing and whether it was + // reached by the system back gesture or a screen's own Back button. + // Every leaf screen can have changed something the list shows, so it + // always refetches. + val goToList = { + reloadToken++ + screen = Screen.SessionList + } + if (screen !is Screen.SessionList) { + BackHandler(onBack = goToList) + } + when (val here = screen) { is Screen.SessionList -> SessionListScreen( settings = current, @@ -58,46 +70,27 @@ fun AppRoot(settingsVersion: Int) { onUsage = { screen = Screen.Usage }, onSettings = { screen = Screen.Settings }, ) - is Screen.Session -> { - BackHandler { + is Screen.Session -> SessionScreen( + settings = current, + summary = here.summary, + onBack = goToList, + ) + is Screen.Spawn -> SpawnScreen( + settings = current, + onSpawned = { spawned -> reloadToken++ - screen = Screen.SessionList - } - SessionScreen( - settings = current, - summary = here.summary, - onBack = { - reloadToken++ - screen = Screen.SessionList - }, - ) - } - is Screen.Spawn -> { - BackHandler { screen = Screen.SessionList } - SpawnScreen( - settings = current, - onSpawned = { spawned -> - reloadToken++ - screen = Screen.Session(spawned) - }, - onBack = { screen = Screen.SessionList }, - ) - } - is Screen.Usage -> { - BackHandler { screen = Screen.SessionList } - UsageScreen(settings = current, onBack = { screen = Screen.SessionList }) - } - is Screen.Settings -> { - BackHandler { screen = Screen.SessionList } - SettingsScreen( - existing = current, - onSaved = { saved -> - settings = saved - reloadToken++ - screen = Screen.SessionList - }, - onBack = { screen = Screen.SessionList }, - ) - } + screen = Screen.Session(spawned) + }, + onBack = goToList, + ) + is Screen.Usage -> UsageScreen(settings = current, onBack = goToList) + is Screen.Settings -> SettingsScreen( + existing = current, + onSaved = { saved -> + settings = saved + goToList() + }, + onBack = goToList, + ) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt index e4c7083..8366f07 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -32,7 +32,7 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S this.connection = connection try { connection.applyPinnedTls() - connection.connectTimeout = 5000 + connection.connectTimeout = CONNECT_TIMEOUT_MS // No read timeout: between events there is nothing to read for // as long as the session is idle; the server's keep-alives and // a dead socket erroring out are the liveness story. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 2d5ad54..e92e4e8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1,9 +1,15 @@ package com.example.aiapp +import android.graphics.BitmapFactory +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,7 +18,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -33,7 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -123,7 +131,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () var expandedTools by remember { mutableStateOf(setOf()) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } val activeStream = remember { AtomicReference(null) } @@ -192,8 +200,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // The system photo picker; the image uploads as soon as it's chosen, // so Send only has ids to reference. - val pickImage = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia(), + val pickImage = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), ) { uri -> if (uri != null) { scope.launch { @@ -248,10 +256,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () LazyColumn( state = listState, modifier = Modifier.weight(1f).fillMaxWidth(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - itemsIndexed(items) { _, item -> + items(items) { item -> when (item) { is TranscriptItem.UserMsg -> UserBubble(item.text) is TranscriptItem.AssistantMsg -> Text(item.text, style = MaterialTheme.typography.bodyLarge) @@ -291,10 +299,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () ) { TextButton(onClick = { pickImage.launch( - androidx.activity.result.PickVisualMediaRequest( - androidx.activity.result.contract.ActivityResultContracts - .PickVisualMedia.ImageOnly, - ), + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), ) }) { Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}") @@ -325,17 +330,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () */ @Composable private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) { - var bitmap by remember(ref) { - mutableStateOf(null) - } + var bitmap by remember(ref) { mutableStateOf(null) } var failed by remember(ref) { mutableStateOf(false) } LaunchedEffect(ref) { try { val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) } - bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - ?.asImageBitmap() + bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() failed = bitmap == null - } catch (e: ApiException) { + } catch (_: ApiException) { failed = true } } @@ -345,7 +347,7 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - else -> androidx.compose.foundation.Image( + else -> Image( bitmap = image, contentDescription = "session image", modifier = Modifier.fillMaxWidth(), diff --git a/app/build-apk.sh b/app/build-apk.sh index 1c7538c..4f9efc7 100755 --- a/app/build-apk.sh +++ b/app/build-apk.sh @@ -40,9 +40,9 @@ fi CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}" if [ -f "$CA" ]; then - # Printed so a wrong or stale certificate is visible here rather than as - # a handshake failure on the phone. Compare with the server's own - # the CA the server is actually presenting. + # Printed so a wrong or stale certificate is visible here rather than + # as a handshake failure on the phone -- compare it against the CA the + # backend is actually presenting. FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \ | openssl pkey -pubin -outform der 2>/dev/null \ | openssl dgst -sha256 -binary 2>/dev/null \ diff --git a/server/src/certs.rs b/server/src/certs.rs index 4225eaa..2ce53b1 100644 --- a/server/src/certs.rs +++ b/server/src/certs.rs @@ -25,7 +25,6 @@ //! which is exactly the attack pinning exists to stop. use std::net::IpAddr; -use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -33,6 +32,8 @@ use rcgen::{ BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, }; +use crate::private; + /// Where the leaf lives, for handing to the TLS listener. pub struct Certificates { pub leaf_cert: PathBuf, @@ -45,17 +46,7 @@ pub struct Certificates { /// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what /// is missing. Safe to call on every start. pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result { - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(dir) - .with_context(|| format!("create {}", dir.display()))?; - // Set explicitly as well: `mode` applies only when the directory is - // created, so a directory that already existed -- made by hand, or by - // an older version -- would otherwise keep whatever permissions it had - // while holding a private key. - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) - .with_context(|| format!("restrict {}", dir.display()))?; + private::create_dir(dir)?; let ca_cert_path = dir.join("ca.pem"); let ca_key_path = dir.join("ca-key.pem"); @@ -63,8 +54,8 @@ pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result { let (ca_pem, ca_key_pem) = if ca_is_new { let (pem, key) = generate_ca()?; - write_private(&ca_key_path, &key)?; - write_private(&ca_cert_path, &pem)?; + private::write_file(&ca_key_path, key.as_bytes())?; + private::write_file(&ca_cert_path, pem.as_bytes())?; tracing::info!("generated a new CA in {}", dir.display()); (pem, key) } else { @@ -79,8 +70,8 @@ pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result { let (leaf_pem, leaf_key_pem) = generate_leaf(&ca_pem, &ca_key_pem, addresses)?; let leaf_cert = dir.join("leaf.pem"); let leaf_key = dir.join("leaf-key.pem"); - write_private(&leaf_key, &leaf_key_pem)?; - write_private(&leaf_cert, &leaf_pem)?; + private::write_file(&leaf_key, leaf_key_pem.as_bytes())?; + private::write_file(&leaf_cert, leaf_pem.as_bytes())?; Ok(Certificates { leaf_cert, leaf_key, ca_is_new }) } @@ -111,7 +102,7 @@ fn generate_leaf( params.distinguished_name.push(DnType::OrganizationName, "ai-app dev"); params.distinguished_name.push( DnType::CommonName, - addresses.first().map(|a| a.to_string()).unwrap_or_else(|| "dev-updater".to_string()), + addresses.first().map(|a| a.to_string()).unwrap_or_else(|| "ai-app".to_string()), ); params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect(); params.is_ca = IsCa::ExplicitNoCa; @@ -121,24 +112,10 @@ fn generate_leaf( Ok((certificate.pem(), key.serialize_pem())) } -/// Writes owner-readable only, from the moment the file exists rather than -/// a `chmod` afterwards. -fn write_private(path: &Path, contents: &str) -> Result<()> { - use std::io::Write; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path) - .with_context(|| format!("write {}", path.display()))?; - file.write_all(contents.as_bytes()) - .with_context(|| format!("write {}", path.display())) -} - #[cfg(test)] mod tests { use super::*; + use std::os::unix::fs::PermissionsExt; fn addresses() -> Vec { vec!["10.66.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap()] diff --git a/server/src/config.rs b/server/src/config.rs index d51f616..33657b9 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -11,40 +11,12 @@ //! JSONL file in its own directory (see `session::transcript`); this file //! holds only the metadata needed to list and respawn sessions. -use std::fs::File; -use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -/// Creates `dir` (and parents) owner-accessible only. Session directories -/// and the config directory both go through here: transcripts are whole -/// conversations, which is the most sensitive thing this server stores. -pub fn create_private_dir(dir: &Path) -> Result<()> { - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(dir) - .with_context(|| format!("create {}", dir.display()))?; - // Set explicitly as well: `mode` applies only when the directory is - // created, so one that already existed -- made by hand, or by a - // version that didn't do this -- would otherwise keep whatever - // permissions it had while holding transcripts. - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) - .with_context(|| format!("restrict {}", dir.display())) -} - -/// Opens `path` for writing, creating it owner-readable only. -pub fn private_file(path: &Path) -> Result { - std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path) - .with_context(|| format!("write {}", path.display())) -} +use crate::private; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -154,9 +126,11 @@ pub struct SessionConfig { /// Working directory the session's process runs in. #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, - /// Claude permission mode chosen at spawn (default/plan/acceptEdits/ - /// bypassPermissions). Meaningless for other kinds; kept as a string - /// because it is passed through to the CLI, not interpreted here. + /// Claude permission mode chosen at spawn. Meaningless for other + /// kinds, and kept as a string because it is passed straight to the + /// CLI's `--permission-mode` rather than interpreted here -- so the + /// CLI stays the one authority on which modes exist, and a new one + /// needs no change on this side. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, /// Epoch seconds when the session was spawned. @@ -215,16 +189,11 @@ impl Config { /// config is never briefly world-readable at its real path. pub fn save(&self, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { - create_private_dir(parent)?; + private::create_dir(parent)?; } let text = serde_json::to_string_pretty(self).context("serialize config")?; let tmp = path.with_extension("json.tmp"); - { - use std::io::Write; - let mut file = private_file(&tmp)?; - file.write_all(text.as_bytes()) - .with_context(|| format!("write {}", tmp.display()))?; - } + private::write_file(&tmp, text.as_bytes())?; std::fs::rename(&tmp, path) .with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?; Ok(()) diff --git a/server/src/main.rs b/server/src/main.rs index 77a29fa..d188838 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -16,6 +16,8 @@ mod auth; mod certs; mod config; +mod media; +mod private; mod routes; mod session; mod ssh; @@ -37,18 +39,22 @@ const WG_INTERFACE: &str = "wg0"; /// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.json` /// and `certs/`. fn config_home() -> PathBuf { - xdg_dir("XDG_CONFIG_HOME", ".config") + xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config") } /// `$XDG_DATA_HOME/ai-app`, or `~/.local/share/ai-app`. Holds the session /// directories: transcripts, attachments, produced images. fn data_home() -> PathBuf { - xdg_dir("XDG_DATA_HOME", ".local/share") + xdg_dir(std::env::var_os("XDG_DATA_HOME"), ".local/share") } -fn xdg_dir(var: &str, fallback: &str) -> PathBuf { - std::env::var_os(var) - .map(PathBuf::from) +/// This app's directory under `base` -- the XDG variable's value, if it +/// was set to an absolute path as the spec requires -- or under +/// `~/` otherwise. Takes the value rather than reading the +/// environment itself so the rule is testable without mutating a +/// process-wide variable other threads may be reading. +fn xdg_dir(base: Option, fallback: &str) -> PathBuf { + base.map(PathBuf::from) .filter(|path| path.is_absolute()) .unwrap_or_else(|| { std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(fallback) @@ -263,15 +269,18 @@ mod tests { #[test] fn xdg_dirs_respect_the_environment_and_are_namespaced() { - // Relative values are ignored per the spec, rather than resolving - // against whatever the working directory happens to be. - unsafe { std::env::set_var("AI_APP_TEST_XDG", "relative/path") }; - let fallback = xdg_dir("AI_APP_TEST_XDG", ".config"); - assert!(fallback.is_absolute() || fallback.starts_with(".")); - assert!(fallback.ends_with("ai-app")); + let home_fallback = xdg_dir(None, ".config"); + assert!(home_fallback.ends_with("ai-app")); + assert!(home_fallback.parent().expect("parent").ends_with(".config")); - unsafe { std::env::set_var("AI_APP_TEST_XDG", "/somewhere") }; - assert_eq!(xdg_dir("AI_APP_TEST_XDG", ".config"), PathBuf::from("/somewhere/ai-app")); - unsafe { std::env::remove_var("AI_APP_TEST_XDG") }; + assert_eq!( + xdg_dir(Some("/somewhere".into()), ".config"), + PathBuf::from("/somewhere/ai-app"), + ); + + // Relative values are ignored per the spec, rather than resolving + // against whatever the working directory happens to be -- so a + // relative setting lands on the same path as no setting at all. + assert_eq!(xdg_dir(Some("relative/path".into()), ".config"), home_fallback); } } diff --git a/server/src/media.rs b/server/src/media.rs new file mode 100644 index 0000000..9626716 --- /dev/null +++ b/server/src/media.rs @@ -0,0 +1,59 @@ +//! The image types that travel between the phone, the session +//! directories, and a driver's dialect. +//! +//! Media type and file extension have to agree in four places -- storing +//! an upload, serving it back, handing it to a CLI as a content block, and +//! saving one a tool produced -- so the table lives here once. The +//! *default* for an unrecognized type is deliberately not here: it differs +//! by direction (a phone upload is a photo, a produced image is a +//! screenshot), so each caller states its own. + +/// Media type to extension. Only the types Claude's API accepts as image +/// content blocks -- anything else has nowhere to go. +const IMAGE_TYPES: [(&str, &str); 4] = [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), +]; + +/// The extension to store `media_type` under, or `None` if it isn't an +/// image type this server handles. +pub fn extension_for(media_type: &str) -> Option<&'static str> { + IMAGE_TYPES + .iter() + .find(|(known, _)| *known == media_type) + .map(|(_, extension)| *extension) +} + +/// The media type of a stored file, from its extension. Names are +/// server-generated (`.`, always lowercase), so no case +/// folding is needed; `None` for anything else. +pub fn media_type_for(name: &str) -> Option<&'static str> { + let (_, extension) = name.rsplit_once('.')?; + IMAGE_TYPES + .iter() + .find(|(_, known)| *known == extension) + .map(|(media_type, _)| *media_type) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_two_directions_agree() { + for (media_type, extension) in IMAGE_TYPES { + assert_eq!(extension_for(media_type), Some(extension)); + assert_eq!(media_type_for(&format!("abc123.{extension}")), Some(media_type)); + } + } + + #[test] + fn unknown_types_are_the_callers_problem() { + assert_eq!(extension_for("application/pdf"), None); + assert_eq!(media_type_for("abc123.pdf"), None); + // No extension at all -- not "the whole name is the extension". + assert_eq!(media_type_for("abc123"), None); + } +} diff --git a/server/src/private.rs b/server/src/private.rs new file mode 100644 index 0000000..29b0da0 --- /dev/null +++ b/server/src/private.rs @@ -0,0 +1,48 @@ +//! Creating files and directories this server alone can read. +//! +//! Everything the server writes outside the repo goes through here: the +//! config (token hashes, hosts, sessions), the TLS private keys, and the +//! session directories holding whole transcripts. One module owns the +//! modes so "owner-only" is a property that can be checked in one place +//! rather than re-argued at every `create`. + +use std::fs::File; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// Creates `dir` and its parents, owner-accessible only. +pub fn create_dir(dir: &Path) -> Result<()> { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("create {}", dir.display()))?; + // Set explicitly as well: `mode` applies only when the directory is + // created, so one that already existed -- made by hand, or by a + // version that didn't do this -- would otherwise keep whatever + // permissions it had while holding secrets. + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("restrict {}", dir.display())) +} + +/// Opens `path` for writing, truncating it, owner-readable only from the +/// moment it exists rather than by a `chmod` afterwards. +pub fn create_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("write {}", path.display())) +} + +/// Writes `contents` to `path`, owner-readable only. +pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> { + use std::io::Write; + create_file(path)? + .write_all(contents) + .with_context(|| format!("write {}", path.display())) +} diff --git a/server/src/routes.rs b/server/src/routes.rs index dbb8c0b..1435e29 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -69,8 +69,8 @@ pub fn router(manager: Arc) -> Router { #[derive(Debug, thiserror::Error)] enum ApiError { - #[error("no session {0}")] - UnknownSession(String), + #[error("{0}")] + NotFound(String), #[error("no such route")] UnknownRoute, #[error("{0}")] @@ -82,7 +82,7 @@ enum ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match self { - Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND, + Self::NotFound(_) | Self::UnknownRoute => StatusCode::NOT_FOUND, Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::Internal(err) => { // The only variant whose real cause isn't safe to hand @@ -103,7 +103,7 @@ fn bad_request(err: anyhow::Error) -> ApiError { } fn lookup(manager: &SessionManager, id: &str) -> Result, ApiError> { - manager.session(id).ok_or_else(|| ApiError::UnknownSession(id.to_string())) + manager.session(id).ok_or_else(|| ApiError::NotFound(format!("no session {id}"))) } async fn list_sessions(State(manager): State>) -> axum::Json> { @@ -207,8 +207,8 @@ async fn delete_session( #[serde(rename_all = "camelCase")] struct MessageRequest { text: String, - /// Ids from `POST /attachments` (phase 2); accepted now so the request - /// shape doesn't change under the app. + /// Ids from `POST /attachments`, uploaded before the message that + /// references them. #[serde(default)] attachment_ids: Vec, } @@ -326,18 +326,16 @@ async fn serve_file( let candidates = [session.dir().join("files").join(&name), session.dir().join("attachments").join(&name)]; let Some(path) = candidates.iter().find(|path| path.is_file()) else { - return Err(ApiError::BadRequest(format!("no file {name} in session {id}"))); - }; - let bytes = std::fs::read(path).map_err(|err| { - tracing::error!("read {} failed: {err}", path.display()); - ApiError::BadRequest("file unreadable".to_string()) - })?; - let content_type = match name.rsplit('.').next() { - Some("png") => "image/png", - Some("gif") => "image/gif", - Some("webp") => "image/webp", - _ => "image/jpeg", + return Err(ApiError::NotFound(format!("no file {name} in session {id}"))); }; + // A file that is there but unreadable is this server's fault, not the + // request's -- Internal logs it and says nothing more to the caller. + let bytes = std::fs::read(path) + .with_context(|| format!("read {}", path.display())) + .map_err(ApiError::Internal)?; + // Names are server-generated, so an unrecognized extension can only + // mean a file this server didn't write. + let content_type = crate::media::media_type_for(&name).unwrap_or("image/jpeg"); Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()) } diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 6b85ef6..2f912e7 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -339,21 +339,14 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result { "type": "image", "source": { "type": "base64", - "media_type": media_type_of(id), + // Ids carry the extension the upload was stored under, so an + // unrecognized one means a name this server didn't write. + "media_type": crate::media::media_type_for(id).unwrap_or("image/jpeg"), "data": base64::engine::general_purpose::STANDARD.encode(bytes), } })) } -fn media_type_of(name: &str) -> &'static str { - match name.rsplit('.').next() { - Some("png") => "image/png", - Some("gif") => "image/gif", - Some("webp") => "image/webp", - _ => "image/jpeg", - } -} - /// What answering a question produced. enum AnswerOutcome { /// Send this control_response line to the CLI. @@ -563,9 +556,7 @@ impl Translator { "response": {"subtype": "success", "request_id": request_id, "response": response}, })) } -} -impl Translator { /// `user` messages: tool results become ToolEnd, with any image parts /// saved into the session dir and referenced by an Image event (the /// phone fetches them from `/sessions/{id}/files/{ref}`). Replayed and @@ -620,16 +611,17 @@ impl Translator { let data = source.get("data")?.as_str()?; use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD.decode(data).ok()?; - let extension = match source.get("media_type").and_then(Value::as_str) { - Some("image/jpeg") => "jpg", - Some("image/gif") => "gif", - Some("image/webp") => "webp", - _ => "png", - }; + // Screenshots are the overwhelming case, and they are PNG; an + // unrecognized type is more likely a dialect change than a JPEG. + let extension = source + .get("media_type") + .and_then(Value::as_str) + .and_then(crate::media::extension_for) + .unwrap_or("png"); let name = format!("{}.{extension}", super::random_hex()); let dir = self.session_dir.join("files"); if let Err(err) = - crate::config::create_private_dir(&dir) + crate::private::create_dir(&dir) .map_err(std::io::Error::other) .and_then(|()| std::fs::write(dir.join(&name), bytes)) { diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 38e83f5..1b98eda 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -9,9 +9,10 @@ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -/// Attachment id of an uploaded image, as returned by `POST /attachments` -/// (arrives in phase 2; the vocabulary is fixed now so the trait doesn't -/// change under the first two drivers). +/// The name a session's image is stored and served under -- returned by +/// `POST /attachments` for an upload, minted by a driver for one a tool +/// produced, and fetched back from `/sessions/{id}/files/{ref}`. Both +/// directions use the one id so the transcript renders them identically. pub type ImageRef = String; /// Everything a session can tell the outside world. Every event is @@ -36,8 +37,8 @@ pub enum Event { }, ToolUpdate { id: String, output: String }, ToolEnd { id: String, output: String }, - /// An image the session produced, saved under the session dir and - /// referenced by id; the phone fetches it by URL (phase 2). + /// An image the session produced or was sent, saved under the session + /// dir and referenced by id; the phone fetches it by URL. Image { #[serde(rename = "ref")] image: ImageRef, diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 342b431..8d4f70c 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -46,7 +46,7 @@ impl Driver for EchoDriver { let sink = self.sink.clone(); if let Some(rest) = text.strip_prefix("/question") { - let id = format!("q-{}", rand_id()); + let id = format!("q-{}", super::random_hex()); let prompt = if rest.trim().is_empty() { "Echo asks: proceed?".to_string() } else { @@ -71,7 +71,7 @@ impl Driver for EchoDriver { send(Event::Status { state: SessionStatus::Running }); if let Some(input) = run_tool { - let id = format!("t-{}", rand_id()); + let id = format!("t-{}", super::random_hex()); send(Event::ToolStart { id: id.clone(), tool: "echo-tool".to_string(), @@ -132,12 +132,3 @@ impl Driver for EchoDriver { self.emit(Event::Status { state: SessionStatus::Exited }); } } - -/// Short random suffix for tool/question ids -- unique within a session is -/// all that's needed. -fn rand_id() -> String { - use rand::Rng; - let mut bytes = [0u8; 4]; - rand::rng().fill_bytes(&mut bytes); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 75289b0..3a8086a 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -139,15 +139,12 @@ impl LiveSession { /// references it by. Removed with the session directory on delete -- /// the same path out as everything else in it. pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result { - let extension = match content_type { - "image/png" => "png", - "image/gif" => "gif", - "image/webp" => "webp", - _ => "jpg", - }; + // An unrecognized type is almost always a phone photo whose + // content type the picker didn't set; jpg is the useful guess. + let extension = crate::media::extension_for(content_type).unwrap_or("jpg"); let name = format!("{}.{extension}", random_hex()); let dir = self.dir().join("attachments"); - crate::config::create_private_dir(&dir)?; + crate::private::create_dir(&dir)?; std::fs::write(dir.join(&name), bytes) .with_context(|| format!("write attachment {name}"))?; Ok(name) @@ -189,7 +186,7 @@ impl SessionManager { /// session spawns its event pump). pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result { let config = Config::load(&config_path)?; - crate::config::create_private_dir(&data_dir)?; + crate::private::create_dir(&data_dir)?; let mut live = HashMap::new(); for meta in &config.sessions { @@ -451,7 +448,7 @@ fn launch( data_dir: &Path, ) -> Result> { let dir = data_dir.join(&meta.id); - crate::config::create_private_dir(&dir)?; + crate::private::create_dir(&dir)?; let transcript_path = dir.join("transcript.jsonl"); let transcript = Transcript::open(&transcript_path)?; diff --git a/server/wg-test.log b/server/wg-test.log deleted file mode 100644 index 83a5a3e..0000000 --- a/server/wg-test.log +++ /dev/null @@ -1,4 +0,0 @@ -2026-08-25T06:18:57.533304Z  INFO ai_server: config: /home/bob/host/repos/ai-app/config.json -2026-08-25T06:18:57.537682Z  INFO ai_server: serving https://10.66.0.1:8443 -2026-08-25T06:20:43.470080Z  INFO ai_server: config: /home/bob/host/repos/ai-app/config.json -2026-08-25T06:20:43.474488Z  INFO ai_server: serving https://10.66.0.1:8443