diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md index 2d235141..08ba5170 100644 --- a/architecture/gateway-single-node.md +++ b/architecture/gateway-single-node.md @@ -170,6 +170,7 @@ For the target daemon (local or remote): - Volume bind mount: `openshell-cluster-{name}:/var/lib/rancher/k3s`. - Network: `openshell-cluster-{name}` (per-gateway bridge network). - Extra host: `host.docker.internal:host-gateway`. + - The cluster entrypoint prefers the resolved IPv4 for `host.docker.internal` when populating sandbox pod `hostAliases`, then falls back to the container default gateway. This keeps sandbox host aliases working on Docker Desktop, where the host-reachable IP differs from the bridge gateway. - Port mappings: | Container Port | Host Port | Purpose | diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index eb0770ca..750aa98b 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -77,6 +77,13 @@ pub struct Config { /// the server over mTLS. #[serde(default)] pub client_tls_secret_name: String, + + /// Host gateway IP for sandbox pod hostAliases. + /// When set, sandbox pods get hostAliases entries mapping + /// `host.docker.internal` and `host.openshell.internal` to this IP, + /// allowing them to reach services running on the Docker host. + #[serde(default)] + pub host_gateway_ip: String, } /// TLS configuration. @@ -125,6 +132,7 @@ impl Config { ssh_handshake_skew_secs: default_ssh_handshake_skew_secs(), ssh_session_ttl_secs: default_ssh_session_ttl_secs(), client_tls_secret_name: String::new(), + host_gateway_ip: String::new(), } } @@ -232,6 +240,13 @@ impl Config { self.client_tls_secret_name = name.into(); self } + + /// Set the host gateway IP for sandbox pod hostAliases. + #[must_use] + pub fn with_host_gateway_ip(mut self, ip: impl Into) -> Self { + self.host_gateway_ip = ip.into(); + self + } } fn default_bind_address() -> SocketAddr { diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index fb58b3b0..31210ac6 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -119,6 +119,7 @@ pub async fn run_server(config: Config, tracing_log_bus: TracingLogBus) -> Resul config.ssh_handshake_secret.clone(), config.ssh_handshake_skew_secs, config.client_tls_secret_name.clone(), + config.host_gateway_ip.clone(), ) .await .map_err(|e| Error::execution(format!("failed to create kubernetes client: {e}")))?; diff --git a/crates/openshell-server/src/main.rs b/crates/openshell-server/src/main.rs index 50fe81dd..5178693a 100644 --- a/crates/openshell-server/src/main.rs +++ b/crates/openshell-server/src/main.rs @@ -91,6 +91,12 @@ struct Args { #[arg(long, env = "OPENSHELL_CLIENT_TLS_SECRET_NAME")] client_tls_secret_name: Option, + /// Host gateway IP for sandbox pod hostAliases. + /// When set, sandbox pods get hostAliases entries mapping + /// host.docker.internal and host.openshell.internal to this IP. + #[arg(long, env = "OPENSHELL_HOST_GATEWAY_IP")] + host_gateway_ip: Option, + /// Disable TLS entirely — listen on plaintext HTTP. /// Use this when the gateway sits behind a reverse proxy or tunnel /// (e.g. Cloudflare Tunnel) that terminates TLS at the edge. @@ -178,6 +184,10 @@ async fn main() -> Result<()> { config = config.with_client_tls_secret_name(name); } + if let Some(ip) = args.host_gateway_ip { + config = config.with_host_gateway_ip(ip); + } + if args.disable_tls { info!("TLS disabled — listening on plaintext HTTP"); } else if args.disable_gateway_auth { diff --git a/crates/openshell-server/src/sandbox/mod.rs b/crates/openshell-server/src/sandbox/mod.rs index f8ab6a6d..49051e93 100644 --- a/crates/openshell-server/src/sandbox/mod.rs +++ b/crates/openshell-server/src/sandbox/mod.rs @@ -49,6 +49,9 @@ pub struct SandboxClient { ssh_handshake_skew_secs: u64, /// When non-empty, sandbox pods get this K8s secret mounted for mTLS to the server. client_tls_secret_name: String, + /// When non-empty, sandbox pods get `hostAliases` entries mapping + /// `host.docker.internal` and `host.openshell.internal` to this IP. + host_gateway_ip: String, } impl std::fmt::Debug for SandboxClient { @@ -71,6 +74,7 @@ impl SandboxClient { ssh_handshake_secret: String, ssh_handshake_skew_secs: u64, client_tls_secret_name: String, + host_gateway_ip: String, ) -> Result { let mut config = match kube::Config::incluster() { Ok(c) => c, @@ -92,6 +96,7 @@ impl SandboxClient { ssh_handshake_secret, ssh_handshake_skew_secs, client_tls_secret_name, + host_gateway_ip, }) } @@ -206,6 +211,7 @@ impl SandboxClient { self.ssh_handshake_secret(), self.ssh_handshake_skew_secs(), &self.client_tls_secret_name, + &self.host_gateway_ip, ); let api = self.api(); @@ -759,6 +765,7 @@ fn sandbox_to_k8s_spec( ssh_handshake_secret: &str, ssh_handshake_skew_secs: u64, client_tls_secret_name: &str, + host_gateway_ip: &str, ) -> serde_json::Value { let mut root = serde_json::Map::new(); if let Some(spec) = spec { @@ -787,6 +794,7 @@ fn sandbox_to_k8s_spec( ssh_handshake_skew_secs, &spec.environment, client_tls_secret_name, + host_gateway_ip, ), ); if !template.agent_socket.is_empty() { @@ -820,6 +828,7 @@ fn sandbox_to_k8s_spec( ssh_handshake_skew_secs, spec_env, client_tls_secret_name, + host_gateway_ip, ), ); } @@ -843,6 +852,7 @@ fn sandbox_template_to_k8s( ssh_handshake_skew_secs: u64, spec_environment: &std::collections::HashMap, client_tls_secret_name: &str, + host_gateway_ip: &str, ) -> serde_json::Value { if let Some(pod_template) = struct_to_json(&template.pod_template) { return inject_pod_template( @@ -859,6 +869,7 @@ fn sandbox_template_to_k8s( ssh_handshake_skew_secs, spec_environment, client_tls_secret_name, + host_gateway_ip, ); } @@ -968,6 +979,17 @@ fn sandbox_template_to_k8s( ); } + // Add hostAliases so sandbox pods can reach the Docker host. + if !host_gateway_ip.is_empty() { + spec.insert( + "hostAliases".to_string(), + serde_json::json!([{ + "ip": host_gateway_ip, + "hostnames": ["host.docker.internal", "host.openshell.internal"] + }]), + ); + } + let mut template_value = serde_json::Map::new(); if !metadata.is_empty() { template_value.insert("metadata".to_string(), serde_json::Value::Object(metadata)); @@ -997,6 +1019,7 @@ fn inject_pod_template( ssh_handshake_skew_secs: u64, spec_environment: &std::collections::HashMap, client_tls_secret_name: &str, + host_gateway_ip: &str, ) -> serde_json::Value { let Some(spec) = pod_template .get_mut("spec") @@ -1012,6 +1035,17 @@ fn inject_pod_template( ); } + // Add hostAliases so sandbox pods can reach the Docker host. + if !host_gateway_ip.is_empty() { + spec.insert( + "hostAliases".to_string(), + serde_json::json!([{ + "ip": host_gateway_ip, + "hostnames": ["host.docker.internal", "host.openshell.internal"] + }]), + ); + } + // Inject TLS volume at the pod spec level. if !client_tls_secret_name.is_empty() { let volumes = spec @@ -1806,6 +1840,7 @@ mod tests { 300, &std::collections::HashMap::new(), "", + "", ); assert_eq!( @@ -1851,6 +1886,7 @@ mod tests { 300, &std::collections::HashMap::new(), "", + "", ); let limits = &pod_template["spec"]["containers"][0]["resources"]["limits"]; @@ -1910,6 +1946,7 @@ mod tests { 300, &std::collections::HashMap::new(), "", + "", ); assert_eq!( @@ -1921,4 +1958,116 @@ mod tests { serde_json::json!(GPU_RESOURCE_QUANTITY) ); } + + #[test] + fn host_aliases_injected_when_gateway_ip_set() { + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + "openshell/sandbox:latest", + "", + "sandbox-id", + "sandbox-name", + "https://gateway.example.com", + "0.0.0.0:2222", + "secret", + 300, + &std::collections::HashMap::new(), + "", + "172.17.0.1", + ); + + let host_aliases = pod_template["spec"]["hostAliases"] + .as_array() + .expect("hostAliases should exist"); + assert_eq!(host_aliases.len(), 1); + assert_eq!(host_aliases[0]["ip"], "172.17.0.1"); + let hostnames = host_aliases[0]["hostnames"] + .as_array() + .expect("hostnames should exist"); + assert!(hostnames.contains(&serde_json::json!("host.docker.internal"))); + assert!(hostnames.contains(&serde_json::json!("host.openshell.internal"))); + } + + #[test] + fn host_aliases_not_injected_when_gateway_ip_empty() { + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + "openshell/sandbox:latest", + "", + "sandbox-id", + "sandbox-name", + "https://gateway.example.com", + "0.0.0.0:2222", + "secret", + 300, + &std::collections::HashMap::new(), + "", + "", + ); + + assert!( + pod_template["spec"]["hostAliases"].is_null(), + "hostAliases should not be present when host_gateway_ip is empty" + ); + } + + #[test] + fn host_aliases_injected_in_custom_pod_template() { + let template = SandboxTemplate { + pod_template: Some(Struct { + fields: [( + "spec".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { + fields: [( + "containers".to_string(), + Value { + kind: Some(Kind::ListValue(prost_types::ListValue { + values: vec![Value { + kind: Some(Kind::StructValue(Struct { + fields: [( + "name".to_string(), + string_value("agent"), + )] + .into_iter() + .collect(), + })), + }], + })), + }, + )] + .into_iter() + .collect(), + })), + }, + )] + .into_iter() + .collect(), + }), + ..SandboxTemplate::default() + }; + + let pod_template = sandbox_template_to_k8s( + &template, + false, + "openshell/sandbox:latest", + "", + "sandbox-id", + "sandbox-name", + "https://gateway.example.com", + "0.0.0.0:2222", + "secret", + 300, + &std::collections::HashMap::new(), + "", + "192.168.65.2", + ); + + let host_aliases = pod_template["spec"]["hostAliases"] + .as_array() + .expect("hostAliases should exist in custom pod template"); + assert_eq!(host_aliases[0]["ip"], "192.168.65.2"); + } } diff --git a/deploy/docker/cluster-entrypoint.sh b/deploy/docker/cluster-entrypoint.sh index 7a9a6081..f2e768c6 100644 --- a/deploy/docker/cluster-entrypoint.sh +++ b/deploy/docker/cluster-entrypoint.sh @@ -330,6 +330,27 @@ if [ "${GPU_ENABLED:-}" = "true" ]; then fi fi +# --------------------------------------------------------------------------- +# Detect host gateway IP for sandbox pod hostAliases +# --------------------------------------------------------------------------- +# Sandbox pods need to reach services running on the Docker host (e.g. +# provider endpoints during local development). On Docker Desktop, +# host.docker.internal resolves to a special host-reachable IP that is NOT the +# bridge default gateway, so prefer Docker's own resolution when available. +# Fall back to the container default gateway on Linux engines where +# host.docker.internal commonly maps to the bridge gateway anyway. +HOST_GATEWAY_IP=$(getent ahostsv4 host.docker.internal 2>/dev/null | awk 'NR == 1 { print $1; exit }') +if [ -n "$HOST_GATEWAY_IP" ]; then + echo "Detected host gateway IP from host.docker.internal: $HOST_GATEWAY_IP" +else + HOST_GATEWAY_IP=$(ip -4 route | awk '/default/ { print $3; exit }') + if [ -n "$HOST_GATEWAY_IP" ]; then + echo "Detected host gateway IP from default route: $HOST_GATEWAY_IP" + else + echo "Warning: Could not detect host gateway IP from host.docker.internal or default route" + fi +fi + # --------------------------------------------------------------------------- # Override image tag and pull policy for local development # --------------------------------------------------------------------------- @@ -428,6 +449,16 @@ if [ -f "$HELMCHART" ]; then fi fi +# Inject host gateway IP into the HelmChart manifest so sandbox pods can +# reach services on the Docker host via host.docker.internal / host.openshell.internal. +if [ -n "$HOST_GATEWAY_IP" ] && [ -f "$HELMCHART" ]; then + echo "Setting host gateway IP: $HOST_GATEWAY_IP" + sed -i "s|__HOST_GATEWAY_IP__|${HOST_GATEWAY_IP}|g" "$HELMCHART" +else + # Clear the placeholder so the server gets an empty string (feature disabled) + sed -i "s|hostGatewayIP: __HOST_GATEWAY_IP__|hostGatewayIP: \"\"|g" "$HELMCHART" +fi + # Inject chart checksum into the HelmChart manifest so that a changed chart # tarball causes the HelmChart CR spec to differ, forcing the k3s Helm # controller to upgrade the release. diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index c75510b7..175b2606 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -65,6 +65,10 @@ spec: - name: OPENSHELL_SSH_GATEWAY_PORT value: {{ .Values.server.sshGatewayPort | quote }} {{- end }} + {{- if .Values.server.hostGatewayIP }} + - name: OPENSHELL_HOST_GATEWAY_IP + value: {{ .Values.server.hostGatewayIP | quote }} + {{- end }} - name: OPENSHELL_SSH_HANDSHAKE_SECRET value: {{ required "server.sshHandshakeSecret is required" .Values.server.sshHandshakeSecret | quote }} {{- if .Values.server.disableTls }} diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 6276eafa..2691fc48 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -85,6 +85,11 @@ server: # Required — the server will refuse to start if empty. For cluster deployments # this is auto-generated by the entrypoint script. sshHandshakeSecret: "" + # Host gateway IP for sandbox pod hostAliases. When set, sandbox pods get + # hostAliases entries mapping host.docker.internal and host.openshell.internal + # to this IP, allowing them to reach services running on the Docker host. + # Auto-detected by the cluster entrypoint script. + hostGatewayIP: "" # Disable gateway authentication (mTLS client certificate requirement). # Set to true when the gateway sits behind a reverse proxy (e.g. # Cloudflare Tunnel) that terminates TLS. diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index fc5fb660..2245c72e 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -34,6 +34,7 @@ spec: sshGatewayPort: __SSH_GATEWAY_PORT__ sshHandshakeSecret: __SSH_HANDSHAKE_SECRET__ grpcEndpoint: "https://openshell.openshell.svc.cluster.local:8080" + hostGatewayIP: __HOST_GATEWAY_IP__ disableGatewayAuth: __DISABLE_GATEWAY_AUTH__ disableTls: __DISABLE_TLS__ tls: diff --git a/e2e/rust/tests/cf_auth_smoke.rs b/e2e/rust/tests/cf_auth_smoke.rs index 0db4a613..671c117a 100644 --- a/e2e/rust/tests/cf_auth_smoke.rs +++ b/e2e/rust/tests/cf_auth_smoke.rs @@ -21,6 +21,9 @@ async fn run_isolated(args: &[&str]) -> (String, i32) { .env("XDG_CONFIG_HOME", tmpdir.path()) .env("HOME", tmpdir.path()) .env_remove("OPENSHELL_GATEWAY") + // `gateway add` may enter the browser auth flow, which prompts on stdin. + // Use a closed stdin so auth is skipped instead of hanging the test. + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -40,6 +43,9 @@ async fn run_with_config(tmpdir: &std::path::Path, args: &[&str]) -> (String, i3 .env("XDG_CONFIG_HOME", tmpdir) .env("HOME", tmpdir) .env_remove("OPENSHELL_GATEWAY") + // `gateway add` may enter the browser auth flow, which prompts on stdin. + // Use a closed stdin so auth is skipped instead of hanging the test. + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -409,4 +415,3 @@ async fn gateway_add_ssh_url_requires_port() { ); } - diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs new file mode 100644 index 00000000..e66ef77d --- /dev/null +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::process::Stdio; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; + +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::sandbox::SandboxGuard; +use tempfile::NamedTempFile; + +const INFERENCE_PROVIDER_NAME: &str = "e2e-host-inference"; +static INFERENCE_ROUTE_LOCK: Mutex<()> = Mutex::new(()); + +async fn run_cli(args: &[&str]) -> Result { + let mut cmd = openshell_cmd(); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .map_err(|e| format!("failed to spawn openshell {}: {e}", args.join(" ")))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + if !output.status.success() { + return Err(format!( + "openshell {} failed (exit {:?}):\n{combined}", + args.join(" "), + output.status.code() + )); + } + + Ok(combined) +} + +fn spawn_server( + response_body: fn(&str) -> String, +) -> Result<(u16, thread::JoinHandle<()>), String> { + let listener = TcpListener::bind("0.0.0.0:0") + .map_err(|e| format!("bind echo server on 0.0.0.0:0: {e}"))?; + listener + .set_nonblocking(false) + .map_err(|e| format!("configure echo server blocking mode: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("read echo server address: {e}"))? + .port(); + + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept echo request"); + stream + .set_read_timeout(Some(Duration::from_secs(30))) + .expect("set read timeout"); + stream + .set_write_timeout(Some(Duration::from_secs(30))) + .expect("set write timeout"); + + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + loop { + let read = stream.read(&mut buf).expect("read echo request"); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + let request_text = String::from_utf8_lossy(&request); + let body = response_body(&request_text); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("write echo response"); + stream.flush().expect("flush echo response"); + }); + + Ok((port, handle)) +} + +fn spawn_echo_server() -> Result<(u16, thread::JoinHandle<()>), String> { + spawn_server(|request_text| { + let request_line = request_text.lines().next().unwrap_or_default(); + format!(r#"{{"message":"hello-from-host","request_line":"{request_line}"}}"#) + }) +} + +fn spawn_inference_server() -> Result<(u16, thread::JoinHandle<()>), String> { + spawn_server(|_| { + r#"{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"host-echo","choices":[{"index":0,"message":{"role":"assistant","content":"hello-from-host"},"finish_reason":"stop"}]}"#.to_string() + }) +} + +async fn provider_exists(name: &str) -> bool { + let mut cmd = openshell_cmd(); + cmd.arg("provider") + .arg("get") + .arg(name) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + cmd.status().await.is_ok_and(|status| status.success()) +} + +async fn delete_provider(name: &str) { + let mut cmd = openshell_cmd(); + cmd.arg("provider") + .arg("delete") + .arg(name) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = cmd.status().await; +} + +fn write_policy(port: u16) -> Result { + let mut file = NamedTempFile::new().map_err(|e| format!("create temp policy file: {e}"))?; + let policy = format!( + r#"version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + host_echo: + name: host_echo + endpoints: + - host: host.openshell.internal + port: {port} + allowed_ips: + - "172.0.0.0/8" + binaries: + - path: /usr/bin/curl +"# + ); + file.write_all(policy.as_bytes()) + .map_err(|e| format!("write temp policy file: {e}"))?; + file.flush() + .map_err(|e| format!("flush temp policy file: {e}"))?; + Ok(file) +} + +#[tokio::test] +async fn sandbox_reaches_host_openshell_internal_via_host_gateway_alias() { + let (port, server) = spawn_echo_server().expect("start host echo server"); + let policy = write_policy(port).expect("write custom policy"); + let policy_path = policy + .path() + .to_str() + .expect("temp policy path should be utf-8") + .to_string(); + + let guard = SandboxGuard::create(&[ + "--policy", + &policy_path, + "--", + "curl", + "--silent", + "--show-error", + &format!("http://host.openshell.internal:{port}/"), + ]) + .await + .expect("sandbox create with host.openshell.internal echo request"); + + server + .join() + .expect("echo server thread should exit cleanly"); + + assert!( + guard + .create_output + .contains("\"message\":\"hello-from-host\""), + "expected sandbox to receive host echo response:\n{}", + guard.create_output + ); + assert!( + guard + .create_output + .contains("\"request_line\":\"GET / HTTP/1.1\""), + "expected host echo server to receive sandbox HTTP request:\n{}", + guard.create_output + ); +} + +#[tokio::test] +async fn sandbox_inference_local_routes_to_host_openshell_internal() { + let _inference_lock = INFERENCE_ROUTE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let current_inference = run_cli(&["inference", "get"]) + .await + .expect("read current inference config"); + if !current_inference.contains("Not configured") { + eprintln!("Skipping test: existing inference config would make shared state unsafe"); + return; + } + + let (port, server) = spawn_inference_server().expect("start host inference echo server"); + + if provider_exists(INFERENCE_PROVIDER_NAME).await { + delete_provider(INFERENCE_PROVIDER_NAME).await; + } + + run_cli(&[ + "provider", + "create", + "--name", + INFERENCE_PROVIDER_NAME, + "--type", + "openai", + "--credential", + "OPENAI_API_KEY=dummy", + "--config", + &format!("OPENAI_BASE_URL=http://host.openshell.internal:{port}/v1"), + ]) + .await + .expect("create host-backed OpenAI provider"); + + run_cli(&[ + "inference", + "set", + "--provider", + INFERENCE_PROVIDER_NAME, + "--model", + "host-echo-model", + "--no-verify", + ]) + .await + .expect("point inference.local at host-backed provider"); + + let guard = SandboxGuard::create(&[ + "--", + "curl", + "--silent", + "--show-error", + "https://inference.local/v1/chat/completions", + "--json", + r#"{"messages":[{"role":"user","content":"hello"}]}"#, + ]) + .await + .expect("sandbox create with inference.local request"); + + server + .join() + .expect("inference echo server thread should exit cleanly"); + + assert!( + guard + .create_output + .contains("\"object\":\"chat.completion\""), + "expected sandbox to receive inference response:\n{}", + guard.create_output + ); + assert!( + guard.create_output.contains("hello-from-host"), + "expected sandbox to receive echoed inference content:\n{}", + guard.create_output + ); +}