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:
1 parent
68e77c7b37
commit
1656b058bf
7 files changed
+138
-13
No files matched your search
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
+1
-1
Submodule wg-app-link updated: f95bc77f7b...d35c880753.
Reference in new issue
Block a user