1pub mod auth;
11pub mod tls;
12mod routes;
13mod state;
14mod materialize;
15mod thq_dispatch;
16mod thq_register;
17pub mod xagent;
18
19static BIN_VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
24
25pub fn set_bin_version(v: &str) {
29 let _ = BIN_VERSION.set(v.to_string());
30}
31
32pub 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
41const 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
55pub 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 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 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 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 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 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 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 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 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(256);
148
149 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 state.clone().spawn_drain_task(workflow_rx);
181
182 thq_dispatch::populate(state.clone());
186
187 let app = axum::Router::new()
195 .route("/api/v1/health", get(routes::health))
197 .nest("/auth", auth::auth_routes())
198 .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 .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 .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 .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 .route("/", get(routes::serve_index))
235 .route("/{file}", get(routes::serve_static))
236 .merge(crate::xagent::router());
238 let app = match &thq_enrollment {
242 Some(enr) => app.merge(thq_register::enroll_route(enr.clone())),
243 None => app,
244 };
245 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 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 let _ = rustls::crypto::ring::default_provider().install_default();
265
266 let cert_dir = tls::default_cert_dir();
268 let (cert_path, key_path) = tls::ensure_certs(&cert_dir)?;
269
270 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 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 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
316struct CedarBoot {
327 authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
328 allow_disabled: bool,
333}
334
335pub(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 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 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 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}