One approval for app and agent; /api/v2 files and show; app_api 2

- The app's device login asks for `app models` and, where discovery says
  login.device.agent_scope, `agent_scope=models memory link` in the same
  approval. The agent's token goes to the bundled CLI on stdin
  (`lembas login --token-stdin --server <url> --json`) and is never kept
  here; if the CLI does not take it, it is given up on the server. A
  computer linked already keeps its link; a server without agent_scope
  gets the old two steps.
- The server pane reads GET /api/v2/files and /files/content (mounts open
  like folders; `withheld` shown in the server's words). The "not offered
  yet" state is gone.
- A show card's file comes from GET /api/v2/chats/{chat}/show?node=&version=
  or ?shown= as plain text (new `api_text` command, 1 MB cap); args.path is
  read only for agent chats on this computer, locally.
- The gate is link protocol 3 plus app_api 2 (APP_API = 2), not the
  server's version number, which says 1.7.2 on dev until its first beta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-03 14:48:00 +00:00
1 parent b82d4c688a
commit ab0c1434c4
9 files changed
+383 -164

No files matched your search

+173 -75
View File
@@ -2,22 +2,27 @@
//! sign-in to it, and every request the app's pages make to it.
//!
//! - **Discovery** (`/.well-known/lembas.json`) decides first. LLeMbas Desktop 1.0 needs a
//! LLeMbas 2.0 server: one that speaks link protocol 3 and has the app's door (`/api/v2`) in a
//! version this app knows. An older server is refused with a sentence that says which side to
//! update, before anything is signed in.
//! LLeMbas 2.0 server: one that speaks link protocol 3 and has the app's door (`/api/v2`) in
//! version 2. That is checked by `app_api`, not by the server's version number, which says 2
//! only from its first 2.0 release on. An older server is refused with a sentence that says
//! which side to update, before anything is signed in.
//! - **The app's sign-in** is the server's device login (RFC 8628), the same one `lembas login`
//! uses, for one scope: `app`, "read and write your chats, as the web interface does". The
//! uses, for `app models`: "read and write your chats, as the web interface does", and the
//! speech routes. **One approval, two tokens**: where discovery says
//! `login.device.agent_scope`, the same login asks for the agent's scopes too
//! (`models memory link`), and the approval answers with a second token, the agent's, which is
//! handed straight to the bundled CLI (`lembas login --token-stdin`) and never kept here. The
//! approval page opens in the system's browser, where the person is signed in already. The
//! token is kept in the app's config directory, readable by this user only.
//! app's token is kept in the app's config directory, readable by this user only.
//! - **The token stays in Rust.** The pages ask for `/api/v2/...` through a command and get the
//! JSON back; they never hold the token, so no page -- and nothing a page shows -- can send it
//! anywhere. A request can only reach the server's `/api/v2`: the path is checked here.
//!
//! The agent's own link is a different token, the CLI's (`lembas login`), which this app never
//! reads: a token that may accept work as a device is not one the chat window needs.
//! The agent's own link is a different token, the CLI's, which this app never keeps or reads
//! back: a token that may accept work as a device is not one the chat window needs.
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
@@ -27,11 +32,15 @@ use serde_json::Value;
/// The link protocol a 2.0 server speaks (harness spec `acp.md`, *Versions*).
pub const NEEDS_PROTOCOL: i64 = 3;
/// The server's major version this app is written for.
pub const NEEDS_MAJOR: u64 = 2;
/// The `/api/v2` contract this app is written against: discovery's `app_api`. A higher number is
/// a change an existing client would misread, so this app says it is too old rather than guess.
pub const APP_API: i64 = 1;
/// The `/api/v2` contract this app is written against: discovery's `app_api`. A lower number is
/// a server before 2.0 (its chats carry what this app no longer reads, and it has no `show`
/// card); a higher one is a change an existing client would misread, so this app says it is too
/// old rather than guess.
pub const APP_API: i64 = 2;
/// The scopes the app's own token asks for: the chats, and the speech routes of `/v1`.
pub const APP_SCOPE: &str = "app models";
/// The scopes the agent's token asks for, in the same approval: what `lembas service` needs.
pub const AGENT_SCOPE: &str = "models memory link";
/// What discovery said, once it has passed every check.
#[derive(Clone, Debug, Serialize, PartialEq)]
@@ -46,6 +55,9 @@ pub struct Discovery {
pub device_code: String,
#[serde(skip)]
pub device_token: String,
/// The server gives the agent's token from the app's own approval
/// (`login.device.agent_scope`): one approval, not two.
pub one_approval: bool,
}
fn agent(read: Duration) -> ureq::Agent {
@@ -113,18 +125,9 @@ pub fn discover(base: &str) -> Result<Discovery, String> {
check(&body, base)
}
/// The leading number of a version: `2` of `2.0.0-beta.1`, `1` of `1.7.3`.
fn major(version: &str) -> Option<u64> {
let digits: String = version
.trim_start_matches('v')
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
digits.parse().ok()
}
/// The checks, on discovery's answer: a LLeMbas server, a 2.0 one (protocol 3, version 2 or
/// later), with an app door whose version this app knows.
/// The checks, on discovery's answer: a LLeMbas server, a 2.0 one (protocol 3, and the app
/// door in version 2), with an app door whose version this app knows. The server's version
/// number is not checked: a 2.0 server in the making still says 1.7 until its first beta.
pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
if body.get("service").and_then(Value::as_str) != Some("lembas") {
return Err(format!("{base} is not a LLeMbas server."));
@@ -143,8 +146,8 @@ pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
.into_iter()
.collect(),
};
let new_enough =
protocols.contains(&NEEDS_PROTOCOL) && major(&version).is_some_and(|m| m >= NEEDS_MAJOR);
let app_api = body.get("app_api").and_then(Value::as_i64).unwrap_or(0);
let new_enough = protocols.contains(&NEEDS_PROTOCOL) && app_api >= APP_API;
if !new_enough {
let runs = if version.is_empty() {
"an older LLeMbas".to_string()
@@ -156,12 +159,6 @@ pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
env!("CARGO_PKG_VERSION")
));
}
let app_api = body.get("app_api").and_then(Value::as_i64).unwrap_or(0);
if app_api < 1 {
return Err(format!(
"{base} has no app interface (/api/v2). Update the server to LLeMbas 2.0 or later."
));
}
if app_api > APP_API {
return Err(format!(
"{base} speaks a newer app interface (version {app_api}) than LLeMbas Desktop {} knows (version {APP_API}). Update LLeMbas Desktop.",
@@ -195,6 +192,10 @@ pub fn check(body: &Value, base: &str) -> Result<Discovery, String> {
app,
device_code,
device_token,
one_approval: device
.and_then(|d| d.get("agent_scope"))
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
@@ -268,25 +269,37 @@ pub struct Code {
pub device_code: String,
}
/// Open a device login for the `app` scope.
pub fn start_sign_in(d: &Discovery, host: &str) -> Result<Code, String> {
/// What a device login asks the server for, as the form it posts.
fn code_request(host: &str, with_agent: bool) -> Value {
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
let device_name = if host.is_empty() {
"LLeMbas Desktop".to_string()
} else {
format!("LLeMbas Desktop on {host}")
};
let mut body = serde_json::json!({
"client_id": format!("llembas-desktop {}", env!("CARGO_PKG_VERSION")),
"device_name": device_name,
"device_platform": platform,
"scope": APP_SCOPE,
});
if with_agent {
// 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
// app's own token alone names no host: it is not a device.
body["device_host"] = Value::String(host.to_string());
body["agent_scope"] = Value::String(AGENT_SCOPE.to_string());
}
body
}
/// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where
/// discovery offered it).
pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> {
let res = agent(Duration::from_secs(15))
.post(&d.device_code)
.set("accept", "application/json")
.send_json(serde_json::json!({
"client_id": format!("llembas-desktop {}", env!("CARGO_PKG_VERSION")),
"device_name": device_name,
// No host on purpose: the host names the device an agent chat runs on, and that is
// the CLI's link, not this sign-in.
"device_platform": platform,
"scope": "app",
}))
.send_json(code_request(host, with_agent && d.one_approval))
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
let v: Value = res
.into_json()
@@ -327,12 +340,45 @@ fn oauth_error(e: ureq::Error, what: &str) -> String {
}
}
/// What an approval gave: the app's sign-in, and the agent's token when the login asked for one.
/// The agent's token is not kept: it goes to the CLI and is dropped.
pub struct Granted {
pub account: Account,
pub agent_token: Option<String>,
}
/// The token endpoint's answer, once it carries a token.
fn granted(v: &Value, server: &str) -> Option<Granted> {
let token = v.get("access_token").and_then(Value::as_str)?;
let account = v.get("account");
let field = |k: &str| {
account
.and_then(|a| a.get(k))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
Some(Granted {
account: Account {
server: server.to_string(),
token: token.to_string(),
email: field("email"),
name: field("name"),
},
agent_token: v
.get("agent_token")
.and_then(Value::as_str)
.filter(|t| !t.is_empty())
.map(String::from),
})
}
/// Wait for the person to approve the code (or not), checking `cancelled` while waiting.
pub fn await_sign_in(
d: &Discovery,
code: &Code,
cancelled: &AtomicBool,
) -> Result<Account, String> {
) -> Result<Granted, String> {
let deadline = Instant::now() + Duration::from_secs(code.expires_in.max(1));
let mut interval = Duration::from_secs(code.interval);
let http = agent(Duration::from_secs(15));
@@ -358,21 +404,8 @@ pub fn await_sign_in(
return Err(format!("Could not reach the server: {t}"))
}
};
if let Some(token) = v.get("access_token").and_then(Value::as_str) {
let account = v.get("account");
let field = |k: &str| {
account
.and_then(|a| a.get(k))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
return Ok(Account {
server: d.base.clone(),
token: token.to_string(),
email: field("email"),
name: field("name"),
});
if let Some(g) = granted(&v, &d.base) {
return Ok(g);
}
match v.get("error").and_then(Value::as_str) {
Some("authorization_pending") => {}
@@ -388,8 +421,8 @@ pub fn await_sign_in(
Err("The code expired before it was approved. Sign in again.".into())
}
/// Give the token up on the server (it is gone from the server's device list then). Best effort:
/// the app forgets it either way.
/// Give a token up on the server (the app's: gone from the account's apps then; the agent's:
/// one the CLI did not take). Best effort: the app forgets it either way.
pub fn sign_out(base: &str, token: &str) {
let _ = agent(Duration::from_secs(10))
.delete(&format!("{base}/api/v1/token"))
@@ -452,6 +485,28 @@ pub fn request(
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))
}
/// The most a plain-text answer may be: the server's own limit for a shown file (1 MB), and a
/// little room. Anything longer is not read to the end.
pub const TEXT_LIMIT: u64 = 1024 * 1024 + 4096;
/// One `GET` on `/api/v2` that answers plain text, not JSON: the file a `show` card names
/// (`/chats/{chat}/show?…`). An error is still the server's JSON `detail` -- 404 when the file
/// is no longer the chat's to show, 413 when it is too large, 415 when it is not text.
pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiError> {
let path = checked_path(path)?;
let res = agent(Duration::from_secs(60))
.get(&format!("{app}{path}"))
.set("authorization", &format!("Bearer {token}"))
.set("accept", "text/plain")
.call()?;
let mut text = String::new();
res.into_reader()
.take(TEXT_LIMIT)
.read_to_string(&mut text)
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
Ok(text)
}
/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame.
#[derive(Default)]
pub struct Sse {
@@ -537,11 +592,12 @@ mod tests {
"name": "Example",
"protocol": 3,
"protocols": [3],
"app_api": 1,
"app_api": 2,
"api": {"app": "https://ai.example/api/v2"},
"login": {"device": {
"code": "https://ai.example/api/device/code",
"token": "https://ai.example/api/device/token"
"token": "https://ai.example/api/device/token",
"agent_scope": true
}}
})
}
@@ -552,6 +608,54 @@ mod tests {
assert_eq!(d.app, "https://ai.example/api/v2");
assert_eq!(d.name, "Example");
assert_eq!(d.device_token, "https://ai.example/api/device/token");
assert!(d.one_approval);
// A server in the making still says 1.7 until its first 2.0 beta: `app_api` decides.
let mut dev = two_oh();
dev["version"] = json!("1.7.2");
assert!(check(&dev, "https://ai.example").is_ok());
}
#[test]
fn without_agent_scope_in_discovery_there_are_two_approvals() {
let mut older = two_oh();
older["login"]["device"]
.as_object_mut()
.unwrap()
.remove("agent_scope");
assert!(!check(&older, "https://ai.example").unwrap().one_approval);
}
#[test]
fn one_approval_asks_for_both_tokens_and_only_then_names_the_host() {
let both = code_request("workbench", true);
assert_eq!(both["scope"], "app models");
assert_eq!(both["agent_scope"], "models memory link");
assert_eq!(both["device_host"], "workbench");
assert_eq!(both["device_name"], "LLeMbas Desktop on workbench");
let app_only = code_request("workbench", false);
assert_eq!(app_only["scope"], "app models");
assert!(app_only.get("agent_scope").is_none());
assert!(app_only.get("device_host").is_none());
}
#[test]
fn an_approval_gives_the_app_its_token_and_the_agent_its_own() {
let v = json!({
"access_token": "lmb_app", "token_type": "Bearer", "scope": "app models",
"account": {"email": "a@example.org", "name": "A"},
"base_url": "https://ai.example",
"agent_token": "lmb_agent", "agent_scope": "models memory link"
});
let g = granted(&v, "https://ai.example").unwrap();
assert_eq!(g.account.token, "lmb_app");
assert_eq!(g.account.email, "a@example.org");
assert_eq!(g.agent_token.as_deref(), Some("lmb_agent"));
let one = json!({"access_token": "lmb_app", "account": {}});
assert_eq!(
granted(&one, "https://ai.example").unwrap().agent_token,
None
);
assert!(granted(&json!({"error": "authorization_pending"}), "x").is_none());
}
#[test]
@@ -561,11 +665,13 @@ mod tests {
let why = check(&old, "https://ai.example").unwrap_err();
assert!(why.contains("LLeMbas 1.7.3"), "{why}");
assert!(why.contains("needs a LLeMbas 2.0 server"), "{why}");
// Protocol 3 alone is not enough: the version must say 2.
// Protocol 3 alone is not enough: the app door must be version 2 (1.7 serves 1).
let mut early = two_oh();
early["version"] = json!("1.7.2");
assert!(check(&early, "https://ai.example").is_err());
// Version 2 without protocol 3 is not one either.
early["version"] = json!("1.7.3");
early["app_api"] = json!(1);
let why = check(&early, "https://ai.example").unwrap_err();
assert!(why.contains("needs a LLeMbas 2.0 server"), "{why}");
// The app door without protocol 3 is not one either.
let mut odd = two_oh();
odd["protocols"] = json!([1, 2]);
assert!(check(&odd, "https://ai.example").is_err());
@@ -585,14 +691,6 @@ mod tests {
assert!(check(&not_ours, "https://ai.example").is_err());
}
#[test]
fn versions_read_their_major() {
assert_eq!(major("2.0.0-beta.1"), Some(2));
assert_eq!(major("v10.1.0"), Some(10));
assert_eq!(major("1.7.3"), Some(1));
assert_eq!(major(""), None);
}
#[test]
fn a_page_reaches_only_the_app_door() {
assert!(checked_path("/chats?kind=agent&limit=50").is_ok());