diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index 162e8e0b40..6e1112a6b5 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -486,6 +486,54 @@ its own (contract request 8). */ pub const CONNECT_ARGUMENTS: [&str; 4] = ["local", "connect", "--approve-connection", "--json"]; +/// Where the CLI records which stack an app folder uses, relative to that folder. +pub const LOCAL_REFERENCE: &str = ".copilotkit/local.json"; + +/// A stack the CLI recorded for a folder that contains an installation, found by +/// `enclosing_reference`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EnclosingReference { + /// The folder the stack was set up for: an ancestor of the installation. + pub directory: PathBuf, + /// The stack it names, when the file names one this app can pass back to the CLI. + pub stack: Option, +} + +/** +The stack another folder's `local setup` left in charge of `installation`, if one is. + +`local setup` and `local connect` do not only look in the folder they run in: they look for +`.copilotkit/local.json` there and then in every folder above it, and take the first one they +find as this folder's own. So a stack set up earlier for a folder that contains the installation, +such as the home folder, is resumed by setup and connected by connect. Connect then writes that +folder's `.env`, never the installation's. + +`None` when the installation has a reference of its own (the CLI stops there), or no folder above +it has one. Only the reference file is read; nothing of the CLI's private configuration is. +*/ +pub fn enclosing_reference(installation: &std::path::Path) -> Option { + // The CLI resolves symbolic links before it looks, so this does too. + let start = std::fs::canonicalize(installation).unwrap_or_else(|_| installation.to_path_buf()); + if start.join(LOCAL_REFERENCE).exists() { + return None; + } + let directory = start + .ancestors() + .skip(1) + .find(|folder| folder.join(LOCAL_REFERENCE).exists())? + .to_path_buf(); + let stack = std::fs::read_to_string(directory.join(LOCAL_REFERENCE)) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|reference| { + reference + .get("stackId") + .and_then(Value::as_str) + .and_then(|id| StackId::parse(id).ok()) + }); + Some(EnclosingReference { directory, stack }) +} + /** `local login` signs the browser in to the stack's dashboard with no code to approve. @@ -4575,4 +4623,76 @@ fn main() { assert!(bundled_cli().is_none()); assert!(bundled_cli_root().is_some()); } + + /// A fresh folder with a canonical path, so the lookup's answer compares by value. + fn reference_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "openbot-local-reference-{name}-{}-{:016x}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::canonicalize(root).unwrap() + } + + fn write_reference(folder: &std::path::Path, stack: &str) { + std::fs::create_dir_all(folder.join(".copilotkit")).unwrap(); + std::fs::write( + folder.join(LOCAL_REFERENCE), + serde_json::json!({ "stackId": stack, "appPath": folder }).to_string(), + ) + .unwrap(); + } + + #[test] + fn a_stack_set_up_for_a_containing_folder_is_found() { + // Found trying OpenBot Desktop on a machine where the CLI had been tested in the home + // folder: the CLI took that stack as the installation's own. + let home = reference_root("enclosing"); + let installation = home.join("OpenBot"); + std::fs::create_dir_all(&installation).unwrap(); + write_reference(&home, "local-9e636d796eff89a28e631942"); + assert_eq!( + enclosing_reference(&installation), + Some(EnclosingReference { + directory: home.clone(), + stack: Some(StackId::parse("local-9e636d796eff89a28e631942").unwrap()), + }) + ); + // A folder that does not exist yet is looked up from where it would be. + assert_eq!( + enclosing_reference(&installation.join("not-yet")).map(|found| found.directory), + Some(home.clone()) + ); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn an_installation_with_its_own_stack_or_none_above_it_has_no_enclosing_one() { + let home = reference_root("own"); + let installation = home.join("OpenBot"); + std::fs::create_dir_all(&installation).unwrap(); + assert_eq!(enclosing_reference(&installation), None); + write_reference(&home, "local-abc123"); + write_reference(&installation, "local-def456"); + assert_eq!(enclosing_reference(&installation), None); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn an_unreadable_enclosing_reference_is_still_found_without_a_stack() { + let home = reference_root("unreadable"); + let installation = home.join("OpenBot"); + std::fs::create_dir_all(home.join(".copilotkit")).unwrap(); + std::fs::create_dir_all(&installation).unwrap(); + std::fs::write(home.join(LOCAL_REFERENCE), "{\"stackId\":\"--force\"}").unwrap(); + assert_eq!( + enclosing_reference(&installation), + Some(EnclosingReference { + directory: home.clone(), + stack: None + }) + ); + let _ = std::fs::remove_dir_all(home); + } } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 51da2118dc..10c90bdc53 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -5498,9 +5498,38 @@ fn apps_unavailable() -> Problem { openbot_desktop_lib::composio::setup_problem() } +/** +What the window says when a folder containing `root` already has a stack of the CLI's, which the +CLI would use as `root`'s own (see `local_eval::enclosing_reference`). Neither setup nor connect +runs then: setup would resume that stack and OpenBot would record it as one it made, and connect +would write that folder's settings, never the installation's. +*/ +fn enclosing_stack_problem(found: &local_eval::EnclosingReference) -> Problem { + let delete = match &found.stack { + Some(stack) => format!( + "copilotkit local delete --stack {0} --confirm-delete {0}", + stack.as_str() + ), + None => "copilotkit local delete".to_owned(), + }; + Problem::with( + format!( + "{} already has a back end that the CopilotKit CLI set up, and OpenBot's folder is inside it. OpenBot can't set up its own back end there. Delete that back end and its data with {delete}, then choose Retry.", + found.directory.display() + ), + found + .directory + .join(local_eval::LOCAL_REFERENCE) + .display() + .to_string(), + ) +} + /** Run `setup` in `root` and record what it made. +Not when a folder containing `root` already has a stack: see `enclosing_stack_problem`. + A stack set up here was made for OpenBot, so it starts and stops with it. The one exception is a record that already calls the same stack shared: setting up again resumes that stack, it does not make OpenBot its owner. @@ -5509,6 +5538,9 @@ fn set_up_local_eval_at(root: &Path, setup: S) -> Result Result<(local_eval::Report, String), local_eval::Failure>, { + if let Some(found) = local_eval::enclosing_reference(root) { + return Err(SetupError::Unrecorded(enclosing_stack_problem(&found))); + } let (report, cli_sha256) = setup(root).map_err(SetupError::Failure)?; let Some(stack) = report .stack @@ -5592,6 +5624,9 @@ where let unconnected = |said: &str| ConnectError::Unconnected(Problem::plain(said)); let recorded = local_eval_state::read(root, None) .ok_or_else(|| unconnected("Set up the back end before connecting OpenBot to it."))?; + if let Some(found) = local_eval::enclosing_reference(root) { + return Err(ConnectError::Unconnected(enclosing_stack_problem(&found))); + } let connection = connect(root).map_err(ConnectError::Failure)?; if connection.outcome != local_eval::Outcome::Completed || connection.conflicted { return Ok(connection); @@ -11555,6 +11590,85 @@ mod tests { let _ = std::fs::remove_dir_all(parent); } + /// A home folder where the CLI was tested before OpenBot Desktop was installed into it: the + /// CLI's reference for that earlier stack sits above the installation. + fn local_eval_under_an_earlier_stack( + label: &str, + ownership: Option, + ) -> (PathBuf, PathBuf) { + let home = temp_root(label); + std::fs::create_dir_all(home.join(".copilotkit")).unwrap(); + std::fs::write( + home.join(local_eval::LOCAL_REFERENCE), + serde_json::json!({ "stackId": "local-9e636d796eff89a28e631942", "appPath": home }) + .to_string(), + ) + .unwrap(); + let root = home.join("OpenBot"); + std::fs::create_dir_all(&root).unwrap(); + if let Some(ownership) = ownership { + let stack = local_eval::StackId::parse("local-9e636d796eff89a28e631942").unwrap(); + local_eval_state::begin(&root, &stack, ownership, LOCAL_EVAL_PIN).unwrap(); + } + (home, root) + } + + #[test] + fn setup_under_a_folder_with_its_own_stack_is_refused_before_the_cli_runs() { + let (home, root) = + local_eval_under_an_earlier_stack("openbot-local-eval-setup-enclosed", None); + let error = set_up_local_eval_at(&root, |_| { + panic!("the CLI would resume the home folder's stack as OpenBot's") + }) + .expect_err("refused"); + let SetupError::Unrecorded(problem) = error else { + panic!("{error:?}"); + }; + let home_shown = std::fs::canonicalize(&home).unwrap().display().to_string(); + assert!(problem.said.starts_with(&home_shown), "{problem:?}"); + assert!( + problem.said.contains( + "copilotkit local delete --stack local-9e636d796eff89a28e631942 --confirm-delete local-9e636d796eff89a28e631942" + ), + "{problem:?}" + ); + assert!(!problem.said.contains("Intelligence"), "{problem:?}"); + // Nothing recorded, so OpenBot never calls that stack its own. + assert_eq!(local_eval_state::read(&root, None), None); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn connect_under_a_folder_with_its_own_stack_is_refused_before_the_cli_runs() { + let (home, root) = local_eval_under_an_earlier_stack( + "openbot-local-eval-connect-enclosed", + Some(local_eval_state::Ownership::Created), + ); + let vault = FakeVault::default(); + let error = connect_local_eval_at( + &root, + |_| -> Result { + panic!("the CLI would write the home folder's .env") + }, + |_, name, value| { + vault.borrow_mut().insert(name.into(), value.into()); + Ok(()) + }, + |_, name| Ok(vault.borrow().get(name).cloned()), + ) + .expect_err("refused"); + let ConnectError::Unconnected(problem) = error else { + panic!("{error:?}"); + }; + assert!( + problem.said.contains("OpenBot's folder is inside it"), + "{problem:?}" + ); + assert!(vault.borrow().is_empty()); + assert!(!local_eval_state::read(&root, None).unwrap().connected); + let _ = std::fs::remove_dir_all(home); + } + const LOCAL_KEY: &str = "cpk-synthetic-local-key"; /// What `local connect` leaves in an installation folder, after a line OpenBot wrote earlier.