1pub mod auth;
11pub mod tls;
12mod routes;
13mod state;
14mod thq_register;
15pub mod xagent;
16
17static BIN_VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
22
23pub fn set_bin_version(v: &str) {
27 let _ = BIN_VERSION.set(v.to_string());
28}
29
30pub fn bin_version() -> &'static str {
33 BIN_VERSION
34 .get()
35 .map(|s| s.as_str())
36 .unwrap_or(env!("CARGO_PKG_VERSION"))
37}
38
39const EMBEDDED_CEDAR_POLICY: &str = include_str!("../policies/trustee_default.cedar");
41const EMBEDDED_CEDAR_SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
42
43use std::net::SocketAddr;
44use std::sync::Arc;
45
46use anyhow::Result;
47use axum::routing::{get, post};
48use tower_http::cors::CorsLayer;
49
50pub use auth::{AuthConfig, AuthState};
51pub use state::ServerState;
52
53pub async fn run(
65 config_toml: String,
66 secrets: std::collections::HashMap<String, String>,
67 build_info: trustee_core::types::BuildInfo,
68 addr: SocketAddr,
69 use_tls: bool,
70) -> Result<()> {
71 let auth_state = if let Some(cfg) = AuthConfig::from_toml(&config_toml) {
73 let is_dev = cfg.dev_config.local_dev_mode;
74 tracing::info!(
75 "Auth enabled: {} mode, issuer={}",
76 if is_dev { "development" } else { "production" },
77 cfg.issuer_url
78 );
79
80 let cedar_boot = parse_cedar_config(&config_toml)
82 .await
83 .map_err(|e| anyhow::anyhow!("{e}"))?;
84 cedar_boot_decision(
85 true,
86 cedar_boot.authorizer.is_some(),
87 cedar_boot.allow_disabled,
88 )
89 .map_err(|e| anyhow::anyhow!("{e}"))?;
90
91 let mut issuer_fallbacks = crate::state::service_issuers_from_config(&config_toml);
94 for si in crate::thq_register::discover_service_issuers() {
95 if !issuer_fallbacks.contains(&si) {
96 issuer_fallbacks.push(si);
97 }
98 }
99 issuer_fallbacks.retain(|si| *si != cfg.issuer_url);
100 if !issuer_fallbacks.is_empty() {
101 tracing::info!("Auth issuer fallbacks armed: {:?}", issuer_fallbacks);
102 }
103 Some(Arc::new(
104 AuthState::with_cedar(cfg, cedar_boot.authorizer)
105 .with_issuer_fallbacks(issuer_fallbacks),
106 ))
107 } else {
108 tracing::warn!("AUTH NOT CONFIGURED: trustee-web is running WITHOUT authentication or Cedar authorization (no [oidc]/[dev] section). Never expose this to a network.");
110 None
111 };
112
113 let thq_config = thq_register::ThqConfig::from_toml(&config_toml);
115
116 let config_toml_for_state = config_toml.clone();
118 let secrets_for_state = secrets.clone();
119 let build_info_for_state = build_info.clone();
120 let (mut session, workflow_rx) = trustee_core::session::Session::new();
121 session.config_toml = Some(config_toml);
122 session.secrets = Some(secrets);
123 session.build_info = Some(build_info);
124 session.parse_auto_handoff_config();
125
126 if let Some(ref config_toml_str) = session.config_toml {
128 if let Ok(table) = config_toml_str.parse::<toml::Value>() {
129 if let Some(name) = table.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()) {
130 session.agent_name = name.to_string();
131 }
132 }
133 }
134
135 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(256);
137
138 let (max_sessions, allow_llm_overlay) = {
141 let config_str: &str = &config_toml_for_state;
142 match toml::from_str::<toml::Value>(config_str) {
143 Ok(v) => {
144 let max_sessions = v
145 .get("web")
146 .and_then(|w| w.as_table())
147 .and_then(|w| w.get("max_sessions_per_user").and_then(|v| v.as_integer()))
148 .map(|v| v as usize)
149 .unwrap_or(4);
150 let allow_llm_overlay = v
151 .get("users")
152 .and_then(|u| u.as_table())
153 .and_then(|u| u.get("allow_llm_overlay").and_then(|v| v.as_bool()))
154 .unwrap_or(false);
155 (max_sessions, allow_llm_overlay)
156 }
157 Err(_) => (4, false),
158 }
159 };
160
161 let state = ServerState::new(session, ws_tx, auth_state)
162 .with_config_toml(config_toml_for_state)
163 .with_secrets(secrets_for_state)
164 .with_build_info(build_info_for_state)
165 .with_max_sessions_per_user(max_sessions)
166 .with_allow_llm_overlay(allow_llm_overlay);
167
168 state.clone().spawn_drain_task(workflow_rx);
170
171 thq_register::spawn_all(thq_config, state.clone());
175
176 let app = axum::Router::new()
184 .route("/api/v1/health", get(routes::health))
186 .nest("/auth", auth::auth_routes())
187 .route("/api/v1/models", get(routes::list_models))
189 .route("/api/v1/session", get(routes::get_session))
190 .route(
191 "/api/v1/session/command",
192 post(routes::post_command).layer(axum::extract::DefaultBodyLimit::max(
193 routes::ATTACH_BODY_LIMIT,
194 )),
195 )
196 .route("/api/v1/session/cancel", post(routes::post_cancel))
197 .route("/api/v1/session/handoff", post(routes::post_handoff))
198 .route("/api/v1/session/stream", get(routes::ws_handler))
199 .route("/api/v1/session/name", post(routes::set_session_name))
201 .route("/api/v1/session/new", post(routes::new_session))
202 .route("/api/v1/project/name", post(routes::set_project_name))
203 .route("/api/v1/sessions", get(routes::list_sessions).post(routes::create_session))
206 .route("/api/v1/sessions/live", get(routes::list_live_sessions))
207 .route("/api/v1/sessions/{id}", get(routes::get_session_detail).delete(routes::destroy_session))
208 .route("/api/v1/sessions/{id}/live", get(routes::get_live_session))
209 .route("/api/v1/sessions/{id}/resume", post(routes::resume_session))
210 .route("/api/v1/sessions/{id}/history", get(routes::get_session_history))
211 .route(
213 "/api/v1/sessions/{id}/command",
214 post(routes::post_command_session).layer(axum::extract::DefaultBodyLimit::max(
215 routes::ATTACH_BODY_LIMIT,
216 )),
217 )
218 .route("/api/v1/sessions/{id}/cancel", post(routes::post_cancel_session))
219 .route("/api/v1/sessions/{id}/handoff", post(routes::post_handoff_session))
220 .route("/api/v1/sessions/{id}/name", post(routes::set_session_name_session))
221 .route("/api/v1/sessions/{id}/stream", get(routes::ws_session_handler))
222 .route("/", get(routes::serve_index))
224 .route("/{file}", get(routes::serve_static))
225 .merge(crate::xagent::router())
227 .layer(CorsLayer::permissive())
228 .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
229 .with_state(state);
230
231 let listener = tokio::net::TcpListener::bind(addr).await?;
233
234 if use_tls {
235 let _ = rustls::crypto::ring::default_provider().install_default();
239
240 let cert_dir = tls::default_cert_dir();
242 let (cert_path, key_path) = tls::ensure_certs(&cert_dir)?;
243
244 let tls_config = tls::load_tls_config(&cert_path, &key_path)?;
246 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_config));
247
248 tracing::info!("Trustee API listening on https://{}", addr);
249
250 loop {
252 let (tcp_stream, peer_addr) = match listener.accept().await {
253 Ok(stream) => stream,
254 Err(e) => {
255 tracing::warn!("TCP accept failed: {}", e);
256 continue;
257 }
258 };
259
260 let acceptor = acceptor.clone();
261 let app = app.clone();
262
263 tokio::spawn(async move {
264 let tls_stream = match acceptor.accept(tcp_stream).await {
265 Ok(s) => s,
266 Err(e) => {
267 tracing::debug!("TLS accept failed from {}: {}", peer_addr, e);
268 return;
269 }
270 };
271
272 let io = hyper_util::rt::TokioIo::new(tls_stream);
275 let svc = hyper_util::service::TowerToHyperService::new(app);
276
277 let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
278 .serve_connection_with_upgrades(io, svc)
279 .await;
280 });
281 }
282 } else {
283 tracing::info!("Trustee API listening on http://{}", addr);
284 axum::serve(listener, app).await?;
285 }
286
287 Ok(())
288}
289
290struct CedarBoot {
301 authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
302 allow_disabled: bool,
307}
308
309pub(crate) fn cedar_boot_decision(
311 auth_configured: bool,
312 cedar_present: bool,
313 allow_disabled: bool,
314) -> Result<(), String> {
315 if !auth_configured {
316 return Ok(());
319 }
320 if cedar_present || allow_disabled {
321 Ok(())
322 } else {
323 Err(
324 "Cedar authorization is REQUIRED in web mode (fail-closed, nghr 645809c3). \
325 Either configure it: [cedar] enabled = true (policies ship embedded), \
326 or explicitly opt out per environment: [cedar] allow_disabled = true."
327 .to_string(),
328 )
329 }
330}
331
332async fn parse_cedar_config(config_toml: &str) -> Result<CedarBoot, String> {
333 let parsed: Option<toml::Table> = toml::from_str(config_toml).ok();
334 let cedar_table = parsed.as_ref().and_then(|t| t.get("cedar"));
335 let allow_disabled = cedar_table
336 .and_then(|c| c.get("allow_disabled"))
337 .and_then(|v| v.as_bool())
338 .unwrap_or(false);
339
340 let Some(cedar_section) = cedar_table.and_then(|c| c.as_table().cloned()) else {
341 return Ok(CedarBoot {
342 authorizer: None,
343 allow_disabled,
344 });
345 };
346 let enabled = cedar_section
347 .get("enabled")
348 .and_then(|v| v.as_bool())
349 .unwrap_or(false);
350
351 if !enabled {
352 tracing::debug!("Cedar authorization disabled (default)");
353 return Ok(CedarBoot {
354 authorizer: None,
355 allow_disabled,
356 });
357 }
358
359 tracing::info!("Cedar authorization enabled — initializing authorizer");
360
361 let agent_name = parsed
364 .as_ref()
365 .and_then(|t| t.get("agent"))
366 .and_then(|a| a.as_table())
367 .and_then(|a| a.get("name"))
368 .and_then(|n| n.as_str())
369 .unwrap_or("trustee");
370
371 let home_policies_dir = dirs::home_dir()
372 .map(|h| h.join(format!(".{}", agent_name)).join("policies"))
373 .unwrap_or_else(|| std::path::PathBuf::from("/nonexistent"));
374
375 let default_policy_path = home_policies_dir.join("trustee_default.cedar");
376 let default_schema_path = home_policies_dir.join("trustee_schema.cedarschema");
377
378 let policy_path = cedar_section
379 .get("policy_path")
380 .and_then(|v| v.as_str())
381 .filter(|s| !s.is_empty())
382 .map(std::path::PathBuf::from)
383 .unwrap_or(default_policy_path);
384
385 let schema_path = cedar_section
386 .get("schema_path")
387 .and_then(|v| v.as_str())
388 .filter(|s| !s.is_empty())
389 .map(std::path::PathBuf::from)
390 .or_else(|| Some(default_schema_path));
391
392 let policy_store_url = cedar_section
393 .get("policy_store_url")
394 .and_then(|v| v.as_str())
395 .map(String::from);
396
397 let policy_store_token = cedar_section
398 .get("policy_store_token")
399 .and_then(|v| v.as_str())
400 .map(String::from);
401
402 let cedar_config = pep::cedar::CedarConfig {
403 policy_path,
404 schema_path,
405 entities_path: None,
406 default_decision: pep::cedar::DefaultDecision::Deny,
407 validate_on_load: true,
408 policy_store_url,
409 policy_store_token,
410 embedded_policy: Some(EMBEDDED_CEDAR_POLICY),
411 embedded_schema: Some(EMBEDDED_CEDAR_SCHEMA),
412 };
413
414 match pep::cedar::CedarAuthorizer::new_with_policy_store(cedar_config).await {
415 Ok(auth) => {
416 tracing::info!("Cedar authorizer initialized successfully");
417 Ok(CedarBoot {
418 authorizer: Some(Arc::new(auth)),
419 allow_disabled,
420 })
421 }
422 Err(e) => {
426 let msg = format!(
427 "Cedar authorization is enabled but FAILED to initialize: {e}. \
428 Refusing to boot (fail-closed). Fix the policy/schema configuration \
429 or explicitly set [cedar] allow_disabled = true to run identity-only."
430 );
431 tracing::error!("{msg}");
432 Err(msg)
433 }
434 }
435}