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)>>,
370}
371
372impl ServerState {
373 pub fn new(
375 session: Session,
376 ws_tx: broadcast::Sender<String>,
377 auth: Option<Arc<AuthState>>,
378 ) -> Self {
379 apply_home_links();
382
383 let sessions = Arc::new(DashMap::new());
384
385 let token_store = Arc::new(pep::MemoryTokenStore::new());
387 let (ws_tx_entry, _) = broadcast::channel::<String>(256);
388
389 let now = chrono::Utc::now();
390 let initial_entry = UserSessionEntry {
391 session: Arc::new(Mutex::new(session)),
392 ws_tx: ws_tx_entry,
393 created_at: now,
394 last_active: Arc::new(Mutex::new(now)),
395 };
396
397 let user_sessions = UserSessions {
398 sessions: DashMap::new(),
399 token_store,
400 };
401 user_sessions
402 .sessions
403 .insert("default".to_string(), initial_entry);
404
405 sessions.insert("default".to_string(), user_sessions);
406
407 Self {
408 sessions,
409 ws_tx,
410 auth,
411 config_toml: None,
412 secrets: None,
413 build_info: None,
414 workflow_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
415 max_sessions_per_user: 4,
416 allow_llm_overlay: false,
417 mcp_loaders: Arc::new(DashMap::new()),
418 mcp_build_locks: Arc::new(DashMap::new()),
419 thq_dispatch: Arc::new(DashMap::new()),
420 agent_dispatch_tokens: Arc::new(DashMap::new()),
421 }
422 }
423
424 pub fn with_config_toml(mut self, config_toml: String) -> Self {
425 self.config_toml = Some(config_toml);
426 self
427 }
428
429 pub fn service_issuers(&self) -> Vec<String> {
440 service_issuers_from_config(self.config_toml.as_deref().unwrap_or(""))
441 }
442
443 pub fn with_secrets(mut self, secrets: std::collections::HashMap<String, String>) -> Self {
444 self.secrets = Some(secrets);
445 self
446 }
447
448 pub fn with_build_info(mut self, build_info: trustee_core::types::BuildInfo) -> Self {
449 self.build_info = Some(build_info);
450 self
451 }
452
453 pub fn with_max_concurrent_workflows(mut self, max: usize) -> Self {
454 self.workflow_semaphore = Arc::new(tokio::sync::Semaphore::new(max));
455 self
456 }
457
458 pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
460 self.max_sessions_per_user = max;
461 self
462 }
463
464 pub fn with_allow_llm_overlay(mut self, allow: bool) -> Self {
467 self.allow_llm_overlay = allow;
468 self
469 }
470
471 pub async fn create_session(
494 &self,
495 user_key: &str,
496 session_name: Option<String>,
497 identity: Option<String>,
498 ) -> Result<String, SessionError> {
499 let user_sessions = self
501 .sessions
502 .entry(user_key.to_string())
503 .or_insert_with(|| UserSessions {
504 sessions: DashMap::new(),
505 token_store: Arc::new(pep::MemoryTokenStore::new()),
506 });
507
508 if user_sessions.sessions.len() >= self.max_sessions_per_user {
510 return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
511 }
512
513 let (mut session, workflow_rx) = Session::new();
515
516 if let Some(ref config_toml) = self.config_toml {
518 session.config_toml = Some(config_toml.clone());
519 session.parse_auto_handoff_config();
520 if let Ok(table) = config_toml.parse::<toml::Value>() {
521 if let Some(name) = table
522 .get("agent")
523 .and_then(|a| a.get("name"))
524 .and_then(|n| n.as_str())
525 {
526 session.agent_name = name.to_string();
527 }
528 }
529 }
530
531 session.secrets = self.secrets.clone();
532 session.build_info = self.build_info.clone();
533
534 self.apply_user_isolation(&mut session, user_key);
536
537 session.session_name = session_name;
539
540 session.identity = identity;
542
543 let (ws_tx_entry, _) = broadcast::channel::<String>(256);
545
546 let session_id = format!(
548 "session_{}_{}",
549 chrono::Utc::now().format("%Y_%m_%d_%H_%M"),
550 &uuid::Uuid::new_v4().to_string()[..8]
551 );
552
553 session.session_id = Some(session_id.clone());
559
560 let now = chrono::Utc::now();
561
562 user_sessions.sessions.insert(
564 session_id.clone(),
565 UserSessionEntry {
566 session: Arc::new(Mutex::new(session)),
567 ws_tx: ws_tx_entry.clone(),
568 created_at: now,
569 last_active: Arc::new(Mutex::new(now)),
570 },
571 );
572
573 let session_arc = user_sessions
575 .sessions
576 .get(&session_id)
577 .map(|e| e.session.clone());
578 if let Some(session_arc) = session_arc {
579 self.spawn_user_drain_task(session_id.clone(), session_arc, ws_tx_entry, workflow_rx);
580 }
581
582 Ok(session_id)
583 }
584
585 pub async fn get_session(
588 &self,
589 user_key: &str,
590 session_id: &str,
591 ) -> Option<(Arc<Mutex<Session>>, broadcast::Sender<String>)> {
592 let user_sessions = self.sessions.get(user_key)?;
593 let entry = user_sessions.sessions.get(session_id)?;
594
595 let now = chrono::Utc::now();
597 *entry.last_active.lock().await = now;
598
599 Some((entry.session.clone(), entry.ws_tx.clone()))
600 }
601
602 pub async fn get_session_by_any_id(
620 &self,
621 user_key: &str,
622 id: &str,
623 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
624 let user_sessions = self.sessions.get(user_key)?;
626 if let Some(entry) = user_sessions.sessions.get(id) {
627 let now = chrono::Utc::now();
629 *entry.last_active.lock().await = now;
630 return Some((id.to_string(), entry.session.clone(), entry.ws_tx.clone()));
631 }
632
633 for entry in user_sessions.sessions.iter() {
635 let session = entry.session.lock().await;
636 if session.session_id.as_deref() == Some(id) {
637 let key = entry.key().clone();
638 let ws_tx = entry.ws_tx.clone();
639 drop(session);
640 let now = chrono::Utc::now();
642 *entry.last_active.lock().await = now;
643 return Some((key, entry.session.clone(), ws_tx));
644 }
645 }
646
647 None
648 }
649
650 fn tombstone_path(&self, user_key: &str, chain_id: &str) -> Option<std::path::PathBuf> {
659 if chain_id.is_empty() || !chain_id.starts_with("session_") {
660 return None;
661 }
662 let user_key = effective_user_key(user_key);
663 let hash = trustee_core::user_hash(&user_key);
664 dirs::home_dir().map(|home| {
665 home.join(".trustee")
666 .join("users")
667 .join(&hash)
668 .join("session_tombstones")
669 .join(chain_id)
670 })
671 }
672
673 fn write_tombstone(&self, user_key: &str, chain_id: &str) {
678 if let Some(path) = self.tombstone_path(user_key, chain_id) {
679 if let Some(parent) = path.parent() {
680 let _ = std::fs::create_dir_all(parent);
681 }
682 let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
683 }
684 }
685
686 pub fn chain_tombstone_stamp(&self, user_key: &str, chain_id: &str) -> Option<String> {
694 let path = self.tombstone_path(user_key, chain_id)?;
695 let text = std::fs::read_to_string(&path).ok()?;
696 let trimmed = text.trim();
697 if trimmed.is_empty() {
698 None
699 } else {
700 Some(trimmed.to_string())
701 }
702 }
703
704 pub async fn get_or_restore_session_by_any_id(
718 &self,
719 user_key: &str,
720 id: &str,
721 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
722 if let Some(found) = self.get_session_by_any_id(user_key, id).await {
723 return Some(found);
724 }
725 self.restore_session_from_checkpoint(user_key, id).await
726 }
727
728 async fn restore_session_from_checkpoint(
737 &self,
738 user_key: &str,
739 chain_id: &str,
740 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
741 if let Some(path) = self.tombstone_path(user_key, chain_id) {
743 if path.exists() {
744 return None;
745 }
746 }
747
748 let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
750 let config_toml = config_toml?;
751
752 let info = match trustee_core::sessions::create_resume_info(
763 &config_toml,
764 chain_id,
765 home_dir.as_deref(),
766 )
767 .await
768 .ok()
769 .flatten()
770 {
771 Some(info) => info,
772 None => {
773 let source = trustee_core::sessions::resolve_resume_handle(
774 &config_toml,
775 chain_id,
776 home_dir.as_deref(),
777 )
778 .await
779 .ok()
780 .flatten()?;
781 trustee_core::sessions::create_resume_info(
782 &config_toml,
783 &source,
784 home_dir.as_deref(),
785 )
786 .await
787 .ok()
788 .flatten()?
789 }
790 };
791
792 let user_sessions = self
795 .sessions
796 .entry(user_key.to_string())
797 .or_insert_with(|| UserSessions {
798 sessions: DashMap::new(),
799 token_store: Arc::new(pep::MemoryTokenStore::new()),
800 });
801
802 if let Some(existing) = user_sessions.sessions.get(chain_id) {
803 let now = chrono::Utc::now();
804 *existing.last_active.lock().await = now;
805 return Some((
806 chain_id.to_string(),
807 existing.session.clone(),
808 existing.ws_tx.clone(),
809 ));
810 }
811
812 if user_sessions.sessions.len() >= self.max_sessions_per_user {
817 let mut evict: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
818 for entry in user_sessions.sessions.iter() {
819 let is_idle = entry.session.lock().await.workflow_state
820 == trustee_core::types::WorkflowState::Idle;
821 if !is_idle {
822 continue;
823 }
824 let la = entry.last_active.lock().await;
825 if evict.as_ref().is_none_or(|(_, t)| *la < *t) {
826 evict = Some((entry.key().clone(), *la));
827 }
828 }
829 if let Some((victim, _)) = evict {
830 user_sessions.sessions.remove(&victim);
831 } else {
832 return None;
833 }
834 }
835
836 let (mut session, workflow_rx) = Session::new();
839 session.config_toml = Some(config_toml.clone());
840 session.parse_auto_handoff_config();
841 if let Ok(table) = config_toml.parse::<toml::Value>() {
842 if let Some(name) = table
843 .get("agent")
844 .and_then(|a| a.get("name"))
845 .and_then(|n| n.as_str())
846 {
847 session.agent_name = name.to_string();
848 }
849 }
850 session.secrets = self.secrets.clone();
851 session.build_info = self.build_info.clone();
852 self.apply_user_isolation(&mut session, user_key);
853 session.session_id = Some(chain_id.to_string());
854 session.resume_info = Some(info);
855 session.session_name = None; let (ws_tx_entry, _) = broadcast::channel::<String>(256);
858 let now = chrono::Utc::now();
859
860 user_sessions.sessions.insert(
861 chain_id.to_string(),
862 UserSessionEntry {
863 session: Arc::new(Mutex::new(session)),
864 ws_tx: ws_tx_entry.clone(),
865 created_at: now,
866 last_active: Arc::new(Mutex::new(now)),
867 },
868 );
869
870 let session_arc = user_sessions
874 .sessions
875 .get(chain_id)
876 .map(|e| e.session.clone())
877 .ok_or_else(|| SessionError::NotFound(chain_id.to_string()))
878 .ok()?;
879 self.spawn_user_drain_task(
880 chain_id.to_string(),
881 session_arc.clone(),
882 ws_tx_entry.clone(),
883 workflow_rx,
884 );
885
886 Some((chain_id.to_string(), session_arc, ws_tx_entry))
887 }
888
889 pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> {
891 let Some(user_sessions) = self.sessions.get(user_key) else {
892 return Vec::new();
893 };
894
895 let mut items = Vec::new();
896 for entry in user_sessions.sessions.iter() {
897 let session = entry.session.lock().await;
898 let workflow_state = match session.workflow_state {
899 trustee_core::types::WorkflowState::Idle => "Idle",
900 trustee_core::types::WorkflowState::Running => "Running",
901 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
902 };
903 let last_active = entry.last_active.lock().await;
904 items.push(SessionListItem {
905 session_id: entry.key().clone(),
906 session_name: session.session_name.clone(),
907 workflow_state: workflow_state.to_string(),
908 created_at: entry.created_at.to_rfc3339(),
909 last_active: last_active.to_rfc3339(),
910 handoff_count: session.handoff_count,
911 });
912 }
913 drop(user_sessions);
914
915 items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
917 items
918 }
919
920 pub async fn destroy_session(
922 &self,
923 user_key: &str,
924 session_id: &str,
925 ) -> Result<(), SessionError> {
926 let user_sessions = self
927 .sessions
928 .get(user_key)
929 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
930
931 let chain_id_for_tombstone = {
934 let entry = user_sessions
935 .sessions
936 .get(session_id)
937 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
938 let session = entry.session.lock().await;
939 if session.workflow_state != trustee_core::types::WorkflowState::Idle {
940 let state_str = match session.workflow_state {
941 trustee_core::types::WorkflowState::Running => "Running",
942 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
943 _ => "Unknown",
944 };
945 return Err(SessionError::NotIdle(state_str.to_string()));
946 }
947 session.session_id.clone()
948 };
949
950 user_sessions.sessions.remove(session_id);
952
953 if let Some(chain_id) = chain_id_for_tombstone {
957 self.write_tombstone(user_key, &chain_id);
958 }
959
960 Ok(())
961 }
962
963 pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
974 if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
985 static GATE_WARNED: std::sync::Once = std::sync::Once::new();
986 GATE_WARNED.call_once(|| {
987 tracing::warn!(
988 "TRUSTEE_CLASSIC_HOME=1 — ALL users share the classic home \
989 (~/.trustee); per-user checkpoint isolation is OFF. \
990 Single-user loopback ONLY — unset this env on multi-user servers."
991 );
992 });
993 return dirs::home_dir().map(|home| home.join(".trustee"));
994 }
995 let user_key = effective_user_key(user_key);
996 let hash = trustee_core::user_hash(&user_key);
997 dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&hash))
998 }
999
1000 pub fn get_user_config_and_home(
1005 &self,
1006 user_key: &str,
1007 ) -> (Option<String>, Option<std::path::PathBuf>) {
1008 (self.config_toml.clone(), self.get_user_home_dir(user_key))
1009 }
1010
1011 fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
1020 if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
1023 if let Some(home) = dirs::home_dir() {
1024 session.home_dir = Some(home.join(".trustee"));
1025 }
1026 return;
1027 }
1028 let user_key = effective_user_key(user_key);
1029 let user_hash = trustee_core::user_hash(&user_key);
1030
1031 let user_home = if let Some(home) = dirs::home_dir() {
1033 let user_home = home.join(".trustee").join("users").join(&user_hash);
1034 session.home_dir = Some(user_home.clone());
1035 Some(user_home)
1036 } else {
1037 None
1038 };
1039
1040 if let Some(ref user_home) = user_home {
1044 if materialized_user_missing_env(user_home) {
1045 tracing::warn!(
1046 "user namespace {} is materialized but has NO per-user .env — \
1047 ${{…}} references will fall through to process env \
1048 (global ~/.trustee/.env) and execute under the GLOBAL principal",
1049 &user_hash[..8.min(user_hash.len())]
1050 );
1051 }
1052 }
1053
1054 session.project_id = Some(format!("web{}", &user_hash[..16]));
1055
1056 let shared_secrets = session.secrets.clone().unwrap_or_default();
1063 let mut merged_secrets = shared_secrets.clone();
1064
1065 if let Some(ref user_home) = user_home {
1066 if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
1067 merged_secrets = merged;
1068 }
1069 }
1070
1071 if let Some(ref user_home) = user_home {
1073 if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref())
1074 {
1075 session.config_toml = Some(merged);
1076 tracing::debug!("Merged per-user config into session");
1077 }
1078 }
1079
1080 if let Some(ref mut config_toml) = session.config_toml {
1082 substitute_env_vars(
1083 config_toml,
1084 &merged_secrets,
1085 &format!("user {}", &user_hash[..8.min(user_hash.len())]),
1086 );
1087 }
1088
1089 session.secrets = Some(shared_secrets);
1091 }
1092
1093 fn load_user_secrets(
1096 &self,
1097 user_home: &std::path::Path,
1098 base: &std::collections::HashMap<String, String>,
1099 ) -> std::io::Result<std::collections::HashMap<String, String>> {
1100 let user_env_path = user_home.join(".env");
1101 if !user_env_path.exists() {
1102 return Ok(base.clone());
1103 }
1104 let content = std::fs::read_to_string(&user_env_path)?;
1105 let mut merged = base.clone();
1106 for line in content.lines() {
1107 let line = line.trim();
1108 if line.is_empty() || line.starts_with('#') {
1109 continue;
1110 }
1111 if let Some((key, value)) = line.split_once('=') {
1112 let key = key.trim().to_string();
1113 let value = value
1114 .trim()
1115 .trim_matches('"')
1116 .trim_matches('\'')
1117 .to_string();
1118 merged.insert(key, value);
1119 }
1120 }
1121 tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
1122 Ok(merged)
1123 }
1124
1125 fn merge_user_config(
1140 &self,
1141 user_home: &std::path::Path,
1142 shared_config: Option<&str>,
1143 ) -> Option<String> {
1144 let user_config_path = user_home.join("config").join("trustee.toml");
1145 if !user_config_path.exists() {
1146 return None;
1147 }
1148 let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
1149 let shared = shared_config.unwrap_or("").parse::<toml::Value>().ok()?;
1150 let overlay = user_config_toml.parse::<toml::Value>().ok()?;
1151
1152 let allowed = |section: &str| {
1153 section == "mcp"
1154 || section == "thq"
1158 || section == "lifecycle"
1163 || (self.allow_llm_overlay && section == "llm")
1164 };
1165 let dir_name = user_home
1168 .file_name()
1169 .and_then(|n| n.to_str())
1170 .unwrap_or("<unknown>");
1171 let masked_user = dir_name.get(..8).unwrap_or(dir_name);
1172
1173 let mut filtered_overlay = toml::map::Map::new();
1174 if let Some(table) = overlay.as_table() {
1175 for (section, value) in table {
1176 if section == "lifecycle" {
1177 let mut v = value.clone();
1178 if let Some(t) = v.as_table_mut() {
1179 if t.remove("enabled").is_some() {
1180 tracing::info!(
1181 "user config overlay: [lifecycle].enabled is not user-settable — stripped for user {}",
1182 masked_user
1183 );
1184 }
1185 }
1186 filtered_overlay.insert(section.clone(), v);
1187 } else if allowed(section) {
1188 filtered_overlay.insert(section.clone(), value.clone());
1189 } else {
1190 tracing::warn!(
1191 "user config overlay: dropping non-allowlisted section [{}] for user {}",
1192 section,
1193 masked_user
1194 );
1195 }
1196 }
1197 }
1198
1199 if filtered_overlay.is_empty() {
1200 return None;
1202 }
1203 let overlay = toml::Value::Table(filtered_overlay);
1204
1205 let mut shared = shared;
1206 deep_merge_toml(&mut shared, &overlay);
1207 let merged = toml::to_string(&shared).ok()?;
1208 tracing::debug!("Merged per-user config from {}", user_config_path.display());
1209 Some(merged)
1210 }
1211
1212 pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1221 let config_toml = self.config_toml.clone()?;
1222
1223 let user_home = self.get_user_home_dir(user_key)?;
1225
1226 let mut merged_secrets = self.secrets.clone().unwrap_or_default();
1228 if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
1229 merged_secrets = merged;
1230 }
1231
1232 let mut resolved = config_toml;
1234 if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
1235 resolved = merged;
1236 }
1237
1238 substitute_env_vars(
1240 &mut resolved,
1241 &merged_secrets,
1242 &format!(
1243 "resolve_user_config user {}",
1244 &trustee_core::user_hash(user_key)[..8]
1245 ),
1246 );
1247
1248 Some(resolved)
1249 }
1250
1251 pub async fn get_or_build_mcp_loader(
1267 &self,
1268 user_key: &str,
1269 token_store: &Arc<pep::MemoryTokenStore>,
1270 ) -> Result<Option<std::sync::Arc<abk::agent::McpToolLoader>>, String> {
1271 let user_key = effective_user_key(user_key);
1272 let user_hash = trustee_core::user_hash(&user_key);
1273
1274 let resolved = self.resolve_user_config(&user_key);
1276 let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1277
1278 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1280 match loader_freshness(
1281 entry.fingerprint,
1282 entry.built_at,
1283 entry.degraded.as_deref(),
1284 entry.failed_at,
1285 fingerprint,
1286 chrono::Utc::now(),
1287 ) {
1288 LoaderFreshness::Fresh => return Ok(entry.loader.clone()),
1289 LoaderFreshness::ConfigChanged => {
1290 }
1292 LoaderFreshness::AgedOut => {
1293 tracing::info!(
1296 "MCP toolset for user {} is {}s old (max {}s) — rebuilding \
1297 to re-list the served surface (phantom-tool guard)",
1298 &user_hash[..8.min(user_hash.len())],
1299 (chrono::Utc::now() - entry.built_at).num_seconds(),
1300 MCP_TOOLSET_MAX_AGE.as_secs()
1301 );
1302 }
1303 LoaderFreshness::Backoff(remaining) => {
1304 let err = entry.degraded.clone().unwrap_or_else(|| {
1305 "previous MCP loader build failed; retry after backoff".to_string()
1306 });
1307 let _ = remaining;
1308 return Err(err);
1309 }
1310 }
1311 }
1312
1313 let lock = self
1315 .mcp_build_locks
1316 .entry(user_hash.clone())
1317 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1318 .clone();
1319 let _guard = lock.lock().await;
1320
1321 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1323 if loader_freshness(
1324 entry.fingerprint,
1325 entry.built_at,
1326 entry.degraded.as_deref(),
1327 entry.failed_at,
1328 fingerprint,
1329 chrono::Utc::now(),
1330 ) == LoaderFreshness::Fresh
1331 {
1332 return Ok(entry.loader.clone());
1333 }
1334 }
1335
1336 match self
1337 .build_mcp_loader(&user_hash, resolved.as_deref(), fingerprint, token_store)
1338 .await
1339 {
1340 Ok(entry) => {
1341 self.mcp_loaders.insert(user_hash.clone(), entry);
1342 Ok(self.mcp_loaders.get(&user_hash).unwrap().loader.clone())
1343 }
1344 Err(err) => {
1345 tracing::warn!(
1346 "MCP loader build FAILED for user {}; dispatch fails loud, retry after {:?}",
1347 &user_hash[..8.min(user_hash.len())],
1348 MCP_BUILD_RETRY_BACKOFF
1349 );
1350 self.mcp_loaders.insert(
1351 user_hash,
1352 McpLoaderEntry {
1353 loader: None,
1354 fingerprint,
1355 built_at: chrono::Utc::now(),
1356 degraded: Some(err.clone()),
1357 failed_at: Some(chrono::Utc::now()),
1358 },
1359 );
1360 Err(err)
1361 }
1362 }
1363 }
1364
1365 async fn build_mcp_loader(
1368 &self,
1369 user_hash: &str,
1370 resolved: Option<&str>,
1371 fingerprint: u64,
1372 token_store: &Arc<pep::MemoryTokenStore>,
1373 ) -> Result<McpLoaderEntry, String> {
1374 let mcp_config: Option<abk::config::McpConfig> = match resolved {
1375 Some(toml_str) => {
1376 let value = toml_str
1377 .parse::<toml::Value>()
1378 .map_err(|e| format!("config parse failed: {}", e))?;
1379 match value.get("mcp") {
1380 Some(section) => {
1381 let mut leftovers: Vec<String> = Vec::new();
1388 collect_unresolved_placeholders(section, &mut leftovers);
1389 if !leftovers.is_empty() {
1390 return Err(format!(
1391 "unresolved {} placeholder(s) in [mcp]: {} — supply the \
1392 exact-case key in the profile secrets",
1393 leftovers.len(),
1394 leftovers.join(", ")
1395 ));
1396 }
1397 use serde::Deserialize as _;
1398 Some(
1399 abk::config::McpConfig::deserialize(section.clone())
1400 .map_err(|e| format!("invalid [mcp] config: {}", e))?,
1401 )
1402 }
1403 None => None,
1404 }
1405 }
1406 None => None,
1407 };
1408
1409 let loader = match mcp_config {
1410 Some(cfg) if cfg.enabled => {
1411 let built = abk::agent::McpToolLoader::with_token_store(
1412 &cfg,
1413 Some(token_store.clone() as std::sync::Arc<dyn pep::token_store::TokenStore>),
1414 )
1415 .await
1416 .map_err(|e| format!("MCP loader build failed: {}", e))?;
1417
1418 let servers: Vec<String> = built
1422 .server_statuses
1423 .iter()
1424 .map(|s| {
1425 if s.connected {
1426 format!("{}(up,{}tools)", s.name, s.tool_count)
1427 } else {
1428 format!("{}(DOWN)", s.name)
1429 }
1430 })
1431 .collect();
1432 tracing::info!(
1433 "MCP loader built for user {}: servers=[{}] total_tools={}",
1434 &user_hash[..8.min(user_hash.len())],
1435 servers.join(", "),
1436 built.tool_count
1437 );
1438 Some(std::sync::Arc::new(built))
1439 }
1440 _ => None,
1441 };
1442
1443 Ok(McpLoaderEntry {
1444 loader,
1445 fingerprint,
1446 built_at: chrono::Utc::now(),
1447 degraded: None,
1448 failed_at: None,
1449 })
1450 }
1451
1452 fn spawn_user_drain_task(
1454 &self,
1455 session_id: String,
1456 session: Arc<Mutex<Session>>,
1457 ws_tx: broadcast::Sender<String>,
1458 mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
1459 ) {
1460 let mut last_broadcast_state: Option<String> = None;
1466 tokio::spawn(async move {
1467 while let Some(msg) = workflow_rx.recv().await {
1468 {
1469 let mut session = session.lock().await;
1470 session.handle_workflow_message(msg.clone());
1471
1472 let state_str = match session.workflow_state {
1473 trustee_core::types::WorkflowState::Idle => "Idle",
1474 trustee_core::types::WorkflowState::Running => "Running",
1475 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1476 };
1477 if last_broadcast_state.as_deref() != Some(state_str) {
1478 last_broadcast_state = Some(state_str.to_string());
1479 let state_msg = serde_json::json!({
1480 "type": "StateChanged",
1481 "state": state_str
1482 });
1483 let _ = ws_tx.send(state_msg.to_string());
1484 }
1485 }
1486
1487 let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1488 let _ = ws_tx.send(json);
1489 }
1490 tracing::debug!("Drain task ended for session: {}", session_id);
1491 });
1492 }
1493
1494 pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1497 let default_user = self
1499 .sessions
1500 .get("default")
1501 .expect("default user must exist");
1502 let first_entry = default_user
1503 .sessions
1504 .iter()
1505 .next()
1506 .expect("default user must have at least one session");
1507 let session = first_entry.session.clone();
1508 let ws_tx = first_entry.ws_tx.clone();
1509 let session_id = first_entry.key().clone();
1510 drop(first_entry);
1511 drop(default_user);
1512
1513 let mut last_broadcast_state = Some("Running".to_string());
1517 tokio::spawn(async move {
1518 while let Some(msg) = workflow_rx.recv().await {
1519 {
1520 let mut session = session.lock().await;
1521 session.handle_workflow_message(msg.clone());
1522
1523 let state_str = match session.workflow_state {
1524 trustee_core::types::WorkflowState::Idle => "Idle",
1525 trustee_core::types::WorkflowState::Running => "Running",
1526 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1527 };
1528 if last_broadcast_state.as_deref() != Some(state_str) {
1529 last_broadcast_state = Some(state_str.to_string());
1530 let state_msg = serde_json::json!({
1531 "type": "StateChanged",
1532 "state": state_str
1533 });
1534 let _ = ws_tx.send(state_msg.to_string());
1535 }
1536 }
1537
1538 let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1539 let _ = ws_tx.send(json);
1540 }
1541 tracing::debug!("Drain task ended for session: {}", session_id);
1542 });
1543 }
1544
1545 pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
1547 let Some(ref auth) = self.auth else {
1548 return "default".to_string();
1549 };
1550
1551 if let Some(token) = headers
1553 .get(axum::http::header::AUTHORIZATION)
1554 .and_then(|v| v.to_str().ok())
1555 .and_then(|v| v.strip_prefix("Bearer "))
1556 .map(|s| s.to_string())
1557 {
1558 if token.starts_with("dev:") {
1559 let parts: Vec<&str> = token.splitn(4, ':').collect();
1560 if parts.len() >= 4 {
1561 return format!("dev:{}", parts[1]);
1562 }
1563 }
1564 if let Ok(claims) = auth.validate_token(&token).await {
1565 return claims.sub;
1566 }
1567 }
1568
1569 let cookie_session_id = headers
1571 .get(axum::http::header::COOKIE)
1572 .and_then(|v| v.to_str().ok())
1573 .and_then(|cookies| {
1574 cookies.split(';').map(|c| c.trim()).find_map(|c| {
1575 c.strip_prefix(&format!("{}=", auth.config.cookie_name))
1576 .map(|s| s.to_string())
1577 })
1578 });
1579
1580 if let Some(session_id) = cookie_session_id {
1581 if session_id.starts_with("dev:") {
1582 let parts: Vec<&str> = session_id.splitn(4, ':').collect();
1583 if parts.len() >= 4 {
1584 return format!("dev:{}", parts[1]);
1585 }
1586 }
1587
1588 if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
1589 if let Ok(claims) = auth.validate_token(&access_token).await {
1590 return claims.sub;
1591 }
1592 }
1593 }
1594
1595 "default".to_string()
1596 }
1597}
1598
1599struct SerializableMessage<'a>(&'a TuiMessage);
1605
1606impl<'a> serde::Serialize for SerializableMessage<'a> {
1607 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1608 where
1609 S: serde::Serializer,
1610 {
1611 use serde::ser::SerializeStruct;
1612
1613 match self.0 {
1614 TuiMessage::OutputLine(line) => {
1615 let mut s = serializer.serialize_struct("msg", 2)?;
1616 s.serialize_field("type", "OutputLine")?;
1617 s.serialize_field("line", line)?;
1618 s.end()
1619 }
1620 TuiMessage::StreamDelta(delta) => {
1621 let mut s = serializer.serialize_struct("msg", 2)?;
1622 s.serialize_field("type", "StreamDelta")?;
1623 s.serialize_field("delta", delta)?;
1624 s.end()
1625 }
1626 TuiMessage::ReasoningDelta(delta) => {
1627 let mut s = serializer.serialize_struct("msg", 2)?;
1628 s.serialize_field("type", "ReasoningDelta")?;
1629 s.serialize_field("delta", delta)?;
1630 s.end()
1631 }
1632 TuiMessage::WorkflowCompleted => {
1633 let mut s = serializer.serialize_struct("msg", 2)?;
1634 s.serialize_field("type", "WorkflowCompleted")?;
1635 s.serialize_field("state", "Idle")?;
1636 s.end()
1637 }
1638 TuiMessage::WorkflowError(err) => {
1639 let mut s = serializer.serialize_struct("msg", 2)?;
1640 s.serialize_field("type", "WorkflowError")?;
1641 s.serialize_field("error", err)?;
1642 s.end()
1643 }
1644 TuiMessage::ResumeInfo(info) => match info {
1645 Some(ri) => {
1646 let mut s = serializer.serialize_struct("msg", 5)?;
1647 s.serialize_field("type", "ResumeInfo")?;
1648 s.serialize_field("state", "Idle")?;
1649 s.serialize_field("session_id", &ri.session_id)?;
1650 s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
1651 s.serialize_field("iteration", &ri.iteration)?;
1652 s.end()
1653 }
1654 None => {
1655 let mut s = serializer.serialize_struct("msg", 2)?;
1656 s.serialize_field("type", "ResumeInfo")?;
1657 s.serialize_field("state", "Idle")?;
1658 s.end()
1659 }
1660 },
1661 TuiMessage::TodoUpdate(content) => {
1662 let mut s = serializer.serialize_struct("msg", 2)?;
1663 s.serialize_field("type", "TodoUpdate")?;
1664 s.serialize_field("content", content)?;
1665 s.end()
1666 }
1667 TuiMessage::WorkflowCancelled => {
1668 let mut s = serializer.serialize_struct("msg", 2)?;
1669 s.serialize_field("type", "WorkflowCancelled")?;
1670 s.serialize_field("state", "Idle")?;
1671 s.end()
1672 }
1673 TuiMessage::HandoffReady(briefing) => {
1674 let mut s = serializer.serialize_struct("msg", 3)?;
1675 s.serialize_field("type", "HandoffReady")?;
1676 s.serialize_field("state", "Idle")?;
1677 s.serialize_field("briefing", briefing)?;
1678 s.end()
1679 }
1680 TuiMessage::SessionRotated { old, new } => {
1681 let mut s = serializer.serialize_struct("msg", 3)?;
1682 s.serialize_field("type", "SessionRotated")?;
1683 s.serialize_field("old", old)?;
1684 s.serialize_field("new", new)?;
1685 s.end()
1686 }
1687 TuiMessage::HandoffFailed => {
1688 let mut s = serializer.serialize_struct("msg", 2)?;
1689 s.serialize_field("type", "HandoffFailed")?;
1690 s.serialize_field("state", "Idle")?;
1691 s.end()
1692 }
1693 TuiMessage::ToolPending { tool_name, hint } => {
1694 let mut s = serializer.serialize_struct("msg", 3)?;
1695 s.serialize_field("type", "ToolPending")?;
1696 s.serialize_field("tool_name", tool_name)?;
1697 s.serialize_field("hint", hint)?;
1698 s.end()
1699 }
1700 TuiMessage::ToolDone {
1701 tool_name,
1702 success,
1703 hint,
1704 } => {
1705 let mut s = serializer.serialize_struct("msg", 4)?;
1706 s.serialize_field("type", "ToolDone")?;
1707 s.serialize_field("tool_name", tool_name)?;
1708 s.serialize_field("success", success)?;
1709 s.serialize_field("hint", hint)?;
1710 s.end()
1711 }
1712 TuiMessage::ContextTokensUpdated(count) => {
1713 let mut s = serializer.serialize_struct("msg", 2)?;
1714 s.serialize_field("type", "ContextTokensUpdated")?;
1715 s.serialize_field("count", count)?;
1716 s.end()
1717 }
1718 TuiMessage::McpServerStatus {
1719 name,
1720 connected,
1721 tool_count,
1722 error,
1723 } => {
1724 let mut s = serializer.serialize_struct("msg", 5)?;
1725 s.serialize_field("type", "McpServerStatus")?;
1726 s.serialize_field("name", name)?;
1727 s.serialize_field("connected", connected)?;
1728 s.serialize_field("tool_count", tool_count)?;
1729 s.serialize_field("error", error)?;
1730 s.end()
1731 }
1732 TuiMessage::SessionTitleUpdated(title) => {
1733 let mut s = serializer.serialize_struct("msg", 2)?;
1734 s.serialize_field("type", "SessionTitleUpdated")?;
1735 s.serialize_field("title", title)?;
1736 s.end()
1737 }
1738 }
1739 }
1740}
1741
1742fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1753 match (base, overlay) {
1754 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1755 for (key, overlay_val) in overlay_table {
1756 match base_table.get_mut(key) {
1757 Some(base_val) => {
1758 deep_merge_toml(base_val, overlay_val);
1760 }
1761 None => {
1762 base_table.insert(key.clone(), overlay_val.clone());
1764 }
1765 }
1766 }
1767 }
1768 (base, overlay) => {
1770 *base = overlay.clone();
1771 }
1772 }
1773}
1774
1775fn collect_unresolved_placeholders(value: &toml::Value, out: &mut Vec<String>) {
1782 match value {
1783 toml::Value::String(s) => {
1784 if let Some(start) = s.find("${") {
1785 if let Some(end_rel) = s[start + 2..].find('}') {
1786 let name = &s[start + 2..start + 2 + end_rel];
1787 let rendered = format!("${{{name}}}");
1788 if !out.contains(&rendered) {
1789 out.push(rendered);
1790 }
1791 }
1792 }
1793 }
1794 toml::Value::Table(t) => {
1795 for v in t.values() {
1796 collect_unresolved_placeholders(v, out);
1797 }
1798 }
1799 toml::Value::Array(a) => {
1800 for v in a {
1801 collect_unresolved_placeholders(v, out);
1802 }
1803 }
1804 _ => {}
1805 }
1806}
1807
1808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1810pub(crate) enum SecretSource {
1811 SecretsMap,
1814 ProcessEnv,
1820 Unresolved,
1822}
1823
1824#[derive(Debug, Clone, PartialEq, Eq)]
1826pub(crate) struct EnvVarResolution {
1827 pub name: String,
1828 pub source: SecretSource,
1829}
1830
1831fn substitute_env_vars(
1832 s: &mut String,
1833 secrets: &std::collections::HashMap<String, String>,
1834 ctx: &str,
1835) -> Vec<EnvVarResolution> {
1836 let mut result = String::with_capacity(s.len());
1838 let bytes = s.as_bytes();
1839 let mut i = 0;
1840 let mut report: Vec<EnvVarResolution> = Vec::new();
1843
1844 while i < bytes.len() {
1845 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1846 if let Some(end) = s[i + 2..].find('}') {
1848 let var_name = &s[i + 2..i + 2 + end];
1849 let (replacement, source) = if let Some(value) = secrets.get(var_name) {
1851 (value.clone(), SecretSource::SecretsMap)
1852 } else if let Ok(value) = std::env::var(var_name) {
1853 (value, SecretSource::ProcessEnv)
1854 } else {
1855 (s[i..i + 2 + end + 1].to_string(), SecretSource::Unresolved)
1860 };
1861 if !report.iter().any(|r| r.name == var_name) {
1862 report.push(EnvVarResolution {
1863 name: var_name.to_string(),
1864 source,
1865 });
1866 }
1867 result.push_str(&replacement);
1868 i = i + 2 + end + 1;
1869 } else {
1870 result.push('$');
1872 i += 1;
1873 }
1874 } else {
1875 result.push(bytes[i] as char);
1876 i += 1;
1877 }
1878 }
1879
1880 for r in &report {
1885 match r.source {
1886 SecretSource::ProcessEnv => tracing::error!(
1887 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from PROCESS ENV \
1888 (global ~/.trustee/.env at boot) — no per-user/profile secret \
1889 answered; calls using it execute under the GLOBAL principal. \
1890 Supply the exact-case key in the per-user .env / profile \
1891 secrets to restore attribution.",
1892 ctx,
1893 r.name
1894 ),
1895 SecretSource::Unresolved => {
1896 }
1898 SecretSource::SecretsMap => tracing::debug!(
1899 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from profile/user secrets",
1900 ctx,
1901 r.name
1902 ),
1903 }
1904 }
1905 let unresolved: Vec<&str> = report
1906 .iter()
1907 .filter(|r| r.source == SecretSource::Unresolved)
1908 .map(|r| r.name.as_str())
1909 .collect();
1910 if !unresolved.is_empty() {
1911 tracing::warn!(
1912 "ENV SUBSTITUTION LEFT {} UNRESOLVED [{}]: {} — the literal ${{…}} remains in the \
1913 effective config; supply the key in the profile secrets (exact-case KEY=VALUE) \
1914 or the environment",
1915 unresolved.len(),
1916 ctx,
1917 unresolved
1918 .iter()
1919 .map(|v| format!("${{{v}}}"))
1920 .collect::<Vec<_>>()
1921 .join(", ")
1922 );
1923 }
1924
1925 *s = result;
1926 report
1927}
1928
1929pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1934 user_home.is_dir() && !user_home.join(".env").exists()
1935}
1936
1937fn fingerprint_mcp_section(resolved: Option<&str>) -> u64 {
1947 use sha2::{Digest, Sha256};
1948 let section = resolved
1949 .and_then(|s| s.parse::<toml::Value>().ok())
1950 .and_then(|v| v.get("mcp").cloned());
1951 let bytes = match section {
1952 Some(v) => v.to_string().into_bytes(),
1953 None => b"<no-mcp>".to_vec(),
1954 };
1955 let digest = Sha256::digest(&bytes);
1956 u64::from_be_bytes(digest[..8].try_into().expect("sha256 digest >= 8 bytes"))
1957}
1958
1959#[cfg(test)]
1960mod tests {
1961 use super::*;
1962
1963 fn test_state() -> ServerState {
1965 let (session, _rx) = Session::new();
1966 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
1967 ServerState::new(session, ws_tx, None)
1968 }
1969
1970 #[tokio::test]
1971 async fn dispatch_scope_pins_user_key_resolution() {
1972 let state = test_state();
1976 let caller = "some-human-sub";
1977 let plain_home = state.get_user_home_dir(caller).unwrap();
1978 let pinned_key = "prof-1234";
1979 let pinned_home = in_dispatch_scope(pinned_key, async {
1980 state.get_user_home_dir(caller).unwrap()
1981 })
1982 .await;
1983 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
1984 assert_eq!(plain_home, users_root.join(trustee_core::user_hash(caller)));
1985 assert_eq!(
1986 pinned_home,
1987 users_root.join(trustee_core::user_hash(pinned_key)),
1988 "dispatch scope must pin resolution to the binding key"
1989 );
1990 assert_ne!(plain_home, pinned_home);
1991 }
1992
1993 fn temp_user_home(tag: &str) -> std::path::PathBuf {
1995 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1996 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1997 let dir = std::env::temp_dir().join(format!(
1998 "trustee-state-test-{}-{}-{}",
1999 tag,
2000 std::process::id(),
2001 n
2002 ));
2003 std::fs::create_dir_all(dir.join("config")).expect("create temp user home");
2004 dir
2005 }
2006
2007 fn parse(toml_str: &str) -> toml::Value {
2008 toml_str.parse::<toml::Value>().expect("valid test TOML")
2009 }
2010
2011 #[test]
2014 fn overlay_allowlist_drops_non_allowlisted_sections() {
2015 let state = test_state();
2016 let home = temp_user_home("allowlist");
2017 std::fs::write(
2018 home.join("config").join("trustee.toml"),
2019 "[server]\nport = 1\n\n[auth]\nmode = \"kanidm\"\n\n[mcp]\nmode = \"user\"\n",
2020 )
2021 .expect("write overlay");
2022
2023 let shared = "[server]\nport = 8080\n\n[mcp]\nmode = \"shared\"\n";
2024 let merged = state
2025 .merge_user_config(&home, Some(shared))
2026 .expect("overlay has allowlisted content");
2027
2028 let merged_val = parse(&merged);
2029 let expected = parse("[server]\nport = 8080\n\n[mcp]\nmode = \"user\"\n");
2030 assert_eq!(
2031 merged_val, expected,
2032 "merged config must be shared + [mcp] overlay only"
2033 );
2034 assert!(
2035 merged_val.get("auth").is_none(),
2036 "overlay [auth] must be dropped"
2037 );
2038 }
2039
2040 #[test]
2042 fn no_overlay_file_returns_none() {
2043 let state = test_state();
2044 let home = temp_user_home("empty");
2045 assert!(state
2046 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2047 .is_none());
2048 }
2049
2050 #[test]
2052 fn overlay_with_no_allowlisted_sections_is_noop() {
2053 let state = test_state();
2054 let home = temp_user_home("all-dropped");
2055 std::fs::write(
2056 home.join("config").join("trustee.toml"),
2057 "[server]\nport = 1\n\n[storage]\npath = \"/tmp/x\"\n",
2058 )
2059 .expect("write overlay");
2060 assert!(state
2061 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2062 .is_none());
2063 }
2064
2065 #[test]
2068 fn llm_overlay_dropped_by_default_and_kept_when_enabled() {
2069 let shared = "[llm]\nprovider = \"openai\"\n\n[mcp]\nmode = \"shared\"\n";
2070 let overlay = "[llm]\nprovider = \"anthropic\"\n";
2071
2072 let state = test_state();
2074 assert!(!state.allow_llm_overlay);
2075 let home = temp_user_home("llm-off");
2076 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2077 assert!(state.merge_user_config(&home, Some(shared)).is_none());
2078
2079 let state = test_state().with_allow_llm_overlay(true);
2081 assert!(state.allow_llm_overlay);
2082 let home = temp_user_home("llm-on");
2083 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2084 let merged = state
2085 .merge_user_config(&home, Some(shared))
2086 .expect("[llm] overlay applies when opted in");
2087 let merged_val = parse(&merged);
2088 assert_eq!(merged_val["llm"]["provider"].as_str(), Some("anthropic"));
2089 assert_eq!(merged_val["mcp"]["mode"].as_str(), Some("shared"));
2090 }
2091
2092 #[test]
2096 fn user_home_dir_uses_consolidated_hash() {
2097 let state = test_state();
2098 if let Some(home) = state.get_user_home_dir("farzan@example.com") {
2099 assert_eq!(
2100 home.file_name().and_then(|n| n.to_str()),
2101 Some(trustee_core::user_hash("farzan@example.com")).as_deref()
2102 );
2103 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2104 assert_eq!(home.parent(), Some(&users_root).map(|p| p.as_path()));
2105 }
2106 }
2108
2109 #[test]
2112 fn lifecycle_overlay_survives_allowlist_enabled_stripped() {
2113 let state = test_state();
2114 let home = temp_user_home("lifecycle-persona");
2115 std::fs::write(
2116 home.join("config").join("trustee.toml"),
2117 "[lifecycle]\nsystem_template = \"You are Saman.\"\nenabled = true\n",
2118 )
2119 .expect("write overlay");
2120
2121 let merged = state
2122 .merge_user_config(
2123 &home,
2124 Some("[lifecycle]\nsystem_template = \"shared default\"\n"),
2125 )
2126 .expect("lifecycle overlay is allowlisted");
2127
2128 let v = merged.parse::<toml::Value>().expect("valid TOML");
2129 assert_eq!(
2130 v["lifecycle"]["system_template"].as_str(),
2131 Some("You are Saman."),
2132 "agent persona wins over shared default"
2133 );
2134 assert_eq!(
2135 v["lifecycle"].get("enabled"),
2136 None,
2137 "WASM extension switch is not user-settable"
2138 );
2139 }
2140
2141 #[tokio::test]
2146 async fn agent_principals_get_isolated_session_buckets() {
2147 let state = test_state();
2148 let key_a = "agent-farzan";
2149 let key_b = "agent-paydar";
2150
2151 let sid_a = state
2152 .create_session(key_a, None, None)
2153 .await
2154 .expect("create session");
2155 let _sid_b = state
2156 .create_session(key_b, None, None)
2157 .await
2158 .expect("create session");
2159
2160 assert!(
2162 state.get_session_by_any_id(key_a, &sid_a).await.is_some(),
2163 "owner bucket resolves its own session"
2164 );
2165 assert!(
2166 state.get_session_by_any_id(key_b, &sid_a).await.is_none(),
2167 "cross-agent session access must be 404/None"
2168 );
2169
2170 if let Some(home) = state.get_user_home_dir(key_a) {
2172 assert_eq!(
2173 home.file_name().and_then(|n| n.to_str()),
2174 Some(trustee_core::user_hash(key_a)).as_deref()
2175 );
2176 }
2177 }
2178}
2179
2180#[cfg(test)]
2185mod mcp_loader_cache_tests {
2186 use super::*;
2187
2188 fn state_with_shared(shared: &str) -> ServerState {
2189 let (session, _rx) = Session::new();
2190 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2191 let mut state = ServerState::new(session, ws_tx, None);
2192 state.config_toml = Some(shared.to_string());
2193 state
2194 }
2195
2196 struct TempUser {
2200 key: String,
2201 home: std::path::PathBuf,
2202 }
2203
2204 impl TempUser {
2205 fn new(tag: &str) -> Self {
2206 let key = format!("16c-{tag}-{}@test.invalid", std::process::id());
2207 let home = dirs::home_dir()
2208 .expect("HOME available in test env")
2209 .join(".trustee")
2210 .join("users")
2211 .join(trustee_core::user_hash(&key));
2212 std::fs::create_dir_all(home.join("config")).expect("create user home");
2213 Self { key, home }
2214 }
2215
2216 fn write_overlay(&self, toml_str: &str) {
2217 std::fs::write(self.home.join("config").join("trustee.toml"), toml_str)
2218 .expect("write overlay");
2219 }
2220 }
2221
2222 impl Drop for TempUser {
2223 fn drop(&mut self) {
2224 let _ = std::fs::remove_dir_all(&self.home);
2225 }
2226 }
2227
2228 #[tokio::test]
2229 async fn no_mcp_config_caches_disabled_marker() {
2230 let state = state_with_shared("[server]\nport = 8080\n");
2231 let user = TempUser::new("nomcp");
2232 let ts = Arc::new(pep::MemoryTokenStore::new());
2233
2234 let first = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2235 assert!(first.is_none(), "no [mcp] anywhere → disabled marker");
2236 assert_eq!(state.mcp_loaders.len(), 1, "exactly one cache entry");
2237
2238 let second = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2240 assert!(second.is_none());
2241 assert_eq!(state.mcp_loaders.len(), 1);
2242 }
2243
2244 #[tokio::test]
2245 async fn concurrent_cold_builds_single_flight() {
2246 let state = Arc::new(state_with_shared("[server]\nport = 8080\n"));
2247 let user = TempUser::new("singleflight");
2248 let ts = Arc::new(pep::MemoryTokenStore::new());
2249
2250 let mut handles = Vec::new();
2251 for _ in 0..5 {
2252 let state = state.clone();
2253 let key = user.key.clone();
2254 let ts = ts.clone();
2255 handles.push(tokio::spawn(async move {
2256 state.get_or_build_mcp_loader(&key, &ts).await
2257 }));
2258 }
2259 for h in handles {
2260 h.await.unwrap().expect("all five succeed");
2261 }
2262 assert_eq!(state.mcp_loaders.len(), 1, "single-flight → one entry");
2263 }
2264
2265 #[tokio::test]
2266 async fn fingerprint_change_triggers_rebuild() {
2267 let state = state_with_shared("[server]\nport = 8080\n");
2268 let user = TempUser::new("fpchange");
2269 let ts = Arc::new(pep::MemoryTokenStore::new());
2270
2271 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2274 let v1 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2275 assert!(v1.is_some(), "enabled [mcp] → real loader");
2276 let fp1 = state
2277 .mcp_loaders
2278 .get(&trustee_core::user_hash(&user.key))
2279 .unwrap()
2280 .fingerprint;
2281
2282 std::thread::sleep(std::time::Duration::from_millis(5));
2284 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v2\"\nurl = \"http://127.0.0.1:9/other\"\n");
2285 let v2 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2286 assert!(v2.is_some());
2287 let entry = state
2288 .mcp_loaders
2289 .get(&trustee_core::user_hash(&user.key))
2290 .unwrap();
2291 assert_ne!(
2292 entry.fingerprint, fp1,
2293 "fingerprint must change with content"
2294 );
2295 assert!(entry.degraded.is_none());
2296
2297 let _still_usable = v1.as_ref().unwrap().tool_count;
2299 }
2300
2301 #[tokio::test]
2302 async fn degraded_entry_fails_loud_within_backoff_and_isolates_users() {
2303 let state = state_with_shared("[server]\nport = 8080\n");
2304 let bad = TempUser::new("degraded-bad");
2305 let good = TempUser::new("degraded-good");
2306 let ts = Arc::new(pep::MemoryTokenStore::new());
2307
2308 bad.write_overlay("[mcp]\nenabled = \"not-a-bool\"\n");
2310 let err = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2311 Ok(_) => panic!("invalid [mcp] must fail loud"),
2312 Err(e) => e,
2313 };
2314 assert!(
2315 err.contains("invalid [mcp]"),
2316 "surfaces the parse error: {err}"
2317 );
2318
2319 let entry = state
2320 .mcp_loaders
2321 .get(&trustee_core::user_hash(&bad.key))
2322 .unwrap();
2323 assert!(entry.degraded.is_some(), "poison entry recorded");
2324
2325 let err2 = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2327 Ok(_) => panic!("still within backoff"),
2328 Err(e) => e,
2329 };
2330 assert_eq!(err, err2, "same cached error");
2331
2332 let good_loader = state
2334 .get_or_build_mcp_loader(&good.key, &ts)
2335 .await
2336 .expect("other user unaffected");
2337 assert!(
2338 good_loader.is_none(),
2339 "good user has no [mcp] → disabled marker"
2340 );
2341 }
2342
2343 #[tokio::test]
2344 async fn aged_out_entry_rebuilds_without_restart() {
2345 let state = state_with_shared("[server]\nport = 8080\n");
2350 let user = TempUser::new("agedout");
2351 let ts = Arc::new(pep::MemoryTokenStore::new());
2352
2353 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2354 let v1 = state
2355 .get_or_build_mcp_loader(&user.key, &ts)
2356 .await
2357 .expect("initial build");
2358 let v1 = v1.expect("enabled [mcp] → loader");
2359
2360 {
2363 let mut entry = state
2364 .mcp_loaders
2365 .get_mut(&trustee_core::user_hash(&user.key))
2366 .unwrap();
2367 entry.built_at = chrono::Utc::now() - chrono::Duration::seconds(601);
2368 }
2369
2370 let v2 = state
2371 .get_or_build_mcp_loader(&user.key, &ts)
2372 .await
2373 .expect("aged-out dispatch rebuilds");
2374 let v2 = v2.expect("rebuilt loader present");
2375 assert!(
2376 !Arc::ptr_eq(&v1, &v2),
2377 "aged-out entry must NOT be reused — fresh connect re-lists the served surface"
2378 );
2379 let entry = state
2380 .mcp_loaders
2381 .get(&trustee_core::user_hash(&user.key))
2382 .unwrap();
2383 assert!(
2384 chrono::Utc::now() - entry.built_at < chrono::Duration::seconds(5),
2385 "built_at refreshed by the rebuild"
2386 );
2387
2388 let v3 = state
2390 .get_or_build_mcp_loader(&user.key, &ts)
2391 .await
2392 .expect("fresh dispatch");
2393 assert!(Arc::ptr_eq(&v2, &v3.unwrap()), "young entry hits the cache");
2394 }
2395}
2396
2397#[cfg(test)]
2398mod restore_tests {
2399 use super::*;
2400
2401 fn fresh_state() -> ServerState {
2402 let (session, _rx) = Session::new();
2403 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2404 let mut state = ServerState::new(session, ws_tx, None);
2405 state.config_toml = Some("[agent]\nname = \"trustee\"\n".to_string());
2406 state
2407 }
2408
2409 struct TempUser {
2411 key: String,
2412 }
2413
2414 impl TempUser {
2415 fn new(tag: &str) -> Self {
2416 let key = format!("restore-{tag}-{}@test.invalid", std::process::id());
2417 let home = dirs::home_dir()
2418 .expect("HOME available in test env")
2419 .join(".trustee")
2420 .join("users")
2421 .join(trustee_core::user_hash(&key));
2422 std::fs::create_dir_all(home).expect("create user home");
2423 Self { key }
2424 }
2425 }
2426
2427 #[tokio::test]
2428 async fn restore_unknown_chain_returns_none() {
2429 let state = fresh_state();
2430 let user = TempUser::new("unknown");
2431 let out = state
2432 .get_or_restore_session_by_any_id(&user.key, "session_2099_01_01_00_00_deadbeef")
2433 .await;
2434 assert!(out.is_none(), "unknown chain must 404, not restore");
2435 }
2436
2437 #[tokio::test]
2438 async fn destroy_tombstones_chain_id() {
2439 let state = fresh_state();
2440 let user = TempUser::new("tombstone");
2441 let sid = state
2442 .create_session(&user.key, None, None)
2443 .await
2444 .expect("create session");
2445
2446 let (session, _ws) = state.get_session(&user.key, &sid).await.unwrap();
2448 {
2449 let mut s = session.lock().await;
2450 s.session_id = Some("session_2026_09_04_10_00_aaaabbbb".to_string());
2451 }
2452
2453 state
2454 .destroy_session(&user.key, &sid)
2455 .await
2456 .expect("destroy");
2457
2458 let path = state
2459 .tombstone_path(&user.key, "session_2026_09_04_10_00_aaaabbbb")
2460 .expect("tombstone path");
2461 assert!(path.exists(), "destroy must write a chain tombstone");
2462
2463 let sid2 = state
2465 .create_session(&user.key, None, None)
2466 .await
2467 .expect("create second session");
2468 state
2469 .destroy_session(&user.key, &sid2)
2470 .await
2471 .expect("destroy 2");
2472 }
2474
2475 #[tokio::test]
2476 async fn restore_refuses_registry_key_shapes() {
2477 let state = fresh_state();
2481 let user = TempUser::new("shapes");
2482 assert!(state.tombstone_path(&user.key, "default").is_none());
2483 let out = state
2484 .get_or_restore_session_by_any_id(&user.key, "default")
2485 .await;
2486 assert!(out.is_none());
2487 }
2488}
2489
2490#[test]
2491fn collect_unresolved_placeholders_walks_the_tree() {
2492 let section: toml::Value = toml::from_str(
2493 r#"
2494[credentials.farzan_service_account]
2495service_token = "${FARZAN_SERVICE_ACCOUNT}"
2496issuer_url = "https://ok.example"
2497
2498[[servers]]
2499name = "fame"
2500url = "https://fame.example"
2501credentials = "farzan_service_account"
2502
2503[[servers]]
2504name = "other"
2505url = "https://other.example"
2506token = "${OTHER_TOKEN}"
2507"#,
2508 )
2509 .unwrap();
2510 let mut out = Vec::new();
2511 collect_unresolved_placeholders(§ion, &mut out);
2512 out.sort();
2513 assert_eq!(
2514 out,
2515 vec![
2516 "${FARZAN_SERVICE_ACCOUNT}".to_string(),
2517 "${OTHER_TOKEN}".to_string()
2518 ],
2519 "every leftover placeholder in the [mcp] tree must be collected, deduped"
2520 );
2521 let clean: toml::Value = toml::from_str("[x]\ny = \"z\"\n").unwrap();
2523 let mut out2 = Vec::new();
2524 collect_unresolved_placeholders(&clean, &mut out2);
2525 assert!(out2.is_empty());
2526}
2527
2528#[test]
2529fn substitute_env_vars_resolves_and_keeps_leftovers_deterministically() {
2530 let mut secrets = std::collections::HashMap::new();
2531 secrets.insert("A_TOKEN".to_string(), "resolved-value".to_string());
2532 let mut s = "x = \"${A_TOKEN}\" y = \"${MISSING_TOKEN}\"".to_string();
2533 let report = substitute_env_vars(&mut s, &secrets, "test");
2534 assert!(s.contains("resolved-value"), "declared key resolves: {s}");
2535 assert!(
2536 s.contains("${MISSING_TOKEN}"),
2537 "undeclared key keeps the literal (the loader guard turns this into a loud failure): {s}"
2538 );
2539 assert_eq!(report.len(), 2, "one entry per distinct var: {report:?}");
2541 assert!(report.contains(&EnvVarResolution {
2542 name: "A_TOKEN".to_string(),
2543 source: SecretSource::SecretsMap
2544 }));
2545 assert!(report.contains(&EnvVarResolution {
2546 name: "MISSING_TOKEN".to_string(),
2547 source: SecretSource::Unresolved
2548 }));
2549}
2550
2551#[test]
2552fn substitute_env_vars_reports_process_env_shadow() {
2553 let var = format!("RAVAND_PROVENANCE_TEST_{}", std::process::id());
2558 std::env::set_var(&var, "global-env-answer");
2559
2560 let mut secrets = std::collections::HashMap::new();
2562 let mut s = format!("x = \"${{{var}}}\"");
2563 let report = substitute_env_vars(&mut s, &secrets, "test-shadow");
2564 assert!(s.contains("global-env-answer"), "process env answered: {s}");
2565 assert_eq!(
2566 report,
2567 vec![EnvVarResolution {
2568 name: var.clone(),
2569 source: SecretSource::ProcessEnv
2570 }]
2571 );
2572
2573 secrets.insert(var.clone(), "per-user-answer".to_string());
2576 let mut s2 = format!("x = \"${{{var}}}\"");
2577 let report2 = substitute_env_vars(&mut s2, &secrets, "test-wins");
2578 assert!(s2.contains("per-user-answer"), "user secrets win: {s2}");
2579 assert_eq!(
2580 report2,
2581 vec![EnvVarResolution {
2582 name: var.clone(),
2583 source: SecretSource::SecretsMap
2584 }]
2585 );
2586
2587 std::env::remove_var(&var);
2588}
2589
2590#[test]
2591fn materialized_user_missing_env_only_fires_on_dir_without_env() {
2592 let base = std::env::temp_dir().join(format!("trustee-envcheck-{}", std::process::id()));
2594 let _ = std::fs::remove_dir_all(&base);
2595
2596 assert!(!materialized_user_missing_env(&base.join("ghost")));
2598
2599 std::fs::create_dir_all(&base).unwrap();
2601 assert!(materialized_user_missing_env(&base));
2602
2603 std::fs::write(base.join(".env"), "K=V\n").unwrap();
2605 assert!(!materialized_user_missing_env(&base));
2606
2607 let _ = std::fs::remove_dir_all(&base);
2608}
2609
2610#[test]
2611fn loader_freshness_decides_all_four_states() {
2612 let now = chrono::Utc::now();
2613 let fp = 0xABCD_u64;
2614 let young = now - chrono::Duration::seconds(60);
2615 let old = now - chrono::Duration::seconds(601);
2616
2617 assert_eq!(
2619 loader_freshness(fp, young, None, None, fp, now),
2620 LoaderFreshness::Fresh
2621 );
2622 assert_eq!(
2624 loader_freshness(fp, old, None, None, 0x1234, now),
2625 LoaderFreshness::ConfigChanged
2626 );
2627 assert_eq!(
2629 loader_freshness(fp, old, None, None, fp, now),
2630 LoaderFreshness::AgedOut
2631 );
2632 let failed = now - chrono::Duration::seconds(10);
2634 match loader_freshness(fp, old, Some("boom"), Some(failed), fp, now) {
2635 LoaderFreshness::Backoff(remaining) => {
2636 assert!(remaining.as_secs() >= 15, "~20s left: {remaining:?}");
2637 }
2638 other => panic!("expected Backoff, got {other:?}"),
2639 }
2640 let failed_long = now - chrono::Duration::seconds(120);
2642 assert_eq!(
2643 loader_freshness(fp, old, Some("boom"), Some(failed_long), fp, now),
2644 LoaderFreshness::AgedOut
2645 );
2646}