Files
ai-app/server/src/main.rs
T
iris-aiandClaude Opus 5 ac476ab0c9 Give llama.cpp sessions tools, web search and a model picker
A llama session was a chat box: no tools, a fixed model, no permission
mode, and a model name drawn as the path the file sits at. It now runs the
agent loop itself, which is what the pieces below all hang off.

Tools are `llama-server`'s own (`--tools all`), which that server both
publishes and runs -- `GET /tools` for the definitions, `POST /tools` to
call one. Web search is Exa's MCP server, reached from this backend rather
than from the machine serving the model: that is what llama.cpp's own web
UI does, and it puts the search on the machine with a route out instead of
the one with the GPU. `llama-server`'s `--mcp-servers-json` can only spawn
local commands, so using it would have meant a Node bridge on every
machine that serves a model.

Driving the loop is what makes the permission gate ours. Two modes,
`manual` and `bypassPermissions`, which is what the mechanism has: the web
UI asks before every call and remembers the tools you say "always" to. The
allowances fold back out of the transcript's own answers, so they survive
a restart and a model change without being stored anywhere else.

Also here, because tools made each of them matter:

- **Loading is a state.** A 12 GB model takes twenty seconds to reach
  memory and refuses everything until it has; the session used to report
  `running` for that whole time, and a message sent meanwhile came back as
  an error. It is `loading` now, and the message waits.
- **The model can be changed.** A `llama-server` holds one model, so this
  stops it and starts another. The conversation survives because it was
  never in the server.
- **Models are named, not pathed.** `general.name` read out of the file
  itself -- over ssh too, in the round trip the spawn was already making.
  Where two models share a name the file name breaks the tie.
- **`-np 1`, and the MTP draft head where the file has one.** Measured on
  the 27B here: 41.5 tok/s plain, 61.4 with `--spec-type draft-mtp` at one
  slot, and 28 with it at four -- speculating against a split KV cache is
  worse than not speculating. The flag is conditional because asking for a
  head that is not there makes `llama-server` exit.
- **A refusal says what to do.** Tool results are thousands of tokens, so
  an overrun context is now ordinary; it was "http status: 400" and is now
  the server's own "exceeds the available context size, try increasing it".

`GET /machines/{id}/models` is gone: the provider models route answers the
same question, and two answers to one question is how a picker comes to
offer a model the spawn screen does not.

Verified end to end against real models: a tool call asked and allowed, an
Exa search, a shell command, a 27B loaded while a message waited on it, a
model switch mid-session, a second message queued behind a running turn,
and the whole of it again on a session running over ssh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:11:49 -04:00

