Files
ai-app/server/src/main.rs
T
irisandClaude Opus 5 9f403cddab Render markdown, and stop wiping messages still waiting to be read
Two things, both about the transcript telling the truth about itself.

Markdown is rendered rather than shown as its source. The parsing is
mikepenz/multiplatform-markdown-renderer, not something written here:
markdown is somebody else's specification, and a hand-written subset of
one disagrees with it at the edges, which is where the bug reports come
from. `Markdown.kt` is only the mapping onto this app's palette, so code,
links and rules take the Catppuccin values the rest of the app uses
rather than the renderer's defaults.

The queued-message list was cleared wholesale whenever a turn ended. But
the backend holds a queue of its own and takes one message per turn, so a
turn ending is precisely the moment the *rest* are still waiting -- the
bubbles vanished while the messages were on their way, which reads as
everything after the first having been dropped. Now a held message
leaves the list exactly two ways: the session reads it, which arrives as
a UserMessage, or its send failed and there is nothing to wait for.

Measured first, because the report was that the backend dropped them:
three messages sent behind one long turn were all delivered in order
(ONE, TWO, THREE) against current main, so the loss was in the display.

Verified on the emulator: headings, emphasis, inline code, nested lists,
a quote bar, a fenced block, a rule and a link all render, and the three
queued messages sit through their turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:10:22 -04:00

230 lines
8.9 KiB
Rust

//! 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 media;
mod models;
mod routes;
mod session;
mod setups;
mod ssh;
mod usage;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use tokio::signal::unix::{SignalKind, signal};
use wg_app_link::enroll;
use wg_app_link::netif::{self, WG_INTERFACE};
use wg_app_link::xdg::{config_home, data_home};
use config::TokenEntry;
use session::SessionManager;
const DEFAULT_PORT: u16 = 8443;
/// 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<IpAddr>,
/// Where the token hashes, providers, hosts, and session list live.
/// Defaults to `$XDG_CONFIG_HOME/ai-app/config.ron`.
#[arg(long)]
config: Option<PathBuf>,
/// Directory for per-session data (transcripts, attachments, images).
/// Defaults to `$XDG_DATA_HOME/ai-app/sessions`.
#[arg(long)]
data_dir: Option<PathBuf>,
/// Directory for downloaded GGUF models. Defaults to
/// `$XDG_DATA_HOME/ai-app/models`.
#[arg(long)]
models_dir: Option<PathBuf>,
/// Directory holding the TLS certificates, generated here on first
/// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
#[arg(long)]
certs: Option<PathBuf>,
/// Invalidate every enrolled token, generate a fresh one, and print
/// its enrollment QR -- the whole lost-phone story.
#[arg(long)]
rotate_token: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq
// brings ring, axum-server brings aws-lc-rs), so rustls refuses to
// pick one itself; choose before anything touches TLS.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("no other TLS crypto provider is installed before main");
tracing_subscriber::fmt().with_env_filter("info").init();
let args = Args::parse();
let config_path = args
.config
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
let data_dir = args
.data_dir
.unwrap_or_else(|| data_home("ai-app").join("sessions"));
// Beside the session data rather than under it: models outlive every
// session and are shared by all of them, so deleting a session must
// never take a multi-gigabyte download with it.
let models_dir = args
.models_dir
.unwrap_or_else(|| data_home("ai-app").join("models"));
let models = Arc::new(models::ModelStore::new(models_dir.clone()));
let manager = Arc::new(
SessionManager::new(config_path.clone(), data_dir, models_dir.clone())
.with_context(|| format!("failed to load {}", config_path.display()))?,
);
// After construction rather than inside it: seeding asks this machine
// what it has, which is I/O, and a constructor that quietly runs a
// subprocess is a surprise to every caller including the tests.
manager.seed_setup().await?;
tracing::info!("config: {}", config_path.display());
tracing::info!("models: {}", models_dir.display());
for setup in manager.setups() {
match &setup.ssh {
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
// No parenthetical naming the local machine: the default
// setup is *called* "this machine", and the line read
// "setup this machine (this machine)".
None => tracing::info!(" setup \"{}\" runs here", setup.name),
}
for provider in &setup.providers {
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
}
}
for info in manager.sessions() {
tracing::info!(
" session {} ({}, {:?})",
info.id,
info.provider,
info.status
);
}
// Before the interface check below, deliberately: the certificates are
// also what the phone app embeds at build time, so they need to be
// obtainable on a machine whose tunnel isn't up yet. The leaf is
// reissued on every start, so once wg0 exists the next start covers it.
let certs_dir = args
.certs
.unwrap_or_else(|| config_home("ai-app").join("certs"));
let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses())
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
if certificates.ca_is_new {
tracing::warn!(
"a new CA was generated in {} -- any installed app pins the previous one and can no \
longer reach this server. Rebuild it with app/build-apk.sh, which embeds this CA, \
and reinstall through Dev Updater.",
certs_dir.display(),
);
}
let bind_ip = match args.bind {
Some(ip) => {
tracing::warn!(
"binding {ip} by explicit --bind override -- production binds {WG_INTERFACE} only"
);
ip
}
None => netif::wg_address("ai-server")?,
};
// 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 = enroll::generate_token();
manager.set_tokens(vec![TokenEntry {
name: "phone".to_string(),
sha256: enroll::token_hash_hex(&token),
}])?;
if rotating {
tracing::info!("rotated the enrolled token; the previous one is now invalid");
}
enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?;
}
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
&certificates.leaf_cert,
&certificates.leaf_key,
)
.await
.context("failed to load TLS cert/key")?;
// No providers listed here any more: which machines can be asked, and
// about what, comes from the setups at the moment the screen is opened
// -- so a machine added from the phone reports its limits without a
// restart, and the backend's own account stops standing in for every
// machine's.
let monitor = Arc::new(usage::UsageMonitor::new());
// 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))
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
.merge(routes::models_router(Arc::clone(&models)))
.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}");
// Let go of the sessions on the way out rather than stopping them:
// their processes are meant to outlive this one, so restarting the
// backend does not end a turn somebody is waiting on. Each is recorded
// in its session directory and adopted again on the way back up (see
// `session::process`). Both signals, because systemd and OpenRC send
// TERM while a terminal sends INT.
let serving = axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>());
let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?;
tokio::select! {
served = serving => served.context("TLS listener failed")?,
_ = terminate.recv() => tracing::info!("SIGTERM -- detaching from sessions"),
_ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- detaching from sessions"),
}
manager.detach_all();
Ok(())
}