From 6d75ec58d44454d784a66eaf09675ca0e63a7c8b Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 29 Oct 2026 00:47:16 -0700 Subject: [PATCH 1/3] desktop: let containers reach this machine's loopback under rootless Podman The server is a container now, and what it calls on this machine listens only on 127.0.0.1: the person's browser, deliberately, and a local Intelligence. Docker Desktop and the Podman machines on macOS and Windows forward host.docker.internal to the host's loopback. Rootless Podman on Linux does not: slirp4netns runs with host loopback disabled and pasta with --no-map-gw, so neither was reachable at any address. - Under rootless Podman, the Desktop writes containers.conf.d/90-openbot-host.conf: allow_host_loopback for slirp4netns and --map-host-loopback 10.0.2.2 for pasta, both appended so the person's own options are kept. A change restarts the API service this session owns. - The first Start after a change brings OpenBot's stack down once, since the rootless network namespace keeps its old options while any container holds it. - OPENBOT_HOST_GATEWAY=10.0.2.2 points OpenBot's own services' host.docker.internal there. The person's other containers keep their host.containers.internal. Measured on Ubuntu 24.04 (Podman 4.9.3, slirp4netns) and Fedora 44 (Podman 5.8.9, pasta), through Docker Compose and the Podman API service: a loopback-only listener was unreachable without this and reached with it, with outbound traffic unaffected. --- desktop/src-tauri/src/engine.rs | 192 ++++++++++++++++++++++++++++++++ desktop/src-tauri/src/env.rs | 39 ++++++- desktop/src-tauri/src/main.rs | 16 +++ docker-compose.yml | 10 +- tests/compose.test.ts | 15 +++ 5 files changed, 267 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index d94810a6ee..69fb37c4a1 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,67 @@ pub mod local_api { pub struct Service { child: Option, socket: Option<(PathBuf, u64, u64)>, + /// The host loopback configuration was written or changed since a Start last asked. + host_loopback_changed: bool, + } + + /// 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"; + + /** + 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. + 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 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 +1039,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 +1142,29 @@ 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> { + if write_host_loopback(config_home).map_err(problem)? { + self.host_loopback_changed = true; + self.stop()?; + } + Ok(()) + } + + /** + Whether the host loopback configuration changed since the last Start asked, and forget it. + + 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. + */ + pub fn take_host_loopback_change(&mut self) -> bool { + std::mem::take(&mut self.host_loopback_changed) + } + pub fn stop(&mut self) -> Result<(), Problem> { if let Some(child) = self.child.as_mut() { if child @@ -1169,6 +1274,93 @@ 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(); + + 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!(!super::local_api::write_host_loopback(&home).unwrap()); + + std::fs::write(&path, "[network]\n").unwrap(); + 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_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 is reported to the next Start once. No change leaves it running. + #[test] + fn a_host_loopback_change_restarts_the_owned_api_and_is_reported_once() { + 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.take_host_loopback_change()); + assert!(!owned.take_host_loopback_change()); + + owned.allow_host_loopback(&home).unwrap(); + assert_eq!(owned.child_id(), Some(second), "no change, no restart"); + assert!(!owned.take_host_loopback_change()); + 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 df9b639e53..db5f2d593d 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 274de76c08..256a8aa9df 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1765,6 +1765,22 @@ 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. + * See `engine::local_api::take_host_loopback_change`. + */ + #[cfg(target_os = "linux")] + if shell.podman_api.lock().unwrap().take_host_loopback_change() { + stack::down(&found, &root).map_err(|error| { + Problem::with( + "OpenBot could not restart its containers with their new network settings. \ + Try Start again.", + error, + ) + })?; + } #[cfg(target_os = "linux")] let status = found.status(); acquire::prepare_for_compose(&found)?; diff --git a/docker-compose.yml b/docker-compose.yml index 2a129a22dc..c7627fde27 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 231ca57566..c9fe20510c 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -602,6 +602,21 @@ 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. * From 3587a9b7cc6ca38a8b567d7c1072fb6325d80cb6 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 29 Oct 2026 00:57:02 -0700 Subject: [PATCH 2/3] desktop: keep the host loopback reset owed until a teardown succeeds The reset was a flag in memory, taken before the teardown. A Start whose teardown failed had already cleared it, so "Try Start again" skipped the teardown and left the old rootless network namespace in place; quitting between writing the drop-in and starting forgot it the same way. It is now a file beside Podman's configuration, written before the drop-in changes and removed by a Start only once stack::down succeeded. --- desktop/src-tauri/src/engine.rs | 72 +++++++++++++++++++++++++++------ desktop/src-tauri/src/main.rs | 17 ++++++-- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 69fb37c4a1..41b2e744b8 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -912,13 +912,24 @@ pub mod local_api { pub struct Service { child: Option, socket: Option<(PathBuf, u64, u64)>, - /// The host loopback configuration was written or changed since a Start last asked. - host_loopback_changed: bool, + /// 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. @@ -946,7 +957,8 @@ pub mod local_api { } /// Write [`host_loopback_config`] under `config_home`, saying whether anything changed. - /// Replaced whole, through a sibling, so Podman never reads half of it. + /// 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(); @@ -959,6 +971,8 @@ pub mod local_api { 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)) @@ -1146,23 +1160,42 @@ pub mod local_api { /// 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.host_loopback_changed = true; self.stop()?; } Ok(()) } /** - Whether the host loopback configuration changed since the last Start asked, and forget it. + 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 take_host_loopback_change(&mut self) -> bool { - std::mem::take(&mut self.host_loopback_changed) + 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> { @@ -1303,15 +1336,20 @@ mod local_api_tests { ) .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() @@ -1326,9 +1364,9 @@ mod local_api_tests { } /// A change restarts the service this session owns, because it read its configuration when it - /// started, and is reported to the next Start once. No change leaves it running. + /// 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_is_reported_once() { + 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"); @@ -1351,12 +1389,22 @@ mod local_api_tests { .unwrap(); let second = owned.child_id().unwrap(); assert_ne!(first, second); - assert!(owned.take_host_loopback_change()); - assert!(!owned.take_host_loopback_change()); + 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"); - assert!(!owned.take_host_loopback_change()); + 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(); } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 256a8aa9df..c0d3175236 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1768,11 +1768,17 @@ async fn start_stack_inner( /* * 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. - * See `engine::local_api::take_host_loopback_change`. + * 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().take_host_loopback_change() { + 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. \ @@ -1780,6 +1786,11 @@ async fn start_stack_inner( error, ) })?; + shell + .podman_api + .lock() + .unwrap() + .acknowledge_host_loopback_reset()?; } #[cfg(target_os = "linux")] let status = found.status(); From c74d91554f0e7d108bb9008dbb3bf678a67e20cd Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 29 Oct 2026 00:58:37 -0700 Subject: [PATCH 3/3] test(compose): format the host gateway case as biome does --- tests/compose.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/compose.test.ts b/tests/compose.test.ts index c9fe20510c..e24f1d137c 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -610,7 +610,11 @@ test("gives the selected harness the same governed callback as the framework Bot */ 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) { + 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", );