From f1f1dc384de4570c03b2d73be8ab5df125979063 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 30 Sep 2026 17:28:14 +0200 Subject: [PATCH 1/2] fix(cli): diagnose Docker Desktop host networking Signed-off-by: Evan Lezar --- crates/openshell-cli/src/commands/doctor.rs | 308 ++++++++++++++++++++ crates/openshell-cli/src/commands/mod.rs | 1 + crates/openshell-cli/src/main.rs | 2 +- crates/openshell-cli/src/run.rs | 42 +-- docs/about/installation.mdx | 2 + docs/how-it-works/gateways/overview.mdx | 9 + skills/debug-openshell-cluster/SKILL.md | 5 +- 7 files changed, 326 insertions(+), 43 deletions(-) create mode 100644 crates/openshell-cli/src/commands/doctor.rs diff --git a/crates/openshell-cli/src/commands/doctor.rs b/crates/openshell-cli/src/commands/doctor.rs new file mode 100644 index 0000000000..6767a28bd1 --- /dev/null +++ b/crates/openshell-cli/src/commands/doctor.rs @@ -0,0 +1,308 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Docker prerequisites, including Docker Desktop's container-to-host route. + +use miette::{IntoDiagnostic, Result, WrapErr, miette}; +use std::io::Write; +use std::path::Path; +use std::process::Output; +use std::time::Duration; +use tokio::process::Command; + +const PROBE_IMAGE: &str = "alpine:3.23"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +/// Check Docker and, for a local Docker Desktop daemon, host networking. +pub async fn doctor_check() -> Result<()> { + // Do not hold a stdout lock across asynchronous commands. + let docker_host = if std::env::var("DOCKER_CONTEXT").is_ok_and(|context| !context.is_empty()) { + None + } else { + std::env::var("DOCKER_HOST").ok() + }; + check_docker( + Path::new("docker"), + &mut std::io::stdout(), + COMMAND_TIMEOUT, + docker_host.as_deref(), + ) + .await +} + +async fn docker_output(docker: &Path, args: &[&str], timeout: Duration) -> Result { + tokio::time::timeout( + timeout, + Command::new(docker).args(args).kill_on_drop(true).output(), + ) + .await + .map_err(|_| { + miette!( + "Docker prerequisite check timed out after {} seconds", + timeout.as_secs() + ) + })? + .into_diagnostic() + .wrap_err("failed to execute Docker prerequisite check") +} + +async fn check_docker( + docker: &Path, + out: &mut impl Write, + timeout: Duration, + docker_host: Option<&str>, +) -> Result<()> { + writeln!(out, "Checking system prerequisites...\n").into_diagnostic()?; + write!(out, " Docker ............. ").into_diagnostic()?; + out.flush().into_diagnostic()?; + + let output = docker_output(docker, &["info", "--format", "{{json .}}"], timeout).await?; + if !output.status.success() { + writeln!(out, "FAILED").into_diagnostic()?; + return Err(miette!( + "docker info failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let info: serde_json::Value = serde_json::from_slice(&output.stdout) + .into_diagnostic() + .wrap_err("failed to read Docker daemon information")?; + let version = info["ServerVersion"].as_str().unwrap_or("unknown"); + writeln!(out, "ok (version {version})").into_diagnostic()?; + writeln!( + out, + " DOCKER_HOST ........ {}", + std::env::var("DOCKER_HOST") + .unwrap_or_else(|_| "(not set, using Docker context)".to_string()) + ) + .into_diagnostic()?; + + let desktop = info["OperatingSystem"] + .as_str() + .unwrap_or_default() + .to_ascii_lowercase() + .contains("docker desktop"); + if desktop { + // An SSH/TCP context can point at another machine. A listener on this + // CLI host cannot qualify that machine's loopback networking. + let endpoint = if let Some(host) = docker_host { + host.to_string() + } else { + let context = docker_output( + docker, + &[ + "context", + "inspect", + "--format", + "{{.Endpoints.docker.Host}}", + ], + timeout, + ) + .await?; + if !context.status.success() { + return Err(miette!( + "cannot determine Docker context endpoint: {}", + String::from_utf8_lossy(&context.stderr).trim() + )); + } + String::from_utf8_lossy(&context.stdout).trim().to_string() + }; + if endpoint.starts_with("unix://") || endpoint.starts_with("npipe://") { + check_host_network(docker, out, timeout).await?; + } else { + writeln!(out, " Host networking .... skipped (remote Docker endpoint; run doctor on the gateway host)") + .into_diagnostic()?; + } + } + writeln!(out, "\nAll checks passed.").into_diagnostic()?; + Ok(()) +} + +async fn check_host_network(docker: &Path, out: &mut impl Write, timeout: Duration) -> Result<()> { + writeln!( + out, + " Host networking .... checking (may pull {PROBE_IMAGE})" + ) + .into_diagnostic()?; + out.flush().into_diagnostic()?; + // No running gateway is needed. Keep the listener alive throughout the + // probe; connect-only nc succeeds without sending application traffic. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .into_diagnostic()?; + let port = listener.local_addr().into_diagnostic()?.port().to_string(); + let name = format!("openshell-doctor-{}-{port}", std::process::id()); + let result = docker_output( + docker, + &[ + "run", + "--rm", + "--name", + &name, + "--network", + "host", + "--pull=missing", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--read-only", + PROBE_IMAGE, + "nc", + "-z", + "-w", + "3", + "127.0.0.1", + &port, + ], + timeout, + ) + .await; + if !result.as_ref().is_ok_and(|output| output.status.success()) { + // Killing the CLI does not stop a daemon-side container. Clean up only + // this probe, with its own bounded deadline. + let _ = docker_output(docker, &["rm", "--force", &name], Duration::from_secs(5)).await; + } + let output = result.wrap_err("could not complete the Docker Desktop host-network probe; check Docker and registry access, then rerun doctor")?; + if output.status.success() { + writeln!( + out, + " Host networking .... ok (container reached host loopback)" + ) + .into_diagnostic()?; + return Ok(()); + } + writeln!(out, " Host networking .... FAILED").into_diagnostic()?; + if output.status.code() == Some(1) { + return Err(miette!( + "Docker Desktop host networking could not reach this machine's loopback listener. Enable host networking in Docker Desktop Settings > Resources > Network, ensure Enhanced Container Isolation is disabled, then Apply and restart Docker Desktop. Rerun `openshell doctor check` before creating a sandbox." + )); + } + Err(miette!( + "could not run the Docker Desktop host-network probe (exit {}): {}. Check image-pull access and container-start errors; host networking has not been verified.", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + async fn check_script( + script: &str, + timeout: Duration, + docker_host: Option<&str>, + ) -> (Result<()>, String) { + let dir = tempfile::tempdir().unwrap(); + let docker = dir.path().join("docker"); + std::fs::write(&docker, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut out = Vec::new(); + let result = check_docker(&docker, &mut out, timeout, docker_host).await; + (result, String::from_utf8(out).unwrap()) + } + + fn desktop_script(probe: &str) -> String { + format!( + r#"case "$1" in +info) echo '{{"ServerVersion":"29.8.0","OperatingSystem":"Docker Desktop"}}';; +context) echo unix:///tmp/docker.sock;; +run) {probe};; +rm) exit 0;; +*) exit 99;; +esac"# + ) + } + + #[tokio::test] + async fn desktop_connectivity_failure_is_actionable() { + let (result, out) = check_script(&desktop_script("exit 1"), COMMAND_TIMEOUT, None).await; + let error = result.unwrap_err().to_string(); + assert!(error.contains("Settings > Resources > Network")); + assert!(error.contains("Enhanced Container Isolation")); + assert!(out.contains("FAILED")); + assert!(!out.contains("All checks passed")); + } + + #[tokio::test] + async fn image_failure_is_not_reported_as_network_misconfiguration() { + let (result, out) = check_script( + &desktop_script("echo 'pull denied' >&2; exit 125"), + COMMAND_TIMEOUT, + None, + ) + .await; + let error = result.unwrap_err().to_string(); + assert!(error.contains("pull denied")); + assert!(error.contains("host networking has not been verified")); + assert!(!error.contains("Enable host networking")); + assert!(!out.contains("All checks passed")); + } + + #[tokio::test] + async fn successful_desktop_probe_passes() { + let (result, out) = check_script(&desktop_script("exit 0"), COMMAND_TIMEOUT, None).await; + result.unwrap(); + assert!(out.contains("container reached host loopback")); + assert!(out.contains("All checks passed")); + } + + #[tokio::test] + async fn native_engine_does_not_launch_a_desktop_probe() { + let (result, out) = check_script( + r#"case "$1" in +info) echo '{"ServerVersion":"29.8.0","OperatingSystem":"Ubuntu"}';; +*) exit 99;; +esac"#, + COMMAND_TIMEOUT, + None, + ) + .await; + result.unwrap(); + assert!(!out.contains("Host networking")); + } + + #[tokio::test] + async fn remote_desktop_does_not_probe_cli_host() { + let (result, out) = check_script( + &desktop_script("exit 99"), + COMMAND_TIMEOUT, + Some("ssh://remote"), + ) + .await; + result.unwrap(); + assert!(out.contains("skipped (remote Docker endpoint")); + } + + #[tokio::test] + async fn stalled_probe_is_bounded_and_does_not_pass() { + let dir = tempfile::tempdir().unwrap(); + let docker = dir.path().join("docker"); + let cleaned = dir.path().join("cleaned"); + std::fs::write( + &docker, + format!( + "#!/bin/sh\ncase \"$1\" in\nrun) exec sleep 10;;\nrm) touch '{}';;\nesac\n", + cleaned.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap(); + let started = std::time::Instant::now(); + let mut out = Vec::new(); + let result = check_host_network(&docker, &mut out, Duration::from_secs(1)).await; + assert!( + result + .unwrap_err() + .to_string() + .contains("could not complete") + ); + assert!( + !String::from_utf8(out) + .unwrap() + .contains("All checks passed") + ); + assert!(cleaned.exists()); + assert!(started.elapsed() < Duration::from_secs(5)); + } +} diff --git a/crates/openshell-cli/src/commands/mod.rs b/crates/openshell-cli/src/commands/mod.rs index 5158a4ac5d..f98dbe1a6c 100644 --- a/crates/openshell-cli/src/commands/mod.rs +++ b/crates/openshell-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 pub mod common; +pub mod doctor; pub mod gateway; pub mod provider; pub mod provider_readiness; diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index f656fd8833..71833827ea 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -2684,7 +2684,7 @@ async fn run_async() -> Result<()> { command: Some(command), }) => match command { DoctorCommands::Check => { - run::doctor_check()?; + run::doctor_check().await?; } }, Some(Commands::Doctor { command: None }) => { diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index ce63bc9f7b..33b2030b6d 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -209,47 +209,7 @@ fn current_user_to_json(view: &CurrentUserView) -> serde_json::Value { }) } -/// Validate system prerequisites for running a gateway. -/// -/// Checks Docker connectivity and reports the result. Returns exit code 0 -/// if all checks pass, 1 otherwise. -pub fn doctor_check() -> Result<()> { - use std::io::Write; - let mut stdout = std::io::stdout().lock(); - - writeln!(stdout, "Checking system prerequisites...\n").into_diagnostic()?; - - // --- Docker connectivity --- - write!(stdout, " Docker ............. ").into_diagnostic()?; - stdout.flush().into_diagnostic()?; - - let output = Command::new("docker") - .args(["info", "--format", "{{.ServerVersion}}"]) - .output() - .into_diagnostic() - .wrap_err("failed to execute docker info")?; - - if output.status.success() { - let version = String::from_utf8_lossy(&output.stdout); - let version_str = version.trim(); - writeln!(stdout, "ok (version {version_str})").into_diagnostic()?; - - // --- DOCKER_HOST --- - write!(stdout, " DOCKER_HOST ........ ").into_diagnostic()?; - match std::env::var("DOCKER_HOST") { - Ok(val) => writeln!(stdout, "{val}").into_diagnostic()?, - Err(_) => writeln!(stdout, "(not set, using default socket)").into_diagnostic()?, - } - - writeln!(stdout, "\nAll checks passed.").into_diagnostic()?; - return Ok(()); - } - - writeln!(stdout, "FAILED").into_diagnostic()?; - writeln!(stdout).into_diagnostic()?; - let stderr = String::from_utf8_lossy(&output.stderr); - Err(miette::miette!("docker info failed: {}", stderr.trim())) -} +pub use crate::commands::doctor::doctor_check; fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>, expose: Option) -> bool { keep || forward.is_some() || expose.is_some() diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index d41639cddc..d604f92788 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -84,6 +84,8 @@ brew services restart openshell The gateway reads `~/.config/openshell/gateway.toml` if it exists, otherwise the Homebrew config at `$(brew --prefix)/var/openshell/gateway.toml`. +For Docker Desktop, enable host networking and disable Enhanced Container Isolation. Run `openshell doctor check` to verify container-to-host loopback connectivity before creating a sandbox. The check may pull a small Alpine probe image. + ## Linux The script installs a Debian package on Debian and Ubuntu or an RPM package on Fedora and RHEL. Set `OPENSHELL_INSTALL_METHOD=snap` to install the [Snap](#snap) package instead; hosts that already have the OpenShell snap keep refreshing it. Linux packages require glibc 2.28 or newer. diff --git a/docs/how-it-works/gateways/overview.mdx b/docs/how-it-works/gateways/overview.mdx index ee1f735fa3..010a10a604 100644 --- a/docs/how-it-works/gateways/overview.mdx +++ b/docs/how-it-works/gateways/overview.mdx @@ -167,6 +167,15 @@ openshell doctor check openshell gateway list ``` +On a local Docker Desktop installation, `openshell doctor check` also tests +whether a host-networked container can reach host loopback. The check starts a +temporary container and pulls `alpine:3.23` if needed. If connectivity fails, +enable host networking in Docker Desktop Settings > Resources > Network, +disable Enhanced Container Isolation, and apply the changes before rerunning +the check. Image-pull and container-start failures mean the check could not +verify connectivity. Remote Docker endpoints skip this test; run it on the +gateway host. + For Kubernetes gateways, inspect the gateway workload and cluster events: ```shell diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 6382428946..557ff19c6d 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -287,7 +287,10 @@ Common findings: - Sandbox never registers: check gateway logs and the supervisor's gateway endpoint. - Calls to an external tool server fail while the sandbox is Ready: inspect `Tool server connections` in `openshell sandbox get `. For configured MCP-over-HTTP endpoints, JSON output exposes each address together with `last_result` and `last_reported_at` in `endpoint_statuses`. Select the endpoint by host, path, and ports, then check the reported failure boundary. `last_reported_at` records gateway acceptance time and can advance when retained evidence is accepted after a reset. Results do not expire or prove current availability; `HttpResponseReceived` can still contain a tool error. If several paths share a host and port, a failure before the path is known remains in logs. Verify the actual operation when current tool availability matters. - On Docker Desktop, repeated `Policy fetch failed after 5 attempts` messages - can mean host networking is disabled. Enable host networking in Docker + can mean host networking is disabled. Run `openshell doctor check` on the + gateway host to test container-to-host loopback connectivity. This starts a + temporary probe container and may pull an Alpine image; an image-pull or + container-start error leaves connectivity unverified. Enable host networking in Docker Desktop, ensure Enhanced Container Isolation is disabled, and verify the gateway's primary endpoint is reachable from a host-networked container. - Sandbox runtime image exits before printing `openshell-sandbox --version`: verify the configured image contains a static executable at `/openshell-sandbox`. From 13fd8ad183788d34e8236624b12d2d8a6286d306 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 1 Oct 2026 10:38:29 +0200 Subject: [PATCH 2/2] refactor(cli): separate doctor prerequisite providers Signed-off-by: Evan Lezar --- .../commands/{doctor.rs => doctor/docker.rs} | 139 +++++++++++------- .../openshell-cli/src/commands/doctor/mod.rs | 90 ++++++++++++ .../src/commands/doctor/process.rs | 30 ++++ docs/about/installation.mdx | 4 +- docs/how-it-works/gateways/overview.mdx | 9 -- skills/debug-openshell-cluster/SKILL.md | 2 +- 6 files changed, 204 insertions(+), 70 deletions(-) rename crates/openshell-cli/src/commands/{doctor.rs => doctor/docker.rs} (73%) create mode 100644 crates/openshell-cli/src/commands/doctor/mod.rs create mode 100644 crates/openshell-cli/src/commands/doctor/process.rs diff --git a/crates/openshell-cli/src/commands/doctor.rs b/crates/openshell-cli/src/commands/doctor/docker.rs similarity index 73% rename from crates/openshell-cli/src/commands/doctor.rs rename to crates/openshell-cli/src/commands/doctor/docker.rs index 6767a28bd1..ad7165b9c2 100644 --- a/crates/openshell-cli/src/commands/doctor.rs +++ b/crates/openshell-cli/src/commands/doctor/docker.rs @@ -1,62 +1,60 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Docker prerequisites, including Docker Desktop's container-to-host route. +//! Docker prerequisite checks. +use super::{PrerequisiteCheck, command_output}; +use futures::future::BoxFuture; use miette::{IntoDiagnostic, Result, WrapErr, miette}; use std::io::Write; use std::path::Path; -use std::process::Output; use std::time::Duration; -use tokio::process::Command; -const PROBE_IMAGE: &str = "alpine:3.23"; const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); -/// Check Docker and, for a local Docker Desktop daemon, host networking. -pub async fn doctor_check() -> Result<()> { - // Do not hold a stdout lock across asynchronous commands. - let docker_host = if std::env::var("DOCKER_CONTEXT").is_ok_and(|context| !context.is_empty()) { - None - } else { - std::env::var("DOCKER_HOST").ok() - }; - check_docker( - Path::new("docker"), - &mut std::io::stdout(), - COMMAND_TIMEOUT, - docker_host.as_deref(), - ) - .await +pub(super) struct DockerCheck { + docker: std::path::PathBuf, + timeout: Duration, + docker_host: Option, } -async fn docker_output(docker: &Path, args: &[&str], timeout: Duration) -> Result { - tokio::time::timeout( - timeout, - Command::new(docker).args(args).kill_on_drop(true).output(), - ) - .await - .map_err(|_| { - miette!( - "Docker prerequisite check timed out after {} seconds", - timeout.as_secs() - ) - })? - .into_diagnostic() - .wrap_err("failed to execute Docker prerequisite check") +impl DockerCheck { + pub(super) fn new() -> Self { + let docker_host = + if std::env::var("DOCKER_CONTEXT").is_ok_and(|context| !context.is_empty()) { + None + } else { + std::env::var("DOCKER_HOST").ok() + }; + Self { + docker: "docker".into(), + timeout: COMMAND_TIMEOUT, + docker_host, + } + } +} + +impl PrerequisiteCheck for DockerCheck { + fn run<'a>(&'a self, out: &'a mut (dyn Write + Send)) -> BoxFuture<'a, Result<()>> { + Box::pin(check_docker( + &self.docker, + out, + self.timeout, + self.docker_host.as_deref(), + )) + } } async fn check_docker( docker: &Path, - out: &mut impl Write, + out: &mut (dyn Write + Send), timeout: Duration, docker_host: Option<&str>, ) -> Result<()> { - writeln!(out, "Checking system prerequisites...\n").into_diagnostic()?; write!(out, " Docker ............. ").into_diagnostic()?; out.flush().into_diagnostic()?; - let output = docker_output(docker, &["info", "--format", "{{json .}}"], timeout).await?; + let output = command_output(docker, &["info", "--format", "{{json .}}"], timeout).await?; if !output.status.success() { writeln!(out, "FAILED").into_diagnostic()?; return Err(miette!( @@ -88,7 +86,7 @@ async fn check_docker( let endpoint = if let Some(host) = docker_host { host.to_string() } else { - let context = docker_output( + let context = command_output( docker, &[ "context", @@ -114,25 +112,29 @@ async fn check_docker( .into_diagnostic()?; } } - writeln!(out, "\nAll checks passed.").into_diagnostic()?; Ok(()) } -async fn check_host_network(docker: &Path, out: &mut impl Write, timeout: Duration) -> Result<()> { +async fn check_host_network( + docker: &Path, + out: &mut (dyn Write + Send), + timeout: Duration, +) -> Result<()> { + let probe_image = openshell_core::image::default_sandbox_image(); writeln!( out, - " Host networking .... checking (may pull {PROBE_IMAGE})" + " Host networking .... checking (may pull {probe_image})" ) .into_diagnostic()?; out.flush().into_diagnostic()?; // No running gateway is needed. Keep the listener alive throughout the - // probe; connect-only nc succeeds without sending application traffic. + // probe; Bash opens a TCP socket without sending application traffic. let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .into_diagnostic()?; let port = listener.local_addr().into_diagnostic()?.port().to_string(); let name = format!("openshell-doctor-{}-{port}", std::process::id()); - let result = docker_output( + let result = command_output( docker, &[ "run", @@ -145,12 +147,14 @@ async fn check_host_network(docker: &Path, out: &mut impl Write, timeout: Durati "--cap-drop=ALL", "--security-opt=no-new-privileges:true", "--read-only", - PROBE_IMAGE, - "nc", - "-z", - "-w", + "--entrypoint", + "/usr/bin/timeout", + &probe_image, "3", - "127.0.0.1", + "/bin/bash", + "-c", + "exec 3<>/dev/tcp/127.0.0.1/$1", + "openshell-doctor", &port, ], timeout, @@ -159,7 +163,7 @@ async fn check_host_network(docker: &Path, out: &mut impl Write, timeout: Durati if !result.as_ref().is_ok_and(|output| output.status.success()) { // Killing the CLI does not stop a daemon-side container. Clean up only // this probe, with its own bounded deadline. - let _ = docker_output(docker, &["rm", "--force", &name], Duration::from_secs(5)).await; + let _ = command_output(docker, &["rm", "--force", &name], Duration::from_secs(5)).await; } let output = result.wrap_err("could not complete the Docker Desktop host-network probe; check Docker and registry access, then rerun doctor")?; if output.status.success() { @@ -171,7 +175,7 @@ async fn check_host_network(docker: &Path, out: &mut impl Write, timeout: Durati return Ok(()); } writeln!(out, " Host networking .... FAILED").into_diagnostic()?; - if output.status.code() == Some(1) { + if matches!(output.status.code(), Some(1 | 124)) { return Err(miette!( "Docker Desktop host networking could not reach this machine's loopback listener. Enable host networking in Docker Desktop Settings > Resources > Network, ensure Enhanced Container Isolation is disabled, then Apply and restart Docker Desktop. Rerun `openshell doctor check` before creating a sandbox." )); @@ -185,6 +189,7 @@ async fn check_host_network(docker: &Path, out: &mut impl Write, timeout: Durati #[cfg(all(test, unix))] mod tests { + use super::super::run_checks; use super::*; use std::os::unix::fs::PermissionsExt; @@ -198,7 +203,12 @@ mod tests { std::fs::write(&docker, format!("#!/bin/sh\n{script}\n")).unwrap(); std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap(); let mut out = Vec::new(); - let result = check_docker(&docker, &mut out, timeout, docker_host).await; + let check = DockerCheck { + docker, + timeout, + docker_host: docker_host.map(str::to_string), + }; + let result = run_checks(&[&check], &mut out).await; (result, String::from_utf8(out).unwrap()) } @@ -216,12 +226,19 @@ esac"# #[tokio::test] async fn desktop_connectivity_failure_is_actionable() { - let (result, out) = check_script(&desktop_script("exit 1"), COMMAND_TIMEOUT, None).await; - let error = result.unwrap_err().to_string(); - assert!(error.contains("Settings > Resources > Network")); - assert!(error.contains("Enhanced Container Isolation")); - assert!(out.contains("FAILED")); - assert!(!out.contains("All checks passed")); + for code in [1, 124] { + let (result, out) = check_script( + &desktop_script(&format!("exit {code}")), + COMMAND_TIMEOUT, + None, + ) + .await; + let error = result.unwrap_err().to_string(); + assert!(error.contains("Settings > Resources > Network")); + assert!(error.contains("Enhanced Container Isolation")); + assert!(out.contains("FAILED")); + assert!(!out.contains("All checks passed")); + } } #[tokio::test] @@ -241,8 +258,16 @@ esac"# #[tokio::test] async fn successful_desktop_probe_passes() { - let (result, out) = check_script(&desktop_script("exit 0"), COMMAND_TIMEOUT, None).await; + let image = openshell_core::image::default_sandbox_image(); + let probe = format!( + r#"case "$*" in +*'--network host'*'--entrypoint /usr/bin/timeout {image} 3 /bin/bash -c exec 3<>/dev/tcp/127.0.0.1/$1 openshell-doctor '*) exit 0;; +*) exit 99;; +esac"# + ); + let (result, out) = check_script(&desktop_script(&probe), COMMAND_TIMEOUT, None).await; result.unwrap(); + assert!(out.contains(&image)); assert!(out.contains("container reached host loopback")); assert!(out.contains("All checks passed")); } diff --git a/crates/openshell-cli/src/commands/doctor/mod.rs b/crates/openshell-cli/src/commands/doctor/mod.rs new file mode 100644 index 0000000000..7e112181f6 --- /dev/null +++ b/crates/openshell-cli/src/commands/doctor/mod.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Orchestration for runtime and system prerequisite checks. + +mod docker; +mod process; + +use futures::future::BoxFuture; +use miette::{IntoDiagnostic, Result}; +use std::io::Write; + +use process::command_output; + +/// A prerequisite provider owns its checks and actionable diagnostics. +/// Additional runtimes can register providers without changing the runner. +trait PrerequisiteCheck: Sync { + fn run<'a>(&'a self, out: &'a mut (dyn Write + Send)) -> BoxFuture<'a, Result<()>>; +} + +pub async fn doctor_check() -> Result<()> { + let docker = docker::DockerCheck::new(); + let checks: [&dyn PrerequisiteCheck; 1] = [&docker]; + // Do not hold a stdout lock across asynchronous commands. + run_checks(&checks, &mut std::io::stdout()).await +} + +async fn run_checks(checks: &[&dyn PrerequisiteCheck], out: &mut (dyn Write + Send)) -> Result<()> { + writeln!(out, "Checking system prerequisites...\n").into_diagnostic()?; + for check in checks { + check.run(out).await?; + } + writeln!(out, "\nAll checks passed.").into_diagnostic()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestCheck { + name: &'static str, + fails: bool, + } + + impl PrerequisiteCheck for TestCheck { + fn run<'a>(&'a self, out: &'a mut (dyn Write + Send)) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + writeln!(out, "{}", self.name).into_diagnostic()?; + if self.fails { + return Err(miette::miette!("{} unavailable", self.name)); + } + Ok(()) + }) + } + } + + #[tokio::test] + async fn runner_accepts_multiple_prerequisite_providers() { + let first = TestCheck { + name: "first", + fails: false, + }; + let second = TestCheck { + name: "second", + fails: false, + }; + let mut out = Vec::new(); + run_checks(&[&first, &second], &mut out).await.unwrap(); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("first\nsecond\n")); + assert!(text.contains("All checks passed")); + } + + #[tokio::test] + async fn failing_provider_prevents_success_summary() { + let check = TestCheck { + name: "runtime", + fails: true, + }; + let mut out = Vec::new(); + let error = run_checks(&[&check], &mut out).await.unwrap_err(); + assert!(error.to_string().contains("runtime unavailable")); + assert!( + !String::from_utf8(out) + .unwrap() + .contains("All checks passed") + ); + } +} diff --git a/crates/openshell-cli/src/commands/doctor/process.rs b/crates/openshell-cli/src/commands/doctor/process.rs new file mode 100644 index 0000000000..1f0dde5603 --- /dev/null +++ b/crates/openshell-cli/src/commands/doctor/process.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded command execution shared by prerequisite providers. + +use miette::{IntoDiagnostic, Result, WrapErr, miette}; +use std::path::Path; +use std::process::Output; +use std::time::Duration; +use tokio::process::Command; + +pub(super) async fn command_output( + program: &Path, + args: &[&str], + timeout: Duration, +) -> Result { + tokio::time::timeout( + timeout, + Command::new(program).args(args).kill_on_drop(true).output(), + ) + .await + .map_err(|_| { + miette!( + "Prerequisite command timed out after {} seconds", + timeout.as_secs() + ) + })? + .into_diagnostic() + .wrap_err("failed to execute prerequisite command") +} diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index d604f92788..7c68a3aa1d 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -69,7 +69,7 @@ The local gateway auto-detects an available runtime. To pin one, set `compute_dr | Runtime | Requirements | |---|---| -| Docker | Docker Desktop or Docker Engine 28.0 or later. | +| Docker | Docker Desktop with host networking enabled and Enhanced Container Isolation disabled, or Docker Engine 28.0 or later. Run `openshell doctor check` to verify prerequisites. | | Podman | Linux with Podman 5.x, cgroups v2, and an active Podman user socket. | | MicroVM | Host virtualization: Hypervisor.framework on macOS or KVM on Linux. | @@ -84,8 +84,6 @@ brew services restart openshell The gateway reads `~/.config/openshell/gateway.toml` if it exists, otherwise the Homebrew config at `$(brew --prefix)/var/openshell/gateway.toml`. -For Docker Desktop, enable host networking and disable Enhanced Container Isolation. Run `openshell doctor check` to verify container-to-host loopback connectivity before creating a sandbox. The check may pull a small Alpine probe image. - ## Linux The script installs a Debian package on Debian and Ubuntu or an RPM package on Fedora and RHEL. Set `OPENSHELL_INSTALL_METHOD=snap` to install the [Snap](#snap) package instead; hosts that already have the OpenShell snap keep refreshing it. Linux packages require glibc 2.28 or newer. diff --git a/docs/how-it-works/gateways/overview.mdx b/docs/how-it-works/gateways/overview.mdx index 010a10a604..ee1f735fa3 100644 --- a/docs/how-it-works/gateways/overview.mdx +++ b/docs/how-it-works/gateways/overview.mdx @@ -167,15 +167,6 @@ openshell doctor check openshell gateway list ``` -On a local Docker Desktop installation, `openshell doctor check` also tests -whether a host-networked container can reach host loopback. The check starts a -temporary container and pulls `alpine:3.23` if needed. If connectivity fails, -enable host networking in Docker Desktop Settings > Resources > Network, -disable Enhanced Container Isolation, and apply the changes before rerunning -the check. Image-pull and container-start failures mean the check could not -verify connectivity. Remote Docker endpoints skip this test; run it on the -gateway host. - For Kubernetes gateways, inspect the gateway workload and cluster events: ```shell diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 557ff19c6d..5dab11d471 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -289,7 +289,7 @@ Common findings: - On Docker Desktop, repeated `Policy fetch failed after 5 attempts` messages can mean host networking is disabled. Run `openshell doctor check` on the gateway host to test container-to-host loopback connectivity. This starts a - temporary probe container and may pull an Alpine image; an image-pull or + temporary probe container and may pull the default sandbox image; an image-pull or container-start error leaves connectivity unverified. Enable host networking in Docker Desktop, ensure Enhanced Container Isolation is disabled, and verify the gateway's primary endpoint is reachable from a host-networked container.