340 lines
14 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 off the open internet. `--bind` overrides
//! explicitly for development; 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 files;
mod gguf;
mod machines;
mod media;
mod models;
mod provider_auth;
mod resume;
mod routes;
mod session;
mod ssh;
mod usage;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::middleware::Next;
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 (Codex, 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
/// (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,
/// Enroll one more device without touching the running server: mint a
/// token, print its enrollment link (one line, stdout, nothing else) and
/// exit. The server adopts the token the first time that device uses it.
/// For a tool that opens the link on the phone, where a QR printed here
/// cannot be scanned.
#[arg(long)]
enroll_link: bool,
/// Hold every response back by this many milliseconds.
///
/// A development aid, and a specific one: over the tunnel a phone's requests
/// take tens to hundreds of milliseconds, and several faults live entirely
/// in what the app does *while* one is outstanding. On a loopback server
/// those windows close before anything can be observed and the bug looks
/// like it is not there.
#[arg(long, default_value_t = 0, value_name = "MS")]
delay: u64,
/// Mark every session spawned here as throwaway: its process is stopped
/// when this server exits, instead of being left running for the next start
/// to adopt. On by default in a debug build.
///
/// Sessions outlive the backend on purpose, which is right for the ones
/// somebody is using and wrong for the ones a test made -- twelve of those
/// accumulated on this machine in a day, each holding a conversation open.
///
/// The flag decides only what *new* sessions are marked as. What happens on
/// the way out is decided by the mark, which outlives the server that made
/// it.
#[arg(
long,
default_value_t = cfg!(debug_assertions),
action = clap::ArgAction::Set,
num_args = 0..=1,
default_missing_value = "true",
value_name = "BOOL",
)]
throwaway_sessions: 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.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("no other TLS crypto provider is installed before main");
// `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than
// as the filter, because `with_env_filter("info")` is a fixed directive that
// never reads the environment -- so `RUST_LOG=ai_server=debug` printed
// nothing, and the switch looked like the code it was meant to instrument.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args = Args::parse();
let config_path = args
.config
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
// Before the manager exists, on purpose: constructing it and seeding machines
// touches sessions and subprocesses this invocation has no business with
// while another instance is serving. Only the hash reaches disk, in the
// spool `auth.rs` reads; the link goes to stdout alone.
if args.enroll_link {
let bind_ip = match args.bind {
Some(ip) => ip,
None => netif::wg_address("ai-server")?,
};
let token = enroll::generate_token();
enroll::spool_pending(
&config::pending_enrollments_dir(&config_path),
"phone",
&token,
)?;
println!(
"{}",
enroll::enrollment_uri("aiapp", bind_ip, args.port, &token)
);
return Ok(());
}
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()))?
.marking_new_sessions_throwaway(args.throwaway_sessions),
);
if args.throwaway_sessions {
tracing::warn!(
"sessions spawned here are marked throwaway -- their processes are stopped when this \
server exits rather than left running (--throwaway-sessions=false to keep them)"
);
}
// After construction rather than inside it: seeding asks this machine what
// it has, and a constructor that quietly runs a subprocess is a surprise to
// every caller including the tests.
manager.seed_machine().await?;
tracing::info!("config: {}", config_path.display());
tracing::info!("models: {}", models_dir.display());
for machine in manager.machines() {
match &machine.ssh {
Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
// No parenthetical naming the local machine: the default machine is
// *called* "this machine", so repeating a local qualifier read
// like a stutter.
None => tracing::info!(" machine \"{}\" runs here", machine.name),
}
for provider in &machine.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.
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.
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 machines at the moment the screen is opened -- so a
// machine added from the phone reports its limits without a restart.
// The fixture is the manager's, because that is where the `/usage` command
// that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
// The one thing in here that acts without a request behind it: a session
// switched to auto-resume waits out its account's usage limit and picks
// itself back up. Started whether or not any session has it on, because
// the setting is per session and changes from the phone -- see
// `resume::run`.
tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor)));
// The bearer-token middleware wraps the entire router -- routes and fallback
// alike -- here and only here, so a new route can't forget auth.
let app = routes::router(Arc::clone(&manager))
.merge(routes::usage_router(
Arc::clone(&monitor),
Arc::clone(&manager),
))
.merge(routes::provider_auth_router(
Arc::clone(&provider_logins),
Arc::clone(&manager),
))
.merge(routes::models_router(Arc::clone(&models)))
.layer(axum::middleware::from_fn_with_state(
Arc::clone(&manager),
auth::require_token,
));
// Outside the auth layer, so an unauthenticated request is refused at the
// speed it always was: this is here to slow the app down, not to widen the
// window on anything guessing at tokens.
let app = match args.delay {
0 => app,
ms => {
tracing::warn!("delaying every response by {ms}ms -- development override");
app.layer(axum::middleware::from_fn(
move |request, next: Next| async move {
tokio::time::sleep(Duration::from_millis(ms)).await;
next.run(request).await
},
))
}
};
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. Each is recorded in its session
// directory and adopted again on the way back up. The exception is the
// sessions marked throwaway, which are stopped first. 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 -- letting go of sessions"),
_ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"),
}
// Stopped before the rest are let go of, and on every way out of the select
// above: a throwaway session is one nobody meant to keep, and the whole point
// is that nothing has to remember to clean it up.
manager.stop_throwaway_sessions();
provider_logins.cancel_all();
manager.detach_all();
Ok(())
}