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

+115 -21
View File
@@ -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,