mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-17 05:31:16 +08:00
368 lines
10 KiB
Rust
368 lines
10 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
#![cfg(feature = "e2e")]
|
|
|
|
use std::io::Write;
|
|
use std::process::Stdio;
|
|
use std::sync::Mutex;
|
|
|
|
use openshell_e2e::harness::binary::openshell_cmd;
|
|
use openshell_e2e::harness::sandbox::SandboxGuard;
|
|
use tempfile::NamedTempFile;
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::net::TcpListener;
|
|
use tokio::task::JoinHandle;
|
|
|
|
const INFERENCE_PROVIDER_NAME: &str = "e2e-host-inference";
|
|
const INFERENCE_PROVIDER_UNREACHABLE_NAME: &str = "e2e-host-inference-unreachable";
|
|
static INFERENCE_ROUTE_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
async fn run_cli(args: &[&str]) -> Result<String, String> {
|
|
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)
|
|
}
|
|
|
|
struct HostServer {
|
|
port: u16,
|
|
task: JoinHandle<()>,
|
|
}
|
|
|
|
impl HostServer {
|
|
async fn start(response_body: &str) -> Result<Self, String> {
|
|
let listener = TcpListener::bind(("0.0.0.0", 0))
|
|
.await
|
|
.map_err(|e| format!("bind host test server: {e}"))?;
|
|
let port = listener
|
|
.local_addr()
|
|
.map_err(|e| format!("read host test server address: {e}"))?
|
|
.port();
|
|
let response_body = response_body.as_bytes().to_vec();
|
|
let task = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
let body = response_body.clone();
|
|
tokio::spawn(async move {
|
|
let mut request = Vec::new();
|
|
let mut buf = [0_u8; 1024];
|
|
loop {
|
|
let Ok(read) = stream.read(&mut buf).await else {
|
|
return;
|
|
};
|
|
if read == 0 {
|
|
return;
|
|
}
|
|
request.extend_from_slice(&buf[..read]);
|
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
break;
|
|
}
|
|
}
|
|
|
|
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()
|
|
);
|
|
if stream.write_all(response.as_bytes()).await.is_err() {
|
|
return;
|
|
}
|
|
let _ = stream.write_all(&body).await;
|
|
let _ = stream.shutdown().await;
|
|
});
|
|
}
|
|
});
|
|
|
|
Ok(Self { port, task })
|
|
}
|
|
}
|
|
|
|
impl Drop for HostServer {
|
|
fn drop(&mut self) {
|
|
self.task.abort();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async fn create_openai_provider(name: &str, base_url: &str) -> Result<String, String> {
|
|
run_cli(&[
|
|
"provider",
|
|
"create",
|
|
"--name",
|
|
name,
|
|
"--type",
|
|
"openai",
|
|
"--credential",
|
|
"OPENAI_API_KEY=dummy",
|
|
"--config",
|
|
&format!("OPENAI_BASE_URL={base_url}"),
|
|
])
|
|
.await
|
|
}
|
|
|
|
fn write_policy(port: u16) -> Result<NamedTempFile, String> {
|
|
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:
|
|
- "10.0.0.0/8"
|
|
- "172.0.0.0/8"
|
|
- "192.168.0.0/16"
|
|
- "fc00::/7"
|
|
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 server = HostServer::start(r#"{"message":"hello-from-host"}"#)
|
|
.await
|
|
.expect("start host echo server");
|
|
let policy = write_policy(server.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",
|
|
"--max-time",
|
|
"15",
|
|
&format!("http://host.openshell.internal:{}/", server.port),
|
|
])
|
|
.await
|
|
.expect("sandbox create with host.openshell.internal echo request");
|
|
|
|
assert!(
|
|
guard
|
|
.create_output
|
|
.contains("\"message\":\"hello-from-host\""),
|
|
"expected sandbox to receive host echo response:\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 server = HostServer::start(
|
|
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"}]}"#,
|
|
)
|
|
.await
|
|
.expect("start host inference echo server");
|
|
|
|
if provider_exists(INFERENCE_PROVIDER_NAME).await {
|
|
delete_provider(INFERENCE_PROVIDER_NAME).await;
|
|
}
|
|
|
|
create_openai_provider(
|
|
INFERENCE_PROVIDER_NAME,
|
|
&format!("http://host.openshell.internal:{}/v1", server.port),
|
|
)
|
|
.await
|
|
.expect("create host-backed OpenAI provider");
|
|
|
|
let inference_output = run_cli(&[
|
|
"inference",
|
|
"set",
|
|
"--provider",
|
|
INFERENCE_PROVIDER_NAME,
|
|
"--model",
|
|
"host-echo-model",
|
|
"--no-verify",
|
|
])
|
|
.await
|
|
.expect("point inference.local at host-backed provider");
|
|
|
|
assert!(
|
|
!inference_output.contains("Validated Endpoints:"),
|
|
"did not expect local CLI verification for host-only alias:\n{inference_output}"
|
|
);
|
|
|
|
let guard = SandboxGuard::create(&[
|
|
"--",
|
|
"curl",
|
|
"--silent",
|
|
"--show-error",
|
|
"--max-time",
|
|
"15",
|
|
"https://inference.local/v1/chat/completions",
|
|
"--json",
|
|
r#"{"messages":[{"role":"user","content":"hello"}]}"#,
|
|
])
|
|
.await
|
|
.expect("sandbox create with inference.local request");
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inference_set_supports_no_verify_for_unreachable_endpoint() {
|
|
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;
|
|
}
|
|
|
|
if provider_exists(INFERENCE_PROVIDER_UNREACHABLE_NAME).await {
|
|
delete_provider(INFERENCE_PROVIDER_UNREACHABLE_NAME).await;
|
|
}
|
|
|
|
create_openai_provider(
|
|
INFERENCE_PROVIDER_UNREACHABLE_NAME,
|
|
"http://host.openshell.internal:9/v1",
|
|
)
|
|
.await
|
|
.expect("create unreachable OpenAI provider");
|
|
|
|
let verify_err = run_cli(&[
|
|
"inference",
|
|
"set",
|
|
"--provider",
|
|
INFERENCE_PROVIDER_UNREACHABLE_NAME,
|
|
"--model",
|
|
"host-echo-model",
|
|
])
|
|
.await
|
|
.expect_err("default verification should fail for unreachable endpoint");
|
|
|
|
assert!(
|
|
verify_err.contains("failed to verify inference endpoint"),
|
|
"expected verification failure output:\n{verify_err}"
|
|
);
|
|
let normalized_verify_err: String = verify_err
|
|
.chars()
|
|
.filter(|c| !c.is_whitespace() && *c != '│')
|
|
.collect();
|
|
assert!(
|
|
normalized_verify_err.contains("--no-verify"),
|
|
"expected retry hint in failure output:\n{verify_err}"
|
|
);
|
|
|
|
let no_verify_output = run_cli(&[
|
|
"inference",
|
|
"set",
|
|
"--provider",
|
|
INFERENCE_PROVIDER_UNREACHABLE_NAME,
|
|
"--model",
|
|
"host-echo-model",
|
|
"--no-verify",
|
|
])
|
|
.await
|
|
.expect("no-verify should bypass validation");
|
|
|
|
assert!(
|
|
!no_verify_output.contains("Validated Endpoints:"),
|
|
"did not expect validation output when bypassing verification:\n{no_verify_output}"
|
|
);
|
|
}
|