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>
230 lines
8.9 KiB
Rust
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(())
|
|
}
|