Skip to main content

trustee_api/
lib.rs

1//! Trustee API — REST + WebSocket server for the Trustee agent.
2//!
3//! Wraps a [`trustee_core::session::Session`] and exposes it over HTTP.
4//! Static frontend files are served from [`trustee_web`].
5//!
6//! Authentication is optional. When `[oidc]` or `[dev]` sections are present
7//! in the config TOML, all `/api/v1/*` endpoints require a valid JWT or dev
8//! token. Otherwise, all endpoints are open.
9
10pub mod auth;
11pub mod tls;
12mod routes;
13mod state;
14mod thq_register;
15
16// Embedded Cedar policy defaults (compiled into binary)
17const EMBEDDED_CEDAR_POLICY: &str = include_str!("../policies/trustee_default.cedar");
18const EMBEDDED_CEDAR_SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
19
20use std::net::SocketAddr;
21use std::sync::Arc;
22
23use anyhow::Result;
24use axum::routing::{get, post};
25use tower_http::cors::CorsLayer;
26
27pub use auth::{AuthConfig, AuthState};
28pub use state::ServerState;
29
30/// Run the API server.
31///
32/// Creates a `Session` with the given config, starts a background task to
33/// drain workflow messages and broadcast them to WebSocket clients, then
34/// serves the REST + WebSocket + static files on `addr`.
35///
36/// If `[oidc]` or `[dev]` sections are found in the config TOML, auth is
37/// enabled — all `/api/v1/*` endpoints (except health) require a valid token.
38///
39/// By default serves over HTTPS using a self-signed certificate from
40/// `~/.trustee/certs/`. If `use_tls` is false, serves plain HTTP.
41pub async fn run(
42    config_toml: String,
43    secrets: std::collections::HashMap<String, String>,
44    build_info: trustee_core::types::BuildInfo,
45    addr: SocketAddr,
46    use_tls: bool,
47) -> Result<()> {
48    // Parse auth config from TOML (returns None if no [oidc] or [dev] sections)
49    let auth_state = if let Some(cfg) = AuthConfig::from_toml(&config_toml) {
50        let is_dev = cfg.dev_config.local_dev_mode;
51        tracing::info!(
52            "Auth enabled: {} mode, issuer={}",
53            if is_dev { "development" } else { "production" },
54            cfg.issuer_url
55        );
56
57        // Parse optional Cedar authorization config
58        let cedar_authorizer = parse_cedar_config(&config_toml).await;
59
60        Some(Arc::new(AuthState::with_cedar(cfg, cedar_authorizer)))
61    } else {
62        None
63    };
64
65    // Parse THQ registration config before config_toml is moved into session
66    let thq_config = thq_register::ThqConfig::from_toml(&config_toml);
67
68    // Build the session — keep copies of secrets/build_info for per-user sessions
69    let config_toml_for_state = config_toml.clone();
70    let secrets_for_state = secrets.clone();
71    let build_info_for_state = build_info.clone();
72    let (mut session, workflow_rx) = trustee_core::session::Session::new();
73    session.config_toml = Some(config_toml);
74    session.secrets = Some(secrets);
75    session.build_info = Some(build_info);
76    session.parse_auto_handoff_config();
77
78    // Extract agent name from config TOML for stateless operation
79    if let Some(ref config_toml_str) = session.config_toml {
80        if let Ok(table) = config_toml_str.parse::<toml::Value>() {
81            if let Some(name) = table.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()) {
82                session.agent_name = name.to_string();
83            }
84        }
85    }
86
87    // Create the broadcast channel for WebSocket fan-out
88    let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(256);
89
90    // Wrap session in shared state (with shared config/secrets/build_info for per-user sessions)
91    // Parse max_sessions_per_user from [web] section
92    let max_sessions: usize = {
93        let config_str: &str = &config_toml_for_state;
94        match toml::from_str::<toml::Value>(config_str) {
95            Ok(v) => v
96                .get("web")
97                .and_then(|w| w.as_table())
98                .and_then(|w| w.get("max_sessions_per_user").and_then(|v| v.as_integer()))
99                .map(|v| v as usize)
100                .unwrap_or(4),
101            Err(_) => 4,
102        }
103    };
104
105    let state = ServerState::new(session, ws_tx, auth_state)
106        .with_config_toml(config_toml_for_state)
107        .with_secrets(secrets_for_state)
108        .with_build_info(build_info_for_state)
109        .with_max_sessions_per_user(max_sessions);
110
111    // Start background message drain task (owns workflow_rx directly — no deadlock)
112    state.clone().spawn_drain_task(workflow_rx);
113
114    // THQ auto-registration with Torpi (if [thq] section is present in config)
115    if let Some(cfg) = thq_config {
116        thq_register::spawn(cfg);
117    } else {
118        tracing::debug!("THQ registration not configured (no [thq] section)");
119    }
120
121    // Build router
122    //
123    // Auth middleware approach: since axum 0.8's from_fn_with_state has
124    // trait bound issues with nested routers, we apply auth checking at
125    // the handler level via a helper. Each protected route's handler
126    // calls auth::check_auth() first. This is simpler and avoids type
127    // complexity.
128    let app = axum::Router::new()
129        // Public routes
130        .route("/api/v1/health", get(routes::health))
131        .nest("/auth", auth::auth_routes())
132        // Protected API routes
133        .route("/api/v1/models", get(routes::list_models))
134        .route("/api/v1/session", get(routes::get_session))
135        .route("/api/v1/session/command", post(routes::post_command))
136        .route("/api/v1/session/cancel", post(routes::post_cancel))
137        .route("/api/v1/session/handoff", post(routes::post_handoff))
138        .route("/api/v1/session/stream", get(routes::ws_handler))
139        // Session naming
140        .route("/api/v1/session/name", post(routes::set_session_name))
141        .route("/api/v1/session/new", post(routes::new_session))
142        .route("/api/v1/project/name", post(routes::set_project_name))
143        // Session discovery & resume
144        // Session discovery & resume (checkpoint-based, existing)
145        .route("/api/v1/sessions", get(routes::list_sessions).post(routes::create_session))
146        .route("/api/v1/sessions/live", get(routes::list_live_sessions))
147        .route("/api/v1/sessions/{id}", get(routes::get_session_detail).delete(routes::destroy_session))
148        .route("/api/v1/sessions/{id}/live", get(routes::get_live_session))
149        .route("/api/v1/sessions/{id}/resume", post(routes::resume_session))
150        .route("/api/v1/sessions/{id}/history", get(routes::get_session_history))
151        // MSU: session-scoped live routes
152        .route("/api/v1/sessions/{id}/command", post(routes::post_command_session))
153        .route("/api/v1/sessions/{id}/cancel", post(routes::post_cancel_session))
154        .route("/api/v1/sessions/{id}/handoff", post(routes::post_handoff_session))
155        .route("/api/v1/sessions/{id}/name", post(routes::set_session_name_session))
156        .route("/api/v1/sessions/{id}/stream", get(routes::ws_session_handler))
157        // Static files from trustee-web
158        .route("/", get(routes::serve_index))
159        .route("/{file}", get(routes::serve_static))
160        .layer(CorsLayer::permissive())
161        .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
162        .with_state(state);
163
164    // Start server
165    let listener = tokio::net::TcpListener::bind(addr).await?;
166
167    if use_tls {
168        // Install ring as the process-level crypto provider (required when
169        // rustls is built with default-features=false to avoid ambiguity
170        // with aws-lc-rs pulled in transitively by other crates).
171        let _ = rustls::crypto::ring::default_provider().install_default();
172
173        // Ensure self-signed certs exist
174        let cert_dir = tls::default_cert_dir();
175        let (cert_path, key_path) = tls::ensure_certs(&cert_dir)?;
176
177        // Load TLS config
178        let tls_config = tls::load_tls_config(&cert_path, &key_path)?;
179        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_config));
180
181        tracing::info!("Trustee API listening on https://{}", addr);
182
183        // Manual accept loop — spawn hyper-util auto connection per TLS stream
184        loop {
185            let (tcp_stream, peer_addr) = match listener.accept().await {
186                Ok(stream) => stream,
187                Err(e) => {
188                    tracing::warn!("TCP accept failed: {}", e);
189                    continue;
190                }
191            };
192
193            let acceptor = acceptor.clone();
194            let app = app.clone();
195
196            tokio::spawn(async move {
197                let tls_stream = match acceptor.accept(tcp_stream).await {
198                    Ok(s) => s,
199                    Err(e) => {
200                        tracing::debug!("TLS accept failed from {}: {}", peer_addr, e);
201                        return;
202                    }
203                };
204
205                // Use hyper-util auto builder with the tower service from axum.
206                // serve_connection_with_upgrades is required for WebSocket support.
207                let io = hyper_util::rt::TokioIo::new(tls_stream);
208                let svc = hyper_util::service::TowerToHyperService::new(app);
209
210                let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
211                    .serve_connection_with_upgrades(io, svc)
212                    .await;
213            });
214        }
215    } else {
216        tracing::info!("Trustee API listening on http://{}", addr);
217        axum::serve(listener, app).await?;
218    }
219
220    Ok(())
221}
222
223/// Parse [cedar] section from config TOML and create a CedarAuthorizer if enabled.
224///
225/// Configuration:
226/// - `[cedar] enabled = true/false` (default: false)
227/// - `[cedar] policy_path = "/path/to/policies.cedar"` (filesystem override)
228/// - `[cedar] schema_path = "/path/to/schema.cedarschema"` (filesystem override)
229/// - `[cedar] policy_store_url = "https://..."` (remote policy store)
230///
231/// When enabled without filesystem paths, uses embedded defaults.
232async fn parse_cedar_config(config_toml: &str) -> Option<Arc<pep::cedar::CedarAuthorizer>> {
233    let table: toml::Table = match toml::from_str(config_toml) {
234        Ok(t) => t,
235        Err(_) => return None,
236    };
237
238    let cedar_section = table.get("cedar")?.as_table()?;
239    let enabled = cedar_section
240        .get("enabled")
241        .and_then(|v| v.as_bool())
242        .unwrap_or(false);
243
244    if !enabled {
245        tracing::debug!("Cedar authorization disabled (default)");
246        return None;
247    }
248
249    tracing::info!("Cedar authorization enabled — initializing authorizer");
250
251    // Default policy/schema paths point to ~/{agent_name}/policies/ (created by trustee init).
252    // Agent name is read from [agent] name in config, defaulting to "trustee".
253    let agent_name = table
254        .get("agent")
255        .and_then(|a| a.as_table())
256        .and_then(|a| a.get("name"))
257        .and_then(|n| n.as_str())
258        .unwrap_or("trustee");
259
260    let home_policies_dir = dirs::home_dir()
261        .map(|h| h.join(format!(".{}", agent_name)).join("policies"))
262        .unwrap_or_else(|| std::path::PathBuf::from("/nonexistent"));
263
264    let default_policy_path = home_policies_dir.join("trustee_default.cedar");
265    let default_schema_path = home_policies_dir.join("trustee_schema.cedarschema");
266
267    let policy_path = cedar_section
268        .get("policy_path")
269        .and_then(|v| v.as_str())
270        .filter(|s| !s.is_empty())
271        .map(std::path::PathBuf::from)
272        .unwrap_or(default_policy_path);
273
274    let schema_path = cedar_section
275        .get("schema_path")
276        .and_then(|v| v.as_str())
277        .filter(|s| !s.is_empty())
278        .map(std::path::PathBuf::from)
279        .or_else(|| Some(default_schema_path));
280
281    let policy_store_url = cedar_section
282        .get("policy_store_url")
283        .and_then(|v| v.as_str())
284        .map(String::from);
285
286    let policy_store_token = cedar_section
287        .get("policy_store_token")
288        .and_then(|v| v.as_str())
289        .map(String::from);
290
291    let cedar_config = pep::cedar::CedarConfig {
292        policy_path,
293        schema_path,
294        entities_path: None,
295        default_decision: pep::cedar::DefaultDecision::Deny,
296        validate_on_load: true,
297        policy_store_url,
298        policy_store_token,
299        embedded_policy: Some(EMBEDDED_CEDAR_POLICY),
300        embedded_schema: Some(EMBEDDED_CEDAR_SCHEMA),
301    };
302
303    match pep::cedar::CedarAuthorizer::new_with_policy_store(cedar_config).await {
304        Ok(auth) => {
305            tracing::info!("Cedar authorizer initialized successfully");
306            Some(Arc::new(auth))
307        }
308        Err(e) => {
309            tracing::error!("Failed to initialize Cedar authorizer: {}", e);
310            tracing::warn!("Cedar was enabled but initialization failed — auth will proceed WITHOUT Cedar");
311            None
312        }
313    }
314}