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

+15
View File
@@ -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.
///
+18 -7
View File
@@ -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::<Vec<_>>(),
[6, 7]
);
assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]);
}
#[test]