1use std::sync::Arc;
16
17use dashmap::DashMap;
18use tokio::sync::{broadcast, mpsc, Mutex};
19use trustee_core::session::Session;
20use trustee_core::types::TuiMessage;
21
22use crate::auth::AuthState;
23
24tokio::task_local! {
29 static USER_KEY_OVERRIDE: String;
37}
38
39pub(crate) fn effective_user_key(user_key: &str) -> String {
42 USER_KEY_OVERRIDE
43 .try_with(|v| v.clone())
44 .unwrap_or_else(|_| user_key.to_string())
45}
46
47pub(crate) async fn in_dispatch_scope<F: std::future::Future>(user_key: &str, fut: F) -> F::Output {
50 USER_KEY_OVERRIDE.scope(user_key.to_string(), fut).await
51}
52
53pub fn apply_home_links() -> Vec<String> {
63 let Some(home) = dirs::home_dir() else {
64 return Vec::new();
65 };
66 let users = home.join(".trustee").join("users");
67 let map_path = users.join("home_links.json");
68 let Ok(text) = std::fs::read_to_string(&map_path) else {
69 return Vec::new();
70 };
71 let Ok(doc) = serde_json::from_str::<serde_json::Value>(&text) else {
72 tracing::warn!(
73 "home_links.json is not valid JSON — ignored: {}",
74 map_path.display()
75 );
76 return Vec::new();
77 };
78 let mut report = Vec::new();
79 if let Some(links) = doc.get("links").and_then(|v| v.as_array()) {
80 for l in links {
81 let (Some(from), Some(to)) = (
82 l.get("from_hash").and_then(|v| v.as_str()),
83 l.get("to_hash").and_then(|v| v.as_str()),
84 ) else {
85 continue;
86 };
87 let from_dir = users.join(from);
88 let to_dir = users.join(to);
89 if !from_dir.exists() {
90 continue; }
92 if to_dir.exists() {
93 let empty = std::fs::read_dir(&to_dir)
94 .map(|it| it.count() == 0)
95 .unwrap_or(false);
96 if !empty {
97 let msg = format!(
98 "home link {from} → {to}: target exists with content — NOT touched, reconcile manually"
99 );
100 tracing::warn!("home_links: {msg}");
101 report.push(msg);
102 continue;
103 }
104 let _ = std::fs::remove_dir(&to_dir);
105 }
106 match std::fs::rename(&from_dir, &to_dir) {
107 Ok(()) => {
108 let msg =
109 format!("home link applied: {from} → {to} (config/sessions restored)");
110 tracing::info!("home_links: {msg}");
111 report.push(msg);
112 }
113 Err(e) => {
114 let msg = format!("home link {from} → {to} FAILED: {e}");
115 tracing::warn!("home_links: {msg}");
116 report.push(msg);
117 }
118 }
119 }
120 }
121 report
122}
123
124pub struct UserSessionEntry {
130 pub session: Arc<Mutex<Session>>,
132 pub ws_tx: broadcast::Sender<String>,
134 pub created_at: chrono::DateTime<chrono::Utc>,
136 pub last_active: Arc<Mutex<chrono::DateTime<chrono::Utc>>>,
139}
140
141pub struct UserSessions {
143 pub sessions: DashMap<String, UserSessionEntry>,
145 pub token_store: Arc<pep::MemoryTokenStore>,
147}
148
149#[derive(Debug, serde::Serialize)]
151pub struct SessionListItem {
152 pub session_id: String,
153 pub session_name: Option<String>,
154 pub workflow_state: String,
155 pub created_at: String,
156 pub last_active: String,
157 pub handoff_count: u32,
162}
163
164#[derive(Debug)]
166pub enum SessionError {
167 MaxSessionsReached(usize),
169 NotFound(String),
171 NotIdle(String),
173}
174
175impl std::fmt::Display for SessionError {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 match self {
178 SessionError::MaxSessionsReached(n) => {
179 write!(f, "Maximum {} sessions per user reached", n)
180 }
181 SessionError::NotFound(id) => write!(f, "Session {} not found", id),
182 SessionError::NotIdle(state) => write!(f, "Session is not idle (state: {})", state),
183 }
184 }
185}
186
187impl std::error::Error for SessionError {}
188
189pub type SessionRegistry = Arc<DashMap<String, UserSessions>>;
191
192pub struct McpLoaderEntry {
203 pub loader: Option<std::sync::Arc<abk::agent::McpToolLoader>>,
205 pub fingerprint: u64,
208 pub built_at: chrono::DateTime<chrono::Utc>,
209 pub degraded: Option<String>,
212 pub failed_at: Option<chrono::DateTime<chrono::Utc>>,
215}
216
217pub const MCP_BUILD_RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
219
220pub const MCP_TOOLSET_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(600);
230
231#[derive(Debug, Clone, PartialEq, Eq)]
233pub(crate) enum LoaderFreshness {
234 Fresh,
236 ConfigChanged,
238 AgedOut,
242 Backoff(std::time::Duration),
244}
245
246pub(crate) fn loader_freshness(
249 entry_fingerprint: u64,
250 entry_built_at: chrono::DateTime<chrono::Utc>,
251 entry_degraded: Option<&str>,
252 entry_failed_at: Option<chrono::DateTime<chrono::Utc>>,
253 wanted_fingerprint: u64,
254 now: chrono::DateTime<chrono::Utc>,
255) -> LoaderFreshness {
256 if entry_degraded.is_some() {
259 if let Some(failed_at) = entry_failed_at {
260 let backoff = chrono::Duration::from_std(MCP_BUILD_RETRY_BACKOFF)
261 .unwrap_or_else(|_| chrono::Duration::seconds(30));
262 if now < failed_at + backoff {
263 return LoaderFreshness::Backoff(
264 (failed_at + backoff - now).to_std().unwrap_or_default(),
265 );
266 }
267 }
268 }
269 if wanted_fingerprint != entry_fingerprint {
270 return LoaderFreshness::ConfigChanged;
271 }
272 let max_age = chrono::Duration::from_std(MCP_TOOLSET_MAX_AGE)
273 .unwrap_or_else(|_| chrono::Duration::seconds(600));
274 if now > entry_built_at + max_age {
275 LoaderFreshness::AgedOut
276 } else {
277 LoaderFreshness::Fresh
278 }
279}
280
281#[derive(Debug, Clone)]
288pub struct ThqDispatchEntry {
289 pub user_key: String,
294 pub service_token: Option<String>,
298 pub issuer_url: Option<String>,
304}
305
306pub fn service_issuers_from_config(config_toml: &str) -> Vec<String> {
310 let Ok(v) = config_toml.parse::<toml::Value>() else {
311 return Vec::new();
312 };
313 let Some(creds) = v
314 .get("mcp")
315 .and_then(|m| m.get("credentials"))
316 .and_then(|c| c.as_table())
317 else {
318 return Vec::new();
319 };
320 let mut out = Vec::new();
321 for (_name, cred) in creds {
322 if cred.get("type").and_then(|t| t.as_str()) == Some("service-account") {
323 if let Some(issuer) = cred.get("issuer_url").and_then(|i| i.as_str()) {
324 if !out.iter().any(|e: &String| e == issuer) {
325 out.push(issuer.to_string());
326 }
327 }
328 }
329 }
330 out
331}
332
333#[derive(Clone)]
335pub struct ServerState {
336 pub sessions: SessionRegistry,
338 pub ws_tx: broadcast::Sender<String>,
340 pub auth: Option<Arc<AuthState>>,
342 pub config_toml: Option<String>,
344 pub secrets: Option<std::collections::HashMap<String, String>>,
346 pub build_info: Option<trustee_core::types::BuildInfo>,
348 pub workflow_semaphore: Arc<tokio::sync::Semaphore>,
351 pub max_sessions_per_user: usize,
353 pub allow_llm_overlay: bool,
356 pub mcp_loaders: Arc<DashMap<String, McpLoaderEntry>>,
358 mcp_build_locks: Arc<DashMap<String, Arc<tokio::sync::Mutex<()>>>>,
360 pub thq_dispatch: Arc<DashMap<String, ThqDispatchEntry>>,
362 pub agent_dispatch_tokens: Arc<DashMap<String, (String, std::time::Instant)>>,
365}
366
367impl ServerState {
368 pub fn new(
370 session: Session,
371 ws_tx: broadcast::Sender<String>,
372 auth: Option<Arc<AuthState>>,
373 ) -> Self {
374 apply_home_links();
377
378 let sessions = Arc::new(DashMap::new());
379
380 let token_store = Arc::new(pep::MemoryTokenStore::new());
382 let (ws_tx_entry, _) = broadcast::channel::<String>(256);
383
384 let now = chrono::Utc::now();
385 let initial_entry = UserSessionEntry {
386 session: Arc::new(Mutex::new(session)),
387 ws_tx: ws_tx_entry,
388 created_at: now,
389 last_active: Arc::new(Mutex::new(now)),
390 };
391
392 let user_sessions = UserSessions {
393 sessions: DashMap::new(),
394 token_store,
395 };
396 user_sessions
397 .sessions
398 .insert("default".to_string(), initial_entry);
399
400 sessions.insert("default".to_string(), user_sessions);
401
402 Self {
403 sessions,
404 ws_tx,
405 auth,
406 config_toml: None,
407 secrets: None,
408 build_info: None,
409 workflow_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
410 max_sessions_per_user: 4,
411 allow_llm_overlay: false,
412 mcp_loaders: Arc::new(DashMap::new()),
413 mcp_build_locks: Arc::new(DashMap::new()),
414 thq_dispatch: Arc::new(DashMap::new()),
415 agent_dispatch_tokens: Arc::new(DashMap::new()),
416 }
417 }
418
419 pub fn with_config_toml(mut self, config_toml: String) -> Self {
420 self.config_toml = Some(config_toml);
421 self
422 }
423
424 pub fn service_issuers(&self) -> Vec<String> {
435 service_issuers_from_config(self.config_toml.as_deref().unwrap_or(""))
436 }
437
438 pub fn with_secrets(mut self, secrets: std::collections::HashMap<String, String>) -> Self {
439 self.secrets = Some(secrets);
440 self
441 }
442
443 pub fn with_build_info(mut self, build_info: trustee_core::types::BuildInfo) -> Self {
444 self.build_info = Some(build_info);
445 self
446 }
447
448 pub fn with_max_concurrent_workflows(mut self, max: usize) -> Self {
449 self.workflow_semaphore = Arc::new(tokio::sync::Semaphore::new(max));
450 self
451 }
452
453 pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
455 self.max_sessions_per_user = max;
456 self
457 }
458
459 pub fn with_allow_llm_overlay(mut self, allow: bool) -> Self {
462 self.allow_llm_overlay = allow;
463 self
464 }
465
466 pub async fn create_session(
489 &self,
490 user_key: &str,
491 session_name: Option<String>,
492 identity: Option<String>,
493 ) -> Result<String, SessionError> {
494 let user_sessions = self
496 .sessions
497 .entry(user_key.to_string())
498 .or_insert_with(|| UserSessions {
499 sessions: DashMap::new(),
500 token_store: Arc::new(pep::MemoryTokenStore::new()),
501 });
502
503 if user_sessions.sessions.len() >= self.max_sessions_per_user {
505 return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
506 }
507
508 let (mut session, workflow_rx) = Session::new();
510
511 if let Some(ref config_toml) = self.config_toml {
513 session.config_toml = Some(config_toml.clone());
514 session.parse_auto_handoff_config();
515 if let Ok(table) = config_toml.parse::<toml::Value>() {
516 if let Some(name) = table
517 .get("agent")
518 .and_then(|a| a.get("name"))
519 .and_then(|n| n.as_str())
520 {
521 session.agent_name = name.to_string();
522 }
523 }
524 }
525
526 session.secrets = self.secrets.clone();
527 session.build_info = self.build_info.clone();
528
529 self.apply_user_isolation(&mut session, user_key);
531
532 session.session_name = session_name;
534
535 session.identity = identity;
537
538 let (ws_tx_entry, _) = broadcast::channel::<String>(256);
540
541 let session_id = format!(
543 "session_{}_{}",
544 chrono::Utc::now().format("%Y_%m_%d_%H_%M"),
545 &uuid::Uuid::new_v4().to_string()[..8]
546 );
547
548 session.session_id = Some(session_id.clone());
554
555 let now = chrono::Utc::now();
556
557 user_sessions.sessions.insert(
559 session_id.clone(),
560 UserSessionEntry {
561 session: Arc::new(Mutex::new(session)),
562 ws_tx: ws_tx_entry.clone(),
563 created_at: now,
564 last_active: Arc::new(Mutex::new(now)),
565 },
566 );
567
568 let session_arc = user_sessions
570 .sessions
571 .get(&session_id)
572 .map(|e| e.session.clone());
573 if let Some(session_arc) = session_arc {
574 self.spawn_user_drain_task(session_id.clone(), session_arc, ws_tx_entry, workflow_rx);
575 }
576
577 Ok(session_id)
578 }
579
580 pub async fn get_session(
583 &self,
584 user_key: &str,
585 session_id: &str,
586 ) -> Option<(Arc<Mutex<Session>>, broadcast::Sender<String>)> {
587 let user_sessions = self.sessions.get(user_key)?;
588 let entry = user_sessions.sessions.get(session_id)?;
589
590 let now = chrono::Utc::now();
592 *entry.last_active.lock().await = now;
593
594 Some((entry.session.clone(), entry.ws_tx.clone()))
595 }
596
597 pub async fn get_session_by_any_id(
615 &self,
616 user_key: &str,
617 id: &str,
618 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
619 let user_sessions = self.sessions.get(user_key)?;
621 if let Some(entry) = user_sessions.sessions.get(id) {
622 let now = chrono::Utc::now();
624 *entry.last_active.lock().await = now;
625 return Some((id.to_string(), entry.session.clone(), entry.ws_tx.clone()));
626 }
627
628 for entry in user_sessions.sessions.iter() {
630 let session = entry.session.lock().await;
631 if session.session_id.as_deref() == Some(id) {
632 let key = entry.key().clone();
633 let ws_tx = entry.ws_tx.clone();
634 drop(session);
635 let now = chrono::Utc::now();
637 *entry.last_active.lock().await = now;
638 return Some((key, entry.session.clone(), ws_tx));
639 }
640 }
641
642 None
643 }
644
645 fn tombstone_path(&self, user_key: &str, chain_id: &str) -> Option<std::path::PathBuf> {
654 if chain_id.is_empty() || !chain_id.starts_with("session_") {
655 return None;
656 }
657 let user_key = effective_user_key(user_key);
658 let hash = trustee_core::user_hash(&user_key);
659 dirs::home_dir().map(|home| {
660 home.join(".trustee")
661 .join("users")
662 .join(&hash)
663 .join("session_tombstones")
664 .join(chain_id)
665 })
666 }
667
668 fn write_tombstone(&self, user_key: &str, chain_id: &str) {
673 if let Some(path) = self.tombstone_path(user_key, chain_id) {
674 if let Some(parent) = path.parent() {
675 let _ = std::fs::create_dir_all(parent);
676 }
677 let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
678 }
679 }
680
681 pub fn chain_tombstone_stamp(&self, user_key: &str, chain_id: &str) -> Option<String> {
689 let path = self.tombstone_path(user_key, chain_id)?;
690 let text = std::fs::read_to_string(&path).ok()?;
691 let trimmed = text.trim();
692 if trimmed.is_empty() {
693 None
694 } else {
695 Some(trimmed.to_string())
696 }
697 }
698
699 pub async fn get_or_restore_session_by_any_id(
713 &self,
714 user_key: &str,
715 id: &str,
716 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
717 if let Some(found) = self.get_session_by_any_id(user_key, id).await {
718 return Some(found);
719 }
720 self.restore_session_from_checkpoint(user_key, id).await
721 }
722
723 async fn restore_session_from_checkpoint(
732 &self,
733 user_key: &str,
734 chain_id: &str,
735 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
736 if let Some(path) = self.tombstone_path(user_key, chain_id) {
738 if path.exists() {
739 return None;
740 }
741 }
742
743 let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
745 let config_toml = config_toml?;
746
747 let info =
750 trustee_core::sessions::create_resume_info(&config_toml, chain_id, home_dir.as_deref())
751 .await
752 .ok()??;
753
754 let user_sessions = self
757 .sessions
758 .entry(user_key.to_string())
759 .or_insert_with(|| UserSessions {
760 sessions: DashMap::new(),
761 token_store: Arc::new(pep::MemoryTokenStore::new()),
762 });
763
764 if let Some(existing) = user_sessions.sessions.get(chain_id) {
765 let now = chrono::Utc::now();
766 *existing.last_active.lock().await = now;
767 return Some((
768 chain_id.to_string(),
769 existing.session.clone(),
770 existing.ws_tx.clone(),
771 ));
772 }
773
774 if user_sessions.sessions.len() >= self.max_sessions_per_user {
779 let mut evict: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
780 for entry in user_sessions.sessions.iter() {
781 let is_idle = entry.session.lock().await.workflow_state
782 == trustee_core::types::WorkflowState::Idle;
783 if !is_idle {
784 continue;
785 }
786 let la = entry.last_active.lock().await;
787 if evict.as_ref().is_none_or(|(_, t)| *la < *t) {
788 evict = Some((entry.key().clone(), *la));
789 }
790 }
791 if let Some((victim, _)) = evict {
792 user_sessions.sessions.remove(&victim);
793 } else {
794 return None;
795 }
796 }
797
798 let (mut session, workflow_rx) = Session::new();
801 session.config_toml = Some(config_toml.clone());
802 session.parse_auto_handoff_config();
803 if let Ok(table) = config_toml.parse::<toml::Value>() {
804 if let Some(name) = table
805 .get("agent")
806 .and_then(|a| a.get("name"))
807 .and_then(|n| n.as_str())
808 {
809 session.agent_name = name.to_string();
810 }
811 }
812 session.secrets = self.secrets.clone();
813 session.build_info = self.build_info.clone();
814 self.apply_user_isolation(&mut session, user_key);
815 session.session_id = Some(chain_id.to_string());
816 session.resume_info = Some(info);
817 session.session_name = None; let (ws_tx_entry, _) = broadcast::channel::<String>(256);
820 let now = chrono::Utc::now();
821
822 user_sessions.sessions.insert(
823 chain_id.to_string(),
824 UserSessionEntry {
825 session: Arc::new(Mutex::new(session)),
826 ws_tx: ws_tx_entry.clone(),
827 created_at: now,
828 last_active: Arc::new(Mutex::new(now)),
829 },
830 );
831
832 let session_arc = user_sessions
836 .sessions
837 .get(chain_id)
838 .map(|e| e.session.clone())
839 .ok_or_else(|| SessionError::NotFound(chain_id.to_string()))
840 .ok()?;
841 self.spawn_user_drain_task(
842 chain_id.to_string(),
843 session_arc.clone(),
844 ws_tx_entry.clone(),
845 workflow_rx,
846 );
847
848 Some((chain_id.to_string(), session_arc, ws_tx_entry))
849 }
850
851 pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> {
853 let Some(user_sessions) = self.sessions.get(user_key) else {
854 return Vec::new();
855 };
856
857 let mut items = Vec::new();
858 for entry in user_sessions.sessions.iter() {
859 let session = entry.session.lock().await;
860 let workflow_state = match session.workflow_state {
861 trustee_core::types::WorkflowState::Idle => "Idle",
862 trustee_core::types::WorkflowState::Running => "Running",
863 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
864 };
865 let last_active = entry.last_active.lock().await;
866 items.push(SessionListItem {
867 session_id: entry.key().clone(),
868 session_name: session.session_name.clone(),
869 workflow_state: workflow_state.to_string(),
870 created_at: entry.created_at.to_rfc3339(),
871 last_active: last_active.to_rfc3339(),
872 handoff_count: session.handoff_count,
873 });
874 }
875 drop(user_sessions);
876
877 items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
879 items
880 }
881
882 pub async fn destroy_session(
884 &self,
885 user_key: &str,
886 session_id: &str,
887 ) -> Result<(), SessionError> {
888 let user_sessions = self
889 .sessions
890 .get(user_key)
891 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
892
893 let chain_id_for_tombstone = {
896 let entry = user_sessions
897 .sessions
898 .get(session_id)
899 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
900 let session = entry.session.lock().await;
901 if session.workflow_state != trustee_core::types::WorkflowState::Idle {
902 let state_str = match session.workflow_state {
903 trustee_core::types::WorkflowState::Running => "Running",
904 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
905 _ => "Unknown",
906 };
907 return Err(SessionError::NotIdle(state_str.to_string()));
908 }
909 session.session_id.clone()
910 };
911
912 user_sessions.sessions.remove(session_id);
914
915 if let Some(chain_id) = chain_id_for_tombstone {
919 self.write_tombstone(user_key, &chain_id);
920 }
921
922 Ok(())
923 }
924
925 pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
936 if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
947 static GATE_WARNED: std::sync::Once = std::sync::Once::new();
948 GATE_WARNED.call_once(|| {
949 tracing::warn!(
950 "TRUSTEE_CLASSIC_HOME=1 — ALL users share the classic home \
951 (~/.trustee); per-user checkpoint isolation is OFF. \
952 Single-user loopback ONLY — unset this env on multi-user servers."
953 );
954 });
955 return dirs::home_dir().map(|home| home.join(".trustee"));
956 }
957 let user_key = effective_user_key(user_key);
958 let hash = trustee_core::user_hash(&user_key);
959 dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&hash))
960 }
961
962 pub fn get_user_config_and_home(
967 &self,
968 user_key: &str,
969 ) -> (Option<String>, Option<std::path::PathBuf>) {
970 (self.config_toml.clone(), self.get_user_home_dir(user_key))
971 }
972
973 fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
982 if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
985 if let Some(home) = dirs::home_dir() {
986 session.home_dir = Some(home.join(".trustee"));
987 }
988 return;
989 }
990 let user_key = effective_user_key(user_key);
991 let user_hash = trustee_core::user_hash(&user_key);
992
993 let user_home = if let Some(home) = dirs::home_dir() {
995 let user_home = home.join(".trustee").join("users").join(&user_hash);
996 session.home_dir = Some(user_home.clone());
997 Some(user_home)
998 } else {
999 None
1000 };
1001
1002 if let Some(ref user_home) = user_home {
1006 if materialized_user_missing_env(user_home) {
1007 tracing::warn!(
1008 "user namespace {} is materialized but has NO per-user .env — \
1009 ${{…}} references will fall through to process env \
1010 (global ~/.trustee/.env) and execute under the GLOBAL principal",
1011 &user_hash[..8.min(user_hash.len())]
1012 );
1013 }
1014 }
1015
1016 session.project_id = Some(format!("web{}", &user_hash[..16]));
1017
1018 let shared_secrets = session.secrets.clone().unwrap_or_default();
1025 let mut merged_secrets = shared_secrets.clone();
1026
1027 if let Some(ref user_home) = user_home {
1028 if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
1029 merged_secrets = merged;
1030 }
1031 }
1032
1033 if let Some(ref user_home) = user_home {
1035 if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref())
1036 {
1037 session.config_toml = Some(merged);
1038 tracing::debug!("Merged per-user config into session");
1039 }
1040 }
1041
1042 if let Some(ref mut config_toml) = session.config_toml {
1044 substitute_env_vars(
1045 config_toml,
1046 &merged_secrets,
1047 &format!("user {}", &user_hash[..8.min(user_hash.len())]),
1048 );
1049 }
1050
1051 session.secrets = Some(shared_secrets);
1053 }
1054
1055 fn load_user_secrets(
1058 &self,
1059 user_home: &std::path::Path,
1060 base: &std::collections::HashMap<String, String>,
1061 ) -> std::io::Result<std::collections::HashMap<String, String>> {
1062 let user_env_path = user_home.join(".env");
1063 if !user_env_path.exists() {
1064 return Ok(base.clone());
1065 }
1066 let content = std::fs::read_to_string(&user_env_path)?;
1067 let mut merged = base.clone();
1068 for line in content.lines() {
1069 let line = line.trim();
1070 if line.is_empty() || line.starts_with('#') {
1071 continue;
1072 }
1073 if let Some((key, value)) = line.split_once('=') {
1074 let key = key.trim().to_string();
1075 let value = value
1076 .trim()
1077 .trim_matches('"')
1078 .trim_matches('\'')
1079 .to_string();
1080 merged.insert(key, value);
1081 }
1082 }
1083 tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
1084 Ok(merged)
1085 }
1086
1087 fn merge_user_config(
1102 &self,
1103 user_home: &std::path::Path,
1104 shared_config: Option<&str>,
1105 ) -> Option<String> {
1106 let user_config_path = user_home.join("config").join("trustee.toml");
1107 if !user_config_path.exists() {
1108 return None;
1109 }
1110 let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
1111 let shared = shared_config.unwrap_or("").parse::<toml::Value>().ok()?;
1112 let overlay = user_config_toml.parse::<toml::Value>().ok()?;
1113
1114 let allowed = |section: &str| {
1115 section == "mcp"
1116 || section == "thq"
1120 || section == "lifecycle"
1125 || (self.allow_llm_overlay && section == "llm")
1126 };
1127 let dir_name = user_home
1130 .file_name()
1131 .and_then(|n| n.to_str())
1132 .unwrap_or("<unknown>");
1133 let masked_user = dir_name.get(..8).unwrap_or(dir_name);
1134
1135 let mut filtered_overlay = toml::map::Map::new();
1136 if let Some(table) = overlay.as_table() {
1137 for (section, value) in table {
1138 if section == "lifecycle" {
1139 let mut v = value.clone();
1140 if let Some(t) = v.as_table_mut() {
1141 if t.remove("enabled").is_some() {
1142 tracing::info!(
1143 "user config overlay: [lifecycle].enabled is not user-settable — stripped for user {}",
1144 masked_user
1145 );
1146 }
1147 }
1148 filtered_overlay.insert(section.clone(), v);
1149 } else if allowed(section) {
1150 filtered_overlay.insert(section.clone(), value.clone());
1151 } else {
1152 tracing::warn!(
1153 "user config overlay: dropping non-allowlisted section [{}] for user {}",
1154 section,
1155 masked_user
1156 );
1157 }
1158 }
1159 }
1160
1161 if filtered_overlay.is_empty() {
1162 return None;
1164 }
1165 let overlay = toml::Value::Table(filtered_overlay);
1166
1167 let mut shared = shared;
1168 deep_merge_toml(&mut shared, &overlay);
1169 let merged = toml::to_string(&shared).ok()?;
1170 tracing::debug!("Merged per-user config from {}", user_config_path.display());
1171 Some(merged)
1172 }
1173
1174 pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1183 let config_toml = self.config_toml.clone()?;
1184
1185 let user_home = self.get_user_home_dir(user_key)?;
1187
1188 let mut merged_secrets = self.secrets.clone().unwrap_or_default();
1190 if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
1191 merged_secrets = merged;
1192 }
1193
1194 let mut resolved = config_toml;
1196 if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
1197 resolved = merged;
1198 }
1199
1200 substitute_env_vars(
1202 &mut resolved,
1203 &merged_secrets,
1204 &format!(
1205 "resolve_user_config user {}",
1206 &trustee_core::user_hash(user_key)[..8]
1207 ),
1208 );
1209
1210 Some(resolved)
1211 }
1212
1213 pub async fn get_or_build_mcp_loader(
1229 &self,
1230 user_key: &str,
1231 token_store: &Arc<pep::MemoryTokenStore>,
1232 ) -> Result<Option<std::sync::Arc<abk::agent::McpToolLoader>>, String> {
1233 let user_key = effective_user_key(user_key);
1234 let user_hash = trustee_core::user_hash(&user_key);
1235
1236 let resolved = self.resolve_user_config(&user_key);
1238 let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1239
1240 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1242 match loader_freshness(
1243 entry.fingerprint,
1244 entry.built_at,
1245 entry.degraded.as_deref(),
1246 entry.failed_at,
1247 fingerprint,
1248 chrono::Utc::now(),
1249 ) {
1250 LoaderFreshness::Fresh => return Ok(entry.loader.clone()),
1251 LoaderFreshness::ConfigChanged => {
1252 }
1254 LoaderFreshness::AgedOut => {
1255 tracing::info!(
1258 "MCP toolset for user {} is {}s old (max {}s) — rebuilding \
1259 to re-list the served surface (phantom-tool guard)",
1260 &user_hash[..8.min(user_hash.len())],
1261 (chrono::Utc::now() - entry.built_at).num_seconds(),
1262 MCP_TOOLSET_MAX_AGE.as_secs()
1263 );
1264 }
1265 LoaderFreshness::Backoff(remaining) => {
1266 let err = entry.degraded.clone().unwrap_or_else(|| {
1267 "previous MCP loader build failed; retry after backoff".to_string()
1268 });
1269 let _ = remaining;
1270 return Err(err);
1271 }
1272 }
1273 }
1274
1275 let lock = self
1277 .mcp_build_locks
1278 .entry(user_hash.clone())
1279 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1280 .clone();
1281 let _guard = lock.lock().await;
1282
1283 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1285 if loader_freshness(
1286 entry.fingerprint,
1287 entry.built_at,
1288 entry.degraded.as_deref(),
1289 entry.failed_at,
1290 fingerprint,
1291 chrono::Utc::now(),
1292 ) == LoaderFreshness::Fresh
1293 {
1294 return Ok(entry.loader.clone());
1295 }
1296 }
1297
1298 match self
1299 .build_mcp_loader(&user_hash, resolved.as_deref(), fingerprint, token_store)
1300 .await
1301 {
1302 Ok(entry) => {
1303 self.mcp_loaders.insert(user_hash.clone(), entry);
1304 Ok(self.mcp_loaders.get(&user_hash).unwrap().loader.clone())
1305 }
1306 Err(err) => {
1307 tracing::warn!(
1308 "MCP loader build FAILED for user {}; dispatch fails loud, retry after {:?}",
1309 &user_hash[..8.min(user_hash.len())],
1310 MCP_BUILD_RETRY_BACKOFF
1311 );
1312 self.mcp_loaders.insert(
1313 user_hash,
1314 McpLoaderEntry {
1315 loader: None,
1316 fingerprint,
1317 built_at: chrono::Utc::now(),
1318 degraded: Some(err.clone()),
1319 failed_at: Some(chrono::Utc::now()),
1320 },
1321 );
1322 Err(err)
1323 }
1324 }
1325 }
1326
1327 async fn build_mcp_loader(
1330 &self,
1331 user_hash: &str,
1332 resolved: Option<&str>,
1333 fingerprint: u64,
1334 token_store: &Arc<pep::MemoryTokenStore>,
1335 ) -> Result<McpLoaderEntry, String> {
1336 let mcp_config: Option<abk::config::McpConfig> = match resolved {
1337 Some(toml_str) => {
1338 let value = toml_str
1339 .parse::<toml::Value>()
1340 .map_err(|e| format!("config parse failed: {}", e))?;
1341 match value.get("mcp") {
1342 Some(section) => {
1343 let mut leftovers: Vec<String> = Vec::new();
1350 collect_unresolved_placeholders(section, &mut leftovers);
1351 if !leftovers.is_empty() {
1352 return Err(format!(
1353 "unresolved {} placeholder(s) in [mcp]: {} — supply the \
1354 exact-case key in the profile secrets",
1355 leftovers.len(),
1356 leftovers.join(", ")
1357 ));
1358 }
1359 use serde::Deserialize as _;
1360 Some(
1361 abk::config::McpConfig::deserialize(section.clone())
1362 .map_err(|e| format!("invalid [mcp] config: {}", e))?,
1363 )
1364 }
1365 None => None,
1366 }
1367 }
1368 None => None,
1369 };
1370
1371 let loader = match mcp_config {
1372 Some(cfg) if cfg.enabled => {
1373 let built = abk::agent::McpToolLoader::with_token_store(
1374 &cfg,
1375 Some(token_store.clone() as std::sync::Arc<dyn pep::token_store::TokenStore>),
1376 )
1377 .await
1378 .map_err(|e| format!("MCP loader build failed: {}", e))?;
1379
1380 let servers: Vec<String> = built
1384 .server_statuses
1385 .iter()
1386 .map(|s| {
1387 if s.connected {
1388 format!("{}(up,{}tools)", s.name, s.tool_count)
1389 } else {
1390 format!("{}(DOWN)", s.name)
1391 }
1392 })
1393 .collect();
1394 tracing::info!(
1395 "MCP loader built for user {}: servers=[{}] total_tools={}",
1396 &user_hash[..8.min(user_hash.len())],
1397 servers.join(", "),
1398 built.tool_count
1399 );
1400 Some(std::sync::Arc::new(built))
1401 }
1402 _ => None,
1403 };
1404
1405 Ok(McpLoaderEntry {
1406 loader,
1407 fingerprint,
1408 built_at: chrono::Utc::now(),
1409 degraded: None,
1410 failed_at: None,
1411 })
1412 }
1413
1414 fn spawn_user_drain_task(
1416 &self,
1417 session_id: String,
1418 session: Arc<Mutex<Session>>,
1419 ws_tx: broadcast::Sender<String>,
1420 mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
1421 ) {
1422 let mut last_broadcast_state: Option<String> = None;
1428 tokio::spawn(async move {
1429 while let Some(msg) = workflow_rx.recv().await {
1430 {
1431 let mut session = session.lock().await;
1432 session.handle_workflow_message(msg.clone());
1433
1434 let state_str = match session.workflow_state {
1435 trustee_core::types::WorkflowState::Idle => "Idle",
1436 trustee_core::types::WorkflowState::Running => "Running",
1437 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1438 };
1439 if last_broadcast_state.as_deref() != Some(state_str) {
1440 last_broadcast_state = Some(state_str.to_string());
1441 let state_msg = serde_json::json!({
1442 "type": "StateChanged",
1443 "state": state_str
1444 });
1445 let _ = ws_tx.send(state_msg.to_string());
1446 }
1447 }
1448
1449 let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1450 let _ = ws_tx.send(json);
1451 }
1452 tracing::debug!("Drain task ended for session: {}", session_id);
1453 });
1454 }
1455
1456 pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1459 let default_user = self
1461 .sessions
1462 .get("default")
1463 .expect("default user must exist");
1464 let first_entry = default_user
1465 .sessions
1466 .iter()
1467 .next()
1468 .expect("default user must have at least one session");
1469 let session = first_entry.session.clone();
1470 let ws_tx = first_entry.ws_tx.clone();
1471 let session_id = first_entry.key().clone();
1472 drop(first_entry);
1473 drop(default_user);
1474
1475 let mut last_broadcast_state = Some("Running".to_string());
1479 tokio::spawn(async move {
1480 while let Some(msg) = workflow_rx.recv().await {
1481 {
1482 let mut session = session.lock().await;
1483 session.handle_workflow_message(msg.clone());
1484
1485 let state_str = match session.workflow_state {
1486 trustee_core::types::WorkflowState::Idle => "Idle",
1487 trustee_core::types::WorkflowState::Running => "Running",
1488 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1489 };
1490 if last_broadcast_state.as_deref() != Some(state_str) {
1491 last_broadcast_state = Some(state_str.to_string());
1492 let state_msg = serde_json::json!({
1493 "type": "StateChanged",
1494 "state": state_str
1495 });
1496 let _ = ws_tx.send(state_msg.to_string());
1497 }
1498 }
1499
1500 let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1501 let _ = ws_tx.send(json);
1502 }
1503 tracing::debug!("Drain task ended for session: {}", session_id);
1504 });
1505 }
1506
1507 pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
1509 let Some(ref auth) = self.auth else {
1510 return "default".to_string();
1511 };
1512
1513 if let Some(token) = headers
1515 .get(axum::http::header::AUTHORIZATION)
1516 .and_then(|v| v.to_str().ok())
1517 .and_then(|v| v.strip_prefix("Bearer "))
1518 .map(|s| s.to_string())
1519 {
1520 if token.starts_with("dev:") {
1521 let parts: Vec<&str> = token.splitn(4, ':').collect();
1522 if parts.len() >= 4 {
1523 return format!("dev:{}", parts[1]);
1524 }
1525 }
1526 if let Ok(claims) = auth.validate_token(&token).await {
1527 return claims.sub;
1528 }
1529 }
1530
1531 let cookie_session_id = headers
1533 .get(axum::http::header::COOKIE)
1534 .and_then(|v| v.to_str().ok())
1535 .and_then(|cookies| {
1536 cookies.split(';').map(|c| c.trim()).find_map(|c| {
1537 c.strip_prefix(&format!("{}=", auth.config.cookie_name))
1538 .map(|s| s.to_string())
1539 })
1540 });
1541
1542 if let Some(session_id) = cookie_session_id {
1543 if session_id.starts_with("dev:") {
1544 let parts: Vec<&str> = session_id.splitn(4, ':').collect();
1545 if parts.len() >= 4 {
1546 return format!("dev:{}", parts[1]);
1547 }
1548 }
1549
1550 if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
1551 if let Ok(claims) = auth.validate_token(&access_token).await {
1552 return claims.sub;
1553 }
1554 }
1555 }
1556
1557 "default".to_string()
1558 }
1559}
1560
1561struct SerializableMessage<'a>(&'a TuiMessage);
1567
1568impl<'a> serde::Serialize for SerializableMessage<'a> {
1569 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1570 where
1571 S: serde::Serializer,
1572 {
1573 use serde::ser::SerializeStruct;
1574
1575 match self.0 {
1576 TuiMessage::OutputLine(line) => {
1577 let mut s = serializer.serialize_struct("msg", 2)?;
1578 s.serialize_field("type", "OutputLine")?;
1579 s.serialize_field("line", line)?;
1580 s.end()
1581 }
1582 TuiMessage::StreamDelta(delta) => {
1583 let mut s = serializer.serialize_struct("msg", 2)?;
1584 s.serialize_field("type", "StreamDelta")?;
1585 s.serialize_field("delta", delta)?;
1586 s.end()
1587 }
1588 TuiMessage::ReasoningDelta(delta) => {
1589 let mut s = serializer.serialize_struct("msg", 2)?;
1590 s.serialize_field("type", "ReasoningDelta")?;
1591 s.serialize_field("delta", delta)?;
1592 s.end()
1593 }
1594 TuiMessage::WorkflowCompleted => {
1595 let mut s = serializer.serialize_struct("msg", 2)?;
1596 s.serialize_field("type", "WorkflowCompleted")?;
1597 s.serialize_field("state", "Idle")?;
1598 s.end()
1599 }
1600 TuiMessage::WorkflowError(err) => {
1601 let mut s = serializer.serialize_struct("msg", 2)?;
1602 s.serialize_field("type", "WorkflowError")?;
1603 s.serialize_field("error", err)?;
1604 s.end()
1605 }
1606 TuiMessage::ResumeInfo(info) => match info {
1607 Some(ri) => {
1608 let mut s = serializer.serialize_struct("msg", 5)?;
1609 s.serialize_field("type", "ResumeInfo")?;
1610 s.serialize_field("state", "Idle")?;
1611 s.serialize_field("session_id", &ri.session_id)?;
1612 s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
1613 s.serialize_field("iteration", &ri.iteration)?;
1614 s.end()
1615 }
1616 None => {
1617 let mut s = serializer.serialize_struct("msg", 2)?;
1618 s.serialize_field("type", "ResumeInfo")?;
1619 s.serialize_field("state", "Idle")?;
1620 s.end()
1621 }
1622 },
1623 TuiMessage::TodoUpdate(content) => {
1624 let mut s = serializer.serialize_struct("msg", 2)?;
1625 s.serialize_field("type", "TodoUpdate")?;
1626 s.serialize_field("content", content)?;
1627 s.end()
1628 }
1629 TuiMessage::WorkflowCancelled => {
1630 let mut s = serializer.serialize_struct("msg", 2)?;
1631 s.serialize_field("type", "WorkflowCancelled")?;
1632 s.serialize_field("state", "Idle")?;
1633 s.end()
1634 }
1635 TuiMessage::HandoffReady(briefing) => {
1636 let mut s = serializer.serialize_struct("msg", 3)?;
1637 s.serialize_field("type", "HandoffReady")?;
1638 s.serialize_field("state", "Idle")?;
1639 s.serialize_field("briefing", briefing)?;
1640 s.end()
1641 }
1642 TuiMessage::SessionRotated { old, new } => {
1643 let mut s = serializer.serialize_struct("msg", 3)?;
1644 s.serialize_field("type", "SessionRotated")?;
1645 s.serialize_field("old", old)?;
1646 s.serialize_field("new", new)?;
1647 s.end()
1648 }
1649 TuiMessage::HandoffFailed => {
1650 let mut s = serializer.serialize_struct("msg", 2)?;
1651 s.serialize_field("type", "HandoffFailed")?;
1652 s.serialize_field("state", "Idle")?;
1653 s.end()
1654 }
1655 TuiMessage::ToolPending { tool_name, hint } => {
1656 let mut s = serializer.serialize_struct("msg", 3)?;
1657 s.serialize_field("type", "ToolPending")?;
1658 s.serialize_field("tool_name", tool_name)?;
1659 s.serialize_field("hint", hint)?;
1660 s.end()
1661 }
1662 TuiMessage::ToolDone {
1663 tool_name,
1664 success,
1665 hint,
1666 } => {
1667 let mut s = serializer.serialize_struct("msg", 4)?;
1668 s.serialize_field("type", "ToolDone")?;
1669 s.serialize_field("tool_name", tool_name)?;
1670 s.serialize_field("success", success)?;
1671 s.serialize_field("hint", hint)?;
1672 s.end()
1673 }
1674 TuiMessage::ContextTokensUpdated(count) => {
1675 let mut s = serializer.serialize_struct("msg", 2)?;
1676 s.serialize_field("type", "ContextTokensUpdated")?;
1677 s.serialize_field("count", count)?;
1678 s.end()
1679 }
1680 TuiMessage::McpServerStatus {
1681 name,
1682 connected,
1683 tool_count,
1684 error,
1685 } => {
1686 let mut s = serializer.serialize_struct("msg", 5)?;
1687 s.serialize_field("type", "McpServerStatus")?;
1688 s.serialize_field("name", name)?;
1689 s.serialize_field("connected", connected)?;
1690 s.serialize_field("tool_count", tool_count)?;
1691 s.serialize_field("error", error)?;
1692 s.end()
1693 }
1694 TuiMessage::SessionTitleUpdated(title) => {
1695 let mut s = serializer.serialize_struct("msg", 2)?;
1696 s.serialize_field("type", "SessionTitleUpdated")?;
1697 s.serialize_field("title", title)?;
1698 s.end()
1699 }
1700 }
1701 }
1702}
1703
1704fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1715 match (base, overlay) {
1716 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1717 for (key, overlay_val) in overlay_table {
1718 match base_table.get_mut(key) {
1719 Some(base_val) => {
1720 deep_merge_toml(base_val, overlay_val);
1722 }
1723 None => {
1724 base_table.insert(key.clone(), overlay_val.clone());
1726 }
1727 }
1728 }
1729 }
1730 (base, overlay) => {
1732 *base = overlay.clone();
1733 }
1734 }
1735}
1736
1737fn collect_unresolved_placeholders(value: &toml::Value, out: &mut Vec<String>) {
1744 match value {
1745 toml::Value::String(s) => {
1746 if let Some(start) = s.find("${") {
1747 if let Some(end_rel) = s[start + 2..].find('}') {
1748 let name = &s[start + 2..start + 2 + end_rel];
1749 let rendered = format!("${{{name}}}");
1750 if !out.contains(&rendered) {
1751 out.push(rendered);
1752 }
1753 }
1754 }
1755 }
1756 toml::Value::Table(t) => {
1757 for v in t.values() {
1758 collect_unresolved_placeholders(v, out);
1759 }
1760 }
1761 toml::Value::Array(a) => {
1762 for v in a {
1763 collect_unresolved_placeholders(v, out);
1764 }
1765 }
1766 _ => {}
1767 }
1768}
1769
1770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1772pub(crate) enum SecretSource {
1773 SecretsMap,
1776 ProcessEnv,
1782 Unresolved,
1784}
1785
1786#[derive(Debug, Clone, PartialEq, Eq)]
1788pub(crate) struct EnvVarResolution {
1789 pub name: String,
1790 pub source: SecretSource,
1791}
1792
1793fn substitute_env_vars(
1794 s: &mut String,
1795 secrets: &std::collections::HashMap<String, String>,
1796 ctx: &str,
1797) -> Vec<EnvVarResolution> {
1798 let mut result = String::with_capacity(s.len());
1800 let bytes = s.as_bytes();
1801 let mut i = 0;
1802 let mut report: Vec<EnvVarResolution> = Vec::new();
1805
1806 while i < bytes.len() {
1807 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1808 if let Some(end) = s[i + 2..].find('}') {
1810 let var_name = &s[i + 2..i + 2 + end];
1811 let (replacement, source) = if let Some(value) = secrets.get(var_name) {
1813 (value.clone(), SecretSource::SecretsMap)
1814 } else if let Ok(value) = std::env::var(var_name) {
1815 (value, SecretSource::ProcessEnv)
1816 } else {
1817 (s[i..i + 2 + end + 1].to_string(), SecretSource::Unresolved)
1822 };
1823 if !report.iter().any(|r| r.name == var_name) {
1824 report.push(EnvVarResolution {
1825 name: var_name.to_string(),
1826 source,
1827 });
1828 }
1829 result.push_str(&replacement);
1830 i = i + 2 + end + 1;
1831 } else {
1832 result.push('$');
1834 i += 1;
1835 }
1836 } else {
1837 result.push(bytes[i] as char);
1838 i += 1;
1839 }
1840 }
1841
1842 for r in &report {
1847 match r.source {
1848 SecretSource::ProcessEnv => tracing::error!(
1849 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from PROCESS ENV \
1850 (global ~/.trustee/.env at boot) — no per-user/profile secret \
1851 answered; calls using it execute under the GLOBAL principal. \
1852 Supply the exact-case key in the per-user .env / profile \
1853 secrets to restore attribution.",
1854 ctx,
1855 r.name
1856 ),
1857 SecretSource::Unresolved => {
1858 }
1860 SecretSource::SecretsMap => tracing::debug!(
1861 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from profile/user secrets",
1862 ctx,
1863 r.name
1864 ),
1865 }
1866 }
1867 let unresolved: Vec<&str> = report
1868 .iter()
1869 .filter(|r| r.source == SecretSource::Unresolved)
1870 .map(|r| r.name.as_str())
1871 .collect();
1872 if !unresolved.is_empty() {
1873 tracing::warn!(
1874 "ENV SUBSTITUTION LEFT {} UNRESOLVED [{}]: {} — the literal ${{…}} remains in the \
1875 effective config; supply the key in the profile secrets (exact-case KEY=VALUE) \
1876 or the environment",
1877 unresolved.len(),
1878 ctx,
1879 unresolved
1880 .iter()
1881 .map(|v| format!("${{{v}}}"))
1882 .collect::<Vec<_>>()
1883 .join(", ")
1884 );
1885 }
1886
1887 *s = result;
1888 report
1889}
1890
1891pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1896 user_home.is_dir() && !user_home.join(".env").exists()
1897}
1898
1899fn fingerprint_mcp_section(resolved: Option<&str>) -> u64 {
1909 use sha2::{Digest, Sha256};
1910 let section = resolved
1911 .and_then(|s| s.parse::<toml::Value>().ok())
1912 .and_then(|v| v.get("mcp").cloned());
1913 let bytes = match section {
1914 Some(v) => v.to_string().into_bytes(),
1915 None => b"<no-mcp>".to_vec(),
1916 };
1917 let digest = Sha256::digest(&bytes);
1918 u64::from_be_bytes(digest[..8].try_into().expect("sha256 digest >= 8 bytes"))
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923 use super::*;
1924
1925 fn test_state() -> ServerState {
1927 let (session, _rx) = Session::new();
1928 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
1929 ServerState::new(session, ws_tx, None)
1930 }
1931
1932 #[tokio::test]
1933 async fn dispatch_scope_pins_user_key_resolution() {
1934 let state = test_state();
1938 let caller = "some-human-sub";
1939 let plain_home = state.get_user_home_dir(caller).unwrap();
1940 let pinned_key = "prof-1234";
1941 let pinned_home = in_dispatch_scope(pinned_key, async {
1942 state.get_user_home_dir(caller).unwrap()
1943 })
1944 .await;
1945 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
1946 assert_eq!(plain_home, users_root.join(trustee_core::user_hash(caller)));
1947 assert_eq!(
1948 pinned_home,
1949 users_root.join(trustee_core::user_hash(pinned_key)),
1950 "dispatch scope must pin resolution to the binding key"
1951 );
1952 assert_ne!(plain_home, pinned_home);
1953 }
1954
1955 fn temp_user_home(tag: &str) -> std::path::PathBuf {
1957 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1958 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1959 let dir = std::env::temp_dir().join(format!(
1960 "trustee-state-test-{}-{}-{}",
1961 tag,
1962 std::process::id(),
1963 n
1964 ));
1965 std::fs::create_dir_all(dir.join("config")).expect("create temp user home");
1966 dir
1967 }
1968
1969 fn parse(toml_str: &str) -> toml::Value {
1970 toml_str.parse::<toml::Value>().expect("valid test TOML")
1971 }
1972
1973 #[test]
1976 fn overlay_allowlist_drops_non_allowlisted_sections() {
1977 let state = test_state();
1978 let home = temp_user_home("allowlist");
1979 std::fs::write(
1980 home.join("config").join("trustee.toml"),
1981 "[server]\nport = 1\n\n[auth]\nmode = \"kanidm\"\n\n[mcp]\nmode = \"user\"\n",
1982 )
1983 .expect("write overlay");
1984
1985 let shared = "[server]\nport = 8080\n\n[mcp]\nmode = \"shared\"\n";
1986 let merged = state
1987 .merge_user_config(&home, Some(shared))
1988 .expect("overlay has allowlisted content");
1989
1990 let merged_val = parse(&merged);
1991 let expected = parse("[server]\nport = 8080\n\n[mcp]\nmode = \"user\"\n");
1992 assert_eq!(
1993 merged_val, expected,
1994 "merged config must be shared + [mcp] overlay only"
1995 );
1996 assert!(
1997 merged_val.get("auth").is_none(),
1998 "overlay [auth] must be dropped"
1999 );
2000 }
2001
2002 #[test]
2004 fn no_overlay_file_returns_none() {
2005 let state = test_state();
2006 let home = temp_user_home("empty");
2007 assert!(state
2008 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2009 .is_none());
2010 }
2011
2012 #[test]
2014 fn overlay_with_no_allowlisted_sections_is_noop() {
2015 let state = test_state();
2016 let home = temp_user_home("all-dropped");
2017 std::fs::write(
2018 home.join("config").join("trustee.toml"),
2019 "[server]\nport = 1\n\n[storage]\npath = \"/tmp/x\"\n",
2020 )
2021 .expect("write overlay");
2022 assert!(state
2023 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2024 .is_none());
2025 }
2026
2027 #[test]
2030 fn llm_overlay_dropped_by_default_and_kept_when_enabled() {
2031 let shared = "[llm]\nprovider = \"openai\"\n\n[mcp]\nmode = \"shared\"\n";
2032 let overlay = "[llm]\nprovider = \"anthropic\"\n";
2033
2034 let state = test_state();
2036 assert!(!state.allow_llm_overlay);
2037 let home = temp_user_home("llm-off");
2038 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2039 assert!(state.merge_user_config(&home, Some(shared)).is_none());
2040
2041 let state = test_state().with_allow_llm_overlay(true);
2043 assert!(state.allow_llm_overlay);
2044 let home = temp_user_home("llm-on");
2045 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2046 let merged = state
2047 .merge_user_config(&home, Some(shared))
2048 .expect("[llm] overlay applies when opted in");
2049 let merged_val = parse(&merged);
2050 assert_eq!(merged_val["llm"]["provider"].as_str(), Some("anthropic"));
2051 assert_eq!(merged_val["mcp"]["mode"].as_str(), Some("shared"));
2052 }
2053
2054 #[test]
2058 fn user_home_dir_uses_consolidated_hash() {
2059 let state = test_state();
2060 if let Some(home) = state.get_user_home_dir("farzan@example.com") {
2061 assert_eq!(
2062 home.file_name().and_then(|n| n.to_str()),
2063 Some(trustee_core::user_hash("farzan@example.com")).as_deref()
2064 );
2065 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2066 assert_eq!(home.parent(), Some(&users_root).map(|p| p.as_path()));
2067 }
2068 }
2070
2071 #[test]
2074 fn lifecycle_overlay_survives_allowlist_enabled_stripped() {
2075 let state = test_state();
2076 let home = temp_user_home("lifecycle-persona");
2077 std::fs::write(
2078 home.join("config").join("trustee.toml"),
2079 "[lifecycle]\nsystem_template = \"You are Saman.\"\nenabled = true\n",
2080 )
2081 .expect("write overlay");
2082
2083 let merged = state
2084 .merge_user_config(
2085 &home,
2086 Some("[lifecycle]\nsystem_template = \"shared default\"\n"),
2087 )
2088 .expect("lifecycle overlay is allowlisted");
2089
2090 let v = merged.parse::<toml::Value>().expect("valid TOML");
2091 assert_eq!(
2092 v["lifecycle"]["system_template"].as_str(),
2093 Some("You are Saman."),
2094 "agent persona wins over shared default"
2095 );
2096 assert_eq!(
2097 v["lifecycle"].get("enabled"),
2098 None,
2099 "WASM extension switch is not user-settable"
2100 );
2101 }
2102
2103 #[tokio::test]
2108 async fn agent_principals_get_isolated_session_buckets() {
2109 let state = test_state();
2110 let key_a = "agent-farzan";
2111 let key_b = "agent-paydar";
2112
2113 let sid_a = state
2114 .create_session(key_a, None, None)
2115 .await
2116 .expect("create session");
2117 let _sid_b = state
2118 .create_session(key_b, None, None)
2119 .await
2120 .expect("create session");
2121
2122 assert!(
2124 state.get_session_by_any_id(key_a, &sid_a).await.is_some(),
2125 "owner bucket resolves its own session"
2126 );
2127 assert!(
2128 state.get_session_by_any_id(key_b, &sid_a).await.is_none(),
2129 "cross-agent session access must be 404/None"
2130 );
2131
2132 if let Some(home) = state.get_user_home_dir(key_a) {
2134 assert_eq!(
2135 home.file_name().and_then(|n| n.to_str()),
2136 Some(trustee_core::user_hash(key_a)).as_deref()
2137 );
2138 }
2139 }
2140}
2141
2142#[cfg(test)]
2147mod mcp_loader_cache_tests {
2148 use super::*;
2149
2150 fn state_with_shared(shared: &str) -> ServerState {
2151 let (session, _rx) = Session::new();
2152 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2153 let mut state = ServerState::new(session, ws_tx, None);
2154 state.config_toml = Some(shared.to_string());
2155 state
2156 }
2157
2158 struct TempUser {
2162 key: String,
2163 home: std::path::PathBuf,
2164 }
2165
2166 impl TempUser {
2167 fn new(tag: &str) -> Self {
2168 let key = format!("16c-{tag}-{}@test.invalid", std::process::id());
2169 let home = dirs::home_dir()
2170 .expect("HOME available in test env")
2171 .join(".trustee")
2172 .join("users")
2173 .join(trustee_core::user_hash(&key));
2174 std::fs::create_dir_all(home.join("config")).expect("create user home");
2175 Self { key, home }
2176 }
2177
2178 fn write_overlay(&self, toml_str: &str) {
2179 std::fs::write(self.home.join("config").join("trustee.toml"), toml_str)
2180 .expect("write overlay");
2181 }
2182 }
2183
2184 impl Drop for TempUser {
2185 fn drop(&mut self) {
2186 let _ = std::fs::remove_dir_all(&self.home);
2187 }
2188 }
2189
2190 #[tokio::test]
2191 async fn no_mcp_config_caches_disabled_marker() {
2192 let state = state_with_shared("[server]\nport = 8080\n");
2193 let user = TempUser::new("nomcp");
2194 let ts = Arc::new(pep::MemoryTokenStore::new());
2195
2196 let first = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2197 assert!(first.is_none(), "no [mcp] anywhere → disabled marker");
2198 assert_eq!(state.mcp_loaders.len(), 1, "exactly one cache entry");
2199
2200 let second = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2202 assert!(second.is_none());
2203 assert_eq!(state.mcp_loaders.len(), 1);
2204 }
2205
2206 #[tokio::test]
2207 async fn concurrent_cold_builds_single_flight() {
2208 let state = Arc::new(state_with_shared("[server]\nport = 8080\n"));
2209 let user = TempUser::new("singleflight");
2210 let ts = Arc::new(pep::MemoryTokenStore::new());
2211
2212 let mut handles = Vec::new();
2213 for _ in 0..5 {
2214 let state = state.clone();
2215 let key = user.key.clone();
2216 let ts = ts.clone();
2217 handles.push(tokio::spawn(async move {
2218 state.get_or_build_mcp_loader(&key, &ts).await
2219 }));
2220 }
2221 for h in handles {
2222 h.await.unwrap().expect("all five succeed");
2223 }
2224 assert_eq!(state.mcp_loaders.len(), 1, "single-flight → one entry");
2225 }
2226
2227 #[tokio::test]
2228 async fn fingerprint_change_triggers_rebuild() {
2229 let state = state_with_shared("[server]\nport = 8080\n");
2230 let user = TempUser::new("fpchange");
2231 let ts = Arc::new(pep::MemoryTokenStore::new());
2232
2233 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2236 let v1 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2237 assert!(v1.is_some(), "enabled [mcp] → real loader");
2238 let fp1 = state
2239 .mcp_loaders
2240 .get(&trustee_core::user_hash(&user.key))
2241 .unwrap()
2242 .fingerprint;
2243
2244 std::thread::sleep(std::time::Duration::from_millis(5));
2246 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v2\"\nurl = \"http://127.0.0.1:9/other\"\n");
2247 let v2 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2248 assert!(v2.is_some());
2249 let entry = state
2250 .mcp_loaders
2251 .get(&trustee_core::user_hash(&user.key))
2252 .unwrap();
2253 assert_ne!(
2254 entry.fingerprint, fp1,
2255 "fingerprint must change with content"
2256 );
2257 assert!(entry.degraded.is_none());
2258
2259 let _still_usable = v1.as_ref().unwrap().tool_count;
2261 }
2262
2263 #[tokio::test]
2264 async fn degraded_entry_fails_loud_within_backoff_and_isolates_users() {
2265 let state = state_with_shared("[server]\nport = 8080\n");
2266 let bad = TempUser::new("degraded-bad");
2267 let good = TempUser::new("degraded-good");
2268 let ts = Arc::new(pep::MemoryTokenStore::new());
2269
2270 bad.write_overlay("[mcp]\nenabled = \"not-a-bool\"\n");
2272 let err = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2273 Ok(_) => panic!("invalid [mcp] must fail loud"),
2274 Err(e) => e,
2275 };
2276 assert!(
2277 err.contains("invalid [mcp]"),
2278 "surfaces the parse error: {err}"
2279 );
2280
2281 let entry = state
2282 .mcp_loaders
2283 .get(&trustee_core::user_hash(&bad.key))
2284 .unwrap();
2285 assert!(entry.degraded.is_some(), "poison entry recorded");
2286
2287 let err2 = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2289 Ok(_) => panic!("still within backoff"),
2290 Err(e) => e,
2291 };
2292 assert_eq!(err, err2, "same cached error");
2293
2294 let good_loader = state
2296 .get_or_build_mcp_loader(&good.key, &ts)
2297 .await
2298 .expect("other user unaffected");
2299 assert!(
2300 good_loader.is_none(),
2301 "good user has no [mcp] → disabled marker"
2302 );
2303 }
2304
2305 #[tokio::test]
2306 async fn aged_out_entry_rebuilds_without_restart() {
2307 let state = state_with_shared("[server]\nport = 8080\n");
2312 let user = TempUser::new("agedout");
2313 let ts = Arc::new(pep::MemoryTokenStore::new());
2314
2315 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2316 let v1 = state
2317 .get_or_build_mcp_loader(&user.key, &ts)
2318 .await
2319 .expect("initial build");
2320 let v1 = v1.expect("enabled [mcp] → loader");
2321
2322 {
2325 let mut entry = state
2326 .mcp_loaders
2327 .get_mut(&trustee_core::user_hash(&user.key))
2328 .unwrap();
2329 entry.built_at = chrono::Utc::now() - chrono::Duration::seconds(601);
2330 }
2331
2332 let v2 = state
2333 .get_or_build_mcp_loader(&user.key, &ts)
2334 .await
2335 .expect("aged-out dispatch rebuilds");
2336 let v2 = v2.expect("rebuilt loader present");
2337 assert!(
2338 !Arc::ptr_eq(&v1, &v2),
2339 "aged-out entry must NOT be reused — fresh connect re-lists the served surface"
2340 );
2341 let entry = state
2342 .mcp_loaders
2343 .get(&trustee_core::user_hash(&user.key))
2344 .unwrap();
2345 assert!(
2346 chrono::Utc::now() - entry.built_at < chrono::Duration::seconds(5),
2347 "built_at refreshed by the rebuild"
2348 );
2349
2350 let v3 = state
2352 .get_or_build_mcp_loader(&user.key, &ts)
2353 .await
2354 .expect("fresh dispatch");
2355 assert!(Arc::ptr_eq(&v2, &v3.unwrap()), "young entry hits the cache");
2356 }
2357}
2358
2359#[cfg(test)]
2360mod restore_tests {
2361 use super::*;
2362
2363 fn fresh_state() -> ServerState {
2364 let (session, _rx) = Session::new();
2365 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2366 let mut state = ServerState::new(session, ws_tx, None);
2367 state.config_toml = Some("[agent]\nname = \"trustee\"\n".to_string());
2368 state
2369 }
2370
2371 struct TempUser {
2373 key: String,
2374 }
2375
2376 impl TempUser {
2377 fn new(tag: &str) -> Self {
2378 let key = format!("restore-{tag}-{}@test.invalid", std::process::id());
2379 let home = dirs::home_dir()
2380 .expect("HOME available in test env")
2381 .join(".trustee")
2382 .join("users")
2383 .join(trustee_core::user_hash(&key));
2384 std::fs::create_dir_all(home).expect("create user home");
2385 Self { key }
2386 }
2387 }
2388
2389 #[tokio::test]
2390 async fn restore_unknown_chain_returns_none() {
2391 let state = fresh_state();
2392 let user = TempUser::new("unknown");
2393 let out = state
2394 .get_or_restore_session_by_any_id(&user.key, "session_2099_01_01_00_00_deadbeef")
2395 .await;
2396 assert!(out.is_none(), "unknown chain must 404, not restore");
2397 }
2398
2399 #[tokio::test]
2400 async fn destroy_tombstones_chain_id() {
2401 let state = fresh_state();
2402 let user = TempUser::new("tombstone");
2403 let sid = state
2404 .create_session(&user.key, None, None)
2405 .await
2406 .expect("create session");
2407
2408 let (session, _ws) = state.get_session(&user.key, &sid).await.unwrap();
2410 {
2411 let mut s = session.lock().await;
2412 s.session_id = Some("session_2026_09_04_10_00_aaaabbbb".to_string());
2413 }
2414
2415 state
2416 .destroy_session(&user.key, &sid)
2417 .await
2418 .expect("destroy");
2419
2420 let path = state
2421 .tombstone_path(&user.key, "session_2026_09_04_10_00_aaaabbbb")
2422 .expect("tombstone path");
2423 assert!(path.exists(), "destroy must write a chain tombstone");
2424
2425 let sid2 = state
2427 .create_session(&user.key, None, None)
2428 .await
2429 .expect("create second session");
2430 state
2431 .destroy_session(&user.key, &sid2)
2432 .await
2433 .expect("destroy 2");
2434 }
2436
2437 #[tokio::test]
2438 async fn restore_refuses_registry_key_shapes() {
2439 let state = fresh_state();
2443 let user = TempUser::new("shapes");
2444 assert!(state.tombstone_path(&user.key, "default").is_none());
2445 let out = state
2446 .get_or_restore_session_by_any_id(&user.key, "default")
2447 .await;
2448 assert!(out.is_none());
2449 }
2450}
2451
2452#[test]
2453fn collect_unresolved_placeholders_walks_the_tree() {
2454 let section: toml::Value = toml::from_str(
2455 r#"
2456[credentials.farzan_service_account]
2457service_token = "${FARZAN_SERVICE_ACCOUNT}"
2458issuer_url = "https://ok.example"
2459
2460[[servers]]
2461name = "fame"
2462url = "https://fame.example"
2463credentials = "farzan_service_account"
2464
2465[[servers]]
2466name = "other"
2467url = "https://other.example"
2468token = "${OTHER_TOKEN}"
2469"#,
2470 )
2471 .unwrap();
2472 let mut out = Vec::new();
2473 collect_unresolved_placeholders(§ion, &mut out);
2474 out.sort();
2475 assert_eq!(
2476 out,
2477 vec![
2478 "${FARZAN_SERVICE_ACCOUNT}".to_string(),
2479 "${OTHER_TOKEN}".to_string()
2480 ],
2481 "every leftover placeholder in the [mcp] tree must be collected, deduped"
2482 );
2483 let clean: toml::Value = toml::from_str("[x]\ny = \"z\"\n").unwrap();
2485 let mut out2 = Vec::new();
2486 collect_unresolved_placeholders(&clean, &mut out2);
2487 assert!(out2.is_empty());
2488}
2489
2490#[test]
2491fn substitute_env_vars_resolves_and_keeps_leftovers_deterministically() {
2492 let mut secrets = std::collections::HashMap::new();
2493 secrets.insert("A_TOKEN".to_string(), "resolved-value".to_string());
2494 let mut s = "x = \"${A_TOKEN}\" y = \"${MISSING_TOKEN}\"".to_string();
2495 let report = substitute_env_vars(&mut s, &secrets, "test");
2496 assert!(s.contains("resolved-value"), "declared key resolves: {s}");
2497 assert!(
2498 s.contains("${MISSING_TOKEN}"),
2499 "undeclared key keeps the literal (the loader guard turns this into a loud failure): {s}"
2500 );
2501 assert_eq!(report.len(), 2, "one entry per distinct var: {report:?}");
2503 assert!(report.contains(&EnvVarResolution {
2504 name: "A_TOKEN".to_string(),
2505 source: SecretSource::SecretsMap
2506 }));
2507 assert!(report.contains(&EnvVarResolution {
2508 name: "MISSING_TOKEN".to_string(),
2509 source: SecretSource::Unresolved
2510 }));
2511}
2512
2513#[test]
2514fn substitute_env_vars_reports_process_env_shadow() {
2515 let var = format!("RAVAND_PROVENANCE_TEST_{}", std::process::id());
2520 std::env::set_var(&var, "global-env-answer");
2521
2522 let mut secrets = std::collections::HashMap::new();
2524 let mut s = format!("x = \"${{{var}}}\"");
2525 let report = substitute_env_vars(&mut s, &secrets, "test-shadow");
2526 assert!(s.contains("global-env-answer"), "process env answered: {s}");
2527 assert_eq!(
2528 report,
2529 vec![EnvVarResolution {
2530 name: var.clone(),
2531 source: SecretSource::ProcessEnv
2532 }]
2533 );
2534
2535 secrets.insert(var.clone(), "per-user-answer".to_string());
2538 let mut s2 = format!("x = \"${{{var}}}\"");
2539 let report2 = substitute_env_vars(&mut s2, &secrets, "test-wins");
2540 assert!(s2.contains("per-user-answer"), "user secrets win: {s2}");
2541 assert_eq!(
2542 report2,
2543 vec![EnvVarResolution {
2544 name: var.clone(),
2545 source: SecretSource::SecretsMap
2546 }]
2547 );
2548
2549 std::env::remove_var(&var);
2550}
2551
2552#[test]
2553fn materialized_user_missing_env_only_fires_on_dir_without_env() {
2554 let base = std::env::temp_dir().join(format!("trustee-envcheck-{}", std::process::id()));
2556 let _ = std::fs::remove_dir_all(&base);
2557
2558 assert!(!materialized_user_missing_env(&base.join("ghost")));
2560
2561 std::fs::create_dir_all(&base).unwrap();
2563 assert!(materialized_user_missing_env(&base));
2564
2565 std::fs::write(base.join(".env"), "K=V\n").unwrap();
2567 assert!(!materialized_user_missing_env(&base));
2568
2569 let _ = std::fs::remove_dir_all(&base);
2570}
2571
2572#[test]
2573fn loader_freshness_decides_all_four_states() {
2574 let now = chrono::Utc::now();
2575 let fp = 0xABCD_u64;
2576 let young = now - chrono::Duration::seconds(60);
2577 let old = now - chrono::Duration::seconds(601);
2578
2579 assert_eq!(
2581 loader_freshness(fp, young, None, None, fp, now),
2582 LoaderFreshness::Fresh
2583 );
2584 assert_eq!(
2586 loader_freshness(fp, old, None, None, 0x1234, now),
2587 LoaderFreshness::ConfigChanged
2588 );
2589 assert_eq!(
2591 loader_freshness(fp, old, None, None, fp, now),
2592 LoaderFreshness::AgedOut
2593 );
2594 let failed = now - chrono::Duration::seconds(10);
2596 match loader_freshness(fp, old, Some("boom"), Some(failed), fp, now) {
2597 LoaderFreshness::Backoff(remaining) => {
2598 assert!(remaining.as_secs() >= 15, "~20s left: {remaining:?}");
2599 }
2600 other => panic!("expected Backoff, got {other:?}"),
2601 }
2602 let failed_long = now - chrono::Duration::seconds(120);
2604 assert_eq!(
2605 loader_freshness(fp, old, Some("boom"), Some(failed_long), fp, now),
2606 LoaderFreshness::AgedOut
2607 );
2608}