The agent's link: asked for whenever it does not work, and "refused" only for the server's word
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s

The sign-in asked for the agent's token only when the CLI had no login for the server on file,
so a computer with an old, refused login got the app's token alone, and the agent stayed
refused. It now asks unless the CLI says the link works, and hands the token over (the CLI's
--token-stdin login replaces the one it had) or gives it up when the link came up meanwhile.

Link this computer is a device login for the agent's scopes alone, handed to the CLI the same
way, with a cancel: `lembas login <server>` only refreshed a login the CLI already had.

A service that ends for want of a login is "not linked"; another reason is "stopped"; only the
server's refusal, with its reason, is "refused".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 12:38:21 +00:00
1 parent a12ed7ac36
commit 1749d2369e
3 files changed
+179 -39

No files matched your search

+60 -11
View File
@@ -45,8 +45,9 @@ pub enum State {
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link, /// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
/// the server cannot be reached, the link dropped). /// the server cannot be reached, the link dropped).
Waiting(String), Waiting(String),
/// The CLI will not try again by itself: the server refused this computer's token, or the /// The server refused this computer's link, in its words (a token it no longer takes, or a
/// login is missing. Linking again is the way out. /// protocol it does not speak). The CLI will not try again by itself; linking again is the
/// way out. Only the server's refusal: a missing login is `NotLinked`.
Refused(String), Refused(String),
/// Another `lembas service` on this computer holds the link. /// Another `lembas service` on this computer holds the link.
Elsewhere(Elsewhere), Elsewhere(Elsewhere),
@@ -108,6 +109,32 @@ pub fn from_link(link: &cli::Link, before: &State) -> State {
} }
} }
/// What a service that ended cleanly (exit 0) said: the server's refusal (`stopped` in its status
/// file, with the server's reason) is `Refused`; no login for the server is `NotLinked`; any
/// other reason it gave (a configuration that cannot work) is `Failed` -- never "refused", which
/// is the server's word only. None when it said nothing.
fn ended_cleanly(stopped: Option<&cli::Link>, said: Option<&str>) -> Option<State> {
if let Some(link) = stopped {
let why = link.detail.clone().unwrap_or_default();
// The CLI's advice to its terminal user ("sign in again: lembas login") is the app's
// Link button here.
let why = why
.split(" — sign in again")
.next()
.unwrap_or("")
.trim()
.to_string();
if !why.is_empty() && why != "stopped" {
return Some(State::Refused(why));
}
}
let said = strip_prefix(said?);
if said.starts_with("not logged in") {
return Some(State::NotLinked);
}
Some(State::Failed(said))
}
/// What a running process has said before its status file says anything: the one line the /// What a running process has said before its status file says anything: the one line the
/// CLI's service writes while a terminal on this computer holds the link for `/remote`. /// CLI's service writes while a terminal on this computer holds the link for `/remote`.
fn from_output(line: &str) -> Option<State> { fn from_output(line: &str) -> Option<State> {
@@ -252,15 +279,12 @@ impl Agent {
Some(RESTART_FOR_UPDATE) => continue, Some(RESTART_FOR_UPDATE) => continue,
Some(0) => { Some(0) => {
// It ended by itself without an error: the CLI's word for "starting again // It ended by itself without an error: the CLI's word for "starting again
// will not help" -- the server refused the token, or there is no login. // will not help". Waiting a long while (linking again starts it at once).
// Waiting a long while (linking again starts it at once). let stopped = cli::link_of(pid).filter(|l| l.state == "stopped");
let why = cli::link_of(pid) let next = ended_cleanly(stopped.as_ref(), said.as_deref());
.filter(|l| l.state == "stopped") match next {
.and_then(|l| l.detail) Some(state) => {
.or_else(|| said.map(|s| strip_prefix(&s))); if !self.set(generation, state) {
match why {
Some(why) => {
if !self.set(generation, State::Refused(why)) {
return; return;
} }
self.pause(generation, REFUSED_WAIT); self.pause(generation, REFUSED_WAIT);
@@ -436,6 +460,31 @@ fn strip_prefix(line: &str) -> String {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn refused_is_only_the_servers_word() {
let refused = link(
"stopped",
Some("not allowed to link — sign in again: lembas login"),
1,
);
assert_eq!(
ended_cleanly(Some(&refused), None),
Some(State::Refused("not allowed to link".into()))
);
assert_eq!(
ended_cleanly(
None,
Some("lembas service: not logged in to any LLeMbas instance — lembas login <address>")
),
Some(State::NotLinked)
);
assert_eq!(
ended_cleanly(None, Some("lembas service: logged in to several instances")),
Some(State::Failed("logged in to several instances".into()))
);
assert_eq!(ended_cleanly(None, None), None);
}
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link { fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
cli::Link { cli::Link {
state: state.into(), state: state.into(),
+79 -26
View File
@@ -9,9 +9,10 @@
//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows //! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows
//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`). //! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`).
//! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same //! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same
//! approval, and `lembas login --token-stdin` takes it (a server without that: `lembas login //! approval, and `lembas login --token-stdin` takes it; *Link this computer* is a login for the
//! --json`, a second approval); `lembas service run` is the device (`agent.rs`). The app reads //! agent's token alone, handed over the same way. `lembas service run` is the device
//! the CLI's state and runs its commands; it never writes the CLI's files. //! (`agent.rs`). The app reads the CLI's state and runs its commands; it never writes the
//! CLI's files.
//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`). //! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`).
mod agent; mod agent;
@@ -97,6 +98,8 @@ struct App {
agent: Agent, agent: Agent,
tray: Mutex<Option<TrayItems>>, tray: Mutex<Option<TrayItems>>,
linking: Mutex<bool>, linking: Mutex<bool>,
/// Set to stop waiting for the approval of a link in progress.
link_cancel: Mutex<Option<Arc<AtomicBool>>>,
/// The newer release the last check found, if any. /// The newer release the last check found, if any.
update: Mutex<Option<String>>, update: Mutex<Option<String>>,
/// The agent's folders, as `lembas remote` last answered. /// The agent's folders, as `lembas remote` last answered.
@@ -286,9 +289,12 @@ enum SignIn {
} }
/// Sign the app in to the server: a device login for the app's scopes, approved in the browser. /// Sign the app in to the server: a device login for the app's scopes, approved in the browser.
/// Where the server can (`one_approval`) and this computer is not linked to it yet, the same /// Where the server can (`one_approval`), the same approval asks for the agent's token too, unless
/// approval links it: the agent's token goes to the CLI. A computer linked already keeps its /// the agent's link works right now (the CLI says it is linked). Whether the CLI has a login for
/// link; "Link again" is the way to replace it. /// this server on file is not asked: a login the server refuses is a file all the same, and
/// asking that left a computer with a refused link and no way to a new one from the sign-in.
/// After the approval the agent's token goes to the CLI -- whose `--token-stdin` login replaces
/// the one it had for this server -- or, when the link came up meanwhile, is given up.
#[tauri::command] #[tauri::command]
fn sign_in(handle: AppHandle) -> Result<(), String> { fn sign_in(handle: AppHandle) -> Result<(), String> {
let app = handle.state::<App>(); let app = handle.state::<App>();
@@ -306,7 +312,7 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
announce(&handle); announce(&handle);
std::thread::spawn(move || { std::thread::spawn(move || {
let host = cli::hostname().unwrap_or_default(); let host = cli::hostname().unwrap_or_default();
let with_agent = d.one_approval && !cli::linked_to(&d.base); let with_agent = d.one_approval && !link_works(&handle.state::<App>());
let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| { let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| {
let _ = handle let _ = handle
.opener() .opener()
@@ -346,7 +352,10 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
let _ = handle.emit("signin", outcome); let _ = handle.emit("signin", outcome);
announce(&handle); announce(&handle);
if let Some(token) = agent_token { if let Some(token) = agent_token {
if let Err(message) = hand_to_cli(&handle, &d.base, token) { if link_works(&app) {
// Linked meanwhile (a service started by hand, say): this token is nobody's.
server::sign_out(&d.base, &token);
} else if let Err(message) = hand_to_cli(&handle, &d.base, token) {
let _ = handle.emit("linking", Linking::Failed { message }); let _ = handle.emit("linking", Linking::Failed { message });
announce(&handle); announce(&handle);
} }
@@ -779,6 +788,24 @@ fn link_computer(handle: AppHandle) -> Result<(), String> {
start_link(&handle) start_link(&handle)
} }
/// Stop waiting for the approval of a link in progress.
#[tauri::command]
fn link_cancel(handle: AppHandle) {
if let Some(c) = handle.state::<App>().link_cancel.lock().unwrap().take() {
c.store(true, Ordering::SeqCst);
}
}
/// Whether the agent's link works right now: the CLI says it is linked -- the app's own service,
/// or one started by hand. A login on file is not a link that works.
fn link_works(app: &App) -> bool {
match app.agent.state() {
AgentState::Running => true,
AgentState::Elsewhere(e) => e.linked,
_ => false,
}
}
fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> { fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
let app = handle.state::<App>(); let app = handle.state::<App>();
let s = app.update(|s| s.agent = on)?; let s = app.update(|s| s.agent = on)?;
@@ -885,31 +912,49 @@ enum Linking {
Failed { message: String }, Failed { message: String },
} }
/// Link this computer: `lembas login <server> --json`, the approval opened in the system's /// Link this computer (again): a device login for the agent's scopes alone, the approval opened
/// browser (where the person is signed in to the server), then the agent started. The way to /// in the system's browser (where the person is signed in to the server), the token handed to
/// link again, and the only way on a server that gives no agent token with the app's sign-in. /// the CLI (`lembas login --token-stdin`, which replaces a login it had for this server), then the
/// agent started. Not `lembas login <server>`: for a server the CLI is logged in to already, that
/// only refreshes the login it has, so a token the server refused would stay refused.
fn start_link(handle: &AppHandle) -> Result<(), String> { fn start_link(handle: &AppHandle) -> Result<(), String> {
let app = handle.state::<App>(); let app = handle.state::<App>();
let Some(server) = app.settings().server else { let Some(d) = app.server.lock().unwrap().clone() else {
return Err("Choose a server first.".into()); return Err("Choose a LLeMbas 2.0 server first.".into());
}; };
claim_linking(&app)?; claim_linking(&app)?;
let child = match Command::new(cli::binary()) let cancel = Arc::new(AtomicBool::new(false));
.arg("login") *app.link_cancel.lock().unwrap() = Some(cancel.clone());
.arg(&server) let handle = handle.clone();
.arg("--json") std::thread::spawn(move || {
.stdin(Stdio::null()) let host = cli::hostname().unwrap_or_default();
.stdout(Stdio::piped()) let result = server::start_link(&d, &host).and_then(|code| {
.stderr(Stdio::piped()) let _ = handle
.spawn() .opener()
{ .open_url(&code.verification_uri_complete, None::<&str>);
Ok(c) => c, let _ = handle.emit(
Err(e) => { "linking",
Linking::Code {
user_code: code.user_code.clone(),
uri: code.verification_uri_complete.clone(),
},
);
server::await_sign_in(&d, &code, &cancel)
});
let app = handle.state::<App>();
app.link_cancel.lock().unwrap().take();
let given = match result {
Ok(granted) => give_to_cli(&handle, &d.base, granted.account.token),
Err(message) => {
*app.linking.lock().unwrap() = false; *app.linking.lock().unwrap() = false;
return Err(format!("Could not start {}: {e}", cli::binary().display())); Err(message)
} }
}; };
follow_login(handle, child, None); if let Err(message) = given {
let _ = handle.emit("linking", Linking::Failed { message });
}
announce(&handle);
});
Ok(()) Ok(())
} }
@@ -924,6 +969,12 @@ fn hand_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), Stri
server::sign_out(base, &token); server::sign_out(base, &token);
return Err(e); return Err(e);
} }
give_to_cli(handle, base, token)
}
/// `hand_to_cli` once linking is claimed: the CLI takes the token, or it is given up.
fn give_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), String> {
let app = handle.state::<App>();
let spawned = Command::new(cli::binary()) let spawned = Command::new(cli::binary())
.args(["login", "--token-stdin", "--server", base, "--json"]) .args(["login", "--token-stdin", "--server", base, "--json"])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
@@ -1460,6 +1511,7 @@ pub fn run() {
open_external, open_external,
set_agent, set_agent,
link_computer, link_computer,
link_cancel,
titlebar_menu, titlebar_menu,
update_app, update_app,
remote_refresh, remote_refresh,
@@ -1496,6 +1548,7 @@ pub fn run() {
agent, agent,
tray: Mutex::new(None), tray: Mutex::new(None),
linking: Mutex::new(false), linking: Mutex::new(false),
link_cancel: Mutex::new(None),
update: Mutex::new(None), update: Mutex::new(None),
remote: Mutex::new(None), remote: Mutex::new(None),
theme: Mutex::new(theme), theme: Mutex::new(theme),
+40 -2
View File
@@ -271,6 +271,18 @@ pub struct Code {
/// What a device login asks the server for, as the form it posts. /// What a device login asks the server for, as the form it posts.
fn code_request(host: &str, with_agent: bool) -> Value { fn code_request(host: &str, with_agent: bool) -> Value {
code_request_for(host, Ask::App { with_agent })
}
/// Which login: the app's own (and the agent's beside it, in the same approval), or the agent's
/// alone -- linking this computer again, which needs no second token for the app.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Ask {
App { with_agent: bool },
Agent,
}
fn code_request_for(host: &str, ask: Ask) -> Value {
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
let device_name = if host.is_empty() { let device_name = if host.is_empty() {
"LLeMbas Desktop".to_string() "LLeMbas Desktop".to_string()
@@ -283,7 +295,13 @@ fn code_request(host: &str, with_agent: bool) -> Value {
"device_platform": platform, "device_platform": platform,
"scope": APP_SCOPE, "scope": APP_SCOPE,
}); });
if with_agent { if ask == Ask::Agent {
// The agent's token alone: the device an agent chat runs on, named by its host.
body["scope"] = Value::String(AGENT_SCOPE.to_string());
body["device_host"] = Value::String(host.to_string());
return body;
}
if matches!(ask, Ask::App { with_agent: true }) {
// The agent's token is the device an agent chat runs on: it carries this computer's // The agent's token is the device an agent chat runs on: it carries this computer's
// host, as `lembas login` sends it, so the app knows which device is this one. The // host, as `lembas login` sends it, so the app knows which device is this one. The
// app's own token alone names no host: it is not a device. // app's own token alone names no host: it is not a device.
@@ -296,10 +314,22 @@ fn code_request(host: &str, with_agent: bool) -> Value {
/// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where /// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where
/// discovery offered it). /// discovery offered it).
pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> { pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> {
start_code(d, code_request(host, with_agent && d.one_approval))
}
/// Open a device login for the agent's scopes alone: linking this computer (again), with the
/// token handed to the CLI as the app's own approval hands it. A login the CLI made itself
/// (`lembas login <server>`) would not do here: for a server it is logged in to already, that
/// only refreshes the login it has -- a token the server refuses stays refused.
pub fn start_link(d: &Discovery, host: &str) -> Result<Code, String> {
start_code(d, code_request_for(host, Ask::Agent))
}
fn start_code(d: &Discovery, form: Value) -> Result<Code, String> {
let res = agent(Duration::from_secs(15)) let res = agent(Duration::from_secs(15))
.post(&d.device_code) .post(&d.device_code)
.set("accept", "application/json") .set("accept", "application/json")
.send_json(code_request(host, with_agent && d.one_approval)) .send_json(form)
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?; .map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
let v: Value = res let v: Value = res
.into_json() .into_json()
@@ -793,6 +823,14 @@ mod tests {
assert!(app_only.get("device_host").is_none()); assert!(app_only.get("device_host").is_none());
} }
#[test]
fn linking_again_asks_for_the_agents_token_alone_and_names_the_host() {
let link = code_request_for("box", Ask::Agent);
assert_eq!(link["scope"], "models memory link");
assert_eq!(link["device_host"], "box");
assert!(link.get("agent_scope").is_none());
}
#[test] #[test]
fn an_approval_gives_the_app_its_token_and_the_agent_its_own() { fn an_approval_gives_the_app_its_token_and_the_agent_its_own() {
let v = json!({ let v = json!({