use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use bincode::{Decode, Encode};
use reqwest::Method;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use crate::error::{Error, Result};
use crate::transport::{BoxFuture, RawResponse, RequestExecutor, RequestSpec};
const MAX_REQUEST_FRAME: u32 = 64 * 1024;
const MAX_RESPONSE_FRAME: u32 = 1024 * 1024;
const FRAME_BODY_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Encode, Decode)]
pub enum AgentRequest {
Ping,
Capabilities,
Execute(WireRequest),
}
#[derive(Debug, Encode, Decode)]
pub enum AgentResponse {
Pong,
Capabilities(Capabilities),
Executed(WireResponse),
Failed(String),
}
#[derive(Debug, Clone, Encode, Decode)]
pub struct Capabilities {
pub base_url: String,
pub trading_enabled: bool,
}
#[derive(Debug, Encode, Decode)]
pub struct WireRequest {
method: String,
path: String,
query: Vec<(String, String)>,
body: Option<Vec<u8>>,
requires_auth: bool,
}
#[derive(Debug, Encode, Decode)]
pub struct WireResponse {
status: u16,
retry_after_millis: Option<u64>,
body: Vec<u8>,
}
#[must_use]
pub fn default_socket_path() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR").map_or_else(
|| {
std::env::temp_dir()
.join("revolutx-agent")
.join("agent.sock")
},
|dir| PathBuf::from(dir).join("revolutx-agent.sock"),
)
}
async fn write_frame<T: Encode + Sync>(stream: &mut UnixStream, value: &T) -> std::io::Result<()> {
let bytes = bincode::encode_to_vec(value, bincode::config::standard())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let len = u32::try_from(bytes.len()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "frame too large to encode")
})?;
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
Ok(())
}
async fn read_frame_bytes(reader: &mut UnixStream, max_len: u32) -> std::io::Result<Vec<u8>> {
let mut len_buf = [0u8; 4];
reader.read_exact(&mut len_buf).await?;
let len = u32::from_be_bytes(len_buf);
if len > max_len {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"frame exceeds the maximum size",
));
}
let mut buf = vec![0u8; len as usize];
tokio::time::timeout(FRAME_BODY_TIMEOUT, reader.read_exact(&mut buf))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "frame body timed out"))??;
Ok(buf)
}
fn decode<T: Decode<()>>(bytes: &[u8]) -> std::io::Result<T> {
bincode::decode_from_slice(bytes, bincode::config::standard())
.map(|(value, _)| value)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
#[derive(Debug)]
pub struct AgentExecutor {
base_url: String,
trading_enabled: bool,
conn: tokio::sync::Mutex<UnixStream>,
}
impl AgentExecutor {
pub async fn connect(socket_path: impl AsRef<Path>) -> Result<Self> {
let socket_path = socket_path.as_ref();
let mut stream = UnixStream::connect(socket_path).await.map_err(|e| {
Error::agent(format!(
"cannot connect to agent at {}: {e}",
socket_path.display()
))
})?;
write_frame(&mut stream, &AgentRequest::Capabilities)
.await
.map_err(|e| Error::agent(format!("capabilities handshake failed: {e}")))?;
let bytes = read_frame_bytes(&mut stream, MAX_RESPONSE_FRAME)
.await
.map_err(|e| Error::agent(format!("capabilities handshake failed: {e}")))?;
let AgentResponse::Capabilities(caps) = decode::<AgentResponse>(&bytes)
.map_err(|e| Error::agent(format!("invalid capabilities response: {e}")))?
else {
return Err(Error::agent("agent did not answer with capabilities"));
};
Ok(Self {
base_url: caps.base_url,
trading_enabled: caps.trading_enabled,
conn: tokio::sync::Mutex::new(stream),
})
}
#[must_use]
pub const fn trading_enabled(&self) -> bool {
self.trading_enabled
}
pub async fn ping(&self) -> Result<()> {
match self.round_trip(&AgentRequest::Ping).await? {
AgentResponse::Pong => Ok(()),
_ => Err(Error::agent("agent did not answer ping with Pong")),
}
}
async fn round_trip(&self, request: &AgentRequest) -> Result<AgentResponse> {
let bytes = {
let mut conn = self.conn.lock().await;
write_frame(&mut conn, request)
.await
.map_err(|e| Error::agent(format!("failed to send request to agent: {e}")))?;
read_frame_bytes(&mut conn, MAX_RESPONSE_FRAME)
.await
.map_err(|e| Error::agent(format!("failed to read agent response: {e}")))?
};
decode(&bytes).map_err(|e| Error::agent(format!("invalid agent response: {e}")))
}
}
impl RequestExecutor for AgentExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
let wire = WireRequest {
method: request.method().as_str().to_owned(),
path: request.path().to_owned(),
query: request.query().to_vec(),
body: request.body().map(<[u8]>::to_vec),
requires_auth: request.requires_auth(),
};
Box::pin(async move {
match self.round_trip(&AgentRequest::Execute(wire)).await? {
AgentResponse::Executed(w) => Ok(RawResponse {
status: w.status,
retry_after: w.retry_after_millis.map(Duration::from_millis),
body: w.body,
}),
AgentResponse::Failed(message) => Err(Error::agent(message)),
AgentResponse::Pong | AgentResponse::Capabilities(_) => {
Err(Error::agent("unexpected agent response to an Execute"))
}
}
})
}
fn base_url(&self) -> &str {
&self.base_url
}
fn is_authenticated(&self) -> bool {
true
}
}
pub async fn serve(
executor: Arc<dyn RequestExecutor>,
socket_path: &Path,
trading_enabled: bool,
on_connect: impl FnOnce() + Send,
) -> Result<()> {
let listener = bind(socket_path).await?;
let (mut stream, _addr) = listener
.accept()
.await
.map_err(|e| Error::agent(format!("agent accept failed: {e}")))?;
on_connect();
let rejector = tokio::spawn(async move {
while let Ok((extra, _addr)) = listener.accept().await {
drop(extra);
}
});
handle_connection(executor.as_ref(), &mut stream, trading_enabled).await;
rejector.abort();
Ok(())
}
async fn bind(socket_path: &Path) -> Result<UnixListener> {
if socket_path.exists() {
if UnixStream::connect(socket_path).await.is_ok() {
return Err(Error::agent(format!(
"an agent is already listening on {}",
socket_path.display()
)));
}
std::fs::remove_file(socket_path)
.map_err(|e| Error::agent(format!("could not remove stale socket: {e}")))?;
}
ensure_private_parent(socket_path)?;
let listener = UnixListener::bind(socket_path)
.map_err(|e| Error::agent(format!("could not bind {}: {e}", socket_path.display())))?;
set_socket_permissions(socket_path)?;
Ok(listener)
}
#[cfg(unix)]
fn ensure_private_parent(socket_path: &Path) -> Result<()> {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let Some(parent) = socket_path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
if !parent.exists() {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)
.map_err(|e| Error::agent(format!("could not create {}: {e}", parent.display())))?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|e| Error::agent(format!("could not secure {}: {e}", parent.display())))?;
}
Ok(())
}
#[cfg(not(unix))]
fn ensure_private_parent(_socket_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_socket_permissions(socket_path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| Error::agent(format!("could not set socket permissions: {e}")))
}
#[cfg(not(unix))]
fn set_socket_permissions(_socket_path: &Path) -> Result<()> {
Ok(())
}
async fn handle_connection(
executor: &dyn RequestExecutor,
stream: &mut UnixStream,
trading_enabled: bool,
) {
loop {
let Ok(bytes) = read_frame_bytes(stream, MAX_REQUEST_FRAME).await else {
return;
};
let request: AgentRequest = match decode(&bytes) {
Ok(request) => request,
Err(_) => return,
};
let response = match request {
AgentRequest::Ping => AgentResponse::Pong,
AgentRequest::Capabilities => AgentResponse::Capabilities(Capabilities {
base_url: executor.base_url().to_owned(),
trading_enabled,
}),
AgentRequest::Execute(wire) => execute_forwarded(executor, wire, trading_enabled).await,
};
if write_frame(stream, &response).await.is_err() {
return;
}
}
}
async fn execute_forwarded(
executor: &dyn RequestExecutor,
wire: WireRequest,
trading_enabled: bool,
) -> AgentResponse {
let Ok(method) = Method::from_bytes(wire.method.as_bytes()) else {
return AgentResponse::Failed(format!("invalid HTTP method '{}'", wire.method));
};
if !trading_enabled && method != Method::GET {
return AgentResponse::Failed(
"trading is disabled on this agent; restart it with --enable-trading to allow orders"
.to_owned(),
);
}
let spec =
RequestSpec::from_parts(method, wire.path, wire.query, wire.body, wire.requires_auth);
match executor.execute(spec).await {
Ok(raw) => AgentResponse::Executed(WireResponse {
status: raw.status,
retry_after_millis: raw
.retry_after
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
body: raw.body,
}),
Err(e) => AgentResponse::Failed(e.to_string()),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[derive(Debug)]
struct EchoExecutor;
impl RequestExecutor for EchoExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
let body = request.path().as_bytes().to_vec();
Box::pin(async move {
Ok(RawResponse {
status: 200,
retry_after: None,
body,
})
})
}
#[allow(clippy::unnecessary_literal_bound)]
fn base_url(&self) -> &str {
"http://stub/api/1.0"
}
fn is_authenticated(&self) -> bool {
true
}
}
async fn wait_for(path: &Path) {
for _ in 0..200 {
if path.exists() {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("socket never appeared");
}
fn spawn_echo(socket: PathBuf, trading: bool) -> tokio::task::JoinHandle<Result<()>> {
tokio::spawn(async move { serve(Arc::new(EchoExecutor), &socket, trading, || {}).await })
}
#[tokio::test]
async fn round_trips_and_reports_capabilities() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let server = spawn_echo(socket.clone(), false);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
assert_eq!(executor.base_url(), "http://stub/api/1.0");
assert!(!executor.trading_enabled());
executor.ping().await.unwrap();
let raw = executor
.execute(RequestSpec::get("/balances"))
.await
.unwrap();
assert_eq!(raw.status, 200);
assert_eq!(raw.body, b"/balances");
let raw = executor
.execute(RequestSpec::get("/orders/active"))
.await
.unwrap();
assert_eq!(raw.body, b"/orders/active");
server.abort();
}
#[tokio::test]
async fn agent_refuses_mutations_when_trading_disabled() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let server = spawn_echo(socket.clone(), false);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
assert!(!executor.trading_enabled());
executor
.execute(RequestSpec::get("/orders/active"))
.await
.unwrap();
let err = executor
.execute(RequestSpec::delete("/orders/abc"))
.await
.unwrap_err();
assert!(matches!(err, Error::Agent { .. }));
server.abort();
}
#[tokio::test]
async fn agent_allows_mutations_when_trading_enabled() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let server = spawn_echo(socket.clone(), true);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
assert!(executor.trading_enabled());
let raw = executor
.execute(RequestSpec::delete("/orders/abc"))
.await
.unwrap();
assert_eq!(raw.body, b"/orders/abc");
server.abort();
}
#[tokio::test]
async fn on_connect_fires_once_when_the_client_connects() {
use std::sync::atomic::{AtomicU64, Ordering};
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let connects = Arc::new(AtomicU64::new(0));
let on_connect = {
let connects = Arc::clone(&connects);
move || {
connects.fetch_add(1, Ordering::Relaxed);
}
};
let server = {
let socket = socket.clone();
tokio::spawn(
async move { serve(Arc::new(EchoExecutor), &socket, false, on_connect).await },
)
};
wait_for(&socket).await;
assert_eq!(connects.load(Ordering::Relaxed), 0, "not yet connected");
let executor = AgentExecutor::connect(&socket).await.unwrap();
executor.ping().await.unwrap();
executor.ping().await.unwrap();
assert_eq!(connects.load(Ordering::Relaxed), 1);
server.abort();
}
#[tokio::test]
async fn refuses_a_second_client_connection() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let server = spawn_echo(socket.clone(), false);
wait_for(&socket).await;
let first = AgentExecutor::connect(&socket).await.unwrap();
first.ping().await.unwrap();
assert!(AgentExecutor::connect(&socket).await.is_err());
first.ping().await.unwrap();
server.abort();
}
}