From 967fc814abe81a1a59a0d9482a764c86538db5c1 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 24 Aug 2026 20:51:34 -0400 Subject: [PATCH] Phase 1 server: TLS + token auth, session registry, EchoDriver, SSE with cursors The whole pipe behind one Driver trait and a common event model: spawn/list/delete sessions, message + question answering, append-only JSONL transcripts whose sequence numbers are the phone's resume cursor (surviving backend restarts), bearer-token middleware wrapping every route including the fallback, wg0-only binding that fails closed, and first-run token enrollment via a terminal QR. Verified: cargo test (10), clippy clean, and curl end-to-end over pinned TLS -- auth rejection, spawn, streamed SSE replay/resume, /question round trip, restart continuing seq numbers, delete removing everything. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- .gitignore | 20 + gen-dev-cert.sh | 105 +++ run-tests.sh | 11 + server/Cargo.lock | 1375 ++++++++++++++++++++++++++++++ server/Cargo.toml | 37 + server/src/auth.rs | 217 +++++ server/src/config.rs | 140 +++ server/src/main.rs | 193 +++++ server/src/routes.rs | 302 +++++++ server/src/session/driver.rs | 93 ++ server/src/session/echo.rs | 143 ++++ server/src/session/mod.rs | 538 ++++++++++++ server/src/session/transcript.rs | 175 ++++ 13 files changed, 3349 insertions(+) create mode 100644 .gitignore create mode 100755 gen-dev-cert.sh create mode 100755 run-tests.sh create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/src/auth.rs create mode 100644 server/src/config.rs create mode 100644 server/src/main.rs create mode 100644 server/src/routes.rs create mode 100644 server/src/session/driver.rs create mode 100644 server/src/session/echo.rs create mode 100644 server/src/session/mod.rs create mode 100644 server/src/session/transcript.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b748bd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.gradle/ +build/ +app/androidApp/build/ +local.properties +.kotlin/ +*.iml +.idea/ +.DS_Store +server/target/ +server/ai-server.log + +# Private key material, regenerated by ./gen-dev-cert.sh. +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/gen-dev-cert.sh b/gen-dev-cert.sh new file mode 100755 index 0000000..de6f4af --- /dev/null +++ b/gen-dev-cert.sh @@ -0,0 +1,105 @@ +#!/bin/sh +# Generates the self-signed dev CA and leaf certificate `server/` serves its +# TLS listener with. Run once before the first `cargo run`; the server exits +# with a clear message if `certs/` is missing. +# +# Same scheme as ../local-updater's: nothing on a device trusts this +# automatically -- the app embeds the CA certificate verbatim and pins to it +# (`PinnedCert.kt`), rather than relying on the device's system trust store. +# This server's API *is* remote code execution (it spawns AI sessions on +# request), so a MITM on it would be as bad as it gets -- hence pinning. +# +# The CA is idempotent -- skipped if `certs/ca.pem` already exists, so +# re-running this doesn't invalidate the certificate the installed app has +# pinned against without a reason to. The leaf is cheap and reissued on +# every run (still signed by that same, unchanged CA), so adding another SAN +# entry only means rerunning this script, not touching anything pinned. +# +# The leaf's SANs must cover every address a device reaches this server at. +# In production that is exactly one: the backend's WireGuard address, which +# the phone uses from everywhere (see PLAN.md's off-network section). +# Override with SERVER_IP=... if your wg0 address differs. +# +# Outputs into `certs/` (gitignored -- private key material, and the whole +# thing is trivially regeneratable anyway): +# ca.pem the CA certificate (not its private key) -- what the +# app embeds and pins against. +# ca-key.pem the CA's private key -- only this script needs it, to +# sign the leaf below. Never shipped anywhere. +# leaf.pem the server's own certificate (CA-signed), presented on +# every TLS handshake. +# leaf-key.pem the leaf's private key -- what the server loads to +# terminate TLS. +# ca-sha256.txt the CA certificate's SPKI SHA-256 fingerprint, printed +# below too. Informational -- the app embeds the whole +# `ca.pem`, not this digest. +# leaf-sha256.txt the leaf's SPKI SHA-256 fingerprint. Informational -- +# nothing pins the leaf; the app pins the CA and +# validates the chain. + +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +CERTS_DIR="$SCRIPT_DIR/certs" +# The backend's WireGuard address -- the one address the phone ever dials in +# production (PLAN.md: single-path addressing, no home/away distinction). +SERVER_IP="${SERVER_IP:-10.66.0.1}" +# Also covered so development before the tunnel exists can complete a real +# handshake against the same pinned CA: +# 127.0.0.1 curl from the machine itself, and tests binding loopback +# 10.0.2.2 the Android emulator's alias for the host's loopback +# LAN_IP a real phone on the same LAN, pre-WireGuard +LOOPBACK_IP="127.0.0.1" +EMULATOR_HOST_IP="10.0.2.2" +LAN_IP="${LAN_IP:-192.168.1.168}" + +mkdir -p "$CERTS_DIR" +cd "$CERTS_DIR" + +if [ -f ca.pem ]; then + echo "==> ca.pem already exists, reusing existing CA." +else + echo "==> Generating CA key + self-signed CA certificate" + openssl ecparam -name prime256v1 -genkey -noout -out ca-key.pem + openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 \ + -subj "/O=ai-app dev/CN=ai-app dev CA" +fi + +echo "==> Generating leaf key + CSR for $SERVER_IP (+ dev addresses)" +openssl ecparam -name prime256v1 -genkey -noout -out leaf-key.pem +openssl req -new -key leaf-key.pem -out leaf.csr \ + -subj "/O=ai-app dev/CN=$SERVER_IP" + +echo "==> Signing leaf certificate with the dev CA" +cat > leaf.ext < Computing certificate fingerprints (SPKI SHA-256)" +CA_SHA256=$(openssl x509 -in ca.pem -pubkey -noout \ + | openssl pkey -pubin -outform der \ + | openssl dgst -sha256 -binary \ + | openssl base64) +echo "$CA_SHA256" > ca-sha256.txt +LEAF_SHA256=$(openssl x509 -in leaf.pem -pubkey -noout \ + | openssl pkey -pubin -outform der \ + | openssl dgst -sha256 -binary \ + | openssl base64) +echo "$LEAF_SHA256" > leaf-sha256.txt + +echo +echo "==> Done." +echo " CA fingerprint (base64): $CA_SHA256" +echo " Leaf fingerprint (base64): $LEAF_SHA256" +echo +echo " Only relevant if the CA was regenerated just now (i.e. certs/ca.pem" +echo " did not already exist): the app embeds PINNED_CA_PEM and needs the" +echo " new certs/ca.pem contents pasted in, or it silently stops being able" +echo " to reach this server. The app installs via Local Updater, so" +echo " recovery is a reinstall through that -- but it's still a one-way" +echo " door for the installed copy." +echo +echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt" diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..de20d4d --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Runs this repo's tests. Extra arguments are forwarded to `cargo test`, +# e.g. `./run-tests.sh transcript` to run just the transcript tests. +# +# Only `server/` has tests: it holds all the logic worth testing (event +# normalization, transcript cursors, config persistence, token auth), while +# the Android app is UI over its HTTP API. Verifying the app means running +# it -- see AGENTS.md. +set -eu +cd "$(dirname "$0")/server" +exec cargo test "$@" diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..1114445 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,1375 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "ai-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "base64", + "clap", + "if-addrs", + "qrcode", + "rand", + "serde", + "serde_json", + "sha2", + "subtle", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tower", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..f7f9052 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "ai-server" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "ai-server" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.8", features = ["json"] } +axum-server = { version = "0.8", features = ["tls-rustls"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] } +tokio-stream = "0.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } +anyhow = "1" +thiserror = "2" +# Token auth: hash for storage, constant-time compare for verification, +# CSPRNG-backed generation, base64url for the enrollment string. +sha2 = "0.11" +subtle = "2" +rand = "0.10" +base64 = "0.23" +# Renders the enrollment QR straight to the terminal; no image output needed. +qrcode = { version = "0.14", default-features = false } +# The wg0-bound listener needs the interface's address; the stdlib has no +# getifaddrs. This is the smallest crate that wraps just that. +if-addrs = "0.15" + +[dev-dependencies] +tempfile = "3" +# ServiceExt::oneshot, to drive the auth middleware without a socket. +tower = { version = "0.5", features = ["util"] } diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..260ff08 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,217 @@ +//! Bearer-token auth for the entire HTTP surface. +//! +//! This server's API *is* remote code execution, so the token gates every +//! route with zero unauthenticated endpoints -- the middleware is applied +//! once around the whole router (including the fallback) in `main.rs`, +//! never per-route, so a new route can't forget it. See PLAN.md's security +//! section for the threat model; the short version is that the token gates +//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it +//! defense in depth rather than the sole gate. +//! +//! Nothing in this module -- and nothing anywhere else -- may log the +//! Authorization header or the token; `token_is_never_logged` below holds a +//! tripwire against a logging change silently starting to. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::{ConnectInfo, Request, State}; +use axum::http::{StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use base64::Engine; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use crate::session::SessionManager; + +/// Applied to every rejection. Not against brute force -- infeasible at 256 +/// bits -- but so a scanner probing the port shows up as a slow, loggable +/// drip rather than a fast one. +const REJECT_DELAY: Duration = Duration::from_millis(300); + +/// 256 bits from the OS CSPRNG, base64url. A machine credential carried by +/// a QR code, never typed, so unguessable costs nothing. +pub fn generate_token() -> String { + use rand::Rng; + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// What `config.json` stores instead of the token: hex SHA-256. A plain +/// hash is enough for high-entropy random input, and buys that a leaked +/// config doesn't leak the credential. +pub fn token_hash_hex(token: &str) -> String { + Sha256::digest(token.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Hash-then-constant-time-compare against every enrolled hash. The fold +/// visits every entry regardless of match so the timing doesn't say which +/// entry (if any) matched. +fn token_matches(presented: &str, stored_hashes: &[String]) -> bool { + let presented = token_hash_hex(presented); + stored_hashes.iter().fold(false, |matched, stored| { + matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes())) + }) +} + +pub async fn require_token( + State(manager): State>, + request: Request, + next: Next, +) -> Response { + let presented = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + if let Some(token) = presented { + let hashes: Vec = + manager.tokens().into_iter().map(|entry| entry.sha256).collect(); + if token_matches(token, &hashes) { + return next.run(request).await; + } + } + + // Peer address only -- never the header value. Absent when there is no + // real socket (tests driving the router directly). + let peer = request + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.to_string()) + .unwrap_or_else(|| "unknown peer".to_string()); + tracing::warn!("rejected request from {peer}: missing or invalid bearer token"); + tokio::time::sleep(REJECT_DELAY).await; + (StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + use axum::Router; + use axum::body::Body; + use axum::routing::get; + use tower::ServiceExt; + + use crate::config::TokenEntry; + + fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc { + let manager = Arc::new( + SessionManager::new(dir.join("config.json"), dir.join("sessions")) + .expect("manager"), + ); + manager + .set_tokens(vec![TokenEntry { + name: "phone".to_string(), + sha256: token_hash_hex(token), + }]) + .expect("set token"); + manager + } + + fn guarded_router(manager: Arc) -> Router { + Router::new() + .route("/probe", get(|| async { "ok" })) + .fallback(|| async { StatusCode::NOT_FOUND }) + .layer(axum::middleware::from_fn_with_state(manager, require_token)) + } + + fn request(path: &str, auth: Option<&str>) -> Request { + let mut builder = axum::http::Request::builder().uri(path); + if let Some(auth) = auth { + builder = builder.header(header::AUTHORIZATION, auth); + } + builder.body(Body::empty()).expect("request") + } + + #[test] + fn hashing_is_stable_and_tokens_verify() { + let token = generate_token(); + assert_eq!(token_hash_hex(&token), token_hash_hex(&token)); + assert_ne!(token, generate_token(), "tokens must not repeat"); + + let hashes = vec![token_hash_hex(&token), token_hash_hex("other")]; + assert!(token_matches(&token, &hashes)); + assert!(token_matches("other", &hashes)); + assert!(!token_matches("wrong", &hashes)); + assert!(!token_matches(&token, &[])); + } + + /// One test rather than separate gating and logging tests, + /// deliberately: tracing caches callsite interest process-wide, so a + /// test that hits the rejection path with no subscriber installed can + /// poison the interest cache for the one that captures logs. Keeping + /// every exercise of the middleware under the capturing subscriber + /// makes the log assertions deterministic. + #[tokio::test] + async fn gates_every_route_and_never_logs_the_token() { + #[derive(Clone, Default)] + struct Capture(Arc>>); + impl std::io::Write for Capture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture { + type Writer = Capture; + fn make_writer(&'a self) -> Capture { + self.clone() + } + } + + let capture = Capture::default(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_writer(capture.clone()) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let dir = tempfile::tempdir().expect("tempdir"); + let token = generate_token(); + let router = guarded_router(manager_with_token(dir.path(), &token)); + + // No header, wrong token, wrong scheme: 401 everywhere, including + // paths that don't exist -- a scanner learns nothing. + for (path, auth) in [ + ("/probe", None), + ("/probe", Some("Bearer wrong".to_string())), + ("/probe", Some(format!("Basic {token}"))), + ("/no-such-route", None), + ] { + let response = router + .clone() + .oneshot(request(path, auth.as_deref())) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path} {auth:?}"); + } + + let ok = router + .clone() + .oneshot(request("/probe", Some(&format!("Bearer {token}")))) + .await + .expect("response"); + assert_eq!(ok.status(), StatusCode::OK); + + // The tripwire that keeps a future logging change (e.g. logging + // request headers) from silently leaking credentials. + let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned(); + assert!( + !logged.contains(&token), + "the bearer token leaked into the logs: {logged}" + ); + // The rejections themselves do get logged (that's the point). + assert!(logged.contains("missing or invalid bearer token")); + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..96e698f --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,140 @@ +//! The server's persistent state: the enrolled token hashes and the +//! sessions that exist. +//! +//! Written whole and atomically (temp file + rename) rather than appended +//! to: it is small, and a half-written config would take the server down on +//! next start with no obvious way to recover from a phone. Every mutation +//! funnels through `SessionManager` (the registry pattern), so in-memory +//! and on-disk state can't come apart. +//! +//! Transcripts do NOT live here -- each session's events are an append-only +//! JSONL file in its own directory (see `session::transcript`); this file +//! holds only the metadata needed to list and respawn sessions. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Config { + /// Enrolled device tokens, hashes only -- a leaked config doesn't leak + /// the credential. A list (of one, today) so per-device tokens with + /// individual revocation are a config entry later, not a migration. + pub tokens: Vec, + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenEntry { + /// Which device this token belongs to, for the human rotating it. + pub name: String, + /// Hex SHA-256 of the token. A plain hash is enough: the token is 256 + /// bits from the OS CSPRNG, so there is nothing to dictionary-attack + /// and no stretching needed. + pub sha256: String, +} + +/// Which driver a session runs. Phase 2 adds `Claude`, phase 4 adds `Pi`; +/// a new kind is a new driver behind the same trait, never a branch in +/// shared code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SessionKind { + /// The phase-1 fake: echoes messages back as streamed events. Proves + /// the whole pipe (spawn, SSE, transcript cursors, questions) with no + /// AI involved, and stays useful as a connectivity check. + Echo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfig { + /// Stable identifier; names the session's directory and its routes. + pub id: String, + pub kind: SessionKind, + pub title: String, + /// Config name of the SSH host to run on; absent means local. Host + /// configs arrive in phase 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// 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. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + /// Epoch seconds when the session was spawned. + pub created: f64, +} + +impl Config { + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str(&text) + .with_context(|| format!("{} is not valid config JSON", path.display())), + // A first run has no config -- the normal starting state; a + // token is generated and saved on that first start. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(err) => Err(err).with_context(|| format!("read {}", path.display())), + } + } + + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(self).context("serialize config")?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_the_config_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.json"); + + // A missing file is the ordinary first-run state, not an error. + let first_run = Config::load(&path).expect("load"); + assert!(first_run.tokens.is_empty()); + assert!(first_run.sessions.is_empty()); + + let config = Config { + tokens: vec![TokenEntry { + name: "phone".to_string(), + sha256: "ab".repeat(32), + }], + sessions: vec![SessionConfig { + id: "abc123".to_string(), + kind: SessionKind::Echo, + title: "test".to_string(), + host: None, + model: None, + cwd: None, + permission_mode: None, + created: 1234.5, + }], + }; + config.save(&path).expect("save"); + + let loaded = Config::load(&path).expect("reload"); + assert_eq!(loaded.tokens[0].name, "phone"); + assert_eq!(loaded.sessions[0].id, "abc123"); + assert_eq!(loaded.sessions[0].kind, SessionKind::Echo); + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..54f2d16 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,193 @@ +//! A phone interface to AI coding sessions -- the backend. See PLAN.md for +//! the whole picture; this is the entry point: config + session registry, +//! token bootstrap, and the one TLS listener. +//! +//! The listener binds the WireGuard interface's address only, and fails +//! closed -- if `wg0` is down the server refuses to start rather than +//! falling back to `0.0.0.0`, because this API *is* remote code execution +//! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP +//! parsing, auth middleware) off the open internet. `--bind` overrides +//! explicitly for development; that is a deliberate, logged choice, never a +//! fallback. +//! +//! There is no plaintext listener at all, so the bearer token can't travel +//! unencrypted by misconfiguration -- even inside the tunnel. + +mod auth; +mod config; +mod routes; +mod session; + +use std::net::{IpAddr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use clap::Parser; + +use config::TokenEntry; +use session::SessionManager; + +const DEFAULT_PORT: u16 = 8443; +const WG_INTERFACE: &str = "wg0"; + +/// The repo root, one level above this crate. Everything the server reads +/// by default -- the TLS cert, the config, the session data -- resolves +/// from here, so there's one definition of it rather than one per caller. +fn repo_root() -> &'static Path { + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + ROOT.get_or_init(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("CARGO_MANIFEST_DIR has a repo-root parent") + .to_path_buf() + }) +} + +/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app. +#[derive(Parser)] +struct Args { + /// TLS port for the whole API surface. + #[arg(long, default_value_t = DEFAULT_PORT)] + port: u16, + + /// Address to bind instead of the wg0 interface's -- a development + /// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone + /// before the tunnel exists). Production runs without it and fails + /// closed when wg0 is absent. + #[arg(long)] + bind: Option, + + /// Where the token hashes and session list live. Defaults to + /// `config.json` beside this repo's `certs/`. + #[arg(long)] + config: Option, + + /// Directory for per-session data (transcripts, attachments, images). + /// Defaults to `sessions/` in the repo root. + #[arg(long)] + data_dir: Option, + + /// Directory holding `leaf.pem`/`leaf-key.pem`. Defaults to this + /// repo's `certs/`, as produced by `gen-dev-cert.sh`. + #[arg(long)] + certs: Option, + + /// Invalidate every enrolled token, generate a fresh one, and print + /// its enrollment QR -- the whole lost-phone story. + #[arg(long)] + rotate_token: bool, +} + +/// The IPv4 address on the WireGuard interface, or a refusal to start. +/// Failing closed here (rather than falling back to a wider bind) is part +/// of the security posture -- see the module doc comment. +fn wg_address() -> Result { + let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?; + interfaces + .into_iter() + .find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4()) + .map(|iface| iface.ip()) + .ok_or_else(|| { + anyhow::anyhow!( + "no IPv4 address on interface {WG_INTERFACE} -- this server binds only to the \ + WireGuard tunnel and refuses to fall back to a wider address. Bring the tunnel \ + up, or pass --bind explicitly for development." + ) + }) +} + +/// Prints the one-time enrollment QR: an `aiapp://enroll` URI carrying +/// where to connect and the bearer token. The CA stays embedded in the APK, +/// so this carries no trust material -- photographing the terminal leaks +/// only the token, which is rotatable (`--rotate-token`). Printed to +/// stdout, not the log: it is for the human at the terminal, once. +fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> { + let uri = format!("aiapp://enroll?host={host}&port={port}&token={token}"); + let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?; + let rendered = code + .render::() + .quiet_zone(true) + .build(); + println!("\n{rendered}\n"); + println!("Scan with the phone's camera to enroll (or paste into the app's settings):"); + println!(" {uri}"); + println!("The token is not stored in the clear and won't be shown again;"); + println!("a lost phone means `--rotate-token`.\n"); + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + let args = Args::parse(); + + let config_path = args.config.unwrap_or_else(|| repo_root().join("config.json")); + let data_dir = args.data_dir.unwrap_or_else(|| repo_root().join("sessions")); + let manager = Arc::new( + SessionManager::new(config_path.clone(), data_dir) + .with_context(|| format!("failed to load {}", config_path.display()))?, + ); + tracing::info!("config: {}", config_path.display()); + for info in manager.sessions() { + tracing::info!(" session {} ({:?}, {:?})", info.id, info.kind, info.status); + } + + let bind_ip = match args.bind { + Some(ip) => { + tracing::warn!( + "binding {ip} by explicit --bind override -- production binds {WG_INTERFACE} only" + ); + ip + } + None => wg_address()?, + }; + + // Token bootstrap: first run generates one; --rotate-token replaces + // whatever exists. Either way the plaintext appears exactly once, in + // the QR printed here. + if args.rotate_token || manager.tokens().is_empty() { + let rotating = args.rotate_token && !manager.tokens().is_empty(); + let token = auth::generate_token(); + manager.set_tokens(vec![TokenEntry { + name: "phone".to_string(), + sha256: auth::token_hash_hex(&token), + }])?; + if rotating { + tracing::info!("rotated the enrolled token; the previous one is now invalid"); + } + print_enrollment(bind_ip, args.port, &token)?; + } + + let certs_dir = args.certs.unwrap_or_else(|| repo_root().join("certs")); + let leaf_cert = certs_dir.join("leaf.pem"); + let leaf_key = certs_dir.join("leaf-key.pem"); + if !leaf_cert.is_file() || !leaf_key.is_file() { + bail!( + "missing {} / {} -- run ./gen-dev-cert.sh first (the app pins the CA it generates, \ + and this server refuses to serve without TLS)", + leaf_cert.display(), + leaf_key.display(), + ); + } + let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&leaf_cert, &leaf_key) + .await + .context("failed to load TLS cert/key")?; + + // The bearer-token middleware wraps the entire router -- routes and + // fallback alike -- here and only here, so a new route can't forget + // auth. Zero unauthenticated endpoints. + let app = routes::router(Arc::clone(&manager)).layer(axum::middleware::from_fn_with_state( + Arc::clone(&manager), + auth::require_token, + )); + + let addr = SocketAddr::new(bind_ip, args.port); + tracing::info!("serving https://{addr}"); + axum_server::bind_rustls(addr, tls_config) + .serve(app.into_make_service_with_connect_info::()) + .await + .context("TLS listener failed")?; + + Ok(()) +} diff --git a/server/src/routes.rs b/server/src/routes.rs new file mode 100644 index 0000000..420ff21 --- /dev/null +++ b/server/src/routes.rs @@ -0,0 +1,302 @@ +//! The HTTP surface -- REST for actions, one SSE stream per open session +//! screen for events, all behind the bearer-token middleware `main.rs` +//! wraps the whole router in. +//! +//! ```text +//! GET /sessions list (id, kind, title, host, model, status, last activity) +//! POST /sessions spawn {kind, title?, host?, model?, cwd?, permissionMode?} +//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live +//! POST /sessions/{id}/message {text, attachmentIds?} +//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions) +//! POST /sessions/{id}/interrupt +//! POST /sessions/{id}/model {model} +//! POST /sessions/{id}/compact +//! DELETE /sessions/{id} kill process, delete transcript + files +//! ``` +//! +//! Later phases add: `POST /attachments`, `GET /files/{session}/{id}`, +//! `GET /usage`, `GET|PUT /hosts` and `/models` -- see PLAN.md's table. +//! +//! Everything here works purely in the common event model; nothing may +//! branch on the session kind (that's what drivers are for). + +use std::convert::Infallible; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::Router; +use axum::extract::{Path as UrlPath, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use serde::Deserialize; +use tokio::sync::{broadcast, mpsc}; +use tokio_stream::StreamExt; +use tokio_stream::wrappers::ReceiverStream; + +use crate::session::transcript::{SeqEvent, read_after}; +use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; + +pub fn router(manager: Arc) -> Router { + Router::new() + .route("/sessions", get(list_sessions).post(spawn_session)) + .route("/sessions/{id}", delete(delete_session)) + .route("/sessions/{id}/events", get(events)) + .route("/sessions/{id}/message", post(message)) + .route("/sessions/{id}/answer", post(answer)) + .route("/sessions/{id}/interrupt", post(interrupt)) + .route("/sessions/{id}/model", post(set_model)) + .route("/sessions/{id}/compact", post(compact)) + // An explicit fallback so the auth middleware (layered around the + // whole router in main.rs) also covers unknown paths -- a scanner + // gets the same 401 everywhere, never a route map. + .fallback(|| async { ApiError::UnknownRoute }) + .with_state(manager) +} + +#[derive(Debug, thiserror::Error)] +enum ApiError { + #[error("no session {0}")] + UnknownSession(String), + #[error("no such route")] + UnknownRoute, + #[error("{0}")] + BadRequest(String), +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = match self { + Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND, + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + }; + (status, self.to_string()).into_response() + } +} + +/// An `anyhow` error from a session mutation is a message written *for* +/// the phone ("no session abc123") -- not an internal fault, so it comes +/// back as a 400 with that message rather than a 500 and a log line. +fn bad_request(err: anyhow::Error) -> ApiError { + ApiError::BadRequest(format!("{err:#}")) +} + +fn lookup(manager: &SessionManager, id: &str) -> Result, ApiError> { + manager.session(id).ok_or_else(|| ApiError::UnknownSession(id.to_string())) +} + +async fn list_sessions(State(manager): State>) -> axum::Json> { + axum::Json(manager.sessions()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpawnRequest { + kind: crate::config::SessionKind, + #[serde(default)] + title: Option, + #[serde(default)] + host: Option, + #[serde(default)] + model: Option, + #[serde(default)] + cwd: Option, + #[serde(default)] + permission_mode: Option, +} + +async fn spawn_session( + State(manager): State>, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let info = manager + .spawn_session(SpawnSpec { + kind: body.kind, + title: body.title, + host: body.host, + model: body.model, + cwd: body.cwd, + permission_mode: body.permission_mode, + }) + .map_err(bad_request)?; + tracing::info!("spawned {:?} session {} ({})", info.kind, info.id, info.title); + Ok(axum::Json(info)) +} + +async fn delete_session( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + manager.delete_session(&id).map_err(bad_request)?; + tracing::info!("deleted session {id}"); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[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. + #[serde(default)] + attachment_ids: Vec, +} + +async fn message( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + let session = lookup(&manager, &id)?; + if body.text.trim().is_empty() && body.attachment_ids.is_empty() { + return Err(ApiError::BadRequest("message is empty".to_string())); + } + session.send_message(body.text, body.attachment_ids); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AnswerRequest { + question_id: String, + answer: String, +} + +async fn answer( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + lookup(&manager, &id)?.answer_question(&body.question_id, &body.answer); + Ok(StatusCode::NO_CONTENT) +} + +async fn interrupt( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + lookup(&manager, &id)?.interrupt(); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +struct ModelRequest { + model: String, +} + +/// What happens is the driver's call -- a driver that can't switch in +/// place reports how it handled it (or that it can't) as events. +async fn set_model( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + lookup(&manager, &id)?.set_model(&body.model); + Ok(StatusCode::NO_CONTENT) +} + +async fn compact( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + lookup(&manager, &id)?.compact(); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +struct EventsQuery { + #[serde(default)] + after: u64, +} + +/// The session screen's one data source: replay everything after the +/// cursor from the transcript, then live events as they happen. An SSE +/// auto-reconnect sends the last event id it saw as `Last-Event-ID`, which +/// takes precedence over `after` -- same cursor, native mechanism. +async fn events( + State(manager): State>, + UrlPath(id): UrlPath, + Query(query): Query, + headers: HeaderMap, +) -> Result>>, ApiError> { + let session = lookup(&manager, &id)?; + let cursor = headers + .get("last-event-id") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .unwrap_or(query.after); + + // Subscribe before reading the file so nothing can land in the gap + // between replay and live; overlap is deduplicated by seq. + let live = session.subscribe(); + let (tx, stream) = mpsc::channel(64); + tokio::spawn(stream_session( + session.transcript_path().to_path_buf(), + cursor, + live, + tx, + )); + Ok(Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())) +} + +/// Feeds one SSE subscriber: transcript replay after the cursor, then live +/// events, catching back up from the file whenever the broadcast channel +/// laps us. Ends when the client disconnects (send fails) or the session +/// is deleted (channel closed). +async fn stream_session( + transcript: PathBuf, + mut last: u64, + mut live: broadcast::Receiver, + tx: mpsc::Sender, +) { + // Synchronous file reads from an async task: transcript lines are + // small and local; revisit if daily use produces transcripts where + // this shows (phase 6 territory). + let catch_up = |after: u64| match read_after(&transcript, after) { + Ok(entries) => Some(entries), + Err(err) => { + tracing::error!("transcript replay failed: {err:#}"); + None + } + }; + + let Some(replay) = catch_up(last) else { return }; + for entry in replay { + last = entry.seq; + if send_event(&tx, &entry).await.is_err() { + return; + } + } + loop { + match live.recv().await { + Ok(entry) => { + if entry.seq <= last { + continue; + } + last = entry.seq; + if send_event(&tx, &entry).await.is_err() { + return; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + let Some(missed) = catch_up(last) else { return }; + for entry in missed { + last = entry.seq; + if send_event(&tx, &entry).await.is_err() { + return; + } + } + } + Err(broadcast::error::RecvError::Closed) => return, + } + } +} + +async fn send_event( + tx: &mpsc::Sender, + entry: &SeqEvent, +) -> Result<(), mpsc::error::SendError> { + let data = serde_json::to_string(entry).expect("events always serialize"); + tx.send(SseEvent::default().id(entry.seq.to_string()).data(data)).await +} diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs new file mode 100644 index 0000000..38e83f5 --- /dev/null +++ b/server/src/session/driver.rs @@ -0,0 +1,93 @@ +//! The common event model and the `Driver` trait -- the one abstraction +//! everything hangs off (see PLAN.md). +//! +//! A driver translates its child process's JSONL dialect into [`Event`]s +//! and accepts the small inbound vocabulary below. The transcript, the SSE +//! stream, and the phone UI work purely in this model; nothing downstream +//! of a driver may branch on the session kind. + +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). +pub type ImageRef = String; + +/// Everything a session can tell the outside world. Every event is +/// appended to the session's transcript with a sequence number, then fanned +/// out to SSE subscribers; the phone renders purely from this stream, so +/// reconnecting is just "events after seq N" -- no separate history path +/// to drift from the live one. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Event { + /// What the user sent, echoed into the transcript by the manager (not + /// by drivers) so every device renders the full conversation from the + /// one stream. + UserMessage { text: String }, + /// Streaming assistant text; the phone renders the concatenation as + /// markdown. + AssistantText { delta: String }, + ToolStart { + id: String, + tool: String, + input: serde_json::Value, + }, + 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). + Image { + #[serde(rename = "ref")] + image: ImageRef, + }, + /// Anything the session needs a human for: AskUserQuestion, and + /// permission requests, are the same shape with different options. + Question { + id: String, + prompt: String, + options: Vec, + }, + /// The manager's record of a question being answered, so a rendered + /// question card resolves on every device, not just the one that + /// answered it. + Answered { id: String, answer: String }, + Status { state: SessionStatus }, + /// Per-turn token counts, where the dialect reports them. + UsageDelta { tokens: u64 }, + Error { message: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SessionStatus { + Idle, + Running, + AwaitingInput, + Compacting, + Exited, +} + +/// Where a driver reports events. Unbounded because producers are child +/// processes a slow phone must never be able to stall; the transcript file +/// is the backpressure-free buffer of record. +pub type EventSink = mpsc::UnboundedSender; + +/// The inbound half of a session. Deliberately small; see PLAN.md for the +/// per-driver mapping of each method onto its dialect. +/// +/// `send_user_message` during a run is the point of the whole app: both +/// real dialects queue it for injection at the next tool boundary rather +/// than the end of the turn. +pub trait Driver: Send + Sync { + fn send_user_message(&self, text: String, images: Vec); + fn answer_question(&self, id: &str, answer: &str); + /// Stop mid-run; the session survives. + fn interrupt(&self); + fn set_model(&self, model: &str); + /// pi: native compaction; claude: `/compact`. + fn compact(&self); + /// Graceful process exit. + fn shutdown(&self); +} diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs new file mode 100644 index 0000000..342b431 --- /dev/null +++ b/server/src/session/echo.rs @@ -0,0 +1,143 @@ +//! The phase-1 fake driver: no child process, just events. It exists to +//! prove the whole pipe -- spawn, transcript, SSE cursors, questions, +//! interrupts -- before any AI is involved, and stays useful afterwards as +//! a connectivity check that costs no tokens. +//! +//! Behavior: every message is echoed back as a few streamed text deltas. A +//! message starting with `/tool` also emits a fake tool run, and one +//! starting with `/question` asks one (exercising the answer path). This is +//! exactly the event vocabulary the real drivers produce, so a UI that +//! renders echo sessions correctly renders the real thing. + +use std::sync::Mutex; +use std::time::Duration; + +use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus}; + +/// Delay between streamed deltas -- long enough that streaming is visibly +/// streaming in the UI, short enough that tests waiting on a full turn +/// stay fast. +const DELTA_DELAY: Duration = Duration::from_millis(50); + +pub struct EchoDriver { + sink: EventSink, + /// Id of the question currently awaiting an answer, if any. One at a + /// time is all the echo behavior ever produces. + pending_question: Mutex>, +} + +impl EchoDriver { + pub fn new(sink: EventSink) -> Self { + let driver = Self { sink, pending_question: Mutex::new(None) }; + driver.emit(Event::Status { state: SessionStatus::Idle }); + driver + } + + /// Sends are infallible from the driver's point of view: a closed sink + /// means the session is being torn down, and there is nobody left to + /// report to. + fn emit(&self, event: Event) { + let _ = self.sink.send(event); + } +} + +impl Driver for EchoDriver { + fn send_user_message(&self, text: String, _images: Vec) { + let sink = self.sink.clone(); + + if let Some(rest) = text.strip_prefix("/question") { + let id = format!("q-{}", rand_id()); + let prompt = if rest.trim().is_empty() { + "Echo asks: proceed?".to_string() + } else { + format!("Echo asks: {}", rest.trim()) + }; + *self.pending_question.lock().unwrap() = Some(id.clone()); + self.emit(Event::Status { state: SessionStatus::Running }); + self.emit(Event::Question { + id, + prompt, + options: vec!["Yes".to_string(), "No".to_string()], + }); + self.emit(Event::Status { state: SessionStatus::AwaitingInput }); + return; + } + + let run_tool = text.strip_prefix("/tool").map(|rest| rest.trim().to_string()); + tokio::spawn(async move { + let send = |event: Event| { + let _ = sink.send(event); + }; + send(Event::Status { state: SessionStatus::Running }); + + if let Some(input) = run_tool { + let id = format!("t-{}", rand_id()); + send(Event::ToolStart { + id: id.clone(), + tool: "echo-tool".to_string(), + input: serde_json::json!({ "input": input }), + }); + tokio::time::sleep(DELTA_DELAY).await; + send(Event::ToolUpdate { id: id.clone(), output: "working...".to_string() }); + tokio::time::sleep(DELTA_DELAY).await; + send(Event::ToolEnd { id, output: format!("echoed: {input}") }); + } + + // Word-at-a-time so streaming is visibly streaming. + for word in format!("You said: {text}").split_inclusive(' ') { + send(Event::AssistantText { delta: word.to_string() }); + tokio::time::sleep(DELTA_DELAY).await; + } + send(Event::UsageDelta { tokens: text.split_whitespace().count() as u64 }); + send(Event::Status { state: SessionStatus::Idle }); + }); + } + + fn answer_question(&self, id: &str, answer: &str) { + let mut pending = self.pending_question.lock().unwrap(); + match pending.as_deref() { + Some(expected) if expected == id => { + *pending = None; + self.emit(Event::AssistantText { + delta: format!("You answered: {answer}"), + }); + self.emit(Event::Status { state: SessionStatus::Idle }); + } + _ => self.emit(Event::Error { + message: format!("no question {id} is awaiting an answer"), + }), + } + } + + fn interrupt(&self) { + // Nothing real to stop; a pending question is abandoned so the + // session isn't stuck awaiting input forever. + *self.pending_question.lock().unwrap() = None; + self.emit(Event::Status { state: SessionStatus::Idle }); + } + + fn set_model(&self, model: &str) { + self.emit(Event::Error { + message: format!("echo sessions have no model to change to {model}"), + }); + } + + fn compact(&self) { + self.emit(Event::Error { + message: "echo sessions have nothing to compact".to_string(), + }); + } + + fn shutdown(&self) { + 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 new file mode 100644 index 0000000..436864e --- /dev/null +++ b/server/src/session/mod.rs @@ -0,0 +1,538 @@ +//! The live session registry. Every session mutation -- spawn, delete, +//! token changes -- funnels through [`SessionManager`] under one lock, so +//! in-memory state and `config.json` can't come apart (the same pattern as +//! local-updater's `registry.rs`). +//! +//! A live session is a driver plus one event pump: the driver reports +//! [`Event`]s into an mpsc channel; the pump assigns each a sequence +//! number, appends it to the session's transcript file, and fans it out to +//! SSE subscribers. The transcript is the source of truth -- subscribers +//! that fall behind or reconnect catch up from the file by cursor. + +pub mod driver; +pub mod echo; +pub mod transcript; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use serde::Serialize; +use tokio::sync::{broadcast, mpsc}; + +use crate::config::{Config, SessionConfig, SessionKind, TokenEntry}; +use driver::{Driver, Event, ImageRef, SessionStatus}; +use echo::EchoDriver; +use transcript::{SeqEvent, Transcript}; + +/// Fan-out buffer per session. A subscriber that falls further behind than +/// this is caught up from the transcript file instead (see `routes`), so +/// the size only bounds memory, not correctness. +const EVENT_BUFFER: usize = 256; + +pub fn now() -> f64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64() +} + +/// What the phone needs to spawn a session -- the spawn screen's fields. +pub struct SpawnSpec { + pub kind: SessionKind, + pub title: Option, + pub host: Option, + pub model: Option, + pub cwd: Option, + pub permission_mode: Option, +} + +/// One row of `GET /sessions`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub id: String, + pub kind: SessionKind, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + pub status: SessionStatus, + pub last_activity: f64, + pub created: f64, +} + +/// A running session: its driver plus the shared state the event pump +/// keeps current. Cheap to clone-by-`Arc` into request handlers. +pub struct LiveSession { + meta: SessionConfig, + driver: Box, + /// The same channel the driver reports into; the manager injects + /// `UserMessage`/`Answered` here so they take a sequence number in + /// order with everything else. + sink: mpsc::UnboundedSender, + events: broadcast::Sender, + transcript_path: PathBuf, + shared: Arc, +} + +/// The pump-maintained view of a session, read by the list endpoint. +struct Shared { + status: Mutex, + last_activity: Mutex, +} + +impl LiveSession { + /// Records the user's message in the transcript, then hands it to the + /// driver -- which queues it for injection mid-run rather than at the + /// end of the turn (the point of the whole app). + pub fn send_message(&self, text: String, images: Vec) { + let _ = self.sink.send(Event::UserMessage { text: text.clone() }); + self.driver.send_user_message(text, images); + } + + pub fn answer_question(&self, question_id: &str, answer: &str) { + let _ = self.sink.send(Event::Answered { + id: question_id.to_string(), + answer: answer.to_string(), + }); + self.driver.answer_question(question_id, answer); + } + + pub fn interrupt(&self) { + self.driver.interrupt(); + } + + /// Hands the change to the driver. The persisted `model` field follows + /// when a driver that actually honors this lands (phase 2) -- echo + /// sessions just report the request as an error event. + pub fn set_model(&self, model: &str) { + self.driver.set_model(model); + } + + pub fn compact(&self) { + self.driver.compact(); + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + pub fn transcript_path(&self) -> &Path { + &self.transcript_path + } + + fn info(&self) -> SessionInfo { + SessionInfo { + id: self.meta.id.clone(), + kind: self.meta.kind, + title: self.meta.title.clone(), + host: self.meta.host.clone(), + model: self.meta.model.clone(), + cwd: self.meta.cwd.clone(), + status: *self.shared.status.lock().unwrap(), + last_activity: *self.shared.last_activity.lock().unwrap(), + created: self.meta.created, + } + } +} + +struct Inner { + config: Config, + live: HashMap>, +} + +pub struct SessionManager { + config_path: PathBuf, + /// Per-session directories (transcript, attachments, produced images) + /// live under here, each named by session id. + data_dir: PathBuf, + inner: RwLock, +} + +impl SessionManager { + /// Loads the config and relaunches a driver for every persisted + /// session -- for the real drivers that is the `--resume`/session-file + /// crash-recovery story; the echo driver just starts fresh over the + /// same transcript. Must be called inside a tokio runtime (each + /// session spawns its event pump). + pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result { + let config = Config::load(&config_path)?; + std::fs::create_dir_all(&data_dir) + .with_context(|| format!("create {}", data_dir.display()))?; + + let mut live = HashMap::new(); + for meta in &config.sessions { + // One unlaunchable session (e.g. a corrupt transcript) shows as + // exited rather than taking the whole server down with it; it + // can still be deleted from the phone. + match launch(meta.clone(), &data_dir) { + Ok(session) => { + live.insert(meta.id.clone(), session); + } + Err(err) => { + tracing::error!("couldn't relaunch session {}: {err:#}", meta.id); + } + } + } + Ok(Self { + config_path, + data_dir, + inner: RwLock::new(Inner { config, live }), + }) + } + + pub fn tokens(&self) -> Vec { + self.inner.read().unwrap().config.tokens.clone() + } + + /// Replaces the enrolled token list. With one device this is rotation: + /// the old hash is invalidated the moment the new config is saved. + pub fn set_tokens(&self, tokens: Vec) -> Result<()> { + let mut inner = self.inner.write().unwrap(); + let mut candidate = inner.config.clone(); + candidate.tokens = tokens; + candidate.save(&self.config_path)?; + inner.config = candidate; + Ok(()) + } + + /// Every session, in config order, with live status joined in. A + /// session that failed to relaunch reports as exited. + pub fn sessions(&self) -> Vec { + let inner = self.inner.read().unwrap(); + inner + .config + .sessions + .iter() + .map(|meta| match inner.live.get(&meta.id) { + Some(session) => session.info(), + None => SessionInfo { + id: meta.id.clone(), + kind: meta.kind, + title: meta.title.clone(), + host: meta.host.clone(), + model: meta.model.clone(), + cwd: meta.cwd.clone(), + status: SessionStatus::Exited, + last_activity: meta.created, + created: meta.created, + }, + }) + .collect() + } + + pub fn session(&self, id: &str) -> Option> { + self.inner.read().unwrap().live.get(id).cloned() + } + + pub fn spawn_session(&self, spec: SpawnSpec) -> Result { + let mut inner = self.inner.write().unwrap(); + let id = unique_id(&inner.config); + let title = spec + .title + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| default_title(spec.kind)); + let meta = SessionConfig { + id: id.clone(), + kind: spec.kind, + title, + host: spec.host, + model: spec.model, + cwd: spec.cwd, + permission_mode: spec.permission_mode, + created: now(), + }; + + let session = launch(meta.clone(), &self.data_dir)?; + let mut candidate = inner.config.clone(); + candidate.sessions.push(meta); + if let Err(err) = candidate.save(&self.config_path) { + // The path out of everything the launch created, taken in the + // same change: drop the session and its directory so a failed + // save leaves no orphan. + drop(session); + let _ = std::fs::remove_dir_all(self.data_dir.join(&id)); + return Err(err); + } + inner.config = candidate; + let info = session.info(); + inner.live.insert(id, session); + Ok(info) + } + + /// Kills the process, releases everything the spawn created, and + /// deletes the transcript and files -- the complete path out. + pub fn delete_session(&self, id: &str) -> Result<()> { + let mut inner = self.inner.write().unwrap(); + if !inner.config.sessions.iter().any(|meta| meta.id == id) { + bail!("no session {id}"); + } + let mut candidate = inner.config.clone(); + candidate.sessions.retain(|meta| meta.id != id); + candidate.save(&self.config_path)?; + inner.config = candidate; + if let Some(session) = inner.live.remove(id) { + session.driver.shutdown(); + } + let dir = self.data_dir.join(id); + if dir.exists() { + std::fs::remove_dir_all(&dir).with_context(|| format!("remove {}", dir.display()))?; + } + Ok(()) + } +} + +fn default_title(kind: SessionKind) -> String { + match kind { + SessionKind::Echo => "Echo session".to_string(), + } +} + +/// 8 random bytes, hex -- short enough for a URL, unique enough forever at +/// this scale. Still checked against the existing list out of caution. +fn unique_id(config: &Config) -> String { + use rand::Rng; + loop { + let mut bytes = [0u8; 8]; + rand::rng().fill_bytes(&mut bytes); + let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + if !config.sessions.iter().any(|meta| meta.id == id) { + return id; + } + } +} + +/// Creates the session directory, opens its transcript (continuing the +/// sequence numbering if one exists), starts the driver, and spawns the +/// event pump connecting them. +fn launch(meta: SessionConfig, data_dir: &Path) -> Result> { + let dir = data_dir.join(&meta.id); + std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + let transcript_path = dir.join("transcript.jsonl"); + let transcript = Transcript::open(&transcript_path)?; + + let (sink, source) = mpsc::unbounded_channel(); + let (events, _) = broadcast::channel(EVENT_BUFFER); + let shared = Arc::new(Shared { + status: Mutex::new(SessionStatus::Idle), + last_activity: Mutex::new(now()), + }); + + let driver: Box = match meta.kind { + SessionKind::Echo => Box::new(EchoDriver::new(sink.clone())), + }; + + tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone())); + + Ok(Arc::new(LiveSession { + meta, + driver, + sink, + events, + transcript_path, + shared, + })) +} + +/// The one writer of a session's transcript: assigns sequence numbers, +/// appends, updates the shared status/activity view, fans out. Ends when +/// every sender is dropped -- i.e. when the session is deleted and its +/// last in-flight task finishes. +/// +/// The appends are synchronous file writes from an async task, +/// deliberately: each is one small line on a local disk, and funneling +/// them through one task is what makes the sequence numbering safe. +async fn pump( + mut transcript: Transcript, + mut source: mpsc::UnboundedReceiver, + shared: Arc, + events: broadcast::Sender, +) { + while let Some(event) = source.recv().await { + let ts = now(); + match transcript.append(event, ts) { + Ok(entry) => { + if let Event::Status { state } = &entry.event { + *shared.status.lock().unwrap() = *state; + } + *shared.last_activity.lock().unwrap() = ts; + // No subscribers is fine; the transcript already has it. + let _ = events.send(entry); + } + Err(err) => tracing::error!("transcript append failed: {err:#}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn echo_spec() -> SpawnSpec { + SpawnSpec { + kind: SessionKind::Echo, + title: None, + host: None, + model: None, + cwd: None, + permission_mode: None, + } + } + + /// Reads events from `rx` until `stop` matches one (returning all seen + /// so far) or five seconds pass (panicking with what was seen). + async fn collect_until( + rx: &mut broadcast::Receiver, + mut stop: impl FnMut(&Event) -> bool, + ) -> Vec { + let mut seen = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let entry = tokio::time::timeout_at(deadline, rx.recv()) + .await + .unwrap_or_else(|_| panic!("timed out; events so far: {seen:?}")) + .expect("event stream closed"); + let done = stop(&entry.event); + seen.push(entry); + if done { + return seen; + } + } + } + + fn is_idle(event: &Event) -> bool { + matches!(event, Event::Status { state: SessionStatus::Idle }) + } + + /// Collects one full echo turn: everything up to the idle that follows + /// the turn's `UsageDelta`. Stopping at the first idle would be racy -- + /// the driver emits an idle at construction, and a subscriber attached + /// just before the pump processes it would stop there, mid-spawn. + async fn collect_turn(rx: &mut broadcast::Receiver) -> Vec { + let mut saw_usage = false; + collect_until(rx, |event| { + saw_usage |= matches!(event, Event::UsageDelta { .. }); + saw_usage && is_idle(event) + }) + .await + } + + #[tokio::test] + async fn spawn_message_and_delete_round_trip() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.json"); + let data_dir = dir.path().join("sessions"); + let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager"); + + let info = manager.spawn_session(echo_spec()).expect("spawn"); + assert_eq!(info.title, "Echo session"); + // Persisted: a fresh load of the config file knows the session. + let persisted = Config::load(&config_path).expect("reload config"); + assert_eq!(persisted.sessions.len(), 1); + assert_eq!(persisted.sessions[0].id, info.id); + + let session = manager.session(&info.id).expect("live session"); + let mut rx = session.subscribe(); + session.send_message("hello there".to_string(), Vec::new()); + let seen = collect_turn(&mut rx).await; + + // The user's message is in the stream, before the echoed reply. + let user_at = seen + .iter() + .position(|entry| { + matches!(&entry.event, Event::UserMessage { text } if text == "hello there") + }) + .expect("user message in the stream"); + let echoed: String = seen[user_at..] + .iter() + .filter_map(|entry| match &entry.event { + Event::AssistantText { delta } => Some(delta.as_str()), + _ => None, + }) + .collect(); + assert_eq!(echoed, "You said: hello there"); + + // The transcript replays the same events by cursor. + let replay = transcript::read_after(session.transcript_path(), 0).expect("replay"); + assert!(replay.len() >= seen.len()); + let cursor = seen[user_at].seq; + let after = transcript::read_after(session.transcript_path(), cursor).expect("replay"); + assert_eq!(after.first().map(|entry| entry.seq), Some(cursor + 1)); + + // Delete is the complete path out: config, registry, and files. + manager.delete_session(&info.id).expect("delete"); + assert!(manager.sessions().is_empty()); + assert!(manager.session(&info.id).is_none()); + assert!(!data_dir.join(&info.id).exists()); + assert!(Config::load(&config_path).expect("reload").sessions.is_empty()); + assert!(manager.delete_session(&info.id).is_err()); + } + + #[tokio::test] + async fn questions_round_trip_through_answer() { + let dir = tempfile::tempdir().expect("tempdir"); + let manager = SessionManager::new( + dir.path().join("config.json"), + dir.path().join("sessions"), + ) + .expect("manager"); + let info = manager.spawn_session(echo_spec()).expect("spawn"); + let session = manager.session(&info.id).expect("live session"); + + let mut rx = session.subscribe(); + session.send_message("/question deploy?".to_string(), Vec::new()); + let seen = collect_until(&mut rx, |event| { + matches!(event, Event::Status { state: SessionStatus::AwaitingInput }) + }) + .await; + let question_id = seen + .iter() + .find_map(|entry| match &entry.event { + Event::Question { id, .. } => Some(id.clone()), + _ => None, + }) + .expect("question event"); + + session.answer_question(&question_id, "Yes"); + let seen = collect_until(&mut rx, is_idle).await; + assert!(seen.iter().any(|entry| matches!( + &entry.event, + Event::Answered { id, answer } if *id == question_id && answer == "Yes" + ))); + } + + #[tokio::test] + async fn a_restart_relaunches_sessions_and_continues_the_numbering() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.json"); + let data_dir = dir.path().join("sessions"); + + let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager"); + let info = manager.spawn_session(echo_spec()).expect("spawn"); + let session = manager.session(&info.id).expect("live session"); + let mut rx = session.subscribe(); + session.send_message("first".to_string(), Vec::new()); + let seen = collect_turn(&mut rx).await; + let last_seq = seen.last().expect("events").seq; + drop(rx); + drop(session); + drop(manager); + + // A new manager over the same state: the session is back, and new + // events continue the sequence rather than restarting it -- which + // is what makes a phone's cursor survive a backend restart. + let manager = SessionManager::new(config_path, data_dir).expect("manager restart"); + let listed = manager.sessions(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, info.id); + let session = manager.session(&info.id).expect("relaunched session"); + let mut rx = session.subscribe(); + session.send_message("second".to_string(), Vec::new()); + let seen = collect_turn(&mut rx).await; + assert!(seen.first().expect("events").seq > last_seq); + } +} diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs new file mode 100644 index 0000000..0581b69 --- /dev/null +++ b/server/src/session/transcript.rs @@ -0,0 +1,175 @@ +//! Append-only JSONL event log, one per session, with monotonically +//! increasing sequence numbers -- the phone's resume cursor. +//! +//! One line per event: `{"seq":N,"ts":...,"type":...,...}`. The writer +//! assigns sequence numbers; readers replay everything after a cursor. +//! Reopening an existing file continues the numbering, which is what makes +//! a backend restart invisible to a phone holding a cursor. + +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::driver::Event; + +/// One transcript line: an [`Event`] plus its position and time. The event +/// is flattened so the wire shape stays one flat object. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SeqEvent { + pub seq: u64, + /// Epoch seconds. + pub ts: f64, + #[serde(flatten)] + pub event: Event, +} + +pub struct Transcript { + file: File, + next_seq: u64, +} + +impl Transcript { + /// Opens (or creates) the log at `path`, continuing the sequence from + /// the last line if one exists. + pub fn open(path: &Path) -> Result { + let last_seq = last_seq(path)?; + let file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open transcript {}", path.display()))?; + Ok(Self { file, next_seq: last_seq + 1 }) + } + + /// Appends `event`, assigning it the next sequence number. Flushed per + /// event: each line is tiny, and the transcript is the source of truth + /// a crash must not lose the tail of. + pub fn append(&mut self, event: Event, ts: f64) -> Result { + let entry = SeqEvent { seq: self.next_seq, ts, event }; + let mut line = serde_json::to_string(&entry).context("serialize event")?; + line.push('\n'); + self.file.write_all(line.as_bytes()).context("append to transcript")?; + self.next_seq += 1; + Ok(entry) + } +} + +/// Replays every event with `seq > after`, oldest first. A missing file is +/// an empty transcript, not an error -- the session just hasn't produced an +/// event yet. +pub fn read_after(path: &Path, after: u64) -> Result> { + let file = match File::open(path) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err).with_context(|| format!("read transcript {}", path.display())), + }; + let mut events = Vec::new(); + for line in BufReader::new(file).lines() { + let line = line.context("read transcript line")?; + if line.trim().is_empty() { + continue; + } + let entry: SeqEvent = serde_json::from_str(&line) + .with_context(|| format!("bad transcript line in {}", path.display()))?; + if entry.seq > after { + events.push(entry); + } + } + Ok(events) +} + +fn last_seq(path: &Path) -> Result { + Ok(read_after(path, 0)?.last().map(|entry| entry.seq).unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::driver::SessionStatus; + + fn text(delta: &str) -> Event { + Event::AssistantText { delta: delta.to_string() } + } + + #[test] + fn assigns_increasing_seqs_and_replays_after_a_cursor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("transcript.jsonl"); + + let mut transcript = Transcript::open(&path).expect("open"); + assert_eq!(transcript.append(text("a"), 1.0).expect("append").seq, 1); + assert_eq!(transcript.append(text("b"), 2.0).expect("append").seq, 2); + assert_eq!(transcript.append(text("c"), 3.0).expect("append").seq, 3); + + let replay = read_after(&path, 1).expect("read"); + assert_eq!(replay.len(), 2); + assert_eq!(replay[0].seq, 2); + assert_eq!(replay[0].event, text("b")); + assert_eq!(replay[1].seq, 3); + + // A cursor at or past the end replays nothing. + assert!(read_after(&path, 3).expect("read").is_empty()); + } + + #[test] + fn reopening_continues_the_numbering() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("transcript.jsonl"); + + let mut transcript = Transcript::open(&path).expect("open"); + transcript.append(text("a"), 1.0).expect("append"); + transcript.append(text("b"), 2.0).expect("append"); + drop(transcript); + + let mut reopened = Transcript::open(&path).expect("reopen"); + assert_eq!(reopened.append(text("c"), 3.0).expect("append").seq, 3); + } + + #[test] + fn a_missing_file_reads_as_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + assert!(read_after(&dir.path().join("nope.jsonl"), 0).expect("read").is_empty()); + } + + #[test] + fn round_trips_every_event_shape() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("transcript.jsonl"); + let events = vec![ + Event::UserMessage { text: "hi".into() }, + text("hello"), + Event::ToolStart { + id: "t1".into(), + tool: "bash".into(), + input: serde_json::json!({"command": "ls"}), + }, + Event::ToolUpdate { id: "t1".into(), output: "partial".into() }, + Event::ToolEnd { id: "t1".into(), output: "done".into() }, + Event::Image { image: "img1".into() }, + Event::Question { + id: "q1".into(), + prompt: "Allow?".into(), + options: vec!["Yes".into(), "No".into()], + }, + Event::Answered { id: "q1".into(), answer: "Yes".into() }, + Event::Status { state: SessionStatus::Idle }, + Event::UsageDelta { tokens: 42 }, + Event::Error { message: "boom".into() }, + ]; + + let mut transcript = Transcript::open(&path).expect("open"); + for event in &events { + transcript.append(event.clone(), 0.0).expect("append"); + } + + let replayed: Vec = read_after(&path, 0) + .expect("read") + .into_iter() + .map(|entry| entry.event) + .collect(); + assert_eq!(replayed, events); + } +}