mod agent_factory;
pub(crate) mod deps;
mod handlers;
mod router;
#[cfg(test)]
mod test_support;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
use zeph_core::serve::LiveSessionRegistry;
use self::deps::ServeAgentDeps;
const EVICT_SCAN_INTERVAL: Duration = Duration::from_mins(1);
pub(crate) struct ServeSessionsArgs {
pub(crate) http_addr: Option<String>,
pub(crate) acp: bool,
pub(crate) max_sessions: Option<usize>,
pub(crate) vault_backend: Option<String>,
pub(crate) vault_key: Option<std::path::PathBuf>,
pub(crate) vault_path: Option<std::path::PathBuf>,
}
#[derive(Clone)]
pub(crate) struct AppState {
pub(crate) registry: Arc<LiveSessionRegistry>,
pub(crate) started_at: std::time::Instant,
pub(crate) supervisor: TaskSupervisor,
pub(crate) deps: ServeAgentDeps,
pub(crate) mailbox_capacity: usize,
pub(crate) max_sessions: usize,
pub(crate) sanitizer: zeph_core::ContentSanitizer,
}
pub(crate) async fn handle_serve_sessions_command(
args: ServeSessionsArgs,
config_path: Option<&std::path::Path>,
) -> anyhow::Result<()> {
use crate::bootstrap::{load_config_or_default, resolve_config_path};
let config_file = resolve_config_path(config_path);
let config = load_config_or_default(&config_file);
let serve_config = &config.serve;
let http_addr: SocketAddr = args
.http_addr
.as_deref()
.unwrap_or(&serve_config.http_addr)
.parse()
.map_err(|e| anyhow::anyhow!("invalid [serve] http_addr: {e}"))?;
let max_sessions = args.max_sessions.unwrap_or(serve_config.max_sessions);
if args.acp {
#[cfg(feature = "acp-http")]
{
return Box::pin(run_serve_with_acp(
&args,
config_path,
http_addr,
max_sessions,
))
.await;
}
#[cfg(not(feature = "acp-http"))]
{
anyhow::bail!(
"zeph serve-sessions --acp requires the `acp-http` feature (bundled in the \
`ide` feature bundle) — this binary was not compiled with it. Rebuild with \
`--features acp-http` (or `ide`), or run `zeph --acp` as a separate process \
alongside `zeph serve-sessions` instead."
);
}
}
let (deps, auth_token) = Box::pin(deps::build_serve_deps(
config_path,
args.vault_backend.as_deref(),
args.vault_key.as_deref(),
args.vault_path.as_deref(),
))
.await?;
check_require_auth_guard(serve_config, http_addr, auth_token.is_some())?;
let cancel = tokio_util::sync::CancellationToken::new();
let supervisor = TaskSupervisor::new(cancel.clone());
let registry = Arc::new(LiveSessionRegistry::new());
let state = AppState {
registry: Arc::clone(®istry),
started_at: std::time::Instant::now(),
supervisor: supervisor.clone(),
deps,
mailbox_capacity: serve_config.max_queued_prompts,
max_sessions,
sanitizer: zeph_core::ContentSanitizer::new(&config.security.content_isolation),
};
spawn_evict_task(&supervisor, ®istry, serve_config.session_idle_ttl_secs);
let listener = tokio::net::TcpListener::bind(http_addr)
.await
.map_err(|e| anyhow::anyhow!("failed to bind {http_addr}: {e}"))?;
tracing::info!(addr = %http_addr, max_sessions, "zeph serve-sessions listening");
let router = router::build_router(state, auth_token.as_deref(), serve_config.require_auth);
let shutdown_cancel = cancel.clone();
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
wait_for_shutdown_signal().await;
tracing::info!("zeph serve-sessions: shutdown signal received");
shutdown_cancel.cancel();
})
.await
.map_err(|e| anyhow::anyhow!("http server error: {e}"))?;
supervisor.shutdown_all(Duration::from_secs(30)).await;
tracing::info!("zeph serve-sessions: shutdown complete");
Ok(())
}
#[cfg(feature = "acp-http")]
async fn run_serve_with_acp(
args: &ServeSessionsArgs,
config_path: Option<&std::path::Path>,
http_addr: SocketAddr,
max_sessions: usize,
) -> anyhow::Result<()> {
let app = crate::bootstrap::AppBuilder::new(
config_path,
args.vault_backend.as_deref(),
args.vault_key.as_deref(),
args.vault_path.as_deref(),
)
.await?;
let serve_config = app.config().serve.clone();
let acp_bind_addr = app.config().acp.http_bind.clone();
let acp_auth_token = app.config().acp.auth_token.clone();
check_acp_http_port_clash(http_addr, &acp_bind_addr)?;
let auth_token = deps::resolve_auth_token(&app).await;
check_require_auth_guard(&serve_config, http_addr, auth_token.is_some())?;
check_acp_auth_guard(&acp_bind_addr, acp_auth_token.is_some())?;
let cancel = tokio_util::sync::CancellationToken::new();
let supervisor = Arc::new(TaskSupervisor::new(cancel.clone()));
let (serve_deps, acp_deps, _acp_keepalive) =
Box::pin(crate::acp::build_combined_deps(&app, &supervisor)).await?;
let registry = Arc::new(LiveSessionRegistry::new());
let memory_sqlite = serve_deps.memory.sqlite().clone();
let sanitizer = zeph_core::ContentSanitizer::new(&app.config().security.content_isolation);
let state = AppState {
registry: Arc::clone(®istry),
started_at: std::time::Instant::now(),
supervisor: TaskSupervisor::clone(&supervisor),
deps: serve_deps,
mailbox_capacity: serve_config.max_queued_prompts,
max_sessions,
sanitizer,
};
spawn_evict_task(&supervisor, ®istry, serve_config.session_idle_ttl_secs);
let mut acp_deps = acp_deps;
let acp_server_config = crate::acp::acp_http_server_config(&mut acp_deps);
let spawner = crate::acp::acp_http_ready_spawner(Arc::new(acp_deps)).await;
let acp_http_state =
zeph_acp::AcpHttpState::new(spawner, acp_server_config).with_store(memory_sqlite);
acp_http_state.mark_ready();
acp_http_state.start_reaper();
let acp_router = zeph_acp::acp_router(acp_http_state);
let http_listener = tokio::net::TcpListener::bind(http_addr)
.await
.map_err(|e| anyhow::anyhow!("failed to bind {http_addr}: {e}"))?;
tracing::info!(addr = %http_addr, max_sessions, "zeph serve-sessions listening");
let acp_listener = tokio::net::TcpListener::bind(&acp_bind_addr)
.await
.map_err(|e| anyhow::anyhow!("failed to bind ACP HTTP {acp_bind_addr}: {e}"))?;
tracing::info!(addr = %acp_bind_addr, "zeph serve-sessions: ACP HTTP transport listening");
let http_router = router::build_router(state, auth_token.as_deref(), serve_config.require_auth);
let shutdown_cancel = cancel.clone();
let shutdown_producer = async move {
wait_for_shutdown_signal().await;
tracing::info!("zeph serve-sessions: shutdown signal received");
shutdown_cancel.cancel();
};
let http_shutdown_cancel = cancel.clone();
let http_done_cancel = cancel.clone();
let serve_http = async move {
let result = axum::serve(
http_listener,
http_router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move { http_shutdown_cancel.cancelled().await })
.await;
http_done_cancel.cancel();
result
};
let acp_shutdown_cancel = cancel.clone();
let acp_done_cancel = cancel.clone();
let serve_acp = async move {
let result = axum::serve(acp_listener, acp_router)
.with_graceful_shutdown(async move { acp_shutdown_cancel.cancelled().await })
.await;
acp_done_cancel.cancel();
result
};
let ((), http_result, acp_result) = tokio::join!(shutdown_producer, serve_http, serve_acp);
http_result.map_err(|e| anyhow::anyhow!("http server error: {e}"))?;
acp_result.map_err(|e| anyhow::anyhow!("ACP http server error: {e}"))?;
supervisor.shutdown_all(Duration::from_secs(30)).await;
tracing::info!("zeph serve-sessions: shutdown complete (combined ACP-HTTP mode)");
Ok(())
}
fn check_require_auth_guard(
serve_config: &zeph_config::ServeConfig,
http_addr: SocketAddr,
has_auth_token: bool,
) -> anyhow::Result<()> {
if serve_config.require_auth && !has_auth_token && !http_addr.ip().is_loopback() {
anyhow::bail!(
"refusing to bind {http_addr}: [serve] require_auth is true but no auth token was \
resolved from the vault key \"{}\" — every request would be rejected. Set that \
vault key, bind to a loopback address (127.0.0.1 or ::1), or set \
[serve] require_auth = false to disable authentication explicitly.",
serve_config.auth_token_vault_key
);
}
if !serve_config.require_auth {
tracing::warn!(
"[serve] require_auth is false — /sessions* endpoints are unauthenticated; only \
bind to loopback or a trusted network"
);
}
Ok(())
}
#[cfg(feature = "acp-http")]
fn check_acp_auth_guard(acp_http_bind: &str, has_acp_auth_token: bool) -> anyhow::Result<()> {
let Ok(acp_addr) = acp_http_bind.parse::<SocketAddr>() else {
return Ok(());
};
if !has_acp_auth_token && !acp_addr.ip().is_loopback() {
anyhow::bail!(
"refusing to bind ACP HTTP {acp_addr}: [acp] auth_token is not set — the ACP \
listener would be reachable over the network with no authentication, sharing the \
same acp_sessions table [serve] require_auth is meant to protect. Set \
[acp] auth_token, or bind [acp] http_bind to a loopback address (127.0.0.1 or ::1)."
);
}
Ok(())
}
#[cfg(feature = "acp-http")]
fn check_acp_http_port_clash(http_addr: SocketAddr, acp_http_bind: &str) -> anyhow::Result<()> {
let Ok(acp_addr) = acp_http_bind.parse::<SocketAddr>() else {
return Ok(());
};
let same_port = http_addr.port() == acp_addr.port();
let ips_overlap = http_addr.ip() == acp_addr.ip()
|| http_addr.ip().is_unspecified()
|| acp_addr.ip().is_unspecified();
if same_port && ips_overlap {
anyhow::bail!(
"port clash: [serve] http_addr ({http_addr}) and [acp] http_bind ({acp_addr}) would \
bind overlapping addresses on the same port. Set them to different ports, or bind \
each to a distinct concrete IP."
);
}
if same_port {
tracing::warn!(
serve_addr = %http_addr,
acp_addr = %acp_addr,
"[serve] http_addr and [acp] http_bind share the same port on distinct concrete \
IPs — legal, but double-check this is intentional"
);
}
Ok(())
}
#[cfg(all(test, feature = "acp-http"))]
mod guard_tests {
use super::{check_acp_auth_guard, check_acp_http_port_clash};
fn addr(s: &str) -> std::net::SocketAddr {
s.parse().unwrap()
}
#[test]
fn same_port_same_ip_is_a_clash() {
let result = check_acp_http_port_clash(addr("127.0.0.1:8080"), "127.0.0.1:8080");
assert!(result.is_err(), "identical addr:port must be rejected");
}
#[test]
fn same_port_wildcard_vs_concrete_is_a_clash() {
let result = check_acp_http_port_clash(addr("0.0.0.0:8080"), "127.0.0.1:8080");
assert!(
result.is_err(),
"an unspecified IP on one side covers every concrete address on that port"
);
}
#[test]
fn same_port_distinct_concrete_ips_is_legal() {
let result = check_acp_http_port_clash(addr("127.0.0.1:8080"), "10.0.0.5:8080");
assert!(
result.is_ok(),
"same port on two genuinely distinct concrete IPs must not be rejected"
);
}
#[test]
fn unparseable_acp_bind_skips_the_check() {
let result = check_acp_http_port_clash(addr("127.0.0.1:8080"), "not-a-valid-socket-addr");
assert!(
result.is_ok(),
"a bare hostname must be tolerated here; the real bind call surfaces any failure"
);
}
#[test]
fn non_loopback_bind_without_token_is_refused() {
let result = check_acp_auth_guard("0.0.0.0:9800", false);
assert!(
result.is_err(),
"a non-loopback ACP bind with no auth token must be refused"
);
}
#[test]
fn non_loopback_bind_with_token_is_allowed() {
let result = check_acp_auth_guard("0.0.0.0:9800", true);
assert!(result.is_ok(), "a configured auth token permits any bind");
}
#[test]
fn loopback_bind_without_token_is_allowed() {
let result = check_acp_auth_guard("127.0.0.1:9800", false);
assert!(
result.is_ok(),
"loopback-only exposure without a token is the same trade-off serve's own guard allows"
);
}
#[test]
fn unparseable_acp_bind_skips_the_auth_check() {
let result = check_acp_auth_guard("not-a-valid-socket-addr", false);
assert!(
result.is_ok(),
"a bare hostname must be tolerated here; the real bind call surfaces any failure"
);
}
}
fn spawn_evict_task(
supervisor: &TaskSupervisor,
registry: &Arc<LiveSessionRegistry>,
ttl_secs: u64,
) {
let idle_ttl = Duration::from_secs(ttl_secs);
let evict_registry = Arc::clone(registry);
let evict_cancel = supervisor.cancellation_token();
supervisor.spawn(TaskDescriptor {
name: "serve.evict",
restart: RestartPolicy::Restart {
max: 5,
base_delay: Duration::from_secs(1),
},
factory: move || evict_loop(Arc::clone(&evict_registry), idle_ttl, evict_cancel.clone()),
});
}
async fn evict_loop(
registry: Arc<LiveSessionRegistry>,
ttl: Duration,
cancel: tokio_util::sync::CancellationToken,
) {
let mut ticker = tokio::time::interval(EVICT_SCAN_INTERVAL);
loop {
tokio::select! {
() = cancel.cancelled() => {
tracing::debug!("serve.evict: shutting down");
return;
}
_ = ticker.tick() => {
for id in registry.idle_candidates(ttl) {
if let Some(handle) = registry.remove(&id) {
tracing::info!(session_id = %id.as_str(), "serve.evict: evicting idle session");
handle.cancel.cancel();
}
}
}
}
}
}
async fn wait_for_shutdown_signal() {
#[cfg(unix)]
{
let Ok(mut term) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
else {
tracing::warn!("failed to install SIGTERM handler; only Ctrl-C will trigger shutdown");
let _ = tokio::signal::ctrl_c().await;
return;
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}