pub mod auth;
pub mod tls;
mod routes;
mod state;
mod thq_register;
pub mod xagent;
const EMBEDDED_CEDAR_POLICY: &str = include_str!("../policies/trustee_default.cedar");
const EMBEDDED_CEDAR_SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Result;
use axum::routing::{get, post};
use tower_http::cors::CorsLayer;
pub use auth::{AuthConfig, AuthState};
pub use state::ServerState;
pub async fn run(
config_toml: String,
secrets: std::collections::HashMap<String, String>,
build_info: trustee_core::types::BuildInfo,
addr: SocketAddr,
use_tls: bool,
) -> Result<()> {
let auth_state = if let Some(cfg) = AuthConfig::from_toml(&config_toml) {
let is_dev = cfg.dev_config.local_dev_mode;
tracing::info!(
"Auth enabled: {} mode, issuer={}",
if is_dev { "development" } else { "production" },
cfg.issuer_url
);
let cedar_boot = parse_cedar_config(&config_toml)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
cedar_boot_decision(
true,
cedar_boot.authorizer.is_some(),
cedar_boot.allow_disabled,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let mut issuer_fallbacks = crate::state::service_issuers_from_config(&config_toml);
for si in crate::thq_register::discover_service_issuers() {
if !issuer_fallbacks.contains(&si) {
issuer_fallbacks.push(si);
}
}
issuer_fallbacks.retain(|si| *si != cfg.issuer_url);
if !issuer_fallbacks.is_empty() {
tracing::info!("Auth issuer fallbacks armed: {:?}", issuer_fallbacks);
}
Some(Arc::new(
AuthState::with_cedar(cfg, cedar_boot.authorizer)
.with_issuer_fallbacks(issuer_fallbacks),
))
} else {
tracing::warn!("AUTH NOT CONFIGURED: trustee-web is running WITHOUT authentication or Cedar authorization (no [oidc]/[dev] section). Never expose this to a network.");
None
};
let thq_config = thq_register::ThqConfig::from_toml(&config_toml);
let config_toml_for_state = config_toml.clone();
let secrets_for_state = secrets.clone();
let build_info_for_state = build_info.clone();
let (mut session, workflow_rx) = trustee_core::session::Session::new();
session.config_toml = Some(config_toml);
session.secrets = Some(secrets);
session.build_info = Some(build_info);
session.parse_auto_handoff_config();
if let Some(ref config_toml_str) = session.config_toml {
if let Ok(table) = config_toml_str.parse::<toml::Value>() {
if let Some(name) = table.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()) {
session.agent_name = name.to_string();
}
}
}
let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(256);
let (max_sessions, allow_llm_overlay) = {
let config_str: &str = &config_toml_for_state;
match toml::from_str::<toml::Value>(config_str) {
Ok(v) => {
let max_sessions = v
.get("web")
.and_then(|w| w.as_table())
.and_then(|w| w.get("max_sessions_per_user").and_then(|v| v.as_integer()))
.map(|v| v as usize)
.unwrap_or(4);
let allow_llm_overlay = v
.get("users")
.and_then(|u| u.as_table())
.and_then(|u| u.get("allow_llm_overlay").and_then(|v| v.as_bool()))
.unwrap_or(false);
(max_sessions, allow_llm_overlay)
}
Err(_) => (4, false),
}
};
let state = ServerState::new(session, ws_tx, auth_state)
.with_config_toml(config_toml_for_state)
.with_secrets(secrets_for_state)
.with_build_info(build_info_for_state)
.with_max_sessions_per_user(max_sessions)
.with_allow_llm_overlay(allow_llm_overlay);
state.clone().spawn_drain_task(workflow_rx);
thq_register::spawn_all(thq_config, state.clone());
let app = axum::Router::new()
.route("/api/v1/health", get(routes::health))
.nest("/auth", auth::auth_routes())
.route("/api/v1/models", get(routes::list_models))
.route("/api/v1/session", get(routes::get_session))
.route("/api/v1/session/command", post(routes::post_command))
.route("/api/v1/session/cancel", post(routes::post_cancel))
.route("/api/v1/session/handoff", post(routes::post_handoff))
.route("/api/v1/session/stream", get(routes::ws_handler))
.route("/api/v1/session/name", post(routes::set_session_name))
.route("/api/v1/session/new", post(routes::new_session))
.route("/api/v1/project/name", post(routes::set_project_name))
.route("/api/v1/sessions", get(routes::list_sessions).post(routes::create_session))
.route("/api/v1/sessions/live", get(routes::list_live_sessions))
.route("/api/v1/sessions/{id}", get(routes::get_session_detail).delete(routes::destroy_session))
.route("/api/v1/sessions/{id}/live", get(routes::get_live_session))
.route("/api/v1/sessions/{id}/resume", post(routes::resume_session))
.route("/api/v1/sessions/{id}/history", get(routes::get_session_history))
.route("/api/v1/sessions/{id}/command", post(routes::post_command_session))
.route("/api/v1/sessions/{id}/cancel", post(routes::post_cancel_session))
.route("/api/v1/sessions/{id}/handoff", post(routes::post_handoff_session))
.route("/api/v1/sessions/{id}/name", post(routes::set_session_name_session))
.route("/api/v1/sessions/{id}/stream", get(routes::ws_session_handler))
.route("/", get(routes::serve_index))
.route("/{file}", get(routes::serve_static))
.merge(crate::xagent::router())
.layer(CorsLayer::permissive())
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
.with_state(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
if use_tls {
let _ = rustls::crypto::ring::default_provider().install_default();
let cert_dir = tls::default_cert_dir();
let (cert_path, key_path) = tls::ensure_certs(&cert_dir)?;
let tls_config = tls::load_tls_config(&cert_path, &key_path)?;
let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_config));
tracing::info!("Trustee API listening on https://{}", addr);
loop {
let (tcp_stream, peer_addr) = match listener.accept().await {
Ok(stream) => stream,
Err(e) => {
tracing::warn!("TCP accept failed: {}", e);
continue;
}
};
let acceptor = acceptor.clone();
let app = app.clone();
tokio::spawn(async move {
let tls_stream = match acceptor.accept(tcp_stream).await {
Ok(s) => s,
Err(e) => {
tracing::debug!("TLS accept failed from {}: {}", peer_addr, e);
return;
}
};
let io = hyper_util::rt::TokioIo::new(tls_stream);
let svc = hyper_util::service::TowerToHyperService::new(app);
let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection_with_upgrades(io, svc)
.await;
});
}
} else {
tracing::info!("Trustee API listening on http://{}", addr);
axum::serve(listener, app).await?;
}
Ok(())
}
struct CedarBoot {
authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
allow_disabled: bool,
}
pub(crate) fn cedar_boot_decision(
auth_configured: bool,
cedar_present: bool,
allow_disabled: bool,
) -> Result<(), String> {
if !auth_configured {
return Ok(());
}
if cedar_present || allow_disabled {
Ok(())
} else {
Err(
"Cedar authorization is REQUIRED in web mode (fail-closed, nghr 645809c3). \
Either configure it: [cedar] enabled = true (policies ship embedded), \
or explicitly opt out per environment: [cedar] allow_disabled = true."
.to_string(),
)
}
}
async fn parse_cedar_config(config_toml: &str) -> Result<CedarBoot, String> {
let parsed: Option<toml::Table> = toml::from_str(config_toml).ok();
let cedar_table = parsed.as_ref().and_then(|t| t.get("cedar"));
let allow_disabled = cedar_table
.and_then(|c| c.get("allow_disabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let Some(cedar_section) = cedar_table.and_then(|c| c.as_table().cloned()) else {
return Ok(CedarBoot {
authorizer: None,
allow_disabled,
});
};
let enabled = cedar_section
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
tracing::debug!("Cedar authorization disabled (default)");
return Ok(CedarBoot {
authorizer: None,
allow_disabled,
});
}
tracing::info!("Cedar authorization enabled — initializing authorizer");
let agent_name = parsed
.as_ref()
.and_then(|t| t.get("agent"))
.and_then(|a| a.as_table())
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("trustee");
let home_policies_dir = dirs::home_dir()
.map(|h| h.join(format!(".{}", agent_name)).join("policies"))
.unwrap_or_else(|| std::path::PathBuf::from("/nonexistent"));
let default_policy_path = home_policies_dir.join("trustee_default.cedar");
let default_schema_path = home_policies_dir.join("trustee_schema.cedarschema");
let policy_path = cedar_section
.get("policy_path")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.unwrap_or(default_policy_path);
let schema_path = cedar_section
.get("schema_path")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.or_else(|| Some(default_schema_path));
let policy_store_url = cedar_section
.get("policy_store_url")
.and_then(|v| v.as_str())
.map(String::from);
let policy_store_token = cedar_section
.get("policy_store_token")
.and_then(|v| v.as_str())
.map(String::from);
let cedar_config = pep::cedar::CedarConfig {
policy_path,
schema_path,
entities_path: None,
default_decision: pep::cedar::DefaultDecision::Deny,
validate_on_load: true,
policy_store_url,
policy_store_token,
embedded_policy: Some(EMBEDDED_CEDAR_POLICY),
embedded_schema: Some(EMBEDDED_CEDAR_SCHEMA),
};
match pep::cedar::CedarAuthorizer::new_with_policy_store(cedar_config).await {
Ok(auth) => {
tracing::info!("Cedar authorizer initialized successfully");
Ok(CedarBoot {
authorizer: Some(Arc::new(auth)),
allow_disabled,
})
}
Err(e) => {
let msg = format!(
"Cedar authorization is enabled but FAILED to initialize: {e}. \
Refusing to boot (fail-closed). Fix the policy/schema configuration \
or explicitly set [cedar] allow_disabled = true to run identity-only."
);
tracing::error!("{msg}");
Err(msg)
}
}
}