From 997f04ae884ad7bbf48bf4a3595cd598aef4f2ac Mon Sep 17 00:00:00 2001 From: Dusty Date: Tue, 27 Oct 2026 16:38:11 -0700 Subject: [PATCH 1/2] desktop: run local Intelligence on OpenBot's engine, on a rootful Podman machine Which engine OpenBot uses is main's (#831): Docker if it answers, Docker Desktop if it has run before, otherwise Podman, saved with the installation. This commit no longer changes that choice. Local Intelligence's k3d needs rootful Podman (CopilotKit/Intelligence#2147), so the machine OpenBot creates is `--rootful`, and OpenBot addresses it through its `-root` connection, the rootful engine, both for its own machine and for a running machine it adopts. On a rootful Windows machine the host-gateway setting goes to the system configuration. An existing rootless machine is never switched; local Intelligence setup and start decline there instead, with a `rootless-podman` failure whose row names `podman machine set --rootful`. Every Intelligence CLI process gets COPILOTKIT_LOCAL_ENGINE for the engine OpenBot runs on, recorded when the engine is ready (also where Docker answers right after the install step) and when Start pins it; before either, the installation's saved engine. For a Podman machine it also gets CONTAINER_HOST and DOCKER_HOST naming the machine's forwarded API, drops an inherited DOCKER_CONTEXT, and has Podman's directory on PATH. While Podman installs, the step row names the password prompt macOS or Linux raises. The window's runtime row no longer says Docker Desktop. --- desktop/src-tauri/src/acquire.rs | 396 ++++++++++++++++++++++++++-- desktop/src-tauri/src/engine.rs | 45 +++- desktop/src-tauri/src/install.rs | 29 +- desktop/src-tauri/src/local_eval.rs | 349 ++++++++++++++++++++++-- desktop/src-tauri/src/main.rs | 46 +++- desktop/src/localEvalCli.ts | 1 + desktop/src/localReadiness.test.ts | 10 +- desktop/src/localReadiness.ts | 19 +- 8 files changed, 834 insertions(+), 61 deletions(-) diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index 9bcbb94b4c..4dbef0bc97 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -9,6 +9,14 @@ //! needs elevation. So the two halves run in different contexts, and the elevated half is the only //! part that may be handed to a helper. See `windows.rs`. Fetching and installing Podman itself is //! `install.rs`. +//! +//! **The machine is rootful.** Local Intelligence runs a k3d cluster, and the Intelligence CLI +//! accepts Podman only in rootful mode, because k3d's node needs privileges a rootless engine +//! cannot grant. A rootful machine is the same VM with three things changed: its default +//! connection is `-root`, `/var/run/docker.sock` inside it is the rootful socket, and the API +//! socket it forwards to this computer is the rootful one. OpenBot's own containers are addressed +//! through `-root` as well (see `connection_for`), so OpenBot and Intelligence share one +//! engine and the supervisor's mount of `/var/run/docker.sock` is the engine it was started by. use crate::quiet::said as command_said; use std::net::Ipv4Addr; @@ -80,7 +88,94 @@ impl StepOutcome { pub const MACHINE: &str = "openbot"; const USER_MODE_NETWORKING_FLAG: &str = "--user-mode-networking=true"; +const ROOTFUL_FLAG: &str = "--rootful"; const HOST_GATEWAY_CONFIG: &str = ".config/containers/containers.conf.d/90-openbot-host.conf"; +/// Where a rootful engine reads the same setting. Root's own `~/.config` is not read by rootful +/// Podman, and the API service a rootful machine runs is the system one, not a user unit. +const ROOTFUL_HOST_GATEWAY_CONFIG: &str = "/etc/containers/containers.conf.d/90-openbot-host.conf"; + +/// The connection that reaches a machine's engine in the mode the machine runs in. +/// +/// `podman machine init` makes two connections for every machine, whatever its mode: ``, +/// which logs in as the machine's user and reaches the rootless engine, and `-root`, which +/// reaches the rootful one. The machine's own mode decides only which of the two is the default, +/// which socket `/var/run/docker.sock` is inside it, and which API it forwards to this computer. +/// Addressing a rootful machine by its bare name would put OpenBot's containers in the rootless +/// engine beside a rootful one, with the supervisor mounting a socket it cannot open. +pub fn connection_for(machine: &str, rootful: bool) -> String { + if rootful { + format!("{machine}-root") + } else { + machine.to_string() + } +} + +/// The machine a connection belongs to, by the naming `connection_for` describes. +fn machine_of(connection: &str) -> &str { + connection.strip_suffix("-root").unwrap_or(connection) +} + +/// What `podman machine inspect` says about one machine that matters here. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct MachineFacts { + /// `None` when inspect did not say, which no supported Podman does. + pub rootful: Option, + /// The engine API this machine forwards to this computer: a Unix socket on macOS, a named pipe + /// on Windows, as a `unix://` or `npipe://` address. + pub api: Option, +} + +fn machine_facts(listing: &str, machine: &str) -> Result { + let machines: Vec = serde_json::from_str(listing) + .map_err(|error| format!("could not read podman machine inspect JSON: {error}"))?; + let found = machines + .iter() + .find(|entry| entry.get("Name").and_then(Value::as_str) == Some(machine)) + .ok_or_else(|| format!("podman machine inspect did not return {machine}"))?; + let path = |key: &str| { + found + .pointer(&format!("/ConnectionInfo/{key}/Path")) + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + }; + // The pipe first: Windows reports a pipe, and a socket path beside it is not one this + // computer can open. + let api = path("PodmanPipe") + .map(pipe_address) + .or_else(|| path("PodmanSocket").map(|socket| format!("unix://{socket}"))); + Ok(MachineFacts { + rootful: found.get("Rootful").and_then(Value::as_bool), + api, + }) +} + +/// `\\.\pipe\podman-openbot` as the `npipe:////./pipe/podman-openbot` that Docker clients, k3d and +/// the Intelligence CLI read. +fn pipe_address(pipe: &str) -> String { + format!("npipe://{}", pipe.replace('\\', "/")) +} + +/// What the machine behind a Podman connection is, or why that could not be found. +/// +/// A connection that is not a machine's (a remote host somebody added) answers with an error, and +/// the caller then leaves the engine to find its own way. +pub fn machine_behind(connection: &str) -> Result { + let machine = machine_of(connection); + machine_facts(&podman(&["machine", "inspect", machine])?, machine) +} + +/// The connection for a machine found by name, in the mode it runs in. +/// +/// A machine that cannot be inspected keeps its bare name, which is how it was addressed before +/// machines were rootful. +pub fn connection_of_machine(machine: &str) -> String { + let rootful = podman(&["machine", "inspect", machine]) + .and_then(|listing| machine_facts(&listing, machine)) + .ok() + .and_then(|facts| facts.rootful) + .unwrap_or(false); + connection_for(machine, rootful) +} fn podman(args: &[&str]) -> Result { podman_with(args, || { @@ -148,6 +243,17 @@ pub fn machine_exists() -> Result { /// No `--provider`: `applehv` has been the default on Apple silicon since Podman 6.1, and pinning it /// asks for what you already get. The libkrun bind-mount trouble that the pin was written for /// belonged to 5.7, where libkrun was the default. +/// +/// `--rootful`, for local Intelligence: see the module comment. +/// +/// AN EXISTING MACHINE IS NEVER SWITCHED TO ROOTFUL, OURS INCLUDED. `podman machine set --rootful` +/// would do it, on a stopped machine, and it would cost the person their OpenBot. A rootless +/// machine keeps its containers and volumes in the rootless engine's storage, and the rootful +/// engine in the same VM does not see them, so OpenBot would come back on an empty database while +/// the old one sat unreachable beside it. Releases up to 0.2.0 created this machine rootless, so +/// "ours" is not "empty". A rootless machine is left as it is and OpenBot keeps running on it; +/// local Intelligence then declines to start there with a sentence that names the switch, which +/// is the person's to make (see `local_eval::Failure::RootlessPodman`). pub fn create_machine(cpus: u32, memory_mib: u32, disk_gib: u32) -> StepOutcome { create_machine_with(cpus, memory_mib, disk_gib, machine_exists, podman) } @@ -190,6 +296,7 @@ fn create_machine_args( memory_mib.to_string(), "--disk-size".to_string(), disk_gib.to_string(), + ROOTFUL_FLAG.to_string(), ]; if target_is_windows { args.push(USER_MODE_NETWORKING_FLAG.to_string()); @@ -201,14 +308,19 @@ fn create_machine_args( struct MachineNetworking { state: String, user_mode: bool, + /// Decides where the host-gateway setting is written. Absent reads as rootless, which is what + /// every machine was before this app created them rootful. + rootful: bool, } +/// Answers with the machine's networking on Windows, where the gateway is configured after start, +/// and with nothing elsewhere. fn prepare_user_mode_networking_before_start( run: &mut impl FnMut(&[&str]) -> Result, target_is_windows: bool, -) -> Result<(), String> { +) -> Result, String> { if !target_is_windows { - return Ok(()); + return Ok(None); } let listing = run(&["machine", "inspect", MACHINE])?; @@ -221,7 +333,7 @@ fn prepare_user_mode_networking_before_start( if networking.state.eq_ignore_ascii_case("stopped") && !networking.user_mode { run(&["machine", "set", USER_MODE_NETWORKING_FLAG, MACHINE])?; } - Ok(()) + Ok(Some(networking)) } fn owned_machine_networking(listing: &str) -> Result { @@ -242,12 +354,21 @@ fn owned_machine_networking(listing: &str) -> Result .ok_or_else(|| { format!("podman machine inspect did not include {MACHINE} user-mode networking") })?; - Ok(MachineNetworking { state, user_mode }) + let rootful = machine + .get("Rootful") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(MachineNetworking { + state, + user_mode, + rootful, + }) } fn configure_host_gateway_after_start( run: &mut impl FnMut(&[&str]) -> Result, target_is_windows: bool, + rootful: bool, ) -> Result<(), String> { if !target_is_windows { return Ok(()); @@ -262,7 +383,11 @@ fn configure_host_gateway_after_start( "host.containers.internal", ])?; let ip = first_valid_host_gateway_ip(&resolved)?; - let script = host_gateway_config_script(ip); + let script = if rootful { + rootful_host_gateway_config_script(ip) + } else { + host_gateway_config_script(ip) + }; run(&["machine", "ssh", MACHINE, &script]).map(|_| ()) } @@ -281,7 +406,7 @@ fn configure_owned_windows_podman_for_compose( "{MACHINE} is running without Podman user-mode networking. Stop the OpenBot engine machine and start OpenBot again so host callbacks can be configured." )); } - configure_host_gateway_after_start(&mut run, true) + configure_host_gateway_after_start(&mut run, true, networking.rootful) } /// Run before Compose even when an existing engine skipped the install/start steps. @@ -292,7 +417,10 @@ pub fn prepare_for_compose(address: &Address) -> Result<(), String> { fn owned_windows_podman_address(address: &Address, target_is_windows: bool) -> bool { target_is_windows && address.engine == Engine::Podman - && address.connection.as_deref() == Some(MACHINE) + && address + .connection + .as_deref() + .is_some_and(|connection| machine_of(connection) == MACHINE) } fn first_valid_host_gateway_ip(raw: &str) -> Result { @@ -331,13 +459,36 @@ fn host_gateway_config_script(ip: Ipv4Addr) -> String { ) } +/// The same setting for a rootful machine: in the system's configuration, and the system's API +/// service restarted. +/// +/// Through `sudo`, which is a no-op for root and passwordless for the machine's own user, so the +/// script does not depend on which of the two `podman machine ssh` logs in as. +fn rootful_host_gateway_config_script(ip: Ipv4Addr) -> String { + format!( + "set -e; \ + target=\"{ROOTFUL_HOST_GATEWAY_CONFIG}\"; \ + tmp=\"$(mktemp)\"; \ + cat > \"$tmp\" <<'EOF'\n[containers]\nhost_containers_internal_ip=\"{ip}\"\nEOF\n\ + if sudo test -f \"$target\" && sudo cmp -s \"$tmp\" \"$target\"; then \ + rm \"$tmp\"; \ + else \ + sudo mkdir -p \"$(dirname \"$target\")\"; \ + sudo install -m 0644 \"$tmp\" \"$target\"; \ + rm \"$tmp\"; \ + sudo systemctl try-restart podman.service; \ + fi" + ) +} + fn start_machine_with( mut run: impl FnMut(&[&str]) -> Result, target_is_windows: bool, ) -> StepOutcome { - if let Err(error) = prepare_user_mode_networking_before_start(&mut run, target_is_windows) { - return StepOutcome::stopped(Step::StartMachine, &error); - } + let rootful = match prepare_user_mode_networking_before_start(&mut run, target_is_windows) { + Ok(networking) => networking.is_some_and(|networking| networking.rootful), + Err(error) => return StepOutcome::stopped(Step::StartMachine, &error), + }; let started = match run(&["machine", "start", MACHINE]) { Ok(_) => format!("{MACHINE} started."), Err(error) if error.contains("already running") => { @@ -345,7 +496,7 @@ fn start_machine_with( } Err(error) => return StepOutcome::stopped(Step::StartMachine, &error), }; - if let Err(error) = configure_host_gateway_after_start(&mut run, target_is_windows) { + if let Err(error) = configure_host_gateway_after_start(&mut run, target_is_windows, rootful) { return StepOutcome::stopped(Step::StartMachine, &error); } StepOutcome::went(Step::StartMachine, started) @@ -380,9 +531,21 @@ fn explain_machine_error(error: &str) -> String { /// How to reach the machine this app just started. /// /// Always by name. The default connection belongs to whoever set it, and after `machine init` it is -/// usually still pointing somewhere else. +/// usually still pointing somewhere else. The rootful connection for the rootful machine this app +/// creates; the bare name for one an earlier release created rootless (see `create_machine`). pub fn address() -> Address { - Address::new(Engine::Podman, Some(MACHINE.to_string())) + address_with(|| podman(&["machine", "inspect", MACHINE])) +} + +fn address_with(inspect: impl FnOnce() -> Result) -> Address { + // Unknown reads as rootful: that is how this app creates the machine, and a machine that cannot + // be inspected is not going to answer on either connection. + let rootful = inspect() + .and_then(|listing| machine_facts(&listing, MACHINE)) + .ok() + .and_then(|facts| facts.rootful) + .unwrap_or(true); + Address::new(Engine::Podman, Some(connection_for(MACHINE, rootful))) } /// The gate before Compose is touched. @@ -679,11 +842,129 @@ mod tests { "8192", "--disk-size", "64", + "--rootful", "--user-mode-networking=true" ] ); } + /// Local Intelligence's k3d needs a rootful engine, on macOS as on Windows. + #[test] + fn the_machine_is_created_rootful_on_every_platform() { + for windows in [false, true] { + let args = create_machine_args(4, 6144, 60, windows); + assert!(args.iter().any(|arg| arg == "--rootful"), "{args:?}"); + assert_eq!(&args[..3], ["machine", "init", "openbot"]); + } + assert_eq!( + create_machine_args(4, 6144, 60, false), + [ + "machine", + "init", + "openbot", + "--cpus", + "4", + "--memory", + "6144", + "--disk-size", + "60", + "--rootful" + ] + ); + } + + /// Nothing switches a machine that already exists, ours included: its OpenBot lives in the + /// rootless engine's storage. Creating is skipped, and nothing else is run. + #[test] + fn an_existing_rootless_machine_is_not_switched_to_rootful() { + let listing = r#"[{"Name":"openbot","Default":true,"Running":false}]"#; + let result = create_machine_with( + 4, + 6144, + 60, + || machine_exists_with(|| Ok(listing.into())), + |args| panic!("nothing runs against an existing machine: {args:?}"), + ); + assert!(result.ok, "{result:?}"); + + let mut calls = Vec::>::new(); + let inspect = serde_json::json!([{ + "Name": "openbot", + "State": "stopped", + "UserModeNetworking": true, + "Rootful": false + }]) + .to_string(); + let started = start_machine_with( + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "start", "openbot"] => Ok(String::new()), + ["machine", "ssh", "openbot", "getent", ..] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", _] => Ok(String::new()), + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ); + assert!(started.ok, "{started:?}"); + assert!( + !calls + .iter() + .any(|call| call.iter().any(|arg| arg == "--rootful")), + "{calls:?}" + ); + // Its gateway goes where its rootless engine reads it. + assert!(calls + .last() + .unwrap() + .last() + .unwrap() + .contains(HOST_GATEWAY_CONFIG)); + } + + #[test] + fn a_rootful_windows_machine_gets_the_gateway_in_the_system_configuration() { + let mut calls = Vec::>::new(); + let inspect = serde_json::json!([{ + "Name": "openbot", + "State": "stopped", + "UserModeNetworking": true, + "Rootful": true + }]) + .to_string(); + let started = start_machine_with( + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "start", "openbot"] => Ok(String::new()), + ["machine", "ssh", "openbot", "getent", ..] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", _] => Ok(String::new()), + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ); + assert!(started.ok, "{started:?}"); + let script = calls.last().unwrap().last().unwrap().clone(); + assert_eq!( + script, + rootful_host_gateway_config_script("192.168.127.254".parse().unwrap()) + ); + assert!(script.contains(ROOTFUL_HOST_GATEWAY_CONFIG), "{script}"); + assert!( + script.contains("sudo systemctl try-restart podman.service"), + "{script}" + ); + assert!(!script.contains("--user"), "{script}"); + } + #[test] fn existing_stopped_windows_machine_is_configured_before_start() { let mut calls = Vec::>::new(); @@ -974,9 +1255,94 @@ mod tests { #[test] fn the_machine_this_app_starts_is_addressed_by_name_not_by_the_default_connection() { - let addressed = address(); - assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + let inspect = |rootful: bool| { + serde_json::json!([{ "Name": "openbot", "Rootful": rootful }]).to_string() + }; + // Rootful, as this app creates it: its root connection, where the rootful engine is. + let addressed = address_with(|| Ok(inspect(true))); + assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); assert_eq!(addressed.engine, Engine::Podman); + // Rootless, as releases up to 0.2.0 created it: its bare name, where its OpenBot is. + let addressed = address_with(|| Ok(inspect(false))); + assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + // Not inspectable: as this app creates it. + let addressed = address_with(|| Err("no such machine".into())); + assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); + } + + #[test] + fn a_connection_names_its_machine_and_its_mode() { + assert_eq!(connection_for("openbot", true), "openbot-root"); + assert_eq!(connection_for("openbot", false), "openbot"); + assert_eq!(machine_of("openbot-root"), "openbot"); + assert_eq!(machine_of("openbot"), "openbot"); + assert_eq!( + machine_of("podman-machine-default-root"), + "podman-machine-default" + ); + } + + #[test] + fn machine_facts_give_the_mode_and_the_api_this_computer_can_open() { + let mac = serde_json::json!([{ + "Name": "openbot", + "Rootful": true, + "ConnectionInfo": { + "PodmanSocket": { "Path": "/var/folders/xy/T/podman/openbot-api.sock" }, + "PodmanPipe": null + } + }]) + .to_string(); + assert_eq!( + machine_facts(&mac, "openbot").unwrap(), + MachineFacts { + rootful: Some(true), + api: Some("unix:///var/folders/xy/T/podman/openbot-api.sock".into()), + } + ); + let windows = serde_json::json!([{ + "Name": "openbot", + "Rootful": false, + "ConnectionInfo": { + "PodmanSocket": { "Path": "C:\\Users\\me\\podman.sock" }, + "PodmanPipe": { "Path": "\\\\.\\pipe\\podman-openbot" } + } + }]) + .to_string(); + assert_eq!( + machine_facts(&windows, "openbot").unwrap(), + MachineFacts { + rootful: Some(false), + api: Some("npipe:////./pipe/podman-openbot".into()), + } + ); + let bare = serde_json::json!([{ "Name": "openbot" }]).to_string(); + assert_eq!( + machine_facts(&bare, "openbot").unwrap(), + MachineFacts::default() + ); + assert!(machine_facts(&bare, "somebody-else").is_err()); + assert!(machine_facts("not json", "openbot").is_err()); + } + + #[test] + fn the_root_connection_of_our_windows_machine_is_still_ours() { + assert!(owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot-root".into())), + true + )); + assert!(owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot".into())), + true + )); + assert!(!owned_windows_podman_address( + &Address::new(Engine::Podman, Some("somebody-else-root".into())), + true + )); + assert!(!owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot-root".into())), + false + )); } #[test] diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index d94810a6ee..4e985b16c2 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -4,8 +4,9 @@ //! documentation, and the three differ more than they look: //! //! - **macOS.** `podman machine` puts a Linux VM behind a socket. Inside that VM -//! `/var/run/docker.sock` is already a symlink to the rootless socket, so Compose's mount needs no -//! help and `ENGINE_SOCKET` stays unset. `applehv` is the default on Apple silicon as of Podman +//! `/var/run/docker.sock` is already a symlink to the socket of the mode the machine runs in +//! (rootful for the machine OpenBot creates, see `acquire`), so Compose's mount needs no help and +//! `ENGINE_SOCKET` stays unset. `applehv` is the default on Apple silicon as of Podman //! 6.1, so no `--provider` is passed. //! - **Linux.** Podman is native and rootless and there is no VM. `/var/run/docker.sock` is either //! absent or, with `podman-docker` installed, a symlink to the *rootful* socket, which is not the @@ -279,6 +280,9 @@ pub struct EngineStatus { /// Somebody who already has a machine up is handed it rather than made to wait while a duplicate /// boots beside it. Ours is preferred among running machines only so that repeat launches settle on /// the same one. +/// +/// Answers with the connection for the machine's own mode (see `acquire::connection_for`): a rootful +/// machine is reached through `-root`, where its rootful engine is. fn running_machine(preferred: &str) -> Option { let output = tool(Engine::Podman) .args(["machine", "list", "--format", "json"]) @@ -292,7 +296,7 @@ fn running_machine(preferred: &str) -> Option { running() .find(|machine| machine.name == preferred) .or_else(|| running().next()) - .map(|machine| machine.name.clone()) + .map(|machine| crate::acquire::connection_of_machine(&machine.name)) } #[derive(Deserialize)] @@ -381,17 +385,33 @@ does not reach Docker Desktop's `/usr/local/bin`. Appended, so a `docker` the pe their PATH stays first; both the link and the directory it resolves to, as `command_at` does. */ pub fn path_with_docker() -> Option { - let binary = program(Engine::Docker)?; + path_with_engines(&[Engine::Docker]) +} + +/// `path_with_docker`, for each of `engines` that is here, or None when none is. +/// +/// The Intelligence CLI runs `podman` by name as it runs `docker`, and a Podman OpenBot installed +/// in this session is not on this process's PATH (see the PATH rule above). +pub fn path_with_engines(engines: &[Engine]) -> Option { + let binaries: Vec = engines + .iter() + .filter_map(|engine| program(*engine)) + .collect(); + if binaries.is_empty() { + return None; + } let mut entries: Vec = std::env::var_os("PATH") .map(|path| std::env::split_paths(&path).collect()) .unwrap_or_default(); - let resolved = std::fs::canonicalize(&binary).ok(); - for directory in [binary.parent(), resolved.as_deref().and_then(Path::parent)] - .into_iter() - .flatten() - { - if !entries.iter().any(|entry| entry == directory) { - entries.push(directory.to_path_buf()); + for binary in &binaries { + let resolved = std::fs::canonicalize(binary).ok(); + for directory in [binary.parent(), resolved.as_deref().and_then(Path::parent)] + .into_iter() + .flatten() + { + if !entries.iter().any(|entry| entry == directory) { + entries.push(directory.to_path_buf()); + } } } std::env::join_paths(entries).ok() @@ -529,7 +549,8 @@ pub fn rootless_socket() -> Option { #[cfg(not(target_os = "linux"))] pub fn rootless_socket() -> Option { // macOS and Windows run the engine in a virtual machine, and inside it `/var/run/docker.sock` - // is already the rootless socket. Compose mounts that path, so there is nothing to override. + // is already the socket of the machine's own mode. Compose mounts that path, so there is + // nothing to override. None } diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 1ad052112f..596789fec5 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -397,17 +397,34 @@ pub fn install_engine_observed( installed: impl FnMut(Engine, bool), progress: &mut dyn FnMut(String), ) -> Result { + // Only one of the two runs, so they share the step row's lines one at a time. + let progress = std::cell::RefCell::new(progress); install_engine_with( cache, Found::here(saved), saved, installed, - || crate::docker_desktop::start(progress), - install_podman, + || crate::docker_desktop::start(&mut **progress.borrow_mut()), + |into| { + (progress.borrow_mut())(podman_install_said(std::env::consts::OS).into()); + install_podman(into) + }, place_compose, ) } +/// The step row's line while Podman installs, naming the prompt the platform raises for it. +/// +/// macOS asks for the person's password, because the package writes to `/opt/podman`. Windows +/// asks for nothing here: the MSI installs for this account alone. Linux asks through `pkexec`. +fn podman_install_said(os: &str) -> &'static str { + match os { + "macos" => "Installing Podman. Enter your Mac password when macOS asks for it.", + "linux" => "Installing Podman. Enter your password when your system asks for it.", + _ => "Installing Podman.", + } +} + /// What engines this machine has, read once so the choice below can be tested on any machine. /// /// Nothing here asks what product is behind a `docker` command. A Docker that answers is used, a @@ -1719,4 +1736,12 @@ esac assert!(quoted.contains("Application Support"), "{quoted}"); assert!(!quoted.contains("\\\""), "{quoted}"); } + + /// The prompt is named where there is one, and none is promised where there is not. + #[test] + fn the_podman_install_line_names_the_prompt_each_platform_raises() { + assert!(podman_install_said("macos").contains("password")); + assert!(podman_install_said("linux").contains("password")); + assert_eq!(podman_install_said("windows"), "Installing Podman."); + } } diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index a5ba1337ad..98bc48728f 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -108,6 +108,11 @@ pub enum Failure { UnexpectedExit, /// An accepted agreement version that is not one `terms` could have reported. InvalidAcceptance, + /// OpenBot runs on a rootless Podman machine, and local Intelligence needs rootful Podman. + /// Refused here, before `setup` or `start`, so the window can name the switch; the CLI would + /// refuse it too, as an engine it does not support. See `acquire::create_machine` for why the + /// switch is the person's to make. + RootlessPodman, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] @@ -1178,6 +1183,160 @@ const INHERITED: &[&str] = &[ "TMP", ]; +/* + * ONE ENGINE FOR OPENBOT AND INTELLIGENCE. + * + * Left to itself the CLI picks Docker when Docker answers and Podman otherwise. That is not always + * the engine OpenBot is running on: Docker Desktop started after OpenBot settled on Podman, or a + * Podman machine of the person's own that is running beside ours. Two engines on one computer is + * two VMs' worth of memory, and an Intelligence that OpenBot's containers reach through a host + * port of a different VM. So every CLI process is told the engine OpenBot uses, by the variable + * the CLI reads for exactly this (`COPILOTKIT_LOCAL_ENGINE`), and for Podman the API of the + * machine OpenBot uses: the CLI would otherwise take the first running machine, and it looks + * `podman` up on a PATH that, in the session OpenBot installed Podman, does not have it. + * + * Recorded by the app when it knows its engine (`use_engine`), and read for every call, because + * the machine's mode can change between calls and asking costs one `podman machine inspect`. + */ +static ENGINE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Run the CLI on the engine OpenBot runs on. +pub fn use_engine(address: &crate::engine::Address) { + *ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(address.clone()); +} + +/// Whether the app has said which engine OpenBot runs on. +pub fn engine_known() -> bool { + ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some() +} + +/// What the CLI is told about its engine. Addresses and a name; nothing here is a secret. +#[derive(Clone, Debug, PartialEq, Eq)] +struct CliEngine { + engine: crate::engine::Engine, + /// The Podman API OpenBot's machine forwards to this computer, when it could be found. + api: Option, + /// A Podman machine running rootless, which local Intelligence does not run on. + rootless: bool, +} + +impl CliEngine { + fn of(address: &crate::engine::Address) -> Self { + use crate::engine::Engine; + let named_only = || Self { + engine: address.engine, + api: None, + rootless: false, + }; + // Linux runs OpenBot's containers without local Intelligence for now, and its Podman has + // no machine: the CLI finds the rootful socket itself, or declines a rootless one. + if address.engine == Engine::Docker || cfg!(target_os = "linux") { + return named_only(); + } + let connection = address + .connection + .clone() + .or_else(|| address.pin().ok().and_then(|pinned| pinned.connection)); + match connection.map(|connection| crate::acquire::machine_behind(&connection)) { + Some(Ok(facts)) => Self { + engine: Engine::Podman, + api: facts.api, + rootless: facts.rootful == Some(false), + }, + // Not a machine this computer can inspect: the CLI finds its own way, and judges it. + _ => named_only(), + } + } +} + +fn recorded_engine() -> Option { + let address = ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone()?; + Some(CliEngine::of(&address)) +} + +/// The variables that name the engine, and those the CLI must not inherit beside them. +/// +/// For Podman, `CONTAINER_HOST` is what the CLI and the Podman CLI read and `DOCKER_HOST` is what +/// k3d reads, so both name the machine's API. An inherited `DOCKER_HOST` or `DOCKER_CONTEXT` names +/// a Docker, and is dropped rather than handed to k3d beside a Podman. For Docker nothing else is +/// set: the CLI finds Docker as Docker's own CLI does, through what it inherits. +fn engine_environment(engine: Option<&CliEngine>) -> Vec<(&'static str, Option)> { + use crate::engine::Engine; + let Some(engine) = engine else { + return Vec::new(); + }; + let mut environment = vec![( + "COPILOTKIT_LOCAL_ENGINE", + Some(engine.engine.binary().to_string()), + )]; + if engine.engine == Engine::Podman { + environment.push(("CONTAINER_HOST", engine.api.clone())); + environment.push(("DOCKER_HOST", engine.api.clone())); + environment.push(("DOCKER_CONTEXT", None)); + } + environment +} + +/// Decline the calls that create or start Intelligence's cluster on a rootless Podman machine. +/// Everything else runs: `status`, `stop` and the account calls work on any engine. +fn refuse_rootless(arguments: &[&str], engine: Option<&CliEngine>) -> Result<(), Failure> { + let runs_workloads = matches!(arguments, ["local", "setup", ..] | ["local", "start", ..]); + if runs_workloads && engine.is_some_and(|engine| engine.rootless) { + return Err(Failure::RootlessPodman); + } + Ok(()) +} + +/// The CLI process for one call: its program, its environment and its folder, not yet started. +fn command_for( + cli: &PinnedCli, + arguments: &[&str], + folder: Option<&std::path::Path>, + engine: Option<&CliEngine>, +) -> std::process::Command { + let mut command = crate::quiet::command(&cli.program); + if let Some(entry) = &cli.entry { + command.arg(entry); + } + command.env_clear(); + for name in INHERITED { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + // The CLI looks `docker` and `podman` up by name. Opened from Finder, this app has the system's + // minimal PATH, which reaches neither Docker Desktop's `/usr/local/bin` nor `/opt/podman/bin`; + // the CLI gets the engine's own directory added. + let engines: &[crate::engine::Engine] = match engine.map(|engine| engine.engine) { + Some(crate::engine::Engine::Podman) => { + &[crate::engine::Engine::Docker, crate::engine::Engine::Podman] + } + _ => &[crate::engine::Engine::Docker], + }; + if let Some(path) = crate::engine::path_with_engines(engines) { + command.env("PATH", path); + } + for (name, value) in engine_environment(engine) { + match value { + Some(value) => command.env(name, value), + None => command.env_remove(name), + }; + } + if let Some(folder) = folder { + command.current_dir(folder); + } + command.args(arguments); + command +} + /** Run one CLI call to completion, within its limits. @@ -1271,28 +1430,11 @@ fn execute( limits: Limits, cancel: &Cancel, ) -> Result<(i32, Vec), Failure> { + let engine = recorded_engine(); + refuse_rootless(arguments, engine.as_ref())?; let output = Output::create()?; - let mut command = crate::quiet::command(&cli.program); - if let Some(entry) = &cli.entry { - command.arg(entry); - } - command.env_clear(); - for name in INHERITED { - if let Some(value) = std::env::var_os(name) { - command.env(name, value); - } - } - // The CLI looks `docker` up by name. Opened from Finder, this app has the system's minimal - // PATH, which does not reach Docker Desktop's `/usr/local/bin`; the CLI gets Docker's own - // directory added. - if let Some(path) = crate::engine::path_with_docker() { - command.env("PATH", path); - } - if let Some(folder) = folder { - command.current_dir(folder); - } + let mut command = command_for(cli, arguments, folder, engine.as_ref()); command - .args(arguments) .stdin(Stdio::null()) .stdout(output.file.try_clone().map_err(|_| Failure::Spawn)?) // stderr can carry anything a dependency prints. It is never read, so it is not kept. @@ -3043,6 +3185,169 @@ mod tests { assert!(status(&input).is_ok()); } + fn engine_variables( + engine: Option<&CliEngine>, + ) -> std::collections::BTreeMap> { + let cli = PinnedCli { + program: PathBuf::from("copilotkit"), + entry: None, + sha256: String::new(), + }; + command_for(&cli, &["local", "status", "--json"], None, engine) + .get_envs() + .map(|(name, value)| { + ( + name.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect() + } + + const ENGINE_VARIABLES: [&str; 4] = [ + "COPILOTKIT_LOCAL_ENGINE", + "CONTAINER_HOST", + "DOCKER_HOST", + "DOCKER_CONTEXT", + ]; + + #[test] + fn the_cli_is_told_the_engine_openbot_runs_on() { + use crate::engine::Engine; + let docker = CliEngine { + engine: Engine::Docker, + api: None, + rootless: false, + }; + let variables = engine_variables(Some(&docker)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("docker".to_string())) + ); + // Docker is found as Docker's own CLI finds it: nothing is set or taken away for it. + for name in ["CONTAINER_HOST", "DOCKER_HOST", "DOCKER_CONTEXT"] { + assert!(!variables.contains_key(name), "{name} in {variables:?}"); + } + + let api = "unix:///var/folders/xy/T/podman/openbot-api.sock"; + let podman = CliEngine { + engine: Engine::Podman, + api: Some(api.into()), + rootless: false, + }; + let variables = engine_variables(Some(&podman)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("podman".to_string())) + ); + assert_eq!( + variables.get("CONTAINER_HOST"), + Some(&Some(api.to_string())) + ); + assert_eq!(variables.get("DOCKER_HOST"), Some(&Some(api.to_string()))); + // An inherited Docker context names a Docker, and is not handed to k3d beside a Podman. + assert!( + engine_environment(Some(&podman)).contains(&("DOCKER_CONTEXT", None)), + "a Podman call must drop DOCKER_CONTEXT" + ); + assert!(!matches!(variables.get("DOCKER_CONTEXT"), Some(Some(_)))); + + // A Podman whose API could not be found is still named, and inherits no Docker address. + let unfound = CliEngine { + api: None, + ..podman + }; + let variables = engine_variables(Some(&unfound)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("podman".to_string())) + ); + assert_eq!( + engine_environment(Some(&unfound))[1..], + [ + ("CONTAINER_HOST", None), + ("DOCKER_HOST", None), + ("DOCKER_CONTEXT", None) + ] + ); + for name in ["CONTAINER_HOST", "DOCKER_HOST", "DOCKER_CONTEXT"] { + assert!( + !matches!(variables.get(name), Some(Some(_))), + "{name} in {variables:?}" + ); + } + + // Before the app knows its engine the CLI chooses for itself, as it did before. + let variables = engine_variables(None); + for name in ENGINE_VARIABLES { + assert!(!variables.contains_key(name), "{name} in {variables:?}"); + } + } + + /// The engine adds names and addresses, never a secret: what the CLI receives is the inherited + /// allowlist and the engine's own variables, and nothing else. + #[test] + fn naming_the_engine_adds_nothing_secret() { + use crate::engine::Engine; + for engine in [ + None, + Some(CliEngine { + engine: Engine::Docker, + api: None, + rootless: false, + }), + Some(CliEngine { + engine: Engine::Podman, + api: Some("npipe:////./pipe/podman-openbot".into()), + rootless: false, + }), + ] { + for name in engine_variables(engine.as_ref()).keys() { + assert!( + INHERITED.contains(&name.as_str()) || ENGINE_VARIABLES.contains(&name.as_str()), + "{name} is neither inherited nor the engine's" + ); + } + } + } + + #[test] + fn a_rootless_podman_declines_setup_and_start_and_nothing_else() { + use crate::engine::Engine; + let rootless = CliEngine { + engine: Engine::Podman, + api: Some("unix:///tmp/podman.sock".into()), + rootless: true, + }; + let rootful = CliEngine { + rootless: false, + ..rootless.clone() + }; + let stack = StackId::parse("local-abc123").unwrap(); + for arguments in [ + SETUP_ARGUMENTS.to_vec(), + arguments(Action::Start, &stack).to_vec(), + ] { + assert_eq!( + refuse_rootless(&arguments, Some(&rootless)), + Err(Failure::RootlessPodman) + ); + assert_eq!(refuse_rootless(&arguments, Some(&rootful)), Ok(())); + assert_eq!(refuse_rootless(&arguments, None), Ok(())); + } + for arguments in [ + arguments(Action::Status, &stack).to_vec(), + arguments(Action::Stop, &stack).to_vec(), + LIST_ARGUMENTS.to_vec(), + CONNECT_ARGUMENTS.to_vec(), + login_arguments(&stack).to_vec(), + ACCOUNT_SIGN_IN_ARGUMENTS.to_vec(), + TERMS_ARGUMENTS.to_vec(), + ] { + assert_eq!(refuse_rootless(&arguments, Some(&rootless)), Ok(())); + } + } + #[test] fn every_failure_category_is_known_to_the_window() { let window = include_str!("../../src/localEvalCli.ts"); @@ -3063,6 +3368,7 @@ mod tests { Failure::ContractMismatch, Failure::UnexpectedExit, Failure::InvalidAcceptance, + Failure::RootlessPodman, ]; // Adding a variant without listing it above stops compiling here. for failure in all { @@ -3082,7 +3388,8 @@ mod tests { | Failure::UnsafeEndpoint | Failure::ContractMismatch | Failure::UnexpectedExit - | Failure::InvalidAcceptance => {} + | Failure::InvalidAcceptance + | Failure::RootlessPodman => {} } let name = serde_json::to_string(&failure).unwrap(); assert!(window.contains(&name), "localEvalCli.ts is missing {name}"); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 274de76c08..08c9074780 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -799,6 +799,7 @@ async fn engine_ready( ) })??; } + local_eval::use_engine(&ready.address); report(app, "engine", true, ready.detail); return Ok(ready.address); } @@ -843,7 +844,7 @@ async fn engine_ready( }, ); }, - // Starting Docker Desktop can take minutes; each line says where it is. + // Installing Podman or starting Docker Desktop can take minutes; each line says where it is. &mut |said| report_running(&progress_app, "install-engine", said, None), ) }) @@ -870,6 +871,7 @@ async fn engine_ready( let after = engine::detect_saved(Some(chosen)); if let Some(address) = answering_after_install(&after, engine::Address::composes)? { desktop_telemetry::observe_engine(app, &after); + local_eval::use_engine(&address); report(app, "engine", true, after.detail); return Ok(address); } @@ -877,7 +879,19 @@ async fn engine_ready( // One at a time, and each only if the last one worked. Written as a loop over an array once, // which ran all three before the first was checked: a failed `machine init` was still followed // by `machine start`. - report_running(app, "create-machine", "Preparing the engine machine.", None); + report_running( + app, + "create-machine", + if cfg!(target_os = "windows") { + // Podman's own installer for WSL, when WSL is missing: its dialog, then Windows' own + // administrator prompt, then a restart. Said before it happens rather than after. + "Preparing the engine machine. If Windows Subsystem for Linux is missing, Podman \ + offers to install it. Windows then asks for administrator approval and a restart." + } else { + "Preparing the engine machine." + }, + None, + ); let created = acquire::create_machine(4, 6144, 60); report(app, "create-machine", created.ok, created.said.clone()); if !created.ok { @@ -915,6 +929,7 @@ async fn engine_ready( ready.detail, ) })?; + local_eval::use_engine(&address); report(app, "engine", true, "The container engine is answering."); Ok(address) } @@ -1763,6 +1778,8 @@ async fn start_stack_inner( return Err(status.detail.into()); }; let found = found.pin()?; + // Intelligence runs on the engine OpenBot is about to run on. See `local_eval::use_engine`. + local_eval::use_engine(&found); #[cfg(target_os = "linux")] ensure_linux_podman_api(&shell, &found)?; #[cfg(target_os = "linux")] @@ -4791,7 +4808,13 @@ async fn run_local_eval( stack: String, ) -> Result { let stack = local_eval::StackId::parse(&stack)?; + let root = { + let shell = app.state::(); + let selected = shell.selected_root.lock().unwrap().clone(); + selected.unwrap_or_else(stack::default_root) + }; let report = tauri::async_runtime::spawn_blocking(move || { + know_cli_engine(&root); local_eval::run_installed(&attempt, action, &stack) }) .await @@ -4800,6 +4823,24 @@ async fn run_local_eval( Ok(report) } +/** +The engine for the Intelligence CLI when nothing this run has named one yet. + +A relaunched OpenBot can ask Intelligence for its status before Start has pinned an engine. The +answer is the one Start would reach with no containers of its own: the engine `root` was set up on +if it saved one (see `preparation::saved_engine`), otherwise whatever `engine::detect` finds +answering. Blocking, so only from a blocking thread. +*/ +fn know_cli_engine(root: &Path) { + if local_eval::engine_known() { + return; + } + let found = engine::detect_saved(preparation::saved_engine(root)); + if let Some(address) = found.address.filter(|_| found.responding) { + local_eval::use_engine(&address); + } +} + /// Keep the dashboard address of every stack a CLI report described, for `Dashboards`. fn learn_dashboards<'a>( app: &tauri::AppHandle, @@ -4848,6 +4889,7 @@ async fn set_up_local_eval( // may not exist yet; the CLI runs in it. std::fs::create_dir_all(&root) .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))?; + know_cli_engine(&root); set_up_local_eval_at(&root, |folder| { local_eval::setup_installed(&attempt, folder, accepted.as_ref()) }) diff --git a/desktop/src/localEvalCli.ts b/desktop/src/localEvalCli.ts index c43895cd22..60623d9045 100644 --- a/desktop/src/localEvalCli.ts +++ b/desktop/src/localEvalCli.ts @@ -29,6 +29,7 @@ export const localFailures = [ "contract-mismatch", "unexpected-exit", "invalid-acceptance", + "rootless-podman", ] as const; export type LocalFailure = (typeof localFailures)[number]; diff --git a/desktop/src/localReadiness.test.ts b/desktop/src/localReadiness.test.ts index c1bdc15a32..5c465ae0b8 100644 --- a/desktop/src/localReadiness.test.ts +++ b/desktop/src/localReadiness.test.ts @@ -84,7 +84,7 @@ test("every failure gives a plain, non-empty sentence without its own name", () test("the reason and failure lists in this test match the source", () => { expect(new Set(reasons).size).toBe(18); - expect(localFailures.length).toBe(16); + expect(localFailures.length).toBe(17); }); test("Docker reasons block the runtime when the probe has no answer", () => { @@ -95,7 +95,7 @@ test("Docker reasons block the runtime when the probe has no answer", () => { }); expect(rows.runtime).toEqual({ state: "blocked", - said: "Docker Desktop is not running.", + said: "The container engine is not running.", action: "retry", }); expect(rows.intelligence.state).toBe("blocked"); @@ -104,7 +104,9 @@ test("Docker reasons block the runtime when the probe has no answer", () => { runtime: "unknown", intelligence: report({ outcome: "blocked", reason: "unsupported-engine" }), }); - expect(engine.runtime.said).toBe("This Docker setup is not supported."); + expect(engine.runtime.said).toBe( + "This container engine setup is not supported.", + ); expect(engine.runtime.action).toBeUndefined(); }); @@ -122,7 +124,7 @@ test("runtime probe maps directly", () => { ); expect(localReadiness({ ...base, runtime: "not-running" }).runtime).toEqual({ state: "blocked", - said: "Docker Desktop is not running.", + said: "The container engine is not running.", action: "retry", }); expect(localReadiness({ ...base, runtime: "unknown" }).runtime.state).toBe( diff --git a/desktop/src/localReadiness.ts b/desktop/src/localReadiness.ts index 62897c8bdc..0dcea5e15c 100644 --- a/desktop/src/localReadiness.ts +++ b/desktop/src/localReadiness.ts @@ -33,7 +33,7 @@ export type ReadinessRow = { }; export type ReadinessInput = { - /** Docker Desktop, as probed by the window. */ + /** The container engine, Docker or Podman, as probed by the window. */ runtime: "running" | "not-running" | "unknown"; /** The latest status call: a report, a failure category, or nothing yet. */ intelligence: ChatGateInput["intelligence"]; @@ -151,6 +151,12 @@ const failureRows: Record = { "The evaluation agreement could not be accepted.", "retry", ), + // OpenBot runs on a Podman machine without root, and Intelligence needs root. Switching it is + // the person's call: the containers already in that machine stay with its current mode. + "rootless-podman": blocked( + "Intelligence needs Podman in rootful mode. Stop your Podman machine, run podman machine set --rootful, then start it again.", + "retry", + ), }; function intelligenceRow( @@ -209,9 +215,9 @@ function runtimeRow( intelligence: ReadinessInput["intelligence"], ): ReadinessRow { if (runtime === "running") { - return { state: "ready", said: "Docker Desktop is running." }; + return { state: "ready", said: "The container engine is running." }; } - const notRunning = blocked("Docker Desktop is not running.", "retry"); + const notRunning = blocked("The container engine is not running.", "retry"); if (runtime === "not-running") return notRunning; const reason = intelligence !== null && typeof intelligence !== "string" @@ -219,9 +225,12 @@ function runtimeRow( : null; if (reason === "docker-unavailable") return notRunning; if (reason === "unsupported-engine") { - return blocked("This Docker setup is not supported."); + return blocked("This container engine setup is not supported."); } - return { state: "unknown", said: "Docker Desktop has not been checked." }; + return { + state: "unknown", + said: "The container engine has not been checked.", + }; } export function localReadiness(input: ReadinessInput): Readiness { From 9150f5bfdcda4a91a1284815b69895feb4bc33a5 Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 28 Oct 2026 21:32:44 -0700 Subject: [PATCH 2/2] desktop: keep OpenBot in the Podman storage its database is in A rootful Podman machine has two engines with separate storage: `` reaches the rootless one and `-root` the rootful one. Releases up to 0.2.0 reached every machine by its bare name, so an OpenBot that adopted a rootful machine, or whose own machine was switched to rootful since, has its database in the rootless storage. Addressing a rootful machine by `-root` opened that OpenBot on an empty database. Now a rootful machine keeps its bare connection when OpenBot's `postgres-data` volume is there and not in the rootful storage, both for a running machine OpenBot adopts and for its own. Local Intelligence still runs on the machine's rootful engine, through the API the machine forwards, when OpenBot stays in the rootless storage of the same machine: one VM, and the only engine there that Intelligence runs on. That choice is now explicit in `CliEngine::of_with` and has a test. Following the window's `podman machine set --rootful` advice therefore no longer costs the person their OpenBot. --- desktop/src-tauri/src/acquire.rs | 113 +++++++++++++++++++++++----- desktop/src-tauri/src/local_eval.rs | 51 ++++++++++++- desktop/src/localReadiness.ts | 3 +- 3 files changed, 148 insertions(+), 19 deletions(-) diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index 4dbef0bc97..37c623fc94 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -101,7 +101,8 @@ const ROOTFUL_HOST_GATEWAY_CONFIG: &str = "/etc/containers/containers.conf.d/90- /// reaches the rootful one. The machine's own mode decides only which of the two is the default, /// which socket `/var/run/docker.sock` is inside it, and which API it forwards to this computer. /// Addressing a rootful machine by its bare name would put OpenBot's containers in the rootless -/// engine beside a rootful one, with the supervisor mounting a socket it cannot open. +/// engine beside a rootful one, with the supervisor mounting a socket it cannot open. The one +/// exception is an OpenBot that already lives there (see `connection_keeping_openbot`). pub fn connection_for(machine: &str, rootful: bool) -> String { if rootful { format!("{machine}-root") @@ -110,6 +111,45 @@ pub fn connection_for(machine: &str, rootful: bool) -> String { } } +/// The connection to address a running machine through: the one for its mode, unless OpenBot's +/// database is only in the other one's storage. +/// +/// The two engines in one machine keep separate containers and volumes. Releases up to 0.2.0 +/// reached every machine by its bare name, so an OpenBot that adopted somebody's rootful machine, +/// or whose own machine was switched to rootful since, has its database in the rootless engine's +/// storage. Moving it to `-root` would open it on an empty database with the old one +/// unreachable beside it, so it stays where it is. Only a rootful machine is asked: a rootless one +/// was always reached by its bare name. +fn connection_keeping_openbot( + machine: &str, + rootful: bool, + holds_openbot: impl Fn(&str) -> bool, +) -> String { + let own = connection_for(machine, rootful); + if rootful && !holds_openbot(&own) && holds_openbot(machine) { + return machine.to_string(); + } + own +} + +/// Whether OpenBot's database volume is in the engine behind `connection`. +/// +/// By Compose's own label for the volume rather than by its full name, which carries the +/// deployment folder's name. Any project's `postgres-data` counts: a false yes keeps the +/// connection a release up to 0.2.0 would have used, and a false no is the one that loses data. +fn holds_openbot(connection: &str) -> bool { + podman(&[ + "--connection", + connection, + "volume", + "ls", + "--quiet", + "--filter", + "label=com.docker.compose.volume=postgres-data", + ]) + .is_ok_and(|listing| !listing.is_empty()) +} + /// The machine a connection belongs to, by the naming `connection_for` describes. fn machine_of(connection: &str) -> &str { connection.strip_suffix("-root").unwrap_or(connection) @@ -164,7 +204,8 @@ pub fn machine_behind(connection: &str) -> Result { machine_facts(&podman(&["machine", "inspect", machine])?, machine) } -/// The connection for a machine found by name, in the mode it runs in. +/// The connection for a machine found by name, in the mode it runs in, or where its OpenBot +/// already is (see `connection_keeping_openbot`). /// /// A machine that cannot be inspected keeps its bare name, which is how it was addressed before /// machines were rootful. @@ -174,7 +215,7 @@ pub fn connection_of_machine(machine: &str) -> String { .ok() .and_then(|facts| facts.rootful) .unwrap_or(false); - connection_for(machine, rootful) + connection_keeping_openbot(machine, rootful, holds_openbot) } fn podman(args: &[&str]) -> Result { @@ -247,13 +288,14 @@ pub fn machine_exists() -> Result { /// `--rootful`, for local Intelligence: see the module comment. /// /// AN EXISTING MACHINE IS NEVER SWITCHED TO ROOTFUL, OURS INCLUDED. `podman machine set --rootful` -/// would do it, on a stopped machine, and it would cost the person their OpenBot. A rootless -/// machine keeps its containers and volumes in the rootless engine's storage, and the rootful -/// engine in the same VM does not see them, so OpenBot would come back on an empty database while -/// the old one sat unreachable beside it. Releases up to 0.2.0 created this machine rootless, so -/// "ours" is not "empty". A rootless machine is left as it is and OpenBot keeps running on it; -/// local Intelligence then declines to start there with a sentence that names the switch, which -/// is the person's to make (see `local_eval::Failure::RootlessPodman`). +/// would do it, on a stopped machine, which is the person's to stop. A rootless machine keeps its +/// containers and volumes in the rootless engine's storage, and the rootful engine in the same VM +/// does not see them. Releases up to 0.2.0 created this machine rootless, so "ours" is not +/// "empty". A rootless machine is left as it is and OpenBot keeps running on it; local +/// Intelligence then declines to start there with a sentence that names the switch (see +/// `local_eval::Failure::RootlessPodman`). After the switch OpenBot stays in the rootless storage +/// its database is in (see `connection_keeping_openbot`), and Intelligence runs on the rootful +/// engine of the same machine. pub fn create_machine(cpus: u32, memory_mib: u32, disk_gib: u32) -> StepOutcome { create_machine_with(cpus, memory_mib, disk_gib, machine_exists, podman) } @@ -532,12 +574,16 @@ fn explain_machine_error(error: &str) -> String { /// /// Always by name. The default connection belongs to whoever set it, and after `machine init` it is /// usually still pointing somewhere else. The rootful connection for the rootful machine this app -/// creates; the bare name for one an earlier release created rootless (see `create_machine`). +/// creates; the bare name for one an earlier release created rootless (see `create_machine`), and +/// for one switched to rootful since whose database is still in the rootless storage. pub fn address() -> Address { - address_with(|| podman(&["machine", "inspect", MACHINE])) + address_with(|| podman(&["machine", "inspect", MACHINE]), holds_openbot) } -fn address_with(inspect: impl FnOnce() -> Result) -> Address { +fn address_with( + inspect: impl FnOnce() -> Result, + holds_openbot: impl Fn(&str) -> bool, +) -> Address { // Unknown reads as rootful: that is how this app creates the machine, and a machine that cannot // be inspected is not going to answer on either connection. let rootful = inspect() @@ -545,7 +591,10 @@ fn address_with(inspect: impl FnOnce() -> Result) -> Address { .ok() .and_then(|facts| facts.rootful) .unwrap_or(true); - Address::new(Engine::Podman, Some(connection_for(MACHINE, rootful))) + Address::new( + Engine::Podman, + Some(connection_keeping_openbot(MACHINE, rootful, holds_openbot)), + ) } /// The gate before Compose is touched. @@ -1258,16 +1307,46 @@ mod tests { let inspect = |rootful: bool| { serde_json::json!([{ "Name": "openbot", "Rootful": rootful }]).to_string() }; + let nowhere = |_: &str| false; // Rootful, as this app creates it: its root connection, where the rootful engine is. - let addressed = address_with(|| Ok(inspect(true))); + let addressed = address_with(|| Ok(inspect(true)), nowhere); assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); assert_eq!(addressed.engine, Engine::Podman); // Rootless, as releases up to 0.2.0 created it: its bare name, where its OpenBot is. - let addressed = address_with(|| Ok(inspect(false))); + let addressed = address_with(|| Ok(inspect(false)), nowhere); assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); // Not inspectable: as this app creates it. - let addressed = address_with(|| Err("no such machine".into())); + let addressed = address_with(|| Err("no such machine".into()), nowhere); assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); + // Switched to rootful since, with its database still in the rootless storage: kept there. + let addressed = address_with(|| Ok(inspect(true)), |connection| connection == MACHINE); + assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + } + + /// An OpenBot is never moved away from its database by the machine's mode. + #[test] + fn a_rootful_machine_keeps_an_openbot_whose_database_is_in_rootless_storage() { + let in_rootless: fn(&str) -> bool = |connection| connection == "podman-machine-default"; + let in_rootful: fn(&str) -> bool = |connection| connection == "podman-machine-default-root"; + let in_both: fn(&str) -> bool = |_| true; + let in_neither: fn(&str) -> bool = |_| false; + let machine = "podman-machine-default"; + assert_eq!( + connection_keeping_openbot(machine, true, in_rootless), + "podman-machine-default" + ); + // Wherever else it is, the machine's own mode wins. + for holds in [in_rootful, in_both, in_neither] { + assert_eq!( + connection_keeping_openbot(machine, true, holds), + "podman-machine-default-root" + ); + } + // A rootless machine was always reached by its bare name, and nothing is asked. + assert_eq!( + connection_keeping_openbot(machine, false, |_: &str| panic!("not asked")), + "podman-machine-default" + ); } #[test] diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index 98bc48728f..cee298daf2 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -1227,6 +1227,20 @@ struct CliEngine { impl CliEngine { fn of(address: &crate::engine::Address) -> Self { + Self::of_with(address, crate::acquire::machine_behind) + } + + /// The machine's mode and API, not the connection's. + /// + /// A rootful machine whose OpenBot is still in its rootless storage keeps OpenBot on the + /// machine's bare connection (see `acquire::connection_keeping_openbot`). Intelligence then + /// runs on that same machine's rootful engine, the one its forwarded API reaches: one VM, so + /// none of the cost of two engines above, and the only engine there Intelligence runs on. + /// Only a rootless machine has no rootful engine to offer, and is declined. + fn of_with( + address: &crate::engine::Address, + machine_behind: impl FnOnce(&str) -> Result, + ) -> Self { use crate::engine::Engine; let named_only = || Self { engine: address.engine, @@ -1242,7 +1256,7 @@ impl CliEngine { .connection .clone() .or_else(|| address.pin().ok().and_then(|pinned| pinned.connection)); - match connection.map(|connection| crate::acquire::machine_behind(&connection)) { + match connection.map(|connection| machine_behind(&connection)) { Some(Ok(facts)) => Self { engine: Engine::Podman, api: facts.api, @@ -3311,6 +3325,41 @@ mod tests { } } + /// OpenBot on the bare connection of a rootful machine: Intelligence goes to the same machine's + /// rootful engine. OpenBot on a rootless machine, by either connection: declined. + #[cfg(not(target_os = "linux"))] + #[test] + fn intelligence_runs_on_the_rootful_engine_of_the_machine_openbot_is_on() { + use crate::acquire::MachineFacts; + use crate::engine::{Address, Engine}; + let api = "unix:///tmp/podman/openbot-api.sock"; + let machine = |rootful| { + move |name: &str| { + assert_eq!(name.strip_suffix("-root").unwrap_or(name), "openbot"); + Ok(MachineFacts { + rootful: Some(rootful), + api: Some(api.into()), + }) + } + }; + for connection in ["openbot", "openbot-root"] { + let address = Address::new(Engine::Podman, Some(connection.into())); + assert_eq!( + CliEngine::of_with(&address, machine(true)), + CliEngine { + engine: Engine::Podman, + api: Some(api.into()), + rootless: false, + }, + "{connection}" + ); + assert!( + CliEngine::of_with(&address, machine(false)).rootless, + "{connection}" + ); + } + } + #[test] fn a_rootless_podman_declines_setup_and_start_and_nothing_else() { use crate::engine::Engine; diff --git a/desktop/src/localReadiness.ts b/desktop/src/localReadiness.ts index 0dcea5e15c..3f62cbdd5d 100644 --- a/desktop/src/localReadiness.ts +++ b/desktop/src/localReadiness.ts @@ -152,7 +152,8 @@ const failureRows: Record = { "retry", ), // OpenBot runs on a Podman machine without root, and Intelligence needs root. Switching it is - // the person's call: the containers already in that machine stay with its current mode. + // the person's call. OpenBot stays in the storage its database is in, and Intelligence runs on + // the machine's rootful engine. "rootless-podman": blocked( "Intelligence needs Podman in rootful mode. Stop your Podman machine, run podman machine set --rootful, then start it again.", "retry",