From 482735d0da9b0b036171fe5b233fecedd1ba97be Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 5 Nov 2026 19:16:46 -0800 Subject: [PATCH] Get the Composio key from the CopilotKit sign-in again The 0.3.0 desktop never asked CopilotKit for this installation's Composio project, so every install started with COMPOSIO_API_KEY empty and apps unavailable. #971 asked during the app's own CopilotKit sign-in; #832 replaced that sign-in with the bundled CLI's and left provisioning out, because the Ops route returned 404 in production at the time. The route is live now. The CLI now does it, as the Ops contract names it: `copilotkit integrations composio provision --installation-id --env-path --json`, with the session its own login saved. OpenBot never reads that session. The CLI writes the key into an owner-only handoff folder in the installation, which OpenBot reads, moves to the protected store as COMPOSIO_PROVISIONED_API_KEY and removes. The key is never written to .env. - The Account step connects apps once the person is signed in. A failure shows the existing sentence with Connect apps to retry and Continue to go on without apps. - Every Start of an installation with local Intelligence and no key tries again, so an install signed in under 0.3.0 gets its key on its next Start without signing in again. A failure never stops Start. - A key the person supplied in .env or the environment, or an Arcade choice, still wins and is never asked about or moved. Ops replays the same key for an installation, so a retry never makes a second project. - The direct Ops request and its HTTP test helper are gone; the CLI is the only client. --- desktop/src-tauri/src/composio.rs | 250 +++++++---- desktop/src-tauri/src/composio_ipc_tests.rs | 452 ++++++++++++++++++++ desktop/src-tauri/src/http_test_support.rs | 42 -- desktop/src-tauri/src/lib.rs | 3 - desktop/src-tauri/src/local_eval.rs | 287 ++++++++++++- desktop/src-tauri/src/main.rs | 48 +++ desktop/src/App.test.tsx | 73 ++++ desktop/src/App.tsx | 1 + desktop/src/CopilotKitAccount.tsx | 82 +++- desktop/src/localEvalCli.ts | 28 ++ docs/desktop-install.md | 21 +- docs/plugins/composio.md | 21 +- 12 files changed, 1165 insertions(+), 143 deletions(-) create mode 100644 desktop/src-tauri/src/composio_ipc_tests.rs delete mode 100644 desktop/src-tauri/src/http_test_support.rs diff --git a/desktop/src-tauri/src/composio.rs b/desktop/src-tauri/src/composio.rs index e938f4615c..7f40b507b5 100644 --- a/desktop/src-tauri/src/composio.rs +++ b/desktop/src-tauri/src/composio.rs @@ -1,4 +1,4 @@ -//! Preparing this installation's app broker with the existing native CopilotKit sign-in. +//! Preparing this installation's app broker with the CopilotKit sign-in the Intelligence CLI holds. use std::collections::BTreeMap; use std::io::Write; @@ -6,15 +6,14 @@ use std::path::Path; use rand::RngCore; +use crate::local_eval::AppsProvisioned; use crate::problem::Problem; -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] +/// What came back for this installation: the CLI's report, and the key from the file it wrote. struct ProjectConnection { installation_id: String, project_id: String, api_key: String, - #[serde(rename = "reused")] _reused: bool, } @@ -156,18 +155,48 @@ pub enum Provisioned { New(ProvisionedKey), } -/// Where CopilotKit Ops answers for an installation's project connection. -const OPS_API: &str = "https://api.operations.copilotkit.ai"; +/// Where the CLI writes the key it brings back. OpenBot's own folder, owner-only, removed with +/// what is in it as soon as the key is read, whatever the CLI did. +const HANDOFF: &str = ".openbot-apps"; +const HANDOFF_FILE: &str = "composio.env"; -/// Ask Ops for this installation's project key, with a CopilotKit Ops session's bearer token. Only -/// the installation identity is written. -pub fn provision(root: &Path, bearer: &str) -> Result { - provision_at(root, bearer, OPS_API) +/// One connection at a time, so a retry and a Start never both ask for the same installation. +static CONNECTING: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/** +Connect this installation's apps, once a CopilotKit sign-in is in place. + +`cli` runs `copilotkit integrations composio provision` for the installation id, writing into the +file it is given (`local_eval::provision_apps_installed`). The CLI is the one that talks to +CopilotKit Ops, with the session its own `login` saved: OpenBot never reads that session. Ops +makes one Composio project per installation, owned by the signed-in account and organization, and +replays the same key for it later. + +Only adds a key. A key the person supplied, an Arcade choice, or a key kept from an earlier +connection means nothing is asked for. A new key goes to the protected store, never to `.env`. +Callers treat a failure as apps unavailable: OpenBot still starts. +*/ +pub fn connect( + root: &Path, + cli: impl FnOnce(&str, &Path) -> Result, +) -> Result<(), Problem> { + let _one = CONNECTING + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match provision(root, cli)? { + Provisioned::Preserved => Ok(()), + Provisioned::New(key) => save(root, key), + } } -fn provision_at(root: &Path, session: &str, origin: &str) -> Result { +/// Ask for this installation's project key through the CLI. Only the installation identity and +/// the short-lived handoff folder are written. +pub fn provision( + root: &Path, + cli: impl FnOnce(&str, &Path) -> Result, +) -> Result { let brokers = resolve(root)?; - provision_with(root, &brokers.values, |id| request_at(origin, session, id)) + provision_with(root, &brokers.values, |id| request_with_cli(root, id, cli)) } /// Keep a provisioned key in the protected store. Never in `.env`, and never under the name of a @@ -180,25 +209,48 @@ pub fn save(root: &Path, key: ProvisionedKey) -> Result<(), Problem> { }) } -fn request_at(origin: &str, session: &str, id: &str) -> Result { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|_| setup_problem())?; - let response = client - .post(format!("{origin}/api/cli/integrations/composio/project")) - .bearer_auth(session) - .json(&serde_json::json!({ "installationId": id })) - .send() - .map_err(|_| setup_problem())?; - if !response.status().is_success() { - return Err(setup_problem()); +fn request_with_cli( + root: &Path, + id: &str, + cli: impl FnOnce(&str, &Path) -> Result, +) -> Result { + let folder = root.join(HANDOFF); + // A handoff an interrupted attempt left behind. It may hold a key; it never outlives a retry. + match std::fs::remove_dir_all(&folder) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(setup_problem()), + } + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + // The CLI refuses a folder another account can write to. + builder.mode(0o700); } - response.json().map_err(|_| setup_problem()) + builder.create(&folder).map_err(|_| setup_problem())?; + let env_file = folder.join(HANDOFF_FILE); + let received = cli(id, &env_file).and_then(|reported| { + let key = file_settings(&env_file)? + .remove("COMPOSIO_API_KEY") + .ok_or_else(setup_problem)?; + Ok(ProjectConnection { + installation_id: reported.installation_id, + project_id: reported.project_id, + api_key: key, + _reused: reported.reused, + }) + }); + let removed = std::fs::remove_dir_all(&folder); + let connection = received.map_err(|_| setup_problem())?; + // A key that could not be cleared from the handoff is not taken up. The next attempt clears + // it first, and Ops replays the same key then. + removed.map_err(|_| setup_problem())?; + Ok(connection) } -fn setup_problem() -> Problem { +/// What the person reads when apps could not be connected. Nothing the CLI or Ops said is in it. +pub fn setup_problem() -> Problem { Problem::plain("OpenBot couldn't connect your apps. Check your connection, then choose Connect apps. If it keeps happening, ask an admin.") } @@ -394,7 +446,7 @@ mod tests { ); // A supplied key is never replaced, so nothing is asked for. assert!(matches!( - provision_at(&root, "synthetic-ops-session", "http://127.0.0.1:1").unwrap(), + provision(&root, |_, _| panic!("a supplied key is never replaced")).unwrap(), Provisioned::Preserved )); @@ -512,32 +564,84 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + /// The CLI as `integrations composio provision` behaves on success: the key in the file it + /// was given, and metadata in its report. + fn cli_writes( + key: &'static str, + reused: bool, + ) -> impl FnOnce(&str, &Path) -> Result { + move |id, env_file| { + assert!(env_file.is_absolute()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let folder = std::fs::metadata(env_file.parent().unwrap()).unwrap(); + assert_eq!(folder.permissions().mode() & 0o777, 0o700); + } + assert!(!env_file.exists(), "the CLI gets an empty file to write"); + std::fs::write(env_file, format!("COMPOSIO_API_KEY={key}\n")).unwrap(); + Ok(AppsProvisioned { + installation_id: id.into(), + project_id: "pr_fixture".into(), + reused, + }) + } + } + + /// A failed CLI leaves nothing behind and says nothing it was told; a report for another + /// installation, or no key in the file, is not taken up either. #[test] - fn ops_request_uses_only_the_cli_session_and_rejects_errors_without_body_details() { - let id = "e1503aba-d1ba-4d21-ab5e-7b6cb109bfb6"; - let (url, requests) = crate::http_test_support::response_server(vec![ - ( - "200 OK", - r#"{"installationId":"e1503aba-d1ba-4d21-ab5e-7b6cb109bfb6","projectId":"pr_fixture","apiKey":"synthetic-key","reused":true}"#, - ), - ("503 Unavailable", "synthetic-private-error-body"), - ]); - let result = request_at(&url, "synthetic-ops-session", id).unwrap(); - assert_eq!(result.api_key, "synthetic-key"); - let error = request_at(&url, "synthetic-ops-session", id).err().unwrap(); - let rendered = serde_json::to_string(&error).unwrap(); + fn a_failed_or_mismatched_cli_answer_keeps_no_key_and_no_handoff() { + let root = crate::test_support::temp_root("composio-cli-failures"); + std::fs::create_dir_all(&root).unwrap(); + let failed = provision_with(&root, &BTreeMap::new(), |id| { + request_with_cli(&root, id, |_, env_file| { + // Even a file the CLI left half-written goes with the handoff. + std::fs::write(env_file, "COMPOSIO_API_KEY=synthetic-partial\n").unwrap(); + Err(Problem::plain("synthetic-private-cli-error")) + }) + }); + let rendered = serde_json::to_string(&failed.err().unwrap()).unwrap(); + assert!(rendered.contains("OpenBot couldn't connect your apps.")); assert!(!rendered.contains("synthetic-")); - let requests = requests.join().unwrap(); - for (headers, body) in requests { - assert!(headers.starts_with("POST /api/cli/integrations/composio/project HTTP/1.1")); - assert!(headers - .to_ascii_lowercase() - .contains("authorization: bearer synthetic-ops-session\r\n")); - assert_eq!( - serde_json::from_slice::(&body).unwrap(), - serde_json::json!({"installationId": id}) - ); - } + assert!(!root.join(HANDOFF).exists()); + + assert!(provision_with(&root, &BTreeMap::new(), |id| { + request_with_cli(&root, id, |_, env_file| { + std::fs::write(env_file, "COMPOSIO_API_KEY=synthetic-key\n").unwrap(); + Ok(AppsProvisioned { + installation_id: "a1503aba-d1ba-4d21-ab5e-7b6cb109bfb6".into(), + project_id: "pr_fixture".into(), + reused: false, + }) + }) + }) + .is_err()); + assert!(provision_with(&root, &BTreeMap::new(), |id| { + request_with_cli(&root, id, |id, _| { + Ok(AppsProvisioned { + installation_id: id.into(), + project_id: "pr_fixture".into(), + reused: false, + }) + }) + }) + .is_err()); + assert!(!root.join(HANDOFF).exists()); + + // A handoff an interrupted attempt left behind is cleared before the CLI runs again. + std::fs::create_dir_all(root.join(HANDOFF)).unwrap(); + std::fs::write( + root.join(HANDOFF).join(HANDOFF_FILE), + "COMPOSIO_API_KEY=stale\n", + ) + .unwrap(); + let key = new_key(provision_with(&root, &BTreeMap::new(), |id| { + request_with_cli(&root, id, cli_writes("synthetic-fresh-key", false)) + })); + assert_eq!(key, "synthetic-fresh-key"); + assert!(!root.join(HANDOFF).exists()); + std::fs::remove_dir_all(root).unwrap(); } #[test] @@ -577,28 +681,30 @@ mod tests { } #[test] - fn native_http_retry_stores_project_key_privately_and_next_sign_in_skips_remote() { - if crate::test_support::isolated_process("composio::tests::native_http_retry_stores_project_key_privately_and_next_sign_in_skips_remote") { return; } + fn a_retry_through_the_cli_keeps_the_key_privately_and_the_next_connect_asks_nobody() { + if crate::test_support::isolated_process("composio::tests::a_retry_through_the_cli_keeps_the_key_privately_and_the_next_connect_asks_nobody") { return; } clear_broker_environment(); - let root = crate::test_support::temp_root("composio-native-roundtrip"); + let root = crate::test_support::temp_root("composio-cli-roundtrip"); std::fs::create_dir_all(&root).unwrap(); let id = "e1503aba-d1ba-4d21-ab5e-7b6cb109bfb6"; let env = format!("OPENBOT_INSTALLATION_ID={id}\nCOMPOSIO_API_KEY= \n"); std::fs::write(root.join(".env"), &env).unwrap(); - let (url, requests) = crate::http_test_support::response_server(vec![ - ("503 Unavailable", "synthetic-private-error"), - ( - "200 OK", - r#"{"installationId":"e1503aba-d1ba-4d21-ab5e-7b6cb109bfb6","projectId":"pr_fixture","apiKey":"synthetic-native-key","reused":true}"#, - ), - ]); - assert!(provision_at(&root, "synthetic-ops-session", &url).is_err()); - let key = new_key(provision_at(&root, "synthetic-ops-session", &url)); - save(&root, ProvisionedKey::new(key)).unwrap(); - assert!(matches!( - provision_at(&root, "synthetic-ops-session", "http://127.0.0.1:1").unwrap(), - Provisioned::Preserved - )); + let mut asked = Vec::new(); + assert!(connect(&root, |id, _| { + asked.push(id.to_owned()); + Err(Problem::plain("synthetic-expired-session")) + }) + .is_err()); + assert!(crate::vault::recall(&root, PROVISIONED_KEY) + .unwrap() + .is_none()); + // Ops replays the key it made for this installation: `reused` is still a key to keep. + connect(&root, |id, env_file| { + asked.push(id.to_owned()); + cli_writes("synthetic-native-key", true)(id, env_file) + }) + .unwrap(); + connect(&root, |_, _| panic!("a kept key is never asked for again")).unwrap(); let brokers = resolve(&root).unwrap(); assert_eq!(brokers.composio_source(), Some(KeySource::Provisioned)); assert_eq!( @@ -607,8 +713,8 @@ mod tests { ); assert!(!brokers.runtime().contains_key("OPENBOT_INSTALLATION_ID")); assert_eq!(std::fs::read_to_string(root.join(".env")).unwrap(), env); - let requests = requests.join().unwrap(); - assert_eq!(requests[0].1, requests[1].1); + assert!(!root.join(HANDOFF).exists()); + assert_eq!(asked, [id, id]); std::fs::remove_dir_all(root).unwrap(); } diff --git a/desktop/src-tauri/src/composio_ipc_tests.rs b/desktop/src-tauri/src/composio_ipc_tests.rs new file mode 100644 index 0000000000..ddf0fb4245 --- /dev/null +++ b/desktop/src-tauri/src/composio_ipc_tests.rs @@ -0,0 +1,452 @@ +// Included by main.rs, so these regressions run its registered commands and its Start environment. +mod composio_ipc { + use super::*; + use std::sync::{Arc, Mutex as StdMutex}; + + /// The broker settings a developer's own shell could carry into a test. + const BROKER_ENVIRONMENT: [&str; 4] = [ + "COMPOSIO_API_KEY", + "ARCADE_API_KEY", + "OPENBOT_BROKER_PROVIDER", + "OPENBOT_INSTALLATION_ID", + ]; + + /// The project key the fake Ops makes. Synthetic: no real key is ever used here. + const PROJECT_KEY: &str = "synthetic-project-key"; + /// The CopilotKit session the fake CLI's sign-in left in its own store. + const SESSION: &str = "synthetic-cli-session"; + + /// What the fake Ops was sent: the request line and headers, and the body. + type Received = Arc)>>>; + + /** + CopilotKit Ops, for `POST /api/cli/integrations/composio/project`, as + `docs/contracts/openbot-composio-provisioning.md` in Intelligence describes it. + + In: a CLI session bearer and exactly `{"installationId": ""}`. Out, on `200`: + `installationId` (the one asked for), `projectId`, `apiKey` and `reused`, with + `Cache-Control: private, no-store`. A second request for the same installation replays the same + key with `reused: true`. A `status` other than `200 OK` answers with the Ops error envelope. + */ + fn fake_ops(status: &'static str) -> (String, Received) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let received: Received = Arc::default(); + let log = received.clone(); + std::thread::spawn(move || { + use std::io::{BufRead, Read, Write}; + let mut made: Vec = Vec::new(); + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let mut reader = std::io::BufReader::new(stream); + let mut head = String::new(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + head.push_str(&line); + if line == "\r\n" { + break; + } + } + let length: usize = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().ok())? + }) + .unwrap_or(0); + let mut body = vec![0; length]; + let _ = reader.read_exact(&mut body); + log.lock().unwrap().push((head, body.clone())); + let answer = if status == "200 OK" { + let asked: serde_json::Value = + serde_json::from_slice(&body).unwrap_or_default(); + let id = asked["installationId"].as_str().unwrap_or_default().to_owned(); + let reused = made.contains(&id); + made.push(id.clone()); + serde_json::json!({ + "installationId": id, + "projectId": "pr_fixture", + "apiKey": PROJECT_KEY, + "reused": reused, + }) + } else { + serde_json::json!({ + "error": { "code": "COMPOSIO_PROVISIONING_UNAVAILABLE", "message": "synthetic-private-ops-error" } + }) + } + .to_string(); + let _ = write!( + reader.get_mut(), + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nCache-Control: private, no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}", + answer.len() + ); + } + }); + (origin, received) + } + + /** + A stand-in for `copilotkit integrations composio provision`, as Intelligence's + `apps/cli/src/commands/integrations.ts` behaves. + + It takes exactly `--installation-id --env-path --json`, reads the session + `copilotkit login` saved (`session` beside it, standing in for the CLI's own store), posts the + installation id to Ops (`ops` beside it), checks the answer is for that installation, writes + `COMPOSIO_API_KEY=` into the env file, owner-only, and prints only metadata. Without a + session, or on an Ops error, it says why on stderr and exits 1, and writes nothing. + */ + const FAKE_CLI: &str = r#" +use std::io::{Read, Write}; +fn field<'a>(json: &'a str, name: &str) -> Option<&'a str> { + let start = json.find(&format!("\"{name}\":"))? + name.len() + 3; + let rest = json[start..].trim_start(); + if let Some(quoted) = rest.strip_prefix('"') { + return quoted.split('"').next(); + } + rest.split(|c| c == ',' || c == '}').next().map(str::trim) +} +fn main() { + let dir = std::env::current_exe().unwrap().parent().unwrap().to_path_buf(); + let args: Vec = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("--warm") { + return; + } + std::fs::write(dir.join("argv"), args.join("\n")).unwrap(); + let shape = args.len() == 8 + && args[..3] == ["integrations", "composio", "provision"] + && args[3] == "--installation-id" + && args[5] == "--env-path" + && args[7] == "--json"; + if !shape { + eprintln!("Usage: copilotkit integrations composio provision --installation-id --env-path --json"); + std::process::exit(1); + } + let (id, env_path) = (&args[4], std::path::PathBuf::from(&args[6])); + if !env_path.is_absolute() || !env_path.parent().map_or(false, |parent| parent.is_dir()) { + eprintln!("--env-path must be an absolute path to an env file in an existing directory."); + std::process::exit(1); + } + let Ok(session) = std::fs::read_to_string(dir.join("session")) else { + eprintln!("Sign in with `copilotkit login --json`, then retry with the same --installation-id."); + std::process::exit(1); + }; + let origin = std::fs::read_to_string(dir.join("ops")).unwrap(); + let address = origin.trim().trim_start_matches("http://").to_owned(); + let body = format!("{{\"installationId\":\"{id}\"}}"); + let mut stream = std::net::TcpStream::connect(&address).unwrap(); + write!( + stream, + "POST /api/cli/integrations/composio/project HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + session.trim(), + body.len() + ) + .unwrap(); + let mut answer = String::new(); + stream.read_to_string(&mut answer).unwrap(); + let (head, json) = answer.split_once("\r\n\r\n").unwrap_or((&answer, "")); + if !head.starts_with("HTTP/1.1 200") { + eprintln!("Composio provisioning could not finish. No credential was printed."); + std::process::exit(1); + } + if field(json, "installationId") != Some(id.as_str()) { + eprintln!("Composio provisioning could not finish. No credential was printed."); + std::process::exit(1); + } + let key = field(json, "apiKey").unwrap(); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&env_path).unwrap(); + writeln!(file, "COMPOSIO_API_KEY={key}").unwrap(); + println!( + "{{\"type\":\"completed\",\"installation_id\":\"{id}\",\"project_id\":\"{}\",\"environment_file_written\":true,\"reused\":{}}}", + field(json, "projectId").unwrap(), + field(json, "reused").unwrap() + ); +} +"#; + + /// The fake CLI, compiled once per process, beside its `session` and `ops`. + struct FakeCli { + dir: PathBuf, + } + + impl FakeCli { + /// Pinned as this debug build's developer CLI, exactly as `local_eval::installed` checks + /// one: by path and SHA-256. Only for a test in its own process. + fn pinned(label: &str, ops: &str, signed_in: bool) -> Self { + let dir = crate::test_support::temp_root(label); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("cli.rs"); + std::fs::write(&source, FAKE_CLI).unwrap(); + let program = dir.join(format!("cli{}", std::env::consts::EXE_SUFFIX)); + crate::test_support::compile_fixture(&source, &program); + assert!(std::process::Command::new(&program) + .arg("--warm") + .status() + .unwrap() + .success()); + std::fs::write(dir.join("ops"), ops).unwrap(); + if signed_in { + std::fs::write(dir.join("session"), SESSION).unwrap(); + } + use sha2::Digest; + let digest: String = sha2::Sha256::digest(std::fs::read(&program).unwrap()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + std::env::set_var("OPENBOT_DEV_LOCAL_EVAL_PROGRAM", &program); + std::env::set_var("OPENBOT_DEV_LOCAL_EVAL_PROGRAM_SHA256", digest); + Self { dir } + } + + fn argv(&self) -> Option { + std::fs::read_to_string(self.dir.join("argv")).ok() + } + } + + /// An installation a 0.3.0 first run left: local Intelligence recorded, no Composio key + /// anywhere, and no installation identity yet. + fn signed_in_installation(label: &str) -> PathBuf { + for name in BROKER_ENVIRONMENT { + std::env::remove_var(name); + } + let root = crate::test_support::temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(".env"), "COMPOSIO_API_KEY=\nOPENBOT_ORIGIN=http://127.0.0.1:3010\n") + .unwrap(); + local_eval_state::begin( + &root, + &local_eval::StackId::parse("local-abc123").unwrap(), + local_eval_state::Ownership::Created, + &"a".repeat(64), + ) + .unwrap(); + root + } + + fn start_environment(root: &Path) -> stack::Secrets { + let (_, secrets) = persist_start_environment( + root, + std::collections::BTreeMap::new(), + &openbot_env::ModelCredential::None, + ) + .unwrap(); + secrets + } + + fn installation_id(root: &Path) -> String { + std::fs::read_to_string(root.join(".env.openbot-installation-id")) + .unwrap() + .trim() + .trim_start_matches("OPENBOT_INSTALLATION_ID=") + .to_owned() + } + + /** + SIGNING IN TO COPILOTKIT GIVES OPENBOT ITS APPS AT START. + + 0.3.0 replaced the app's own CopilotKit sign-in with the CLI's and dropped the step that asked + Ops for this installation's Composio project. Nothing else asked, so every desktop install ran + with `COMPOSIO_API_KEY` empty and apps unavailable. An installation signed in under 0.3.0 has + the CLI's session and no key: its next Start gets the key from Ops through the CLI, keeps it in + the protected store and never in `.env`, and hands it to the server. + */ + #[test] + fn a_copilotkit_sign_in_gives_start_a_composio_key() { + if crate::test_support::isolated_process( + "tests::composio_ipc::a_copilotkit_sign_in_gives_start_a_composio_key", + ) { + return; + } + let (ops, received) = fake_ops("200 OK"); + let cli = FakeCli::pinned("composio-start-cli", &ops, true); + let root = signed_in_installation("composio-start"); + + let secrets = start_environment(&root); + + assert_eq!( + secrets.get("COMPOSIO_API_KEY").map(String::as_str), + Some(PROJECT_KEY), + "Start must hand the server the key the CopilotKit sign-in provisions" + ); + let id = installation_id(&root); + assert_eq!( + cli.argv().unwrap(), + format!( + "integrations\ncomposio\nprovision\n--installation-id\n{id}\n--env-path\n{}\n--json", + root.join(".openbot-apps").join("composio.env").display() + ) + ); + let sent = received.lock().unwrap().clone(); + assert_eq!(sent.len(), 1); + let (head, body) = &sent[0]; + assert!(head.starts_with("POST /api/cli/integrations/composio/project HTTP/1.1\r\n")); + assert!(head.contains(&format!("Authorization: Bearer {SESSION}\r\n"))); + assert_eq!( + serde_json::from_slice::(body).unwrap(), + serde_json::json!({ "installationId": id }) + ); + // Kept in the protected store, under its own name. Never in `.env`, and nothing left in + // the folder the CLI wrote it to. + assert_eq!( + openbot_desktop_lib::vault::recall(&root, "COMPOSIO_PROVISIONED_API_KEY").unwrap(), + Some(PROJECT_KEY.to_owned()) + ); + assert!(!std::fs::read_to_string(root.join(".env")) + .unwrap() + .contains(PROJECT_KEY)); + assert!(!root.join(".openbot-apps").exists()); + + // The next Start uses the kept key and asks nobody. + let again = start_environment(&root); + assert_eq!( + again.get("COMPOSIO_API_KEY").map(String::as_str), + Some(PROJECT_KEY) + ); + assert_eq!(received.lock().unwrap().len(), 1); + std::fs::remove_dir_all(root).unwrap(); + } + + fn ipc( + window: &tauri::WebviewWindow, + cmd: &str, + body: serde_json::Value, + ) -> Result { + tauri::test::get_ipc_response( + window, + tauri::webview::InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: if cfg!(any(windows, target_os = "android")) { + "http://tauri.localhost" + } else { + "tauri://localhost" + } + .parse() + .unwrap(), + body: tauri::ipc::InvokeBody::Json(body), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) + .map(|response| response.deserialize::().unwrap()) + } + + fn window() -> ( + tauri::App, + tauri::WebviewWindow, + ) { + let app = tauri::test::mock_builder() + .manage(Shell::default()) + .invoke_handler(tauri::generate_handler![connect_copilotkit_apps]) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + (app, window) + } + + /** + THE SIGN-IN STEP CONNECTS APPS, AND CONNECT APPS TRIES AGAIN. + + The window calls `connect_copilotkit_apps` once the person is signed in. Signed out, it fails + with the sentence the window shows beside Connect apps, keeps nothing and leaves no handoff. + After signing in, Connect apps gets the key; another call asks nobody. Nothing secret crosses. + */ + #[test] + fn connect_apps_after_sign_in_keeps_the_key_and_a_retry_works() { + if crate::test_support::isolated_process( + "tests::composio_ipc::connect_apps_after_sign_in_keeps_the_key_and_a_retry_works", + ) { + return; + } + let (ops, received) = fake_ops("200 OK"); + let cli = FakeCli::pinned("composio-ipc-cli", &ops, false); + let root = signed_in_installation("composio-ipc"); + let (_app, window) = window(); + let body = serde_json::json!({ "attempt": "apps-ipc-1", "root": root }); + + let error = ipc(&window, "connect_copilotkit_apps", body.clone()) + .expect_err("signed out, there is no key to get"); + assert_eq!( + error["said"], + "OpenBot couldn't connect your apps. Check your connection, then choose Connect apps. If it keeps happening, ask an admin." + ); + assert!(!error.to_string().contains("copilotkit login")); + assert!(received.lock().unwrap().is_empty()); + assert!(!root.join(".openbot-apps").exists()); + assert_eq!( + openbot_desktop_lib::vault::recall(&root, "COMPOSIO_PROVISIONED_API_KEY").unwrap(), + None + ); + + std::fs::write(cli.dir.join("session"), SESSION).unwrap(); + let connected = ipc(&window, "connect_copilotkit_apps", body.clone()).unwrap(); + assert_eq!(connected, serde_json::Value::Null); + assert!(!connected.to_string().contains(PROJECT_KEY)); + assert_eq!( + openbot_desktop_lib::vault::recall(&root, "COMPOSIO_PROVISIONED_API_KEY").unwrap(), + Some(PROJECT_KEY.to_owned()) + ); + assert!(!root.join(".openbot-apps").exists()); + + std::fs::remove_file(cli.dir.join("argv")).unwrap(); + ipc(&window, "connect_copilotkit_apps", body).unwrap(); + assert_eq!(cli.argv(), None, "a kept key is never asked for again"); + assert_eq!(received.lock().unwrap().len(), 1); + std::fs::remove_dir_all(root).unwrap(); + } + + /** + A KEY THE PERSON SUPPLIED WINS, AND APPS NEVER STOP START. + + With `COMPOSIO_API_KEY` in `.env`, Start asks nobody and hands the server that key, and `.env` + is left as it was. When Ops cannot provision, Start still succeeds, with apps unavailable. + */ + #[test] + fn a_supplied_key_wins_and_a_failure_never_stops_start() { + if crate::test_support::isolated_process( + "tests::composio_ipc::a_supplied_key_wins_and_a_failure_never_stops_start", + ) { + return; + } + let (ops, received) = fake_ops("503 Service Unavailable"); + let cli = FakeCli::pinned("composio-supplied-cli", &ops, true); + + let root = signed_in_installation("composio-supplied"); + let env = "COMPOSIO_API_KEY=synthetic-person-key\nOPENBOT_ORIGIN=http://127.0.0.1:3010\n"; + std::fs::write(root.join(".env"), env).unwrap(); + let secrets = start_environment(&root); + assert_eq!( + secrets.get("COMPOSIO_API_KEY").map(String::as_str), + Some("synthetic-person-key") + ); + assert_eq!(cli.argv(), None); + assert!(std::fs::read_to_string(root.join(".env")) + .unwrap() + .contains("COMPOSIO_API_KEY=synthetic-person-key")); + std::fs::remove_dir_all(root).unwrap(); + + let root = signed_in_installation("composio-ops-down"); + let secrets = start_environment(&root); + assert_eq!(secrets.get("COMPOSIO_API_KEY"), None); + assert_eq!(received.lock().unwrap().len(), 1); + assert!(!root.join(".openbot-apps").exists()); + assert_eq!( + openbot_desktop_lib::vault::recall(&root, "COMPOSIO_PROVISIONED_API_KEY").unwrap(), + None + ); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/desktop/src-tauri/src/http_test_support.rs b/desktop/src-tauri/src/http_test_support.rs deleted file mode 100644 index 15fe1119f4..0000000000 --- a/desktop/src-tauri/src/http_test_support.rs +++ /dev/null @@ -1,42 +0,0 @@ -pub(crate) type CapturedRequests = Vec<(String, Vec)>; - -pub(crate) fn response_server( - responses: Vec<(&'static str, &'static str)>, -) -> (String, std::thread::JoinHandle) { - 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 mut requests = Vec::new(); - for (status, body) in responses { - 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_or(0); - 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(); - requests.push((headers, request_body)); - } - requests - }); - (url, request) -} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 71759b9a0c..5f5bae6b0e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -34,6 +34,3 @@ pub mod windows; #[cfg(test)] pub(crate) mod test_support; - -#[cfg(test)] -mod http_test_support; diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index 8a5cea18c0..162e8e0b40 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -11,8 +11,9 @@ //! fetches (`install.rs`). There is no fallback to `PATH`, `npx` or a global install. //! - **Arguments are fixed.** Only `status`, `start`, `stop` and `login`, a validated stack id and //! `--json`, plus the constant `list`, `setup`, `connect` and `login --help` lists and the sign-in's -//! `--mode openbot`, as separate arguments with no shell. The CLI's `resumeCommand` strings are text for a -//! terminal user; they are dropped here and never executed. +//! `--mode openbot`, and `integrations composio provision` with a validated installation id and +//! a file OpenBot made, as separate arguments with no shell. The CLI's `resumeCommand` strings +//! are text for a terminal user; they are dropped here and never executed. //! - **Only allowlisted facts leave.** Raw stdout, stderr, error messages and recovery text never //! cross IPC or reach a log. Failures are static categories with no payload. //! @@ -363,6 +364,116 @@ pub fn help_lists_openbot_mode(exit_code: i32, stdout: &[u8]) -> bool { /// The evaluation agreement, read only: `onboard local-terms --json`. pub const TERMS_ARGUMENTS: [&str; 3] = ["onboard", "local-terms", "--json"]; +/** +This installation's Composio project, from CopilotKit, for the account `login` signed in: +`integrations composio provision --installation-id --env-path --json`. + +The CLI posts the installation id to Ops with its own session, which never leaves the CLI, writes +the project key it gets back into `env_file` as `COMPOSIO_API_KEY`, and prints only metadata. Ops +makes one project per installation and replays the same key for it after a lost answer +(`reused`). The contract is Intelligence's `docs/contracts/openbot-composio-provisioning.md`; the +command is `apps/cli/src/commands/integrations.ts`, in the CLI pinned since +`desktop/intelligence-cli.json` named 256cc68. +*/ +pub fn apps_arguments<'a>(installation: &'a Installation, env_file: &'a str) -> [&'a str; 8] { + [ + "integrations", + "composio", + "provision", + "--installation-id", + installation.as_str(), + "--env-path", + env_file, + "--json", + ] +} + +/// An installation id, as OpenBot keeps it: a lowercase UUID, and nothing that could be another +/// argument. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Installation(String); + +impl Installation { + pub fn parse(value: &str) -> Result { + let bytes = value.as_bytes(); + let canonical = bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| { + if [8, 13, 18, 23].contains(&index) { + *byte == b'-' + } else { + byte.is_ascii_digit() || (b'a'..=b'f').contains(byte) + } + }); + if canonical { + Ok(Self(value.to_owned())) + } else { + // Refused before anything runs. + Err(Failure::Spawn) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// What `integrations composio provision` reported. Metadata only: the key is in the file it +/// was given, and the CLI never prints it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AppsProvisioned { + pub installation_id: String, + pub project_id: String, + /// Ops replayed the key it made for this installation before. + pub reused: bool, +} + +/** +`integrations composio provision`'s one line of JSON on success, exit 0. + +Any failure exits 1 with a sentence on stderr, which is never read: a missing or expired session, +another account's installation, provisioning not set up on Ops, or a repair. Only `completed`, for +the installation asked about, with the file written, is believed. `already_configured` means the +file already held a key, and the file OpenBot hands over never does. +*/ +pub fn normalize_apps( + installation: &Installation, + exit_code: i32, + stdout: &[u8], +) -> Result { + if exit_code != 0 { + return Err(Failure::UnexpectedExit); + } + if stdout.len() > MAX_OUTPUT { + return Err(Failure::OutputLimit); + } + let text_out = std::str::from_utf8(stdout).map_err(|_| Failure::InvalidJson)?; + let wire: Value = serde_json::from_str(text_out.trim()).map_err(|_| Failure::InvalidJson)?; + let wire = object(&wire)?; + if wire.get("type").and_then(Value::as_str) != Some("completed") + || wire.get("installation_id").and_then(Value::as_str) != Some(installation.as_str()) + || wire + .get("environment_file_written") + .and_then(Value::as_bool) + != Some(true) + { + return Err(Failure::ContractMismatch); + } + let project_id = wire + .get("project_id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty() && id.len() <= 256) + .ok_or(Failure::ContractMismatch)?; + let reused = wire + .get("reused") + .and_then(Value::as_bool) + .ok_or(Failure::ContractMismatch)?; + Ok(AppsProvisioned { + installation_id: installation.as_str().to_owned(), + project_id: project_id.to_owned(), + reused, + }) +} + /** `local connect` connects the folder it runs in, the installation `local setup` was run in, to that folder's stack. @@ -1050,6 +1161,15 @@ impl Limits { } } + /// Composio provisioning: one request to Ops, which gives Composio ten seconds. Start waits on + /// it when an installation has no key yet, so it is bounded well under `status`. + pub fn for_apps() -> Self { + Self { + timeout: Duration::from_secs(60), + ..Self::for_action(Action::Status) + } + } + /// `stop` while OpenBot quits. Somebody is waiting for the app to close, so the stop gets two /// minutes rather than ten; a stack that has not stopped by then is reported, not waited on. pub fn for_quit() -> Self { @@ -1497,10 +1617,34 @@ pub fn list(cli: &PinnedCli, limits: Limits, cancel: &Cancel) -> Result Result { + // The CLI takes an absolute path in a folder that exists; OpenBot makes both. + let env_file = env_file + .is_absolute() + .then(|| env_file.to_str()) + .flatten() + .ok_or(Failure::Spawn)?; + let (code, bytes) = execute( + cli, + &apps_arguments(installation, env_file), + None, + limits, + cancel, + )?; + normalize_apps(installation, code, &bytes) +} + /// The exit code and stdout of one bounded CLI process. Arguments come only from `arguments`, /// `login_arguments`, `LIST_ARGUMENTS`, `setup_arguments`, `CONNECT_ARGUMENTS`, -/// `ACCOUNT_SIGN_IN_ARGUMENTS` and `TERMS_ARGUMENTS`; only setup and connect run somewhere other -/// than inherited. +/// `ACCOUNT_SIGN_IN_ARGUMENTS`, `TERMS_ARGUMENTS` and `apps_arguments`; only setup and connect +/// run somewhere other than inherited. fn execute( cli: &PinnedCli, arguments: &[&str], @@ -1929,6 +2073,20 @@ pub fn connect_installed( }) } +/// This installation's Composio project against the installed CLI. See `apps_arguments`. +pub fn provision_apps_installed( + attempt: &str, + installation: &str, + env_file: &std::path::Path, +) -> Result { + let installation = Installation::parse(installation)?; + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_apps(), |limits| { + let cli = installed()?; + provision_apps(&cli, &installation, env_file, limits, &attempt.0) + }) +} + /// `local login` against the installed CLI, within the limits of `status`: the CLI waits up to a /// minute for the dashboard before it opens anything. pub fn login_installed(attempt: &str, stack: &StackId) -> Result { @@ -3456,6 +3614,7 @@ mod tests { login_arguments(&stack).to_vec(), ACCOUNT_SIGN_IN_ARGUMENTS.to_vec(), TERMS_ARGUMENTS.to_vec(), + apps_arguments(&Installation::parse(APPS_ID).unwrap(), "/tmp/composio.env").to_vec(), ] { assert_eq!(refuse_rootless(&arguments, Some(&rootless)), Ok(())); } @@ -4121,6 +4280,126 @@ fn main() { } } + const APPS_ID: &str = "e1503aba-d1ba-4d21-ab5e-7b6cb109bfb6"; + + /// `integrations composio provision`'s line, as `commands/integrations.ts` prints it. + fn apps_report(fields: Value) -> String { + let mut report = json!({ + "type": "completed", + "installation_id": APPS_ID, + "project_id": "pr_fixture", + "environment_file_written": true, + "reused": false, + }); + for (name, value) in fields.as_object().unwrap() { + report[name] = value.clone(); + } + format!("{report}\n") + } + + /// Only an id OpenBot could have made reaches the command line, and the arguments are fixed. + #[test] + fn apps_arguments_are_fixed_and_take_only_a_lowercase_installation_id() { + let id = Installation::parse(APPS_ID).unwrap(); + assert_eq!( + apps_arguments(&id, "/home/person/OpenBot/.openbot-apps/composio.env"), + [ + "integrations", + "composio", + "provision", + "--installation-id", + APPS_ID, + "--env-path", + "/home/person/OpenBot/.openbot-apps/composio.env", + "--json" + ] + ); + for refused in [ + "", + "--json", + "E1503ABA-D1BA-4D21-AB5E-7B6CB109BFB6", + "e1503aba-d1ba-4d21-ab5e-7b6cb109bfb", + "e1503aba-d1ba-4d21-ab5e-7b6cb109bfb6 --x", + "e1503abad1ba-4d21-ab5e-7b6cb109bfb6-", + ] { + assert_eq!( + Installation::parse(refused), + Err(Failure::Spawn), + "{refused}" + ); + } + } + + /// Only a completed report for the installation asked about, with the file written, counts. + #[test] + fn an_apps_report_is_believed_only_for_this_installation() { + let id = Installation::parse(APPS_ID).unwrap(); + assert_eq!( + normalize_apps(&id, 0, apps_report(json!({ "reused": true })).as_bytes()), + Ok(AppsProvisioned { + installation_id: APPS_ID.into(), + project_id: "pr_fixture".into(), + reused: true, + }) + ); + // Every failure exits 1 with its sentence on stderr only. + assert_eq!(normalize_apps(&id, 1, b""), Err(Failure::UnexpectedExit)); + for wrong in [ + json!({ "type": "already_configured", "environment_file_written": false }), + json!({ "installation_id": "a1503aba-d1ba-4d21-ab5e-7b6cb109bfb6" }), + json!({ "environment_file_written": false }), + json!({ "project_id": "" }), + json!({ "reused": "yes" }), + ] { + assert_eq!( + normalize_apps(&id, 0, apps_report(wrong.clone()).as_bytes()), + Err(Failure::ContractMismatch), + "{wrong}" + ); + } + assert_eq!( + normalize_apps(&id, 0, b"Composio provisioning could not finish."), + Err(Failure::InvalidJson) + ); + } + + /// The real bounded process: fixed arguments in, the one line read back. + #[test] + fn provisioning_apps_runs_the_fixed_arguments_and_reads_only_the_report() { + let fixture = Fixture::new("local-eval-apps", &format!("0\n{}", apps_report(json!({})))); + let env_file = fixture.root.join("composio.env"); + let id = Installation::parse(APPS_ID).unwrap(); + assert_eq!( + provision_apps( + &fixture.cli(), + &id, + &env_file, + Limits::for_apps(), + &Cancel::default() + ) + .map(|provisioned| provisioned.reused), + Ok(false) + ); + assert_eq!( + fixture.argv().unwrap(), + format!( + "integrations\ncomposio\nprovision\n--installation-id\n{APPS_ID}\n--env-path\n{}\n--json", + env_file.display() + ) + ); + // A relative file is refused before anything runs. + assert_eq!( + provision_apps( + &fixture.cli(), + &id, + std::path::Path::new("composio.env"), + Limits::for_apps(), + &Cancel::default() + ), + Err(Failure::Spawn) + ); + } + /// #991: the sign-in page a person sees explains OpenBot's setup. The CLI carries that as /// `login --mode openbot`, once it has the flag; an older one would refuse it and sign nobody in. #[test] diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 69f57e4365..3fe2771e6a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3353,6 +3353,21 @@ fn persist_start_environment( openbot_desktop_lib::saved_intent::persist_configuration( root, &settings, &secrets, &purge, credential, )?; + /* + * This installation's Composio project, for an installation signed in to CopilotKit with no + * key yet: one signed in under 0.3.0, which never asked, or one whose sign-in-time attempt + * failed. Asks nobody when a key is supplied or already kept. Never fatal. + */ + if local_eval_state::read(root, None).is_some() { + static STARTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let attempt = format!( + "apps-start-{}", + STARTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + if let Err(problem) = connect_apps(root, &attempt) { + eprintln!("[apps] {}", problem.said); + } + } /* * The app broker's settings, read where the person keeps them and handed over at runtime only. * @@ -5293,6 +5308,37 @@ async fn local_eval_terms(attempt: String) -> Result Result<(), Problem> { + let root = stack::root_from(&root); + if !root.is_absolute() { + return Err(apps_unavailable()); + } + tauri::async_runtime::spawn_blocking(move || connect_apps(&root, &attempt)) + .await + .map_err(|_| apps_unavailable())? +} + +/// Ask CopilotKit for this installation's Composio project through the CLI, and keep its key. +/// See `composio::connect`. +fn connect_apps(root: &Path, attempt: &str) -> Result<(), Problem> { + openbot_desktop_lib::composio::connect(root, |installation, env_file| { + local_eval::provision_apps_installed(attempt, installation, env_file) + .map_err(|_| apps_unavailable()) + }) +} + +fn apps_unavailable() -> Problem { + openbot_desktop_lib::composio::setup_problem() +} + /** Run `setup` in `root` and record what it made. @@ -7165,6 +7211,7 @@ fn main() { set_up_local_eval, sign_in_to_copilotkit, local_eval_terms, + connect_copilotkit_apps, connect_local_eval, last_local_eval_stop_failure, quit_status, @@ -7409,6 +7456,7 @@ mod tests { use std::io::{Read, Write}; include!("stop_ipc_tests.rs"); + include!("composio_ipc_tests.rs"); // The release half (it starts the install thread) is the app's own launch; a test runs a debug build. #[cfg(debug_assertions)] diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index af561d9df1..136175ba31 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -333,6 +333,12 @@ function withLocalCli(local: Record) { // Signed in and agreed already, unless a test says otherwise: the account step passes. if (command === "local_eval_terms" && !("local_eval_terms" in local)) return ACCEPTED_TERMS; + // Apps connect once signed in, unless a test says otherwise. + if ( + command === "connect_copilotkit_apps" && + !("connect_copilotkit_apps" in local) + ) + return null; if (command in local) { const answer = local[command]; return typeof answer === "function" ? answer(args) : answer; @@ -609,6 +615,12 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as await view.findByRole("heading", { name: "Connect your AI" }); expect(stepLabel(view)).toBe("AI"); expect(view.queryByText(/needs your CopilotKit account/)).toBeNull(); + // Signed in, this installation's apps were connected before moving on. + expect( + invokeCalls + .filter((call) => call.command === "connect_copilotkit_apps") + .map((call) => call.args), + ).toEqual([{ attempt: expect.any(String), root: "/tmp/local-eval-account" }]); // Leaving the account step does not install a second time. expect( installationCalls().filter( @@ -625,6 +637,67 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as expectPlain(view); }); +const APPS_UNAVAILABLE = { + said: "OpenBot couldn't connect your apps. Check your connection, then choose Connect apps. If it keeps happening, ask an admin.", +}; + +test("apps that could not be connected at sign-in say so, and Connect apps tries again", async () => { + setupRootConfiguration("/tmp/local-eval-apps-retry", async () => + emptyConfiguration(), + ); + let attempts = 0; + withLocalCli({ + connect_copilotkit_apps: () => { + attempts += 1; + if (attempts === 1) throw APPS_UNAVAILABLE; + return null; + }, + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); + const view = await renderApp(); + await installLocal(view); + await view.findByText(APPS_UNAVAILABLE.said); + expect(stepLabel(view)).toBe("Account"); + expect(view.getByRole("heading", { name: "Connect your apps" })).toBeTruthy(); + expect( + view.getByText( + "You can continue without them. OpenBot tries again each time it starts.", + ), + ).toBeTruthy(); + // Intelligence waits for the account step, and nothing of the key reaches the window. + expect(localSequence()).toEqual([]); + await userEvent.click(view.getByRole("button", { name: "Connect apps" })); + await view.findByRole("heading", { name: "Connect your AI" }); + expect(attempts).toBe(2); + await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); + expectPlain(view); +}); + +test("Continue goes on without apps when they could not be connected", async () => { + setupRootConfiguration("/tmp/local-eval-apps-skip", async () => + emptyConfiguration(), + ); + withLocalCli({ + connect_copilotkit_apps: () => { + throw APPS_UNAVAILABLE; + }, + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); + const view = await renderApp(); + await installLocal(view); + await view.findByText(APPS_UNAVAILABLE.said); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await view.findByRole("heading", { name: "Connect your AI" }); + await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); + expect( + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ acceptedEulaVersion: null }); +}); + test("a first run already signed in with the agreement accepted passes the account step by itself", async () => { setupRootConfiguration("/tmp/local-eval-account-done", async () => emptyConfiguration(), diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index afcd927088..cbad793638 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -812,6 +812,7 @@ function Setup() { return (
{ if (acceptedVersion) local.acceptTerms(acceptedVersion); // Intelligence sets itself up once the install has Docker answering; see above. The diff --git a/desktop/src/CopilotKitAccount.tsx b/desktop/src/CopilotKitAccount.tsx index 4a62110565..d1b5a80c44 100644 --- a/desktop/src/CopilotKitAccount.tsx +++ b/desktop/src/CopilotKitAccount.tsx @@ -8,10 +8,12 @@ import { import { ExternalLink } from "./ExternalLink"; import { cancelLocalEval, + connectCopilotKitApps, type LocalTerms, localEvalTerms, signInToCopilotKit, } from "./localEvalCli"; +import { InlineFailure, type Problem } from "./Problem"; import { Sections } from "./Sections"; import { StatusLine } from "./SetupProgress"; @@ -20,6 +22,8 @@ type Seen = | { at: "signed-out" } | { at: "signing-in" } | { at: "agreement"; terms: Extract } + | { at: "apps"; version: string | null } + | { at: "apps-failed"; version: string | null; problem: Problem } | { at: "failed"; said: string }; const COULD_NOT_CHECK = "Couldn’t check your CopilotKit account. Try again."; @@ -35,11 +39,19 @@ const COULD_NOT_CHECK = "Couldn’t check your CopilotKit account. Try again."; * Accepting is the person's own act: they read the agreement at its address and click Accept here, * and that click is what setup later passes to the CLI as the version they accepted (a product * decision of 2026-10-22). Nothing accepts it without the click. + * + * Signed in, it connects this installation's apps before moving on: CopilotKit makes or replays + * the installation's Composio project for this account. That can only add apps. A failure says + * so, with Connect apps to try again and Continue to go on without them, and every Start tries + * again by itself. */ export function CopilotKitAccount({ + root, onDone, failure, }: { + /** The installation folder the apps are connected for. */ + root: string; /** Signed in and agreed: the version accepted here, or null when nothing needed accepting. */ onDone: (acceptedVersion: string | null) => void; /** A failure from setup itself, shown inside this card rather than under it. */ @@ -50,6 +62,8 @@ export function CopilotKitAccount({ const finished = useRef(false); const done = useRef(onDone); done.current = onDone; + const installation = useRef(root); + installation.current = root; const finish = useCallback((version: string | null) => { if (finished.current) return; @@ -57,6 +71,28 @@ export function CopilotKitAccount({ done.current(version); }, []); + /** Signed in and agreed: connect this installation's apps, then move on. */ + const connectApps = useCallback( + (version: string | null) => { + setSeen({ at: "apps", version }); + const started = connectCopilotKitApps(installation.current); + attempt.current = started.attempt; + started.connected.then( + () => { + if (attempt.current !== started.attempt) return; + attempt.current = null; + finish(version); + }, + (problem: Problem) => { + if (attempt.current !== started.attempt) return; + attempt.current = null; + setSeen({ at: "apps-failed", version, problem }); + }, + ); + }, + [finish], + ); + const check = useCallback(() => { setSeen({ at: "checking" }); const started = localEvalTerms(); @@ -73,9 +109,9 @@ export function CopilotKitAccount({ at: "failed", said: "The license agreement isn’t available right now. Try again later.", }); - // Accepted already, or none needed: nothing to do here. + // Accepted already, or none needed: nothing to accept here. else if (terms.status === "required" || terms.status === "not-required") - finish(null); + connectApps(null); // A status this window does not know is never taken as nothing to do. else setSeen({ at: "failed", said: COULD_NOT_CHECK }); }, @@ -85,7 +121,7 @@ export function CopilotKitAccount({ setSeen({ at: "failed", said: COULD_NOT_CHECK }); }, ); - }, [finish]); + }, [connectApps]); // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. useEffect(() => { @@ -115,20 +151,28 @@ export function CopilotKitAccount({ } const agreement = seen.at === "agreement" ? seen.terms : null; + // Signed in already: what is left to say is about the apps. + const appsFailed = seen.at === "apps-failed"; return (
{/* OpenBot is what is being set up, so its mark heads this step as it heads every other. CopilotKit is named where that is true: the account and the agreement's own title. */}

- {agreement ? "Accept the license agreement" : "Sign in to CopilotKit"} + {agreement + ? "Accept the license agreement" + : appsFailed + ? "Connect your apps" + : "Sign in to CopilotKit"}

{agreement ? `To run OpenBot on this computer, accept the CopilotKit Intelligence license agreement for ${agreement.organizationName}.` - : "OpenBot runs on this computer with your CopilotKit account. Sign in, then accept the license agreement."} + : appsFailed + ? "OpenBot uses your CopilotKit account to set up apps like Gmail and Slack." + : "OpenBot runs on this computer with your CopilotKit account. Sign in, then accept the license agreement."}

- {!agreement && ( + {!agreement && !appsFailed && (

Your conversations and files are stored on this computer. Signing in does not send them to CopilotKit. @@ -143,6 +187,16 @@ export function CopilotKitAccount({ Finish signing in to CopilotKit in your browser… )} + {seen.at === "apps" && Connecting your apps…} + {seen.at === "apps-failed" && ( + <> + +

+ You can continue without them. OpenBot tries again each time it + starts. +

+ + )} {agreement && (

Read the agreement ( @@ -161,10 +215,24 @@ export function CopilotKitAccount({ )} {agreement && ( - )} + {seen.at === "apps-failed" && ( + <> + + + + )} {seen.at === "failed" && (