chore: Add Chinese README and refactor NPU backend to use Unix domain sockets

- Introduced a new Chinese version of the README (README_CN.md) to provide localized documentation for AgentOS.
- Refactored the NPU bridge to utilize Unix domain sockets instead of HTTP loopback, enhancing security and performance.
- Updated the NPU backend to include a server socket configuration, ensuring proper communication over Unix sockets.
- Modified the Ntex client to support both network and Unix socket transports, improving flexibility in backend communication.
- Adjusted validation logic to enforce the use of Unix sockets for local model endpoints, rejecting loopback HTTP addresses.
- Enhanced error messages and documentation throughout the codebase to clarify the new socket-based architecture.
This commit is contained in:
emmettlu
2026-08-02 15:48:09 +08:00
parent eee7fed161
commit f863f83960
8 changed files with 1157 additions and 65 deletions
+207 -18
View File
@@ -1,4 +1,4 @@
//! OpenAI-compatible remote, subprocess/NPU bridge, and deterministic mock backends.
//! Remote OpenAI-compatible, Unix-socket, subprocess, and deterministic fake backends.
use agentos_protocol::{
BackendError, BackendStatus, ChatBackend, ChatMessage, ChatRequest, ChatResponse, MessageRole,
@@ -14,7 +14,8 @@ use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
@@ -155,6 +156,17 @@ pub struct NtexJsonClient {
sender: tokio::sync::mpsc::UnboundedSender<NtexRequest>,
}
#[derive(Clone, Debug)]
pub struct NtexUdsJsonClient {
inner: NtexJsonClient,
}
#[derive(Clone, Debug)]
enum NtexTransport {
Network,
Unix(PathBuf),
}
struct NtexRequest {
endpoint: String,
payload: Value,
@@ -180,6 +192,22 @@ impl NtexJsonClient {
max_retries: usize,
user_agent: &'static str,
bearer_token: Option<String>,
) -> Result<Self, BackendError> {
Self::with_transport(
timeout,
max_retries,
user_agent,
bearer_token,
NtexTransport::Network,
)
}
fn with_transport(
timeout: Duration,
max_retries: usize,
user_agent: &'static str,
bearer_token: Option<String>,
transport: NtexTransport,
) -> Result<Self, BackendError> {
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
let (startup_sender, startup_receiver) = std::sync::mpsc::sync_channel(1);
@@ -190,7 +218,7 @@ impl NtexJsonClient {
.name("agentos-ntex-client")
.build(rt::DefaultRuntime)
.block_on(async move {
match build_ntex_client(timeout, user_agent).await {
match build_ntex_client(timeout, user_agent, transport).await {
Ok(client) => {
let _ = startup_sender.send(Ok(()));
run_ntex_worker(
@@ -234,10 +262,47 @@ impl NtexJsonClient {
}
}
async fn build_ntex_client(timeout: Duration, user_agent: &str) -> Result<Client, BackendError> {
let tls = verified_tls_connector()?;
impl NtexUdsJsonClient {
pub fn new(
socket_path: impl Into<PathBuf>,
timeout: Duration,
max_retries: usize,
user_agent: &'static str,
) -> Result<Self, BackendError> {
let socket_path = socket_path.into();
validate_uds_path(&socket_path)?;
Ok(Self {
inner: NtexJsonClient::with_transport(
timeout,
max_retries,
user_agent,
None,
NtexTransport::Unix(socket_path),
)?,
})
}
pub async fn post(
&self,
endpoint_path: &str,
payload: &Value,
) -> Result<NtexJsonResponse, BackendError> {
let endpoint = uds_http_endpoint(endpoint_path)?;
self.inner.post(&endpoint, payload).await
}
}
async fn build_ntex_client(
timeout: Duration,
user_agent: &str,
transport: NtexTransport,
) -> Result<Client, BackendError> {
let connector = match transport {
NtexTransport::Network => Connector::default().openssl(verified_tls_connector()?),
NtexTransport::Unix(socket_path) => unix_http_connector(socket_path),
};
Client::builder()
.connector::<&str>(Connector::default().openssl(tls))
.connector::<&str>(connector)
.response_timeout(timeout)
.response_payload_limit(MAX_RESPONSE_BYTES)
.response_payload_timeout(Millis::from(timeout))
@@ -248,6 +313,50 @@ async fn build_ntex_client(timeout: Duration, user_agent: &str) -> Result<Client
.map_err(|error| BackendError::Transport(error.to_string()))
}
fn unix_http_connector(socket_path: PathBuf) -> Connector {
let factory = ntex::service::fn_factory_with_config(move |config: SharedCfg| {
let socket_path = socket_path.clone();
async move {
Ok::<_, std::io::Error>(ntex::service::fn_service(
move |_request: ntex::connect::Connect<ntex::http::Uri>| {
let config = config.clone();
let socket_path = socket_path.clone();
async move {
rt::unix_connect(socket_path, config)
.await
.map_err(ntex::connect::ConnectError::from)
}
},
))
}
});
Connector::new().connector(factory)
}
fn validate_uds_path(path: &Path) -> Result<(), BackendError> {
const LINUX_SUN_PATH_BYTES: usize = 108;
let bytes = path.as_os_str().as_bytes();
if !path.is_absolute()
|| bytes.is_empty()
|| bytes.len() >= LINUX_SUN_PATH_BYTES
|| bytes.contains(&0)
{
return Err(BackendError::NotReady(
"Unix model socket must be an absolute filesystem path shorter than 108 bytes".into(),
));
}
Ok(())
}
fn uds_http_endpoint(path: &str) -> Result<String, BackendError> {
if !path.starts_with('/') || path.contains(['\r', '\n', '?', '#']) {
return Err(BackendError::NotReady(
"Unix model endpoint must be an absolute HTTP path without query or fragment".into(),
));
}
Ok(format!("http://agentos.local{path}"))
}
async fn run_ntex_worker(
mut receiver: tokio::sync::mpsc::UnboundedReceiver<NtexRequest>,
client: Client,
@@ -482,7 +591,7 @@ impl ChatBackend for FakeBackend {
BackendStatus {
name: self.name().into(),
ready: true,
reason: "deterministic local mock".into(),
reason: "deterministic unit-test fake".into(),
details: json!({"hardware_required": false}),
}
}
@@ -495,7 +604,10 @@ impl ChatBackend for FakeBackend {
.find(|message| message.role == MessageRole::Tool)
{
return Ok(ChatResponse {
content: format!("Mock 已完成只读检查:{}", tool.content),
content: format!(
"Fake backend completed a read-only inspection: {}",
tool.content
),
usage: TokenUsage {
input_tokens: 12,
output_tokens: 8,
@@ -525,7 +637,7 @@ impl ChatBackend for FakeBackend {
});
}
Ok(ChatResponse {
content: "Mock backend is ready.".into(),
content: "Fake backend is ready.".into(),
..ChatResponse::default()
})
}
@@ -811,19 +923,32 @@ fn required_string(value: &Value, key: &str) -> Result<String, BackendError> {
fn validated_base_url(value: &str) -> Result<Url, BackendError> {
let url = Url::parse(value).map_err(|error| BackendError::NotReady(error.to_string()))?;
let loopback = url
.host_str()
.is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1"));
let local = match url.host() {
Some(url::Host::Domain(host)) => {
host.eq_ignore_ascii_case("localhost")
|| host.to_ascii_lowercase().ends_with(".localhost")
}
Some(url::Host::Ipv4(address)) => address.is_loopback() || address.is_unspecified(),
Some(url::Host::Ipv6(address)) => {
address.is_loopback()
|| address.is_unspecified()
|| address
.to_ipv4_mapped()
.is_some_and(|mapped| mapped.is_loopback() || mapped.is_unspecified())
}
None => true,
};
if url.cannot_be_a_base()
|| url.host_str().is_none()
|| local
|| url.username() != ""
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| !(url.scheme() == "https" || (url.scheme() == "http" && loopback))
|| !matches!(url.scheme(), "http" | "https")
{
return Err(BackendError::NotReady(
"invalid OpenAI-compatible base URL".into(),
"remote OpenAI-compatible base URL must use HTTP(S), not a local or loopback host"
.into(),
));
}
Ok(url)
@@ -950,10 +1075,12 @@ mod tests {
}
#[test]
fn only_https_or_loopback_http_is_accepted() {
fn remote_http_is_accepted_but_loopback_model_urls_are_rejected() {
assert!(validated_base_url("https://api.openai.com/v1").is_ok());
assert!(validated_base_url("http://127.0.0.1:8080/v1").is_ok());
assert!(validated_base_url("http://example.com/v1").is_err());
assert!(validated_base_url("http://model.example/v1").is_ok());
assert!(validated_base_url("http://127.0.0.1:8080/v1").is_err());
assert!(validated_base_url("http://localhost:8080/v1").is_err());
assert!(validated_base_url("http://[::ffff:127.0.0.1]:8080/v1").is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1033,4 +1160,66 @@ mod tests {
}
server.await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ntex_client_carries_local_http_over_a_unix_socket() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn read_http_request(stream: &mut tokio::net::UnixStream) -> String {
let mut request = Vec::new();
loop {
let mut chunk = [0_u8; 4096];
let received = stream.read(&mut chunk).await.unwrap();
assert_ne!(received, 0);
request.extend_from_slice(&chunk[..received]);
let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n")
else {
continue;
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
let request_end = header_end + 4 + content_length;
if request.len() >= request_end {
return String::from_utf8(request[..request_end].to_vec()).unwrap();
}
}
}
let root =
std::env::temp_dir().join(format!("agentos-inference-uds-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let socket_path = root.join("model.sock");
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
)
.await
.unwrap();
});
let client =
NtexUdsJsonClient::new(&socket_path, Duration::from_secs(2), 0, "agentos-uds-test")
.unwrap();
let response = client
.post("/v1/chat/completions", &json!({"ping": true}))
.await
.unwrap();
assert!(response.is_success());
assert_eq!(response.body, json!({"ok": true}));
server.await.unwrap();
std::fs::remove_dir_all(root).unwrap();
}
}