diff --git a/AGENTS.md b/AGENTS.md index 840e2d1..ae43815 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -362,7 +362,10 @@ first if a remote spawn ever mangles an argument. - Run the server for development with `--bind 127.0.0.1`. Without it the server binds wg0, which exists here but is unreachable from the emulator (it dials 10.0.2.2). First run prints the enrollment QR/URI with the - token — capture it from the log. + token — capture it from the log. `ai-server --enroll-link` (same + `--config`/`--bind`/`--port`) mints one more device's link while the + server keeps running and prints only the URI; the server adopts that + token on its first use. It is what Dev Updater's Enroll button runs. - **`app/debug-transcript.sh` puts a real conversation on the emulator.** The echo driver stays the right rig for most things and is the wrong one for anything whose cost scales with what was actually written: a real diff --git a/server/src/auth.rs b/server/src/auth.rs index b3319a4..3b2c884 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -21,8 +21,9 @@ use axum::extract::{ConnectInfo, Request, State}; use axum::http::{StatusCode, header}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; -use wg_app_link::enroll::token_matches; +use wg_app_link::enroll::{take_pending, token_hash_hex, token_matches}; +use crate::config::TokenEntry; use crate::session::SessionManager; /// Applied to every rejection. Not against brute force -- infeasible at 256 @@ -49,6 +50,26 @@ pub async fn require_token( if token_matches(token, &hashes) { return next.run(request).await; } + // A link minted by `--enroll-link` while this server was running: + // the entry moves from the spool into the config here, on first + // use, and is an ordinary token from then on. + match take_pending(&manager.pending_enrollments_dir(), token) { + Ok(Some(name)) => { + let entry = TokenEntry { + name, + sha256: token_hash_hex(token), + }; + match manager.add_token(entry) { + Ok(()) => { + tracing::info!("adopted an enrollment minted with --enroll-link"); + return next.run(request).await; + } + Err(error) => tracing::error!("couldn't adopt the enrollment: {error:#}"), + } + } + Ok(None) => {} + Err(error) => tracing::warn!("couldn't read the enrollment spool: {error:#}"), + } } // Peer address only -- never the header value. Absent when there is no @@ -73,9 +94,7 @@ mod tests { use axum::routing::get; use tower::ServiceExt; - use wg_app_link::enroll::{generate_token, token_hash_hex}; - - use crate::config::TokenEntry; + use wg_app_link::enroll::{generate_token, spool_pending}; fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc { let manager = Arc::new( @@ -184,4 +203,44 @@ mod tests { // The rejections themselves do get logged (that's the point). assert!(logged.contains("missing or invalid bearer token")); } + + /// A token spooled by `--enroll-link` is refused by nothing: the first + /// request carrying it is served, and from then on it is in the config + /// like any other. + #[tokio::test] + async fn a_spooled_enrollment_is_adopted_on_first_use() { + let dir = tempfile::tempdir().expect("tempdir"); + let manager = manager_with_token(dir.path(), "first"); + let spooled = generate_token(); + spool_pending(&manager.pending_enrollments_dir(), "tablet", &spooled).unwrap(); + + let router = guarded_router(Arc::clone(&manager)); + let bearer = format!("Bearer {spooled}"); + let served = router + .clone() + .oneshot(request("/probe", Some(&bearer))) + .await + .unwrap(); + assert_eq!(served.status(), StatusCode::OK); + let names: Vec = manager.tokens().into_iter().map(|t| t.name).collect(); + assert_eq!(names, vec!["phone".to_string(), "tablet".to_string()]); + assert!( + std::fs::read_dir(manager.pending_enrollments_dir()) + .unwrap() + .next() + .is_none(), + "the spool entry is consumed" + ); + + let again = router + .oneshot(request("/probe", Some(&bearer))) + .await + .unwrap(); + assert_eq!(again.status(), StatusCode::OK, "now an ordinary token"); + let stranger = guarded_router(manager) + .oneshot(request("/probe", Some("Bearer nope"))) + .await + .unwrap(); + assert_eq!(stranger.status(), StatusCode::UNAUTHORIZED); + } } diff --git a/server/src/config.rs b/server/src/config.rs index d78f1d7..a9f89ba 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -295,6 +295,13 @@ pub const LOCAL_SETUP: &str = "this machine"; /// a hand-written config can name it without looking one up. pub const LOCAL_SETUP_ID: &str = "local"; +/// Where `ai-server --enroll-link` leaves a token for the running server +/// to adopt: beside the config, since it is config in transit. See +/// `wg_app_link::enroll::spool_pending`. +pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf { + config_path.with_file_name("pending-enrollments") +} + impl Config { pub fn setup(&self, id: &str) -> Option<&SetupConfig> { self.setups.iter().find(|setup| setup.id == id) diff --git a/server/src/main.rs b/server/src/main.rs index 4f37d9c..9ddcf36 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -81,6 +81,14 @@ struct Args { #[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 -- Dev Updater -- 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 @@ -148,6 +156,28 @@ 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. + 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")); diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 1835d27..152c2a7 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -825,6 +825,21 @@ impl SessionManager { Ok(()) } + /// Adds one enrolled device, keeping the others -- the other half of + /// [`Self::set_tokens`], which replaces them all. + pub fn add_token(&self, token: TokenEntry) -> Result<()> { + let mut inner = self.inner.write().unwrap(); + let mut candidate = inner.config.clone(); + candidate.tokens.push(token); + candidate.save(&self.config_path)?; + inner.config = candidate; + Ok(()) + } + + pub fn pending_enrollments_dir(&self) -> PathBuf { + crate::config::pending_enrollments_dir(&self.config_path) + } + /// Lets go of every session's process, for a server that is going /// away and means to adopt them again when it comes back. /// diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 1efc2f4..352b86d 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -543,11 +543,18 @@ mod tests { ); // Asking for more than there is gives what there is, rather than failing. - assert_eq!(read_window(&path, None, 100, false).expect("window").len(), 10); + assert_eq!( + read_window(&path, None, 100, false).expect("window").len(), + 10 + ); // Nothing before the first event, which is how the phone learns to stop // paging. An empty answer here is the end of the history, not a fault. - assert!(read_window(&path, Some(1), 3, false).expect("window").is_empty()); + assert!( + read_window(&path, Some(1), 3, false) + .expect("window") + .is_empty() + ); assert!( read_window(&dir.path().join("nope.jsonl"), None, 3, false) .expect("window") @@ -589,7 +596,14 @@ mod tests { &rows[0], SeqEvent { seq: 1, event: Event::AssistantText { delta }, .. } if delta == "abc" )); - assert!(matches!(&rows[1], SeqEvent { seq: 4, event: Event::ToolStart { .. }, .. })); + assert!(matches!( + &rows[1], + SeqEvent { + seq: 4, + event: Event::ToolStart { .. }, + .. + } + )); assert!(matches!( &rows[2], SeqEvent { seq: 5, event: Event::AssistantText { delta }, .. } if delta == "def" @@ -601,10 +615,7 @@ mod tests { // The newest window never coalesces even when asked: the live cursor depends on real seqs. let newest = read_window(&path, None, 2, true).expect("window"); - assert_eq!( - newest.iter().map(|e| e.seq).collect::>(), - [6, 7] - ); + assert_eq!(newest.iter().map(|e| e.seq).collect::>(), [6, 7]); } #[test] diff --git a/wg-app-link b/wg-app-link index f95bc77..d35c880 160000 --- a/wg-app-link +++ b/wg-app-link @@ -1 +1 @@ -Subproject commit f95bc77f7bf55a57ba6c051b06f11d30ac68c55e +Subproject commit d35c880753b4a7ece0d542f0d49f1fbd140108c1