diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index d94810a6e..41b2e744b 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -877,6 +877,24 @@ fn main() { } } +/** +Where a container under rootless Podman reaches this machine's loopback. + +Rootless Podman puts its containers in a network namespace of the person's own, and by default +nothing in it leads back to the host's `127.0.0.1`: slirp4netns runs with host loopback disabled +and pasta with `--no-map-gw`. The server is a container now, and what it calls on this machine +listens only on loopback: the person's browser, deliberately, and a local Intelligence. Measured +on Ubuntu 24.04 (Podman 4.9.3, slirp4netns) and Fedora 44 (Podman 5.8.9, pasta): a container on a +Compose network reached neither, at any address, until [`local_api::HOST_LOOPBACK_CONFIG`] was in +place, and then reached both at this address, which is slirp4netns's fixed one and which pasta is +told to map. + +Docker Desktop and the Podman machines on macOS and Windows already forward `host.docker.internal` +to the host's loopback, so this is for rootless Podman on Linux alone. Compose is pointed at it +through `OPENBOT_HOST_GATEWAY` (see `env::compose`). +*/ +pub const ROOTLESS_HOST_LOOPBACK: &str = "10.0.2.2"; + /// A native Podman API service belongs to the desktop session only when this session spawned it. /// Existing user/systemd services are probed and reused, never stopped or reconfigured. #[cfg(any(target_os = "linux", all(test, unix)))] @@ -894,6 +912,81 @@ pub mod local_api { pub struct Service { child: Option, socket: Option<(PathBuf, u64, u64)>, + /// Where [`allow_host_loopback`](Service::allow_host_loopback) last wrote, so a Start can + /// ask whether the reset it calls for is still owed. + config_home: Option, + } + + /// Podman's own drop-in directory, under the person's configuration home. + pub const HOST_LOOPBACK_CONFIG: &str = "containers/containers.conf.d/90-openbot-host.conf"; + + /** + Present from the moment [`HOST_LOOPBACK_CONFIG`] changes until a Start has taken OpenBot's + stack down, so the containers after it build a new rootless network namespace. + + A file rather than a flag in memory, so the reset is still owed after a Start whose teardown + failed, and after the app quit between writing the drop-in and starting. Beside Podman's + configuration rather than in `containers.conf.d`, where Podman reads drop-ins. + */ + pub const HOST_LOOPBACK_PENDING: &str = "containers/openbot-host-loopback.pending"; + + /** + The drop-in that opens the rootless network namespace to this machine's loopback at + [`super::ROOTLESS_HOST_LOOPBACK`], for both of the programs rootless Podman builds it with. + Only the one in use reads its own key. + + Appended rather than set, so options the person gave either program in their own files are + kept: a later drop-in's list otherwise replaces an earlier one's. Both Podman 4.9 and 5.8 + parse `{append=true}`. + + Nothing here renames `host.containers.internal` or `host.docker.internal` for the person's + other containers. OpenBot's own services are pointed at this address by Compose, and anything + else would have to ask for it by number. + */ + pub fn host_loopback_config() -> String { + let address = super::ROOTLESS_HOST_LOOPBACK; + format!( + "# Written by OpenBot Desktop: lets its containers reach services this machine runs on\n\ + # 127.0.0.1, such as the person's browser and a local Intelligence, at {address}.\n\ + [engine]\n\ + network_cmd_options = [\"allow_host_loopback=true\", {{append=true}}]\n\ + \n\ + [network]\n\ + pasta_options = [\"--map-host-loopback\", \"{address}\", {{append=true}}]\n" + ) + } + + /// Write [`host_loopback_config`] under `config_home`, saying whether anything changed. + /// Replaced whole, through a sibling, so Podman never reads half of it. A change marks the + /// reset owed ([`HOST_LOOPBACK_PENDING`]) first, so no drop-in is ever in place without it. + pub fn write_host_loopback(config_home: &Path) -> Result { + let path = config_home.join(HOST_LOOPBACK_CONFIG); + let wanted = host_loopback_config(); + match std::fs::read_to_string(&path) { + Ok(current) if current == wanted => return Ok(false), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("{}: {error}", path.display())), + } + let directory = path.parent().expect("the drop-in has a directory"); + std::fs::create_dir_all(directory) + .map_err(|error| format!("{}: {error}", directory.display()))?; + let pending = config_home.join(HOST_LOOPBACK_PENDING); + std::fs::write(&pending, "").map_err(|error| format!("{}: {error}", pending.display()))?; + let partial = path.with_extension("conf.partial"); + std::fs::write(&partial, wanted) + .and_then(|()| std::fs::rename(&partial, &path)) + .map_err(|error| format!("{}: {error}", path.display()))?; + Ok(true) + } + + /// Where the person's configuration lives, as Podman looks for it. + #[cfg(target_os = "linux")] + fn config_home() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) } fn problem(detail: impl Into) -> Problem { @@ -960,6 +1053,9 @@ pub mod local_api { }); let socket = runtime.join("podman/podman.sock"); let log = root.join("logs/podman-api.log"); + let config_home = + config_home().ok_or_else(|| problem("Neither XDG_CONFIG_HOME nor HOME is set."))?; + self.allow_host_loopback(&config_home)?; self.ensure_with(&socket, &log, || { let mut command = address.command(); command @@ -1060,6 +1156,48 @@ pub mod local_api { })) } + /// Write the host loopback drop-in before any container needs it. A service this session + /// started read its configuration when it started, so a change stops it, and the + /// `ensure` that follows starts it again. A service somebody else runs is left alone. + pub(super) fn allow_host_loopback(&mut self, config_home: &Path) -> Result<(), Problem> { + self.config_home = Some(config_home.to_path_buf()); + if write_host_loopback(config_home).map_err(problem)? { + self.stop()?; + } + Ok(()) + } + + /** + Whether a Start still owes the host loopback configuration a teardown of OpenBot's stack. + + Podman reads it only when the rootless network namespace is created, and that namespace + lives while any container on a Compose network does, so a stack still running from before + keeps the old one. Measured: written while a container held the namespace, nothing + changed; once the last one stopped, the next container reached the host. + + Owed until [`acknowledge_host_loopback_reset`](Self::acknowledge_host_loopback_reset), + which a Start calls only once the teardown succeeded. + */ + pub fn host_loopback_reset_pending(&self) -> bool { + self.config_home + .as_ref() + .is_some_and(|home| home.join(HOST_LOOPBACK_PENDING).exists()) + } + + /// The teardown [`host_loopback_reset_pending`](Self::host_loopback_reset_pending) asked + /// for is done. + pub fn acknowledge_host_loopback_reset(&self) -> Result<(), Problem> { + let Some(home) = self.config_home.as_ref() else { + return Ok(()); + }; + let pending = home.join(HOST_LOOPBACK_PENDING); + match std::fs::remove_file(&pending) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(problem(format!("{}: {error}", pending.display()))), + } + } + pub fn stop(&mut self) -> Result<(), Problem> { if let Some(child) = self.child.as_mut() { if child @@ -1169,6 +1307,108 @@ mod local_api_tests { } } + /// The drop-in opens host loopback for both programs, at the one address Compose is given, and + /// appends to what the person configured rather than replacing it. + #[test] + fn the_host_loopback_drop_in_names_both_programs_and_keeps_the_persons_options() { + let config = super::local_api::host_loopback_config(); + let address = super::ROOTLESS_HOST_LOOPBACK; + assert!(config.contains( + "[engine]\nnetwork_cmd_options = [\"allow_host_loopback=true\", {append=true}]\n" + )); + assert!(config.contains(&format!( + "[network]\npasta_options = [\"--map-host-loopback\", \"{address}\", {{append=true}}]\n" + ))); + // slirp4netns serves the host's loopback at its gateway, which is fixed. + assert_eq!(address, "10.0.2.2"); + assert!(!config.contains("host_containers_internal_ip")); + } + + #[test] + fn the_host_loopback_drop_in_is_written_once_and_restored_when_changed() { + let home = temp_root("host-loopback-drop-in"); + let path = home.join(super::local_api::HOST_LOOPBACK_CONFIG); + let theirs = path.with_file_name("10-person.conf"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &theirs, + "[network]\npasta_options = [\"--mtu\", \"1400\"]\n", + ) + .unwrap(); + + let pending = home.join(super::local_api::HOST_LOOPBACK_PENDING); + assert!(super::local_api::write_host_loopback(&home).unwrap()); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + super::local_api::host_loopback_config() + ); + assert!(pending.exists(), "a new drop-in owes a reset"); + std::fs::remove_file(&pending).unwrap(); + assert!(!super::local_api::write_host_loopback(&home).unwrap()); + assert!(!pending.exists(), "an unchanged drop-in owes nothing"); + + std::fs::write(&path, "[network]\n").unwrap(); + assert!(super::local_api::write_host_loopback(&home).unwrap()); + assert!(pending.exists(), "a changed drop-in owes a reset"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + super::local_api::host_loopback_config() + ); + assert_eq!( + std::fs::read_to_string(&theirs).unwrap(), + "[network]\npasta_options = [\"--mtu\", \"1400\"]\n", + "the person's own drop-in is not touched" + ); + assert!(!path.with_extension("conf.partial").exists()); + std::fs::remove_dir_all(home).unwrap(); + } + + /// A change restarts the service this session owns, because it read its configuration when it + /// started, and owes a Start a teardown until one acknowledges it. No change leaves it running. + #[test] + fn a_host_loopback_change_restarts_the_owned_api_and_owes_a_reset_until_acknowledged() { + let root = temp_root("host-loopback-restart"); + std::fs::create_dir_all(&root).unwrap(); + let home = root.join("config"); + let socket = root.join("api.sock"); + let log = root.join("api.log"); + let mut owned = Service::default(); + owned + .ensure_with(&socket, &log, || fixture(&socket)) + .unwrap(); + let first = owned.child_id().unwrap(); + + owned.allow_host_loopback(&home).unwrap(); + assert_eq!( + unsafe { libc::kill(first as i32, 0) }, + -1, + "the owned service that predates the change is stopped" + ); + owned + .ensure_with(&socket, &log, || fixture(&socket)) + .unwrap(); + let second = owned.child_id().unwrap(); + assert_ne!(first, second); + assert!(owned.host_loopback_reset_pending()); + // A Start whose teardown failed acknowledges nothing, so asking again still says owed. + assert!(owned.host_loopback_reset_pending()); + + // Nor is it forgotten with the app: a later session, with the drop-in already in place + // and nothing written, still owes it. + owned.allow_host_loopback(&home).unwrap(); + assert_eq!(owned.child_id(), Some(second), "no change, no restart"); + let mut later = Service::default(); + later.allow_host_loopback(&home).unwrap(); + assert!(later.host_loopback_reset_pending()); + + later.acknowledge_host_loopback_reset().unwrap(); + assert!(!later.host_loopback_reset_pending()); + assert!(!owned.host_loopback_reset_pending()); + later.acknowledge_host_loopback_reset().unwrap(); + owned.stop().unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn linux_api_starts_a_real_socket_and_reaps_only_its_own_child() { let root = temp_root("linux-api"); diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index df9b639e5..db5f2d593 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -899,6 +899,12 @@ pub fn compose( // Only rootless Podman on Linux needs this; see engine.rs. if let Some(socket) = &engine.engine_socket { env.insert("ENGINE_SOCKET".into(), socket.clone()); + // And only there is `host-gateway` not this machine's loopback, which is where the + // person's browser and a local Intelligence listen. See `engine::ROOTLESS_HOST_LOOPBACK`. + env.insert( + HOST_GATEWAY_ENV.into(), + crate::engine::ROOTLESS_HOST_LOOPBACK.into(), + ); } env @@ -1049,15 +1055,20 @@ pub const MINTED: [&str; 6] = [ /// earlier run wrote outlives the answer that implied it: `BOT_MODEL` once asked OpenAI for a model /// an earlier endpoint had named, and `OPENBOT_BROWSER_URL` would send the person to a browser that /// no longer runs. -pub const REMOVED_WHEN_UNSET: [&str; 6] = [ +pub const REMOVED_WHEN_UNSET: [&str; 7] = [ "BOT_PROVIDER", "BOT_MODEL", "AGENT_BOT_MODEL", crate::local_browser::BROWSER_URL_ENV, BROWSER_CONTAINER_URL_ENV, crate::local_browser::BROWSER_ABSENT_ENV, + // A machine that moves from rootless Podman to Docker must go back to `host-gateway`. + HOST_GATEWAY_ENV, ]; +/// What `docker-compose.yml` maps `host.docker.internal` to, in place of `host-gateway`. +pub const HOST_GATEWAY_ENV: &str = "OPENBOT_HOST_GATEWAY"; + /// [`crate::local_browser::BROWSER_URL_ENV`] as the server's container reaches it, which /// `docker-compose.yml` prefers for the `server` service. pub const BROWSER_CONTAINER_URL_ENV: &str = "OPENBOT_BROWSER_CONTAINER_URL"; @@ -1918,6 +1929,32 @@ HTTPS_PROXY=http://proxy:8080 ); } + /// Rootless Podman alone sends `host.docker.internal` to the address its namespace maps to this + /// machine's loopback. Everywhere else `host-gateway` already gets there, and a value left from + /// rootless Podman would send Docker's containers to an address nothing answers on. + #[test] + fn host_docker_internal_is_moved_only_under_rootless_podman() { + let compose_with = |socket: Option<&str>| { + compose( + &intelligence(), + &Model::default(), + &engine_status(socket), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ) + }; + assert!(!compose_with(None).contains_key(HOST_GATEWAY_ENV)); + assert_eq!( + compose_with(Some("/run/user/501/podman/podman.sock")) + .get(HOST_GATEWAY_ENV) + .map(String::as_str), + Some(crate::engine::ROOTLESS_HOST_LOOPBACK) + ); + assert!(REMOVED_WHEN_UNSET.contains(&HOST_GATEWAY_ENV)); + } + #[test] fn no_model_choice_does_not_advertise_an_unselected_bundled_service() { let env = compose( diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 274de76c0..c0d317523 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1765,6 +1765,33 @@ async fn start_stack_inner( let found = found.pin()?; #[cfg(target_os = "linux")] ensure_linux_podman_api(&shell, &found)?; + /* + * A rootless network namespace that predates the host loopback configuration keeps the old + * options for as long as any container holds it, and an OpenBot left running by an earlier + * version does. Taken down once, here, so the containers about to start build a new one, + * and acknowledged only once that worked, so "Try Start again" tries the teardown again. + * See `engine::local_api::Service::host_loopback_reset_pending`. + */ + #[cfg(target_os = "linux")] + if shell + .podman_api + .lock() + .unwrap() + .host_loopback_reset_pending() + { + stack::down(&found, &root).map_err(|error| { + Problem::with( + "OpenBot could not restart its containers with their new network settings. \ + Try Start again.", + error, + ) + })?; + shell + .podman_api + .lock() + .unwrap() + .acknowledge_host_loopback_reset()?; + } #[cfg(target_os = "linux")] let status = found.status(); acquire::prepare_for_compose(&found)?; diff --git a/docker-compose.yml b/docker-compose.yml index 2a129a22d..c7627fde2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,9 +188,11 @@ services: - "127.0.0.1:${SERVER_PORT:-3001}:3001" - "[::1]:${SERVER_PORT:-3001}:3001" # For whatever stays on the host and is addressed as such: a model endpoint on this machine, and - # anything else the Bots below already reach the same way. + # anything else the Bots below already reach the same way. OpenBot Desktop sets + # `OPENBOT_HOST_GATEWAY` under rootless Podman on Linux, where `host-gateway` is not this + # machine's loopback and the address it gives is. extra_hosts: - - "host.docker.internal:host-gateway" + - "host.docker.internal:${OPENBOT_HOST_GATEWAY:-host-gateway}" depends_on: migrate: condition: service_completed_successfully @@ -498,7 +500,7 @@ services: CHATGPT_AUTH_FILE: ${CHATGPT_AUTH_FILE:-} BOT_MODEL: ${BOT_MODEL:-gpt-5.5} extra_hosts: - - "host.docker.internal:host-gateway" + - "host.docker.internal:${OPENBOT_HOST_GATEWAY:-host-gateway}" volumes: # A bind mount, not a named volume, and read-write on purpose. The signed-in plan's access # token expires within the hour and the harness renews it, so the renewal has to land back on @@ -551,7 +553,7 @@ services: OPENBOT_TOOL_URL: ${OPENBOT_TOOL_URL:-http://host.docker.internal:3001/api/agent-tools/call} AGENT_TOOL_TOKEN: ${AGENT_TOOL_TOKEN:-} extra_hosts: - - "host.docker.internal:host-gateway" + - "host.docker.internal:${OPENBOT_HOST_GATEWAY:-host-gateway}" healthcheck: test: ["CMD-SHELL", "bun -e \"await fetch('http://localhost:4201/health')\""] interval: 10s diff --git a/tests/compose.test.ts b/tests/compose.test.ts index 231ca5756..e24f1d137 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -602,6 +602,25 @@ test("gives the selected harness the same governed callback as the framework Bot } }); +/** + * Rootless Podman on Linux gives `host-gateway` an address that is not this machine's loopback, where + * the person's browser and a local Intelligence listen. OpenBot Desktop sets `OPENBOT_HOST_GATEWAY` + * there, to the address its Podman configuration maps to loopback, and every service that calls the + * host by name takes it. + */ +test("sends host.docker.internal where OPENBOT_HOST_GATEWAY says", () => { + const config = runComposeConfig({ OPENBOT_HOST_GATEWAY: "10.0.2.2" }); + for (const service of [ + "server", + "agent-harness", + "agent-langgraph", + ] as const) { + expect(config.services[service].extra_hosts).toContain( + "host.docker.internal=10.0.2.2", + ); + } +}); + /** * OpenBot's own server and worker, for an installed OpenBot, and out of a developer's way. *