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;
15pub mod xagent;
16
17// Embedded Cedar policy defaults (compiled into binary)
18const EMBEDDED_CEDAR_POLICY: &str = include_str!("../policies/trustee_default.cedar");
19const EMBEDDED_CEDAR_SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
20
21use std::net::SocketAddr;
22use std::sync::Arc;
23
24use anyhow::Result;
25use axum::routing::{get, post};
26use tower_http::cors::CorsLayer;
27
28pub use auth::{AuthConfig, AuthState};
29pub use state::ServerState;
30
31/// Run the API server.
32///
33/// Creates a `Session` with the given config, starts a background task to
34/// drain workflow messages and broadcast them to WebSocket clients, then
35/// serves the REST + WebSocket + static files on `addr`.
36///
37/// If `[oidc]` or `[dev]` sections are found in the config TOML, auth is
38/// enabled — all `/api/v1/*` endpoints (except health) require a valid token.
39///
40/// By default serves over HTTPS using a self-signed certificate from
41/// `~/.trustee/certs/`. If `use_tls` is false, serves plain HTTP.
42pub async fn run(
43    config_toml: String,
44    secrets: std::collections::HashMap<String, String>,
45    build_info: trustee_core::types::BuildInfo,
46    addr: SocketAddr,
47    use_tls: bool,
48) -> Result<()> {
49    // Parse auth config from TOML (returns None if no [oidc] or [dev] sections)
50    let auth_state = if let Some(cfg) = AuthConfig::from_toml(&config_toml) {
51        let is_dev = cfg.dev_config.local_dev_mode;
52        tracing::info!(
53            "Auth enabled: {} mode, issuer={}",
54            if is_dev { "development" } else { "production" },
55            cfg.issuer_url
56        );
57
58        // Parse Cedar authorization config (P2: fail-closed on init failure)
59        let cedar_boot = parse_cedar_config(&config_toml)
60            .await
61            .map_err(|e| anyhow::anyhow!("{e}"))?;
62        cedar_boot_decision(
63            true,
64            cedar_boot.authorizer.is_some(),
65            cedar_boot.allow_disabled,
66        )
67        .map_err(|e| anyhow::anyhow!("{e}"))?;
68
69        Some(Arc::new(AuthState::with_cedar(cfg, cedar_boot.authorizer)))
70    } else {
71        // Open mode — loud, by design (local/dev posture preserved).
72        tracing::warn!("AUTH NOT CONFIGURED: trustee-web is running WITHOUT authentication or Cedar authorization (no [oidc]/[dev] section). Never expose this to a network.");
73        None
74    };
75
76    // Parse THQ registration config before config_toml is moved into session
77    let thq_config = thq_register::ThqConfig::from_toml(&config_toml);
78
79    // Build the session — keep copies of secrets/build_info for per-user sessions
80    let config_toml_for_state = config_toml.clone();
81    let secrets_for_state = secrets.clone();
82    let build_info_for_state = build_info.clone();
83    let (mut session, workflow_rx) = trustee_core::session::Session::new();
84    session.config_toml = Some(config_toml);
85    session.secrets = Some(secrets);
86    session.build_info = Some(build_info);
87    session.parse_auto_handoff_config();
88
89    // Extract agent name from config TOML for stateless operation
90    if let Some(ref config_toml_str) = session.config_toml {
91        if let Ok(table) = config_toml_str.parse::<toml::Value>() {
92            if let Some(name) = table.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()) {
93                session.agent_name = name.to_string();
94            }
95        }
96    }
97
98    // Create the broadcast channel for WebSocket fan-out
99    let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(256);
100
101    // Wrap session in shared state (with shared config/secrets/build_info for per-user sessions)
102    // Parse knobs: [web].max_sessions_per_user, [users].allow_llm_overlay
103    let (max_sessions, allow_llm_overlay) = {
104        let config_str: &str = &config_toml_for_state;
105        match toml::from_str::<toml::Value>(config_str) {
106            Ok(v) => {
107                let max_sessions = v
108                    .get("web")
109                    .and_then(|w| w.as_table())
110                    .and_then(|w| w.get("max_sessions_per_user").and_then(|v| v.as_integer()))
111                    .map(|v| v as usize)
112                    .unwrap_or(4);
113                let allow_llm_overlay = v
114                    .get("users")
115                    .and_then(|u| u.as_table())
116                    .and_then(|u| u.get("allow_llm_overlay").and_then(|v| v.as_bool()))
117                    .unwrap_or(false);
118                (max_sessions, allow_llm_overlay)
119            }
120            Err(_) => (4, false),
121        }
122    };
123
124    let state = ServerState::new(session, ws_tx, auth_state)
125        .with_config_toml(config_toml_for_state)
126        .with_secrets(secrets_for_state)
127        .with_build_info(build_info_for_state)
128        .with_max_sessions_per_user(max_sessions)
129        .with_allow_llm_overlay(allow_llm_overlay);
130
131    // Start background message drain task (owns workflow_rx directly — no deadlock)
132    state.clone().spawn_drain_task(workflow_rx);
133
134    // THQ auto-registration with Torpi (16E): every agent-user with a
135    // per-user [thq] overlay registers as its own agent; the process-level
136    // [thq] is only a legacy single-registration fallback.
137    thq_register::spawn_all(thq_config, state.clone());
138
139    // Build router
140    //
141    // Auth middleware approach: since axum 0.8's from_fn_with_state has
142    // trait bound issues with nested routers, we apply auth checking at
143    // the handler level via a helper. Each protected route's handler
144    // calls auth::check_auth() first. This is simpler and avoids type
145    // complexity.
146    let app = axum::Router::new()
147        // Public routes
148        .route("/api/v1/health", get(routes::health))
149        .nest("/auth", auth::auth_routes())
150        // Protected API routes
151        .route("/api/v1/models", get(routes::list_models))
152        .route("/api/v1/session", get(routes::get_session))
153        .route("/api/v1/session/command", post(routes::post_command))
154        .route("/api/v1/session/cancel", post(routes::post_cancel))
155        .route("/api/v1/session/handoff", post(routes::post_handoff))
156        .route("/api/v1/session/stream", get(routes::ws_handler))
157        // Session naming
158        .route("/api/v1/session/name", post(routes::set_session_name))
159        .route("/api/v1/session/new", post(routes::new_session))
160        .route("/api/v1/project/name", post(routes::set_project_name))
161        // Session discovery & resume
162        // Session discovery & resume (checkpoint-based, existing)
163        .route("/api/v1/sessions", get(routes::list_sessions).post(routes::create_session))
164        .route("/api/v1/sessions/live", get(routes::list_live_sessions))
165        .route("/api/v1/sessions/{id}", get(routes::get_session_detail).delete(routes::destroy_session))
166        .route("/api/v1/sessions/{id}/live", get(routes::get_live_session))
167        .route("/api/v1/sessions/{id}/resume", post(routes::resume_session))
168        .route("/api/v1/sessions/{id}/history", get(routes::get_session_history))
169        // MSU: session-scoped live routes
170        .route("/api/v1/sessions/{id}/command", post(routes::post_command_session))
171        .route("/api/v1/sessions/{id}/cancel", post(routes::post_cancel_session))
172        .route("/api/v1/sessions/{id}/handoff", post(routes::post_handoff_session))
173        .route("/api/v1/sessions/{id}/name", post(routes::set_session_name_session))
174        .route("/api/v1/sessions/{id}/stream", get(routes::ws_session_handler))
175        // Static files from trustee-web
176        .route("/", get(routes::serve_index))
177        .route("/{file}", get(routes::serve_static))
178        // 16F: per-agent THQ dispatch surface (impersonation by Bearer swap)
179        .merge(crate::xagent::router())
180        .layer(CorsLayer::permissive())
181        .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
182        .with_state(state);
183
184    // Start server
185    let listener = tokio::net::TcpListener::bind(addr).await?;
186
187    if use_tls {
188        // Install ring as the process-level crypto provider (required when
189        // rustls is built with default-features=false to avoid ambiguity
190        // with aws-lc-rs pulled in transitively by other crates).
191        let _ = rustls::crypto::ring::default_provider().install_default();
192
193        // Ensure self-signed certs exist
194        let cert_dir = tls::default_cert_dir();
195        let (cert_path, key_path) = tls::ensure_certs(&cert_dir)?;
196
197        // Load TLS config
198        let tls_config = tls::load_tls_config(&cert_path, &key_path)?;
199        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_config));
200
201        tracing::info!("Trustee API listening on https://{}", addr);
202
203        // Manual accept loop — spawn hyper-util auto connection per TLS stream
204        loop {
205            let (tcp_stream, peer_addr) = match listener.accept().await {
206                Ok(stream) => stream,
207                Err(e) => {
208                    tracing::warn!("TCP accept failed: {}", e);
209                    continue;
210                }
211            };
212
213            let acceptor = acceptor.clone();
214            let app = app.clone();
215
216            tokio::spawn(async move {
217                let tls_stream = match acceptor.accept(tcp_stream).await {
218                    Ok(s) => s,
219                    Err(e) => {
220                        tracing::debug!("TLS accept failed from {}: {}", peer_addr, e);
221                        return;
222                    }
223                };
224
225                // Use hyper-util auto builder with the tower service from axum.
226                // serve_connection_with_upgrades is required for WebSocket support.
227                let io = hyper_util::rt::TokioIo::new(tls_stream);
228                let svc = hyper_util::service::TowerToHyperService::new(app);
229
230                let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
231                    .serve_connection_with_upgrades(io, svc)
232                    .await;
233            });
234        }
235    } else {
236        tracing::info!("Trustee API listening on http://{}", addr);
237        axum::serve(listener, app).await?;
238    }
239
240    Ok(())
241}
242
243/// Parse [cedar] section from config TOML and create a CedarAuthorizer if enabled.
244///
245/// Configuration:
246/// - `[cedar] enabled = true/false` (default: false)
247/// - `[cedar] policy_path = "/path/to/policies.cedar"` (filesystem override)
248/// - `[cedar] schema_path = "/path/to/schema.cedarschema"` (filesystem override)
249/// - `[cedar] policy_store_url = "https://..."` (remote policy store)
250///
251/// When enabled without filesystem paths, uses embedded defaults.
252/// P2 boot result for Cedar (nghr 645809c3).
253struct CedarBoot {
254    authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
255    /// Explicit per-environment escape hatch: `[cedar] allow_disabled = true`
256    /// opts THIS deployment into identity-only mode (Cedar absent). The
257    /// DEFAULT is fail-closed: web mode with auth configured refuses to
258    /// boot without a working Cedar authorizer.
259    allow_disabled: bool,
260}
261
262/// Pure decision for the P2 fail-closed posture — unit-tested.
263pub(crate) fn cedar_boot_decision(
264    auth_configured: bool,
265    cedar_present: bool,
266    allow_disabled: bool,
267) -> Result<(), String> {
268    if !auth_configured {
269        // Open mode (no [oidc]/[dev]) — preserved for local/dev usage; the
270        // absence of auth is logged loudly at boot.
271        return Ok(());
272    }
273    if cedar_present || allow_disabled {
274        Ok(())
275    } else {
276        Err(
277            "Cedar authorization is REQUIRED in web mode (fail-closed, nghr 645809c3). \
278             Either configure it: [cedar] enabled = true (policies ship embedded), \
279             or explicitly opt out per environment: [cedar] allow_disabled = true."
280                .to_string(),
281        )
282    }
283}
284
285async fn parse_cedar_config(config_toml: &str) -> Result<CedarBoot, String> {
286    let parsed: Option<toml::Table> = toml::from_str(config_toml).ok();
287    let cedar_table = parsed.as_ref().and_then(|t| t.get("cedar"));
288    let allow_disabled = cedar_table
289        .and_then(|c| c.get("allow_disabled"))
290        .and_then(|v| v.as_bool())
291        .unwrap_or(false);
292
293    let Some(cedar_section) = cedar_table.and_then(|c| c.as_table().cloned()) else {
294        return Ok(CedarBoot {
295            authorizer: None,
296            allow_disabled,
297        });
298    };
299    let enabled = cedar_section
300        .get("enabled")
301        .and_then(|v| v.as_bool())
302        .unwrap_or(false);
303
304    if !enabled {
305        tracing::debug!("Cedar authorization disabled (default)");
306        return Ok(CedarBoot {
307            authorizer: None,
308            allow_disabled,
309        });
310    }
311
312    tracing::info!("Cedar authorization enabled — initializing authorizer");
313
314    // Default policy/schema paths point to ~/{agent_name}/policies/ (created by trustee init).
315    // Agent name is read from [agent] name in config, defaulting to "trustee".
316    let agent_name = parsed
317        .as_ref()
318        .and_then(|t| t.get("agent"))
319        .and_then(|a| a.as_table())
320        .and_then(|a| a.get("name"))
321        .and_then(|n| n.as_str())
322        .unwrap_or("trustee");
323
324    let home_policies_dir = dirs::home_dir()
325        .map(|h| h.join(format!(".{}", agent_name)).join("policies"))
326        .unwrap_or_else(|| std::path::PathBuf::from("/nonexistent"));
327
328    let default_policy_path = home_policies_dir.join("trustee_default.cedar");
329    let default_schema_path = home_policies_dir.join("trustee_schema.cedarschema");
330
331    let policy_path = cedar_section
332        .get("policy_path")
333        .and_then(|v| v.as_str())
334        .filter(|s| !s.is_empty())
335        .map(std::path::PathBuf::from)
336        .unwrap_or(default_policy_path);
337
338    let schema_path = cedar_section
339        .get("schema_path")
340        .and_then(|v| v.as_str())
341        .filter(|s| !s.is_empty())
342        .map(std::path::PathBuf::from)
343        .or_else(|| Some(default_schema_path));
344
345    let policy_store_url = cedar_section
346        .get("policy_store_url")
347        .and_then(|v| v.as_str())
348        .map(String::from);
349
350    let policy_store_token = cedar_section
351        .get("policy_store_token")
352        .and_then(|v| v.as_str())
353        .map(String::from);
354
355    let cedar_config = pep::cedar::CedarConfig {
356        policy_path,
357        schema_path,
358        entities_path: None,
359        default_decision: pep::cedar::DefaultDecision::Deny,
360        validate_on_load: true,
361        policy_store_url,
362        policy_store_token,
363        embedded_policy: Some(EMBEDDED_CEDAR_POLICY),
364        embedded_schema: Some(EMBEDDED_CEDAR_SCHEMA),
365    };
366
367    match pep::cedar::CedarAuthorizer::new_with_policy_store(cedar_config).await {
368        Ok(auth) => {
369            tracing::info!("Cedar authorizer initialized successfully");
370            Ok(CedarBoot {
371                authorizer: Some(Arc::new(auth)),
372                allow_disabled,
373            })
374        }
375        // FAIL-CLOSED (nghr 645809c3): the v0.1.0–0.1.1 fame bug class —
376        // enabled-but-broken Cedar used to silently disable authorization.
377        // Now the boot dies loudly instead.
378        Err(e) => {
379            let msg = format!(
380                "Cedar authorization is enabled but FAILED to initialize: {e}. \
381                 Refusing to boot (fail-closed). Fix the policy/schema configuration \
382                 or explicitly set [cedar] allow_disabled = true to run identity-only."
383            );
384            tracing::error!("{msg}");
385            Err(msg)
386        }
387    }
388}