Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs
This commit is contained in:
commit
3c0214ece8
94 files changed
+8299
-9454
No files matched your search
+71
-94
@@ -1,14 +1,12 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
//! 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.
|
||||
@@ -50,10 +48,9 @@ struct Args {
|
||||
#[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.
|
||||
/// 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>,
|
||||
|
||||
@@ -83,44 +80,34 @@ struct Args {
|
||||
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 -- Dev Updater -- that opens the link on the
|
||||
/// phone, where a QR printed here cannot be scanned.
|
||||
/// 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 --
|
||||
/// a page of history landing mid-fling, a screen drawn before its
|
||||
/// first answer arrives. On a loopback server every response is back
|
||||
/// within a millisecond or two, so those windows close before
|
||||
/// anything can be observed and the bug looks like it is not there.
|
||||
/// This reopens them on demand rather than by unplugging something.
|
||||
/// 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.
|
||||
/// 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: a
|
||||
/// session spawned to check something leaves a `claude` behind that
|
||||
/// every later server adopts, and they accumulate silently -- twelve
|
||||
/// of them on this machine in a day, each holding a conversation open.
|
||||
/// So a development build cleans up after itself unless told not to
|
||||
/// (`--throwaway-sessions=false`), and a release build never does
|
||||
/// unless asked.
|
||||
/// 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 is written
|
||||
/// into the session and outlives the server that made it -- so
|
||||
/// sessions spawned without it keep running, whichever server is up
|
||||
/// when one exits.
|
||||
/// 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),
|
||||
@@ -134,18 +121,16 @@ struct Args {
|
||||
|
||||
#[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.
|
||||
// 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 the per-request diagnostics that AGENTS.md tells you to turn on with
|
||||
// `RUST_LOG=ai_server=debug` printed nothing, and the switch looked like the code it was
|
||||
// meant to instrument being wrong.
|
||||
// `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()
|
||||
@@ -157,11 +142,10 @@ async fn main() -> Result<()> {
|
||||
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
|
||||
// setups 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 itself goes to
|
||||
// stdout alone, because the caller opens whatever this prints.
|
||||
// Before the manager exists, on purpose: constructing it and seeding setups
|
||||
// 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,
|
||||
@@ -182,9 +166,9 @@ async fn main() -> Result<()> {
|
||||
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.
|
||||
// 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"));
|
||||
@@ -200,9 +184,9 @@ async fn main() -> Result<()> {
|
||||
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, which is I/O, and a constructor that quietly runs a
|
||||
// subprocess is a surprise to every caller including the tests.
|
||||
// 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_setup().await?;
|
||||
|
||||
tracing::info!("config: {}", config_path.display());
|
||||
@@ -210,9 +194,9 @@ async fn main() -> Result<()> {
|
||||
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)".
|
||||
// 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 {
|
||||
@@ -228,10 +212,9 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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"));
|
||||
@@ -256,9 +239,8 @@ async fn main() -> Result<()> {
|
||||
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.
|
||||
// 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();
|
||||
@@ -279,18 +261,15 @@ async fn main() -> Result<()> {
|
||||
.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.
|
||||
// The fixture is the manager's, because that is where the `/usage`
|
||||
// command that sets it is typed; the monitor is what serves it.
|
||||
// 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.
|
||||
// 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()));
|
||||
|
||||
// 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.
|
||||
// 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(monitor, Arc::clone(&manager)))
|
||||
.merge(routes::models_router(Arc::clone(&models)))
|
||||
@@ -299,9 +278,9 @@ async fn main() -> Result<()> {
|
||||
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.
|
||||
// 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 => {
|
||||
@@ -318,13 +297,11 @@ async fn main() -> Result<()> {
|
||||
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`). The exception is the sessions marked throwaway,
|
||||
// which are stopped first -- see `--throwaway-sessions`. Both signals,
|
||||
// because systemd and OpenRC send TERM while a terminal sends INT.
|
||||
// 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")?;
|
||||
@@ -333,9 +310,9 @@ async fn main() -> Result<()> {
|
||||
_ = 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.
|
||||
// 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();
|
||||
manager.detach_all();
|
||||
|
||||
|
||||
Reference in new issue
Block a user