ai-server --enroll-link: mint one more device's link while the server runs

Prints the enrollment URI, one line on stdout, and exits; the running
server adopts the token the first time that device presents it, via the
spool wg-app-link's enroll module now provides (submodule bumped to
d35c880). This is the server half of enrolling through Dev Updater: its
coming per-component Enroll button runs this command and opens whatever
it prints on the phone, which is what a reinstall -- a signing change, a
new phone -- needs when nobody is at the terminal the QR is printed on.

Verified against the sandbox server: minted while it ran, first request
with the token served and the token moved into config.ron, spool empty,
second request served as an ordinary token. 108 tests, clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-02 05:44:17 -04:00
1 parent 68e77c7b37
commit 1656b058bf
7 files changed
+138 -13

No files matched your search

+4 -1
View File
@@ -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 - 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 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 (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.** - **`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 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 for anything whose cost scales with what was actually written: a real
+63 -4
View File
@@ -21,8 +21,9 @@ use axum::extract::{ConnectInfo, Request, State};
use axum::http::{StatusCode, header}; use axum::http::{StatusCode, header};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; 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; use crate::session::SessionManager;
/// Applied to every rejection. Not against brute force -- infeasible at 256 /// 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) { if token_matches(token, &hashes) {
return next.run(request).await; 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 // Peer address only -- never the header value. Absent when there is no
@@ -73,9 +94,7 @@ mod tests {
use axum::routing::get; use axum::routing::get;
use tower::ServiceExt; use tower::ServiceExt;
use wg_app_link::enroll::{generate_token, token_hash_hex}; use wg_app_link::enroll::{generate_token, spool_pending};
use crate::config::TokenEntry;
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> { fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
let manager = Arc::new( let manager = Arc::new(
@@ -184,4 +203,44 @@ mod tests {
// The rejections themselves do get logged (that's the point). // The rejections themselves do get logged (that's the point).
assert!(logged.contains("missing or invalid bearer token")); 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<String> = 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);
}
} }
+7
View File
@@ -295,6 +295,13 @@ pub const LOCAL_SETUP: &str = "this machine";
/// a hand-written config can name it without looking one up. /// a hand-written config can name it without looking one up.
pub const LOCAL_SETUP_ID: &str = "local"; 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 { impl Config {
pub fn setup(&self, id: &str) -> Option<&SetupConfig> { pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.id == id) self.setups.iter().find(|setup| setup.id == id)
+30
View File
@@ -81,6 +81,14 @@ struct Args {
#[arg(long)] #[arg(long)]
rotate_token: bool, 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. /// Hold every response back by this many milliseconds.
/// ///
/// A development aid, and a specific one: over the tunnel a phone's /// 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 let config_path = args
.config .config
.unwrap_or_else(|| config_home("ai-app").join("config.ron")); .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 let data_dir = args
.data_dir .data_dir
.unwrap_or_else(|| data_home("ai-app").join("sessions")); .unwrap_or_else(|| data_home("ai-app").join("sessions"));
+15
View File
@@ -825,6 +825,21 @@ impl SessionManager {
Ok(()) 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 /// Lets go of every session's process, for a server that is going
/// away and means to adopt them again when it comes back. /// away and means to adopt them again when it comes back.
/// ///
+18 -7
View File
@@ -543,11 +543,18 @@ mod tests {
); );
// Asking for more than there is gives what there is, rather than failing. // 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 // 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. // 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!( assert!(
read_window(&dir.path().join("nope.jsonl"), None, 3, false) read_window(&dir.path().join("nope.jsonl"), None, 3, false)
.expect("window") .expect("window")
@@ -589,7 +596,14 @@ mod tests {
&rows[0], &rows[0],
SeqEvent { seq: 1, event: Event::AssistantText { delta }, .. } if delta == "abc" 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!( assert!(matches!(
&rows[2], &rows[2],
SeqEvent { seq: 5, event: Event::AssistantText { delta }, .. } if delta == "def" 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. // 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"); let newest = read_window(&path, None, 2, true).expect("window");
assert_eq!( assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]);
newest.iter().map(|e| e.seq).collect::<Vec<_>>(),
[6, 7]
);
} }
#[test] #[test]