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" && (