Cleanup pass: one home for duplicated logic, stale comments out
Nothing behavioral except two status codes; mostly removing places where the same rule was written down more than once and could drift. - server/src/private.rs: the owner-only create/write helpers, which config.rs, certs.rs, and the session dirs each had their own copy of (certs.rs even duplicated the explanatory comment). One module owns the modes now, so the "nothing this server writes is readable by anyone else" property is checkable in one place. - server/src/media.rs: the image media-type/extension table, which the four places that have to agree on it each spelled out separately -- storing an upload, serving it back, building a content block, saving a produced image. The differing *defaults* stay at the call sites with the reasoning, since they genuinely differ by direction. - routes.rs: a missing file was a 400 and an unreadable one a 400 with a hand-rolled log line; they are now 404 and Internal respectively. UnknownSession became NotFound, since it was the only 404-with-message. - main.rs: xdg_dir takes the variable's value instead of reading the environment, which drops the unsafe set_var from its test and lets the test actually assert the relative-path rule. - echo.rs had its own 4-byte hex generator beside session::random_hex. - claude.rs: the two impl Translator blocks were one type's methods. - Stale comments: phase-2 markers on shipped work, a permission-mode list that had drifted from the CLI's, "dev-updater" as the leaf certificate's fallback common name, a half-written sentence in build-apk.sh. - App: the JSONArray walk written out in four fetchers, the four near-identical BackHandlers in AppRoot, and SessionScreen's inline fully-qualified names where the file otherwise imports. - server/wg-test.log was committed by accident; *.log is ignored now, and the gitignore comments describe where state actually lives. - PLAN.md's backend layout gains the new modules and drops hosts.rs for the ssh.rs that was built instead. Verified: 35 server tests, clippy clean, app compiles warning-free, and a scratch server driven over curl -- attachment upload/serve round-trip with both a known and an unknown content type, the new 404s, transcript and session-dir deletion, plus a real claude-cli session answering a prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
d4a4ee7808
commit
99bcc341c1
18 files changed
+282
-228
No files matched your search
+9
-7
@@ -7,14 +7,16 @@ local.properties
|
|||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/target/
|
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/
|
certs/
|
||||||
|
|
||||||
# Machine-local state: token hashes and the session list. Nothing here is
|
|
||||||
# shareable, and the token hashes shouldn't be.
|
|
||||||
config.json
|
config.json
|
||||||
|
|
||||||
# Per-session transcripts, attachments, and produced images.
|
|
||||||
sessions/
|
sessions/
|
||||||
@@ -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
|
- `session/transcript.rs` — append-only JSONL event log per session, with
|
||||||
monotonically increasing sequence numbers (the phone's resume cursor).
|
monotonically increasing sequence numbers (the phone's resume cursor).
|
||||||
- `llama.rs` — `LlamaServerManager`.
|
- `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.
|
- `usage.rs` — Anthropic usage polling.
|
||||||
- `config.rs` — persisted schema.
|
- `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
|
### The common event model
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import java.net.URL
|
|||||||
// carrying the server's own explanation where it sent one, since those
|
// carrying the server's own explanation where it sent one, since those
|
||||||
// messages are written to be read on this screen.
|
// 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)
|
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||||
|
|
||||||
@@ -32,7 +35,7 @@ fun <T> requestFromServer(
|
|||||||
jsonBody: String? = null,
|
jsonBody: String? = null,
|
||||||
/** Raw request body as content-type to bytes -- the upload path. */
|
/** Raw request body as content-type to bytes -- the upload path. */
|
||||||
binaryBody: Pair<String, ByteArray>? = null,
|
binaryBody: Pair<String, ByteArray>? = null,
|
||||||
readTimeoutMs: Int = 5000,
|
readTimeoutMs: Int = READ_TIMEOUT_MS,
|
||||||
readBody: (HttpURLConnection) -> T,
|
readBody: (HttpURLConnection) -> T,
|
||||||
): T {
|
): T {
|
||||||
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
|
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
|
||||||
@@ -88,6 +91,19 @@ fun <T> 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 <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
|
||||||
|
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
|
||||||
|
|
||||||
|
private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
|
||||||
|
(0 until length()).map { parse(getJSONObject(it)) }
|
||||||
|
|
||||||
|
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
|
||||||
|
|
||||||
// One row of GET /sessions. `provider` is what runs it, `host` where --
|
// One row of GET /sessions. `provider` is what runs it, `host` where --
|
||||||
// the two are independent, so a session names both.
|
// the two are independent, so a session names both.
|
||||||
data class SessionSummary(
|
data class SessionSummary(
|
||||||
@@ -111,10 +127,7 @@ private fun parseSession(session: JSONObject) = SessionSummary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||||
requestFromServer(settings, "/sessions") { connection ->
|
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
|
||||||
val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
|
|
||||||
(0 until sessions.length()).map { parseSession(sessions.getJSONObject(it)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the server offers, so the spawn screen has no hardcoded lists: a
|
// 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
|
// 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<Provider> =
|
fun fetchProviders(settings: ServerSettings): List<Provider> =
|
||||||
requestFromServer(settings, "/providers") { connection ->
|
requestFromServer(settings, "/providers") { connection ->
|
||||||
val providers = JSONArray(connection.inputStream.bufferedReader().readText())
|
connection.jsonObjects { provider ->
|
||||||
(0 until providers.length()).map { i ->
|
|
||||||
val provider = providers.getJSONObject(i)
|
|
||||||
val models = provider.optJSONArray("models")
|
|
||||||
Provider(
|
Provider(
|
||||||
name = provider.getString("name"),
|
name = provider.getString("name"),
|
||||||
kind = provider.getString("kind"),
|
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<RemoteHost> =
|
fun fetchHosts(settings: ServerSettings): List<RemoteHost> =
|
||||||
requestFromServer(settings, "/hosts") { connection ->
|
requestFromServer(settings, "/hosts") { connection ->
|
||||||
val hosts = JSONArray(connection.inputStream.bufferedReader().readText())
|
connection.jsonObjects { host ->
|
||||||
(0 until hosts.length()).map { i ->
|
|
||||||
val host = hosts.getJSONObject(i)
|
|
||||||
RemoteHost(name = host.getString("name"), address = host.getString("address"))
|
RemoteHost(name = host.getString("name"), address = host.getString("address"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +180,7 @@ fun spawnSession(
|
|||||||
}.toString(),
|
}.toString(),
|
||||||
readTimeoutMs = 30000,
|
readTimeoutMs = 30000,
|
||||||
) { connection ->
|
) { connection ->
|
||||||
parseSession(JSONObject(connection.inputStream.bufferedReader().readText()))
|
parseSession(connection.jsonObject())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
@@ -212,7 +221,7 @@ fun uploadAttachment(
|
|||||||
binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail),
|
binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail),
|
||||||
readTimeoutMs = 60000,
|
readTimeoutMs = 60000,
|
||||||
) { connection ->
|
) { 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. */
|
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
|
||||||
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
||||||
requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection ->
|
requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection ->
|
||||||
val snapshots = JSONArray(connection.inputStream.bufferedReader().readText())
|
connection.jsonObjects { snapshot ->
|
||||||
(0 until snapshots.length()).map { i ->
|
|
||||||
val snapshot = snapshots.getJSONObject(i)
|
|
||||||
val windows = snapshot.getJSONArray("windows")
|
|
||||||
UsageSnapshot(
|
UsageSnapshot(
|
||||||
provider = snapshot.getString("provider"),
|
provider = snapshot.getString("provider"),
|
||||||
available = snapshot.getBoolean("available"),
|
available = snapshot.getBoolean("available"),
|
||||||
error = snapshot.optString("error").ifEmpty { null },
|
error = snapshot.optString("error").ifEmpty { null },
|
||||||
windows = (0 until windows.length()).map { j ->
|
windows = snapshot.getJSONArray("windows").mapObjects { window ->
|
||||||
val window = windows.getJSONObject(j)
|
|
||||||
UsageWindow(
|
UsageWindow(
|
||||||
label = window.getString("label"),
|
label = window.getString("label"),
|
||||||
percent = window.getDouble("percent"),
|
percent = window.getDouble("percent"),
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ fun AppRoot(settingsVersion: Int) {
|
|||||||
return
|
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) {
|
when (val here = screen) {
|
||||||
is Screen.SessionList -> SessionListScreen(
|
is Screen.SessionList -> SessionListScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
@@ -58,46 +70,27 @@ fun AppRoot(settingsVersion: Int) {
|
|||||||
onUsage = { screen = Screen.Usage },
|
onUsage = { screen = Screen.Usage },
|
||||||
onSettings = { screen = Screen.Settings },
|
onSettings = { screen = Screen.Settings },
|
||||||
)
|
)
|
||||||
is Screen.Session -> {
|
is Screen.Session -> SessionScreen(
|
||||||
BackHandler {
|
|
||||||
reloadToken++
|
|
||||||
screen = Screen.SessionList
|
|
||||||
}
|
|
||||||
SessionScreen(
|
|
||||||
settings = current,
|
settings = current,
|
||||||
summary = here.summary,
|
summary = here.summary,
|
||||||
onBack = {
|
onBack = goToList,
|
||||||
reloadToken++
|
|
||||||
screen = Screen.SessionList
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
is Screen.Spawn -> SpawnScreen(
|
||||||
is Screen.Spawn -> {
|
|
||||||
BackHandler { screen = Screen.SessionList }
|
|
||||||
SpawnScreen(
|
|
||||||
settings = current,
|
settings = current,
|
||||||
onSpawned = { spawned ->
|
onSpawned = { spawned ->
|
||||||
reloadToken++
|
reloadToken++
|
||||||
screen = Screen.Session(spawned)
|
screen = Screen.Session(spawned)
|
||||||
},
|
},
|
||||||
onBack = { screen = Screen.SessionList },
|
onBack = goToList,
|
||||||
)
|
)
|
||||||
}
|
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList)
|
||||||
is Screen.Usage -> {
|
is Screen.Settings -> SettingsScreen(
|
||||||
BackHandler { screen = Screen.SessionList }
|
|
||||||
UsageScreen(settings = current, onBack = { screen = Screen.SessionList })
|
|
||||||
}
|
|
||||||
is Screen.Settings -> {
|
|
||||||
BackHandler { screen = Screen.SessionList }
|
|
||||||
SettingsScreen(
|
|
||||||
existing = current,
|
existing = current,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
settings = saved
|
settings = saved
|
||||||
reloadToken++
|
goToList()
|
||||||
screen = Screen.SessionList
|
|
||||||
},
|
},
|
||||||
onBack = { screen = Screen.SessionList },
|
onBack = goToList,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S
|
|||||||
this.connection = connection
|
this.connection = connection
|
||||||
try {
|
try {
|
||||||
connection.applyPinnedTls()
|
connection.applyPinnedTls()
|
||||||
connection.connectTimeout = 5000
|
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
// No read timeout: between events there is nothing to read for
|
// No read timeout: between events there is nothing to read for
|
||||||
// as long as the session is idle; the server's keep-alives and
|
// as long as the session is idle; the server's keep-alives and
|
||||||
// a dead socket erroring out are the liveness story.
|
// a dead socket erroring out are the liveness story.
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.example.aiapp
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
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
|
||||||
@@ -12,7 +18,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -33,7 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -123,7 +131,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
||||||
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
||||||
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = LocalContext.current
|
||||||
// The resume cursor, written from the stream's IO thread.
|
// The resume cursor, written from the stream's IO thread.
|
||||||
val lastSeq = remember { AtomicLong(0) }
|
val lastSeq = remember { AtomicLong(0) }
|
||||||
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
val activeStream = remember { AtomicReference<EventStream?>(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,
|
// The system photo picker; the image uploads as soon as it's chosen,
|
||||||
// so Send only has ids to reference.
|
// so Send only has ids to reference.
|
||||||
val pickImage = androidx.activity.compose.rememberLauncherForActivityResult(
|
val pickImage = rememberLauncherForActivityResult(
|
||||||
androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia(),
|
ActivityResultContracts.PickVisualMedia(),
|
||||||
) { uri ->
|
) { uri ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -248,10 +256,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items) { _, item ->
|
items(items) { item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
||||||
is TranscriptItem.AssistantMsg -> Text(item.text, style = MaterialTheme.typography.bodyLarge)
|
is TranscriptItem.AssistantMsg -> Text(item.text, style = MaterialTheme.typography.bodyLarge)
|
||||||
@@ -291,10 +299,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
) {
|
) {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
pickImage.launch(
|
pickImage.launch(
|
||||||
androidx.activity.result.PickVisualMediaRequest(
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||||
androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
.PickVisualMedia.ImageOnly,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
|
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
|
||||||
@@ -325,17 +330,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
|
private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
|
||||||
var bitmap by remember(ref) {
|
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
|
|
||||||
}
|
|
||||||
var failed by remember(ref) { mutableStateOf(false) }
|
var failed by remember(ref) { mutableStateOf(false) }
|
||||||
LaunchedEffect(ref) {
|
LaunchedEffect(ref) {
|
||||||
try {
|
try {
|
||||||
val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
|
val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
|
||||||
bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||||
?.asImageBitmap()
|
|
||||||
failed = bitmap == null
|
failed = bitmap == null
|
||||||
} catch (e: ApiException) {
|
} catch (_: ApiException) {
|
||||||
failed = true
|
failed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,7 +347,7 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
else -> androidx.compose.foundation.Image(
|
else -> Image(
|
||||||
bitmap = image,
|
bitmap = image,
|
||||||
contentDescription = "session image",
|
contentDescription = "session image",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
+3
-3
@@ -40,9 +40,9 @@ fi
|
|||||||
|
|
||||||
CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}"
|
CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}"
|
||||||
if [ -f "$CA" ]; then
|
if [ -f "$CA" ]; then
|
||||||
# Printed so a wrong or stale certificate is visible here rather than as
|
# Printed so a wrong or stale certificate is visible here rather than
|
||||||
# a handshake failure on the phone. Compare with the server's own
|
# as a handshake failure on the phone -- compare it against the CA the
|
||||||
# the CA the server is actually presenting.
|
# backend is actually presenting.
|
||||||
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
|
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
|
||||||
| openssl pkey -pubin -outform der 2>/dev/null \
|
| openssl pkey -pubin -outform der 2>/dev/null \
|
||||||
| openssl dgst -sha256 -binary 2>/dev/null \
|
| openssl dgst -sha256 -binary 2>/dev/null \
|
||||||
|
|||||||
+9
-32
@@ -25,7 +25,6 @@
|
|||||||
//! which is exactly the attack pinning exists to stop.
|
//! which is exactly the attack pinning exists to stop.
|
||||||
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -33,6 +32,8 @@ use rcgen::{
|
|||||||
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::private;
|
||||||
|
|
||||||
/// Where the leaf lives, for handing to the TLS listener.
|
/// Where the leaf lives, for handing to the TLS listener.
|
||||||
pub struct Certificates {
|
pub struct Certificates {
|
||||||
pub leaf_cert: PathBuf,
|
pub leaf_cert: PathBuf,
|
||||||
@@ -45,17 +46,7 @@ pub struct Certificates {
|
|||||||
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
|
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
|
||||||
/// is missing. Safe to call on every start.
|
/// is missing. Safe to call on every start.
|
||||||
pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
||||||
std::fs::DirBuilder::new()
|
private::create_dir(dir)?;
|
||||||
.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()))?;
|
|
||||||
|
|
||||||
let ca_cert_path = dir.join("ca.pem");
|
let ca_cert_path = dir.join("ca.pem");
|
||||||
let ca_key_path = dir.join("ca-key.pem");
|
let ca_key_path = dir.join("ca-key.pem");
|
||||||
@@ -63,8 +54,8 @@ pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
|||||||
|
|
||||||
let (ca_pem, ca_key_pem) = if ca_is_new {
|
let (ca_pem, ca_key_pem) = if ca_is_new {
|
||||||
let (pem, key) = generate_ca()?;
|
let (pem, key) = generate_ca()?;
|
||||||
write_private(&ca_key_path, &key)?;
|
private::write_file(&ca_key_path, key.as_bytes())?;
|
||||||
write_private(&ca_cert_path, &pem)?;
|
private::write_file(&ca_cert_path, pem.as_bytes())?;
|
||||||
tracing::info!("generated a new CA in {}", dir.display());
|
tracing::info!("generated a new CA in {}", dir.display());
|
||||||
(pem, key)
|
(pem, key)
|
||||||
} else {
|
} else {
|
||||||
@@ -79,8 +70,8 @@ pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
|||||||
let (leaf_pem, leaf_key_pem) = generate_leaf(&ca_pem, &ca_key_pem, addresses)?;
|
let (leaf_pem, leaf_key_pem) = generate_leaf(&ca_pem, &ca_key_pem, addresses)?;
|
||||||
let leaf_cert = dir.join("leaf.pem");
|
let leaf_cert = dir.join("leaf.pem");
|
||||||
let leaf_key = dir.join("leaf-key.pem");
|
let leaf_key = dir.join("leaf-key.pem");
|
||||||
write_private(&leaf_key, &leaf_key_pem)?;
|
private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
|
||||||
write_private(&leaf_cert, &leaf_pem)?;
|
private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
|
||||||
|
|
||||||
Ok(Certificates { leaf_cert, leaf_key, ca_is_new })
|
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::OrganizationName, "ai-app dev");
|
||||||
params.distinguished_name.push(
|
params.distinguished_name.push(
|
||||||
DnType::CommonName,
|
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.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
|
||||||
params.is_ca = IsCa::ExplicitNoCa;
|
params.is_ca = IsCa::ExplicitNoCa;
|
||||||
@@ -121,24 +112,10 @@ fn generate_leaf(
|
|||||||
Ok((certificate.pem(), key.serialize_pem()))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
fn addresses() -> Vec<IpAddr> {
|
fn addresses() -> Vec<IpAddr> {
|
||||||
vec!["10.66.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap()]
|
vec!["10.66.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap()]
|
||||||
|
|||||||
+8
-39
@@ -11,40 +11,12 @@
|
|||||||
//! JSONL file in its own directory (see `session::transcript`); this file
|
//! JSONL file in its own directory (see `session::transcript`); this file
|
||||||
//! holds only the metadata needed to list and respawn sessions.
|
//! 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 std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Creates `dir` (and parents) owner-accessible only. Session directories
|
use crate::private;
|
||||||
/// 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<File> {
|
|
||||||
std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("write {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
@@ -154,9 +126,11 @@ pub struct SessionConfig {
|
|||||||
/// Working directory the session's process runs in.
|
/// Working directory the session's process runs in.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
/// Claude permission mode chosen at spawn (default/plan/acceptEdits/
|
/// Claude permission mode chosen at spawn. Meaningless for other
|
||||||
/// bypassPermissions). Meaningless for other kinds; kept as a string
|
/// kinds, and kept as a string because it is passed straight to the
|
||||||
/// because it is passed through to the CLI, not interpreted here.
|
/// 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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission_mode: Option<String>,
|
pub permission_mode: Option<String>,
|
||||||
/// Epoch seconds when the session was spawned.
|
/// Epoch seconds when the session was spawned.
|
||||||
@@ -215,16 +189,11 @@ impl Config {
|
|||||||
/// config is never briefly world-readable at its real path.
|
/// config is never briefly world-readable at its real path.
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
if let Some(parent) = path.parent() {
|
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 text = serde_json::to_string_pretty(self).context("serialize config")?;
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
{
|
private::write_file(&tmp, text.as_bytes())?;
|
||||||
use std::io::Write;
|
|
||||||
let mut file = private_file(&tmp)?;
|
|
||||||
file.write_all(text.as_bytes())
|
|
||||||
.with_context(|| format!("write {}", tmp.display()))?;
|
|
||||||
}
|
|
||||||
std::fs::rename(&tmp, path)
|
std::fs::rename(&tmp, path)
|
||||||
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?;
|
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+23
-14
@@ -16,6 +16,8 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod certs;
|
mod certs;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod media;
|
||||||
|
mod private;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod session;
|
mod session;
|
||||||
mod ssh;
|
mod ssh;
|
||||||
@@ -37,18 +39,22 @@ const WG_INTERFACE: &str = "wg0";
|
|||||||
/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.json`
|
/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.json`
|
||||||
/// and `certs/`.
|
/// and `certs/`.
|
||||||
fn config_home() -> PathBuf {
|
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
|
/// `$XDG_DATA_HOME/ai-app`, or `~/.local/share/ai-app`. Holds the session
|
||||||
/// directories: transcripts, attachments, produced images.
|
/// directories: transcripts, attachments, produced images.
|
||||||
fn data_home() -> PathBuf {
|
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 {
|
/// This app's directory under `base` -- the XDG variable's value, if it
|
||||||
std::env::var_os(var)
|
/// was set to an absolute path as the spec requires -- or under
|
||||||
.map(PathBuf::from)
|
/// `~/<fallback>` 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<std::ffi::OsString>, fallback: &str) -> PathBuf {
|
||||||
|
base.map(PathBuf::from)
|
||||||
.filter(|path| path.is_absolute())
|
.filter(|path| path.is_absolute())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(fallback)
|
std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(fallback)
|
||||||
@@ -263,15 +269,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn xdg_dirs_respect_the_environment_and_are_namespaced() {
|
fn xdg_dirs_respect_the_environment_and_are_namespaced() {
|
||||||
// Relative values are ignored per the spec, rather than resolving
|
let home_fallback = xdg_dir(None, ".config");
|
||||||
// against whatever the working directory happens to be.
|
assert!(home_fallback.ends_with("ai-app"));
|
||||||
unsafe { std::env::set_var("AI_APP_TEST_XDG", "relative/path") };
|
assert!(home_fallback.parent().expect("parent").ends_with(".config"));
|
||||||
let fallback = xdg_dir("AI_APP_TEST_XDG", ".config");
|
|
||||||
assert!(fallback.is_absolute() || fallback.starts_with("."));
|
|
||||||
assert!(fallback.ends_with("ai-app"));
|
|
||||||
|
|
||||||
unsafe { std::env::set_var("AI_APP_TEST_XDG", "/somewhere") };
|
assert_eq!(
|
||||||
assert_eq!(xdg_dir("AI_APP_TEST_XDG", ".config"), PathBuf::from("/somewhere/ai-app"));
|
xdg_dir(Some("/somewhere".into()), ".config"),
|
||||||
unsafe { std::env::remove_var("AI_APP_TEST_XDG") };
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 (`<hex>.<extension>`, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<File> {
|
||||||
|
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()))
|
||||||
|
}
|
||||||
+15
-17
@@ -69,8 +69,8 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum ApiError {
|
enum ApiError {
|
||||||
#[error("no session {0}")]
|
#[error("{0}")]
|
||||||
UnknownSession(String),
|
NotFound(String),
|
||||||
#[error("no such route")]
|
#[error("no such route")]
|
||||||
UnknownRoute,
|
UnknownRoute,
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -82,7 +82,7 @@ enum ApiError {
|
|||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = match self {
|
let status = match self {
|
||||||
Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
|
Self::NotFound(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
|
||||||
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
Self::Internal(err) => {
|
Self::Internal(err) => {
|
||||||
// The only variant whose real cause isn't safe to hand
|
// 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<Arc<LiveSession>, ApiError> {
|
fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, 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<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
||||||
@@ -207,8 +207,8 @@ async fn delete_session(
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct MessageRequest {
|
struct MessageRequest {
|
||||||
text: String,
|
text: String,
|
||||||
/// Ids from `POST /attachments` (phase 2); accepted now so the request
|
/// Ids from `POST /attachments`, uploaded before the message that
|
||||||
/// shape doesn't change under the app.
|
/// references them.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
attachment_ids: Vec<String>,
|
attachment_ids: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -326,18 +326,16 @@ async fn serve_file(
|
|||||||
let candidates =
|
let candidates =
|
||||||
[session.dir().join("files").join(&name), session.dir().join("attachments").join(&name)];
|
[session.dir().join("files").join(&name), session.dir().join("attachments").join(&name)];
|
||||||
let Some(path) = candidates.iter().find(|path| path.is_file()) else {
|
let Some(path) = candidates.iter().find(|path| path.is_file()) else {
|
||||||
return Err(ApiError::BadRequest(format!("no file {name} in session {id}")));
|
return Err(ApiError::NotFound(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",
|
|
||||||
};
|
};
|
||||||
|
// 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())
|
Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,21 +339,14 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
|
|||||||
"type": "image",
|
"type": "image",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "base64",
|
"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),
|
"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.
|
/// What answering a question produced.
|
||||||
enum AnswerOutcome {
|
enum AnswerOutcome {
|
||||||
/// Send this control_response line to the CLI.
|
/// Send this control_response line to the CLI.
|
||||||
@@ -563,9 +556,7 @@ impl Translator {
|
|||||||
"response": {"subtype": "success", "request_id": request_id, "response": response},
|
"response": {"subtype": "success", "request_id": request_id, "response": response},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Translator {
|
|
||||||
/// `user` messages: tool results become ToolEnd, with any image parts
|
/// `user` messages: tool results become ToolEnd, with any image parts
|
||||||
/// saved into the session dir and referenced by an Image event (the
|
/// saved into the session dir and referenced by an Image event (the
|
||||||
/// phone fetches them from `/sessions/{id}/files/{ref}`). Replayed and
|
/// phone fetches them from `/sessions/{id}/files/{ref}`). Replayed and
|
||||||
@@ -620,16 +611,17 @@ impl Translator {
|
|||||||
let data = source.get("data")?.as_str()?;
|
let data = source.get("data")?.as_str()?;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data).ok()?;
|
let bytes = base64::engine::general_purpose::STANDARD.decode(data).ok()?;
|
||||||
let extension = match source.get("media_type").and_then(Value::as_str) {
|
// Screenshots are the overwhelming case, and they are PNG; an
|
||||||
Some("image/jpeg") => "jpg",
|
// unrecognized type is more likely a dialect change than a JPEG.
|
||||||
Some("image/gif") => "gif",
|
let extension = source
|
||||||
Some("image/webp") => "webp",
|
.get("media_type")
|
||||||
_ => "png",
|
.and_then(Value::as_str)
|
||||||
};
|
.and_then(crate::media::extension_for)
|
||||||
|
.unwrap_or("png");
|
||||||
let name = format!("{}.{extension}", super::random_hex());
|
let name = format!("{}.{extension}", super::random_hex());
|
||||||
let dir = self.session_dir.join("files");
|
let dir = self.session_dir.join("files");
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
crate::config::create_private_dir(&dir)
|
crate::private::create_dir(&dir)
|
||||||
.map_err(std::io::Error::other)
|
.map_err(std::io::Error::other)
|
||||||
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// Attachment id of an uploaded image, as returned by `POST /attachments`
|
/// The name a session's image is stored and served under -- returned by
|
||||||
/// (arrives in phase 2; the vocabulary is fixed now so the trait doesn't
|
/// `POST /attachments` for an upload, minted by a driver for one a tool
|
||||||
/// change under the first two drivers).
|
/// 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;
|
pub type ImageRef = String;
|
||||||
|
|
||||||
/// Everything a session can tell the outside world. Every event is
|
/// Everything a session can tell the outside world. Every event is
|
||||||
@@ -36,8 +37,8 @@ pub enum Event {
|
|||||||
},
|
},
|
||||||
ToolUpdate { id: String, output: String },
|
ToolUpdate { id: String, output: String },
|
||||||
ToolEnd { id: String, output: String },
|
ToolEnd { id: String, output: String },
|
||||||
/// An image the session produced, saved under the session dir and
|
/// An image the session produced or was sent, saved under the session
|
||||||
/// referenced by id; the phone fetches it by URL (phase 2).
|
/// dir and referenced by id; the phone fetches it by URL.
|
||||||
Image {
|
Image {
|
||||||
#[serde(rename = "ref")]
|
#[serde(rename = "ref")]
|
||||||
image: ImageRef,
|
image: ImageRef,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ impl Driver for EchoDriver {
|
|||||||
let sink = self.sink.clone();
|
let sink = self.sink.clone();
|
||||||
|
|
||||||
if let Some(rest) = text.strip_prefix("/question") {
|
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() {
|
let prompt = if rest.trim().is_empty() {
|
||||||
"Echo asks: proceed?".to_string()
|
"Echo asks: proceed?".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -71,7 +71,7 @@ impl Driver for EchoDriver {
|
|||||||
send(Event::Status { state: SessionStatus::Running });
|
send(Event::Status { state: SessionStatus::Running });
|
||||||
|
|
||||||
if let Some(input) = run_tool {
|
if let Some(input) = run_tool {
|
||||||
let id = format!("t-{}", rand_id());
|
let id = format!("t-{}", super::random_hex());
|
||||||
send(Event::ToolStart {
|
send(Event::ToolStart {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
tool: "echo-tool".to_string(),
|
tool: "echo-tool".to_string(),
|
||||||
@@ -132,12 +132,3 @@ impl Driver for EchoDriver {
|
|||||||
self.emit(Event::Status { state: SessionStatus::Exited });
|
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()
|
|
||||||
}
|
|
||||||
@@ -139,15 +139,12 @@ impl LiveSession {
|
|||||||
/// references it by. Removed with the session directory on delete --
|
/// references it by. Removed with the session directory on delete --
|
||||||
/// the same path out as everything else in it.
|
/// the same path out as everything else in it.
|
||||||
pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result<String> {
|
pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result<String> {
|
||||||
let extension = match content_type {
|
// An unrecognized type is almost always a phone photo whose
|
||||||
"image/png" => "png",
|
// content type the picker didn't set; jpg is the useful guess.
|
||||||
"image/gif" => "gif",
|
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
|
||||||
"image/webp" => "webp",
|
|
||||||
_ => "jpg",
|
|
||||||
};
|
|
||||||
let name = format!("{}.{extension}", random_hex());
|
let name = format!("{}.{extension}", random_hex());
|
||||||
let dir = self.dir().join("attachments");
|
let dir = self.dir().join("attachments");
|
||||||
crate::config::create_private_dir(&dir)?;
|
crate::private::create_dir(&dir)?;
|
||||||
std::fs::write(dir.join(&name), bytes)
|
std::fs::write(dir.join(&name), bytes)
|
||||||
.with_context(|| format!("write attachment {name}"))?;
|
.with_context(|| format!("write attachment {name}"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
@@ -189,7 +186,7 @@ impl SessionManager {
|
|||||||
/// session spawns its event pump).
|
/// session spawns its event pump).
|
||||||
pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result<Self> {
|
pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result<Self> {
|
||||||
let config = Config::load(&config_path)?;
|
let config = Config::load(&config_path)?;
|
||||||
crate::config::create_private_dir(&data_dir)?;
|
crate::private::create_dir(&data_dir)?;
|
||||||
|
|
||||||
let mut live = HashMap::new();
|
let mut live = HashMap::new();
|
||||||
for meta in &config.sessions {
|
for meta in &config.sessions {
|
||||||
@@ -451,7 +448,7 @@ fn launch(
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
) -> Result<Arc<LiveSession>> {
|
) -> Result<Arc<LiveSession>> {
|
||||||
let dir = data_dir.join(&meta.id);
|
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_path = dir.join("transcript.jsonl");
|
||||||
let transcript = Transcript::open(&transcript_path)?;
|
let transcript = Transcript::open(&transcript_path)?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
[2m2026-08-25T06:18:57.533304Z[0m [32m INFO[0m [2mai_server[0m[2m:[0m config: /home/bob/host/repos/ai-app/config.json
|
|
||||||
[2m2026-08-25T06:18:57.537682Z[0m [32m INFO[0m [2mai_server[0m[2m:[0m serving https://10.66.0.1:8443
|
|
||||||
[2m2026-08-25T06:20:43.470080Z[0m [32m INFO[0m [2mai_server[0m[2m:[0m config: /home/bob/host/repos/ai-app/config.json
|
|
||||||
[2m2026-08-25T06:20:43.474488Z[0m [32m INFO[0m [2mai_server[0m[2m:[0m serving https://10.66.0.1:8443
|
|
||||||
Reference in new issue
Block a user