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:
9 files changed
+383
-164
No files matched your search
+115
-21
@@ -8,9 +8,10 @@
|
||||
//! - **Only that page gets the bridge.** The one capability names the `main` window and no remote
|
||||
//! 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`).
|
||||
//! - **The agent is the CLI's**: `lembas login --json` links this computer, and
|
||||
//! `lembas service run` is the device (`agent.rs`). The app reads the CLI's state and runs its
|
||||
//! commands; it never writes the CLI's files.
|
||||
//! - **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
|
||||
//! --json`, a second approval); `lembas service run` is the device (`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`).
|
||||
|
||||
mod agent;
|
||||
@@ -23,7 +24,7 @@ mod settings;
|
||||
mod watch;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -282,7 +283,10 @@ enum SignIn {
|
||||
Failed { message: String },
|
||||
}
|
||||
|
||||
/// Sign the app in to the server: a device login for the `app` scope, 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
|
||||
/// approval links it: the agent's token goes to the CLI. A computer linked already keeps its
|
||||
/// link; "Link again" is the way to replace it.
|
||||
#[tauri::command]
|
||||
fn sign_in(handle: AppHandle) -> Result<(), String> {
|
||||
let app = handle.state::<App>();
|
||||
@@ -300,7 +304,8 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
||||
announce(&handle);
|
||||
std::thread::spawn(move || {
|
||||
let host = cli::hostname().unwrap_or_default();
|
||||
let result = server::start_sign_in(&d, &host).and_then(|code| {
|
||||
let with_agent = d.one_approval && !cli::linked_to(&d.base);
|
||||
let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| {
|
||||
let _ = handle
|
||||
.opener()
|
||||
.open_url(&code.verification_uri_complete, None::<&str>);
|
||||
@@ -314,21 +319,36 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
||||
*busy = None;
|
||||
}
|
||||
}
|
||||
let mut agent_token = None;
|
||||
let outcome = match result {
|
||||
Ok(account) => match server::save_account(&app.config_dir, &account) {
|
||||
Ok(granted) => match server::save_account(&app.config_dir, &granted.account) {
|
||||
Ok(()) => {
|
||||
*app.account.lock().unwrap() = Some(account);
|
||||
*app.account.lock().unwrap() = Some(granted.account);
|
||||
app.watch.reset();
|
||||
agent_token = granted.agent_token;
|
||||
SignIn::Done
|
||||
}
|
||||
Err(e) => SignIn::Failed {
|
||||
message: format!("The sign-in could not be saved: {e}"),
|
||||
},
|
||||
Err(e) => {
|
||||
// Nobody holds either token now: give both up.
|
||||
server::sign_out(&d.base, &granted.account.token);
|
||||
if let Some(t) = granted.agent_token {
|
||||
server::sign_out(&d.base, &t);
|
||||
}
|
||||
SignIn::Failed {
|
||||
message: format!("The sign-in could not be saved: {e}"),
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(message) => SignIn::Failed { message },
|
||||
};
|
||||
let _ = handle.emit("signin", outcome);
|
||||
announce(&handle);
|
||||
if let Some(token) = agent_token {
|
||||
if let Err(message) = hand_to_cli(&handle, &d.base, token) {
|
||||
let _ = handle.emit("linking", Linking::Failed { message });
|
||||
announce(&handle);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -387,6 +407,22 @@ async fn api(
|
||||
result
|
||||
}
|
||||
|
||||
/// One request to `/api/v2` that answers plain text: the file a `show` card names.
|
||||
#[tauri::command]
|
||||
async fn api_text(handle: AppHandle, path: String) -> Result<String, ApiError> {
|
||||
let Some((app_url, token)) = handle.state::<App>().door() else {
|
||||
return Err(ApiError::new(401, "Not signed in."));
|
||||
};
|
||||
let result =
|
||||
tauri::async_runtime::spawn_blocking(move || server::request_text(&app_url, &token, &path))
|
||||
.await
|
||||
.map_err(|e| ApiError::new(0, e.to_string()))?;
|
||||
if matches!(&result, Err(e) if e.status == 401) {
|
||||
token_refused(&handle);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// A frame of a reply being written, as the page hears it.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct Frame {
|
||||
@@ -662,20 +698,15 @@ enum Linking {
|
||||
}
|
||||
|
||||
/// Link this computer: `lembas login <server> --json`, the approval opened in the system's
|
||||
/// browser (where the person is signed in to the server), then the agent started.
|
||||
/// browser (where the person is signed in to the server), then the agent started. The way to
|
||||
/// link again, and the only way on a server that gives no agent token with the app's sign-in.
|
||||
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||
let app = handle.state::<App>();
|
||||
let Some(server) = app.settings().server else {
|
||||
return Err("Choose a server first.".into());
|
||||
};
|
||||
{
|
||||
let mut busy = app.linking.lock().unwrap();
|
||||
if *busy {
|
||||
return Err("Linking is already in progress.".into());
|
||||
}
|
||||
*busy = true;
|
||||
}
|
||||
let mut child = match Command::new(cli::binary())
|
||||
claim_linking(&app)?;
|
||||
let child = match Command::new(cli::binary())
|
||||
.arg("login")
|
||||
.arg(&server)
|
||||
.arg("--json")
|
||||
@@ -690,6 +721,60 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
||||
}
|
||||
};
|
||||
follow_login(handle, child, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hand the agent's token from the app's own approval to the bundled CLI:
|
||||
/// `lembas login --token-stdin --server <url> --json`. The token goes on the CLI's standard
|
||||
/// input and nowhere else -- not an argument, not the environment, not a file of the app's --
|
||||
/// and the CLI writes its own files, as `lembas login` does. If the CLI does not take it, the
|
||||
/// token is given up on the server, so no live token is left that nobody holds.
|
||||
fn hand_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), String> {
|
||||
let app = handle.state::<App>();
|
||||
if let Err(e) = claim_linking(&app) {
|
||||
server::sign_out(base, &token);
|
||||
return Err(e);
|
||||
}
|
||||
let spawned = Command::new(cli::binary())
|
||||
.args(["login", "--token-stdin", "--server", base, "--json"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
let mut child = match spawned {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
*app.linking.lock().unwrap() = false;
|
||||
server::sign_out(base, &token);
|
||||
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
||||
}
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
// Dropped at the end of this block: the CLI reads to the end of its input.
|
||||
let _ = stdin.write_all(format!("{token}\n").as_bytes());
|
||||
}
|
||||
follow_login(handle, child, Some((base.to_string(), token)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn claim_linking(app: &App) -> Result<(), String> {
|
||||
let mut busy = app.linking.lock().unwrap();
|
||||
if *busy {
|
||||
return Err("Linking is already in progress.".into());
|
||||
}
|
||||
*busy = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a `lembas login --json` to its end: a code to approve (opened in the browser), then
|
||||
/// `done` or `error`; the agent started on success. `revoke`: the server and the token to give
|
||||
/// up if the login fails (the agent's token from the app's approval).
|
||||
fn follow_login(
|
||||
handle: &AppHandle,
|
||||
mut child: std::process::Child,
|
||||
mut revoke: Option<(String, String)>,
|
||||
) {
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut failure: Option<String> = None;
|
||||
@@ -701,6 +786,12 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||
};
|
||||
match ev.get("event").and_then(Value::as_str) {
|
||||
Some("code") => {
|
||||
// A CLI asking for an approval did not take the token it was handed
|
||||
// (one too old for `--token-stdin`): give that token up now, and let
|
||||
// this login go on as the two-step one.
|
||||
if let Some((base, token)) = revoke.take() {
|
||||
server::sign_out(&base, &token);
|
||||
}
|
||||
let field =
|
||||
|k: &str| ev.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let uri = field("verification_uri_complete");
|
||||
@@ -742,12 +833,14 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||
let _ = set_agent_wanted(&handle, true);
|
||||
}
|
||||
Some(message) => {
|
||||
if let Some((base, token)) = revoke {
|
||||
server::sign_out(&base, &token);
|
||||
}
|
||||
let _ = handle.emit("linking", Linking::Failed { message });
|
||||
}
|
||||
}
|
||||
announce(&handle);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- The window, the tray and the menu ------------------------------------------------------------
|
||||
@@ -1160,6 +1253,7 @@ pub fn run() {
|
||||
sign_in_cancel,
|
||||
sign_out,
|
||||
api,
|
||||
api_text,
|
||||
stream_open,
|
||||
stream_close,
|
||||
local_list,
|
||||
|
||||
+173
-75
@@ -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(¬_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());
|
||||
|
||||
Reference in new issue
Block a user