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