From 6901e3c76f37f678a1179525d09b819d5bc43a7f Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 11:00:25 -0700 Subject: [PATCH 01/11] fix(desktop): make installation progress and connection setup reliable --- CHANGELOG.md | 8 + desktop/src-tauri/capabilities/default.json | 12 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- desktop/src-tauri/src/env.rs | 319 ++++++++- desktop/src-tauri/src/install.rs | 38 +- desktop/src-tauri/src/intelligence.rs | 137 ++++ desktop/src-tauri/src/main.rs | 327 ++++++--- desktop/src-tauri/src/preparation.rs | 32 +- desktop/src-tauri/src/provider.rs | 50 +- desktop/src-tauri/src/pull_metrics.rs | 209 +++++- desktop/src-tauri/src/stack.rs | 327 ++++++++- desktop/src/App.test.tsx | 440 +++++++++++- desktop/src/App.tsx | 648 +++++++++++------- desktop/src/ExternalLink.tsx | 46 ++ desktop/src/Problem.test.tsx | 84 ++- desktop/src/Problem.tsx | 16 + desktop/src/ProviderPicker.test.tsx | 233 ++++++- desktop/src/ProviderPicker.tsx | 186 +++-- desktop/src/styles.css | 117 +++- 19 files changed, 2683 insertions(+), 548 deletions(-) create mode 100644 desktop/src/ExternalLink.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de25da35..f54fd6252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Desktop setup shows progress and works around occupied local ports + +Downloads show transferred bytes and elapsed time, Back preserves saved connections, and repair +stays under Installation options after setup. OpenBot selects and remembers usable local ports, +including when Windows reserves a default port. Setup can create a CopilotKit project and offers +Google Gemini and xAI API-key choices. Unsupported Bun installations are replaced with the pinned +runtime; startup errors retain useful details and provide a configurable setup-help link. + ## 0.0.14 ### A tool cannot be granted for an app this deployment has not added diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index d52d8c6e4..ba8a903c7 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,15 @@ "identifier": "default", "description": "What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.", "windows": ["main"], - "permissions": ["core:event:default"] + "permissions": [ + "core:event:default", + { + "identifier": "opener:allow-open-url", + "allow": [ + { "url": "https://*" }, + { "url": "http://*" }, + { "url": "mailto:*" } + ] + } + ] } diff --git a/desktop/src-tauri/gen/schemas/capabilities.json b/desktop/src-tauri/gen/schemas/capabilities.json index 48a6d5f9e..ab3490333 100644 --- a/desktop/src-tauri/gen/schemas/capabilities.json +++ b/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.","local":true,"windows":["main"],"permissions":["core:event:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.","local":true,"windows":["main"],"permissions":["core:event:default",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"},{"url":"mailto:*"}]}]}} \ No newline at end of file diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index b5b12a6ec..ae5ba6239 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -38,8 +38,8 @@ somebody switches away from the plan. pub const CHATGPT_STORE_FILE: &str = ".langchain/chatgpt-auth.json"; pub const CHATGPT_STORE_INSIDE: &str = "/root/.langchain/chatgpt-auth.json"; -/// Ports the stack publishes. Matched to `docker-compose.yml` defaults so a person who later runs -/// Compose by hand finds the deployment where the documentation says it is. +/// Host ports, persisted in this deployment's .env and reused while they remain available. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Ports { pub app: u16, pub server: u16, @@ -48,6 +48,7 @@ pub struct Ports { pub bot: u16, pub langgraph: u16, pub supervisor: u16, + pub harness: Option, } impl Default for Ports { @@ -60,10 +61,267 @@ impl Default for Ports { bot: 4200, langgraph: 4201, supervisor: 4500, + harness: None, } } } +impl Ports { + pub fn read(root: &Path) -> std::io::Result { + let values = read_already_set( + &root.join(".env"), + &[ + "APP_PORT", + "SERVER_PORT", + "POSTGRES_PORT", + "COMPUTER_PORT", + "BOT_PORT", + "LANGGRAPH_PORT", + "SUPERVISOR_PORT", + "PICKED_HARNESS_HOST_PORT", + ], + )?; + let mut ports = Self::default(); + for (key, port) in [ + ("APP_PORT", &mut ports.app), + ("SERVER_PORT", &mut ports.server), + ("POSTGRES_PORT", &mut ports.postgres), + ("COMPUTER_PORT", &mut ports.computer), + ("BOT_PORT", &mut ports.bot), + ("LANGGRAPH_PORT", &mut ports.langgraph), + ("SUPERVISOR_PORT", &mut ports.supervisor), + ] { + if let Some(value) = values.get(key) { + *port = parse_port(key, value)?; + } + } + ports.harness = values + .get("PICKED_HARNESS_HOST_PORT") + .map(|value| parse_port("PICKED_HARNESS_HOST_PORT", value)) + .transpose()?; + Ok(ports) + } + + pub fn settings(&self) -> BTreeMap { + let mut settings: BTreeMap<_, _> = [ + ("APP_PORT", self.app), + ("SERVER_PORT", self.server), + ("POSTGRES_PORT", self.postgres), + ("COMPUTER_PORT", self.computer), + ("BOT_PORT", self.bot), + ("LANGGRAPH_PORT", self.langgraph), + ("SUPERVISOR_PORT", self.supervisor), + ] + .into_iter() + .map(|(key, port)| (key.to_string(), port.to_string())) + .collect(); + if let Some(port) = self.harness { + settings.insert("PICKED_HARNESS_HOST_PORT".into(), port.to_string()); + } + settings + } + + /// A connect probe misses Windows excluded ports: only a bind proves a port is usable. + /// Hold the probes until every port is chosen so allocations cannot collide with each other. + /// Existing containers from this exact Compose project are reusable, never foreign listeners. + pub fn available( + self, + ours: &std::collections::HashSet, + harness: Option, + ) -> std::io::Result { + let mut held = Vec::new(); + let mut chosen = std::collections::HashSet::new(); + let mut choose = |preferred, container: bool| -> std::io::Result { + if !chosen.contains(&preferred) { + if container && ours.contains(&preferred) { + chosen.insert(preferred); + return Ok(preferred); + } + if let Ok(listeners) = bind_loopbacks(preferred) { + held.extend(listeners); + chosen.insert(preferred); + return Ok(preferred); + } + } + let mut last = None; + for _ in 0..32 { + match bind_loopbacks(0) { + Ok(listeners) => { + let port = listeners[0].local_addr()?.port(); + if chosen.insert(port) { + held.extend(listeners); + return Ok(port); + } + } + Err(error) => last = Some(error), + } + } + Err(last.unwrap_or_else(|| { + std::io::Error::other("could not allocate distinct local ports") + })) + }; + Ok(Self { + app: choose(self.app, false)?, + server: choose(self.server, false)?, + postgres: choose(self.postgres, true)?, + computer: choose(self.computer, true)?, + bot: choose(self.bot, true)?, + langgraph: choose(self.langgraph, true)?, + supervisor: choose(self.supervisor, true)?, + harness: harness + .map(|default| choose(self.harness.unwrap_or(default), true)) + .transpose()?, + }) + } +} + +fn parse_port(key: &str, value: &str) -> std::io::Result { + value + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{key} must be a port from 1 to 65535"), + ) + }) +} + +fn bind_loopbacks(port: u16) -> std::io::Result> { + let ipv4 = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))?; + let port = ipv4.local_addr()?.port(); + let mut held = vec![ipv4]; + match std::net::TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port)) { + Ok(ipv6) => held.push(ipv6), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported + ) => {} + Err(error) => return Err(error), + } + Ok(held) +} + +#[cfg(test)] +mod port_tests { + use super::*; + use std::collections::HashSet; + use std::net::{TcpListener, TcpStream}; + + #[test] + fn occupied_ports_are_replaced_with_distinct_bindable_loopback_ports() { + let foreign = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = foreign.local_addr().unwrap().port(); + let preferred = Ports { + app: port, + server: port, + postgres: port, + computer: port, + bot: port, + langgraph: port, + supervisor: port, + harness: Some(port), + }; + let chosen = preferred.available(&HashSet::new(), Some(port)).unwrap(); + let values: HashSet = chosen + .settings() + .values() + .map(|value| value.parse().unwrap()) + .collect(); + assert_eq!(values.len(), 8); + assert!(!values.contains(&port)); + for value in values { + assert!(bind_loopbacks(value).is_ok(), "port {value} is unavailable"); + } + assert!( + TcpStream::connect(foreign.local_addr().unwrap()).is_ok(), + "foreign listener must remain untouched" + ); + } + + #[test] + fn occupied_ipv6_port_is_not_treated_as_available_ipv4_port() { + let foreign = TcpListener::bind("[::1]:0").unwrap(); + let port = foreign.local_addr().unwrap().port(); + let preferred = Ports { + postgres: port, + ..Ports::default() + }; + let chosen = preferred.available(&HashSet::new(), None).unwrap(); + assert_ne!(chosen.postgres, port); + } + + #[test] + fn saved_ports_survive_reopen_and_only_a_new_conflict_moves() { + let root = crate::test_support::temp_root("saved-local-ports"); + std::fs::create_dir_all(&root).unwrap(); + let chosen = Ports::default() + .available(&HashSet::new(), Some(4206)) + .unwrap(); + std::fs::write( + root.join(".env"), + "CUSTOM=kept\nOPENAI_API_KEY=synthetic-kept\n", + ) + .unwrap(); + write(&root.join(".env"), &chosen.settings(), &BTreeMap::new()).unwrap(); + let reopened = Ports::read(&root).unwrap(); + assert_eq!(reopened, chosen); + assert_eq!( + reopened.available(&HashSet::new(), Some(4206)).unwrap(), + chosen + ); + let foreign = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, chosen.server)).unwrap(); + let changed = reopened.available(&HashSet::new(), Some(4206)).unwrap(); + assert_ne!(changed.server, chosen.server); + assert_eq!( + Ports { + server: chosen.server, + ..changed + }, + chosen + ); + let settings = std::fs::read_to_string(root.join(".env")).unwrap(); + assert!(settings.contains("CUSTOM=kept")); + assert!(settings.contains("OPENAI_API_KEY=synthetic-kept")); + drop(foreign); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn own_published_container_ports_are_reused_but_do_not_authorize_host_port_reuse() { + let owned = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = owned.local_addr().unwrap().port(); + let preferred = Ports { + postgres: port, + server: port, + ..Ports::default() + }; + let chosen = preferred.available(&HashSet::from([port]), None).unwrap(); + assert_eq!(chosen.postgres, port); + assert_ne!(chosen.server, port); + assert_ne!( + preferred.available(&HashSet::new(), None).unwrap().postgres, + port + ); + } + + #[test] + fn invalid_saved_ports_fail_instead_of_probing_an_unrelated_default() { + let root = crate::test_support::temp_root("invalid-local-ports"); + std::fs::create_dir_all(&root).unwrap(); + for value in ["0", "65536", "unknown"] { + std::fs::write(root.join(".env"), format!("SERVER_PORT={value}\n")).unwrap(); + assert_eq!( + Ports::read(&root).unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + } + std::fs::remove_dir_all(root).unwrap(); + } +} + /** The secrets this shell mints rather than being given. @@ -351,6 +609,7 @@ pub fn compose( run_path, remote_agent_id, } => { + let host_port = ports.harness.unwrap_or(*port); env.insert("PICKED_HARNESS_IMAGE".into(), image.clone()); env.insert("PICKED_HARNESS_PORT".into(), port.to_string()); env.insert("PICKED_HARNESS_NAME".into(), name.clone()); @@ -358,11 +617,11 @@ pub fn compose( env.insert( "PICKED_HARNESS_URL".into(), if run_path.is_empty() { - format!("http://127.0.0.1:{port}") + format!("http://127.0.0.1:{host_port}") } else if run_path.starts_with('/') { - format!("http://127.0.0.1:{port}{run_path}") + format!("http://127.0.0.1:{host_port}{run_path}") } else { - format!("http://127.0.0.1:{port}/{run_path}") + format!("http://127.0.0.1:{host_port}/{run_path}") }, ); /* @@ -411,13 +670,14 @@ pub fn compose( format!("http://127.0.0.1:{}", ports.server), ); - env.insert("APP_PORT".into(), ports.app.to_string()); - env.insert("SERVER_PORT".into(), ports.server.to_string()); - env.insert("POSTGRES_PORT".into(), ports.postgres.to_string()); - env.insert("COMPUTER_PORT".into(), ports.computer.to_string()); - env.insert("BOT_PORT".into(), ports.bot.to_string()); - env.insert("LANGGRAPH_PORT".into(), ports.langgraph.to_string()); - env.insert("SUPERVISOR_PORT".into(), ports.supervisor.to_string()); + env.extend(ports.settings()); + env.insert( + "OPENBOT_TOOL_URL".into(), + format!( + "http://host.docker.internal:{}/api/agent-tools/call", + ports.server + ), + ); // The whole deployment is on this machine, so the server must be allowed to talk to it. // @@ -1586,6 +1846,41 @@ HTTPS_PROXY=http://proxy:8080 "http://127.0.0.1:3999", "a setting that is not a secret was carried forward and is now stale" ); + assert_eq!( + value_of(&written, "OPENBOT_TOOL_URL"), + "http://host.docker.internal:3999/api/agent-tools/call", + "container tools must call the selected API port" + ); + } + + #[test] + fn selected_harness_uses_dynamic_host_port_without_changing_image_port() { + let ports = Ports { + harness: Some(52106), + ..Ports::default() + }; + let settings = compose( + &intelligence(), + &Model::default(), + &engine_status(None), + &ports, + &pinned(), + Some(&PickedHarness::Installed { + image: "synthetic:local".into(), + port: 4206, + name: "Synthetic".into(), + mastra: false, + run_path: "/ag-ui".into(), + remote_agent_id: String::new(), + }), + &BTreeMap::new(), + ); + assert_eq!(settings["PICKED_HARNESS_PORT"], "4206"); + assert_eq!(settings["PICKED_HARNESS_HOST_PORT"], "52106"); + assert_eq!( + settings["PICKED_HARNESS_URL"], + "http://127.0.0.1:52106/ag-ui" + ); } } diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 75a1a5fa0..e451d4155 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -215,8 +215,8 @@ fn ensure_bun_with( install: impl FnOnce() -> Result, ) -> Result { match existing { - Some(path) => Ok(path), - None => install(), + Some(path) if verify_bun(&path).is_ok() => Ok(path), + _ => install(), } } @@ -338,7 +338,7 @@ fn extract_bun(archive: &Path, target: &Path, entry: &str) -> Result<(), Problem Ok(()) } -fn verify_bun(binary: &Path) -> Result<(), Problem> { +pub(crate) fn verify_bun(binary: &Path) -> Result<(), Problem> { let output = crate::quiet::command(binary) .arg("--version") .output() @@ -992,11 +992,41 @@ mod tests { #[test] fn an_existing_bun_does_not_trigger_installation() { - let existing = PathBuf::from("existing user runtime/bun.exe"); + let root = temp_root("existing-pinned-bun"); + let existing = runtime_version_fixture(&root, BUN); assert_eq!( ensure_bun_with(Some(existing.clone()), || panic!("already installed")).unwrap(), existing ); + std::fs::remove_dir_all(root).unwrap(); + } + + fn runtime_version_fixture(root: &Path, version: &str) -> PathBuf { + std::fs::create_dir_all(root).unwrap(); + let source = root.join("runtime.rs"); + std::fs::write(&source, format!("fn main() {{ assert_eq!(std::env::args().nth(1).as_deref(), Some(\"--version\")); println!({version:?}); }}")).unwrap(); + let binary = root.join(format!("bun{}", std::env::consts::EXE_SUFFIX)); + crate::test_support::compile_fixture(&source, &binary); + binary + } + + #[test] + fn unsupported_existing_bun_acquires_pinned_runtime_without_changing_user_binary() { + let root = temp_root("unsupported-existing-bun"); + let existing = runtime_version_fixture(&root.join("user"), "1.2.15"); + let pinned = runtime_version_fixture(&root.join("openbot"), BUN); + let before = std::fs::read(&existing).unwrap(); + let chosen = ensure_bun_with(Some(existing.clone()), || Ok(pinned.clone())).unwrap(); + assert_eq!( + chosen, pinned, + "an incompatible user runtime must not be selected" + ); + assert_eq!( + std::fs::read(existing).unwrap(), + before, + "the user's runtime is untouched" + ); + std::fs::remove_dir_all(root).unwrap(); } #[test] diff --git a/desktop/src-tauri/src/intelligence.rs b/desktop/src-tauri/src/intelligence.rs index 9d5dec2c0..3c47911ff 100644 --- a/desktop/src-tauri/src/intelligence.rs +++ b/desktop/src-tauri/src/intelligence.rs @@ -396,6 +396,51 @@ fn list_projects(product: &str) -> Result, crate::problem::Problem> Ok(found) } +fn project_creation_body(name: &str) -> Result { + let name = name.trim(); + // Match the product API's existing POST /api/projects schema. + if name.is_empty() || name.encode_utf16().count() > 255 { + return Err(crate::problem::Problem::plain( + "Enter a project name between 1 and 255 characters.", + )); + } + Ok(serde_json::json!({"name": name})) +} + +pub fn create_project(product: &str, name: &str) -> Result { + create_project_at(PRODUCT_API, product, name) +} + +fn create_project_at( + api: &str, + product: &str, + name: &str, +) -> Result { + let body = project_creation_body(name)?; + let response = client()? + .post(format!("{api}/api/projects")) + .bearer_auth(product) + .json(&body) + .send() + .map_err(|error| { + crate::problem::Problem::with("Your project could not be created.", error.to_string()) + })?; + if !response.status().is_success() { + let status = response.status(); + return Err(crate::problem::Problem::with( + "CopilotKit could not create that project. Check the name and try again.", + format!( + "HTTP {status}\n{}", + without_credentials(&response.text().unwrap_or_default()) + ), + )); + } + let raw = read_json(response, "created project")?; + projects_in(&serde_json::json!([raw])).into_iter().next().filter(|project| !project.id.trim().is_empty()).ok_or_else(|| { + crate::problem::Problem::plain("CopilotKit created a project but did not return its ID. Sign in again to refresh the project list.") + }) +} + /// Whether a payload actually says "no projects" rather than saying something unrecognised. fn looks_genuinely_empty(raw: &serde_json::Value) -> bool { let rows = raw @@ -530,6 +575,98 @@ pub fn projects_in(raw: &serde_json::Value) -> Vec { #[cfg(test)] mod tests { + #[test] + fn project_creation_trims_names_and_rejects_empty_or_overlong_names() { + assert_eq!( + super::project_creation_body(" Desktop test ").unwrap(), + serde_json::json!({"name":"Desktop test"}) + ); + assert!(super::project_creation_body(" \n ").is_err()); + assert!(super::project_creation_body(&"x".repeat(256)).is_err()); + assert!(super::project_creation_body(&"x".repeat(255)).is_ok()); + } + + #[test] + fn creates_project_using_the_signed_in_credential_and_parses_numeric_id() { + let (url, request) = + project_creation_server("201 Created", r#"{"id":42,"name":"Desktop validation"}"#); + let project = + super::create_project_at(&url, "synthetic-product-session", " Desktop validation ") + .unwrap(); + assert_eq!( + project, + super::Project { + id: "42".into(), + name: "Desktop validation".into() + } + ); + let (headers, body) = request.join().unwrap(); + assert!(headers.starts_with("POST /api/projects HTTP/1.1\r\n")); + assert!(headers + .to_lowercase() + .contains("authorization: bearer synthetic-product-session\r\n")); + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + serde_json::json!({"name":"Desktop validation"}) + ); + } + + #[test] + fn project_creation_preserves_api_failure_and_rejects_missing_id() { + for (status, body) in [ + ("403 Forbidden", r#"{"error":"not allowed"}"#), + ("201 Created", r#"{"name":"No ID"}"#), + ] { + let (url, request) = project_creation_server(status, body); + let error = + super::create_project_at(&url, "synthetic-product-session", "Desktop validation") + .unwrap_err(); + if status.starts_with("403") { + assert!(error.detail.unwrap().contains("HTTP 403")); + } else { + assert!(error.said.contains("did not return its ID")); + } + request.join().unwrap(); + } + } + + fn project_creation_server( + status: &'static str, + body: &'static str, + ) -> (String, std::thread::JoinHandle<(String, Vec)>) { + use std::io::{BufRead, Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let request = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut reader = std::io::BufReader::new(stream); + let mut headers = String::new(); + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + headers.push_str(&line); + if line == "\r\n" { + break; + } + } + let length: usize = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().unwrap()) + }) + .unwrap(); + let mut request_body = vec![0; length]; + reader.read_exact(&mut request_body).unwrap(); + write!(reader.get_mut(), "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + (headers, request_body) + }); + (url, request) + } /// The field name that broke a whole sign-in, read off the real response. #[test] fn the_session_is_read_from_the_name_the_endpoint_uses() { diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index a2858d4c2..f1e612ae5 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -191,6 +191,9 @@ struct Progress { step: String, ok: bool, detail: String, + running: bool, + #[serde(rename = "downloadBytes", skip_serializing_if = "Option::is_none")] + download_bytes: Option, } #[derive(Serialize)] @@ -302,6 +305,26 @@ fn report( step: step.into(), ok, detail: detail.into(), + running: false, + download_bytes: None, + }, + ); +} + +fn report_running( + app: &tauri::AppHandle, + step: &str, + detail: impl Into, + download_bytes: Option, +) { + let _ = app.emit( + "setup:progress", + Progress { + step: step.into(), + ok: true, + detail: detail.into(), + running: true, + download_bytes, }, ); } @@ -432,14 +455,27 @@ async fn prepare_installation( if let Some(problem) = stack::deployment_problem(&root) { return Err(Problem::from(problem)); } - report( + report_running( &handle, "dependencies", - true, "Preparing the app's dependencies.", + None, ); preparation::install_dependencies_if_needed(&root, || { - install::ensure_bun(&root, which_bun()) + report_running( + &handle, + "dependencies", + "Preparing the local runtime.", + None, + ); + let bun = install::ensure_bun(&root, which_bun())?; + report_running( + &handle, + "dependencies", + "Installing the app's packages.", + None, + ); + Ok(bun) })?; report( &handle, @@ -447,6 +483,12 @@ async fn prepare_installation( true, "The app's dependencies are installed.", ); + report_running( + &handle, + "images", + "Checking which local software to download.", + None, + ); let settings = preparation::image_settings(&root, picked.as_ref())?; let installed = picked .as_ref() @@ -463,20 +505,30 @@ async fn prepare_installation( images.dedup(); for (index, image) in images.iter().enumerate() { attempt.require_current()?; - report( - &handle, - "images", - true, - format!( - "Downloading local software ({}/{}).", - index + 1, - images.len() - ), + let detail = format!( + "Downloading local software ({}/{}).", + index + 1, + images.len() ); - pull_metrics::pull_image(&address, image, |metrics| { - desktop_telemetry::pull_completed(&handle, metrics) - })?; + report_running(&handle, "images", &detail, None); + pull_metrics::pull_image( + &address, + image, + |bytes| report_running(&handle, "images", &detail, Some(bytes)), + |metrics| desktop_telemetry::pull_completed(&handle, metrics), + )?; } + report( + &handle, + "images", + true, + format!( + "Local software is downloaded ({}/{}).", + images.len(), + images.len() + ), + ); + report_running(&handle, "installation", "Finishing installation.", None); attempt.require_current()?; preparation::complete(&root, harness.as_ref(), images, &address)?; preparation::save_selected_root( @@ -510,6 +562,7 @@ async fn prepare_installation( /// Reported step by step rather than as one result, because these take minutes and a window with /// nothing moving in it reads as a hang. async fn engine_ready(app: &tauri::AppHandle) -> Result { + report_running(app, "engine", "Checking the software OpenBot needs.", None); let found = engine::detect(); desktop_telemetry::observe_engine(app, &found); let root = stack::default_root(); @@ -567,11 +620,11 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result Result Result Result( app: &tauri::AppHandle, root: &Path, ) -> Result<(), Problem> { + report_running(app, "deployment", "Checking the OpenBot release.", None); // Both release discovery and downloading use blocking HTTP. Keeping them in a blocking task // avoids dropping reqwest's runtime inside this async context. let target = root.to_path_buf(); @@ -671,7 +735,12 @@ async fn deployment_ready( let version = tauri::async_runtime::spawn_blocking(move || { let version = deployment_release::resolve_version(&target)?; if deployment::needs_fetch(&target, &version) { - report(&handle, "deployment", true, format!("fetching {version}")); + report_running( + &handle, + "deployment", + format!("Downloading OpenBot {version}."), + None, + ); deployment::fetch(&target, &version)?; } Ok::<_, String>(version) @@ -1071,7 +1140,7 @@ async fn start_stack_inner( ) })?; - let (logs, bun, mut secrets) = { + let (logs, bun, mut secrets, ports) = { let _startup = attempt.lock_current()?; // Belt and braces: a fetch that reported success and left something out is still not a @@ -1121,6 +1190,30 @@ async fn start_stack_inner( stack::postgres_volume_exists(&found, &root, &existing_secrets) })?; + // Only this deployment's recorded hosts are reclaimed; its existing containers are reusable. + let previous_ports = openbot_env::Ports::read(&root).map_err(|error| { + Problem::with("OpenBot could not read its local ports.", error.to_string()) + })?; + let reclaimed = cleanup_before_start(&app, &attempt, &root, stack::stop_processes_under)?; + if reclaimed > 0 { + stack::wait_for_ports_to_clear( + &[previous_ports.server, previous_ports.app], + std::time::Duration::from_secs(5), + ); + } + let ours = stack::ports_we_already_publish(&found, &root); + let ports = previous_ports + .available( + &ours, + picked.as_ref().and_then(|picked| picked.installed_port()), + ) + .map_err(|error| { + Problem::with( + "OpenBot could not find available local ports. Try Start again.", + error.to_string(), + ) + })?; + let mut settings = openbot_env::compose( &openbot_env::Intelligence { api_url, @@ -1131,7 +1224,7 @@ async fn start_stack_inner( credential: credential.clone(), }, &status, - &openbot_env::Ports::default(), + &ports, &deployment::image_variables(&root)?, picked.as_ref(), // What a previous start of this deployment already minted. Without it every Start writes a @@ -1173,38 +1266,19 @@ async fn start_stack_inner( &credential, )?; report(&app, "env", true, "settings written, credentials stored"); + // Compose gives inherited environment precedence over .env. Pin this run's chosen ports. + secrets.extend(ports.settings()); + for key in ["PICKED_HARNESS_PORT", "OPENBOT_TOOL_URL"] { + if let Some(value) = settings.get(key) { + secrets.insert(key.into(), value.clone()); + } + } // Set before Bun imports the runtime, and retained for supervised restarts. secrets.extend(desktop_telemetry::runtime_env(&app)); // Installation already verified these images. Start only raises the local containers; // its no-pull policy sends missing assets back to the installation step. - report(&app, "services", true, "starting installed containers"); - /* - * The harness's port, before the containers rather than after. - * - * The check below covers the host processes, and it runs too late for this: a port already held - * makes `compose up` fail inside the daemon, and what reaches the person is - * "Bind for 0.0.0.0:4202 failed: port is already allocated". Every harness has a fixed port of - * its own, so this is not a rare case — anything else using it, including a previous run's - * container, produces that sentence. - */ - /* - * Our own containers are not somebody else on the port. - * - * A start that failed after the containers went up left them running, and the next press of - * Start refused because of them, naming a port the person never chose and cannot find. See - * `ports_we_already_publish`. `compose up` reuses what is already there, so the only thing this - * check is for is a stranger on the port. - */ - let ours = stack::ports_we_already_publish(&found, &root); - if let Some(port) = picked.as_ref().and_then(|picked| picked.installed_port()) { - if let Some(problem) = - stack::port_already_taken_except(&[("Bot you picked", port)], &ours) - { - report(&app, "ports", false, problem.clone()); - return Err(problem.into()); - } - } + report_running(&app, "services", "starting installed containers", None); // Only an installed harness needs the local service; a BYO endpoint is already running elsewhere. let installed_harness = picked @@ -1229,7 +1303,7 @@ async fn start_stack_inner( stack::up(&found, &root, installed_harness, bundled_bots, &secrets)?; report(&app, "services", true, "containers up"); - report(&app, "migrate", true, "applying migrations"); + report_running(&app, "migrate", "applying migrations", None); stack::migrate(&found, &root, &secrets)?; report(&app, "migrate", true, "migrations applied"); @@ -1240,27 +1314,7 @@ async fn start_stack_inner( report(&app, "services", false, detail); })?; - /* - * Reclaim this deployment's own host processes before deciding the ports are taken. - * - * Same failure as the containers above, by a different route: a start that got as far as - * spawning the server and then stopped left it running, and the next attempt refused because - * port 3001 was held. By its own server. These are found by working directory, so anything this - * stops belongs to this deployment and to no other. - */ - let reclaimed = cleanup_before_start(&app, &attempt, &root, stack::stop_processes_under)?; - // Before spawning: if these are still held, whatever answers later is not ours. - let ports = openbot_env::Ports::default(); - if reclaimed > 0 { - // A kill is not instant and the check is. Without this the socket of a process this run - // just stopped reads as somebody else's, and the refusal names a process that no longer - // exists. See `wait_for_ports_to_clear`. - stack::wait_for_ports_to_clear( - &[ports.server, ports.app], - std::time::Duration::from_secs(5), - ); - } if let Some(problem) = stack::port_already_taken(&[("API server", ports.server), ("app", ports.app)]) { @@ -1269,7 +1323,7 @@ async fn start_stack_inner( } let logs = root.join(".logs"); - (logs, bun, secrets) + (logs, bun, secrets, ports) }; // Never persisted or passed to Compose. Only the server process receives this credential; @@ -1292,15 +1346,15 @@ async fn start_stack_inner( started, &logs_for_wait, &stack::Ready { - api: openbot_env::Ports::default().server, - app: openbot_env::Ports::default().app, + api: ports.server, + app: ports.app, }, std::time::Duration::from_secs(180), ) }, ) .await - .inspect_err(|problem| report(&app, "answering", false, problem_detail(problem.clone())))?; + .inspect_err(|problem| report(&app, "answering", false, problem.said.clone()))?; // Stop must not finish between accepting readiness and reporting a successful Start. let _startup = attempt.lock_current()?; // Only a stack that answered successfully acquires a restart policy. @@ -1312,7 +1366,7 @@ async fn start_stack_inner( .map(|owned| owned.address.clone()) .ok_or_else(|| Problem::plain("The local container runtime is unavailable."))?; let config = host_access::HostAccessConfig::new( - format!("http://127.0.0.1:{}", openbot_env::Ports::default().server), + format!("http://127.0.0.1:{}", ports.server), host_token, address, deployment::reference(&root, "agent-computer")?, @@ -1332,7 +1386,7 @@ async fn start_stack_inner( ) })?; preparation::save_selected_root(&config, &root)?; - supervise_host_processes(app.clone(), root, logs, bun, secrets, generation); + supervise_host_processes(app.clone(), root, logs, bun, secrets, generation, ports); report(&app, "answering", true, "the API and the app are answering"); Ok(()) @@ -1680,7 +1734,16 @@ where .lock() .unwrap_or_else(std::sync::PoisonError::into_inner), ); - finish_host_start(attempt, root, started, outcome) + let readiness_failure = outcome.as_ref().err().cloned(); + finish_host_start(attempt, root, started, outcome).map_err(|problem| { + // Preserve cancellation and lifecycle failures. Only the actual readiness error gets + // the startup headline; cleanup details remain attached and are redacted with it. + if readiness_failure.as_deref() == Some(problem.said.as_str()) { + stack::startup_problem(problem, secrets) + } else { + problem + } + }) } fn finish_host_start( @@ -2090,12 +2153,22 @@ where #[tauri::command] async fn show_openbot(app: tauri::AppHandle) -> Result<(), Problem> { tauri::async_runtime::spawn_blocking(move || { - show_openbot_on(app, &openbot_env::Ports::default()) + let ports = ports_for_shell(&app)?; + show_openbot_on(app, &ports) }) .await .map_err(|error| Problem::with("OpenBot could not open its window.", error.to_string()))? } +fn ports_for_shell( + app: &tauri::AppHandle, +) -> Result { + let root = cleanup_root(&app.state::(), &stack::default_root()); + openbot_env::Ports::read(&root).map_err(|error| { + Problem::with("OpenBot could not read its local ports.", error.to_string()) + }) +} + fn show_openbot_on( app: tauri::AppHandle, ports: &openbot_env::Ports, @@ -2307,7 +2380,7 @@ fn already_running(app: tauri::AppHandle, root: String) -> let shell = app.state::(); let _startup = shell.startup.lock().unwrap(); !recovery_required_or_pending_quit_notice(&shell, &root) - && already_running_at(&root, &openbot_env::Ports::default()) + && openbot_env::Ports::read(&root).is_ok_and(|ports| already_running_at(&root, &ports)) } fn already_running_at(root: &Path, ports: &openbot_env::Ports) -> bool { @@ -2758,6 +2831,26 @@ async fn finish_intelligence_sign_in( Ok(projects) } +/// Create a project using the account already signed in, without exposing its credential to the UI. +#[tauri::command] +async fn create_intelligence_project( + app: tauri::AppHandle, + name: String, +) -> Result { + let credential = app + .state::() + .intelligence_credential + .lock() + .unwrap() + .clone() + .ok_or_else(|| Problem::plain("Sign in to CopilotKit first."))?; + tauri::async_runtime::spawn_blocking(move || { + openbot_desktop_lib::intelligence::create_project(&credential, &name) + }) + .await + .map_err(|error| Problem::with("Your project could not be created.", error.to_string()))? +} + /// Create a key for the project somebody chose, and hand it back for the field. #[tauri::command] async fn intelligence_key_for( @@ -2919,6 +3012,10 @@ fn publish_connection_failure( Ok(true) } +#[allow( + clippy::too_many_arguments, + reason = "Retain selected ports alongside the supervised run's identity and credentials." +)] fn supervise_host_processes( app: tauri::AppHandle, root: PathBuf, @@ -2928,6 +3025,7 @@ fn supervise_host_processes( // already wrong, and a credential prompt at that moment is the worst time to ask for one. secrets: stack::Secrets, generation: u64, + ports: openbot_env::Ports, ) -> std::thread::JoinHandle<()> { std::thread::spawn(move || { eprintln!( @@ -2964,11 +3062,7 @@ fn supervise_host_processes( &connection_client, secrets.get("OPENBOT_DESKTOP_HOST_TOKEN"), ) { - match desktop_connection::poll( - client, - openbot_env::Ports::default().server, - token, - ) { + match desktop_connection::poll(client, ports.server, token) { Ok(Some(connection)) => { match publish_connection_failure(&app, &root, generation, connection) { Ok(published) => connection_notice_sent = published, @@ -3126,7 +3220,15 @@ where /// Used by the tray and by a second launch, both of which happen at moments when the caller has no /// idea which of the two the person should be looking at. fn show_whichever_applies(app: &tauri::AppHandle) { - restore_window_on(app, &openbot_env::Ports::default()); + match ports_for_shell(app) { + Ok(ports) => restore_window_on(app, &ports), + Err(problem) => { + *app.state::().last_failure.lock().unwrap() = Some(problem); + if let Err(error) = show_setup_and_focus(app.clone()) { + eprintln!("{error}"); + } + } + } } fn restore_window_on(app: &tauri::AppHandle, ports: &openbot_env::Ports) { @@ -3294,6 +3396,7 @@ fn main() { begin_intelligence_sign_in, finish_intelligence_sign_in, intelligence_key_for, + create_intelligence_project, begin_organization_sign_in, finish_organization_sign_in, cancel_organization_sign_in, @@ -7616,6 +7719,7 @@ fn main() { fixture.host.bun.clone(), stack::Secrets::new(), generation, + openbot_env::Ports::default(), )); // The actual watcher exhausts its actual budget and backoffs after this role fails. std::fs::write(fixture.host.root.join(failed_role).join("fail"), "").unwrap(); @@ -7814,6 +7918,7 @@ fn main() { fixture.host.bun.clone(), stack::Secrets::new(), generation, + openbot_env::Ports::default(), )); std::fs::write(fixture.host.root.join("worker/fail"), "").unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(70); @@ -7937,6 +8042,7 @@ fn main() { fixture.host.bun.clone(), stack::Secrets::new(), generation, + openbot_env::Ports::default(), )); let original = { let mut children = shell.children.lock().unwrap(); @@ -8262,7 +8368,22 @@ fn main() { "wait-panics" => "the wait did not run:", _ => unreachable!(), }; - assert!(problem.said.starts_with(expected), "{problem:?}"); + if matches!(mode, "wait-fails" | "wait-panics") { + assert!( + problem.said.contains("could not finish starting"), + "{problem:?}" + ); + assert!( + problem + .detail + .as_deref() + .unwrap_or_default() + .starts_with(expected), + "{problem:?}" + ); + } else { + assert!(problem.said.starts_with(expected), "{problem:?}"); + } if mode == "cleanup-refuses" { assert_eq!(alive.len(), 1); assert_eq!(shell.children.lock().unwrap().len(), 1); @@ -9290,6 +9411,40 @@ fn main() { assert_eq!(window.url().unwrap().as_str(), current); } + #[test] + fn reopening_uses_persisted_ports_and_still_requires_deployment_ownership() { + let f = RestoreFixture::new(); + for root in [&f.owned, &f.selected] { + openbot_env::write( + &root.join(".env"), + &f.ports.settings(), + &std::collections::BTreeMap::new(), + ) + .unwrap(); + } + let app = f.app(&f.owned, "tauri://localhost/"); + assert_eq!(ports_for_shell(app.handle()).unwrap(), f.ports); + assert!(already_running( + app.handle().clone(), + f.owned.to_string_lossy().into_owned() + )); + tauri::async_runtime::block_on(show_openbot(app.handle().clone())).unwrap(); + assert_eq!( + app.get_webview_window("main") + .unwrap() + .url() + .unwrap() + .port(), + Some(f.ports.app) + ); + let other = f.app(&f.selected, "tauri://localhost/"); + assert!(!already_running( + other.handle().clone(), + f.selected.to_string_lossy().into_owned() + )); + assert!(tauri::async_runtime::block_on(show_openbot(other.handle().clone())).is_err()); + } + #[test] fn restore_window_refuses_answering_other_deployment_and_shows_recorded_setup() { let f = RestoreFixture::new(); diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index d7ca0bce2..787d4ac5e 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::{deployment, engine::Address, harness::HarnessChoice, problem::Problem, quiet, stack}; +use crate::{deployment, engine::Address, harness::HarnessChoice, problem::Problem, stack}; pub const REQUIRED: &str = "Finish installing OpenBot's local software before signing in or starting. Return to Install and try again."; pub const FILE: &str = ".openbot-prepared.json"; @@ -228,15 +228,8 @@ pub fn dependencies_ready(root: &Path) -> Result { )); } // This executes only the installed runtime's version probe; it cannot install packages. - if !quiet::command(&record.bun) - .arg("--version") - .output() - .map_err(|e| required(e.to_string()))? - .status - .success() - { - return Err(required("The installed app runtime is unavailable.")); - } + crate::install::verify_bun(&record.bun) + .map_err(|problem| required(problem.detail.unwrap_or(problem.said)))?; Ok(record.bun) } @@ -383,7 +376,11 @@ mod tests { use std::io::Write; fn main() { let args: Vec = std::env::args().skip(1).collect(); - if args == ["--version"] { println!("1.3.14"); return; } + if args == ["--version"] { + let version = std::env::current_exe().unwrap().parent().unwrap().join("runtime-version"); + println!("{}", std::fs::read_to_string(version).unwrap_or_else(|_| "1.3.14".into())); + return; + } assert_eq!(args, ["install", "--frozen-lockfile", "--ignore-scripts"]); let previous = std::fs::read_to_string("installs").unwrap_or_default(); writeln!(std::fs::OpenOptions::new().create(true).append(true).open("installs").unwrap(), "install").unwrap(); @@ -452,6 +449,19 @@ fn main() { ); } + #[test] + fn cached_dependencies_with_unsupported_runtime_require_installation_before_start() { + let f = Fixture::new(); + f.dependencies(); + std::fs::write(f.root.join("runtime-version"), "1.2.15").unwrap(); + let problem = dependencies_ready(&f.root).expect_err("a cached older runtime is not ready"); + assert!(problem.said.contains("Finish installing")); + assert!(problem + .detail + .unwrap_or_default() + .contains(crate::install::BUN)); + } + #[test] fn failed_image_preparation_cannot_publish_completion() { let f = Fixture::new(); diff --git a/desktop/src-tauri/src/provider.rs b/desktop/src-tauri/src/provider.rs index 879da8c30..48d734df2 100644 --- a/desktop/src-tauri/src/provider.rs +++ b/desktop/src-tauri/src/provider.rs @@ -1,9 +1,7 @@ //! The model provider screen's list, as data. //! -//! Two providers are first-class and everything else is one row. That is not a shortlist waiting to -//! be grown: it is the shape, and growing it is how this screen turns into a directory nobody -//! maintains. Most people have a plan with one of two companies; everybody else has something that -//! speaks the OpenAI wire format, because at this point everything does. +//! OpenAI and Anthropic offer plan sign-in alongside API keys. Google and xAI use named endpoint +//! presets with API keys; their official addresses are supplied by the frontend. //! //! The row that asks for a URL is the last one, and it is the only one that asks. Keeping it there //! is what keeps a base URL off the main path, which the audience rule at the top of the build doc @@ -88,10 +86,26 @@ pub fn catalogue() -> Vec { .into(), }), }, + Provider { + id: "google".into(), + name: "Google Gemini".into(), + summary: "Use Gemini with a Google AI Studio API key.".into(), + logins: vec![Login::Endpoint], + mark: None, + caution: None, + }, + Provider { + id: "xai".into(), + name: "xAI".into(), + summary: "Use Grok with an xAI API key.".into(), + logins: vec![Login::Endpoint], + mark: None, + caution: None, + }, Provider { id: "openai-compatible".into(), name: "Any OpenAI-compatible endpoint".into(), - summary: "Azure, Bedrock, Mistral, DeepSeek, xAI, Ollama, vLLM or your own.".into(), + summary: "Azure, Bedrock, Mistral, DeepSeek, Ollama, vLLM or your own.".into(), logins: vec![Login::Endpoint], // Deliberately unmarked: it stands for every provider rather than one, so any single // vendor's logo here would be a lie about what the row does. @@ -105,16 +119,21 @@ pub fn catalogue() -> Vec { mod tests { use super::*; - /// The audience rule, as a test. A base URL is a developer's tool, and exactly one row may ask - /// for one; if a second ever does, the main path has grown a terminal-shaped step. + /// Named endpoint presets use API keys and never claim support for a subscription sign-in. #[test] - fn only_one_row_asks_for_a_url() { + fn endpoint_providers_do_not_offer_plan_sign_in() { let asking: Vec = catalogue() .into_iter() .filter(|p| p.logins.contains(&Login::Endpoint)) .map(|p| p.id) .collect(); - assert_eq!(asking, vec!["openai-compatible".to_string()]); + assert_eq!(asking, vec!["google", "xai", "openai-compatible"]); + for provider in catalogue() + .into_iter() + .filter(|provider| provider.logins.contains(&Login::Endpoint)) + { + assert_eq!(provider.logins, vec![Login::Endpoint]); + } } /// Where a plan can stand in for a key, it is the default. Reordering these is a product change @@ -133,14 +152,15 @@ mod tests { } } - /// Two first-class providers and one escape hatch. Growing this list is how the screen becomes - /// a directory, so it fails here rather than in review. + /// Named providers stay ahead of the custom endpoint escape hatch. #[test] - fn two_named_providers_and_one_way_in_for_everything_else() { + fn four_named_providers_and_one_custom_endpoint() { let rows = catalogue(); - assert_eq!(rows.len(), 3, "the provider list grew"); - assert_eq!(rows[0].id, "openai"); - assert_eq!(rows[1].id, "anthropic"); + let ids: Vec<&str> = rows.iter().map(|provider| provider.id.as_str()).collect(); + assert_eq!( + ids, + vec!["openai", "anthropic", "google", "xai", "openai-compatible"] + ); } /// Every row is readable without recognising a logo. diff --git a/desktop/src-tauri/src/pull_metrics.rs b/desktop/src-tauri/src/pull_metrics.rs index a74087445..0b38aff47 100644 --- a/desktop/src-tauri/src/pull_metrics.rs +++ b/desktop/src-tauri/src/pull_metrics.rs @@ -1,8 +1,9 @@ //! Metrics for an explicit image pull, before containers or provider sign-in start. use std::collections::HashMap; -use std::io::Write; -use std::process::{Command, Stdio}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Command, Output, Stdio}; +use std::sync::mpsc::{sync_channel, SyncSender}; use std::time::Instant; use serde::Deserialize; @@ -76,38 +77,49 @@ struct Progress { current: Option, } +#[derive(Default)] +struct DownloadBytes(HashMap); + +impl DownloadBytes { + fn observe(&mut self, progress: Progress) { + if progress.text == "Downloading" + && !progress.id.is_empty() + && !progress.parent_id.as_deref().unwrap_or_default().is_empty() + { + if let Some(current) = progress.current { + self.0 + .entry(progress.id) + .and_modify(|maximum| *maximum = (*maximum).max(current)) + .or_insert(current); + } + } + } + + fn total(&self) -> Option { + if self.0.is_empty() { + return None; + } + self.0 + .values() + .try_fold(0_u64, |sum, current| sum.checked_add(*current)) + } +} + /// Compose's JSON writer carries Docker's byte counters as integers: /// https://github.com/docker/compose/blob/v2.39.2/pkg/compose/pull.go#L391-L440 /// The same layer can appear beneath multiple services. Keep its maximum download counter, /// regardless of parent, and never add extraction progress or the advertised total size. fn download_bytes(stdout: &[u8], stderr: &[u8]) -> Option { - let mut layers: HashMap = HashMap::new(); + let mut layers = DownloadBytes::default(); for output in [stdout, stderr] { for line in output.split(|byte| *byte == b'\n') { let Ok(progress) = serde_json::from_slice::(line) else { continue; }; - if progress.text != "Downloading" - || progress.id.is_empty() - || progress.parent_id.as_deref().unwrap_or_default().is_empty() - { - continue; - } - if let Some(current) = progress.current { - layers - .entry(progress.id) - .and_modify(|maximum| *maximum = (*maximum).max(current)) - .or_insert(current); - } + layers.observe(progress); } } - if layers.is_empty() { - None - } else { - layers - .values() - .try_fold(0_u64, |sum, current| sum.checked_add(*current)) - } + layers.total() } /// Pull a provider sign-in image without starting its CLI or creating a container. @@ -116,6 +128,7 @@ fn download_bytes(stdout: &[u8], stderr: &[u8]) -> Option { pub fn pull_image( engine: &Address, image: &str, + on_progress: impl FnMut(u64), on_complete: impl FnOnce(PullMetrics), ) -> Result<(), Problem> { if crate::preparation::image_present(engine, image)? { @@ -124,7 +137,7 @@ pub fn pull_image( let Some(json_progress) = compose_pull_progress(engine) else { let mut command = engine.command(); command.args(["pull", image]); - return run(command, None, false, on_complete); + return run_with_progress(command, None, false, on_progress, on_complete); }; let mut command = engine.command(); command.arg("compose"); @@ -141,10 +154,11 @@ pub fn pull_image( "missing", ]); let project = serde_json::json!({"services": {"image": {"image": image}}}).to_string(); - run( + run_with_progress( command, Some(project.as_bytes()), json_progress, + on_progress, on_complete, ) } @@ -152,9 +166,19 @@ pub fn pull_image( /// Complete one explicit pull and report its outcome before the caller can start containers. /// A failed pull is returned as the same two-part Problem used by the existing startup path. pub(crate) fn run( + command: Command, + input: Option<&[u8]>, + json_progress: bool, + on_complete: impl FnOnce(PullMetrics), +) -> Result<(), Problem> { + run_with_progress(command, input, json_progress, |_| {}, on_complete) +} + +fn run_with_progress( mut command: Command, input: Option<&[u8]>, json_progress: bool, + mut on_progress: impl FnMut(u64), on_complete: impl FnOnce(PullMetrics), ) -> Result<(), Problem> { command @@ -180,7 +204,41 @@ pub(crate) fn run( return Err(error); } } - child.wait_with_output() + // Drain both pipes concurrently, just as wait_with_output does, while forwarding only + // structured byte counters. Raw engine output stays in the existing error diagnostics. + let stdout = child.stdout.take().expect("piped pull stdout"); + let stderr = child.stderr.take().expect("piped pull stderr"); + std::thread::scope(|scope| { + let (sender, receiver) = sync_channel(64); + let stdout_sender = sender.clone(); + let out = scope.spawn(move || read_output(stdout, json_progress, stdout_sender)); + let err = scope.spawn(move || read_output(stderr, json_progress, sender)); + let mut layers = DownloadBytes::default(); + let mut last = None; + for progress in receiver { + layers.observe(progress); + let total = layers.total(); + if total != last { + if let Some(bytes) = total { + on_progress(bytes); + } + last = total; + } + } + let stdout = out + .join() + .map_err(|_| std::io::Error::other("pull stdout reader panicked")); + let stderr = err + .join() + .map_err(|_| std::io::Error::other("pull stderr reader panicked")); + // Always reap the child, including when reading a pipe failed. + let status = child.wait()?; + Ok(Output { + status, + stdout: stdout??, + stderr: stderr??, + }) + }) })(); let duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; let success = output.as_ref().is_ok_and(|output| output.status.success()); @@ -215,10 +273,85 @@ pub(crate) fn run( Err(Problem::with(crate::problem::said_about(&raw), raw)) } +fn read_output( + pipe: impl Read, + json_progress: bool, + sender: SyncSender, +) -> std::io::Result> { + let mut reader = BufReader::new(pipe); + let mut output = Vec::new(); + let mut line = Vec::new(); + loop { + line.clear(); + if reader.read_until(b'\n', &mut line)? == 0 { + return Ok(output); + } + output.extend_from_slice(&line); + if json_progress { + if let Ok(progress) = serde_json::from_slice(&line) { + sender + .send(progress) + .map_err(|_| std::io::Error::other("pull progress receiver closed"))?; + } + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn streams_download_bytes_before_the_child_exits() { + let root = crate::test_support::temp_root("live-pull-progress"); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("progress.rs"); + let binary = root.join(format!("progress{}", std::env::consts::EXE_SUFFIX)); + let acknowledgement = root.join("progress-observed"); + std::fs::write( + &source, + r#" +use std::io::Write; +fn main() { + let acknowledgement = std::env::args().nth(1).unwrap(); + println!("{{\"id\":\"a\",\"parent_id\":\"image\",\"text\":\"Downloading\",\"current\":7}}"); + std::io::stdout().flush().unwrap(); + eprintln!("{{\"id\":\"b\",\"parent_id\":\"image\",\"text\":\"Downloading\",\"current\":5}}"); + for _ in 0..300 { + if std::path::Path::new(&acknowledgement).exists() { return; } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + eprintln!("progress was not delivered while the pull was running"); + std::process::exit(71); +} +"#, + ) + .unwrap(); + crate::test_support::compile_fixture(&source, &binary); + let mut command = crate::quiet::command(&binary); + command.arg(&acknowledgement); + let mut updates = Vec::new(); + let mut metrics = Vec::new(); + let result = run_with_progress( + command, + None, + true, + |bytes| { + updates.push(bytes); + if bytes == 12 { + std::fs::write(&acknowledgement, "received").unwrap(); + } + }, + |metric| metrics.push(metric), + ); + std::fs::remove_dir_all(root).unwrap(); + assert!(result.is_ok(), "{result:?}"); + assert_eq!(updates.last(), Some(&12)); + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].bytes, Some(12)); + assert_eq!(metrics[0].outcome, Outcome::Success); + } + #[test] fn older_compose_explicitly_downloads_missing_images_and_reuses_cached_images() { if crate::test_support::isolated_process("pull_metrics::tests::older_compose_explicitly_downloads_missing_images_and_reuses_cached_images") { return; } @@ -246,16 +379,26 @@ fn main() { std::env::set_var("PATH", &root); let address = Address::new(crate::engine::Engine::Docker, None); let mut outcomes = Vec::new(); - pull_image(&address, "synthetic-image", |metric| { - outcomes.push(metric.outcome) - }) + pull_image( + &address, + "synthetic-image", + |_| {}, + |metric| outcomes.push(metric.outcome), + ) .unwrap(); - pull_image(&address, "synthetic-image", |_| { - panic!("cached image must not be downloaded again") - }) + pull_image( + &address, + "synthetic-image", + |_| {}, + |_| panic!("cached image must not be downloaded again"), + ) .unwrap(); - assert!(pull_image(&address, "missing-image", |metric| outcomes - .push(metric.outcome)) + assert!(pull_image( + &address, + "missing-image", + |_| {}, + |metric| outcomes.push(metric.outcome) + ) .is_err()); assert_eq!(outcomes, [Outcome::Success, Outcome::Failure]); std::fs::remove_dir_all(root).unwrap(); diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index b51371a8c..8a732fc71 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -256,6 +256,47 @@ fn postgres_volume_name(configuration: &[u8]) -> Result { const MACOS_PODMAN_PORTS_FILE: &str = ".openbot-macos-podman.yml"; const MACOS_PODMAN_PORTS: &str = include_str!("macos-podman-ports.yml"); +const HARNESS_PORT_FILE: &str = ".openbot-harness-port.yml"; + +fn harness_port_overlay(root: &Path, ipv4_only: bool) -> Result, Problem> { + let values = crate::env::read_already_set( + &root.join(".env"), + &["PICKED_HARNESS_HOST_PORT", "PICKED_HARNESS_PORT"], + ) + .map_err(|error| { + Problem::with( + "OpenBot could not read the Bot's local port.", + error.to_string(), + ) + })?; + let Some(host) = values.get("PICKED_HARNESS_HOST_PORT") else { + return Ok(None); + }; + let parse = |value: &str| { + value + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| { + Problem::plain("The Bot's local port setting is invalid. Try Start again.") + }) + }; + let host = parse(host)?; + let target = parse( + values + .get("PICKED_HARNESS_PORT") + .map(String::as_str) + .unwrap_or("4202"), + )?; + if host == target { + return Ok(None); + } + let mut overlay = format!("services:\n agent-harness:\n ports: !override\n - \"127.0.0.1:{host}:{target}\"\n"); + if !ipv4_only { + overlay.push_str(&format!(" - \"[::1]:{host}:{target}\"\n")); + } + Ok(Some(overlay)) +} /// Only service creation needs the Mac Podman port overlay. In particular, `run migrate` can /// create its Postgres dependency, so it must use the same configuration as `up`. @@ -266,7 +307,9 @@ fn compose_start_command( os: &str, ) -> Result { let mut command = compose_command(engine, root, secrets); - if os != "macos" || engine.engine != crate::engine::Engine::Podman { + let macos_podman = os == "macos" && engine.engine == crate::engine::Engine::Podman; + let harness_ports = harness_port_overlay(root, macos_podman)?; + if !macos_podman && harness_ports.is_none() { return Ok(command); } @@ -291,19 +334,29 @@ fn compose_start_command( let environment = String::from_utf8(environment.stdout) .map_err(|_| Problem::plain("Compose returned unreadable deployment settings."))?; let files = compose_files(root, &environment)?; - let overlay = root.join(MACOS_PODMAN_PORTS_FILE); - if std::fs::read(&overlay).ok().as_deref() != Some(MACOS_PODMAN_PORTS.as_bytes()) { - std::fs::write(&overlay, MACOS_PODMAN_PORTS).map_err(|error| { - Problem::with( - "OpenBot could not prepare the Mac Podman port settings.", - error.to_string(), - ) - })?; - } for file in files { command.arg("-f").arg(file); } - command.arg("-f").arg(MACOS_PODMAN_PORTS_FILE); + for (file, contents) in [ + ( + MACOS_PODMAN_PORTS_FILE, + macos_podman.then_some(MACOS_PODMAN_PORTS), + ), + (HARNESS_PORT_FILE, harness_ports.as_deref()), + ] { + if let Some(contents) = contents { + let overlay = root.join(file); + if std::fs::read(&overlay).ok().as_deref() != Some(contents.as_bytes()) { + std::fs::write(&overlay, contents).map_err(|error| { + Problem::with( + "OpenBot could not prepare its local port settings.", + error.to_string(), + ) + })?; + } + command.arg("-f").arg(file); + } + } Ok(command) } @@ -317,7 +370,7 @@ fn compose_files(root: &Path, environment: &str) -> Result, Problem> if let Some(files) = setting("COMPOSE_FILE") { let separator = setting("COMPOSE_PATH_SEPARATOR") .filter(|value| !value.is_empty()) - .unwrap_or(":"); // Only used on macOS. + .unwrap_or(if cfg!(windows) { ";" } else { ":" }); return Ok(files.split(separator).map(str::to_owned).collect()); } @@ -959,6 +1012,11 @@ pub fn spawn_host_process( * fought with. */ configure_host_process_env(&mut command, process.name, secrets); + let ports = crate::env::Ports::read(root)?; + command.envs(ports.settings()); + if process.name == "server" { + command.env("PORT", ports.server.to_string()); + } if process.script.is_empty() { command.args(["run", process.package_script]); } else { @@ -2531,8 +2589,7 @@ pub fn port_already_taken_except( } if something_answers(*port) { return Some(format!( - "Something is already listening on port {port}, which OpenBot uses for the {name}. \ - Stop it, or change the port, and start again." + "Port {port} for the {name} became unavailable. Try Start again so OpenBot can choose another local port." )); } } @@ -2638,23 +2695,68 @@ pub fn wait_until_answering( )) } -/// The last few lines of a process's log, which is where the reason is. +/// The last build error can precede Bun's wrapper stack, version, and exit message. +/// Bound both the file read and displayed lines while preserving that useful context. fn tail_of(logs: &Path, name: &str) -> String { - let Ok(text) = std::fs::read_to_string(logs.join(format!("{name}.log"))) else { - return format!("Nothing was written to {name}.log."); + use std::io::{Read, Seek, SeekFrom}; + const MAX_BYTES: u64 = 8 * 1024; + let read = || -> std::io::Result> { + let mut file = std::fs::File::open(logs.join(format!("{name}.log")))?; + let offset = file.metadata()?.len().saturating_sub(MAX_BYTES); + file.seek(SeekFrom::Start(offset))?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES).read_to_end(&mut bytes)?; + // Do not show a truncated first line, which may include part of a credential. + if offset > 0 { + let first_line = bytes.iter().position(|byte| *byte == b'\n'); + bytes.drain(..first_line.map_or(bytes.len(), |index| index + 1)); + } + Ok(bytes) }; + let bytes = match read() { + Ok(bytes) => bytes, + Err(error) => return format!("Could not read {name}.log: {error}"), + }; + let text = String::from_utf8_lossy(&bytes); let tail: Vec<&str> = text .lines() .filter(|line| !line.trim().is_empty()) .rev() - .take(3) + .take(40) .collect(); if tail.is_empty() { - return format!("{name}.log is empty."); + return format!("No complete lines were available in the tail of {name}.log."); } let mut lines = tail; lines.reverse(); - format!("Last from {name}.log: {}", lines.join(" / ")) + format!( + "Last from {name}.log (up to 40 lines, 8 KiB):\n{}", + lines.join("\n") + ) +} + +/// Keep a short startup headline and useful local diagnostics, with credentials removed. +pub fn startup_problem(problem: Problem, secrets: &Secrets) -> Problem { + let mut detail = problem.said; + if let Some(cleanup) = problem.detail { + detail.push('\n'); + detail.push_str(&cleanup); + } + let mut credentials: Vec<_> = secrets + .iter() + .filter(|(key, value)| { + !value.is_empty() + && (crate::vault::is_secret(key) || key.as_str() == "OPENBOT_DESKTOP_HOST_TOKEN") + }) + .collect(); + credentials.sort_by_key(|(_, value)| std::cmp::Reverse(value.len())); + for (key, value) in credentials { + detail = detail.replace(value, &format!("<{key}>")); + } + Problem::with( + "OpenBot could not finish starting. Try Start again, or share the details below for help.", + detail, + ) } /// What a directory has to contain before it can be raised. @@ -2770,6 +2872,57 @@ mod tests { use super::*; use crate::test_support::temp_root; + #[test] + fn startup_log_tail_retains_build_error_before_wrapper_without_loading_whole_log() { + let root = temp_root("startup-build-diagnostic"); + std::fs::create_dir_all(&root).unwrap(); + let mut log = "old output must be omitted\n".repeat(1_000); + log.push_str("error during build: Could not resolve imported module\n"); + log.push_str(&" at synthetic build frame\n".repeat(20)); + log.push_str(" at run (app/scripts/serve-or-build.ts:36:11)\n"); + log.push_str("Bun v1.2.15 (macOS arm64)\nerror: script serve exited with code 1\n"); + std::fs::write(root.join("app.log"), log).unwrap(); + + let detail = tail_of(&root, "app"); + assert!( + detail.contains("Could not resolve imported module"), + "{detail}" + ); + assert!(detail.contains("serve-or-build.ts:36:11"), "{detail}"); + assert!(!detail.contains(&"old output must be omitted\n".repeat(20))); + assert!(detail.len() < 8_500, "unbounded startup diagnostic"); + assert!(detail.lines().count() <= 42, "too many startup log lines"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn startup_problem_keeps_build_and_cleanup_details_but_removes_credentials() { + let secrets = Secrets::from([ + ("OPENAI_API_KEY".into(), "synthetic+key.long".into()), + ( + "OPENBOT_DESKTOP_HOST_TOKEN".into(), + "synthetic-host-token".into(), + ), + ("APP_PORT".into(), "4567".into()), + ]); + let problem = startup_problem( + Problem::with( + "app stopped: Could not resolve imported module; key=synthetic+key.long", + "cleanup failed: port 4567 token=synthetic-host-token", + ), + &secrets, + ); + assert!(problem.said.len() < 120); + assert!(!problem.said.contains("Could not resolve")); + let detail = problem.detail.unwrap(); + assert!(detail.contains("Could not resolve imported module")); + assert!(detail.contains("cleanup failed: port 4567")); + assert!(detail.contains("")); + assert!(detail.contains("")); + assert!(!detail.contains("synthetic+key.long")); + assert!(!detail.contains("synthetic-host-token")); + } + fn postgres_config_fixture(name: &str) -> serde_json::Value { serde_json::json!({ "services": {"postgres": {"volumes": [{ @@ -2900,6 +3053,58 @@ mod tests { } } + #[test] + fn host_processes_receive_persisted_ports_on_start_and_restart() { + let root = temp_root("host-selected-ports"); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("ports.rs"); + let bun = root.join(format!("ports{}", std::env::consts::EXE_SUFFIX)); + std::fs::write( + &source, + r#" +fn main() { + let role = std::env::current_dir().unwrap().file_name().unwrap().to_string_lossy().into_owned(); + assert_eq!(std::env::var("APP_PORT").unwrap(), "52110"); + assert_eq!(std::env::var("SERVER_PORT").unwrap(), "52101"); + if role == "server" { assert_eq!(std::env::var("PORT").unwrap(), "52101"); } +} +"#, + ) + .unwrap(); + crate::test_support::compile_fixture(&source, &bun); + let ports = crate::env::Ports { + app: 52110, + server: 52101, + ..Default::default() + }; + crate::env::write( + &root.join(".env"), + &ports.settings(), + &std::collections::BTreeMap::new(), + ) + .unwrap(); + let stale = Secrets::from([ + ("SERVER_PORT".into(), "3001".into()), + ("APP_PORT".into(), "3010".into()), + ("PORT".into(), "3001".into()), + ]); + for _ in 0..2 { + for process in HOST_PROCESSES { + std::fs::create_dir_all(root.join(process.cwd)).unwrap(); + let status = spawn_host_process(&process, &root, &root.join(".logs"), &bun, &stale) + .unwrap() + .wait() + .unwrap(); + assert!( + status.success(), + "{} did not receive selected ports", + process.name + ); + } + } + std::fs::remove_dir_all(root).unwrap(); + } + #[cfg(unix)] fn unix_fixture(pid: u32, parent: u32) -> UnixProcess { UnixProcess { @@ -6372,7 +6577,11 @@ fn main() { ["compose.yaml", "compose.override.yml"] ); assert_eq!( - compose_files(&root, "COMPOSE_FILE=first.yml:folder/custom file.yml\n").unwrap(), + compose_files( + &root, + "COMPOSE_FILE=first.yml:folder/custom file.yml\nCOMPOSE_PATH_SEPARATOR=:\n" + ) + .unwrap(), ["first.yml", "folder/custom file.yml"] ); assert_eq!( @@ -6386,6 +6595,82 @@ fn main() { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn dynamic_harness_overlay_keeps_container_port_and_loopback_policy() { + let root = temp_root("dynamic-harness-port"); + std::fs::create_dir_all(&root).unwrap(); + assert!(harness_port_overlay(&root, false).unwrap().is_none()); + std::fs::write( + root.join(".env"), + "PICKED_HARNESS_PORT=4206\nPICKED_HARNESS_HOST_PORT=52106\n", + ) + .unwrap(); + let dual = harness_port_overlay(&root, false).unwrap().unwrap(); + assert!(dual.contains("ports: !override")); + assert!(dual.contains("127.0.0.1:52106:4206")); + assert!(dual.contains("[::1]:52106:4206")); + let mac_podman = harness_port_overlay(&root, true).unwrap().unwrap(); + assert!(mac_podman.contains("127.0.0.1:52106:4206")); + assert!(!mac_podman.contains("[::1]")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + #[ignore = "requires Docker Compose; configuration only, no running engine needed"] + fn dynamic_harness_port_is_applied_by_real_compose_without_changing_container_ports() { + let root = temp_root("dynamic-harness-compose"); + std::fs::create_dir_all(&root).unwrap(); + let source = include_str!("../../../docker-compose.yml"); + std::fs::write(root.join("docker-compose.yml"), source).unwrap(); + std::fs::write( + root.join("docker-compose.override.yml"), + "services:\n agent-harness:\n labels:\n regression: preserved\n", + ) + .unwrap(); + std::fs::write(root.join(".env"), "PICKED_HARNESS_IMAGE=synthetic:local\nPICKED_HARNESS_PORT=4206\nPICKED_HARNESS_HOST_PORT=52106\nPOSTGRES_PORT=55432\n").unwrap(); + let command = compose_start_command( + &Address::new(crate::engine::Engine::Docker, None), + &root, + &Secrets::new(), + "windows", + ) + .unwrap(); + let output = { + let mut command = command; + command + .args(["--profile", "*", "config", "--format", "json"]) + .output() + .unwrap() + }; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let config: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let harness = &config["services"]["agent-harness"]; + assert_eq!(harness["labels"]["regression"], "preserved"); + let ports = harness["ports"].as_array().unwrap(); + assert_eq!(ports.len(), 2); + for port in ports { + assert_eq!(port["published"], "52106"); + assert_eq!(port["target"], 4206); + assert!(matches!( + port["host_ip"].as_str(), + Some("127.0.0.1" | "::1") + )); + } + for port in config["services"]["postgres"]["ports"].as_array().unwrap() { + assert_eq!(port["published"], "55432"); + assert_eq!(port["target"], 5432); + } + assert_eq!( + std::fs::read_to_string(root.join("docker-compose.yml")).unwrap(), + source + ); + std::fs::remove_dir_all(root).unwrap(); + } + /// Real Compose merging is the important assertion: without !override, IPv6 ports survive. /// This only reads configuration; it never contacts a container engine or registry. #[test] diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 81f8f73e9..f5755ab1d 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -6,11 +6,19 @@ import { fireEvent, render, waitFor, + within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { StrictMode } from "react"; type Invoke = (command: string, args?: unknown) => Promise; +type ProgressEvent = { + step: string; + ok: boolean; + detail: string; + running?: boolean; + downloadBytes?: number; +}; type Deferred = { promise: Promise; resolve: (value: T) => void; @@ -22,7 +30,7 @@ let invokeHandler: Invoke = async () => { throw new Error("invoke handler was not installed"); }; const progressListeners = new Set< - (event: { payload: { step: string; ok: boolean; detail: string } }) => void + (event: { payload: ProgressEvent }) => void >(); mock.module("@tauri-apps/api/core", () => ({ @@ -35,9 +43,7 @@ mock.module("@tauri-apps/api/core", () => ({ mock.module("@tauri-apps/api/event", () => ({ listen: async ( name: string, - listener: (event: { - payload: { step: string; ok: boolean; detail: string }; - }) => void, + listener: (event: { payload: ProgressEvent }) => void, ) => { if (name === "setup:progress") progressListeners.add(listener); return () => progressListeners.delete(listener); @@ -102,8 +108,196 @@ function installationCalls() { ); } +async function beginPendingInstallation() { + setupRootConfiguration("/tmp/install-progress", async () => + emptyConfiguration(), + ); + let preparation = deferred(); + const previous = invokeHandler; + invokeHandler = async (command, args) => + command === "prepare_installation" + ? preparation.promise + : previous(command, args); + const view = await renderApp(); + await enterInstallation(view); + await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); + return { + view, + preparation, + retry: async () => { + preparation = deferred(); + await userEvent.click( + view.getByRole("button", { name: "Retry installation" }), + ); + return preparation; + }, + }; +} + +async function emitProgress(payload: ProgressEvent) { + await act(async () => { + for (const listener of progressListeners) listener({ payload }); + }); +} + +test("installation shows its active stage immediately and advances elapsed time", async () => { + const { view, preparation } = await beginPendingInstallation(); + const engine = within( + view.getByRole("listitem", { name: "Container engine" }), + ); + expect(engine.getByText("Checking the software OpenBot needs.")).toBeTruthy(); + expect(engine.getByText("In progress")).toBeTruthy(); + expect(engine.queryByText("✓")).toBeNull(); + await waitFor(() => expect(engine.getByText(/1s elapsed/)).toBeTruthy(), { + timeout: 1800, + }); + await act(async () => preparation.resolve()); + expect(engine.getByText("Complete")).toBeTruthy(); + expect(engine.getByText("✓")).toBeTruthy(); + expect(view.queryByText("In progress") === null).toBe(true); + expect(view.queryByText(/elapsed/)).toBeNull(); +}); + +test("running updates show real download bytes without completing a stage early", async () => { + const { view, preparation } = await beginPendingInstallation(); + await emitProgress({ + step: "engine", + ok: true, + detail: "Docker is answering.", + }); + await emitProgress({ + step: "dependencies", + ok: true, + running: true, + detail: "Downloading the local runtime.", + downloadBytes: 2_400_000, + }); + const dependencies = within( + view.getByRole("listitem", { name: "Dependencies" }), + ); + expect(dependencies.getByText("In progress")).toBeTruthy(); + expect(dependencies.getByText(/2.4 MB downloaded/)).toBeTruthy(); + expect(dependencies.queryByText("✓")).toBeNull(); + await emitProgress({ + step: "dependencies", + ok: true, + running: true, + detail: "Downloading the local runtime.", + downloadBytes: 5_700_000, + }); + expect(view.getAllByRole("listitem", { name: "Dependencies" })).toHaveLength( + 1, + ); + expect(dependencies.getByText(/5.7 MB downloaded/)).toBeTruthy(); + expect(dependencies.queryByText(/2.4 MB downloaded/)).toBeNull(); + expect(dependencies.queryByText("Complete")).toBeNull(); + await emitProgress({ + step: "dependencies", + ok: true, + running: false, + detail: "Local runtime installed.", + }); + expect(dependencies.getByText("Complete")).toBeTruthy(); + expect(dependencies.getByText("✓")).toBeTruthy(); + expect(dependencies.queryByText("In progress")).toBeNull(); + await emitProgress({ + step: "images", + ok: true, + running: true, + detail: "Downloading local software (2/4).", + }); + expect(view.getByText("Downloading local software (2/4).")).toBeTruthy(); + await act(async () => preparation.resolve()); + expect(view.queryByText("In progress") === null).toBe(true); +}); + +test("installation failure stops active indicators and retry begins fresh", async () => { + const { view, preparation, retry } = await beginPendingInstallation(); + await emitProgress({ + step: "engine", + ok: true, + running: false, + detail: "Docker is answering.", + }); + await emitProgress({ + step: "dependencies", + ok: true, + running: true, + detail: "Installing the local runtime.", + }); + await act(async () => + preparation.reject({ said: "The download was interrupted." }), + ); + const dependencies = within( + view.getByRole("listitem", { name: "Dependencies" }), + ); + expect(dependencies.getByText("Failed")).toBeTruthy(); + expect(dependencies.getByText("✗")).toBeTruthy(); + expect(dependencies.getByText("The download was interrupted.")).toBeTruthy(); + expect(view.queryByText("In progress") === null).toBe(true); + expect(view.queryByText(/elapsed/)).toBeNull(); + expect( + within(view.getByRole("listitem", { name: "Container engine" })).getByText( + "Complete", + ), + ).toBeTruthy(); + const secondAttempt = await retry(); + expect(view.queryByRole("listitem", { name: "Dependencies" })).toBeNull(); + expect(view.queryByText("Failed")).toBeNull(); + expect(view.getByText("In progress")).toBeTruthy(); + expect(view.queryByRole("alert")).toBeNull(); + await act(async () => secondAttempt.resolve()); + expect(view.queryByText("In progress") === null).toBe(true); + expect( + view.getByRole("heading", { name: "Installation complete" }), + ).toBeTruthy(); +}); + +test.each([ + [false, false], + [true, false], + [false, true], + [true, true], +])( + "startup settles active progress (automatic=%s, succeeds=%s)", + async (automatic, succeeds) => { + useInterruptedShutdownSetup(); + const launch = deferred(); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure" && automatic) return null; + if (command === "start_stack") return launch.promise; + return previous(command, args); + }; + const view = await renderApp(); + if (!automatic) + await userEvent.click( + view.getByRole("button", { name: "Start OpenBot" }), + ); + await emitProgress({ + step: "services", + ok: true, + running: true, + detail: "Starting local services.", + }); + expect(view.getByText("In progress")).toBeTruthy(); + await act(async () => { + if (succeeds) launch.resolve(); + else launch.reject({ said: "Local services could not start." }); + }); + expect(view.queryByText("In progress") === null).toBe(true); + const services = within(view.getByRole("listitem", { name: "Containers" })); + expect(services.getByText(succeeds ? "Complete" : "Failed")).toBeTruthy(); + expect(services.getByText(succeeds ? "✓" : "✗")).toBeTruthy(); + if (!succeeds) + expect( + view.getByRole("button", { name: "Start OpenBot" }), + ).toHaveProperty("disabled", false); + }, +); + test("installation completes before either sign-in is available", async () => { - useRootConfigurationSetup("/tmp/install-before-signin", async () => + setupRootConfiguration("/tmp/install-before-signin", async () => emptyConfiguration(), ); const preparation = deferred(); @@ -164,6 +358,11 @@ test("installation completes before either sign-in is available", async () => { expect( view.getByRole("heading", { name: "Installation complete" }), ).toBeTruthy(); + expect( + view + .getByRole("button", { name: "Repair installation" }) + .closest("details"), + ).toHaveProperty("open", false); expect(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); await userEvent.click( view.getByRole("button", { name: "Continue to sign in" }), @@ -171,8 +370,73 @@ test("installation completes before either sign-in is available", async () => { expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); }); +test("CopilotKit Back preserves the selected AI and connection settings", async () => { + setupRootConfiguration("/tmp/back-preserves-setup", async () => + emptyConfiguration(), + ); + const view = await renderApp(); + const user = userEvent.setup({ document: view.container.ownerDocument }); + await enterInstallation(view); + await completeInstallation(view); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click( + view.getByText("Point at your own Intelligence server"), + ); + await userEvent.type(view.getByLabelText("Project key"), "project-test-key"); + const api = view.getByLabelText("API URL"); + await user.clear(api); + await user.type(api, "https://intelligence.example/api"); + const gateway = view.getByLabelText("Gateway WebSocket URL"); + await user.clear(gateway); + await user.type(gateway, "wss://intelligence.example/ws"); + await userEvent.click(view.getByRole("button", { name: "Back" })); + expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); + expect(view.getByRole("radio", { name: /OpenAI/ })).toHaveProperty( + "checked", + true, + ); + expect(view.getByLabelText("OpenAI API key")).toHaveProperty( + "value", + "model-test-key", + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + expect( + view.getByRole("heading", { name: "Connect to CopilotKit" }), + ).toBeTruthy(); + await userEvent.click( + view.getByText("Point at your own Intelligence server"), + ); + expect(view.getByLabelText("Project key")).toHaveProperty( + "value", + "project-test-key", + ); + expect(view.getByLabelText("API URL")).toHaveProperty( + "value", + "https://intelligence.example/api", + ); + expect(view.getByLabelText("Gateway WebSocket URL")).toHaveProperty( + "value", + "wss://intelligence.example/ws", + ); + expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( + "disabled", + false, + ); + expect( + view.queryByRole("button", { name: "Change AI connection" }) === null, + ).toBe(true); + expect( + view + .getByRole("button", { name: "Change installation" }) + .closest("details"), + ).toHaveProperty("open", false); + expect(installationCalls()).toHaveLength(1); +}); + test("failed installation blocks sign-in and retries before reporting completion", async () => { - useRootConfigurationSetup("/tmp/install-retry", async () => + setupRootConfiguration("/tmp/install-retry", async () => emptyConfiguration(), ); const previous = invokeHandler; @@ -211,7 +475,7 @@ test("failed installation blocks sign-in and retries before reporting completion }); test("provider sign-in retries and Back reuse the completed local installation", async () => { - useRootConfigurationSetup("/tmp/provider-retry", async () => + setupRootConfiguration("/tmp/provider-retry", async () => emptyConfiguration(), ); const previous = invokeHandler; @@ -260,7 +524,7 @@ test("provider sign-in retries and Back reuse the completed local installation", }); test("a completed installation can be repaired after a provider reports missing assets", async () => { - useRootConfigurationSetup("/tmp/repair-installation", async () => + setupRootConfiguration("/tmp/repair-installation", async () => emptyConfiguration(), ); const previous = invokeHandler; @@ -293,6 +557,7 @@ test("a completed installation can be repaired after a provider reports missing ); await view.findByText("Return to Install and try again."); await userEvent.click(view.getByRole("button", { name: "Back" })); + await userEvent.click(view.getByText("Installation options")); await userEvent.click( view.getByRole("button", { name: "Repair installation" }), ); @@ -345,7 +610,7 @@ test.each([false, true])( "reopening a successful setup starts its saved root and connections without the wizard or Ask (Strict Mode=%s)", async (strictMode) => { const root = "/tmp/successful-setup-root"; - useRootConfigurationSetup("/tmp/default-root", async () => ({ + setupRootConfiguration("/tmp/default-root", async () => ({ values: { INTELLIGENCE_API_URL: "https://own.example/api", INTELLIGENCE_GATEWAY_WS_URL: "wss://own.example/ws", @@ -397,7 +662,7 @@ test.each([false, true])( ); test("a failed automatic reopen offers recovery without retrying or installing", async () => { - useRootConfigurationSetup("/tmp/reopen-failure", async () => ({ + setupRootConfiguration("/tmp/reopen-failure", async () => ({ ...savedOpenAiConfiguration(), saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, launch: { harness: { id: "langgraph" } }, @@ -428,7 +693,7 @@ test("a failed automatic reopen offers recovery without retrying or installing", test("menu Stop retains the installed root for explicit Start without the wizard", async () => { const root = "/tmp/menu-stopped-installation"; - useRootConfigurationSetup(root, async () => ({ + setupRootConfiguration(root, async () => ({ ...savedOpenAiConfiguration(), saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, launch: { harness: { id: "mastra" } }, @@ -457,7 +722,7 @@ test("menu Stop retains the installed root for explicit Start without the wizard }); test("completed local installation resumes at sign-in when it has never launched", async () => { - useRootConfigurationSetup("/tmp/installed-before-signin", async () => ({ + setupRootConfiguration("/tmp/installed-before-signin", async () => ({ ...emptyConfiguration(), installation: { harness: { id: "mastra" } }, })); @@ -507,7 +772,7 @@ test.each(["reopen", "runtime"])( async (source) => { const root = "/tmp/organization-installation"; const authorityUrl = "https://company.example"; - useRootConfigurationSetup(root, async () => ({ + setupRootConfiguration(root, async () => ({ ...savedOpenAiConfiguration(), values: { OPENBOT_ORGANIZATION_AUTH_URL: authorityUrl }, saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, @@ -578,7 +843,7 @@ test.each(["model", "intelligence"])( "an unavailable saved %s connection opens only its refresh screen and returns to the existing app", async (connection) => { const root = "/tmp/installed-refresh"; - useRootConfigurationSetup(root, async () => ({ + setupRootConfiguration(root, async () => ({ ...savedOpenAiConfiguration(), saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, launch: { harness: { id: "mastra" } }, @@ -654,7 +919,7 @@ test.each(["model", "intelligence"])( test("reopening a retained root waits for its saved setup without flashing the wizard", async () => { const root = "/tmp/reopen-pending-configuration"; const configuration = deferred>(); - useRootConfigurationSetup(root, async () => configuration.promise); + setupRootConfiguration(root, async () => configuration.promise); const previous = invokeHandler; invokeHandler = async (command, args) => { if (command === "selected_root") return root; @@ -676,7 +941,7 @@ test("reopening a retained root waits for its saved setup without flashing the w function useInterruptedShutdownSetup() { const root = "/tmp/interrupted-shutdown-root"; const notice = "OpenBot had trouble shutting down last time."; - useRootConfigurationSetup("/tmp/default-root", async () => ({ + setupRootConfiguration("/tmp/default-root", async () => ({ values: { INTELLIGENCE_API_URL: "https://own.example/api", INTELLIGENCE_GATEWAY_WS_URL: "wss://own.example/ws", @@ -786,7 +1051,7 @@ test.each(["model", "root", "Intelligence"])( test.each(["supervisor", "windows"])( "automatic reopen respects the existing %s blocker", async (blocker) => { - useRootConfigurationSetup("/tmp/reopen-blocked", async () => ({ + setupRootConfiguration("/tmp/reopen-blocked", async () => ({ ...savedOpenAiConfiguration(), saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, launch: { harness: { id: "langgraph" } }, @@ -822,7 +1087,7 @@ test.each(["supervisor", "windows"])( ); test("setup records telemetry without a consent gate and deduplicates viewed steps", async () => { - useRootConfigurationSetup("/tmp/private-setup-root", async () => + setupRootConfiguration("/tmp/private-setup-root", async () => emptyConfiguration(), ); const previous = invokeHandler; @@ -962,7 +1227,7 @@ function emptyConfiguration() { }; } -function useRootConfigurationSetup( +function setupRootConfiguration( rootA: string, loadConfiguration: (root: string) => Promise, ) { @@ -1025,7 +1290,7 @@ function useRootConfigurationSetup( } test("Windows detection failure blocks setup and displays its diagnostic", async () => { - useRootConfigurationSetup("/tmp/openbot-windows-detection-test", async () => + setupRootConfiguration("/tmp/openbot-windows-detection-test", async () => emptyConfiguration(), ); const setupHandler = invokeHandler; @@ -1054,7 +1319,7 @@ test("Windows detection failure blocks setup and displays its diagnostic", async }); test("a failed Windows blocker instruction is visible instead of an empty blocker", async () => { - useRootConfigurationSetup("/tmp/openbot-windows-detection-test", async () => + setupRootConfiguration("/tmp/openbot-windows-detection-test", async () => emptyConfiguration(), ); const setupHandler = invokeHandler; @@ -1073,7 +1338,7 @@ test("a failed Windows blocker instruction is visible instead of an empty blocke }); test("a successfully detected missing WSL feature keeps its setup instruction", async () => { - useRootConfigurationSetup("/tmp/openbot-windows-detection-test", async () => + setupRootConfiguration("/tmp/openbot-windows-detection-test", async () => emptyConfiguration(), ); const setupHandler = invokeHandler; @@ -1091,7 +1356,7 @@ test("a successfully detected missing WSL feature keeps its setup instruction", }); test("disabled Virtual Machine Platform displays its feature-specific fix and blocks setup", async () => { - useRootConfigurationSetup("/tmp/openbot-vmp-detection-test", async () => + setupRootConfiguration("/tmp/openbot-vmp-detection-test", async () => emptyConfiguration(), ); const setupHandler = invokeHandler; @@ -1564,9 +1829,118 @@ test("empty Intelligence projects keep sign-in retryable while Start waits for a ); }); +async function enterProjectSelection() { + setupRootConfiguration("/tmp/create-project", async () => + emptyConfiguration(), + ); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "begin_intelligence_sign_in") + return "https://copilotkit.test/sign-in"; + if (command === "finish_intelligence_sign_in") return []; + if (command === "intelligence_key_for") return "created-project-key"; + return previous(command, args); + }; + const view = await renderApp(); + await enterInstallation(view); + await completeInstallation(view); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click( + view.getByRole("button", { name: "Sign in to CopilotKit" }), + ); + return view; +} + +test("creating a named project connects it only after the user submits", async () => { + const view = await enterProjectSelection(); + const creation = deferred<{ id: string; name: string }>(); + const previous = invokeHandler; + invokeHandler = async (command, args) => + command === "create_intelligence_project" + ? creation.promise + : previous(command, args); + expect( + invokeCalls.some((call) => call.command === "create_intelligence_project"), + ).toBe(false); + const create = view.getByRole("button", { name: "Create project" }); + expect(create).toHaveProperty("disabled", true); + await userEvent.type( + view.getByLabelText("New project name"), + " OpenBot workspace ", + ); + await userEvent.click(create); + expect(invokeCalls).toContainEqual({ + command: "create_intelligence_project", + args: { name: "OpenBot workspace" }, + }); + expect( + view.getByRole("button", { name: "Creating project…" }), + ).toHaveProperty("disabled", true); + expect(view.getByLabelText("New project name")).toHaveProperty( + "disabled", + true, + ); + await act(async () => + creation.resolve({ id: "new-project", name: "OpenBot workspace" }), + ); + expect(view.getByText("Connected to CopilotKit.")).toBeTruthy(); + expect(invokeCalls).toContainEqual({ + command: "intelligence_key_for", + args: { project: "new-project" }, + }); + expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( + "disabled", + false, + ); +}); + +test("project creation failure preserves the name and retries without losing a created project", async () => { + const view = await enterProjectSelection(); + const previous = invokeHandler; + let creations = 0; + let provisions = 0; + invokeHandler = async (command, args) => { + if (command === "create_intelligence_project") { + if (++creations === 1) + throw { said: "The project could not be created." }; + return { id: "new-project", name: "OpenBot workspace" }; + } + if (command === "intelligence_key_for" && ++provisions === 1) + throw { said: "The project key could not be created." }; + return previous(command, args); + }; + await userEvent.type( + view.getByLabelText("New project name"), + "OpenBot workspace", + ); + await userEvent.click(view.getByRole("button", { name: "Create project" })); + expect(view.getByRole("alert").textContent).toContain( + "The project could not be created.", + ); + expect(view.getByLabelText("New project name")).toHaveProperty( + "value", + "OpenBot workspace", + ); + expect(view.getByRole("button", { name: "Create project" })).toHaveProperty( + "disabled", + false, + ); + await userEvent.click(view.getByRole("button", { name: "Create project" })); + expect(view.getByRole("alert").textContent).toContain( + "The project key could not be created.", + ); + await userEvent.click( + view.getByRole("button", { name: "OpenBot workspace" }), + ); + expect(view.getByText("Connected to CopilotKit.")).toBeTruthy(); + expect(creations).toBe(2); +}); + test("mount navigates to OpenBot only when the selected root is already owned and running", async () => { const root = "/tmp/openbot-owned-running-root"; - useRootConfigurationSetup(root, async () => emptyConfiguration()); + setupRootConfiguration(root, async () => emptyConfiguration()); const setupHandler = invokeHandler; invokeHandler = async (command, args) => { if (command === "already_running") { @@ -1584,7 +1958,7 @@ test("mount navigates to OpenBot only when the selected root is already owned an test("mount leaves setup visible when the shared port answers without selected root ownership", async () => { const root = "/tmp/openbot-unowned-running-root"; - useRootConfigurationSetup(root, async () => emptyConfiguration()); + setupRootConfiguration(root, async () => emptyConfiguration()); const view = await renderApp(); @@ -1598,7 +1972,7 @@ test("mount leaves setup visible when the shared port answers without selected r for (const staleProbe of [false, true]) { test(`recovery mount keeps setup available after ${staleProbe ? "a stale positive" : "a negative"} adoption probe`, async () => { - useRootConfigurationSetup("/tmp/openbot-worker-recovery", async () => + setupRootConfiguration("/tmp/openbot-worker-recovery", async () => emptyConfiguration(), ); const setupHandler = invokeHandler; @@ -1726,7 +2100,7 @@ test("root edits reload saved configuration for that root and ignore stale saved const emptyForRootB = deferred>(); const savedForRootC = deferred>(); - useRootConfigurationSetup(rootA, async (requestedRoot) => { + setupRootConfiguration(rootA, async (requestedRoot) => { if (requestedRoot === rootA) return savedForRootA.promise; if (requestedRoot === rootB) return emptyForRootB.promise; if (requestedRoot === rootC) return savedForRootC.promise; @@ -1818,7 +2192,7 @@ test("root edits reload saved configuration for that root and ignore stale saved test("same-process setup remount prefers the retained selected root", async () => { const rootA = "/tmp/openbot-default-root"; const rootB = "/tmp/openbot-retained-root"; - useRootConfigurationSetup(rootA, async (requestedRoot) => { + setupRootConfiguration(rootA, async (requestedRoot) => { if (requestedRoot !== rootB) throw new Error(`unexpected already_configured root ${requestedRoot}`); return savedOpenAiConfiguration(); @@ -1880,7 +2254,7 @@ test.each([ root: string; response: Deferred>; }> = []; - useRootConfigurationSetup(rootA, async (root) => { + setupRootConfiguration(rootA, async (root) => { const response = deferred>(); requests.push({ root, response }); return response.promise; @@ -2337,7 +2711,7 @@ for (const provider of [ async (session) => { const planToken = `synthetic-${provider.id}-plan-token`; const hiddenKey = `sk-synthetic-${provider.id}-hidden`; - useRootConfigurationSetup("/tmp/openbot-app-test", async () => ({ + setupRootConfiguration("/tmp/openbot-app-test", async () => ({ ...emptyConfiguration(), saved: { ...emptyConfiguration().saved, @@ -2542,7 +2916,7 @@ for (const provider of [ ]) { for (const login of ["plan", "api-key"] as const) { test(`unknown legacy ${provider.name} ${login} and Intelligence reuse stays passive until Start`, async () => { - useRootConfigurationSetup("/tmp/synthetic-legacy-root", async () => ({ + setupRootConfiguration("/tmp/synthetic-legacy-root", async () => ({ values: {}, saved: {}, })); @@ -2597,9 +2971,7 @@ for (const provider of [ ); expect(view.queryByText("Connected to CopilotKit.")).toBeNull(); // Returning to the provider screen retains deliberate reuse without signing in automatically. - await userEvent.click( - view.getByRole("button", { name: "Change AI connection" }), - ); + await userEvent.click(view.getByRole("button", { name: "Back" })); await userEvent.click( await view.findByRole("button", { name: "Continue" }), ); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 3bdd28e0e..74a4f19c0 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -38,7 +38,13 @@ type Blocker = | "virtualization-disabled" | "not-administrator"; -type Progress = { step: string; ok: boolean; detail: string }; +type Progress = { + step: string; + ok: boolean; + detail: string; + running?: boolean; + downloadBytes?: number; +}; type AlreadyConfigured = { values: Record; @@ -104,6 +110,8 @@ export function App() { const [projects, setProjects] = useState< { id: string; name: string }[] | null >(null); + const [projectName, setProjectName] = useState(""); + const [creatingProject, setCreatingProject] = useState(false); const [signingIn, setSigningIn] = useState(false); /* * The address the browser was sent to, kept so the screen can show it. @@ -148,6 +156,28 @@ export function App() { setSigningIn(false); } } + async function createProject() { + const name = projectName.trim(); + if (!name || signingIn) return; + setSigningIn(true); + setCreatingProject(true); + setFailure(null); + try { + const project = await invoke<{ id: string; name: string }>( + "create_intelligence_project", + { name }, + ); + setProjects((current) => [...(current ?? []), project]); + setProjectName(""); + await pickProject(project.id); + } catch (error) { + setFailure(asProblem(error)); + } finally { + setSigningIn(false); + setCreatingProject(false); + } + } + const [step, setStep] = useState("welcome"); const [apiUrl, setApiUrl] = useState(MANAGED_INTELLIGENCE_API_URL); const [wsUrl, setWsUrl] = useState(MANAGED_INTELLIGENCE_GATEWAY_WS_URL); @@ -196,6 +226,7 @@ export function App() { const [recoveryFailure, setRecoveryFailure] = useState(null); const recoverFromStart = useCallback((error: unknown) => { const problem = asProblem(error); + setSteps((current) => settleProgress(current, problem)); setRecoveryFailure(problem); if (problem.connection === "model") setStep("model"); if (problem.connection === "intelligence") { @@ -385,6 +416,7 @@ export function App() { : {}), }); if (!active) return; + setSteps((current) => settleProgress(current)); setRunning(true); setRecoveryFailure(null); await invoke("show_openbot"); @@ -426,14 +458,24 @@ export function App() { if (busy || !root.trim()) return; setBusy(true); setFailure(null); - setSteps([]); + setSteps([ + { + step: "engine", + ok: true, + running: true, + detail: "Checking the software OpenBot needs.", + }, + ]); setPreparation({ key: installationKey, status: "preparing" }); try { await invoke("prepare_installation", { root: root.trim(), harness }); + setSteps((current) => settleProgress(current)); setPreparation({ key: installationKey, status: "complete" }); } catch (error) { + const problem = asProblem(error); + setSteps((current) => settleProgress(current, problem)); setPreparation({ key: installationKey, status: "failed" }); - setFailure(asProblem(error)); + setFailure(problem); } finally { setBusy(false); } @@ -464,6 +506,7 @@ export function App() { ? { organizationAuthUrl: organizationAuthorityUrl } : {}), }); + setSteps((current) => settleProgress(current)); setRunning(true); setRecoveryFailure(null); // Refreshing credentials does not turn an existing installation into a first run. @@ -695,9 +738,12 @@ export function App() { {displayedFailure && } {busy &&

Installing local software…

} {installationReady && ( - +
+ Installation options + +
)}
+ )} + + ) : (alreadyHeld.saved?.intelligenceApiKey || reuseIntelligence) && + !signingIn && + !projects ? ( + <> +

+ A saved CopilotKit connection will be checked when you start. +

- )} - - ) : (alreadyHeld.saved?.intelligenceApiKey || reuseIntelligence) && - !signingIn && - !projects ? ( - <> -

- A saved CopilotKit connection will be checked when you start. -

- - - ) : signInUrl ? ( - <> -

- Finish signing in to CopilotKit in your browser. If it did not - open, this is the address: -

- {/* Selectable text, not a link: the browser has already been asked to open it, and + + ) : signInUrl ? ( + <> +

+ Finish signing in to CopilotKit in your browser. If it did not + open, this is the address: +

+ {/* Selectable text, not a link: the browser has already been asked to open it, and what is needed here is something a person can copy. */} -

- {signInUrl} -

-

Waiting for you to approve it…

- - ) : projects ? ( - <> -

Which project should OpenBot use?

-
- Project - {projects.map((project) => ( +

+ {signInUrl} +

+

Waiting for you to approve it…

+ + ) : projects ? ( + <> +

Which project should OpenBot use?

+
+ Project + {projects.map((project) => ( + + ))} +
+ {projects.length === 0 && ( + <> +

+ That account has no projects yet. Create one below, or + sign in with a different account. +

+ + + )} +
{ + event.preventDefault(); + void createProject(); + }} + > +
+ + setProjectName(event.target.value)} + disabled={signingIn} + autoComplete="off" + /> +
- ))} -
- {projects.length === 0 && ( - <> -

- That account has no projects yet. Make one at copilotkit.ai, - then sign in again. -

+ + + ) : ( + <> +

+ OpenBot keeps your conversations in CopilotKit. Sign in and it + sets the rest up for you. +

+
- - )} - - ) : ( - <> -

- OpenBot keeps your conversations in CopilotKit. Sign in and it - sets the rest up for you. -

- - - )} - {!apiKey && - !reuseIntelligence && - alreadyHeld.saved?.intelligenceApiKey == null && - !signingIn && - !projects && ( - + {!apiKey && + !reuseIntelligence && + alreadyHeld.saved?.intelligenceApiKey == null && + !signingIn && ( + + )} +
+ )} - {/* + {/* This used to be headed "Self-hosted Intelligence" over two fields pre-filled with the MANAGED service's addresses, which says the opposite of what it does: somebody opening it to check where their data goes read "self-hosted" and saw CopilotKit's own hosts. The heading now describes the action, and the note says what the defaults are. */} -
- Point at your own Intelligence server -

- These default to CopilotKit's managed service. Change them only if - you run Intelligence yourself, and paste that server's key below. -

-
- - setApiKey(event.target.value)} - placeholder="the key from your own Intelligence" - autoComplete="off" - spellCheck={false} - /> -
-
- - setApiUrl(event.target.value)} - spellCheck={false} - /> -
-
- - setWsUrl(event.target.value)} - spellCheck={false} - /> -
-
-
- Sign in through your organization -

- Enter your organization’s OpenBot address to use its sign-in and - access rules. -

-
- - - setOrganizationAuthorityUrl(event.target.value) - } - placeholder="https://openbot.your-company.com" - spellCheck={false} - /> -
-
- - )} +
+ Point at your own Intelligence server +

+ These default to CopilotKit's managed service. Change them only + if you run Intelligence yourself, and paste that server's key + below. +

+
+ + setApiKey(event.target.value)} + placeholder="the key from your own Intelligence" + autoComplete="off" + spellCheck={false} + /> +
+
+ + setApiUrl(event.target.value)} + spellCheck={false} + /> +
+
+ + setWsUrl(event.target.value)} + spellCheck={false} + /> +
+
+
+ Sign in through your organization +

+ Enter your organization’s OpenBot address to use its sign-in and + access rules. +

+
+ + + setOrganizationAuthorityUrl(event.target.value) + } + placeholder="https://openbot.your-company.com" + spellCheck={false} + /> +
+
+ + )} - + - {displayedFailure && } + {displayedFailure && } - {!running && !returningToInstallation && ( - - )} - {!running && ( - - )} -
- {running ? ( - <> + {!running && !returningToInstallation && ( +
+ Installation options + +
+ )} +
+ {!running && ( + )} + {running ? ( + <> + + + + ) : ( - - ) : ( - - )} + )} +
); } +function settleProgress(steps: Progress[], problem?: Problem): Progress[] { + return steps.map((step) => + step.running + ? { + ...step, + running: false, + ok: !problem, + detail: problem?.said ?? "Finished.", + } + : step, + ); +} + function SetupProgress({ steps }: { steps: Progress[] }) { if (steps.length === 0) return null; return ( -
+
    {steps.map((step) => ( -
    - - {step.ok ? "✓" : "✗"} - - {label(step.step)} - {step.detail} -
    + ))} -
+ + ); +} + +function SetupProgressRow({ step }: { step: Progress }) { + const [elapsed, setElapsed] = useState(0); + const running = step.running ?? false; + useEffect(() => { + if (!running) return; + const started = Date.now(); + setElapsed(0); + const timer = setInterval( + () => setElapsed(Math.floor((Date.now() - started) / 1000)), + 1000, + ); + return () => clearInterval(timer); + }, [running]); + return ( +
  • +
  • ); } +function formatDownloadBytes(bytes: number): string { + if (bytes < 1000) return `${bytes} B`; + const units = ["kB", "MB", "GB", "TB"]; + const power = Math.min(Math.floor(Math.log10(bytes) / 3), units.length); + return `${(bytes / 1000 ** power).toFixed(1)} ${units[power - 1]}`; +} + function titleFor(blocker: Blocker): string { switch (blocker) { case "wsl-absent": @@ -1175,6 +1312,7 @@ function titleFor(blocker: Blocker): string { function label(step: string): string { switch (step) { + case "engine": case "install-engine": return "Container engine"; case "create-machine": diff --git a/desktop/src/ExternalLink.tsx b/desktop/src/ExternalLink.tsx new file mode 100644 index 000000000..c1be4507d --- /dev/null +++ b/desktop/src/ExternalLink.tsx @@ -0,0 +1,46 @@ +import { invoke } from "@tauri-apps/api/core"; +import { type MouseEvent, type ReactNode, useState } from "react"; + +/** Open setup links explicitly so a refused browser launch is visible and retryable. */ +export function ExternalLink({ + href, + className, + children, +}: { + href: string; + className?: string; + children: ReactNode; +}) { + const [failure, setFailure] = useState(null); + + async function open(event: MouseEvent) { + event.preventDefault(); + setFailure(null); + try { + // This is the opener plugin's openUrl command; keep the URL separate from any form data. + await invoke("plugin:opener|open_url", { url: href }); + } catch (error) { + setFailure(String(error)); + } + } + + return ( + <> + + {children} + + {failure && ( + + Could not open your browser. Try the link again or open {href}{" "} + manually. ({failure}) + + )} + + ); +} diff --git a/desktop/src/Problem.test.tsx b/desktop/src/Problem.test.tsx index f07c7acdb..03f925eab 100644 --- a/desktop/src/Problem.test.tsx +++ b/desktop/src/Problem.test.tsx @@ -1,21 +1,33 @@ import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { cleanup, render } from "@testing-library/react"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; let invokeCalls: Array<{ command: string; args?: unknown }> = []; +let invokeFailure: string | null = null; mock.module("@tauri-apps/api/core", () => ({ invoke: (command: string, args?: unknown) => { invokeCalls.push({ command, args }); + if (invokeFailure) return Promise.reject(invokeFailure); return Promise.resolve(null); }, })); -const { Failure } = await import("./Problem"); +const { Failure, InlineFailure } = await import("./Problem"); -beforeAll(() => GlobalRegistrator.register()); +const previousSupportUrl = process.env.VITE_OPENBOT_SUPPORT_URL; + +beforeAll(() => + GlobalRegistrator.register({ + settings: { navigation: { disableChildPageNavigation: true } }, + }), +); afterEach(() => { invokeCalls = []; + invokeFailure = null; + if (previousSupportUrl === undefined) + delete process.env.VITE_OPENBOT_SUPPORT_URL; + else process.env.VITE_OPENBOT_SUPPORT_URL = previousSupportUrl; cleanup(); }); afterAll(() => GlobalRegistrator.unregister()); @@ -36,3 +48,69 @@ test("problem_ui_has_no_credential_restore_action", () => { expect(view.queryByRole("button")).toBeNull(); expect(invokeCalls).toEqual([]); }); + +for (const Component of [Failure, InlineFailure]) { + test(`${Component.name} offers external setup help without including error data`, () => { + delete process.env.VITE_OPENBOT_SUPPORT_URL; + const view = render( + , + ); + const help = view.getByRole("link", { name: "Get setup help" }); + expect(help.getAttribute("href")).toBe( + "https://github.com/CopilotKit/OpenBot/issues/new/choose", + ); + expect(help.getAttribute("target")).toBe("_blank"); + expect(help.getAttribute("rel")).toBe("noreferrer"); + expect(invokeCalls).toEqual([]); + fireEvent.click(help); + expect(invokeCalls).toEqual([ + { + command: "plugin:opener|open_url", + args: { + url: "https://github.com/CopilotKit/OpenBot/issues/new/choose", + }, + }, + ]); + }); +} + +test("setup help honors a branded support URL", () => { + process.env.VITE_OPENBOT_SUPPORT_URL = "https://support.example.test/openbot"; + const view = render(); + expect( + view.getByRole("link", { name: "Get setup help" }).getAttribute("href"), + ).toBe("https://support.example.test/openbot"); + fireEvent.click(view.getByRole("link", { name: "Get setup help" })); + expect(invokeCalls).toEqual([ + { + command: "plugin:opener|open_url", + args: { url: "https://support.example.test/openbot" }, + }, + ]); +}); + +test("setup help reports a failed browser open and allows retry", async () => { + delete process.env.VITE_OPENBOT_SUPPORT_URL; + invokeFailure = "synthetic browser refusal"; + const view = render(); + const help = view.getByRole("link", { name: "Get setup help" }); + + fireEvent.click(help); + const failure = await view.findByText(/Could not open your browser/); + expect(failure.textContent).toContain("synthetic browser refusal"); + expect(failure.textContent).toContain( + "https://github.com/CopilotKit/OpenBot/issues/new/choose", + ); + + invokeFailure = null; + fireEvent.click(help); + await waitFor(() => + expect(view.queryByText(/Could not open your browser/) === null).toBe(true), + ); + expect(invokeCalls).toHaveLength(2); +}); diff --git a/desktop/src/Problem.tsx b/desktop/src/Problem.tsx index 4d78bae0d..c427eeed6 100644 --- a/desktop/src/Problem.tsx +++ b/desktop/src/Problem.tsx @@ -1,3 +1,5 @@ +import { ExternalLink } from "./ExternalLink"; + /** * A failure, in both registers, wherever one happens. * @@ -20,6 +22,18 @@ export function asProblem(thrown: unknown): Problem { return { said: String(thrown) }; } +function SetupHelp() { + // Whitelabel builds can choose their own support page. Never prefill it with error data. + const url = + import.meta.env.VITE_OPENBOT_SUPPORT_URL?.trim() || + "https://github.com/CopilotKit/OpenBot/issues/new/choose"; + return ( + + Get setup help + + ); +} + export function Failure({ problem }: { problem: Problem }) { return (
    @@ -33,6 +47,7 @@ export function Failure({ problem }: { problem: Problem }) {
    {problem.detail}
    )} +
    ); } @@ -54,6 +69,7 @@ export function InlineFailure({ problem }: { problem: Problem }) {
    {problem.detail}
    )} +
    ); } diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index 94381c84a..b90aedff1 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -2,7 +2,11 @@ import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { HeldConfiguration, Provider } from "./ProviderPicker"; +import type { + HeldConfiguration, + ModelChoice, + Provider, +} from "./ProviderPicker"; const providers: Provider[] = [ { @@ -34,6 +38,38 @@ const endpointProviders: Provider[] = [ }, ]; +const cloudProviders = [ + { + id: "google", + name: "Google Gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + model: "gemini-3.8-flash", + keyUrl: "https://aistudio.google.com/apikey", + }, + { + id: "xai", + name: "xAI", + baseUrl: "https://api.x.ai/v1", + model: "grok-4.7", + keyUrl: "https://console.x.ai/", + }, +]; + +const allProviders: Provider[] = [ + ...providers, + ...cloudProviders.map( + (provider): Provider => ({ + id: provider.id, + name: provider.name, + summary: "Use an API key.", + logins: ["endpoint"], + mark: null, + caution: null, + }), + ), + ...endpointProviders, +]; + type Invoke = (command: string, args?: unknown) => Promise; let invokeCalls: Array<{ command: string; args?: unknown }> = []; @@ -58,7 +94,11 @@ mock.module("./Mark", () => ({ const { ProviderPicker } = await import("./ProviderPicker"); -beforeAll(() => GlobalRegistrator.register()); +beforeAll(() => + GlobalRegistrator.register({ + settings: { navigation: { disableChildPageNavigation: true } }, + }), +); afterEach(() => { invokeCalls = []; cleanup(); @@ -767,3 +807,192 @@ test("a saved keyless endpoint never requests a saved first-party key", async () }, ]); }); + +for (const provider of cloudProviders) { + test(`${provider.name} opens its API key page externally without sending entered credentials`, async () => { + invokeHandler = async (command) => { + if (command === "providers") return allProviders; + if (command === "plugin:opener|open_url") return null; + throw new Error(`unexpected command ${command}`); + }; + const view = await renderPicker(); + const user = userEvent.setup({ document: view.container.ownerDocument }); + await user.click( + await view.findByRole("radio", { name: new RegExp(provider.name) }), + ); + await user.type( + view.getByLabelText(`${provider.name} API key`), + "synthetic-private-api-key", + ); + expect(invokeCalls.map((call) => call.command)).toEqual(["providers"]); + await user.click( + view.getByRole("link", { name: `Get a ${provider.name} API key` }), + ); + expect(invokeCalls).toEqual([ + { command: "providers", args: undefined }, + { command: "plugin:opener|open_url", args: { url: provider.keyUrl } }, + ]); + }); + + test(`${provider.name} uses its official endpoint and editable model with an API key`, async () => { + invokeHandler = async (command) => { + if (command === "providers") return allProviders; + throw new Error(`unexpected protected command ${command}`); + }; + const user = userEvent.setup({ document }); + const choices: unknown[] = []; + const view = await renderPicker((choice) => choices.push(choice)); + await user.click( + await view.findByRole("radio", { name: new RegExp(provider.name) }), + ); + + expect(view.getByLabelText("Model name")).toHaveProperty( + "value", + provider.model, + ); + expect(view.queryByLabelText("Base URL")).toBeNull(); + expect( + view.queryByRole("tab", { name: "Sign in with my plan" }), + ).toBeNull(); + expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( + "disabled", + true, + ); + await user.type( + view.getByLabelText(`${provider.name} API key`), + "synthetic-provider-key", + ); + await user.click(view.getByRole("button", { name: "Continue" })); + expect(choices[0]).toEqual({ + provider: "openai-compatible", + login: "endpoint", + baseUrl: provider.baseUrl, + model: provider.model, + apiKey: "synthetic-provider-key", + }); + await user.clear(view.getByLabelText("Model name")); + await user.type( + view.getByLabelText("Model name"), + "another-compatible-model", + ); + await user.click(view.getByRole("button", { name: "Continue" })); + expect(choices[1]).toEqual({ + provider: "openai-compatible", + login: "endpoint", + baseUrl: provider.baseUrl, + model: "another-compatible-model", + apiKey: "synthetic-provider-key", + }); + }); + + test(`${provider.name} restores its choice when returning from the next setup step`, async () => { + invokeHandler = async (command) => { + if (command === "providers") return allProviders; + throw new Error(`unexpected protected command ${command}`); + }; + const chosen: ModelChoice = { + provider: "openai-compatible", + login: "endpoint", + baseUrl: provider.baseUrl, + model: "chosen-model", + apiKey: "synthetic-returning-key", + }; + const choices: ModelChoice[] = []; + let view!: ReturnType; + await act(async () => { + view = render( + {}} + onChoose={(choice) => choices.push(choice)} + />, + ); + }); + expect( + await view.findByRole("radio", { name: new RegExp(provider.name) }), + ).toHaveProperty("checked", true); + expect(view.getByLabelText(`${provider.name} API key`)).toHaveProperty( + "value", + "synthetic-returning-key", + ); + expect(view.getByLabelText("Model name")).toHaveProperty( + "value", + "chosen-model", + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + expect(choices).toEqual([chosen]); + }); + + test(`${provider.name} recognizes a saved endpoint but never carries its key to another provider`, async () => { + invokeHandler = async (command) => { + if (command === "providers") return allProviders; + throw new Error(`unexpected protected command ${command}`); + }; + // A trailing slash is an equivalent UI identity, but the saved URL itself must + // survive unchanged so backend credential matching remains endpoint-scoped. + const savedUrl = provider.baseUrl.endsWith("/") + ? provider.baseUrl.slice(0, -1) + : `${provider.baseUrl}/`; + const choices: unknown[] = []; + const view = await renderPickerWithHeld( + { + OPENAI_BASE_URL: savedUrl, + BOT_MODEL: "saved-model", + saved: { + model: "compatible-endpoint", + modelApiKeys: { compatible: true, openai: true }, + }, + }, + (choice) => choices.push(choice), + ); + expect( + await view.findByRole("radio", { name: new RegExp(provider.name) }), + ).toHaveProperty("checked", true); + expect(view.getByLabelText(`${provider.name} API key`)).toHaveProperty( + "value", + "", + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + expect(choices[0]).toEqual({ + provider: "openai-compatible", + login: "endpoint", + baseUrl: savedUrl, + model: "saved-model", + saved: true, + }); + + await userEvent.type( + view.getByLabelText(`${provider.name} API key`), + "synthetic-replacement-key", + ); + const other = cloudProviders.find( + (candidate) => candidate.id !== provider.id, + )!; + await userEvent.click( + view.getByRole("radio", { name: new RegExp(other.name) }), + ); + expect(view.getByLabelText(`${other.name} API key`)).toHaveProperty( + "value", + "", + ); + expect(view.queryByText(/A saved API key for this endpoint/)).toBeNull(); + expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( + "disabled", + true, + ); + await userEvent.type( + view.getByLabelText(`${other.name} API key`), + "synthetic-other-key", + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + expect(choices[1]).toEqual({ + provider: "openai-compatible", + login: "endpoint", + baseUrl: other.baseUrl, + model: other.model, + apiKey: "synthetic-other-key", + }); + }); +} diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index 8acfd578e..dc833966a 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useEffect, useRef, useState } from "react"; +import { ExternalLink } from "./ExternalLink"; import { isHttpEndpointUrl } from "./http-endpoint-url"; import { Mark } from "./Mark"; import { asProblem, InlineFailure, type Problem } from "./Problem"; @@ -57,6 +58,42 @@ export type HeldConfiguration = { saved?: SavedConfiguration; }; +const endpointPresets: Record< + string, + { baseUrl: string; model: string; keyUrl: string } +> = { + // https://ai.google.dev/gemini-api/docs/openai + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + model: "gemini-3.8-flash", + keyUrl: "https://aistudio.google.com/apikey", + }, + // https://docs.x.ai/developers/model-capabilities/legacy/chat-completions + xai: { + baseUrl: "https://api.x.ai/v1", + model: "grok-4.7", + keyUrl: "https://console.x.ai/", + }, +}; + +function endpointIdentity(baseUrl: string | undefined): string { + if (!baseUrl) return ""; + try { + return new URL(baseUrl.trim()).href.replace(/\/+$/, ""); + } catch { + return ""; + } +} + +function endpointProvider(baseUrl: string | undefined): string { + return ( + Object.entries(endpointPresets).find( + ([, preset]) => + endpointIdentity(preset.baseUrl) === endpointIdentity(baseUrl), + )?.[0] ?? "openai-compatible" + ); +} + export function recordedModel(held: HeldConfiguration): ModelChoice | null { switch (held.saved?.model) { case "open-ai-api-key": @@ -84,13 +121,8 @@ export function recordedModel(held: HeldConfiguration): ModelChoice | null { /** * Connect a model. * - * Two providers are first-class and everything else is one row, which is the shape rather than a - * shortlist. See the build doc: growing this into a directory is how the screen stops being - * finishable by somebody who has never opened a terminal. - * - * A PLAN IS THE DEFAULT WHEREVER ONE EXISTS, and the key sits beside it rather than behind it. - * Anybody with a key and a base URL to hand is a developer; everybody else has a plan they already - * pay for, and asking them for a key is asking them to go and get one. + * Plans remain the default wherever supported. Google and xAI use the same endpoint credential + * route as custom models, while their named rows supply the address for the user. */ export function ProviderPicker({ chosen, @@ -123,7 +155,9 @@ export function ProviderPicker({ ); const [rows, setRows] = useState([]); const [open, setOpen] = useState( - initialChoice?.provider ?? null, + initialChoice?.provider === "openai-compatible" + ? endpointProvider(initialChoice.baseUrl) + : (initialChoice?.provider ?? null), ); const [login, setLogin] = useState( initialChoice?.login ?? null, @@ -265,6 +299,7 @@ export function ProviderPicker({ }, []); const row = rows.find((r) => r.id === open) ?? null; + const preset = row ? endpointPresets[row.id] : undefined; const token = row ? (tokens[row.id] ?? "") : ""; const savedPlan = row?.id === "openai" || row?.id === "anthropic" @@ -277,7 +312,7 @@ export function ProviderPicker({ (reuse?.provider === row.id && reuse.login === "api-key") : false; const savedEndpointKey = - row?.id === "openai-compatible" && + login === "endpoint" && reuseEndpointKey && held.saved?.modelApiKeys?.compatible === true && baseUrl.trim() === held.OPENAI_BASE_URL?.trim(); @@ -298,7 +333,8 @@ export function ProviderPicker({ (login === "endpoint" && isHttpEndpointUrl(baseUrl) && containerBaseUrlIsValid && - model.trim().length > 0); + model.trim().length > 0 && + (!preset || apiKey.trim().length > 0 || savedEndpointKey)); function continueWithChoice() { if (!row || !login || !ready) return; @@ -308,7 +344,7 @@ export function ProviderPicker({ const trimmedModel = model.trim(); const trimmedContainerBaseUrl = containerBaseUrl.trim(); onChoose({ - provider: row.id, + provider: login === "endpoint" ? "openai-compatible" : row.id, login, ...((login === "api-key" || login === "endpoint") && trimmedApiKey ? { apiKey: trimmedApiKey } @@ -332,7 +368,7 @@ export function ProviderPicker({ {!returning &&

    Step 3 of 4

    }

    {returning ? "Refresh your AI connection" : "Connect your AI"}

    - Sign in to the plan you already pay for. No key needed. + Connect your provider with a supported plan or an API key.

    @@ -369,18 +405,23 @@ export function ProviderPicker({ ? held.ANTHROPIC_API_KEY : undefined; setApiKey(kept ?? ""); + const nextPreset = endpointPresets[r.id]; + const restoreEndpoint = + r.id === "openai-compatible" || + (nextPreset && + endpointProvider(held.OPENAI_BASE_URL) === r.id); setReuseEndpointKey( - r.id === "openai-compatible" && + Boolean(restoreEndpoint) && held.saved?.modelApiKeys?.compatible === true, ); - if (r.id === "openai-compatible" && held.OPENAI_BASE_URL) { + if (restoreEndpoint && held.OPENAI_BASE_URL) { setBaseUrl(held.OPENAI_BASE_URL); setContainerBaseUrl(held.OPENAI_CONTAINER_BASE_URL ?? ""); - setModel(held.BOT_MODEL ?? ""); + setModel(held.BOT_MODEL ?? nextPreset?.model ?? ""); } else { - setBaseUrl(""); + setBaseUrl(nextPreset?.baseUrl ?? ""); setContainerBaseUrl(""); - setModel(""); + setModel(nextPreset?.model ?? ""); } }} /> @@ -553,54 +594,60 @@ export function ProviderPicker({ {savedEndpointKey && !apiKey && (

    A saved API key for this endpoint will be used.{" "} - + {!preset && ( + + )}

    )} -
    - - { - const next = e.target.value; - setBaseUrl(next); - if ( - held.OPENAI_CONTAINER_BASE_URL && - containerBaseUrl.trim() === - held.OPENAI_CONTAINER_BASE_URL.trim() && - next.trim() !== held.OPENAI_BASE_URL?.trim() - ) { - setContainerBaseUrl(""); - } - }} - placeholder="https://…/v1" - spellCheck={false} - /> -
    -
    - Advanced compatible endpoint options - - setContainerBaseUrl(e.target.value)} - placeholder="http://ollama:11434/v1" - spellCheck={false} - /> -

    - Leave this empty unless containers need a different address - for a locally hosted model. Remote endpoints usually use the - same Base URL. -

    -
    + {!preset && ( + <> +
    + + { + const next = e.target.value; + setBaseUrl(next); + if ( + held.OPENAI_CONTAINER_BASE_URL && + containerBaseUrl.trim() === + held.OPENAI_CONTAINER_BASE_URL.trim() && + next.trim() !== held.OPENAI_BASE_URL?.trim() + ) { + setContainerBaseUrl(""); + } + }} + placeholder="https://…/v1" + spellCheck={false} + /> +
    +
    + Advanced compatible endpoint options + + setContainerBaseUrl(e.target.value)} + placeholder="http://ollama:11434/v1" + spellCheck={false} + /> +

    + Leave this empty unless containers need a different + address for a locally hosted model. Remote endpoints + usually use the same Base URL. +

    +
    + + )}
    - +
    + {preset && ( +

    + + Get a {row.name} API key + +

    + )} )} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index ed57eb1c7..049420746 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -149,6 +149,33 @@ h2 { margin-top: 1.5rem; } +.connection-sheet .row { + flex-wrap: wrap; +} + +.connection-form { + width: 100%; + min-width: 0; + border: 0; + padding: 0; + margin: 0; +} + +.connection-actions { + margin-top: 0; +} + +.new-project-form { + margin-top: 1rem; +} + +.tile.project-choice { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "name"; + text-align: left; + overflow-wrap: anywhere; +} + button { font: inherit; font-weight: 500; @@ -465,26 +492,89 @@ details > summary:hover { font-size: 0.85rem; } +.setup-help { + display: inline-block; + margin-top: 0.65rem; + color: var(--ink-soft); + font-size: 0.8rem; + text-underline-offset: 2px; +} + +.external-link-error { + display: block; + margin-top: 0.5rem; + color: var(--ink-soft); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + /* Per-item progress, because this is the slowest screen in the product. */ .steps { - margin-top: 1.25rem; + margin: 1.25rem 0 0; + padding: 0; + list-style: none; display: flex; flex-direction: column; - gap: 0.3rem; + gap: 0.8rem; } .step { display: grid; - grid-template-columns: 1rem 1fr auto; - gap: 0.5rem; + grid-template-columns: 1rem minmax(0, 1fr) auto; + grid-template-areas: + "mark label status" + ". detail detail"; + column-gap: 0.5rem; + row-gap: 0.15rem; align-items: baseline; font-size: 0.82rem; } .step .mark { + grid-area: mark; + align-self: start; font-size: 0.75rem; } +.step-label { + grid-area: label; +} + +.step-status { + grid-area: status; + color: var(--ink-soft); + font-size: 0.72rem; + text-align: right; +} + +.step-elapsed { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.step-spinner { + display: inline-block; + width: 0.85rem; + height: 0.85rem; + margin-top: 0.2rem; + border: 2px solid var(--hair); + border-top-color: var(--ink); + border-radius: 50%; + animation: step-spin 0.8s linear infinite; +} + +@keyframes step-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .step-spinner { + animation: none; + } +} + .step .mark.good { color: var(--good); } @@ -493,9 +583,24 @@ details > summary:hover { } .step .detail { - color: var(--ink-faint); + grid-area: detail; + color: var(--ink-soft); font-size: 0.75rem; - text-align: right; + overflow-wrap: anywhere; +} + +@media (max-width: 580px) { + .step { + grid-template-columns: 1rem minmax(0, 1fr); + grid-template-areas: + "mark label" + ". status" + ". detail"; + } + + .step-status { + text-align: left; + } } .sr-only { From 5111cf28c772b3675c0312592b276c0f739a39d7 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 11:49:58 -0700 Subject: [PATCH 02/11] feat(desktop): add Google and xAI provider OAuth --- CHANGELOG.md | 5 +- desktop/PROVIDER_OAUTH.md | 52 ++ desktop/src-tauri/src/env.rs | 36 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 82 ++++ desktop/src-tauri/src/provider.rs | 12 +- desktop/src-tauri/src/provider_oauth.rs | 617 ++++++++++++++++++++++++ desktop/src-tauri/src/saved_intent.rs | 18 + desktop/src-tauri/src/stack.rs | 6 +- desktop/src/App.tsx | 3 + desktop/src/ProviderPicker.test.tsx | 91 ++++ desktop/src/ProviderPicker.tsx | 173 ++++++- server/src/app.ts | 7 + server/src/index.ts | 4 + server/src/provider-oauth.ts | 303 ++++++++++++ server/tests/provider-oauth.test.ts | 285 +++++++++++ 16 files changed, 1678 insertions(+), 17 deletions(-) create mode 100644 desktop/PROVIDER_OAUTH.md create mode 100644 desktop/src-tauri/src/provider_oauth.rs create mode 100644 server/src/provider-oauth.ts create mode 100644 server/tests/provider-oauth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f54fd6252..a167c3ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. Downloads show transferred bytes and elapsed time, Back preserves saved connections, and repair stays under Installation options after setup. OpenBot selects and remembers usable local ports, including when Windows reserves a default port. Setup can create a CopilotKit project and offers -Google Gemini and xAI API-key choices. Unsupported Bun installations are replaced with the pinned -runtime; startup errors retain useful details and provide a configurable setup-help link. +Google Gemini and xAI API-key choices, plus OAuth sign-in with automatic token refresh. Google +OAuth requires the distributor's desktop client and quota-project configuration. Unsupported Bun +installations are replaced with the pinned runtime; startup errors retain useful details and provide a configurable setup-help link. ## 0.0.14 diff --git a/desktop/PROVIDER_OAUTH.md b/desktop/PROVIDER_OAUTH.md new file mode 100644 index 000000000..8293adcc4 --- /dev/null +++ b/desktop/PROVIDER_OAUTH.md @@ -0,0 +1,52 @@ +# Model provider OAuth + +OpenBot's Google and xAI model connections are separate from signing in to +OpenBot or CopilotKit. API keys remain available for both providers. + +## Google Gemini + +Google OAuth uses the Gemini Developer API and the selected Google Cloud +project's API quota. It does not use a personal Gemini subscription. + +Before distributing a configured desktop build, register a **Desktop app** OAuth +client in a Google Cloud project with the Generative Language API enabled. Set +these variables when building the desktop app to include its defaults. Runtime +process environment variables can override those defaults: + +- `OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID`: that desktop client's ID. +- `OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET`: the client secret, when issued. +- `OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT`: the project that supplies API quota. + +Do not reuse OpenBot's web SSO credentials (`GOOGLE_OAUTH_CLIENT_ID` and +`GOOGLE_OAUTH_CLIENT_SECRET`): their callback and permissions serve a different +purpose. Configure the consent screen and test users, and complete Google's +verification requirements before wider distribution. + +References: [Gemini API OAuth](https://ai.google.dev/gemini-api/docs/oauth) and +[Google desktop OAuth](https://developers.google.com/identity/protocols/oauth2/native-app). + +## xAI + +xAI sign-in uses device authorization: OpenBot opens the provider's verification +page, the user approves the displayed code, and OpenBot receives refreshable +credentials. API calls use `https://api.x.ai/v1`. + +The integration follows the public OAuth flow in xAI's endorsed OpenCode +integration. A distributor can set `OPENBOT_XAI_MODEL_OAUTH_CLIENT_ID` to an +alternative registered client. Account entitlements and provider limits still +apply. + +References: [xAI's OpenCode integration](https://x.ai/news/grok-opencode) and +[xAI OAuth discovery](https://auth.x.ai/.well-known/openid-configuration). + +## Credential lifecycle + +The desktop's provider credentials remain in its local deployment; React does +not receive access or refresh tokens. The local server refreshes credentials +before model calls and saves rotated tokens. Agent processes receive a local +proxy credential instead of the provider's refresh token. Provider revocation +requires signing in again. + +Do not commit deployment credential files or include their contents in support +reports. OAuth validation must include a real model call, refresh and restart, +and a Bot using its browser tool and rendering a graphical component. diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index ae5ba6239..2ee9a3341 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -391,6 +391,7 @@ pub fn compose( "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "CHATGPT_AUTH_FILE", + "OPENBOT_MODEL_OAUTH_FILE", "BOT_PROVIDER", /* * Retired, and cleared for exactly that reason. An earlier version put the ChatGPT @@ -414,7 +415,10 @@ pub fn compose( * Removed rather than emptied, so `docker-compose.yml`'s own default applies. Blank would * be passed through as a model named "", which is a worse question to ask a provider. */ - if !matches!(model.credential, ModelCredential::Compatible { .. }) { + if !matches!( + model.credential, + ModelCredential::Compatible { .. } | ModelCredential::ProviderOAuth { .. } + ) { for key in ["BOT_MODEL", "AGENT_BOT_MODEL"] { env.remove(key); } @@ -454,6 +458,29 @@ pub fn compose( env.insert("CHATGPT_AUTH_FILE".into(), CHATGPT_STORE_INSIDE.into()); } } + ModelCredential::ProviderOAuth { + path, + proxy_token, + model, + .. + } => { + env.insert("OPENBOT_MODEL_OAUTH_FILE".into(), path.clone()); + env.insert("OPENAI_API_KEY".into(), proxy_token.clone()); + env.insert( + "OPENAI_BASE_URL".into(), + format!("http://127.0.0.1:{}/api/model-provider/v1", ports.server), + ); + env.insert( + "OPENAI_CONTAINER_BASE_URL".into(), + format!( + "http://host.docker.internal:{}/api/model-provider/v1", + ports.server + ), + ); + env.insert("BOT_PROVIDER".into(), "openai".into()); + env.insert("BOT_MODEL".into(), model.clone()); + env.insert("AGENT_BOT_MODEL".into(), model.clone()); + } ModelCredential::Compatible { base_url, container_base_url, @@ -822,6 +849,13 @@ pub enum ModelCredential { build doc. */ ChatGptPlan { store: String }, + /// A native-held OAuth session; the local proxy owns access-token refresh. + ProviderOAuth { + provider: String, + path: String, + proxy_token: String, + model: String, + }, /// Anything that speaks the OpenAI wire format, at an address the person gave. /// /// Also where a signed-in ChatGPT plan lands, because that login yields a token and the address diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b90fbc680..1699eae5f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ pub mod plan; pub mod preparation; pub mod problem; pub mod provider; +pub mod provider_oauth; pub mod pull_metrics; pub mod quiet; pub mod saved_intent; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index f1e612ae5..2bf76739c 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -209,6 +209,10 @@ struct SavedModelApiKeys { struct SavedModelSessions { openai: Option, anthropic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + google: Option, + #[serde(skip_serializing_if = "Option::is_none")] + xai: Option, } #[derive(Serialize)] @@ -828,6 +832,18 @@ impl ChosenModel { let given = |value: Option| value.unwrap_or_default().trim().to_string(); let saved = self.saved.unwrap_or(false); match (self.provider.as_str(), self.login.as_str()) { + ("google" | "xai", "oauth") => { + let saved = openbot_desktop_lib::provider_oauth::read(root, &self.provider) + .map_err(Problem::plain)?; + let model = given(self.model); + if model.is_empty() { return Err("Choose a model for this provider.".into()); } + Ok(openbot_env::ModelCredential::ProviderOAuth { + provider: self.provider, + path: root.join(openbot_desktop_lib::provider_oauth::FILE).to_string_lossy().into_owned(), + proxy_token: saved.proxy_token, + model, + }) + } ("openai", "api-key") => { let api_key = if saved { saved_secret(root, "OPENAI_API_KEY")? @@ -2654,6 +2670,8 @@ fn already_configured_for_root(root: String) -> AlreadyConfigured { openbot_env::saved_chatgpt_plan_store(&root), ), anthropic: hint(Category::ClaudePlan, claude_plan), + google: hint(Category::GoogleOauth, false), + xai: hint(Category::XaiOauth, false), }, model: intent.model, }, @@ -2952,6 +2970,32 @@ fn providers() -> Vec { provider::catalogue() } +#[tauri::command] +async fn begin_model_oauth( + root: String, + provider: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + openbot_desktop_lib::provider_oauth::begin(&stack::root_from(&root), &provider) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn finish_model_oauth(attempt_id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + openbot_desktop_lib::provider_oauth::finish(&attempt_id) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +fn cancel_model_oauth(attempt_id: String) { + openbot_desktop_lib::provider_oauth::cancel(&attempt_id); +} + /// `bun` from PATH, or the places an installer puts it when PATH has not been reloaded. fn which_bun() -> Option { if quiet::command("bun") @@ -3392,6 +3436,9 @@ fn main() { begin_claude_sign_in, finish_claude_sign_in, begin_chatgpt_sign_in, + begin_model_oauth, + finish_model_oauth, + cancel_model_oauth, finish_chatgpt_sign_in, begin_intelligence_sign_in, finish_intelligence_sign_in, @@ -4384,6 +4431,41 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn oauth_start_reads_private_session_without_contacting_unstarted_proxy() { + let root = temp_root("oauth-start"); + std::fs::create_dir_all(root.join(".openbot")).unwrap(); + for provider in ["google", "xai"] { + let path = root.join(openbot_desktop_lib::provider_oauth::FILE); + let record = serde_json::json!({"version":1,"sessionId":"synthetic-session","provider":provider,"clientId":"synthetic-client","accessToken":"synthetic-access","refreshToken":"synthetic-refresh","expiresAt":1,"scope":"synthetic","proxyToken":"synthetic-proxy"}); + std::fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); + let choice = ChosenModel { + provider: provider.into(), + login: "oauth".into(), + api_key: None, + base_url: None, + container_base_url: None, + model: Some("chosen-model".into()), + token: None, + saved: Some(true), + }; + let credential = start_stack_credential_with(&root, choice, |_, _| { + panic!("OAuth must not resolve an API key") + }) + .unwrap(); + assert_eq!( + credential, + openbot_env::ModelCredential::ProviderOAuth { + provider: provider.into(), + path: path.to_string_lossy().into_owned(), + proxy_token: "synthetic-proxy".into(), + model: "chosen-model".into() + } + ); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn start_and_ask_resolve_saved_secrets_from_the_selected_root() { let root_a = temp_root("selected-saved-root-a"); diff --git a/desktop/src-tauri/src/provider.rs b/desktop/src-tauri/src/provider.rs index 48d734df2..7a24ad2d5 100644 --- a/desktop/src-tauri/src/provider.rs +++ b/desktop/src-tauri/src/provider.rs @@ -22,6 +22,8 @@ pub enum Login { ApiKey, /// A base URL, a key and a model name. The developer row and the everything-else row at once. Endpoint, + /// Browser authorization for the provider's model API, with renewable credentials. + Oauth, } /// One row on the model screen. @@ -89,16 +91,16 @@ pub fn catalogue() -> Vec { Provider { id: "google".into(), name: "Google Gemini".into(), - summary: "Use Gemini with a Google AI Studio API key.".into(), - logins: vec![Login::Endpoint], + summary: "Use a Google AI Studio key or sign in for Gemini API access.".into(), + logins: vec![Login::Endpoint, Login::Oauth], mark: None, caution: None, }, Provider { id: "xai".into(), name: "xAI".into(), - summary: "Use Grok with an xAI API key.".into(), - logins: vec![Login::Endpoint], + summary: "Use an xAI API key or sign in to your xAI account.".into(), + logins: vec![Login::Endpoint, Login::Oauth], mark: None, caution: None, }, @@ -132,7 +134,7 @@ mod tests { .into_iter() .filter(|provider| provider.logins.contains(&Login::Endpoint)) { - assert_eq!(provider.logins, vec![Login::Endpoint]); + assert!(!provider.logins.contains(&Login::Plan)); } } diff --git a/desktop/src-tauri/src/provider_oauth.rs b/desktop/src-tauri/src/provider_oauth.rs new file mode 100644 index 000000000..336a8d78f --- /dev/null +++ b/desktop/src-tauri/src/provider_oauth.rs @@ -0,0 +1,617 @@ +//! Desktop provider authorization. Tokens stay in the installation's private runtime store. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + io::{Read, Write}, + net::TcpListener, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +pub const FILE: &str = ".openbot/model-oauth.json"; +const XAI_CLIENT: &str = "b1a00492-073a-47ea-816f-4c329264a828"; +const XAI_SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; +const GOOGLE_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever"; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Credentials { + pub version: u8, + pub session_id: String, + pub provider: String, + pub client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + pub access_token: String, + pub refresh_token: String, + pub expires_at: u64, + pub scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub quota_project: Option, + pub proxy_token: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Authorization { + pub attempt_id: String, + pub url: String, + pub user_code: Option, +} + +struct Flow { + id: String, + root: PathBuf, + canceled: AtomicBool, + pending: Mutex>, +} +enum Pending { + Google { + listener: TcpListener, + verifier: String, + state: String, + redirect: String, + client_id: String, + client_secret: Option, + project: String, + }, + Xai { + client_id: String, + device_code: String, + interval: u64, + expires: Instant, + }, +} +static ACTIVE: OnceLock>>> = OnceLock::new(); +fn active() -> &'static Mutex>> { + ACTIVE.get_or_init(|| Mutex::new(None)) +} +fn random() -> String { + let mut bytes = [0; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} +fn configured(name: &str) -> Option { + let embedded = match name { + "OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID" => { + option_env!("OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID") + } + "OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET" => { + option_env!("OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET") + } + "OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT" => { + option_env!("OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT") + } + "OPENBOT_XAI_MODEL_OAUTH_CLIENT_ID" => option_env!("OPENBOT_XAI_MODEL_OAUTH_CLIENT_ID"), + _ => None, + }; + std::env::var(name) + .ok() + .or_else(|| embedded.map(str::to_owned)) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) +} +fn http() -> Result { + reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(25)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("OpenBot/desktop-provider-oauth") + .build() + .map_err(|e| e.to_string()) +} +fn response_json(response: reqwest::blocking::Response) -> Result { + let status = response.status(); + let value: serde_json::Value = response + .json() + .map_err(|_| "The provider returned an unreadable sign-in response.".to_string())?; + if !status.is_success() { + return Err(format!( + "Provider sign-in failed ({}): {}", + status.as_u16(), + value + .get("error_description") + .or_else(|| value.get("error")) + .and_then(|v| v.as_str()) + .unwrap_or("request refused") + )); + } + Ok(value) +} +fn required(value: &serde_json::Value, field: &str) -> Result { + value + .get(field) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("The provider omitted {field} from its sign-in response.")) +} +fn reserve(root: &Path) -> Arc { + let flow = Arc::new(Flow { + id: random(), + root: root.to_owned(), + canceled: AtomicBool::new(false), + pending: Mutex::new(None), + }); + if let Some(previous) = active().lock().unwrap().replace(flow.clone()) { + previous.canceled.store(true, Ordering::SeqCst); + } + flow +} +pub fn begin(root: &Path, provider: &str) -> Result { + // Reserve before blocking network work so a late response cannot replace a newer sign-in. + let flow = reserve(root); + let (pending, url, user_code) = match provider { + "google" => { + let client_id = configured("OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID").ok_or("Google sign-in needs this OpenBot build's registered desktop OAuth client. Use an API key until it is configured.")?; + let project = configured("OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT").ok_or( + "Google sign-in needs a Google Cloud quota project with the Gemini API enabled.", + )?; + let client_secret = configured("OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET"); + let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; + listener.set_nonblocking(true).map_err(|e| e.to_string())?; + let redirect = format!( + "http://127.0.0.1:{}/oauth/callback", + listener.local_addr().map_err(|e| e.to_string())?.port() + ); + let verifier = random(); + let state = random(); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + let mut url = + reqwest::Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(); + url.query_pairs_mut().extend_pairs([ + ("client_id", client_id.as_str()), + ("redirect_uri", redirect.as_str()), + ("response_type", "code"), + ("scope", GOOGLE_SCOPE), + ("access_type", "offline"), + ("prompt", "consent"), + ("state", state.as_str()), + ("code_challenge", challenge.as_str()), + ("code_challenge_method", "S256"), + ]); + ( + Pending::Google { + listener, + verifier, + state, + redirect, + client_id, + client_secret, + project, + }, + url.to_string(), + None, + ) + } + "xai" => { + let client_id = configured("OPENBOT_XAI_MODEL_OAUTH_CLIENT_ID") + .unwrap_or_else(|| XAI_CLIENT.into()); + let body = response_json( + http()? + .post("https://auth.x.ai/oauth2/device/code") + .form(&[ + ("client_id", client_id.as_str()), + ("scope", XAI_SCOPE), + ("referrer", "openbot"), + ]) + .send() + .map_err(|e| e.to_string())?, + )?; + let url = body + .get("verification_uri_complete") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .unwrap_or(required(&body, "verification_uri")?); + let user_code = required(&body, "user_code")?; + let device_code = required(&body, "device_code")?; + let interval = body + .get("interval") + .and_then(|v| v.as_u64()) + .unwrap_or(5) + .max(1); + let expires = Instant::now() + + Duration::from_secs( + body.get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(600) + .min(1800), + ); + ( + Pending::Xai { + client_id, + device_code, + interval, + expires, + }, + url, + Some(user_code), + ) + } + _ => return Err("That provider does not support this sign-in flow.".into()), + }; + check(&flow)?; + *flow.pending.lock().unwrap() = Some(pending); + Ok(Authorization { + attempt_id: flow.id.clone(), + url, + user_code, + }) +} + +pub fn cancel(id: &str) { + let mut current = active().lock().unwrap(); + if current.as_ref().is_some_and(|flow| flow.id == id) { + if let Some(flow) = current.take() { + flow.canceled.store(true, Ordering::SeqCst); + } + } +} +fn check(flow: &Flow) -> Result<(), String> { + if flow.canceled.load(Ordering::SeqCst) { + Err("Sign-in was canceled.".into()) + } else { + Ok(()) + } +} +fn wait(flow: &Flow, duration: Duration) -> Result<(), String> { + let end = Instant::now() + duration; + while Instant::now() < end { + check(flow)?; + std::thread::sleep( + Duration::from_millis(100).min(end.saturating_duration_since(Instant::now())), + ); + } + check(flow) +} + +pub fn finish(id: &str) -> Result<(), String> { + let flow = active() + .lock() + .unwrap() + .as_ref() + .filter(|flow| flow.id == id) + .cloned() + .ok_or("That sign-in is no longer active.")?; + let pending = flow + .pending + .lock() + .unwrap() + .take() + .ok_or("That sign-in is already being completed.")?; + let result = finish_flow(&flow, pending); + let mut current = active().lock().unwrap(); + if current.as_ref().is_some_and(|current| current.id == id) { + current.take(); + } + result +} +fn finish_flow(flow: &Flow, pending: Pending) -> Result<(), String> { + let (provider, client_id, client_secret, project, scope, tokens) = match pending { + Pending::Google { + listener, + verifier, + state, + redirect, + client_id, + client_secret, + project, + } => { + let deadline = Instant::now() + Duration::from_secs(600); + let code = loop { + check(flow)?; + if Instant::now() >= deadline { + return Err("Google sign-in timed out. Try signing in again.".into()); + } + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|e| e.to_string())?; + let mut bytes = [0; 8192]; + let count = stream.read(&mut bytes).map_err(|e| e.to_string())?; + let request = String::from_utf8_lossy(&bytes[..count]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or(""); + let parsed = reqwest::Url::parse(&format!("http://localhost{path}")) + .map_err(|_| "Invalid sign-in callback.")?; + let query: std::collections::HashMap<_, _> = + parsed.query_pairs().into_owned().collect(); + if parsed.path() != "/oauth/callback" || query.get("state") != Some(&state) + { + let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nInvalid sign-in callback."); + continue; + } + if let Some(error) = query.get("error") { + let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nSign-in was not approved. Return to OpenBot."); + return Err(format!("Google sign-in was not approved: {error}")); + } + let code = query + .get("code") + .filter(|v| !v.is_empty()) + .cloned() + .ok_or("Google did not return an authorization code.")?; + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nReturn to OpenBot to finish signing in.").map_err(|e| e.to_string())?; + break code; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + wait(flow, Duration::from_millis(100))? + } + Err(error) => return Err(error.to_string()), + } + }; + let mut form = vec![ + ("client_id", client_id.as_str()), + ("code", code.as_str()), + ("code_verifier", verifier.as_str()), + ("redirect_uri", redirect.as_str()), + ("grant_type", "authorization_code"), + ]; + if let Some(secret) = &client_secret { + form.push(("client_secret", secret)); + } + let tokens = response_json( + http()? + .post("https://oauth2.googleapis.com/token") + .form(&form) + .send() + .map_err(|e| e.to_string())?, + )?; + ( + "google", + client_id, + client_secret, + Some(project), + GOOGLE_SCOPE, + tokens, + ) + } + Pending::Xai { + client_id, + device_code, + mut interval, + expires, + } => { + let tokens = loop { + if Instant::now() >= expires { + return Err("xAI sign-in expired. Try signing in again.".into()); + } + wait(flow, Duration::from_secs(interval))?; + let response = http()? + .post("https://auth.x.ai/oauth2/token") + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", client_id.as_str()), + ("device_code", device_code.as_str()), + ]) + .send() + .map_err(|e| e.to_string())?; + let status = response.status(); + let body: serde_json::Value = response + .json() + .map_err(|_| "xAI returned an unreadable sign-in response.")?; + if status.is_success() { + break body; + } + match body.get("error").and_then(|v| v.as_str()) { + Some("authorization_pending") => continue, + Some("slow_down") => { + interval = interval.saturating_add(5); + continue; + } + Some("access_denied") => return Err("xAI sign-in was not approved.".into()), + Some("expired_token") => { + return Err("xAI sign-in expired. Try signing in again.".into()) + } + _ => { + return Err(format!( + "xAI sign-in failed ({}). Try signing in again.", + status.as_u16() + )) + } + } + }; + ("xai", client_id, None, None, XAI_SCOPE, tokens) + } + }; + check(flow)?; + let credentials = + credentials_from_tokens(provider, client_id, client_secret, project, scope, &tokens)?; + write(&flow.root, &credentials, || check(flow)) +} +fn credentials_from_tokens( + provider: &str, + client_id: String, + client_secret: Option, + quota_project: Option, + scope: &str, + tokens: &serde_json::Value, +) -> Result { + let duration = tokens + .get("expires_in") + .and_then(|v| v.as_u64()) + .filter(|v| *v > 0) + .or_else(|| (provider == "xai").then_some(3600)) + .ok_or("The provider did not return a token expiry.")?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis() as u64; + Ok(Credentials { + version: 1, + session_id: random(), + provider: provider.into(), + client_id, + client_secret, + access_token: required(tokens, "access_token")?, + refresh_token: required(tokens, "refresh_token")?, + expires_at: now.saturating_add(duration.saturating_mul(1000)), + scope: tokens + .get("scope") + .and_then(|v| v.as_str()) + .unwrap_or(scope) + .into(), + quota_project, + proxy_token: random(), + }) +} +pub fn read(root: &Path, provider: &str) -> Result { + let bytes = std::fs::read(root.join(FILE)) + .map_err(|_| "The saved provider sign-in is unavailable. Sign in again.")?; + let saved: Credentials = serde_json::from_slice(&bytes) + .map_err(|_| "The saved provider sign-in is unreadable. Sign in again.")?; + if saved.version != 1 + || saved.provider != provider + || saved.refresh_token.is_empty() + || saved.proxy_token.is_empty() + { + return Err("The saved sign-in does not match this provider. Sign in again.".into()); + } + Ok(saved) +} +fn write( + root: &Path, + credentials: &Credentials, + current: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let path = root.join(FILE); + let directory = path.parent().unwrap(); + std::fs::create_dir_all(directory).map_err(|e| e.to_string())?; + let metadata = std::fs::symlink_metadata(directory).map_err(|e| e.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("The provider credential directory must be a plain directory.".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + #[cfg(windows)] + { + use std::os::windows::{fs::MetadataExt, process::CommandExt}; + if metadata.file_attributes() & 0x400 != 0 { + return Err("The provider credential directory cannot be redirected.".into()); + } + // Give new atomically replaced files an owner-only inherited DACL, including runtime refreshes. + let script = "$ErrorActionPreference='Stop'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; $acl=New-Object System.Security.AccessControl.DirectorySecurity; $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); $rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow'); $acl.AddAccessRule($rule); Set-Acl -LiteralPath $env:OPENBOT_OAUTH_DIRECTORY -AclObject $acl"; + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("OPENBOT_OAUTH_DIRECTORY", directory) + .creation_flags(0x08000000) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err( + "OpenBot could not make the provider sign-in private to your Windows account." + .into(), + ); + } + } + let lock = PathBuf::from(format!("{}.lock", path.display())); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match std::fs::create_dir(&lock) { + Ok(()) => break, + Err(error) + if error.kind() == std::io::ErrorKind::AlreadyExists + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(50)) + } + Err(error) => return Err(format!("Could not save provider sign-in: {error}")), + } + } + let result = current().and_then(|()| { + serde_json::to_vec(credentials) + .map_err(|e| e.to_string()) + .and_then(|bytes| { + crate::env::write_private_file(&path, &bytes).map_err(|e| e.to_string()) + }) + }); + let unlock = std::fs::remove_dir(lock).map_err(|e| e.to_string()); + result.and(unlock) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn tokens_require_refresh_and_expiry() { + let missing = serde_json::json!({"access_token":"synthetic", "expires_in":3600}); + assert!( + credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &missing) + .is_err() + ); + let complete = serde_json::json!({"access_token":"synthetic", "refresh_token":"synthetic-refresh", "expires_in":3600}); + let saved = + credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &complete) + .unwrap(); + assert_eq!(saved.provider, "xai"); + assert!(saved.expires_at > 0); + assert_ne!(saved.proxy_token, saved.access_token); + } + #[test] + fn xai_tokens_accept_documented_missing_expiry() { + let tokens = + serde_json::json!({"access_token":"synthetic", "refresh_token":"synthetic-refresh"}); + assert!( + credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &tokens).is_ok() + ); + assert!(credentials_from_tokens( + "google", + "client".into(), + None, + Some("project".into()), + GOOGLE_SCOPE, + &tokens + ) + .is_err()); + } + #[test] + fn late_begin_cannot_cancel_newer_authorization() { + let root = crate::test_support::temp_root("oauth-out-of-order"); + let delayed = reserve(&root); + let newer = reserve(&root); + assert!(check(&delayed).is_err()); + assert!(check(&newer).is_ok()); + cancel(&delayed.id); + assert_eq!(active().lock().unwrap().as_ref().unwrap().id, newer.id); + assert!(check(&newer).is_ok()); + cancel(&newer.id); + } + #[test] + fn persisted_credentials_remain_provider_bound() { + let root = crate::test_support::temp_root("provider-oauth"); + let tokens = serde_json::json!({"access_token":"synthetic", "refresh_token":"synthetic-refresh", "expires_in":3600}); + let saved = credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &tokens) + .unwrap(); + write(&root, &saved, || Ok(())).unwrap(); + assert_eq!(read(&root, "xai").unwrap().session_id, saved.session_id); + assert!(read(&root, "google").is_err()); + assert!(!root.join(format!("{FILE}.lock")).exists()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] + fn canceled_sign_in_cannot_replace_current_credentials() { + let root = crate::test_support::temp_root("provider-oauth-canceled"); + let tokens = serde_json::json!({"access_token":"synthetic", "refresh_token":"synthetic-refresh", "expires_in":3600}); + let saved = credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &tokens) + .unwrap(); + write(&root, &saved, || Ok(())).unwrap(); + let mut replacement = saved.clone(); + replacement.session_id = "canceled".into(); + assert!(write(&root, &replacement, || Err("canceled".into())).is_err()); + assert_eq!(read(&root, "xai").unwrap().session_id, saved.session_id); + assert!(!root.join(format!("{FILE}.lock")).exists()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/desktop/src-tauri/src/saved_intent.rs b/desktop/src-tauri/src/saved_intent.rs index 4294fc3b7..255b9286c 100644 --- a/desktop/src-tauri/src/saved_intent.rs +++ b/desktop/src-tauri/src/saved_intent.rs @@ -19,6 +19,8 @@ pub enum Category { ClaudePlan, ChatGptPlan, CompatibleEndpointApiKey, + GoogleOauth, + XaiOauth, } // A closed enum intentionally cannot contain fields from the secret-bearing ChosenModel request. @@ -30,6 +32,8 @@ pub enum ModelIntent { ClaudePlan, ChatGptPlan, CompatibleEndpoint, + GoogleOauth, + XaiOauth, } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -102,6 +106,11 @@ impl SavedIntent { } // write_plan_store persists the selected plan, and clears it for other selections. self.categories.remove(&Category::ChatGptPlan); + self.categories.remove(&Category::GoogleOauth); + self.categories.remove(&Category::XaiOauth); + if matches!(credential, ModelCredential::ProviderOAuth { .. }) { + self.categories.remove(&Category::OpenAiApiKey); + } if matches!(credential, ModelCredential::None) { self.model = None; return; @@ -118,6 +127,15 @@ impl SavedIntent { ModelIntent::ChatGptPlan } ModelCredential::Compatible { .. } => ModelIntent::CompatibleEndpoint, + ModelCredential::ProviderOAuth { provider, .. } => { + if provider == "google" { + self.categories.insert(Category::GoogleOauth); + ModelIntent::GoogleOauth + } else { + self.categories.insert(Category::XaiOauth); + ModelIntent::XaiOauth + } + } }); } diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 8a732fc71..c49d4653e 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -49,9 +49,9 @@ impl BundledBots { pub fn for_credential(credential: &crate::env::ModelCredential) -> Self { use crate::env::ModelCredential; match credential { - ModelCredential::OpenAi { .. } | ModelCredential::Compatible { .. } => { - Self::openai_compatible() - } + ModelCredential::OpenAi { .. } + | ModelCredential::Compatible { .. } + | ModelCredential::ProviderOAuth { .. } => Self::openai_compatible(), ModelCredential::Anthropic { .. } => Self::anthropic(), ModelCredential::None | ModelCredential::ClaudePlan { .. } diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 74a4f19c0..f641e1eb3 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -543,6 +543,9 @@ export function App() { function modelCanStart() { if (!model) return false; if (!model.saved) return true; + if (model.provider === "google" || model.provider === "xai") { + return model.login === "oauth" && Boolean(model.model?.trim()); + } if (model.provider === "openai-compatible") { return ( model.login === "endpoint" && diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index b90aedff1..a976856de 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -808,7 +808,98 @@ test("a saved keyless endpoint never requests a saved first-party key", async () ]); }); +test("OAuth tab switch cancels a late authorization before opening the browser", async () => { + let resolveBegin!: (value: unknown) => void; + invokeHandler = async (command) => { + if (command === "providers") + return allProviders.map((row) => + row.id === "xai" ? { ...row, logins: ["endpoint", "oauth"] } : row, + ); + if (command === "begin_model_oauth") + return new Promise((resolve) => { + resolveBegin = resolve; + }); + if (command === "cancel_model_oauth") return null; + throw new Error(`unexpected command ${command}`); + }; + const view = await renderPicker(); + const user = userEvent.setup({ document: view.container.ownerDocument }); + await user.click(await view.findByRole("radio", { name: /xAI/ })); + await user.click(view.getByRole("tab", { name: "Sign in" })); + await user.click(view.getByRole("button", { name: "Sign in with xAI" })); + await user.click(view.getByRole("tab", { name: "Use an API key" })); + await act(async () => { + resolveBegin({ + attemptId: "late-attempt", + url: "https://authorization.example.test", + userCode: null, + }); + }); + expect(invokeCalls).toContainEqual({ + command: "cancel_model_oauth", + args: { attemptId: "late-attempt" }, + }); + expect( + invokeCalls.some((call) => call.command === "plugin:opener|open_url"), + ).toBe(false); + expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( + "disabled", + true, + ); +}); + for (const provider of cloudProviders) { + test(`${provider.name} OAuth completes through native storage without exposing tokens`, async () => { + invokeHandler = async (command) => { + if (command === "providers") + return allProviders.map((row) => + row.id === provider.id + ? { ...row, logins: ["endpoint", "oauth"] } + : row, + ); + if (command === "begin_model_oauth") + return { + attemptId: "synthetic-attempt", + url: "https://authorization.example.test/approve", + userCode: "SYNTHETIC", + }; + if ( + command === "plugin:opener|open_url" || + command === "finish_model_oauth" + ) + return null; + throw new Error(`unexpected command ${command}`); + }; + const choices: unknown[] = []; + const view = await renderPicker((choice) => choices.push(choice)); + const user = userEvent.setup({ document: view.container.ownerDocument }); + await user.click( + await view.findByRole("radio", { name: new RegExp(provider.name) }), + ); + await user.click(view.getByRole("tab", { name: "Sign in" })); + await user.click( + view.getByRole("button", { name: `Sign in with ${provider.name}` }), + ); + await view.findByText(`Signed in to ${provider.name}.`); + await user.click(view.getByRole("button", { name: "Continue" })); + expect(choices).toEqual([ + { + provider: provider.id, + login: "oauth", + model: provider.model, + saved: true, + }, + ]); + expect(invokeCalls).toContainEqual({ + command: "begin_model_oauth", + args: { root: "/tmp/openbot-provider-root", provider: provider.id }, + }); + expect(invokeCalls).toContainEqual({ + command: "finish_model_oauth", + args: { attemptId: "synthetic-attempt" }, + }); + }); + test(`${provider.name} opens its API key page externally without sending entered credentials`, async () => { invokeHandler = async (command) => { if (command === "providers") return allProviders; diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index dc833966a..c4cd3eb91 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -6,7 +6,7 @@ import { isHttpEndpointUrl } from "./http-endpoint-url"; import { Mark } from "./Mark"; import { asProblem, InlineFailure, type Problem } from "./Problem"; -export type Login = "plan" | "api-key" | "endpoint"; +export type Login = "plan" | "api-key" | "endpoint" | "oauth"; export type Provider = { id: string; @@ -38,12 +38,16 @@ export type SavedConfiguration = { | "claude-plan" | "chat-gpt-plan" | "compatible-endpoint" + | "google-oauth" + | "xai-oauth" | null; intelligenceApiKey?: boolean | null; modelApiKeys?: Partial< Record<"openai" | "anthropic" | "compatible", boolean | null> >; - modelSessions?: Partial>; + modelSessions?: Partial< + Record<"openai" | "anthropic" | "google" | "xai", boolean | null> + >; }; export type HeldConfiguration = { @@ -104,6 +108,14 @@ export function recordedModel(held: HeldConfiguration): ModelChoice | null { return { provider: "anthropic", login: "plan", saved: true }; case "chat-gpt-plan": return { provider: "openai", login: "plan", saved: true }; + case "google-oauth": + case "xai-oauth": + return { + provider: held.saved.model === "google-oauth" ? "google" : "xai", + login: "oauth", + saved: true, + model: held.BOT_MODEL, + }; case "compatible-endpoint": return { provider: "openai-compatible", @@ -200,6 +212,96 @@ export function ProviderPicker({ const [failure, setFailure] = useState(null); const openRef = useRef(open); const signInRunRef = useRef(0); + const oauthAttempt = useRef(null); + const [oauthCode, setOauthCode] = useState(null); + const [oauthSignedIn, setOauthSignedIn] = useState( + initialChoice?.login === "oauth" && initialChoice.saved + ? initialChoice.provider + : null, + ); + + async function cancelOAuth() { + signInRunRef.current += 1; + const attemptId = oauthAttempt.current; + oauthAttempt.current = null; + setBusy(false); + setSignInUrl(null); + setOauthCode(null); + if (attemptId) { + try { + await invoke("cancel_model_oauth", { attemptId }); + } catch (error) { + setFailure(asProblem(error)); + } + } + } + + async function beginOAuth() { + if (!row) return; + const providerId = row.id; + const run = ++signInRunRef.current; + const current = () => + signInRunRef.current === run && openRef.current === providerId; + setBusy(true); + setFailure(null); + setOauthSignedIn(null); + try { + const authorization = await invoke<{ + attemptId: string; + url: string; + userCode: string | null; + }>("begin_model_oauth", { root: root.trim(), provider: providerId }); + if (!current()) { + await invoke("cancel_model_oauth", { + attemptId: authorization.attemptId, + }); + return; + } + oauthAttempt.current = authorization.attemptId; + setSignInUrl(authorization.url); + setOauthCode(authorization.userCode); + try { + await invoke("plugin:opener|open_url", { url: authorization.url }); + } catch (error) { + if (current()) setFailure(asProblem(error)); + } + await invoke("finish_model_oauth", { + attemptId: authorization.attemptId, + }); + if (current()) { + setOauthSignedIn(providerId); + setSignInUrl(null); + setOauthCode(null); + setFailure(null); + } + } catch (error) { + if (current()) { + setFailure(asProblem(error)); + setSignInUrl(null); + setOauthCode(null); + } + } finally { + if (current()) { + oauthAttempt.current = null; + setBusy(false); + } + } + } + + useEffect( + () => () => { + signInRunRef.current += 1; + const attemptId = oauthAttempt.current; + if (attemptId) { + invoke("cancel_model_oauth", { attemptId }).catch(() => { + console.error( + "OpenBot could not cancel the pending provider sign-in.", + ); + }); + } + }, + [], + ); useEffect(() => { openRef.current = open; @@ -322,6 +424,9 @@ export function ProviderPicker({ // What "done" means differs by the way in, and each is checked before Continue lights up rather // than after a run fails with something unreadable. const ready = + (login === "oauth" && + oauthSignedIn === row?.id && + model.trim().length > 0) || (login === "plan" && (token.trim().length > 0 || savedPlan)) || (login === "api-key" && (apiKey.trim().length > 0 || savedApiKey)) || /* @@ -350,13 +455,16 @@ export function ProviderPicker({ ? { apiKey: trimmedApiKey } : {}), ...(login === "plan" && trimmedToken ? { token: trimmedToken } : {}), + ...(login === "oauth" ? { saved: true } : {}), ...((login === "plan" && !trimmedToken && savedPlan) || (login === "api-key" && !trimmedApiKey && savedApiKey) || (login === "endpoint" && !trimmedApiKey && savedEndpointKey) ? { saved: true } : {}), - ...(trimmedBaseUrl ? { baseUrl: trimmedBaseUrl } : {}), - ...(trimmedContainerBaseUrl + ...(login !== "oauth" && trimmedBaseUrl + ? { baseUrl: trimmedBaseUrl } + : {}), + ...(login !== "oauth" && trimmedContainerBaseUrl ? { containerBaseUrl: trimmedContainerBaseUrl } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), @@ -385,6 +493,7 @@ export function ProviderPicker({ value={r.id} checked={open === r.id} onChange={() => { + if (oauthAttempt.current) void cancelOAuth(); signInRunRef.current += 1; setOpen(r.id); // A failure belongs to the row that produced it. Left in place, a refused OpenAI @@ -443,16 +552,68 @@ export function ProviderPicker({ role="tab" aria-selected={login === option} className={login === option ? "on" : ""} - onClick={() => setLogin(option)} + onClick={() => { + if (login === "oauth" && option !== login) + void cancelOAuth(); + setLogin(option); + }} > {option === "plan" ? "Sign in with my plan" - : "Use an API key"} + : option === "oauth" + ? "Sign in" + : "Use an API key"} ))} )} + {login === "oauth" && ( + <> +

    + {row.id === "google" + ? "Authorize Gemini API access using the configured Google Cloud project and its API quota." + : "Authorize OpenBot to use models available to your xAI account."} +

    + {oauthSignedIn === row.id ? ( +

    Signed in to {row.name}.

    + ) : signInUrl ? ( + <> +

    + Waiting for you to approve sign-in in your browser. +

    + {oauthCode && ( +

    + Verification code: {oauthCode} +

    + )} + + Open sign-in page + + + + ) : null} + {!busy && ( + + )} + {busy && !signInUrl &&

    Preparing sign-in…

    } +
    + + setModel(event.target.value)} + spellCheck={false} + /> +
    + + )} + {login === "plan" && (token || (savedPlan && !signInUrl && !busy) ? ( <> diff --git a/server/src/app.ts b/server/src/app.ts index ecfa781c6..3bec2d2f8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -67,6 +67,10 @@ import { type PluginStore, } from "./plugins/store"; import { REFUSAL_MARKER, vendorAnswer } from "./plugins/tools"; +import { + type ModelProviderProxy, + mountProviderOAuthProxy, +} from "./provider-oauth"; import { createRoutineRoutes, type RoutineStore } from "./routines/routes"; import type { RoutineRunner } from "./routines/runner"; import type { IntentRouter } from "./routing/classify"; @@ -315,9 +319,12 @@ export function createApp( * no app directory to offer, rather than one that lists apps nobody can connect. */ composio?: { broker: ComposioBroker }, + /** Native model OAuth stays server-side; callers hold only a separate local bearer. */ + modelProviderProxy?: ModelProviderProxy, ) { const app = new Hono<{ Variables: AppVariables }>(); mountDesktopConnectionFailure(app, desktopHostToken); + mountProviderOAuthProxy(app, modelProviderProxy); app.get("/health", (context) => context.json({ status: "ok" })); // Projected, never the raw runtime. config.runtime carries the Intelligence contract, including diff --git a/server/src/index.ts b/server/src/index.ts index b6d641ff3..47065bea1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -90,6 +90,7 @@ import { hostAccessTools } from "./host-access/tools"; import { observeIntelligenceAuthentication } from "./intelligence-client"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; +import { createProviderOAuthProxy } from "./provider-oauth"; import { useRoutineTools } from "./plugins/builtin-routines"; import { useComposioClient } from "./plugins/composio"; import { createComposioClient } from "./plugins/composio-adapter"; @@ -1309,6 +1310,9 @@ const app = createApp( // Absent without a key, which leaves the routes reporting no broker rather than listing apps // nobody could connect. composio ? { broker: composio.broker } : undefined, + process.env.OPENBOT_MODEL_OAUTH_FILE?.trim() + ? createProviderOAuthProxy(process.env.OPENBOT_MODEL_OAUTH_FILE.trim()) + : undefined, ); /** diff --git a/server/src/provider-oauth.ts b/server/src/provider-oauth.ts new file mode 100644 index 000000000..44032d435 --- /dev/null +++ b/server/src/provider-oauth.ts @@ -0,0 +1,303 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, rename, rmdir, unlink } from "node:fs/promises"; +import { isAbsolute } from "node:path"; +import type { Env, Hono } from "hono"; +import { z } from "zod"; +import { sameToken } from "./agents/callback-token"; +import { + clearDesktopConnectionFailure, + recordDesktopConnectionFailure, +} from "./desktop-connection-failure"; + +export type ModelOAuthRecord = { + version: 1; + sessionId: string; + provider: "google" | "xai"; + clientId: string; + clientSecret?: string; + accessToken: string; + refreshToken: string; + expiresAt: number; + scope: string; + quotaProject?: string; + proxyToken: string; +}; + +type Fetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; +export type ModelProviderProxy = (request: Request) => Promise; + +const schema = z.object({ + version: z.literal(1), + sessionId: z.string().min(1), + provider: z.enum(["google", "xai"]), + clientId: z.string().min(1), + clientSecret: z.string().optional(), + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + expiresAt: z.number().finite().positive(), + scope: z.string(), + quotaProject: z.string().min(1).optional(), + proxyToken: z.string().min(1), +}); + +const providers = { + google: { + token: "https://oauth2.googleapis.com/token", + chat: "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + }, + xai: { + token: "https://auth.x.ai/oauth2/token", + chat: "https://api.x.ai/v1/chat/completions", + }, +}; + +class SignInRequired extends Error {} + +async function readRecord(file: string): Promise { + const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const info = await handle.stat(); + if ( + !info.isFile() || + info.size > 64 * 1024 || + (process.platform !== "win32" && (info.mode & 0o077) !== 0) + ) + throw new Error("The private model credential file is not valid."); + const record = schema.parse(JSON.parse(await handle.readFile("utf8"))); + if (record.provider === "google" && !record.quotaProject) + throw new Error("The Google model quota project is missing."); + return record; + } finally { + await handle.close(); + } +} + +function hasCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +/** Native sign-in uses the same lock. A new session must win over an older refresh. */ +async function persistRotation( + file: string, + previous: ModelOAuthRecord, + next: ModelOAuthRecord, +): Promise { + const lock = `${file}.lock`; + const deadline = Date.now() + 5000; + for (;;) { + try { + await mkdir(lock, { mode: 0o700 }); + break; + } catch (error) { + if (!hasCode(error, "EEXIST") || Date.now() >= deadline) throw error; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + const temporary = `${file}.${randomUUID()}.tmp`; + try { + const current = await readRecord(file); + if ( + current.sessionId !== previous.sessionId || + current.refreshToken !== previous.refreshToken + ) + throw new SignInRequired("The model sign-in changed during refresh."); + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(JSON.stringify(next)); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, file); + } finally { + try { + await unlink(temporary).catch((error: unknown) => { + if (!hasCode(error, "ENOENT")) throw error; + }); + } finally { + await rmdir(lock); + } + } +} + +function refused(status: number, error: string): Response { + return Response.json( + { error }, + { status, headers: { "cache-control": "no-store" } }, + ); +} + +/** One host-server instance owns refresh; agents use only its separate local bearer. */ +export function createProviderOAuthProxy( + file: string, + options: { fetch?: Fetch } = {}, +): ModelProviderProxy { + if (!isAbsolute(file)) + throw new Error("The model OAuth file must be absolute."); + const requestProvider = options.fetch ?? fetch; + const refreshes = new Map>(); + + async function refresh( + previous: ModelOAuthRecord, + ): Promise { + const pending = refreshes.get(previous.sessionId); + if (pending) return pending; + const work = (async () => { + const current = await readRecord(file); + if (current.sessionId !== previous.sessionId) + throw new SignInRequired("The model sign-in changed."); + // A concurrent request may have already rotated the token that received a 401. + if (current.accessToken !== previous.accessToken) return current; + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: current.clientId, + refresh_token: current.refreshToken, + }); + if (current.clientSecret) body.set("client_secret", current.clientSecret); + const response = await requestProvider( + providers[current.provider].token, + { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(15_000), + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body, + }, + ); + if (!response.ok) { + await response.body?.cancel(); + throw new SignInRequired("The model provider refused token refresh."); + } + const tokens = z + .object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.number().finite().positive().optional(), + token_type: z.string().optional(), + }) + .parse(await response.json()); + if (tokens.token_type && tokens.token_type.toLowerCase() !== "bearer") + throw new SignInRequired( + "The model provider returned an unsupported token.", + ); + const next = { + ...current, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? current.refreshToken, + expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000, + }; + // Do not use a rotated pair until it is durable; a failed save requires sign-in again. + await persistRotation(file, current, next); + return next; + })(); + refreshes.set(previous.sessionId, work); + try { + return await work; + } finally { + refreshes.delete(previous.sessionId); + } + } + + return async (request) => { + let current: ModelOAuthRecord; + try { + current = await readRecord(file); + } catch { + return refused( + 503, + "The local model sign-in is unavailable. Sign in again.", + ); + } + const offered = request.headers.get("authorization") ?? ""; + if (!sameToken(offered, `Bearer ${current.proxyToken}`)) + return refused(401, "The local model proxy requires authentication."); + + let body: ArrayBuffer; + try { + body = await request.arrayBuffer(); + if (current.expiresAt <= Date.now() + 120_000) + current = await refresh(current); + } catch { + recordDesktopConnectionFailure({ + connection: "model", + code: "provider_authentication_failed", + }); + return refused( + 401, + "The model sign-in could not be refreshed. Sign in again.", + ); + } + + const send = (credential: ModelOAuthRecord) => { + const headers = new Headers({ + "content-type": "application/json", + authorization: `Bearer ${credential.accessToken}`, + }); + if (credential.provider === "google" && credential.quotaProject) + headers.set("x-goog-user-project", credential.quotaProject); + return requestProvider(providers[credential.provider].chat, { + method: "POST", + headers, + body, + redirect: "error", + signal: request.signal, + }); + }; + try { + let response = await send(current); + if (response.status === 401) { + await response.body?.cancel(); + try { + current = await refresh(current); + } catch { + throw new SignInRequired("The model sign-in could not be refreshed."); + } + response = await send(current); + } + if (!response.ok) { + await response.body?.cancel(); + if (response.status === 401 || response.status === 403) + throw new SignInRequired("The model provider refused this sign-in."); + return refused( + response.status >= 400 ? response.status : 502, + `The model provider rejected the request (HTTP ${response.status}).`, + ); + } + clearDesktopConnectionFailure("model"); + return new Response(response.body, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + "cache-control": "no-store", + }, + }); + } catch (error) { + if (error instanceof SignInRequired) { + recordDesktopConnectionFailure({ + connection: "model", + code: "provider_authentication_failed", + }); + return refused(401, "The model provider needs you to sign in again."); + } + return refused(502, "The model provider could not be reached."); + } + }; +} + +export function mountProviderOAuthProxy( + app: Hono, + proxy: ModelProviderProxy | undefined, +): void { + if (proxy) + app.post("/api/model-provider/v1/chat/completions", (context) => + proxy(context.req.raw), + ); +} diff --git a/server/tests/provider-oauth.test.ts b/server/tests/provider-oauth.test.ts new file mode 100644 index 000000000..6a78eab4e --- /dev/null +++ b/server/tests/provider-oauth.test.ts @@ -0,0 +1,285 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Hono } from "hono"; +import { + createProviderOAuthProxy, + type ModelOAuthRecord, + mountProviderOAuthProxy, +} from "../src/provider-oauth"; + +const cleanup: (() => Promise | void)[] = []; +afterEach(async () => { + for (const dispose of cleanup.splice(0).reverse()) await dispose(); +}); + +function record(overrides: Partial = {}): ModelOAuthRecord { + return { + version: 1, + sessionId: "session-one", + provider: "google", + clientId: "desktop-client", + clientSecret: "desktop-client-secret", + accessToken: "provider-access-token", + refreshToken: "provider-refresh-token", + expiresAt: Date.now() + 3_600_000, + scope: "model-scope", + quotaProject: "google-quota-project", + proxyToken: "local-proxy-token", + ...overrides, + }; +} + +async function fixture( + current: ModelOAuthRecord, + handler: (request: Request) => Response | Promise, +) { + const root = await mkdtemp(join(tmpdir(), "openbot-model-oauth-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const file = join(root, "model-oauth.json"); + await writeFile(file, JSON.stringify(current), { mode: 0o600 }); + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler }); + cleanup.push(() => server.stop(true)); + const destinations: string[] = []; + const app = new Hono(); + mountProviderOAuthProxy( + app, + createProviderOAuthProxy(file, { + fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + destinations.push(url.href); + return fetch(new URL(url.pathname, server.url), init); + }, + }), + ); + const ask = (token: string | null = current.proxyToken) => + app.request("/api/model-provider/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "browser-session-must-not-pass-upstream", + ...(token === null ? {} : { authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ + model: "chosen-model", + messages: [], + stream: true, + }), + }); + return { app, file, ask, destinations }; +} + +test("the model proxy requires its bearer even with a browser cookie", async () => { + const f = await fixture(record(), () => new Response("not called")); + for (const token of [null, "wrong-token"]) { + const response = await f.ask(token); + expect(response.status).toBe(401); + expect(await response.text()).not.toContain("provider-access-token"); + } + expect(f.destinations).toEqual([]); +}); + +test("Google uses provider bearer and quota project while preserving streamed model output", async () => { + const f = await fixture(record(), async (request) => { + expect(request.headers.get("authorization")).toBe( + "Bearer provider-access-token", + ); + expect(request.headers.get("x-goog-user-project")).toBe( + "google-quota-project", + ); + expect(request.headers.get("cookie")).toBeNull(); + expect(await request.json()).toMatchObject({ + model: "chosen-model", + stream: true, + }); + return new Response('data: {"choices":[]}\n\ndata: [DONE]\n\n', { + headers: { + "content-type": "text/event-stream", + "set-cookie": "must-not-leave-provider", + }, + }); + }); + const response = await f.ask(); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.text()).toContain("data: [DONE]"); + expect(f.destinations).toEqual([ + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + ]); +}); + +test("xAI uses the existing compatible model endpoint without Google quota headers", async () => { + const f = await fixture( + record({ provider: "xai", quotaProject: undefined }), + (request) => { + expect(request.headers.get("authorization")).toBe( + "Bearer provider-access-token", + ); + expect(request.headers.get("x-goog-user-project")).toBeNull(); + return Response.json({ choices: [] }); + }, + ); + expect((await f.ask()).status).toBe(200); + expect(f.destinations).toEqual(["https://api.x.ai/v1/chat/completions"]); +}); + +test("concurrent requests refresh once and persist the rotated pair before forwarding", async () => { + let refreshes = 0; + const f = await fixture(record({ expiresAt: 1 }), async (request) => { + if (new URL(request.url).pathname === "/token") { + refreshes++; + const body = new URLSearchParams(await request.text()); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("provider-refresh-token"); + expect(body.get("client_id")).toBe("desktop-client"); + expect(body.get("client_secret")).toBe("desktop-client-secret"); + await Bun.sleep(20); + return Response.json({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + }); + } + expect(request.headers.get("authorization")).toBe("Bearer rotated-access"); + const saved = JSON.parse(await readFile(f.file, "utf8")); + expect(saved.refreshToken).toBe("rotated-refresh"); + return Response.json({ choices: [] }); + }); + const responses = await Promise.all(Array.from({ length: 8 }, () => f.ask())); + expect(responses.map((response) => response.status)).toEqual( + Array(8).fill(200), + ); + expect(refreshes).toBe(1); + const saved = JSON.parse(await readFile(f.file, "utf8")); + expect(saved).toMatchObject({ + accessToken: "rotated-access", + refreshToken: "rotated-refresh", + sessionId: "session-one", + }); + expect(saved.expiresAt).toBeGreaterThan(Date.now()); + if (process.platform !== "win32") + expect((await stat(f.file)).mode & 0o777).toBe(0o600); + expect((await f.ask()).status).toBe(200); + expect(refreshes).toBe(1); +}); + +test("an unexpired rejected xAI token is refreshed once and the request is replayed", async () => { + let refreshes = 0; + let calls = 0; + const f = await fixture( + record({ + provider: "xai", + quotaProject: undefined, + clientSecret: undefined, + }), + async (request) => { + if (new URL(request.url).pathname === "/oauth2/token") { + refreshes++; + const body = new URLSearchParams(await request.text()); + expect(body.get("client_secret")).toBeNull(); + return Response.json({ + access_token: "new-xai-token", + expires_in: 3600, + }); + } + calls++; + return request.headers.get("authorization") === "Bearer new-xai-token" + ? Response.json({ choices: [] }) + : Response.json({ error: "expired" }, { status: 401 }); + }, + ); + expect((await f.ask()).status).toBe(200); + expect(refreshes).toBe(1); + expect(calls).toBe(2); + expect(JSON.parse(await readFile(f.file, "utf8")).refreshToken).toBe( + "provider-refresh-token", + ); +}); + +test("a refused refresh exposes no provider response or credential and keeps the old file", async () => { + const initial = record({ expiresAt: 1 }); + const f = await fixture(initial, () => + Response.json( + { error: "invalid_grant", secret: initial.refreshToken }, + { status: 400 }, + ), + ); + const response = await f.ask(); + expect(response.status).toBe(401); + const body = await response.text(); + expect(body).not.toContain(initial.refreshToken); + expect(body).not.toContain("invalid_grant"); + expect(JSON.parse(await readFile(f.file, "utf8"))).toEqual(initial); +}); + +test.each(["model", "refresh"] as const)( + "%s endpoint redirects cannot carry provider credentials elsewhere", + async (endpoint) => { + let leaked = 0; + const destination = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => { + leaked++; + return new Response("leaked"); + }, + }); + cleanup.push(() => destination.stop(true)); + const f = await fixture( + record({ + expiresAt: endpoint === "refresh" ? 1 : Date.now() + 3_600_000, + }), + () => Response.redirect(destination.url, 307), + ); + const response = await f.ask(); + expect(response.status).toBe(endpoint === "refresh" ? 401 : 502); + expect(response.headers.get("location")).toBeNull(); + expect(leaked).toBe(0); + }, +); + +test("a completed new sign-in cannot be overwritten by an older in-flight refresh", async () => { + let began!: () => void; + let finish!: () => void; + const started = new Promise((resolve) => { + began = resolve; + }); + const release = new Promise((resolve) => { + finish = resolve; + }); + const f = await fixture(record({ expiresAt: 1 }), async () => { + began(); + await release; + return Response.json({ + access_token: "old-session-rotated-access", + refresh_token: "old-session-rotated-refresh", + expires_in: 3600, + }); + }); + const pending = f.ask(); + await started; + const replacement = record({ + sessionId: "new-sign-in", + proxyToken: "new-local-token", + refreshToken: "new-sign-in-refresh", + }); + await writeFile(f.file, JSON.stringify(replacement), { mode: 0o600 }); + finish(); + expect((await pending).status).toBe(401); + expect(JSON.parse(await readFile(f.file, "utf8"))).toEqual(replacement); +}); + +test("missing or malformed credential files fail closed without exposing their content", async () => { + const f = await fixture(record(), () => new Response("not called")); + await writeFile(f.file, '{"accessToken":"malformed-private-token"'); + let response = await f.ask(); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain("malformed-private-token"); + await rm(f.file); + response = await f.ask(); + expect(response.status).toBe(503); + expect(f.destinations).toEqual([]); +}); From f23c7acbd127d168e25e699afefea3c64337d581 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 12:07:29 -0700 Subject: [PATCH 03/11] fix(desktop): preserve OAuth storage across PowerShell environments --- desktop/src-tauri/src/provider_oauth.rs | 40 ++++++++++++++++++++++--- server/tests/provider-oauth.test.ts | 7 ++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/provider_oauth.rs b/desktop/src-tauri/src/provider_oauth.rs index 336a8d78f..f027c1275 100644 --- a/desktop/src-tauri/src/provider_oauth.rs +++ b/desktop/src-tauri/src/provider_oauth.rs @@ -506,14 +506,17 @@ fn write( let output = std::process::Command::new("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", script]) .env("OPENBOT_OAUTH_DIRECTORY", directory) + // A PowerShell 7 parent passes its incompatible modules through Cargo/OpenBot. + // Let Windows PowerShell rebuild its own default module search path. + .env_remove("PSModulePath") .creation_flags(0x08000000) .output() .map_err(|e| e.to_string())?; if !output.status.success() { - return Err( - "OpenBot could not make the provider sign-in private to your Windows account." - .into(), - ); + return Err(format!( + "OpenBot could not make the provider sign-in private to your Windows account. {}", + crate::quiet::said(&output.stderr) + )); } } let lock = PathBuf::from(format!("{}.lock", path.display())); @@ -588,6 +591,35 @@ mod tests { assert!(check(&newer).is_ok()); cancel(&newer.id); } + #[cfg(windows)] + #[test] + fn windows_oauth_persistence_ignores_incompatible_parent_powershell_modules() { + let root = crate::test_support::temp_root("oauth-parent-modules"); + let module = root.join("Microsoft.PowerShell.Security"); + std::fs::create_dir_all(&module).unwrap(); + // A module with a newer engine requirement models the PS7 path inherited through Cargo. + std::fs::write( + module.join("Microsoft.PowerShell.Security.psd1"), + "@{ModuleVersion='99.0';PowerShellVersion='99.0';CmdletsToExport=@('Set-Acl')}", + ) + .unwrap(); + let output = crate::quiet::command(std::env::current_exe().unwrap()) + .args([ + "--exact", + "provider_oauth::tests::persisted_credentials_remain_provider_bound", + "--nocapture", + ]) + .env("PSModulePath", &root) + .output() + .unwrap(); + std::fs::remove_dir_all(root).unwrap(); + assert!( + output.status.success(), + "{}\n{}", + crate::quiet::said(&output.stdout), + crate::quiet::said(&output.stderr) + ); + } #[test] fn persisted_credentials_remain_provider_bound() { let root = crate::test_support::temp_root("provider-oauth"); diff --git a/server/tests/provider-oauth.test.ts b/server/tests/provider-oauth.test.ts index 6a78eab4e..f7ddf366f 100644 --- a/server/tests/provider-oauth.test.ts +++ b/server/tests/provider-oauth.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Hono } from "hono"; +import { clearDesktopConnectionFailure } from "../src/desktop-connection-failure"; import { createProviderOAuthProxy, type ModelOAuthRecord, @@ -11,7 +12,11 @@ import { const cleanup: (() => Promise | void)[] = []; afterEach(async () => { - for (const dispose of cleanup.splice(0).reverse()) await dispose(); + try { + for (const dispose of cleanup.splice(0).reverse()) await dispose(); + } finally { + clearDesktopConnectionFailure("model"); + } }); function record(overrides: Partial = {}): ModelOAuthRecord { From 1e70fdd7935ca8dfb7c6d075fa483e1432e82904 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 12:15:00 -0700 Subject: [PATCH 04/11] fix(desktop): find bundled Docker credential helpers --- CHANGELOG.md | 2 + desktop/src-tauri/src/engine.rs | 148 ++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a167c3ca0..8eea2f199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ including when Windows reserves a default port. Setup can create a CopilotKit pr Google Gemini and xAI API-key choices, plus OAuth sign-in with automatic token refresh. Google OAuth requires the distributor's desktop client and quota-project configuration. Unsupported Bun installations are replaced with the pinned runtime; startup errors retain useful details and provide a configurable setup-help link. +Docker image downloads can find the credential helper bundled beside Docker even when it is +missing from the desktop app's PATH. Existing Docker credentials and helper preferences are preserved. ## 0.0.14 diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 4b63842e4..2754f4970 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -221,11 +221,8 @@ impl Address { /// because that file belongs to whoever else may have configured it. pub fn command(&self) -> Command { let (binary, arguments) = self.parts(); - let mut command = command(binary); + let mut command = command_at(self.engine, &binary); command.args(arguments); - if let Some(dir) = tools_dir() { - command.env("PATH", path_with(dir)); - } command } @@ -329,9 +326,48 @@ fn tools_dir() -> Option<&'static PathBuf> { /// A command that runs this engine's binary, wherever it actually is. pub fn tool(engine: Engine) -> Command { - let mut built = command(program(engine).unwrap_or_else(|| PathBuf::from(engine.binary()))); - if let Some(dir) = tools_dir() { - built.env("PATH", path_with(dir)); + command_at( + engine, + &program(engine).unwrap_or_else(|| PathBuf::from(engine.binary())), + ) +} + +fn command_at(engine: Engine, binary: &Path) -> Command { + let mut built = command(binary); + let mut path = tools_dir() + .map(|dir| path_with(dir)) + .or_else(|| std::env::var_os("PATH")); + if engine == Engine::Docker && binary.is_absolute() { + // A GUI can find Docker via its installation path while its PATH cannot find Docker's + // credential helper. Desktop ships them together, sometimes behind a CLI symlink. + // Append to preserve the person's chosen helpers and our Compose-provider precedence. + let resolved = std::fs::canonicalize(binary).ok(); + for directory in [binary.parent(), resolved.as_deref().and_then(Path::parent)] + .into_iter() + .flatten() + { + let existing: Vec<_> = path + .as_deref() + .map(std::env::split_paths) + .into_iter() + .flatten() + .collect(); + if existing.iter().any(|entry| entry == directory) { + continue; + } + // A directory containing the platform PATH separator cannot be represented here. + // Keep the original environment in that case; Docker still reports its real error. + if let Ok(expanded) = std::env::join_paths( + existing + .into_iter() + .chain(std::iter::once(directory.to_path_buf())), + ) { + path = Some(expanded); + } + } + } + if let Some(path) = path { + built.env("PATH", path); } built } @@ -528,6 +564,104 @@ fn socket_override(engine: Engine) -> Option { mod tests { use super::*; + #[test] + fn docker_credential_helper_is_found_beside_resolved_cli_without_changing_config() { + if crate::test_support::isolated_process( + "engine::tests::docker_credential_helper_is_found_beside_resolved_cli_without_changing_config", + ) { + return; + } + let root = crate::test_support::temp_root("docker credential helper path"); + let bin = root.join("Docker application/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let source = root.join("fixture.rs"); + let docker = bin.join(format!("docker{}", std::env::consts::EXE_SUFFIX)); + let helper = bin.join(format!( + "docker-credential-desktop{}", + std::env::consts::EXE_SUFFIX + )); + std::fs::write( + &source, + r#" +fn main() { + if std::env::current_exe().unwrap().file_stem().unwrap() == "docker-credential-desktop" { + println!("configured helper used"); + return; + } + let filename = format!("docker-credential-desktop{}", std::env::consts::EXE_SUFFIX); + let path = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(&filename)).find(|path| path.is_file()); + let Some(path) = path else { + eprintln!("docker-credential-desktop: executable file not found in PATH"); + std::process::exit(41); + }; + let output = std::process::Command::new(path).arg("get").output().unwrap(); + assert!(output.status.success()); + print!("{}", String::from_utf8(output.stdout).unwrap()); +} +"#, + ) + .unwrap(); + crate::test_support::compile_fixture(&source, &docker); + std::fs::copy(&docker, &helper).unwrap(); + let config = root.join("config.json"); + let original = br#"{"credsStore":"desktop","credHelpers":{"private.example":"custom"}}"#; + std::fs::write(&config, original).unwrap(); + std::env::set_var("PATH", root.join("gui-path-without-docker")); + let output = command_at(Engine::Docker, &docker) + .env("DOCKER_CONFIG", &root) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, b"configured helper used\n"); + assert_eq!(std::fs::read(config).unwrap(), original); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn docker_symlink_adds_target_helpers_after_inherited_paths_and_managed_compose() { + if crate::test_support::isolated_process( + "engine::tests::docker_symlink_adds_target_helpers_after_inherited_paths_and_managed_compose", + ) { + return; + } + let root = crate::test_support::temp_root("docker symlink helper path"); + let target = root.join("Docker.app/Contents/Resources/bin"); + let links = root.join("usr/local/bin"); + for directory in [&target, &links] { + std::fs::create_dir_all(directory).unwrap(); + } + std::fs::write(target.join("docker"), "fixture").unwrap(); + std::os::unix::fs::symlink(target.join("docker"), links.join("docker")).unwrap(); + let inherited = root.join("custom-helpers"); + let managed = root.join("managed-compose"); + std::env::set_var("PATH", &inherited); + tools_live_in(managed.clone()); + let command = command_at(Engine::Docker, &links.join("docker")); + let path = command + .get_envs() + .find(|(key, _)| *key == "PATH") + .unwrap() + .1 + .unwrap(); + let directories: Vec<_> = std::env::split_paths(path).collect(); + assert_eq!( + directories, + [ + managed, + inherited, + links, + std::fs::canonicalize(target).unwrap() + ] + ); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn runtime_selectors_keep_the_existing_status_wire_shape() { let mut address = Address::new(Engine::Docker, None); From d0420926c8673a0ab0b23820ce710a94d17880ad Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 13:08:52 -0700 Subject: [PATCH 05/11] build(desktop): include configured Google OAuth defaults --- .github/workflows/desktop-signing.yml | 3 +++ .github/workflows/desktop.yml | 6 ++++++ desktop/PROVIDER_OAUTH.md | 14 ++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/.github/workflows/desktop-signing.yml b/.github/workflows/desktop-signing.yml index 7e7d53dc5..8bf29d3cb 100644 --- a/.github/workflows/desktop-signing.yml +++ b/.github/workflows/desktop-signing.yml @@ -118,6 +118,9 @@ jobs: - name: Build and sign env: WINDOWS_SIGNING: keyvault + OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID: ${{ vars.OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID }} + OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET: ${{ secrets.OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET }} + OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT: ${{ vars.OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT }} run: bun run tauri build --config src-tauri/tauri.windows-signing.conf.json --config src-tauri/tauri.build-version.conf.json --bundles nsis working-directory: desktop # Tauri restores the unsigned build output after bundling. Verify the app diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 910ca05a6..e2cf3a7d2 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -10,6 +10,9 @@ on: branches: [main] paths: ["desktop/**", "package.json", ".github/workflows/desktop.yml"] workflow_call: + secrets: + OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET: + required: false permissions: contents: read @@ -101,6 +104,9 @@ jobs: working-directory: desktop env: APPLE_SIGNING_IDENTITY: ${{ matrix.platform.name == 'macos' && '-' || '' }} + OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID: ${{ vars.OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID }} + OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET: ${{ secrets.OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET }} + OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT: ${{ vars.OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT }} - name: Verify packaged Mac version and ad-hoc signature if: matrix.platform.name == 'macos' run: | diff --git a/desktop/PROVIDER_OAUTH.md b/desktop/PROVIDER_OAUTH.md index 8293adcc4..9ae0b9b21 100644 --- a/desktop/PROVIDER_OAUTH.md +++ b/desktop/PROVIDER_OAUTH.md @@ -17,6 +17,20 @@ process environment variables can override those defaults: - `OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET`: the client secret, when issued. - `OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT`: the project that supplies API quota. +For GitHub-built artifacts, configure repository Actions **variables** named +`OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_ID` and +`OPENBOT_GOOGLE_MODEL_OAUTH_QUOTA_PROJECT`, plus an Actions **secret** named +`OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET` when the client has one. Both the +Desktop artifact workflow and the Windows signing workflow pass these settings +to the native build. They become defaults inside the distributed desktop app; +the desktop client credential is not a user access or refresh token. + +When calling the Desktop workflow as a reusable workflow, pass its optional +`OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET` secret explicitly or use +`secrets: inherit`. GitHub does not provide repository secrets to fork pull +requests. Missing settings do not fail the build; API-key connections remain +available, and Google sign-in requires a configured build or runtime settings. + Do not reuse OpenBot's web SSO credentials (`GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`): their callback and permissions serve a different purpose. Configure the consent screen and test users, and complete Google's From 146f2eabe6eaa857b4331a1881a5e63514921995 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 13:54:38 -0700 Subject: [PATCH 06/11] fix: use native Gemini transport for Google OAuth --- .../tests/google-oauth-proxy.test.ts | 134 +++++ server/src/google-oauth-transport.ts | 456 ++++++++++++++++++ server/src/provider-oauth.ts | 25 +- server/tests/google-oauth-transport.test.ts | 229 +++++++++ server/tests/provider-oauth.test.ts | 42 +- 5 files changed, 874 insertions(+), 12 deletions(-) create mode 100644 agent-langgraph/tests/google-oauth-proxy.test.ts create mode 100644 server/src/google-oauth-transport.ts create mode 100644 server/tests/google-oauth-transport.test.ts diff --git a/agent-langgraph/tests/google-oauth-proxy.test.ts b/agent-langgraph/tests/google-oauth-proxy.test.ts new file mode 100644 index 000000000..526a4e8a7 --- /dev/null +++ b/agent-langgraph/tests/google-oauth-proxy.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { RunAgentInputSchema } from "@ag-ui/core"; +import type { AIMessageChunk } from "@langchain/core/messages"; +import { ChatOpenAI } from "@langchain/openai"; +import { + googleRequest, + googleResponse, +} from "../../server/src/google-oauth-transport"; +import { toLangChainMessages } from "../src/history"; + +test("LangGraph streams and replays Gemini tool signatures through its actual SDK and AG-UI history", async () => { + const signature = `${"aBc012+/".repeat(512)}==`; + let calls = 0; + const nativeBodies: ReturnType["body"][] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const converted = googleRequest(await request.json()); + nativeBodies.push(converted.body); + calls++; + const parts = + calls === 1 + ? [ + { + functionCall: { + id: "native_browser_1", + name: "browser", + args: { url: "https://example.com" }, + }, + thoughtSignature: signature, + }, + ] + : [{ text: "Page loaded." }]; + return googleResponse( + new Response( + `data: ${JSON.stringify({ candidates: [{ content: { parts }, finishReason: "STOP" }] })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + converted.model, + true, + ); + }, + }); + try { + const model = new ChatOpenAI({ + model: "gemini-3.6-flash", + apiKey: "local-fixture-token", + configuration: { baseURL: `${server.url}v1` }, + useResponsesApi: false, + maxRetries: 0, + }); + const tools = [ + { + type: "function" as const, + function: { + name: "browser", + parameters: { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }, + }, + }, + ]; + let message: AIMessageChunk | undefined; + for await (const chunk of await model + .bindTools(tools) + .stream("Open example.com")) + message = message ? message.concat(chunk) : chunk; + const call = message?.tool_calls?.[0]; + expect(call?.id).toBe(`native_browser_1__thought__${signature}`); + const history = RunAgentInputSchema.parse({ + threadId: "fixture-thread", + runId: "fixture-run", + state: {}, + tools: [], + context: [], + forwardedProps: {}, + messages: [ + { id: "user-1", role: "user", content: "Open example.com" }, + { + id: "assistant-1", + role: "assistant", + content: "", + toolCalls: [ + { + id: call?.id, + type: "function", + function: { + name: call?.name, + arguments: JSON.stringify(call?.args), + }, + }, + ], + }, + { + id: "result-1", + role: "tool", + toolCallId: call?.id, + content: "The page loaded", + }, + ], + }); + let answer = ""; + for await (const chunk of await model + .bindTools(tools) + .stream(toLangChainMessages(history))) + answer += chunk.content; + expect(answer).toBe("Page loaded."); + expect(nativeBodies[1].contents[1].parts).toEqual([ + { + functionCall: { + id: "native_browser_1", + name: "browser", + args: { url: "https://example.com" }, + }, + thoughtSignature: signature, + }, + ]); + expect(nativeBodies[1].contents[2].parts).toEqual([ + { + functionResponse: { + id: "native_browser_1", + name: "browser", + response: { result: "The page loaded" }, + }, + }, + ]); + expect(calls).toBe(2); + } finally { + server.stop(true); + } +}); diff --git a/server/src/google-oauth-transport.ts b/server/src/google-oauth-transport.ts new file mode 100644 index 000000000..4a0ce4f97 --- /dev/null +++ b/server/src/google-oauth-transport.ts @@ -0,0 +1,456 @@ +import { randomUUID } from "node:crypto"; +import { z } from "zod"; + +// Google's OpenAI endpoint accepts API keys, not the desktop's user OAuth token. +// Keep the local agents' Chat Completions contract while using the native API. +const toolCall = z.object({ + id: z.string(), + function: z.object({ name: z.string(), arguments: z.string() }), +}); +const chat = z.object({ + model: z.string().regex(/^(?:models\/)?[a-zA-Z0-9._-]+$/), + messages: z.array( + z.object({ + role: z.enum(["system", "developer", "user", "assistant", "tool"]), + content: z.unknown().optional(), + tool_calls: z.array(toolCall).optional(), + tool_call_id: z.string().optional(), + }), + ), + tools: z + .array( + z.object({ + type: z.literal("function"), + function: z.object({ + name: z.string(), + description: z.string().optional(), + parameters: z.record(z.string(), z.unknown()).optional(), + }), + }), + ) + .optional(), + tool_choice: z + .union([ + z.enum(["auto", "none", "required"]), + z.object({ + type: z.literal("function"), + function: z.object({ name: z.string() }), + }), + ]) + .optional(), + stream: z.boolean().optional(), + stream_options: z + .object({ include_usage: z.boolean().optional() }) + .optional(), + temperature: z.number().optional(), + top_p: z.number().optional(), + max_tokens: z.number().optional(), + max_completion_tokens: z.number().optional(), + stop: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + response_format: z + .object({ + type: z.string(), + json_schema: z + .object({ schema: z.record(z.string(), z.unknown()) }) + .optional(), + }) + .optional(), +}); +const partSchema = z + .object({ + text: z.string().optional(), + thought: z.boolean().optional(), + thoughtSignature: z.string().optional(), + functionCall: z + .object({ + name: z.string(), + args: z.record(z.string(), z.unknown()).optional(), + id: z.string().optional(), + }) + .optional(), + }) + .passthrough(); +type Part = z.infer; +type Content = { role: "user" | "model"; parts: Part[] }; +const nativeResponse = z.object({ + candidates: z + .array( + z.object({ + index: z.number().optional(), + content: z.object({ parts: z.array(partSchema).optional() }).optional(), + finishReason: z.string().optional(), + }), + ) + .optional(), + usageMetadata: z + .object({ + promptTokenCount: z.number().optional(), + candidatesTokenCount: z.number().optional(), + thoughtsTokenCount: z.number().optional(), + totalTokenCount: z.number().optional(), + }) + .optional(), + promptFeedback: z.object({ blockReason: z.string().optional() }).optional(), +}); +type NativeResponse = z.infer; + +function parseNative(value: unknown): NativeResponse { + if (value && typeof value === "object" && "error" in value) + throw new Error("The Google model stream failed."); + return nativeResponse.parse(value); +} + +function contentParts(value: unknown): Part[] { + if (value == null) return []; + if (typeof value === "string") return value ? [{ text: value }] : []; + return z + .array( + z.object({ + type: z.string(), + text: z.string().optional(), + image_url: z.object({ url: z.string() }).optional(), + }), + ) + .parse(value) + .map((part) => { + if (part.type === "text" && part.text !== undefined) + return { text: part.text }; + if (part.type === "image_url" && part.image_url) { + const data = + /^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/.exec( + part.image_url.url, + ); + if (data) return { inlineData: { mimeType: data[1], data: data[2] } }; + const url = new URL(part.image_url.url); + if (url.protocol !== "https:") throw new Error("Unsupported image URL"); + return { fileData: { fileUri: url.href } }; + } + throw new Error("Unsupported model content"); + }); +} + +// Match LiteLLM's maintained OpenAI-client compatibility technique: only the +// opaque signature travels in the ID, never model text or tool arguments. +// https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/prompt_templates/factory.py +const signatureSeparator = "__thought__"; + +export function googleRequest(input: unknown) { + const request = chat.parse(input); + const contents: Content[] = []; + const system: Part[] = []; + const calls = new Map(); + for (const message of request.messages) { + let parts = contentParts(message.content); + if (message.role === "system" || message.role === "developer") { + if (parts.some((part) => part.text === undefined)) + throw new Error("System instructions must be text"); + system.push(...parts); + continue; + } + if (message.role === "assistant" && message.tool_calls?.length) { + const generated = message.tool_calls.map((call) => { + const args = z + .record(z.string(), z.unknown()) + .parse(JSON.parse(call.function.arguments)); + const separator = call.id.indexOf(signatureSeparator); + const cleanId = separator < 0 ? call.id : call.id.slice(0, separator); + const signature = + separator < 0 + ? undefined + : call.id.slice(separator + signatureSeparator.length); + const id = /^(?:models\/)?gemini-3/.test(request.model) + ? cleanId + : undefined; + calls.set(call.id, { name: call.function.name, id }); + return { + functionCall: { + name: call.function.name, + args, + ...(id ? { id } : {}), + }, + ...(signature ? { thoughtSignature: signature } : {}), + }; + }); + parts.push(...generated); + } + if (message.role === "tool") { + const call = calls.get(message.tool_call_id ?? ""); + if (!call) throw new Error("Tool result has no matching call"); + const text = parts + .flatMap((part) => (part.text === undefined ? [] : [part.text])) + .join("\n"); + const images = parts.filter((part) => part.inlineData !== undefined); + if (parts.some((part) => part.fileData !== undefined)) + throw new Error("Tool images must use inline data"); + let result: unknown = text; + try { + result = JSON.parse(text); + } catch { + /* Plain text tool output is valid. */ + } + parts = [ + { + functionResponse: { + ...call, + response: { result }, + ...(images.length ? { parts: images } : {}), + }, + }, + ]; + } + if (!parts.length) continue; + const role = message.role === "assistant" ? "model" : "user"; + const previous = contents.at(-1); + if (previous?.role === role) previous.parts.push(...parts); + else contents.push({ role, parts }); + } + const generationConfig: Record = {}; + if (request.temperature !== undefined) + generationConfig.temperature = request.temperature; + if (request.top_p !== undefined) generationConfig.topP = request.top_p; + if ( + request.max_completion_tokens !== undefined || + request.max_tokens !== undefined + ) + generationConfig.maxOutputTokens = + request.max_completion_tokens ?? request.max_tokens; + if (request.stop) + generationConfig.stopSequences = + typeof request.stop === "string" ? [request.stop] : request.stop; + if ( + request.response_format?.type === "json_object" || + request.response_format?.type === "json_schema" + ) { + generationConfig.responseMimeType = "application/json"; + if (request.response_format.json_schema) + generationConfig.responseJsonSchema = + request.response_format.json_schema.schema; + } + const choice = request.tool_choice; + const model = request.model.replace(/^models\//, ""); + return { + model, + stream: request.stream === true, + includeUsage: request.stream_options?.include_usage === true, + url: `https://generativelanguage.googleapis.com/v1beta/models/${model}:${request.stream ? "streamGenerateContent?alt=sse" : "generateContent"}`, + body: { + contents, + ...(system.length ? { systemInstruction: { parts: system } } : {}), + ...(Object.keys(generationConfig).length ? { generationConfig } : {}), + ...(request.tools?.length + ? { + tools: [ + { + functionDeclarations: request.tools.map(({ function: fn }) => ({ + name: fn.name, + ...(fn.description ? { description: fn.description } : {}), + ...(fn.parameters + ? { parametersJsonSchema: fn.parameters } + : {}), + })), + }, + ], + } + : {}), + ...(choice + ? { + toolConfig: { + functionCallingConfig: + typeof choice === "object" + ? { + mode: "ANY", + allowedFunctionNames: [choice.function.name], + } + : { + mode: { auto: "AUTO", none: "NONE", required: "ANY" }[ + choice + ], + }, + }, + } + : {}), + }, + }; +} + +function usage(metadata: NativeResponse["usageMetadata"]) { + if (!metadata) return undefined; + const prompt = metadata.promptTokenCount ?? 0; + const completion = + (metadata.candidatesTokenCount ?? 0) + (metadata.thoughtsTokenCount ?? 0); + return { + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: metadata.totalTokenCount ?? prompt + completion, + completion_tokens_details: { + reasoning_tokens: metadata.thoughtsTokenCount ?? 0, + }, + }; +} + +function finishReason(reason: string | undefined, hasCalls: boolean): string { + if (reason === "MAX_TOKENS") return "length"; + if (reason && reason !== "STOP") return "content_filter"; + return hasCalls ? "tool_calls" : "stop"; +} + +function outputCalls(parts: Part[]) { + return parts + .flatMap((part) => + part.functionCall ? [{ part, fn: part.functionCall }] : [], + ) + .map(({ part, fn }) => ({ + id: `${fn.id ?? `call_${randomUUID().replaceAll("-", "")}`}${part.thoughtSignature ? `${signatureSeparator}${part.thoughtSignature}` : ""}`, + type: "function" as const, + function: { name: fn.name, arguments: JSON.stringify(fn.args ?? {}) }, + })); +} + +/** Transform only successful native responses; the caller handles auth and errors. */ +export async function googleResponse( + response: Response, + model: string, + stream: boolean, + includeUsage = false, +): Promise { + const id = `chatcmpl-${randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + const headers = { + "content-type": stream ? "text/event-stream" : "application/json", + "cache-control": "no-store", + }; + if (!stream) { + const native = parseNative(await response.json()); + const candidate = native.candidates?.[0]; + const parts = candidate?.content?.parts ?? []; + const calls = outputCalls(parts); + return Response.json( + { + id, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: + parts + .filter((part) => !part.thought) + .map((part) => part.text ?? "") + .join("") || null, + ...(calls.length ? { tool_calls: calls } : {}), + extra_content: { google: { parts } }, + }, + finish_reason: finishReason( + candidate?.finishReason ?? native.promptFeedback?.blockReason, + calls.length > 0, + ), + }, + ], + usage: usage(native.usageMetadata), + }, + { headers }, + ); + } + if (!response.body) throw new Error("Gemini returned no stream"); + let buffer = ""; + let data: string[] = []; + const parts: Part[] = []; + let metadata: NativeResponse["usageMetadata"]; + let reason: string | undefined; + let began = false; + let failed = false; + const encode = new TextEncoder(); + const fail = (controller: TransformStreamDefaultController) => { + if (failed) return; + failed = true; + controller.enqueue( + encode.encode( + `data: ${JSON.stringify({ error: { message: "The Google model stream failed or ended early. Try again.", type: "provider_error", code: "incomplete_model_stream" } })}\n\n`, + ), + ); + }; + const send = ( + controller: TransformStreamDefaultController, + delta: Record, + finish: string | null = null, + ) => { + controller.enqueue( + encode.encode( + `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`, + ), + ); + }; + const frame = (controller: TransformStreamDefaultController) => { + if (!data.length) return; + const json = data.join("\n"); + data = []; + if (json === "[DONE]") return; + let native: NativeResponse; + try { + native = parseNative(JSON.parse(json)); + } catch { + fail(controller); + return; + } + if (!began) { + send(controller, { role: "assistant", content: "" }); + began = true; + } + const candidate = native.candidates?.[0]; + for (const part of candidate?.content?.parts ?? []) { + parts.push(part); + if (part.text && !part.thought) send(controller, { content: part.text }); + } + metadata = native.usageMetadata ?? metadata; + reason = + candidate?.finishReason ?? native.promptFeedback?.blockReason ?? reason; + }; + const transformed = response.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (failed) return; + buffer += chunk; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (!line) frame(controller); + else if (line.startsWith("data:")) + data.push(line.slice(5).replace(/^ /, "")); + else if (!/^(?::|event:|id:|retry:)/.test(line)) fail(controller); + if (failed) return; + newline = buffer.indexOf("\n"); + } + }, + flush(controller) { + if (failed) return; + if (buffer.trim() || data.length || !reason) { + fail(controller); + return; + } + const calls = outputCalls(parts); + if (calls.length) + send(controller, { + tool_calls: calls.map((call, index) => ({ index, ...call })), + }); + send(controller, {}, finishReason(reason, calls.length > 0)); + if (includeUsage && metadata) + controller.enqueue( + encode.encode( + `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage: usage(metadata) })}\n\n`, + ), + ); + controller.enqueue(encode.encode("data: [DONE]\n\n")); + }, + }), + ); + return new Response(transformed, { headers }); +} diff --git a/server/src/provider-oauth.ts b/server/src/provider-oauth.ts index 44032d435..4c3683b86 100644 --- a/server/src/provider-oauth.ts +++ b/server/src/provider-oauth.ts @@ -9,6 +9,7 @@ import { clearDesktopConnectionFailure, recordDesktopConnectionFailure, } from "./desktop-connection-failure"; +import { googleRequest, googleResponse } from "./google-oauth-transport"; export type ModelOAuthRecord = { version: 1; @@ -47,7 +48,6 @@ const schema = z.object({ const providers = { google: { token: "https://oauth2.googleapis.com/token", - chat: "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", }, xai: { token: "https://auth.x.ai/oauth2/token", @@ -235,6 +235,18 @@ export function createProviderOAuthProxy( ); } + let google: ReturnType | undefined; + if (current.provider === "google") { + try { + google = googleRequest(JSON.parse(new TextDecoder().decode(body))); + } catch { + return refused( + 400, + "The Google model request has unsupported or invalid content.", + ); + } + } + const send = (credential: ModelOAuthRecord) => { const headers = new Headers({ "content-type": "application/json", @@ -242,10 +254,10 @@ export function createProviderOAuthProxy( }); if (credential.provider === "google" && credential.quotaProject) headers.set("x-goog-user-project", credential.quotaProject); - return requestProvider(providers[credential.provider].chat, { + return requestProvider(google?.url ?? providers.xai.chat, { method: "POST", headers, - body, + body: google ? JSON.stringify(google.body) : body, redirect: "error", signal: request.signal, }); @@ -271,6 +283,13 @@ export function createProviderOAuthProxy( ); } clearDesktopConnectionFailure("model"); + if (google) + return await googleResponse( + response, + google.model, + google.stream, + google.includeUsage, + ); return new Response(response.body, { status: response.status, headers: { diff --git a/server/tests/google-oauth-transport.test.ts b/server/tests/google-oauth-transport.test.ts new file mode 100644 index 000000000..3e847db97 --- /dev/null +++ b/server/tests/google-oauth-transport.test.ts @@ -0,0 +1,229 @@ +import { expect, test } from "bun:test"; +import { googleRequest, googleResponse } from "../src/google-oauth-transport"; + +test("native Gemini converts tools, screenshot input and signed tool results without losing parts", async () => { + const nativeParts = [ + { text: "Looking at the page" }, + { + functionCall: { + name: "browser", + args: { url: "https://example.com" }, + id: "native-1", + }, + thoughtSignature: "opaque-signature", + }, + { + functionCall: { name: "chart", args: { values: [1, 2] }, id: "native-2" }, + }, + ]; + const response = await googleResponse( + Response.json({ + candidates: [{ content: { parts: nativeParts }, finishReason: "STOP" }], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + thoughtsTokenCount: 3, + totalTokenCount: 15, + }, + }), + "gemini-3.6-flash", + false, + ); + const completion = await response.json(); + expect(completion.choices[0].finish_reason).toBe("tool_calls"); + expect(completion.usage.completion_tokens).toBe(7); + const calls = completion.choices[0].message.tool_calls; + // Real generic clients keep standard fields and discard provider-specific fields. + const converted = googleRequest({ + model: "gemini-3.6-flash", + stream: true, + messages: [ + { role: "system", content: "Use the tools" }, + { + role: "user", + content: [ + { type: "text", text: "Read this" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,aW1hZ2U=" }, + }, + ], + }, + { + role: "assistant", + content: "Looking at the page", + tool_calls: calls.map( + (call: { id: string; type: string; function: unknown }) => ({ + id: call.id, + type: call.type, + function: call.function, + }), + ), + }, + { role: "tool", tool_call_id: calls[0].id, content: "Page loaded" }, + { + role: "tool", + tool_call_id: calls[1].id, + content: [ + { type: "text", text: "Rendered" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,c2NyZWVu" }, + }, + ], + }, + ], + tools: [ + { + type: "function", + function: { + name: "browser", + description: "Browse", + parameters: { + type: "object", + properties: { url: { type: "string" } }, + }, + }, + }, + ], + tool_choice: "required", + }); + expect(converted.url).toBe( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.6-flash:streamGenerateContent?alt=sse", + ); + expect(converted.body).toMatchObject({ + systemInstruction: { parts: [{ text: "Use the tools" }] }, + toolConfig: { functionCallingConfig: { mode: "ANY" } }, + tools: [ + { + functionDeclarations: [ + { name: "browser", parametersJsonSchema: { type: "object" } }, + ], + }, + ], + }); + expect(converted.body.contents[0].parts[1]).toEqual({ + inlineData: { mimeType: "image/png", data: "aW1hZ2U=" }, + }); + expect(converted.body.contents[1].parts).toEqual(nativeParts); + expect(converted.body.contents[2].parts).toEqual([ + { + functionResponse: { + name: "browser", + id: "native-1", + response: { result: "Page loaded" }, + }, + }, + { + functionResponse: { + name: "chart", + id: "native-2", + response: { result: "Rendered" }, + parts: [{ inlineData: { mimeType: "image/png", data: "c2NyZWVu" } }], + }, + }, + ]); +}); + +test("Gemini SSE produces incremental OpenAI text, signed tool calls, usage and DONE", async () => { + const frames = [ + { candidates: [{ content: { parts: [{ text: "Hello " }] } }] }, + { + candidates: [ + { + content: { + parts: [ + { text: "world" }, + { + functionCall: { + name: "browser", + args: { url: "https://example.com" }, + }, + thoughtSignature: "signed", + }, + ], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 3, + totalTokenCount: 5, + }, + }, + ]; + const wire = frames + .map((frame) => `data: ${JSON.stringify(frame)}\r\n\r\n`) + .join(""); + const bytes = new TextEncoder().encode(wire); + const upstream = new Response( + new ReadableStream({ + start(controller) { + for (let offset = 0; offset < bytes.length; offset += 7) + controller.enqueue(bytes.slice(offset, offset + 7)); + controller.close(); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + const response = await googleResponse( + upstream, + "gemini-3.6-flash", + true, + true, + ); + const text = await response.text(); + const chunks = text + .split("\n\n") + .filter((line) => line.startsWith("data: {")) + .map((line) => JSON.parse(line.slice(6))); + expect( + chunks + .flatMap((chunk) => chunk.choices) + .map((choice) => choice.delta.content ?? "") + .join(""), + ).toBe("Hello world"); + const toolChunk = chunks.find((chunk) => chunk.choices[0]?.delta.tool_calls); + expect(toolChunk.choices[0].delta.tool_calls[0].function.name).toBe( + "browser", + ); + expect( + chunks.some((chunk) => chunk.choices[0]?.finish_reason === "tool_calls"), + ).toBe(true); + expect(chunks.at(-1).usage.total_tokens).toBe(5); + expect(text.endsWith("data: [DONE]\n\n")).toBe(true); +}); + +test("unsupported content and unsafe model names fail before sending provider credentials", () => { + expect(() => googleRequest({ model: "../../other", messages: [] })).toThrow(); + expect(() => + googleRequest({ + model: "gemini-3.6-flash", + messages: [ + { role: "user", content: [{ type: "audio", data: "unsupported" }] }, + ], + }), + ).toThrow(); +}); + +test.each([ + 'data: {"error":{"code":429,"message":"private provider detail"}}\n\n', + '{"error":{"code":429,"message":"private provider detail"}}', + 'data: {"candidates":[{"content":{"parts":[{"text":"partial"}]}}]}\n\n', + 'data: {"candidates":', +])( + "failed or truncated native streams cannot become successful completions: %s", + async (wire) => { + const response = await googleResponse( + new Response(wire), + "gemini-3.6-flash", + true, + ); + const text = await response.text(); + expect(text).toContain('"error"'); + expect(text).not.toContain("private provider detail"); + expect(text).not.toContain('"finish_reason":"stop"'); + expect(text).not.toContain("[DONE]"); + }, +); diff --git a/server/tests/provider-oauth.test.ts b/server/tests/provider-oauth.test.ts index f7ddf366f..1bcf3355d 100644 --- a/server/tests/provider-oauth.test.ts +++ b/server/tests/provider-oauth.test.ts @@ -95,15 +95,17 @@ test("Google uses provider bearer and quota project while preserving streamed mo ); expect(request.headers.get("cookie")).toBeNull(); expect(await request.json()).toMatchObject({ - model: "chosen-model", - stream: true, + contents: [], }); - return new Response('data: {"choices":[]}\n\ndata: [DONE]\n\n', { - headers: { - "content-type": "text/event-stream", - "set-cookie": "must-not-leave-provider", + return new Response( + 'data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]},"finishReason":"STOP"}]}\n\n', + { + headers: { + "content-type": "text/event-stream", + "set-cookie": "must-not-leave-provider", + }, }, - }); + ); }); const response = await f.ask(); expect(response.status).toBe(200); @@ -112,7 +114,7 @@ test("Google uses provider bearer and quota project while preserving streamed mo expect(response.headers.get("cache-control")).toBe("no-store"); expect(await response.text()).toContain("data: [DONE]"); expect(f.destinations).toEqual([ - "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + "https://generativelanguage.googleapis.com/v1beta/models/chosen-model:streamGenerateContent?alt=sse", ]); }); @@ -151,12 +153,17 @@ test("concurrent requests refresh once and persist the rotated pair before forwa expect(request.headers.get("authorization")).toBe("Bearer rotated-access"); const saved = JSON.parse(await readFile(f.file, "utf8")); expect(saved.refreshToken).toBe("rotated-refresh"); - return Response.json({ choices: [] }); + return new Response( + 'data: {"candidates":[{"content":{"parts":[{"text":"Refreshed"}]},"finishReason":"STOP"}]}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); }); const responses = await Promise.all(Array.from({ length: 8 }, () => f.ask())); expect(responses.map((response) => response.status)).toEqual( Array(8).fill(200), ); + for (const response of responses) + expect(await response.text()).toContain("data: [DONE]"); expect(refreshes).toBe(1); const saved = JSON.parse(await readFile(f.file, "utf8")); expect(saved).toMatchObject({ @@ -171,6 +178,23 @@ test("concurrent requests refresh once and persist the rotated pair before forwa expect(refreshes).toBe(1); }); +test("invalid Google requests are rejected before forwarding the provider bearer", async () => { + const f = await fixture(record(), () => new Response("must not be called")); + const response = await f.app.request( + "/api/model-provider/v1/chat/completions", + { + method: "POST", + headers: { + authorization: "Bearer local-proxy-token", + "content-type": "application/json", + }, + body: JSON.stringify({ model: "../outside", messages: [] }), + }, + ); + expect(response.status).toBe(400); + expect(f.destinations).toEqual([]); +}); + test("an unexpired rejected xAI token is refreshed once and the request is replayed", async () => { let refreshes = 0; let calls = 0; From ce788c3db4a7d1eeb54470a8838dfd1e7d53ccc3 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 13:55:47 -0700 Subject: [PATCH 07/11] docs: explain native Google OAuth transport --- desktop/PROVIDER_OAUTH.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/PROVIDER_OAUTH.md b/desktop/PROVIDER_OAUTH.md index 9ae0b9b21..7aaefd774 100644 --- a/desktop/PROVIDER_OAUTH.md +++ b/desktop/PROVIDER_OAUTH.md @@ -6,7 +6,10 @@ OpenBot or CopilotKit. API keys remain available for both providers. ## Google Gemini Google OAuth uses the Gemini Developer API and the selected Google Cloud -project's API quota. It does not use a personal Gemini subscription. +project's API quota. It does not use a personal Gemini subscription. OAuth +requests use Google's native generation API; the local server translates the +existing agents' Chat Completions requests, streamed replies, tool calls and +screenshots. API-key connections continue to use Google's compatibility API. Before distributing a configured desktop build, register a **Desktop app** OAuth client in a Google Cloud project with the Generative Language API enabled. Set From f2e2d0f3c2323d7023f1b293ed5dbd0cd98e870c Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 14:05:24 -0700 Subject: [PATCH 08/11] test: use bounded blocking sockets in host result collector --- desktop/src-tauri/src/host_access.rs | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs index c74c43fb3..c3f60ef29 100644 --- a/desktop/src-tauri/src/host_access.rs +++ b/desktop/src-tauri/src/host_access.rs @@ -1653,6 +1653,15 @@ mod tests { loop { match listener.accept() { Ok((mut stream, _)) => { + // Winsock accept inherits the listener's nonblocking mode. + // Only accept polls; each fixture request uses bounded blocking I/O. + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); let mut content_length = 0_usize; loop { @@ -1720,6 +1729,30 @@ mod tests { } } + #[test] + fn result_collector_accepts_delayed_fragmented_requests() { + let collector = ResultCollector::start(); + let address = collector.base_url.strip_prefix("http://").unwrap(); + let mut stream = std::net::TcpStream::connect(address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream.write_all(b"POST /res").unwrap(); + thread::sleep(Duration::from_millis(50)); + stream + .write_all(b"ults HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\n\r\n{\"ok\":") + .unwrap(); + thread::sleep(Duration::from_millis(50)); + stream.write_all(b"true}").unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); + assert_eq!(collector.bodies(), vec!["{\"ok\":true}".to_owned()]); + } + fn temp_root(name: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( "{name}-{}-{}", From 5d9b2576a5923a697a181974e864b334668bc2f8 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 14:13:50 -0700 Subject: [PATCH 09/11] test: read the framed host response without waiting for EOF --- desktop/src-tauri/src/host_access.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs index c3f60ef29..9c4c53281 100644 --- a/desktop/src-tauri/src/host_access.rs +++ b/desktop/src-tauri/src/host_access.rs @@ -1747,9 +1747,11 @@ mod tests { .unwrap(); thread::sleep(Duration::from_millis(50)); stream.write_all(b"true}").unwrap(); - let mut response = String::new(); - stream.read_to_string(&mut response).unwrap(); - assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); + // Content-Length frames the response; a subsequent socket close is not part of it. + let expected = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"; + let mut response = vec![0_u8; expected.len()]; + stream.read_exact(&mut response).unwrap(); + assert_eq!(response, expected); assert_eq!(collector.bodies(), vec!["{\"ok\":true}".to_owned()]); } From 70fa994457914c00b5f2249da9f01bdf7b29f155 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 16:03:12 -0700 Subject: [PATCH 10/11] fix: make desktop OAuth callbacks and token rotation reliable --- desktop/src-tauri/capabilities/default.json | 6 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- desktop/src-tauri/src/organization_auth.rs | 112 ++++- desktop/src-tauri/src/provider_oauth.rs | 473 +++++++++++++++--- desktop/src-tauri/src/provider_oauth_lock.rs | 111 ++++ .../provider-oauth-lock-owner.rs | 12 + server/src/desktop-connection-failure.ts | 6 +- server/src/provider-oauth-lock.ts | 176 +++++++ server/src/provider-oauth.ts | 166 +++--- .../tests/desktop-connection-failure.test.ts | 70 ++- .../provider-oauth-lock-cross-process.ts | 102 ++++ .../fixtures/provider-oauth-lock-owner.ts | 18 + server/tests/google-oauth-transport.test.ts | 99 +++- server/tests/provider-oauth.test.ts | 318 +++++++++++- 14 files changed, 1480 insertions(+), 191 deletions(-) create mode 100644 desktop/src-tauri/src/provider_oauth_lock.rs create mode 100644 desktop/src-tauri/test-fixtures/provider-oauth-lock-owner.rs create mode 100644 server/src/provider-oauth-lock.ts create mode 100644 server/tests/fixtures/provider-oauth-lock-cross-process.ts create mode 100644 server/tests/fixtures/provider-oauth-lock-owner.ts diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index ba8a903c7..d9f97e5a0 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -7,11 +7,7 @@ "core:event:default", { "identifier": "opener:allow-open-url", - "allow": [ - { "url": "https://*" }, - { "url": "http://*" }, - { "url": "mailto:*" } - ] + "allow": [{ "url": "https://*" }, { "url": "http://*" }] } ] } diff --git a/desktop/src-tauri/gen/schemas/capabilities.json b/desktop/src-tauri/gen/schemas/capabilities.json index ab3490333..c5e5647c6 100644 --- a/desktop/src-tauri/gen/schemas/capabilities.json +++ b/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.","local":true,"windows":["main"],"permissions":["core:event:default",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"},{"url":"mailto:*"}]}]}} \ No newline at end of file +{"default":{"identifier":"default","description":"What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.","local":true,"windows":["main"],"permissions":["core:event:default",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"}]}]}} \ No newline at end of file diff --git a/desktop/src-tauri/src/organization_auth.rs b/desktop/src-tauri/src/organization_auth.rs index 60fa912c4..e6eff80c6 100644 --- a/desktop/src-tauri/src/organization_auth.rs +++ b/desktop/src-tauri/src/organization_auth.rs @@ -7,8 +7,8 @@ use reqwest::blocking::Client; use reqwest::Url; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::io::{Read, Write}; -use std::net::TcpListener; +use std::io::Write; +use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex, OnceLock}; @@ -177,6 +177,23 @@ fn callback_code(request: &str, expected_state: &str) -> Result Ok(codes[0].1.to_string()) } +fn receive_callback(stream: &mut TcpStream, expected_state: &str) -> Result { + let result = crate::provider_oauth::read_callback_request(stream, Duration::from_secs(3)) + .map_err(|_| problem("OpenBot could not read the organization callback.")) + .and_then(|request| callback_code(&request, expected_state)); + let (status, message) = if result.is_ok() { + ("200 OK", "Sign-in received. You can return to OpenBot.") + } else { + ( + "400 Bad Request", + "Sign-in did not match. Return to OpenBot and try again.", + ) + }; + let reply = format!("HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{message}", message.len()); + let _ = stream.write_all(reply.as_bytes()); + result +} + pub fn begin(root: &Path, authority_url: &str, provider: &str) -> Result { let authority = authority(authority_url)?; if !matches!(provider, "google" | "microsoft" | "okta") { @@ -227,27 +244,7 @@ pub fn begin(root: &Path, authority_url: &str, provider: &str) -> Result { - let _ = stream.set_read_timeout(Some(Duration::from_secs(3))); - let mut bytes = [0u8; 8192]; - let result = stream - .read(&mut bytes) - .map_err(|_| problem("OpenBot could not read the organization callback.")) - .and_then(|length| { - callback_code( - &String::from_utf8_lossy(&bytes[..length]), - &expected_state, - ) - }); - let (status, message) = if result.is_ok() { - ("200 OK", "Sign-in received. You can return to OpenBot.") - } else { - ( - "400 Bad Request", - "Sign-in did not match. Return to OpenBot and try again.", - ) - }; - let reply = format!("HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{message}", message.len()); - let _ = stream.write_all(reply.as_bytes()); + let result = receive_callback(&mut stream, &expected_state); let _ = sender.send(result); return; } @@ -403,7 +400,74 @@ pub fn session_destination( #[cfg(test)] mod tests { use super::*; - use std::io::{BufRead, BufReader}; + use std::io::{BufRead, BufReader, Read}; + fn callback_over_tcp(nonblocking: bool, fragmented: bool) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut browser = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + browser + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "browser connection was not accepted" + ); + std::thread::yield_now(); + } + Err(error) => panic!("browser connection failed: {error}"), + } + }; + // Unix does not consistently inherit this flag; model the Windows accept behavior. + if nonblocking { + stream.set_nonblocking(true).unwrap(); + } else { + stream.set_nonblocking(false).unwrap(); + } + let request = b"GET /organization-auth/callback?state=expected&code=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let split = if fragmented { 18 } else { 0 }; + if fragmented { + browser.write_all(&request[..split]).unwrap(); + } + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + sender + .send(receive_callback(&mut stream, "expected")) + .unwrap(); + }); + let early = receiver.recv_timeout(Duration::from_millis(100)); + if !matches!(early, Err(std::sync::mpsc::RecvTimeoutError::Timeout)) { + worker.join().unwrap(); + panic!("callback completed before its request line arrived: {early:?}"); + } + browser.write_all(&request[split..]).unwrap(); + assert_eq!( + receiver + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .unwrap(), + "a".repeat(32) + ); + let mut response = String::new(); + browser.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); + worker.join().unwrap(); + } + + #[test] + fn callback_waits_for_delayed_get_on_nonblocking_stream() { + callback_over_tcp(true, false); + } + + #[test] + fn callback_waits_for_fragmented_request_line() { + callback_over_tcp(false, true); + } + #[test] fn cancellation_retires_a_finish_already_waiting_for_the_browser() { let root = PathBuf::from("organization-cancellation-fixture"); diff --git a/desktop/src-tauri/src/provider_oauth.rs b/desktop/src-tauri/src/provider_oauth.rs index f027c1275..a487f9bf5 100644 --- a/desktop/src-tauri/src/provider_oauth.rs +++ b/desktop/src-tauri/src/provider_oauth.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ io::{Read, Write}, - net::TcpListener, + net::{TcpListener, TcpStream}, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, @@ -14,6 +14,9 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +#[path = "provider_oauth_lock.rs"] +mod credential_lock; + pub const FILE: &str = ".openbot/model-oauth.json"; const XAI_CLIENT: &str = "b1a00492-073a-47ea-816f-4c329264a828"; const XAI_SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; @@ -203,11 +206,7 @@ pub fn begin(root: &Path, provider: &str) -> Result { .send() .map_err(|e| e.to_string())?, )?; - let url = body - .get("verification_uri_complete") - .and_then(|v| v.as_str()) - .map(str::to_owned) - .unwrap_or(required(&body, "verification_uri")?); + let url = xai_verification_url(&body)?; let user_code = required(&body, "user_code")?; let device_code = required(&body, "device_code")?; let interval = body @@ -270,6 +269,27 @@ fn wait(flow: &Flow, duration: Duration) -> Result<(), String> { check(flow) } +fn xai_verification_url(body: &serde_json::Value) -> Result { + let value = body + .get("verification_uri_complete") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .map(Ok) + .unwrap_or_else(|| required(body, "verification_uri"))?; + let invalid = "xAI returned an unsupported sign-in URL."; + let parsed = reqwest::Url::parse(&value).map_err(|_| invalid)?; + // The provider's device verification page is https://accounts.x.ai/oauth2/device. + if parsed.scheme() != "https" + || parsed.host_str() != Some("accounts.x.ai") + || parsed.port_or_known_default() != Some(443) + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(invalid.into()); + } + Ok(parsed.into()) +} + pub fn finish(id: &str) -> Result<(), String> { let flow = active() .lock() @@ -291,6 +311,74 @@ pub fn finish(id: &str) -> Result<(), String> { } result } +pub(crate) fn read_callback_request( + stream: &mut TcpStream, + timeout: Duration, +) -> std::io::Result { + // Winsock accept inherits the listener's nonblocking mode; a timeout does not clear it. + stream.set_nonblocking(false)?; + let deadline = Instant::now() + timeout; + let mut bytes = [0; 8192]; + let mut length = 0; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Sign-in callback timed out.", + )); + } + stream.set_read_timeout(Some(remaining))?; + let count = stream.read(&mut bytes[length..])?; + if count == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Incomplete sign-in callback.", + )); + } + length += count; + if let Some(end) = bytes[..length].iter().position(|byte| *byte == b'\n') { + return Ok(String::from_utf8_lossy(&bytes[..=end]).into_owned()); + } + if length == bytes.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Sign-in callback is too large.", + )); + } + } +} + +fn receive_callback(stream: &mut TcpStream, state: &str) -> Result, String> { + let request = + read_callback_request(stream, Duration::from_secs(2)).map_err(|error| error.to_string())?; + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or(""); + let parsed = reqwest::Url::parse(&format!("http://localhost{path}")) + .map_err(|_| "Invalid sign-in callback.")?; + let query: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect(); + if parsed.path() != "/oauth/callback" || query.get("state").map(String::as_str) != Some(state) { + let _ = stream.write_all( + b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nInvalid sign-in callback.", + ); + return Ok(None); + } + if let Some(error) = query.get("error") { + let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nSign-in was not approved. Return to OpenBot."); + return Err(format!("Google sign-in was not approved: {error}")); + } + let code = query + .get("code") + .filter(|v| !v.is_empty()) + .cloned() + .ok_or("Google did not return an authorization code.")?; + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nReturn to OpenBot to finish signing in.").map_err(|e| e.to_string())?; + Ok(Some(code)) +} + fn finish_flow(flow: &Flow, pending: Pending) -> Result<(), String> { let (provider, client_id, client_secret, project, scope, tokens) = match pending { Pending::Google { @@ -310,37 +398,9 @@ fn finish_flow(flow: &Flow, pending: Pending) -> Result<(), String> { } match listener.accept() { Ok((mut stream, _)) => { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|e| e.to_string())?; - let mut bytes = [0; 8192]; - let count = stream.read(&mut bytes).map_err(|e| e.to_string())?; - let request = String::from_utf8_lossy(&bytes[..count]); - let path = request - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or(""); - let parsed = reqwest::Url::parse(&format!("http://localhost{path}")) - .map_err(|_| "Invalid sign-in callback.")?; - let query: std::collections::HashMap<_, _> = - parsed.query_pairs().into_owned().collect(); - if parsed.path() != "/oauth/callback" || query.get("state") != Some(&state) - { - let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nInvalid sign-in callback."); - continue; + if let Some(code) = receive_callback(&mut stream, &state)? { + break code; } - if let Some(error) = query.get("error") { - let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nSign-in was not approved. Return to OpenBot."); - return Err(format!("Google sign-in was not approved: {error}")); - } - let code = query - .get("code") - .filter(|v| !v.is_empty()) - .cloned() - .ok_or("Google did not return an authorization code.")?; - stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nReturn to OpenBot to finish signing in.").map_err(|e| e.to_string())?; - break code; } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { wait(flow, Duration::from_millis(100))? @@ -519,34 +579,152 @@ fn write( )); } } - let lock = PathBuf::from(format!("{}.lock", path.display())); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - match std::fs::create_dir(&lock) { - Ok(()) => break, - Err(error) - if error.kind() == std::io::ErrorKind::AlreadyExists - && Instant::now() < deadline => - { - std::thread::sleep(Duration::from_millis(50)) - } - Err(error) => return Err(format!("Could not save provider sign-in: {error}")), - } - } - let result = current().and_then(|()| { + let _lock = credential_lock::acquire(&path) + .map_err(|error| format!("Could not save provider sign-in: {error}"))?; + current().and_then(|()| { serde_json::to_vec(credentials) .map_err(|e| e.to_string()) .and_then(|bytes| { crate::env::write_private_file(&path, &bytes).map_err(|e| e.to_string()) }) - }); - let unlock = std::fs::remove_dir(lock).map_err(|e| e.to_string()); - result.and(unlock) + }) } #[cfg(test)] mod tests { use super::*; + #[test] + fn xai_verification_url_rejects_unsafe_or_foreign_destinations() { + for url in [ + "http://accounts.x.ai/oauth2/device", + "https://accounts.x.ai.evil.example/oauth2/device", + "https://evil.example/oauth2/device", + "https://user@accounts.x.ai/oauth2/device", + "https://user:password@accounts.x.ai/oauth2/device", + "https://accounts.x.ai:444/oauth2/device", + "javascript:alert(1)", + "file:///tmp/signin", + "mailto:signin@example.com", + "/oauth2/device", + ] { + for field in ["verification_uri", "verification_uri_complete"] { + let body = serde_json::json!({field: url}); + assert!( + xai_verification_url(&body).is_err(), + "accepted {field}: {url}" + ); + } + } + } + + #[test] + fn xai_verification_url_preserves_the_provider_code() { + let plain = "https://accounts.x.ai/oauth2/device"; + let complete = "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH&referrer=openbot"; + assert_eq!( + xai_verification_url(&serde_json::json!({"verification_uri": plain})).unwrap(), + plain + ); + assert_eq!(xai_verification_url(&serde_json::json!({"verification_uri": plain, "verification_uri_complete": complete})).unwrap(), complete); + assert_eq!( + xai_verification_url(&serde_json::json!({"verification_uri_complete": complete})) + .unwrap(), + complete + ); + } + + #[test] + fn callback_read_keeps_its_time_and_size_bounds() { + for (payload, expected) in [ + (Vec::new(), std::io::ErrorKind::TimedOut), + (vec![b'x'; 8192], std::io::ErrorKind::InvalidData), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let mut browser = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + browser.write_all(&payload).unwrap(); + let started = Instant::now(); + let error = read_callback_request(&mut stream, Duration::from_millis(100)).unwrap_err(); + if expected == std::io::ErrorKind::TimedOut { + assert!(matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + )); + assert!(started.elapsed() >= Duration::from_millis(80)); + } else { + assert_eq!(error.kind(), expected); + } + assert!(started.elapsed() < Duration::from_secs(2)); + } + } + + fn callback_over_tcp(nonblocking: bool, fragmented: bool) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut browser = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + browser + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "browser connection was not accepted" + ); + std::thread::yield_now(); + } + Err(error) => panic!("browser connection failed: {error}"), + } + }; + // Unix does not consistently inherit this flag; model the Windows accept behavior. + if nonblocking { + stream.set_nonblocking(true).unwrap(); + } else { + stream.set_nonblocking(false).unwrap(); + } + let request = b"GET /oauth/callback?state=expected&code=synthetic HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let split = if fragmented { 18 } else { 0 }; + if fragmented { + browser.write_all(&request[..split]).unwrap(); + } + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + sender + .send(receive_callback(&mut stream, "expected")) + .unwrap(); + }); + let early = receiver.recv_timeout(Duration::from_millis(100)); + if !matches!(early, Err(std::sync::mpsc::RecvTimeoutError::Timeout)) { + worker.join().unwrap(); + panic!("callback completed before its request line arrived: {early:?}"); + } + browser.write_all(&request[split..]).unwrap(); + assert_eq!( + receiver + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .unwrap(), + Some("synthetic".into()) + ); + let mut response = String::new(); + browser.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); + worker.join().unwrap(); + } + + #[test] + fn callback_waits_for_delayed_get_on_nonblocking_stream() { + callback_over_tcp(true, false); + } + + #[test] + fn callback_waits_for_fragmented_request_line() { + callback_over_tcp(false, true); + } + #[test] fn tokens_require_refresh_and_expiry() { let missing = serde_json::json!({"access_token":"synthetic", "expires_in":3600}); @@ -629,9 +807,192 @@ mod tests { write(&root, &saved, || Ok(())).unwrap(); assert_eq!(read(&root, "xai").unwrap().session_id, saved.session_id); assert!(read(&root, "google").is_err()); - assert!(!root.join(format!("{FILE}.lock")).exists()); + assert!(root.join(format!("{FILE}.lock/owner.lock")).is_file()); std::fs::remove_dir_all(root).unwrap(); } + struct LockChild(std::process::Child); + impl Drop for LockChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[test] + fn credential_lock_child_process() { + let Some(path) = std::env::var_os("OPENBOT_TEST_CREDENTIAL_LOCK_CHILD") else { + return; + }; + let _lock = credential_lock::acquire(Path::new(&path)).unwrap(); + println!("locked"); + std::io::stdout().flush().unwrap(); + std::io::stdin().read_line(&mut String::new()).unwrap(); + } + + fn lock_child_ready(child: &mut LockChild) -> std::sync::mpsc::Receiver { + use std::io::BufRead; + let stdout = child.0.stdout.take().unwrap(); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(stdout); + let mut line = String::new(); + loop { + match reader.read_line(&mut line) { + Ok(0) => { + let _ = send.send("child exited without a lock".into()); + return; + } + Ok(_) if line.trim() == "locked" => { + let _ = send.send("locked".into()); + return; + } + Ok(_) => line.clear(), + Err(error) => { + let _ = send.send(error.to_string()); + return; + } + } + } + }); + receive + } + + #[test] + fn credential_lock_excludes_bun_in_both_directions_and_recovers_after_kill() { + use std::process::Stdio; + let root = crate::test_support::temp_root("provider-oauth-cross-writer"); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("model-oauth.json"); + let native = credential_lock::acquire(&path).unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../server/tests/fixtures/provider-oauth-lock-owner.ts"); + let bun = std::env::var_os("OPENBOT_TEST_BUN").unwrap_or_else(|| "bun".into()); + let mut child = LockChild( + crate::quiet::command(bun) + .args([fixture.as_os_str(), path.as_os_str()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ); + let ready = lock_child_ready(&mut child); + assert!(matches!( + ready.recv_timeout(Duration::from_millis(150)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + drop(native); + assert_eq!( + ready.recv_timeout(Duration::from_secs(15)).unwrap(), + "locked" + ); + let other_path = path.clone(); + let (send, receive) = std::sync::mpsc::channel(); + let waiter = std::thread::spawn(move || { + send.send(credential_lock::acquire(&other_path)).unwrap(); + }); + assert!(matches!( + receive.recv_timeout(Duration::from_millis(150)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + drop( + receive + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(), + ); + waiter.join().unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn credential_lock_recovers_after_native_process_is_killed() { + use std::process::Stdio; + let root = crate::test_support::temp_root("provider-oauth-native-kill"); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("model-oauth.json"); + let mut child = LockChild( + crate::quiet::command(std::env::current_exe().unwrap()) + .args([ + "--exact", + "provider_oauth::tests::credential_lock_child_process", + "--nocapture", + ]) + .env("OPENBOT_TEST_CREDENTIAL_LOCK_CHILD", &path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ); + let ready = lock_child_ready(&mut child); + assert_eq!( + ready.recv_timeout(Duration::from_secs(5)).unwrap(), + "locked" + ); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + drop(credential_lock::acquire(&path).unwrap()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn credential_replacement_waits_for_existing_rotation_owner() { + let root = crate::test_support::temp_root("provider-oauth-replacement"); + let tokens = serde_json::json!({"access_token":"synthetic", "refresh_token":"synthetic-refresh", "expires_in":3600}); + let old = credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &tokens) + .unwrap(); + write(&root, &old, || Ok(())).unwrap(); + let lock = credential_lock::acquire(&root.join(FILE)).unwrap(); + let new = credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &tokens) + .unwrap(); + let newer_session = new.session_id.clone(); + let other_root = root.clone(); + let (send, receive) = std::sync::mpsc::channel(); + let writer = + std::thread::spawn(move || send.send(write(&other_root, &new, || Ok(()))).unwrap()); + assert!(matches!( + receive.recv_timeout(Duration::from_millis(150)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + assert_eq!(read(&root, "xai").unwrap().session_id, old.session_id); + drop(lock); + receive + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + writer.join().unwrap(); + assert_eq!(read(&root, "xai").unwrap().session_id, newer_session); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn credential_save_recovers_abandoned_legacy_lock_directory() { + let root = crate::test_support::temp_root("provider-oauth-orphan"); + std::fs::create_dir_all(root.join(format!("{FILE}.lock"))).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + root.join(format!("{FILE}.lock")), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + let tokens = serde_json::json!({"access_token":"synthetic", "refresh_token":"synthetic-refresh", "expires_in":3600}); + let saved = credentials_from_tokens("xai", "client".into(), None, None, XAI_SCOPE, &tokens) + .unwrap(); + let result = write(&root, &saved, || Ok(())); + assert!( + result.is_ok(), + "abandoned directory blocked sign-in: {result:?}" + ); + assert_eq!(read(&root, "xai").unwrap().session_id, saved.session_id); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn canceled_sign_in_cannot_replace_current_credentials() { let root = crate::test_support::temp_root("provider-oauth-canceled"); @@ -643,7 +1004,7 @@ mod tests { replacement.session_id = "canceled".into(); assert!(write(&root, &replacement, || Err("canceled".into())).is_err()); assert_eq!(read(&root, "xai").unwrap().session_id, saved.session_id); - assert!(!root.join(format!("{FILE}.lock")).exists()); + assert!(root.join(format!("{FILE}.lock/owner.lock")).is_file()); std::fs::remove_dir_all(root).unwrap(); } } diff --git a/desktop/src-tauri/src/provider_oauth_lock.rs b/desktop/src-tauri/src/provider_oauth_lock.rs new file mode 100644 index 000000000..71c26e214 --- /dev/null +++ b/desktop/src-tauri/src/provider_oauth_lock.rs @@ -0,0 +1,111 @@ +//! Shared with server/src/provider-oauth-lock.ts. Keep the directory and lock +//! inode permanently; OS handle ownership releases exclusion after a crash. +use std::{ + fs::{File, OpenOptions}, + io, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +pub fn acquire(credential: &Path) -> io::Result { + let mut name = credential.as_os_str().to_os_string(); + name.push(".lock"); + let directory = PathBuf::from(name); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&directory) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (), + Err(error) => return Err(error), + } + let metadata = std::fs::symlink_metadata(&directory)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid credential lock directory.", + )); + } + validate_private(&metadata, true)?; + let path = directory.join("owner.lock"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::{fs::OpenOptionsExt, io::AsRawFd}; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + let file = options.open(path)?; + validate_file(&file)?; + loop { + // SAFETY: file owns a valid descriptor for the lifetime of this call. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(file); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::WouldBlock || Instant::now() >= deadline { + return Err(error); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Exactly the CreateFileW sharing/no-follow mode used by the Bun helper. + options.share_mode(0).custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + loop { + match options.open(&path) { + Ok(file) => { + validate_file(&file)?; + return Ok(file); + } + Err(error) if error.raw_os_error() == Some(32) && Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => return Err(error), + } + } + } +} + +fn validate_file(file: &File) -> io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid credential lock file.", + )); + } + validate_private(&metadata, false) +} + +fn validate_private(metadata: &std::fs::Metadata, directory: bool) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Legacy mkdir directories may be 0755, but other users must not be + // able to replace their entries. The lock file remains owner-only. + if metadata.permissions().mode() & (if directory { 0o022 } else { 0o077 }) != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Credential lock is not private.", + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + let _ = directory; + if metadata.file_attributes() & 0x400 != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Credential lock cannot be redirected.", + )); + } + } + Ok(()) +} diff --git a/desktop/src-tauri/test-fixtures/provider-oauth-lock-owner.rs b/desktop/src-tauri/test-fixtures/provider-oauth-lock-owner.rs new file mode 100644 index 000000000..f213fcfde --- /dev/null +++ b/desktop/src-tauri/test-fixtures/provider-oauth-lock-owner.rs @@ -0,0 +1,12 @@ +// Standalone Windows proof: rustc --edition 2021 provider-oauth-lock-owner.rs +// Unix integration uses the Cargo test binary so libc is supplied by the package. +#[path = "../src/provider_oauth_lock.rs"] +mod credential_lock; +use std::io::{self, Write}; +fn main() { + let path = std::env::args_os().nth(1).expect("credential path"); + let _lock = credential_lock::acquire(std::path::Path::new(&path)).unwrap(); + println!("locked"); + io::stdout().flush().unwrap(); + io::stdin().read_line(&mut String::new()).unwrap(); +} diff --git a/server/src/desktop-connection-failure.ts b/server/src/desktop-connection-failure.ts index 6b1197872..669dbc09f 100644 --- a/server/src/desktop-connection-failure.ts +++ b/server/src/desktop-connection-failure.ts @@ -43,14 +43,16 @@ export function mountDesktopConnectionFailure( }); } -/** Only provider SDK errors qualify; a harness HTTP 401 is a different credential. */ +/** Only provider SDK errors qualify; a harness HTTP 401 is a different credential. + * A provider 403 can mean missing project/resource permission, not expired credentials. + */ export function isModelAuthenticationError(error: unknown): boolean { if (!(error instanceof Error)) return false; if (error.name === "OpenBotModelAuthenticationError") return true; if ( error.name === "AI_APICallError" && "statusCode" in error && - (error.statusCode === 401 || error.statusCode === 403) + error.statusCode === 401 ) return true; return ( diff --git a/server/src/provider-oauth-lock.ts b/server/src/provider-oauth-lock.ts new file mode 100644 index 000000000..a27c62262 --- /dev/null +++ b/server/src/provider-oauth-lock.ts @@ -0,0 +1,176 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, open } from "node:fs/promises"; +import { join } from "node:path"; + +export class CredentialLockUnavailable extends Error {} + +// Both writers keep this directory and inode permanently. Never remove a held lock: +// another opener could then lock a different inode. Empty legacy directories work too. +export async function lockProviderCredentials( + file: string, +): Promise<() => Promise> { + try { + const directory = `${file}.lock`; + await mkdir(directory, { mode: 0o700 }).catch((error: unknown) => { + if ( + !(error instanceof Error && "code" in error && error.code === "EEXIST") + ) + throw error; + }); + const info = await lstat(directory); + // Legacy native mkdir could leave 0755; only its owner can change entries. + // The stable lock file itself must still be 0600. + if ( + !info.isDirectory() || + info.isSymbolicLink() || + (process.platform !== "win32" && (info.mode & 0o022) !== 0) + ) + throw new Error("The credential lock directory is not private."); + const path = join(directory, "owner.lock"); + return process.platform === "win32" + ? await windowsLock(path) + : await unixLock(path); + } catch { + throw new CredentialLockUnavailable( + "The model credential store is busy or unavailable.", + ); + } +} + +async function unixLock(path: string): Promise<() => Promise> { + const ffi = await import("bun:ffi"); + const mac = process.platform === "darwin"; + const library = ffi.dlopen(mac ? "/usr/lib/libSystem.B.dylib" : "libc.so.6", { + flock: { args: ["i32", "i32"], returns: "i32" }, + [mac ? "__error" : "__errno_location"]: { args: [], returns: "ptr" }, + }); + const handle = await open( + path, + constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, + 0o600, + ).catch((error: unknown) => { + library.close(); + throw error; + }); + try { + const info = await handle.stat(); + if (!info.isFile() || (info.mode & 0o077) !== 0) + throw new Error("Invalid credential lock file."); + const deadline = Date.now() + 5000; + while (library.symbols.flock(handle.fd, 2 | 4) !== 0) { + // LOCK_EX | LOCK_NB + const address = library.symbols[mac ? "__error" : "__errno_location"](); + if ( + !address || + ffi.read.i32(address) !== (mac ? 35 : 11) || + Date.now() >= deadline + ) + throw new Error("Could not acquire credential lock."); + await Bun.sleep(25); + } + return async () => { + // Closing releases flock even if this process is killed before explicit cleanup. + try { + await handle.close(); + } finally { + library.close(); + } + }; + } catch (error) { + try { + await handle.close(); + } finally { + library.close(); + } + throw error; + } +} + +// Bun 1.3 has no Windows ARM64 FFI. Windows PowerShell ships with the OS and +// owns the same share-mode lock as Rust. EOF on this pipe also releases it when +// the Bun parent dies. The path is environment data, never interpolated code. +const windowsLockScript = ` +$ErrorActionPreference='Stop' +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class OpenBotCredentialLock { + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool GetFileInformationByHandleEx(SafeFileHandle file, int kind, out TagInfo info, uint size); + [StructLayout(LayoutKind.Sequential)] struct TagInfo { public uint Attributes; public uint Tag; } + public static SafeFileHandle Open(string path) { + var file = CreateFileW(path, 0xC0000000, 0, IntPtr.Zero, 4, 0x00200000, IntPtr.Zero); + if (file.IsInvalid) { int error = Marshal.GetLastWin32Error(); file.Dispose(); throw new Win32Exception(error); } + TagInfo info; + if (!GetFileInformationByHandleEx(file, 9, out info, 8) || (info.Attributes & 0x410) != 0) { + file.Dispose(); throw new InvalidOperationException("Invalid credential lock file."); + } + return file; + } +} +'@ +$deadline=[DateTime]::UtcNow.AddSeconds(5) +$file=$null +while ($null -eq $file) { + try { $file=[OpenBotCredentialLock]::Open($env:OPENBOT_CREDENTIAL_LOCK) } + catch { + $cause=$_.Exception.GetBaseException() + if ($cause -isnot [System.ComponentModel.Win32Exception] -or $cause.NativeErrorCode -ne 32 -or [DateTime]::UtcNow -ge $deadline) { exit 1 } + Start-Sleep -Milliseconds 25 + } +} +try { [Console]::Out.WriteLine('locked'); [Console]::Out.Flush(); [Console]::In.ReadLine() | Out-Null } +finally { $file.Dispose() } +`; + +async function windowsLock(path: string): Promise<() => Promise> { + const env: NodeJS.ProcessEnv = { + ...process.env, + OPENBOT_CREDENTIAL_LOCK: path, + }; + delete env.PSModulePath; + const child = Bun.spawn( + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-Command", + windowsLockScript, + ], + { + env, + stdin: "pipe", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }, + ); + const reader = child.stdout.getReader(); + const timeout = setTimeout(() => child.kill(), 15_000); + try { + let message = ""; + while (!message.includes("\n")) { + const { value, done } = await reader.read(); + if (done) throw new Error("Could not acquire credential lock."); + message += new TextDecoder().decode(value); + } + if (message.trim() !== "locked") + throw new Error("Invalid credential lock response."); + } catch (error) { + child.kill(); + await child.exited; + throw error; + } finally { + clearTimeout(timeout); + reader.releaseLock(); + } + return async () => { + child.stdin.end(); + if ((await child.exited) !== 0) + throw new Error("Could not release credential lock."); + }; +} diff --git a/server/src/provider-oauth.ts b/server/src/provider-oauth.ts index 4c3683b86..75d48cd52 100644 --- a/server/src/provider-oauth.ts +++ b/server/src/provider-oauth.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { mkdir, open, rename, rmdir, unlink } from "node:fs/promises"; +import { lstat, open, rename, unlink } from "node:fs/promises"; import { isAbsolute } from "node:path"; import type { Env, Hono } from "hono"; import { z } from "zod"; @@ -10,6 +10,10 @@ import { recordDesktopConnectionFailure, } from "./desktop-connection-failure"; import { googleRequest, googleResponse } from "./google-oauth-transport"; +import { + CredentialLockUnavailable, + lockProviderCredentials, +} from "./provider-oauth-lock"; export type ModelOAuthRecord = { version: 1; @@ -58,13 +62,25 @@ const providers = { class SignInRequired extends Error {} async function readRecord(file: string): Promise { + // Windows ignores O_NOFOLLOW. Bind its no-follow path metadata to the opened + // handle before reading bytes, so a replacement between lstat/open is refused. + const before = + process.platform === "win32" + ? await lstat(file, { bigint: true }) + : undefined; + if (before && (!before.isFile() || before.ino === 0n)) + throw new Error("The private model credential path is not valid."); const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); try { - const info = await handle.stat(); + const info = await handle.stat({ bigint: true }); + if (before && (before.dev !== info.dev || before.ino !== info.ino)) + throw new Error( + "The private model credential path changed while opening.", + ); if ( !info.isFile() || - info.size > 64 * 1024 || - (process.platform !== "win32" && (info.mode & 0o077) !== 0) + info.size > 64n * 1024n || + (process.platform !== "win32" && (info.mode & 0o077n) !== 0n) ) throw new Error("The private model credential file is not valid."); const record = schema.parse(JSON.parse(await handle.readFile("utf8"))); @@ -86,17 +102,6 @@ async function persistRotation( previous: ModelOAuthRecord, next: ModelOAuthRecord, ): Promise { - const lock = `${file}.lock`; - const deadline = Date.now() + 5000; - for (;;) { - try { - await mkdir(lock, { mode: 0o700 }); - break; - } catch (error) { - if (!hasCode(error, "EEXIST") || Date.now() >= deadline) throw error; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } const temporary = `${file}.${randomUUID()}.tmp`; try { const current = await readRecord(file); @@ -114,13 +119,9 @@ async function persistRotation( } await rename(temporary, file); } finally { - try { - await unlink(temporary).catch((error: unknown) => { - if (!hasCode(error, "ENOENT")) throw error; - }); - } finally { - await rmdir(lock); - } + await unlink(temporary).catch((error: unknown) => { + if (!hasCode(error, "ENOENT")) throw error; + }); } } @@ -147,55 +148,61 @@ export function createProviderOAuthProxy( const pending = refreshes.get(previous.sessionId); if (pending) return pending; const work = (async () => { - const current = await readRecord(file); - if (current.sessionId !== previous.sessionId) - throw new SignInRequired("The model sign-in changed."); - // A concurrent request may have already rotated the token that received a 401. - if (current.accessToken !== previous.accessToken) return current; - const body = new URLSearchParams({ - grant_type: "refresh_token", - client_id: current.clientId, - refresh_token: current.refreshToken, - }); - if (current.clientSecret) body.set("client_secret", current.clientSecret); - const response = await requestProvider( - providers[current.provider].token, - { - method: "POST", - redirect: "error", - signal: AbortSignal.timeout(15_000), - headers: { - "content-type": "application/x-www-form-urlencoded", - accept: "application/json", + const unlock = await lockProviderCredentials(file); + try { + const current = await readRecord(file); + if (current.sessionId !== previous.sessionId) + throw new SignInRequired("The model sign-in changed."); + // A concurrent request may have already rotated the token that received a 401. + if (current.accessToken !== previous.accessToken) return current; + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: current.clientId, + refresh_token: current.refreshToken, + }); + if (current.clientSecret) + body.set("client_secret", current.clientSecret); + const response = await requestProvider( + providers[current.provider].token, + { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(15_000), + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body, }, - body, - }, - ); - if (!response.ok) { - await response.body?.cancel(); - throw new SignInRequired("The model provider refused token refresh."); - } - const tokens = z - .object({ - access_token: z.string().min(1), - refresh_token: z.string().min(1).optional(), - expires_in: z.number().finite().positive().optional(), - token_type: z.string().optional(), - }) - .parse(await response.json()); - if (tokens.token_type && tokens.token_type.toLowerCase() !== "bearer") - throw new SignInRequired( - "The model provider returned an unsupported token.", ); - const next = { - ...current, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? current.refreshToken, - expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000, - }; - // Do not use a rotated pair until it is durable; a failed save requires sign-in again. - await persistRotation(file, current, next); - return next; + if (!response.ok) { + await response.body?.cancel(); + throw new SignInRequired("The model provider refused token refresh."); + } + const tokens = z + .object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.number().finite().positive().optional(), + token_type: z.string().optional(), + }) + .parse(await response.json()); + if (tokens.token_type && tokens.token_type.toLowerCase() !== "bearer") + throw new SignInRequired( + "The model provider returned an unsupported token.", + ); + const next = { + ...current, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? current.refreshToken, + expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000, + }; + // Do not use a rotated pair until it is durable; a failed save requires sign-in again. + await persistRotation(file, current, next); + return next; + } finally { + await unlock(); + } })(); refreshes.set(previous.sessionId, work); try { @@ -222,9 +229,18 @@ export function createProviderOAuthProxy( let body: ArrayBuffer; try { body = await request.arrayBuffer(); + } catch { + return refused(400, "The model request body could not be read."); + } + try { if (current.expiresAt <= Date.now() + 120_000) current = await refresh(current); - } catch { + } catch (error) { + if (error instanceof CredentialLockUnavailable) + return refused( + 503, + "The model credential store is busy or unavailable. Try again.", + ); recordDesktopConnectionFailure({ connection: "model", code: "provider_authentication_failed", @@ -268,14 +284,15 @@ export function createProviderOAuthProxy( await response.body?.cancel(); try { current = await refresh(current); - } catch { + } catch (error) { + if (error instanceof CredentialLockUnavailable) throw error; throw new SignInRequired("The model sign-in could not be refreshed."); } response = await send(current); } if (!response.ok) { await response.body?.cancel(); - if (response.status === 401 || response.status === 403) + if (response.status === 401) throw new SignInRequired("The model provider refused this sign-in."); return refused( response.status >= 400 ? response.status : 502, @@ -299,6 +316,11 @@ export function createProviderOAuthProxy( }, }); } catch (error) { + if (error instanceof CredentialLockUnavailable) + return refused( + 503, + "The model credential store is busy or unavailable. Try again.", + ); if (error instanceof SignInRequired) { recordDesktopConnectionFailure({ connection: "model", diff --git a/server/tests/desktop-connection-failure.test.ts b/server/tests/desktop-connection-failure.test.ts index 15ec78f10..ad5cbef1d 100644 --- a/server/tests/desktop-connection-failure.test.ts +++ b/server/tests/desktop-connection-failure.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test } from "bun:test"; import { EventType } from "@ag-ui/client"; import { Hono } from "hono"; -import { firstValueFrom, of } from "rxjs"; +import { firstValueFrom, of, throwError } from "rxjs"; import { clearDesktopConnectionFailure, isModelAuthenticationError, @@ -74,30 +74,78 @@ test("typed provider failures trigger refresh and successful model output clears }); }); +function providerError(statusCode: number, cause?: Error) { + return Object.assign( + new Error("provider detail must not enter native status", { cause }), + { + name: "AI_APICallError", + statusCode, + }, + ); +} + +function explicitAuthenticationError() { + return Object.assign(new Error("provider authentication failed"), { + name: "OpenBotModelAuthenticationError", + }); +} + test("HTTP status and error prose from unrelated services are not model authentication", () => { expect( isModelAuthenticationError( Object.assign(new Error("401 model unauthorized"), { statusCode: 401 }), ), ).toBe(false); - for (const statusCode of [400, 404, 429, 500]) { + for (const statusCode of [400, 403, 404, 429, 500]) { + const error = providerError(statusCode); + expect(isModelAuthenticationError(error)).toBe(false); expect( isModelAuthenticationError( - Object.assign(new Error("provider issue"), { - name: "AI_APICallError", - statusCode, - }), + new Error("wrapped provider issue", { cause: error }), ), ).toBe(false); } - for (const statusCode of [401, 403]) { +}); + +test("provider 401 and explicit authentication markers survive SDK wrapping", () => { + for (const error of [providerError(401), explicitAuthenticationError()]) { + expect(isModelAuthenticationError(error)).toBe(true); expect( isModelAuthenticationError( - Object.assign(new Error("provider issue"), { - name: "AI_APICallError", - statusCode, - }), + new Error("wrapped provider issue", { cause: error }), ), ).toBe(true); } + expect( + isModelAuthenticationError( + providerError(403, explicitAuthenticationError()), + ), + ).toBe(true); }); + +test.each([ + ["provider 403 permission denial", providerError(403), false], + [ + "wrapped provider 403 permission denial", + new Error("wrapped provider issue", { cause: providerError(403) }), + false, + ], + ["provider 401 invalid credentials", providerError(401), true], + ["explicit authentication failure", explicitAuthenticationError(), true], +] as const)( + "model observation preserves %s without guessing authentication", + async (_label, error, requiresAuthentication) => { + const { read } = endpoint(); + await expect( + firstValueFrom(observeModelConnection(throwError(() => error))), + ).rejects.toBe(error); + expect(await (await read()).json()).toEqual( + requiresAuthentication + ? { + connection: "model", + code: "provider_authentication_failed", + } + : null, + ); + }, +); diff --git a/server/tests/fixtures/provider-oauth-lock-cross-process.ts b/server/tests/fixtures/provider-oauth-lock-cross-process.ts new file mode 100644 index 000000000..e41aae019 --- /dev/null +++ b/server/tests/fixtures/provider-oauth-lock-cross-process.ts @@ -0,0 +1,102 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CredentialLockUnavailable, + lockProviderCredentials, +} from "../../src/provider-oauth-lock"; + +// Runs the exact production Bun and Rust lock helpers in both directions. +const native = process.argv[2]; +if (!native) throw new Error("Native lock-owner executable is required."); +const root = await mkdtemp(join(tmpdir(), "openbot-cross-writer-")); +const file = join(root, "model-oauth.json"); +const children: ReturnType[] = []; +function holder(command: string[]) { + const child = Bun.spawn(command, { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + let acquired = false; + const ready = (async () => { + const reader = child.stdout.getReader(); + const timeout = setTimeout(() => child.kill(), 15000); + try { + let output = ""; + while (!output.includes("\n")) { + const { done, value } = await reader.read(); + if (done) + throw new Error( + `Lock owner exited: ${await new Response(child.stderr).text()}`, + ); + output += new TextDecoder().decode(value); + } + if (output.trim() !== "locked") + throw new Error(`Unexpected readiness: ${output}`); + acquired = true; + } finally { + reader.releaseLock(); + clearTimeout(timeout); + } + })(); + return { child, ready, acquired: () => acquired }; +} +try { + const nativeFirst = holder([native, file]); + children.push(nativeFirst); + await nativeFirst.ready; + const started = performance.now(); + try { + const unexpectedRelease = await lockProviderCredentials(file); + await unexpectedRelease(); + throw new Error("Bun entered a live native lock."); + } catch (error) { + if (!(error instanceof CredentialLockUnavailable)) throw error; + } + const blockedMs = performance.now() - started; + if (blockedMs < 4900) + throw new Error( + `Lock failure preceded the acquisition timeout: ${blockedMs}ms`, + ); + if (nativeFirst.child.exitCode !== null) + throw new Error( + "Native owner exited before the blocked attempt completed.", + ); + nativeFirst.child.kill("SIGKILL"); + await nativeFirst.child.exited; + const release = await lockProviderCredentials(file); + await release(); + console.log( + `PASS native owner excludes Bun for the full timeout (${Math.round(blockedMs)}ms); killing native permits Bun acquisition`, + ); + + const bunFirst = holder([ + process.execPath, + fileURLToPath(new URL("./provider-oauth-lock-owner.ts", import.meta.url)), + file, + ]); + children.push(bunFirst); + await bunFirst.ready; + const pendingNative = holder([native, file]); + children.push(pendingNative); + await Bun.sleep(200); + if (pendingNative.acquired()) + throw new Error("Native entered a live Bun lock."); + bunFirst.child.kill("SIGKILL"); + await bunFirst.child.exited; + await pendingNative.ready; + pendingNative.child.stdin.end(); + if ((await pendingNative.child.exited) !== 0) + throw new Error("Native release failed."); + console.log( + "PASS Bun owner excludes native; killing Bun releases its PowerShell/OS handle", + ); +} finally { + for (const { child } of children) { + child.kill(); + await child.exited; + } + await rm(root, { recursive: true, force: true }); +} diff --git a/server/tests/fixtures/provider-oauth-lock-owner.ts b/server/tests/fixtures/provider-oauth-lock-owner.ts new file mode 100644 index 000000000..f69dc8a43 --- /dev/null +++ b/server/tests/fixtures/provider-oauth-lock-owner.ts @@ -0,0 +1,18 @@ +import { mkdir } from "node:fs/promises"; +import { lockProviderCredentials } from "../../src/provider-oauth-lock"; + +const file = process.argv[2]; +if (!file) throw new Error("A credential path is required."); +if (process.argv[3] === "legacy") { + await mkdir(`${file}.lock`, { mode: 0o700 }); + console.log("locked"); + await Bun.stdin.text(); +} else { + const unlock = await lockProviderCredentials(file); + try { + console.log("locked"); + await Bun.stdin.text(); + } finally { + await unlock(); + } +} diff --git a/server/tests/google-oauth-transport.test.ts b/server/tests/google-oauth-transport.test.ts index 3e847db97..9bab0986a 100644 --- a/server/tests/google-oauth-transport.test.ts +++ b/server/tests/google-oauth-transport.test.ts @@ -207,23 +207,90 @@ test("unsupported content and unsafe model names fail before sending provider cr ).toThrow(); }); +const terminalFrame = 'data: {"candidates":[{"finishReason":"STOP"}]}\n\n'; +const partialFrame = + 'data: {"candidates":[{"content":{"parts":[{"text":"partial"}]}}]}\n\n'; +const providerErrorFrame = + 'data: {"error":{"code":429,"message":"private provider detail"}}\n\n'; +const sanitizedError = `data: ${JSON.stringify({ + error: { + message: "The Google model stream failed or ended early. Try again.", + type: "provider_error", + code: "incomplete_model_stream", + }, +})}`; + +async function expectFailedStream(chunks: string[]) { + const encoder = new TextEncoder(); + const upstream = new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + const response = await googleResponse(upstream, "gemini-3.6-flash", true); + const text = await response.text(); + const events = text.split("\n\n").filter(Boolean); + expect(events.filter((event) => event.startsWith('data: {"error":'))).toEqual( + [sanitizedError], + ); + expect(events.at(-1)).toBe(sanitizedError); + expect(text).not.toContain("private provider detail"); + expect(text).not.toContain("content after failure"); + expect(text).not.toMatch(/"finish_reason"\s*:\s*"/); + expect(text).not.toContain("[DONE]"); + return text; +} + +// A terminal reason is already present, so the missing-terminal guard cannot +// mask a missing parser, framing, or trailing-data guard in these cases. test.each([ - 'data: {"error":{"code":429,"message":"private provider detail"}}\n\n', - '{"error":{"code":429,"message":"private provider detail"}}', - 'data: {"candidates":[{"content":{"parts":[{"text":"partial"}]}}]}\n\n', - 'data: {"candidates":', + ["provider error", providerErrorFrame], + ["malformed JSON", 'data: {"private provider detail":\n\n'], + ["invalid schema", 'data: {"candidates":"private provider detail"}\n\n'], + ["invalid SSE line", "private provider detail\n\n"], + ["trailing fragment", "private provider detail"], + [ + "undelimited data", + 'data: {"candidates":[{"content":{"parts":[{"text":"private provider detail"}]}}]}\n', + ], ])( - "failed or truncated native streams cannot become successful completions: %s", - async (wire) => { - const response = await googleResponse( - new Response(wire), - "gemini-3.6-flash", - true, - ); - const text = await response.text(); - expect(text).toContain('"error"'); - expect(text).not.toContain("private provider detail"); - expect(text).not.toContain('"finish_reason":"stop"'); - expect(text).not.toContain("[DONE]"); + "native stream rejects %s after a terminal frame", + async (_name, invalid) => { + await expectFailedStream([terminalFrame + invalid]); + }, +); + +test("native stream rejects a partial response without a terminal reason", async () => { + const text = await expectFailedStream([partialFrame]); + expect(text).toContain('"content":"partial"'); +}); + +const laterDataLine = + 'data: {"candidates":[{"content":{"parts":[{"text":"content after failure"}]},"finishReason":"STOP"}]}\n'; +test.each([ + [ + "the same chunk", + [`${partialFrame}${providerErrorFrame}${laterDataLine}\n`], + ], + [ + "separate chunks", + [partialFrame + providerErrorFrame, `${laterDataLine}\n`], + ], + [ + "a later chunk completing an interrupted event", + [ + `${partialFrame}${laterDataLine}private provider detail\n`, + `\n${terminalFrame}`, + ], + ], +] satisfies [string, string[]][])( + "native stream cannot resume after failure in %s", + async (_name, chunks) => { + const text = await expectFailedStream(chunks); + expect(text).toContain('"content":"partial"'); }, ); diff --git a/server/tests/provider-oauth.test.ts b/server/tests/provider-oauth.test.ts index 1bcf3355d..023c96b32 100644 --- a/server/tests/provider-oauth.test.ts +++ b/server/tests/provider-oauth.test.ts @@ -1,9 +1,24 @@ -import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as fsPromises from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Hono } from "hono"; -import { clearDesktopConnectionFailure } from "../src/desktop-connection-failure"; +import { fileURLToPath } from "node:url"; +import { lockProviderCredentials } from "../src/provider-oauth-lock"; +import { + clearDesktopConnectionFailure, + mountDesktopConnectionFailure, +} from "../src/desktop-connection-failure"; import { createProviderOAuthProxy, type ModelOAuthRecord, @@ -58,6 +73,7 @@ async function fixture( }, }), ); + mountDesktopConnectionFailure(app, "test-host-token"); const ask = (token: string | null = current.proxyToken) => app.request("/api/model-provider/v1/chat/completions", { method: "POST", @@ -72,7 +88,7 @@ async function fixture( stream: true, }), }); - return { app, file, ask, destinations }; + return { app, file, ask, destinations, providerUrl: server.url }; } test("the model proxy requires its bearer even with a browser cookie", async () => { @@ -312,3 +328,297 @@ test("missing or malformed credential files fail closed without exposing their c expect(response.status).toBe(503); expect(f.destinations).toEqual([]); }); + +async function authenticationFailure(app: Hono) { + const response = await app.request("/api/desktop/connection-failure", { + headers: { "x-openbot-desktop-host-token": "test-host-token" }, + }); + return response.json(); +} + +test("an abandoned legacy lock directory does not consume and lose a rotated token", async () => { + let exchanges = 0; + const f = await fixture(record({ provider: "xai", expiresAt: 1 }), () => { + exchanges++; + return Response.json({ + access_token: "next", + refresh_token: "next-refresh", + expires_in: 3600, + }); + }); + const child = await lockOwner(f.file, "legacy"); + // The former Rust create_dir used the process umask (commonly 0755). + if (process.platform !== "win32") await chmod(`${f.file}.lock`, 0o755); + child.kill("SIGKILL"); + await child.exited; + const response = await f.ask(); + expect(response.status).toBe(200); + expect(exchanges).toBe(2); // One refresh and one model request. + expect(JSON.parse(await readFile(f.file, "utf8")).refreshToken).toBe( + "next-refresh", + ); +}, 10000); + +test("independent proxy instances serialize real rotating-token exchanges", async () => { + let exchanges = 0; + const initial = record({ provider: "xai", expiresAt: 1 }); + const f = await fixture(initial, async (request) => { + if (new URL(request.url).pathname === "/oauth2/token") { + const exchange = ++exchanges; + await Bun.sleep(50); + return exchange === 1 + ? Response.json({ + access_token: "next", + refresh_token: "next-refresh", + expires_in: 3600, + }) + : Response.json({ error: "already spent" }, { status: 400 }); + } + return Response.json({ choices: [] }); + }); + // Each separate Hono/proxy instance has its own in-memory refresh deduplication map. + const other = createProviderOAuthProxy(f.file, { + fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + return fetch(new URL(url.pathname, f.providerUrl), init); + }, + }); + const responses = await Promise.all([ + f.ask(), + other( + new Request("http://localhost/model", { + method: "POST", + headers: { authorization: `Bearer ${initial.proxyToken}` }, + body: JSON.stringify({ model: "model", messages: [] }), + }), + ), + ]); + expect(responses.map((response) => response.status)).toEqual([200, 200]); + expect(exchanges).toBe(1); +}); + +test("an unreadable request body does not invalidate provider sign-in", async () => { + const f = await fixture(record(), () => new Response("must not be called")); + const response = await f.app.request( + "/api/model-provider/v1/chat/completions", + { + method: "POST", + headers: { authorization: "Bearer local-proxy-token" }, + body: new ReadableStream({ + start(controller) { + controller.error(new Error("client disconnected")); + }, + }), + }, + ); + expect(response.status).toBe(400); + expect(f.destinations).toEqual([]); + expect(await authenticationFailure(f.app)).toBeNull(); +}); + +test("a quota-project permission denial stays 403 without invalidating sign-in", async () => { + const f = await fixture(record(), () => + Response.json({ error: "private quota project detail" }, { status: 403 }), + ); + const response = await f.ask(); + expect(response.status).toBe(403); + expect(await response.text()).not.toContain("private quota project detail"); + expect(await authenticationFailure(f.app)).toBeNull(); + expect(f.destinations).toHaveLength(1); +}); + +test("credential file paths must be absolute", () => { + expect(() => createProviderOAuthProxy("relative.json")).toThrow("absolute"); +}); + +test.each(["world-readable", "symlink", "oversized", "missing-quota"] as const)( + "%s credential files are refused before provider I/O", + async (kind) => { + if (kind === "world-readable" && process.platform === "win32") return; + const f = await fixture(record(), () => new Response("must not be called")); + if (kind === "world-readable") await chmod(f.file, 0o644); + if (kind === "symlink") { + const target = `${f.file}.target`; + await writeFile(target, JSON.stringify(record()), { mode: 0o600 }); + await rm(f.file); + await symlink(target, f.file); + } + if (kind === "oversized") + await writeFile( + f.file, + JSON.stringify(record({ scope: "x".repeat(64 * 1024) })), + ); + if (kind === "missing-quota") + await writeFile( + f.file, + JSON.stringify(record({ quotaProject: undefined })), + ); + const response = await f.ask(); + expect(response.status).toBe(503); + expect(f.destinations).toEqual([]); + expect(await response.text()).not.toContain("provider-refresh-token"); + }, +); + +async function lockOwner(file: string, mode?: "legacy") { + const child = Bun.spawn( + [ + process.execPath, + fileURLToPath( + new URL("./fixtures/provider-oauth-lock-owner.ts", import.meta.url), + ), + file, + ...(mode ? [mode] : []), + ], + { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }, + ); + cleanup.push(async () => { + child.kill(); + await child.exited; + }); + const reader = child.stdout.getReader(); + const timeout = setTimeout(() => child.kill(), 15000); + try { + let message = ""; + while (!message.includes("\n")) { + const { value, done } = await reader.read(); + if (done) + throw new Error( + `Lock owner exited: ${await new Response(child.stderr).text()}`, + ); + message += new TextDecoder().decode(value); + } + expect(message.trim()).toBe("locked"); + } finally { + clearTimeout(timeout); + reader.releaseLock(); + } + return child; +} + +test("killing a real lock owner permits a subsequent durable rotating-token exchange", async () => { + let refreshes = 0; + const f = await fixture( + record({ provider: "xai", expiresAt: 1 }), + (request) => { + if (new URL(request.url).pathname === "/oauth2/token") { + refreshes++; + return Response.json({ + access_token: "recovered", + refresh_token: "recovered-refresh", + expires_in: 3600, + }); + } + return Response.json({ choices: [] }); + }, + ); + const child = await lockOwner(f.file); + child.kill("SIGKILL"); + await child.exited; + expect((await f.ask()).status).toBe(200); + expect(refreshes).toBe(1); + expect(JSON.parse(await readFile(f.file, "utf8")).refreshToken).toBe( + "recovered-refresh", + ); +}, 20000); + +test("a live lock owner prevents any provider exchange until ownership is obtained", async () => { + let refreshes = 0; + const f = await fixture( + record({ provider: "xai", expiresAt: 1 }), + (request) => { + if (new URL(request.url).pathname === "/oauth2/token") { + refreshes++; + return Response.json({ + access_token: "next", + refresh_token: "next-refresh", + expires_in: 3600, + }); + } + return Response.json({ choices: [] }); + }, + ); + const child = await lockOwner(f.file); + const pending = f.ask(); + await Bun.sleep(100); + expect(refreshes).toBe(0); + child.stdin.end(); + expect(await child.exited).toBe(0); + expect((await pending).status).toBe(200); + expect(refreshes).toBe(1); +}, 20000); + +test("a lock timeout returns a retryable failure without consuming a refresh token or recording an auth failure", async () => { + const f = await fixture( + record({ expiresAt: 1 }), + () => new Response("must not be called"), + ); + const unlock = await lockProviderCredentials(f.file); + try { + expect((await f.ask()).status).toBe(503); + expect(f.destinations).toEqual([]); + expect(await authenticationFailure(f.app)).toBeNull(); + expect(JSON.parse(await readFile(f.file, "utf8")).refreshToken).toBe( + "provider-refresh-token", + ); + } finally { + await unlock(); + } +}, 20000); + +test("a redirected lock inode is rejected before a provider exchange", async () => { + const f = await fixture( + record({ expiresAt: 1 }), + () => new Response("must not be called"), + ); + await mkdir(`${f.file}.lock`, { mode: 0o700 }); + const target = `${f.file}.untouched`; + await writeFile(target, "untouched", { mode: 0o600 }); + await symlink(target, join(`${f.file}.lock`, "owner.lock")); + expect((await f.ask()).status).toBe(503); + expect(f.destinations).toEqual([]); + expect(await readFile(target, "utf8")).toBe("untouched"); +}); + +test("Windows validates the opened credential identity before reading a replaced path", async () => { + const f = await fixture(record({ provider: "xai" }), () => + Response.json({ choices: [] }), + ); + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + const before = await fsPromises.lstat(f.file, { bigint: true }); + const replacement = `${f.file}.replacement`; + await writeFile( + replacement, + JSON.stringify( + record({ provider: "xai", accessToken: "must-not-be-forwarded" }), + ), + { mode: 0o600 }, + ); + // Return the real metadata sampled before another writer atomically replaced + // the path: a deterministic race between Windows lstat and open. + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + try { + expect((await f.ask()).status).toBe(200); // Valid file identities still work. + f.destinations.length = 0; + await fsPromises.rename(replacement, f.file); + const inspect = spyOn(fsPromises, "lstat").mockResolvedValue(before); + try { + expect((await f.ask()).status).toBe(503); + expect(f.destinations).toEqual([]); + } finally { + inspect.mockRestore(); + } + } finally { + Object.defineProperty(process, "platform", originalPlatform); + } +}); From 5b70772bd3e48ac0f87de18d5eff3172b9094225 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 21 Sep 2026 17:37:19 -0700 Subject: [PATCH 11/11] fix(desktop): recover leftover databases during fresh setup --- desktop/src-tauri/src/main.rs | 592 +++++++++++++++++++++++++++++-- desktop/src-tauri/src/problem.rs | 5 + desktop/src-tauri/src/stack.rs | 367 ++++++++++++++++++- desktop/src/App.test.tsx | 101 ++++++ desktop/src/App.tsx | 28 ++ desktop/src/DatabaseReset.tsx | 52 +++ desktop/src/Problem.tsx | 1 + 7 files changed, 1096 insertions(+), 50 deletions(-) create mode 100644 desktop/src/DatabaseReset.tsx diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 2bf76739c..ea7667611 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -57,6 +57,8 @@ struct Shell { last_failure: Mutex>, /// Reading the notification must not make a partially running deployment adoptable again. recovery_required: Mutex>, + /// An explicit reset remains bound to the engine that found the leftover database. + leftover_database: Mutex>, selected_root: Mutex>, root: Mutex>, /// Containers may outlive a failed Start before any host root is published. @@ -93,6 +95,12 @@ struct ContainerDeployment { address: engine::Address, } +struct LeftoverDatabase { + root: PathBuf, + volume: String, + address: engine::Address, +} + struct RecoveryRequired { root: PathBuf, generation: u64, @@ -1022,6 +1030,148 @@ fn require_existing_encryption_key( Ok(()) } +fn require_existing_encryption_key_with_recovery( + root: &Path, + secrets: &stack::Secrets, + existing_postgres_volume: impl FnOnce() -> Result, + resettable_volume: impl FnOnce() -> Result, Problem>, +) -> Result<(), Problem> { + let mut volume_exists = false; + require_existing_encryption_key(root, secrets, || { + volume_exists = existing_postgres_volume()?; + Ok(volume_exists) + }) + .map_err(|mut problem| { + // A failed probe is not proof of an existing volume. Configured roots never reach here. + if volume_exists { + let recovery = fresh_root_without_encryption_key(root, secrets).and_then(|fresh| { + if fresh { + resettable_volume() + } else { + Ok(None) + } + }); + match recovery { + Ok(volume) => { + if volume.is_some() { + problem.said = "OpenBot found a database from a previous installation, but its encryption key is unavailable. Restore the original key to keep its saved data, or reset the leftover database to start fresh.".into(); + } + problem.database_reset = volume; + } + Err(verification) => { + problem.detail = Some(match verification.detail { + Some(detail) => format!("{}\n{detail}", verification.said), + None => verification.said, + }); + } + } + } + problem + }) +} + +/// Destructive recovery needs positive evidence that root metadata is readable and unconfigured. +/// The ordinary startup guard keeps its existing behavior when metadata is unknown. +fn fresh_root_without_encryption_key( + root: &Path, + secrets: &stack::Secrets, +) -> Result { + if secrets + .get("KEY_ENCRYPTION_KEY") + .is_some_and(|key| openbot_env::usable_encryption_key(key)) + { + return Ok(false); + } + let unknown = || { + Problem::plain("OpenBot could not verify that this is an unconfigured installation. Its leftover database cannot be reset here.") + }; + let settings = openbot_env::read_already_set(&root.join(".env"), &["DATABASE_URL"]) + .map_err(|_| unknown())?; + if settings.contains_key("DATABASE_URL") { + return Ok(false); + } + use openbot_desktop_lib::saved_intent::{SavedIntent, FILE}; + match std::fs::read(root.join(FILE)) { + Ok(bytes) => { + let record: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| unknown())?; + if record["version"].as_u64() != Some(1) { + return Err(unknown()); + } + let intent: SavedIntent = serde_json::from_value(record).map_err(|_| unknown())?; + Ok(intent.model.is_none()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(_) => Err(unknown()), + } +} + +fn reset_leftover_database_with( + shell: &Shell, + root: &Path, + volume: &str, + confirmed: bool, + read_secrets: impl FnOnce() -> Result, + reset: impl FnOnce(&engine::Address, &stack::Secrets) -> Result<(), Problem>, +) -> Result<(), Problem> { + if !confirmed { + return Err(Problem::plain("Confirm that you want to permanently delete the leftover database before resetting it.")); + } + let attempt = StartAttempt::begin(shell)?; + let _startup = attempt.lock_current()?; + if shell.containers.lock().unwrap().is_some() + || shell.root.lock().unwrap().is_some() + || !shell.children.lock().unwrap().is_empty() + { + return Err(Problem::plain("OpenBot still owns running services. Choose Stop OpenBot before resetting a leftover database.")); + } + if shell + .selected_root + .lock() + .unwrap() + .as_ref() + .is_some_and(|selected| selected != root) + { + return Err(Problem::plain("The selected installation changed. Try Start again before resetting its leftover database.")); + } + let address = shell.leftover_database.lock().unwrap().as_ref() + .filter(|offer| offer.root == root && offer.volume == volume) + .map(|offer| offer.address.clone()) + .ok_or_else(|| Problem::plain("The leftover database reset offer is no longer current. Try Start again before confirming a reset."))?; + let secrets = read_secrets()?; + if !fresh_root_without_encryption_key(root, &secrets)? { + return Err(Problem::plain("This installation is already configured or has its original encryption key. Its database cannot be reset here.")); + } + reset(&address, &secrets)?; + *shell.leftover_database.lock().unwrap() = None; + Ok(()) +} + +#[tauri::command] +async fn reset_leftover_database( + root: String, + volume: String, + confirmed: bool, + app: tauri::AppHandle, +) -> Result<(), Problem> { + let root = stack::root_from(&root); + let shell = app.state::(); + reset_leftover_database_with( + &shell, + &root, + &volume, + confirmed, + || { + openbot_desktop_lib::vault::already_given_no_ui( + &root, + &root.join(".env"), + &openbot_env::MINTED[..], + ) + }, + |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume), + ) +} + /// Write the `.env`, raise the containers, migrate, then start the three host processes. #[tauri::command] #[allow( @@ -1100,6 +1250,7 @@ async fn start_stack_inner( } // A rejected concurrent Start must not replace the accepted attempt's selection. remember_selected_root(&shell, &root); + *shell.leftover_database.lock().unwrap() = None; } /* * Resolved from the catalogue rather than taken from the window. @@ -1202,8 +1353,22 @@ async fn start_stack_inner( &root.join(".env"), &openbot_env::MINTED[..], )?; - require_existing_encryption_key(&root, &existing_secrets, || { - stack::postgres_volume_exists(&found, &root, &existing_secrets) + require_existing_encryption_key_with_recovery( + &root, + &existing_secrets, + || stack::postgres_volume_exists(&found, &root, &existing_secrets), + || stack::leftover_database_volume(&found, &root, &existing_secrets), + ) + .inspect_err(|problem| { + *shell.leftover_database.lock().unwrap() = + problem + .database_reset + .as_ref() + .map(|volume| LeftoverDatabase { + root: root.clone(), + volume: volume.clone(), + address: found.clone(), + }); })?; // Only this deployment's recorded hosts are reclaimed; its existing containers are reusable. @@ -3423,6 +3588,7 @@ fn main() { prepare_engine, prepare_installation, start_stack, + reset_leftover_database, stop_stack, show_openbot, show_setup, @@ -4666,14 +4832,294 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn leftover_database_recovery_is_offered_only_for_a_proven_fresh_root() { + let root = temp_root("leftover-database-offer"); + std::fs::create_dir_all(&root).unwrap(); + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || Ok(Some("openbot_postgres-data".into())), + ) + .unwrap_err(); + assert_eq!( + serde_json::to_value(error).unwrap()["database_reset"], + "openbot_postgres-data" + ); + for (file, content) in [ + (".env", "DATABASE_URL=postgres://fixture\n"), + ( + openbot_desktop_lib::saved_intent::FILE, + r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, + ), + ( + openbot_desktop_lib::saved_intent::FILE, + "invalid-settings-secret", + ), + ( + openbot_desktop_lib::saved_intent::FILE, + r#"{"version":99,"categories":[],"model":null}"#, + ), + ] { + std::fs::write(root.join(file), content).unwrap(); + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || panic!("unknown or configured roots must not offer deletion"), + ) + .unwrap_err(); + assert!(serde_json::to_value(error) + .unwrap() + .get("database_reset") + .is_none()); + std::fs::remove_file(root.join(file)).unwrap(); + } + let secrets = stack::Secrets::from([( + "KEY_ENCRYPTION_KEY".into(), + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), + )]); + assert!(require_existing_encryption_key_with_recovery( + &root, + &secrets, + || panic!("original key needs no probe"), + || panic!("original key needs no reset") + ) + .is_ok()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn leftover_database_reset_rechecks_confirmation_configuration_key_and_ownership() { + let root = temp_root("leftover-database-command"); + std::fs::create_dir_all(&root).unwrap(); + let shell = Shell::default(); + let offer = || { + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volume: "openbot_postgres-data".into(), + address: engine::Address::new( + engine::Engine::Podman, + Some("original-machine".into()), + ), + }); + }; + offer(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + false, + || panic!("unconfirmed must not access credentials"), + |_, _| panic!("unconfirmed must not delete") + ) + .is_err()); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| Ok(()) + ) + .is_ok()); + offer(); + for (file, content) in [ + (".env", "DATABASE_URL=postgres://fixture\n"), + ( + openbot_desktop_lib::saved_intent::FILE, + r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, + ), + ( + openbot_desktop_lib::saved_intent::FILE, + "invalid-settings-secret", + ), + ] { + std::fs::write(root.join(file), content).unwrap(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| panic!("configured or unknown root must not delete") + ) + .is_err()); + std::fs::remove_file(root.join(file)).unwrap(); + } + std::fs::create_dir(root.join(".env")).unwrap(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| panic!("unreadable settings must not delete") + ) + .is_err()); + std::fs::remove_dir(root.join(".env")).unwrap(); + let secrets = stack::Secrets::from([( + "KEY_ENCRYPTION_KEY".into(), + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), + )]); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(secrets), + |_, _| panic!("restored key must prevent deletion") + ) + .is_err()); + *shell.root.lock().unwrap() = Some(root.clone()); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || panic!("owned hosts must refuse before credential access"), + |_, _| panic!("owned hosts must prevent deletion") + ) + .is_err()); + *shell.root.lock().unwrap() = None; + *shell.containers.lock().unwrap() = Some(ContainerDeployment { + root: root.clone(), + address: engine::Address::new(engine::Engine::Podman, Some("fixture".into())), + }); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || panic!("owned containers must refuse"), + |_, _| panic!("owned containers must prevent deletion") + ) + .is_err()); + *shell.containers.lock().unwrap() = None; + let attempt = StartAttempt::begin(&shell).unwrap(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || panic!("concurrent startup must refuse"), + |_, _| panic!("concurrent startup must prevent deletion") + ) + .is_err()); + drop(attempt); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| { + assert!( + shell.startup.try_lock().is_err(), + "deletion must retain the startup lock" + ); + assert!( + StartAttempt::begin(&shell).is_err(), + "startup must remain excluded during deletion" + ); + Ok(()) + } + ) + .is_ok()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers() { + if crate::test_support::isolated_process( + "tests::leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers", + ) { + return; + } + let root = temp_root("leftover-database-affinity"); + std::fs::create_dir_all(&root).unwrap(); + let shell = Shell::default(); + let original = + engine::Address::new(engine::Engine::Podman, Some("original-machine".into())); + let volume = "openbot_postgres-data"; + assert!(reset_leftover_database_with( + &shell, + &root, + volume, + true, + || panic!("a missing offer must refuse before reading credentials"), + |_, _| panic!("a missing offer must not remove anything") + ) + .is_err()); + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volume: volume.into(), + address: original.clone(), + }); + for (selected_root, selected_volume) in [ + (&root, "other-volume"), + (&root.join("another-root"), volume), + ] { + assert!(reset_leftover_database_with( + &shell, + selected_root, + selected_volume, + true, + || panic!("a mismatched offer must refuse before reading credentials"), + |_, _| panic!("a mismatched offer must not remove anything") + ) + .is_err()); + } + // Ambient choices may change while the confirmation is open. Both a new Docker endpoint + // and a new Podman default remain irrelevant to the already pinned offer. + std::env::set_var("DOCKER_HOST", "unix:///another-engine.sock"); + std::env::set_var("CONTAINER_CONNECTION", "replacement-machine"); + let unavailable = Problem::plain("the originally offered engine is unavailable"); + assert_eq!( + reset_leftover_database_with( + &shell, + &root, + volume, + true, + || Ok(stack::Secrets::new()), + |address, _| { + assert_eq!(address, &original); + Err(unavailable.clone()) + } + ), + Err(unavailable) + ); + reset_leftover_database_with( + &shell, + &root, + volume, + true, + || Ok(stack::Secrets::new()), + |address, _| { + assert_eq!(address, &original); + let command = address.command(); + let arguments: Vec<_> = command.get_args().collect(); + assert_eq!(arguments, ["--connection", "original-machine"]); + Ok(()) + }, + ) + .unwrap(); + assert!( + shell.leftover_database.lock().unwrap().is_none(), + "successful reset consumes the offer" + ); + std::fs::remove_dir_all(root).unwrap(); + } + /// Real engine boundary: all root metadata can disappear while a named volume survives. - /// Creates only one uniquely named, empty test volume; never starts a container or database. + /// Owns one isolated project and empty database; never touches an existing deployment. #[test] - #[ignore = "creates and removes one isolated Docker volume"] + #[ignore = "creates an isolated database volume and starts Postgres on the explicitly selected engine"] fn surviving_postgres_volume_blocks_fresh_root_without_key() { let root = temp_root("encryption-key-volume-reinstall"); std::fs::create_dir_all(&root).unwrap(); - let volume = format!( + let project = format!( "openbot-key-reinstall-fixture-{}-{}", std::process::id(), std::time::SystemTime::now() @@ -4681,16 +5127,22 @@ mod tests { .unwrap() .as_nanos() ); + let volume = format!("{project}_postgres-data"); std::fs::write( root.join("docker-compose.yml"), format!( - "services:\n postgres:\n image: pgvector/pgvector:pg17\n volumes:\n - database:/var/lib/postgresql/data\nvolumes:\n database:\n name: {volume}\n" + "name: {project}\nservices:\n postgres:\n image: docker.io/pgvector/pgvector:pg17\n environment:\n POSTGRES_PASSWORD: isolated-fixture-only\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [CMD, pg_isready, -U, postgres]\n interval: 1s\n timeout: 5s\n retries: 45\nvolumes:\n postgres-data:\n" ), ) .unwrap(); - let address = engine::Address::new(engine::Engine::Docker, None) + let selected = match std::env::var("OPENBOT_TEST_ENGINE").as_deref() { + Ok("podman") => engine::Engine::Podman, + Ok("docker") | Err(_) => engine::Engine::Docker, + Ok(other) => panic!("unknown explicit test engine: {other}"), + }; + let address = engine::Address::new(selected, std::env::var("OPENBOT_TEST_CONNECTION").ok()) .pin() - .expect("explicit selected Docker runtime"); + .expect("explicit selected test runtime"); let created = address .command() .args([ @@ -4698,19 +5150,104 @@ mod tests { "create", "--label", "ai.copilotkit.openbot.fixture=key-reinstall", + "--label", + &format!("com.docker.compose.project={project}"), + "--label", + "com.docker.compose.volume=postgres-data", &volume, ]) .output() .expect("create isolated volume"); assert!(created.status.success(), "fixture volume creation failed"); - let secrets = std::collections::BTreeMap::new(); - let guarded = require_existing_encryption_key(&root, &secrets, || { - stack::postgres_volume_exists(&address, &root, &secrets) + // Always clean up this fixture, including when an assertion inside the workflow fails. + let workflow = std::panic::catch_unwind(|| { + let secrets = stack::Secrets::new(); + let guarded = require_existing_encryption_key_with_recovery( + &root, + &secrets, + || stack::postgres_volume_exists(&address, &root, &secrets), + || stack::leftover_database_volume(&address, &root, &secrets), + ) + .unwrap_err(); + assert_eq!(guarded.database_reset.as_deref(), Some(volume.as_str())); + let shell = Shell::default(); + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volume: volume.clone(), + address: address.clone(), + }); + assert!(reset_leftover_database_with( + &shell, + &root, + &volume, + false, + || Ok(secrets.clone()), + |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume) + ) + .is_err()); + assert!(stack::postgres_volume_exists(&address, &root, &secrets).unwrap()); + assert!(!root.join(".secrets/KEY_ENCRYPTION_KEY.secret").exists()); + assert!(!root.join(".env").exists()); + reset_leftover_database_with( + &shell, + &root, + &volume, + true, + || Ok(secrets.clone()), + |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume), + ) + .unwrap(); + require_existing_encryption_key(&root, &secrets, || { + stack::postgres_volume_exists(&address, &root, &secrets) + }) + .unwrap(); + let started = address + .command() + .current_dir(&root) + .args([ + "compose", + "up", + "--detach", + "--wait", + "--wait-timeout", + "60", + "postgres", + ]) + .output() + .unwrap(); + assert!( + started.status.success(), + "isolated Postgres must start after recovery: {}", + String::from_utf8_lossy(&started.stderr) + ); + assert!( + stack::reset_leftover_database(&address, &root, &secrets, &volume).is_err(), + "the real engine must refuse an attached database volume" + ); + let ready = address + .command() + .current_dir(&root) + .args([ + "compose", + "exec", + "-T", + "postgres", + "pg_isready", + "-U", + "postgres", + ]) + .output() + .unwrap(); + assert!( + ready.status.success(), + "Postgres must remain ready after the refused attached-volume reset" + ); }); - let survived = address + let stopped = address .command() - .args(["volume", "inspect", &volume]) + .current_dir(&root) + .args(["compose", "down"]) .output() .unwrap(); let removed = address @@ -4718,33 +5255,18 @@ mod tests { .args(["volume", "rm", &volume]) .output() .unwrap(); - let fresh = require_existing_encryption_key(&root, &secrets, || { - stack::postgres_volume_exists(&address, &root, &secrets) - }); - let key_was_not_written = !root.join(".secrets/KEY_ENCRYPTION_KEY.secret").exists(); - let settings_were_not_written = !root.join(".env").exists(); std::fs::remove_dir_all(&root).unwrap(); assert!( - removed.status.success(), - "remove only the fixture-owned volume" + stopped.status.success(), + "stop only the isolated fixture project" ); assert!( - survived.status.success(), - "the guard must preserve the existing volume" - ); - assert!(key_was_not_written && settings_were_not_written); - assert!( - guarded.is_err(), - "a fresh root with a surviving Postgres volume must not mint a replacement key" - ); - assert!(guarded - .unwrap_err() - .said - .contains("Restore its original private key")); - assert!( - fresh.is_ok(), - "the same root is fresh once its test-owned volume is absent" + removed.status.success(), + "remove only the fixture-owned volume" ); + if let Err(panic) = workflow { + std::panic::resume_unwind(panic); + } } #[test] diff --git a/desktop/src-tauri/src/problem.rs b/desktop/src-tauri/src/problem.rs index 0329c3d42..285418fd8 100644 --- a/desktop/src-tauri/src/problem.rs +++ b/desktop/src-tauri/src/problem.rs @@ -33,6 +33,9 @@ pub struct Problem { /// The credential operation that failed, never inferred from provider log text. #[serde(skip_serializing_if = "Option::is_none")] pub connection: Option, + /// The full, verified Compose volume name offered for an explicit fresh-install reset. + #[serde(skip_serializing_if = "Option::is_none")] + pub database_reset: Option, } impl Problem { @@ -42,6 +45,7 @@ impl Problem { said: said.into(), detail: None, connection: None, + database_reset: None, } } @@ -52,6 +56,7 @@ impl Problem { said: said.into(), detail: (!detail.trim().is_empty()).then_some(detail), connection: None, + database_reset: None, } } diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index c49d4653e..22e01c845 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -187,6 +187,16 @@ pub fn postgres_volume_exists( root: &Path, secrets: &Secrets, ) -> Result { + let configuration = postgres_configuration(engine, root, secrets)?; + let volume = postgres_volume_name(&configuration)?; + named_volume_exists(engine, root, &volume) +} + +fn postgres_configuration( + engine: &Address, + root: &Path, + secrets: &Secrets, +) -> Result, Problem> { let configuration = compose_command(engine, root, secrets) .args(["config", "--format", "json"]) .output() @@ -199,7 +209,10 @@ pub fn postgres_volume_exists( configuration.status ))); } - let volume = postgres_volume_name(&configuration.stdout)?; + Ok(configuration.stdout) +} + +fn named_volume_exists(engine: &Address, root: &Path, volume: &str) -> Result { let inventory = engine .command() .current_dir(root) @@ -224,9 +237,134 @@ fn postgres_volume_problem(detail: impl Into) -> Problem { ) } +/// A surviving database is recoverable only when Compose and the engine agree it is owned. +pub fn leftover_database_volume( + engine: &Address, + root: &Path, + secrets: &Secrets, +) -> Result, Problem> { + let configuration = postgres_configuration(engine, root, secrets)?; + let config: serde_json::Value = serde_json::from_slice(&configuration) + .map_err(|_| postgres_volume_problem("Compose config did not return valid JSON."))?; + let Some((source, definition)) = postgres_data_volume(&config) else { + return Ok(None); + }; + let Some(project) = config["name"].as_str().filter(|name| !name.is_empty()) else { + return Ok(None); + }; + let Some(name) = definition["name"].as_str() else { + return Ok(None); + }; + // Compose's explicit names and external volumes can refer to somebody else's database. + // Permit only ordinary project-scoped, local storage, even if a custom volume has labels. + if name != format!("{project}_{source}") + || !name.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_alphanumeric() || (index > 0 && b"_.-".contains(&byte)) + }) + || !matches!( + definition.get("external"), + None | Some(serde_json::Value::Bool(false)) + ) + || definition["driver"] + .as_str() + .is_some_and(|driver| driver != "local") + || !empty_volume_options(&definition["driver_opts"]) + || config["services"].as_object().is_some_and(|services| { + services.iter().any(|(service, configuration)| { + service != "postgres" + && configuration["volumes"].as_array().is_some_and(|mounts| { + mounts.iter().any(|mount| { + mount["source"].as_str().is_some_and(|other_source| { + other_source == source + || config["volumes"][other_source]["name"].as_str() + == Some(name) + }) + }) + }) + }) + }) + { + return Ok(None); + } + if !named_volume_exists(engine, root, name)? { + return Ok(None); + } + let inspect = engine + .command() + .current_dir(root) + .args(["volume", "inspect", name]) + .output() + .map_err(|error| { + postgres_volume_problem(format!("Could not inspect the database volume: {error}")) + })?; + if !inspect.status.success() { + return Err(postgres_volume_problem(format!("Database volume inspection exited with {}. Output omitted because it can contain credentials.", inspect.status))); + } + let inspected: serde_json::Value = serde_json::from_slice(&inspect.stdout).map_err(|_| { + postgres_volume_problem("Database volume inspection did not return valid JSON.") + })?; + let Some(volumes) = inspected.as_array().filter(|volumes| volumes.len() == 1) else { + return Ok(None); + }; + let volume = &volumes[0]; + // Podman accepts unique name prefixes. Exact inventory and inspect matches keep the command + // tied to the full name the person confirmed, never a similarly named backup. + Ok((volume["Name"].as_str() == Some(name) + && volume["Driver"].as_str() == Some("local") + && empty_volume_options(&volume["Options"]) + && volume["Labels"]["com.docker.compose.project"].as_str() == Some(project) + && volume["Labels"]["com.docker.compose.volume"].as_str() == Some(source)) + .then(|| name.to_owned())) +} + +pub fn reset_leftover_database( + engine: &Address, + root: &Path, + secrets: &Secrets, + confirmed_volume: &str, +) -> Result<(), Problem> { + if leftover_database_volume(engine, root, secrets)?.as_deref() != Some(confirmed_volume) { + return Err(Problem::plain("The leftover database no longer matches the volume you confirmed, or OpenBot could not verify that it owns the volume. Nothing was removed. Try Start again.")); + } + // Never force this operation: the engine must refuse any container attachment, including a + // stopped container or one created after inspection. Do not stop containers to make it pass. + let removed = engine + .command() + .current_dir(root) + .args(["volume", "rm", confirmed_volume]) + .output() + .map_err(|error| { + Problem::with( + "OpenBot could not reset the leftover database. Nothing else was removed.", + format!("Could not run volume removal: {error}"), + ) + })?; + if !removed.status.success() { + return Err(Problem::with("OpenBot could not reset the leftover database. It may still be attached to a container. Nothing else was removed; stop the installation using it before trying again.", format!("Volume removal exited with {}. Output omitted because it can contain credentials.", removed.status))); + } + Ok(()) +} + +fn empty_volume_options(options: &serde_json::Value) -> bool { + options.is_null() + || options + .as_object() + .is_some_and(|options| options.is_empty()) +} + fn postgres_volume_name(configuration: &[u8]) -> Result { let config: serde_json::Value = serde_json::from_slice(configuration) .map_err(|_| postgres_volume_problem("Compose config did not return valid JSON."))?; + postgres_data_volume(&config) + .and_then(|(_, definition)| definition["name"].as_str()) + .filter(|name| !name.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + postgres_volume_problem("Compose did not resolve a named volume for Postgres data.") + }) +} + +fn postgres_data_volume(config: &serde_json::Value) -> Option<(&str, &serde_json::Value)> { let postgres = &config["services"]["postgres"]; let data = postgres["environment"]["PGDATA"] .as_str() @@ -244,14 +382,14 @@ fn postgres_volume_name(configuration: &[u8]) -> Result { }) .max_by_key(|mount| mount["target"].as_str().unwrap().len()) }); - let volume = mount + mount .filter(|mount| mount["type"].as_str() == Some("volume")) .and_then(|mount| mount["source"].as_str()) - .and_then(|source| config["volumes"][source]["name"].as_str()) - .filter(|name| !name.trim().is_empty()); - volume.map(str::to_owned).ok_or_else(|| { - postgres_volume_problem("Compose did not resolve a named volume for Postgres data.") - }) + .and_then(|source| { + config["volumes"] + .get(source) + .map(|definition| (source, definition)) + }) } const MACOS_PODMAN_PORTS_FILE: &str = ".openbot-macos-podman.yml"; @@ -2737,6 +2875,7 @@ fn tail_of(logs: &Path, name: &str) -> String { /// Keep a short startup headline and useful local diagnostics, with credentials removed. pub fn startup_problem(problem: Problem, secrets: &Secrets) -> Problem { + let database_reset = problem.database_reset; let mut detail = problem.said; if let Some(cleanup) = problem.detail { detail.push('\n'); @@ -2753,10 +2892,12 @@ pub fn startup_problem(problem: Problem, secrets: &Secrets) -> Problem { for (key, value) in credentials { detail = detail.replace(value, &format!("<{key}>")); } - Problem::with( + let mut problem = Problem::with( "OpenBot could not finish starting. Try Start again, or share the details below for help.", detail, - ) + ); + problem.database_reset = database_reset; + problem } /// What a directory has to contain before it can be raised. @@ -2905,12 +3046,15 @@ mod tests { ), ("APP_PORT".into(), "4567".into()), ]); - let problem = startup_problem( - Problem::with( - "app stopped: Could not resolve imported module; key=synthetic+key.long", - "cleanup failed: port 4567 token=synthetic-host-token", - ), - &secrets, + let mut original = Problem::with( + "app stopped: Could not resolve imported module; key=synthetic+key.long", + "cleanup failed: port 4567 token=synthetic-host-token", + ); + original.database_reset = Some("openbot_postgres-data".into()); + let problem = startup_problem(original, &secrets); + assert_eq!( + problem.database_reset.as_deref(), + Some("openbot_postgres-data") ); assert!(problem.said.len() < 120); assert!(!problem.said.contains("Could not resolve")); @@ -3031,6 +3175,188 @@ mod tests { } } + fn leftover_database_fixture(root: &Path) -> serde_json::Value { + let mut config = postgres_config_fixture("openbot_postgres-data"); + config["name"] = "openbot".into(); + std::fs::write(root.join(".fixture-config"), config.to_string()).unwrap(); + std::fs::write( + root.join(".fixture-volumes"), + "unrelated\nopenbot_postgres-data\n", + ) + .unwrap(); + std::fs::write(root.join(".fixture-inspect"), serde_json::json!([{ + "Name": "openbot_postgres-data", "Driver": "local", "Options": {}, + "Labels": {"com.docker.compose.project":"openbot", "com.docker.compose.volume":"postgres-data"} + }]).to_string()).unwrap(); + config + } + + #[test] + fn leftover_database_reset_uses_only_confirmed_compose_owned_volume() { + if crate::test_support::isolated_process( + "stack::tests::leftover_database_reset_uses_only_confirmed_compose_owned_volume", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + for address in computer_stop_addresses(&path) { + let root = path.bin.join(format!("{}-reset", address.engine.binary())); + std::fs::create_dir(&root).unwrap(); + let record = root.join("commands.log"); + std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); + leftover_database_fixture(&root); + assert_eq!( + leftover_database_volume(&address, &root, &Secrets::new()).unwrap(), + Some("openbot_postgres-data".into()) + ); + assert!( + reset_leftover_database(&address, &root, &Secrets::new(), "unrelated").is_err() + ); + assert!(!std::fs::read_to_string(&record) + .unwrap() + .contains("volume rm")); + reset_leftover_database(&address, &root, &Secrets::new(), "openbot_postgres-data") + .unwrap(); + assert_eq!( + std::fs::read_to_string(root.join(".fixture-volumes")).unwrap(), + "unrelated\n" + ); + let log = std::fs::read_to_string(&record).unwrap(); + let removals: Vec<_> = log + .lines() + .filter(|line| line.contains("volume rm")) + .collect(); + assert_eq!(removals.len(), 1); + assert!(removals[0].ends_with("volume rm openbot_postgres-data")); + assert!(!log.contains("--force") && !log.contains("prune") && !log.contains("down")); + if address.engine == crate::engine::Engine::Podman { + assert!(log.lines().all(|line| line + .split_once('\t') + .unwrap() + .1 + .starts_with("--connection fixture-machine "))); + } + } + } + + #[test] + fn leftover_database_reset_refuses_shared_unowned_changed_or_attached_volumes() { + if crate::test_support::isolated_process( + "stack::tests::leftover_database_reset_refuses_shared_unowned_changed_or_attached_volumes", + ) { return; } + let path = PathFixture::with_fake_engine("postgres-volume"); + let address = computer_stop_addresses(&path)[1].clone(); + let root = path.bin.join("reset-refusals"); + std::fs::create_dir(&root).unwrap(); + let record = root.join("commands.log"); + std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); + for scenario in [ + "external", + "custom-name", + "driver", + "driver-options", + "shared-service", + "shared-alias", + "foreign-label", + "missing-label", + "prefix-inspect", + "missing", + "config-failure", + "inventory-failure", + "inspect-failure", + "attached", + ] { + let mut config = leftover_database_fixture(&root); + std::fs::write(&record, "").unwrap(); + let marker = match scenario { + "external" => { + config["volumes"]["postgres-data"]["external"] = true.into(); + None + } + "custom-name" => { + config["volumes"]["postgres-data"]["name"] = "shared-database".into(); + None + } + "driver" => { + config["volumes"]["postgres-data"]["driver"] = "nfs".into(); + None + } + "driver-options" => { + config["volumes"]["postgres-data"]["driver_opts"] = + serde_json::json!({"device":"/shared"}); + None + } + "shared-service" => { + config["services"]["other"] = config["services"]["postgres"].clone(); + None + } + "shared-alias" => { + config["services"]["other"] = config["services"]["postgres"].clone(); + config["services"]["other"]["volumes"][0]["source"] = "backup-alias".into(); + config["volumes"]["backup-alias"] = + serde_json::json!({"name": "openbot_postgres-data"}); + None + } + "foreign-label" | "missing-label" | "prefix-inspect" => { + let mut inspect: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(root.join(".fixture-inspect")).unwrap(), + ) + .unwrap(); + if scenario == "foreign-label" { + inspect[0]["Labels"]["com.docker.compose.project"] = + "another-project".into(); + } + if scenario == "missing-label" { + inspect[0]["Labels"] = serde_json::json!({}); + } + if scenario == "prefix-inspect" { + inspect[0]["Name"] = "openbot_postgres-data-backup".into(); + } + std::fs::write(root.join(".fixture-inspect"), inspect.to_string()).unwrap(); + None + } + "missing" => { + std::fs::write(root.join(".fixture-volumes"), "unrelated\n").unwrap(); + None + } + "config-failure" => Some(".fixture-config-failure"), + "inventory-failure" => Some(".fixture-volume-failure"), + "inspect-failure" => Some(".fixture-inspect-failure"), + "attached" => Some(".fixture-attached"), + _ => unreachable!(), + }; + std::fs::write(root.join(".fixture-config"), config.to_string()).unwrap(); + if let Some(marker) = marker { + std::fs::write(root.join(marker), "").unwrap(); + } + let error = + reset_leftover_database(&address, &root, &Secrets::new(), "openbot_postgres-data") + .expect_err(scenario); + assert!( + !format!("{error:?}").contains("synthetic-secret"), + "{scenario}" + ); + let log = std::fs::read_to_string(&record).unwrap(); + assert_eq!( + log.contains("volume rm"), + scenario == "attached", + "{scenario}: {log}" + ); + assert!(!log.contains("--force")); + assert!(std::fs::read_to_string(root.join(".fixture-volumes")) + .unwrap() + .contains("unrelated")); + if scenario == "attached" { + assert!(std::fs::read_to_string(root.join(".fixture-volumes")) + .unwrap() + .contains("openbot_postgres-data")); + } + if let Some(marker) = marker { + std::fs::remove_file(root.join(marker)).unwrap(); + } + } + } + #[test] fn desktop_approval_transport_credential_reaches_only_the_server() { let secrets = Secrets::from([ @@ -4419,6 +4745,17 @@ fn main() { (".fixture-config", ".fixture-config-failure") } else if actual == ["volume", "ls", "--format", "{{.Name}}"] { (".fixture-volumes", ".fixture-volume-failure") + } else if actual == ["volume", "inspect", "openbot_postgres-data"] { + (".fixture-inspect", ".fixture-inspect-failure") + } else if actual == ["volume", "rm", "openbot_postgres-data"] { + if std::path::Path::new(".fixture-attached").exists() { + eprintln!("synthetic-secret attached container"); + std::process::exit(2); + } + let inventory = std::fs::read_to_string(".fixture-volumes").unwrap(); + let remaining: String = inventory.lines().filter(|name| *name != "openbot_postgres-data").map(|name| format!("{name}\n")).collect(); + std::fs::write(".fixture-volumes", remaining).unwrap(); + return; } else { panic!("unexpected volume probe command: {actual:?}"); }; if std::path::Path::new(failure).exists() { eprintln!("synthetic-secret-must-not-appear-in-diagnostics"); diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index f5755ab1d..8d7e16f45 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -1227,6 +1227,107 @@ function emptyConfiguration() { }; } +test("leftover database recovery requires confirmation and keeps setup ready to retry", async () => { + useCompatibleEndpointSetup({}); + const previous = invokeHandler; + const reset = deferred(); + let starts = 0; + invokeHandler = async (command, args) => { + if (command === "start_stack" && ++starts === 1) + throw { + said: "A previous OpenBot database remains without its encryption key.", + database_reset: "fixture_postgres-data", + }; + if (command === "reset_leftover_database") return reset.promise; + return previous(command, args); + }; + const view = await enterCompatibleEndpoint( + "https://model.example/v1", + "synthetic-key", + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + const resets = () => + invokeCalls.filter((call) => call.command === "reset_leftover_database"); + await userEvent.click( + await view.findByRole("button", { name: "Reset leftover database" }), + ); + expect(view.getByText(/permanently deletes/)).toBeTruthy(); + expect(resets()).toHaveLength(0); + await userEvent.click(view.getByRole("button", { name: "Keep saved data" })); + expect(resets()).toHaveLength(0); + expect(view.queryByRole("button", { name: "Delete saved data" })).toBeNull(); + await userEvent.click( + view.getByRole("button", { name: "Reset leftover database" }), + ); + await userEvent.click( + view.getByRole("button", { name: "Delete saved data" }), + ); + expect(resets()).toEqual([ + { + command: "reset_leftover_database", + args: { + root: "/tmp/openbot-app-test", + volume: "fixture_postgres-data", + confirmed: true, + }, + }, + ]); + expect( + view.getByRole("button", { name: "Deleting saved data…" }), + ).toHaveProperty("disabled", true); + expect(view.getByRole("button", { name: "Working…" })).toHaveProperty( + "disabled", + true, + ); + await act(async () => reset.resolve()); + expect( + view.queryByRole("button", { name: "Reset leftover database" }), + ).toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); + expect(starts).toBe(1); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await view.findByRole("button", { name: "Ask" }); + expect(installationCalls()).toHaveLength(1); +}); + +test("database recovery failure is shown without restarting services", async () => { + useCompatibleEndpointSetup({}); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "start_stack") + throw { + said: "A previous database remains.", + database_reset: "fixture_postgres-data", + }; + if (command === "reset_leftover_database") + throw { + said: "The database is still in use. Close the other OpenBot installation first.", + }; + return previous(command, args); + }; + const view = await enterCompatibleEndpoint( + "https://model.example/v1", + "synthetic-key", + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await userEvent.click( + await view.findByRole("button", { name: "Reset leftover database" }), + ); + await userEvent.click( + view.getByRole("button", { name: "Delete saved data" }), + ); + expect(await view.findByText(/database is still in use/)).toBeTruthy(); + expect( + invokeCalls.filter((call) => call.command === "start_stack"), + ).toHaveLength(1); + expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( + "disabled", + false, + ); +}); + function setupRootConfiguration( rootA: string, loadConfiguration: (root: string) => Promise, diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index f641e1eb3..04b2b7bf7 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; import { Ask } from "./Ask"; +import { DatabaseReset } from "./DatabaseReset"; import { DEFAULT_HARNESS, type HarnessChoice, @@ -540,6 +541,24 @@ export function App() { } } + async function resetLeftoverDatabase(volume: string) { + setBusy(true); + try { + await invoke("reset_leftover_database", { + root: root.trim(), + volume, + confirmed: true, + }); + setFailure(null); + setRecoveryFailure(null); + setSteps([]); + } catch (error) { + setFailure(asProblem(error)); + } finally { + setBusy(false); + } + } + function modelCanStart() { if (!model) return false; if (!model.saved) return true; @@ -902,6 +921,15 @@ export function App() { {/* A failure outranks `running`. The supervisor gives up on a process and sends the window back here, and a heading that still says everything is running while the box underneath names the process that stopped is a screen arguing with itself. */} + {!running && displayedFailure?.database_reset && ( + + )} + {!running && !returningToInstallation && (

    Step 4 of 4

    )} diff --git a/desktop/src/DatabaseReset.tsx b/desktop/src/DatabaseReset.tsx new file mode 100644 index 000000000..8d3dcd46a --- /dev/null +++ b/desktop/src/DatabaseReset.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; + +/** Only rendered when native setup has identified a recoverable leftover database. */ +export function DatabaseReset({ + busy, + onReset, + volume, +}: { + busy: boolean; + volume: string; + onReset: (volume: string) => Promise; +}) { + const [confirming, setConfirming] = useState(false); + if (!confirming) { + return ( + + ); + } + return ( +
    +

    + Delete the previous installation’s data? +

    +

    + This permanently deletes the local OpenBot database, including saved + chats, Bots, and settings. Only continue if you want a fresh + installation and do not need that data. +

    +

    To keep your data, restore its original encryption key instead.

    +
    + + +
    +
    + ); +} diff --git a/desktop/src/Problem.tsx b/desktop/src/Problem.tsx index c427eeed6..723f93ede 100644 --- a/desktop/src/Problem.tsx +++ b/desktop/src/Problem.tsx @@ -12,6 +12,7 @@ export type Problem = { said: string; detail?: string | null; connection?: "model" | "intelligence" | "organization" | null; + database_reset?: string | null; }; /** Anything thrown, as a problem. A bare string keeps working and reads as it always did. */