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 let model = config_toml
432 .parse::<toml::Value>()
433 .ok()
434 .and_then(|v| {
435 v.get("llm")
436 .and_then(|l| l.get("provider"))
437 .and_then(|p| p.get("model"))
438 .and_then(|m| m.as_str())
439 .map(|s| s.trim().to_string())
440 })
441 .unwrap_or_default();
442 if model.is_empty() {
443 tracing::warn!(
444 "[llm.provider].model is missing or empty — LLM calls will fail with 'no model configured: set [llm.provider].model or OPENAI_DEFAULT_MODEL' until set (THQ-runtime instances that never serve LLM sessions may ignore this)"
445 );
446 }
447 self.config_toml = Some(config_toml);
448 self
449 }
450
451 pub fn service_issuers(&self) -> Vec<String> {
462 service_issuers_from_config(self.config_toml.as_deref().unwrap_or(""))
463 }
464
465 pub fn with_secrets(mut self, secrets: std::collections::HashMap<String, String>) -> Self {
466 self.secrets = Some(secrets);
467 self
468 }
469
470 pub fn with_build_info(mut self, build_info: trustee_core::types::BuildInfo) -> Self {
471 self.build_info = Some(build_info);
472 self
473 }
474
475 pub fn with_max_concurrent_workflows(mut self, max: usize) -> Self {
476 self.workflow_semaphore = Arc::new(tokio::sync::Semaphore::new(max));
477 self
478 }
479
480 pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
482 self.max_sessions_per_user = max;
483 self
484 }
485
486 pub fn with_allow_llm_overlay(mut self, allow: bool) -> Self {
489 self.allow_llm_overlay = allow;
490 self
491 }
492
493 pub async fn create_session(
516 &self,
517 user_key: &str,
518 session_name: Option<String>,
519 identity: Option<String>,
520 ) -> Result<String, SessionError> {
521 let user_sessions = self
523 .sessions
524 .entry(user_key.to_string())
525 .or_insert_with(|| UserSessions {
526 sessions: DashMap::new(),
527 token_store: Arc::new(pep::MemoryTokenStore::new()),
528 });
529
530 if user_sessions.sessions.len() >= self.max_sessions_per_user {
532 return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
533 }
534
535 let (mut session, workflow_rx) = Session::new();
537
538 if let Some(ref config_toml) = self.config_toml {
540 session.config_toml = Some(config_toml.clone());
541 session.parse_auto_handoff_config();
542 if let Ok(table) = config_toml.parse::<toml::Value>() {
543 if let Some(name) = table
544 .get("agent")
545 .and_then(|a| a.get("name"))
546 .and_then(|n| n.as_str())
547 {
548 session.agent_name = name.to_string();
549 }
550 }
551 }
552
553 session.secrets = self.secrets.clone();
554 session.build_info = self.build_info.clone();
555
556 self.apply_user_isolation(&mut session, user_key);
558
559 session.session_name = session_name;
561
562 session.identity = identity;
564
565 let (ws_tx_entry, _) = broadcast::channel::<String>(256);
567
568 let session_id = format!(
570 "session_{}_{}",
571 chrono::Utc::now().format("%Y_%m_%d_%H_%M"),
572 &uuid::Uuid::new_v4().to_string()[..8]
573 );
574
575 session.session_id = Some(session_id.clone());
581
582 let now = chrono::Utc::now();
583
584 user_sessions.sessions.insert(
586 session_id.clone(),
587 UserSessionEntry {
588 session: Arc::new(Mutex::new(session)),
589 ws_tx: ws_tx_entry.clone(),
590 created_at: now,
591 last_active: Arc::new(Mutex::new(now)),
592 },
593 );
594
595 let session_arc = user_sessions
597 .sessions
598 .get(&session_id)
599 .map(|e| e.session.clone());
600 if let Some(session_arc) = session_arc {
601 self.spawn_user_drain_task(session_id.clone(), session_arc, ws_tx_entry, workflow_rx);
602 }
603
604 Ok(session_id)
605 }
606
607 pub async fn get_session(
610 &self,
611 user_key: &str,
612 session_id: &str,
613 ) -> Option<(Arc<Mutex<Session>>, broadcast::Sender<String>)> {
614 let user_sessions = self.sessions.get(user_key)?;
615 let entry = user_sessions.sessions.get(session_id)?;
616
617 let now = chrono::Utc::now();
619 *entry.last_active.lock().await = now;
620
621 Some((entry.session.clone(), entry.ws_tx.clone()))
622 }
623
624 pub async fn get_session_by_any_id(
642 &self,
643 user_key: &str,
644 id: &str,
645 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
646 let user_sessions = self.sessions.get(user_key)?;
648 if let Some(entry) = user_sessions.sessions.get(id) {
649 let now = chrono::Utc::now();
651 *entry.last_active.lock().await = now;
652 return Some((id.to_string(), entry.session.clone(), entry.ws_tx.clone()));
653 }
654
655 for entry in user_sessions.sessions.iter() {
657 let session = entry.session.lock().await;
658 if session.session_id.as_deref() == Some(id) {
659 let key = entry.key().clone();
660 let ws_tx = entry.ws_tx.clone();
661 drop(session);
662 let now = chrono::Utc::now();
664 *entry.last_active.lock().await = now;
665 return Some((key, entry.session.clone(), ws_tx));
666 }
667 }
668
669 None
670 }
671
672 fn tombstone_path(&self, user_key: &str, chain_id: &str) -> Option<std::path::PathBuf> {
681 if chain_id.is_empty() || !chain_id.starts_with("session_") {
682 return None;
683 }
684 let user_key = effective_user_key(user_key);
685 let hash = trustee_core::user_hash(&user_key);
686 dirs::home_dir().map(|home| {
687 home.join(".trustee")
688 .join("users")
689 .join(&hash)
690 .join("session_tombstones")
691 .join(chain_id)
692 })
693 }
694
695 fn write_tombstone(&self, user_key: &str, chain_id: &str) {
700 if let Some(path) = self.tombstone_path(user_key, chain_id) {
701 if let Some(parent) = path.parent() {
702 let _ = std::fs::create_dir_all(parent);
703 }
704 let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
705 }
706 }
707
708 pub fn chain_tombstone_stamp(&self, user_key: &str, chain_id: &str) -> Option<String> {
716 let path = self.tombstone_path(user_key, chain_id)?;
717 let text = std::fs::read_to_string(&path).ok()?;
718 let trimmed = text.trim();
719 if trimmed.is_empty() {
720 None
721 } else {
722 Some(trimmed.to_string())
723 }
724 }
725
726 pub async fn get_or_restore_session_by_any_id(
740 &self,
741 user_key: &str,
742 id: &str,
743 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
744 if let Some(found) = self.get_session_by_any_id(user_key, id).await {
745 return Some(found);
746 }
747 self.restore_session_from_checkpoint(user_key, id).await
748 }
749
750 async fn restore_session_from_checkpoint(
759 &self,
760 user_key: &str,
761 chain_id: &str,
762 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
763 if let Some(path) = self.tombstone_path(user_key, chain_id) {
765 if path.exists() {
766 return None;
767 }
768 }
769
770 let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
772 let config_toml = config_toml?;
773
774 let info = match trustee_core::sessions::create_resume_info(
785 &config_toml,
786 chain_id,
787 home_dir.as_deref(),
788 )
789 .await
790 .ok()
791 .flatten()
792 {
793 Some(info) => info,
794 None => {
795 let source = trustee_core::sessions::resolve_resume_handle(
796 &config_toml,
797 chain_id,
798 home_dir.as_deref(),
799 )
800 .await
801 .ok()
802 .flatten()?;
803 trustee_core::sessions::create_resume_info(
804 &config_toml,
805 &source,
806 home_dir.as_deref(),
807 )
808 .await
809 .ok()
810 .flatten()?
811 }
812 };
813
814 let user_sessions = self
817 .sessions
818 .entry(user_key.to_string())
819 .or_insert_with(|| UserSessions {
820 sessions: DashMap::new(),
821 token_store: Arc::new(pep::MemoryTokenStore::new()),
822 });
823
824 if let Some(existing) = user_sessions.sessions.get(chain_id) {
825 let now = chrono::Utc::now();
826 *existing.last_active.lock().await = now;
827 return Some((
828 chain_id.to_string(),
829 existing.session.clone(),
830 existing.ws_tx.clone(),
831 ));
832 }
833
834 if user_sessions.sessions.len() >= self.max_sessions_per_user {
839 let mut evict: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
840 for entry in user_sessions.sessions.iter() {
841 let is_idle = entry.session.lock().await.workflow_state
842 == trustee_core::types::WorkflowState::Idle;
843 if !is_idle {
844 continue;
845 }
846 let la = entry.last_active.lock().await;
847 if evict.as_ref().is_none_or(|(_, t)| *la < *t) {
848 evict = Some((entry.key().clone(), *la));
849 }
850 }
851 if let Some((victim, _)) = evict {
852 user_sessions.sessions.remove(&victim);
853 } else {
854 return None;
855 }
856 }
857
858 let (mut session, workflow_rx) = Session::new();
861 session.config_toml = Some(config_toml.clone());
862 session.parse_auto_handoff_config();
863 if let Ok(table) = config_toml.parse::<toml::Value>() {
864 if let Some(name) = table
865 .get("agent")
866 .and_then(|a| a.get("name"))
867 .and_then(|n| n.as_str())
868 {
869 session.agent_name = name.to_string();
870 }
871 }
872 session.secrets = self.secrets.clone();
873 session.build_info = self.build_info.clone();
874 self.apply_user_isolation(&mut session, user_key);
875 session.session_id = Some(chain_id.to_string());
876 session.resume_info = Some(info);
877 session.session_name = None; let (ws_tx_entry, _) = broadcast::channel::<String>(256);
880 let now = chrono::Utc::now();
881
882 user_sessions.sessions.insert(
883 chain_id.to_string(),
884 UserSessionEntry {
885 session: Arc::new(Mutex::new(session)),
886 ws_tx: ws_tx_entry.clone(),
887 created_at: now,
888 last_active: Arc::new(Mutex::new(now)),
889 },
890 );
891
892 let session_arc = user_sessions
896 .sessions
897 .get(chain_id)
898 .map(|e| e.session.clone())
899 .ok_or_else(|| SessionError::NotFound(chain_id.to_string()))
900 .ok()?;
901 self.spawn_user_drain_task(
902 chain_id.to_string(),
903 session_arc.clone(),
904 ws_tx_entry.clone(),
905 workflow_rx,
906 );
907
908 Some((chain_id.to_string(), session_arc, ws_tx_entry))
909 }
910
911 pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> {
913 let Some(user_sessions) = self.sessions.get(user_key) else {
914 return Vec::new();
915 };
916
917 let mut items = Vec::new();
918 for entry in user_sessions.sessions.iter() {
919 let session = entry.session.lock().await;
920 let workflow_state = match session.workflow_state {
921 trustee_core::types::WorkflowState::Idle => "Idle",
922 trustee_core::types::WorkflowState::Running => "Running",
923 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
924 };
925 let last_active = entry.last_active.lock().await;
926 items.push(SessionListItem {
927 session_id: entry.key().clone(),
928 session_name: session.session_name.clone(),
929 workflow_state: workflow_state.to_string(),
930 created_at: entry.created_at.to_rfc3339(),
931 last_active: last_active.to_rfc3339(),
932 handoff_count: session.handoff_count,
933 });
934 }
935 drop(user_sessions);
936
937 items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
939 items
940 }
941
942 pub async fn destroy_session(
944 &self,
945 user_key: &str,
946 session_id: &str,
947 ) -> Result<(), SessionError> {
948 let user_sessions = self
949 .sessions
950 .get(user_key)
951 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
952
953 let chain_id_for_tombstone = {
956 let entry = user_sessions
957 .sessions
958 .get(session_id)
959 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
960 let session = entry.session.lock().await;
961 if session.workflow_state != trustee_core::types::WorkflowState::Idle {
962 let state_str = match session.workflow_state {
963 trustee_core::types::WorkflowState::Running => "Running",
964 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
965 _ => "Unknown",
966 };
967 return Err(SessionError::NotIdle(state_str.to_string()));
968 }
969 session.session_id.clone()
970 };
971
972 user_sessions.sessions.remove(session_id);
974
975 if let Some(chain_id) = chain_id_for_tombstone {
979 self.write_tombstone(user_key, &chain_id);
980 }
981
982 Ok(())
983 }
984
985 pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
996 if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
1007 static GATE_WARNED: std::sync::Once = std::sync::Once::new();
1008 GATE_WARNED.call_once(|| {
1009 tracing::warn!(
1010 "TRUSTEE_CLASSIC_HOME=1 — ALL users share the classic home \
1011 (~/.trustee); per-user checkpoint isolation is OFF. \
1012 Single-user loopback ONLY — unset this env on multi-user servers."
1013 );
1014 });
1015 return dirs::home_dir().map(|home| home.join(".trustee"));
1016 }
1017 let user_key = effective_user_key(user_key);
1018 let hash = trustee_core::user_hash(&user_key);
1019 dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&hash))
1020 }
1021
1022 pub fn get_user_config_and_home(
1027 &self,
1028 user_key: &str,
1029 ) -> (Option<String>, Option<std::path::PathBuf>) {
1030 (self.config_toml.clone(), self.get_user_home_dir(user_key))
1031 }
1032
1033 fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
1042 if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
1045 if let Some(home) = dirs::home_dir() {
1046 session.home_dir = Some(home.join(".trustee"));
1047 }
1048 return;
1049 }
1050 let user_key = effective_user_key(user_key);
1051 let user_hash = trustee_core::user_hash(&user_key);
1052
1053 let user_home = if let Some(home) = dirs::home_dir() {
1055 let user_home = home.join(".trustee").join("users").join(&user_hash);
1056 session.home_dir = Some(user_home.clone());
1057 Some(user_home)
1058 } else {
1059 None
1060 };
1061
1062 if let Some(ref user_home) = user_home {
1066 if materialized_user_missing_env(user_home) {
1067 tracing::warn!(
1068 "user namespace {} is materialized but has NO per-user .env — \
1069 ${{…}} references will fall through to process env \
1070 (global ~/.trustee/.env) and execute under the GLOBAL principal",
1071 &user_hash[..8.min(user_hash.len())]
1072 );
1073 }
1074 }
1075
1076 session.project_id = Some(format!("web{}", &user_hash[..16]));
1077
1078 let shared_secrets = session.secrets.clone().unwrap_or_default();
1085 let mut merged_secrets = shared_secrets.clone();
1086
1087 if let Some(ref user_home) = user_home {
1088 if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
1089 merged_secrets = merged;
1090 }
1091 }
1092
1093 if let Some(ref user_home) = user_home {
1095 if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref())
1096 {
1097 session.config_toml = Some(merged);
1098 tracing::debug!("Merged per-user config into session");
1099 }
1100 }
1101
1102 if let Some(ref mut config_toml) = session.config_toml {
1104 substitute_env_vars(
1105 config_toml,
1106 &merged_secrets,
1107 &format!("user {}", &user_hash[..8.min(user_hash.len())]),
1108 );
1109 }
1110
1111 session.secrets = Some(shared_secrets);
1113 }
1114
1115 fn load_user_secrets(
1118 &self,
1119 user_home: &std::path::Path,
1120 base: &std::collections::HashMap<String, String>,
1121 ) -> std::io::Result<std::collections::HashMap<String, String>> {
1122 let user_env_path = user_home.join(".env");
1123 if !user_env_path.exists() {
1124 return Ok(base.clone());
1125 }
1126 let content = std::fs::read_to_string(&user_env_path)?;
1127 let mut merged = base.clone();
1128 for line in content.lines() {
1129 let line = line.trim();
1130 if line.is_empty() || line.starts_with('#') {
1131 continue;
1132 }
1133 if let Some((key, value)) = line.split_once('=') {
1134 let key = key.trim().to_string();
1135 let value = value
1136 .trim()
1137 .trim_matches('"')
1138 .trim_matches('\'')
1139 .to_string();
1140 merged.insert(key, value);
1141 }
1142 }
1143 tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
1144 Ok(merged)
1145 }
1146
1147 fn merge_user_config(
1162 &self,
1163 user_home: &std::path::Path,
1164 shared_config: Option<&str>,
1165 ) -> Option<String> {
1166 let user_config_path = user_home.join("config").join("trustee.toml");
1167 if !user_config_path.exists() {
1168 return None;
1169 }
1170 let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
1171 let shared = shared_config.unwrap_or("").parse::<toml::Value>().ok()?;
1172 let overlay = user_config_toml.parse::<toml::Value>().ok()?;
1173
1174 let allowed = |section: &str| {
1175 section == "mcp"
1176 || section == "thq"
1180 || section == "lifecycle"
1185 || (self.allow_llm_overlay && section == "llm")
1186 };
1187 let dir_name = user_home
1190 .file_name()
1191 .and_then(|n| n.to_str())
1192 .unwrap_or("<unknown>");
1193 let masked_user = dir_name.get(..8).unwrap_or(dir_name);
1194
1195 let mut filtered_overlay = toml::map::Map::new();
1196 if let Some(table) = overlay.as_table() {
1197 for (section, value) in table {
1198 if section == "lifecycle" {
1199 let mut v = value.clone();
1200 if let Some(t) = v.as_table_mut() {
1201 if t.remove("enabled").is_some() {
1202 tracing::info!(
1203 "user config overlay: [lifecycle].enabled is not user-settable — stripped for user {}",
1204 masked_user
1205 );
1206 }
1207 }
1208 filtered_overlay.insert(section.clone(), v);
1209 } else if allowed(section) {
1210 filtered_overlay.insert(section.clone(), value.clone());
1211 } else {
1212 tracing::warn!(
1213 "user config overlay: dropping non-allowlisted section [{}] for user {}",
1214 section,
1215 masked_user
1216 );
1217 }
1218 }
1219 }
1220
1221 if filtered_overlay.is_empty() {
1222 return None;
1224 }
1225 let overlay = toml::Value::Table(filtered_overlay);
1226
1227 let mut shared = shared;
1228 deep_merge_toml(&mut shared, &overlay);
1229 let merged = toml::to_string(&shared).ok()?;
1230 tracing::debug!("Merged per-user config from {}", user_config_path.display());
1231 Some(merged)
1232 }
1233
1234 pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1243 let config_toml = self.config_toml.clone()?;
1244
1245 let user_home = self.get_user_home_dir(user_key)?;
1247
1248 let mut merged_secrets = self.secrets.clone().unwrap_or_default();
1250 if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
1251 merged_secrets = merged;
1252 }
1253
1254 let mut resolved = config_toml;
1256 if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
1257 resolved = merged;
1258 }
1259
1260 substitute_env_vars(
1262 &mut resolved,
1263 &merged_secrets,
1264 &format!(
1265 "resolve_user_config user {}",
1266 &trustee_core::user_hash(user_key)[..8]
1267 ),
1268 );
1269
1270 Some(resolved)
1271 }
1272
1273 pub async fn get_or_build_mcp_loader(
1289 &self,
1290 user_key: &str,
1291 token_store: &Arc<pep::MemoryTokenStore>,
1292 ) -> Result<Option<std::sync::Arc<abk::agent::McpToolLoader>>, String> {
1293 let user_key = effective_user_key(user_key);
1294 let user_hash = trustee_core::user_hash(&user_key);
1295
1296 let resolved = self.resolve_user_config(&user_key);
1298 let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1299
1300 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1302 match loader_freshness(
1303 entry.fingerprint,
1304 entry.built_at,
1305 entry.degraded.as_deref(),
1306 entry.failed_at,
1307 fingerprint,
1308 chrono::Utc::now(),
1309 ) {
1310 LoaderFreshness::Fresh => return Ok(entry.loader.clone()),
1311 LoaderFreshness::ConfigChanged => {
1312 }
1314 LoaderFreshness::AgedOut => {
1315 tracing::info!(
1318 "MCP toolset for user {} is {}s old (max {}s) — rebuilding \
1319 to re-list the served surface (phantom-tool guard)",
1320 &user_hash[..8.min(user_hash.len())],
1321 (chrono::Utc::now() - entry.built_at).num_seconds(),
1322 MCP_TOOLSET_MAX_AGE.as_secs()
1323 );
1324 }
1325 LoaderFreshness::Backoff(remaining) => {
1326 let err = entry.degraded.clone().unwrap_or_else(|| {
1327 "previous MCP loader build failed; retry after backoff".to_string()
1328 });
1329 let _ = remaining;
1330 return Err(err);
1331 }
1332 }
1333 }
1334
1335 let lock = self
1337 .mcp_build_locks
1338 .entry(user_hash.clone())
1339 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1340 .clone();
1341 let _guard = lock.lock().await;
1342
1343 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1345 if loader_freshness(
1346 entry.fingerprint,
1347 entry.built_at,
1348 entry.degraded.as_deref(),
1349 entry.failed_at,
1350 fingerprint,
1351 chrono::Utc::now(),
1352 ) == LoaderFreshness::Fresh
1353 {
1354 return Ok(entry.loader.clone());
1355 }
1356 }
1357
1358 match self
1359 .build_mcp_loader(&user_hash, resolved.as_deref(), fingerprint, token_store)
1360 .await
1361 {
1362 Ok(entry) => {
1363 self.mcp_loaders.insert(user_hash.clone(), entry);
1364 Ok(self.mcp_loaders.get(&user_hash).unwrap().loader.clone())
1365 }
1366 Err(err) => {
1367 tracing::warn!(
1368 "MCP loader build FAILED for user {}; dispatch fails loud, retry after {:?}",
1369 &user_hash[..8.min(user_hash.len())],
1370 MCP_BUILD_RETRY_BACKOFF
1371 );
1372 self.mcp_loaders.insert(
1373 user_hash,
1374 McpLoaderEntry {
1375 loader: None,
1376 fingerprint,
1377 built_at: chrono::Utc::now(),
1378 degraded: Some(err.clone()),
1379 failed_at: Some(chrono::Utc::now()),
1380 },
1381 );
1382 Err(err)
1383 }
1384 }
1385 }
1386
1387 async fn build_mcp_loader(
1390 &self,
1391 user_hash: &str,
1392 resolved: Option<&str>,
1393 fingerprint: u64,
1394 token_store: &Arc<pep::MemoryTokenStore>,
1395 ) -> Result<McpLoaderEntry, String> {
1396 let mcp_config: Option<abk::config::McpConfig> = match resolved {
1397 Some(toml_str) => {
1398 let value = toml_str
1399 .parse::<toml::Value>()
1400 .map_err(|e| format!("config parse failed: {}", e))?;
1401 match value.get("mcp") {
1402 Some(section) => {
1403 let mut leftovers: Vec<String> = Vec::new();
1410 collect_unresolved_placeholders(section, &mut leftovers);
1411 if !leftovers.is_empty() {
1412 return Err(format!(
1413 "unresolved {} placeholder(s) in [mcp]: {} — supply the \
1414 exact-case key in the profile secrets",
1415 leftovers.len(),
1416 leftovers.join(", ")
1417 ));
1418 }
1419 use serde::Deserialize as _;
1420 Some(
1421 abk::config::McpConfig::deserialize(section.clone())
1422 .map_err(|e| format!("invalid [mcp] config: {}", e))?,
1423 )
1424 }
1425 None => None,
1426 }
1427 }
1428 None => None,
1429 };
1430
1431 let loader = match mcp_config {
1432 Some(cfg) if cfg.enabled => {
1433 let built = abk::agent::McpToolLoader::with_token_store(
1434 &cfg,
1435 Some(token_store.clone() as std::sync::Arc<dyn pep::token_store::TokenStore>),
1436 )
1437 .await
1438 .map_err(|e| format!("MCP loader build failed: {}", e))?;
1439
1440 let servers: Vec<String> = built
1444 .server_statuses
1445 .iter()
1446 .map(|s| {
1447 if s.connected {
1448 format!("{}(up,{}tools)", s.name, s.tool_count)
1449 } else {
1450 format!("{}(DOWN)", s.name)
1451 }
1452 })
1453 .collect();
1454 tracing::info!(
1455 "MCP loader built for user {}: servers=[{}] total_tools={}",
1456 &user_hash[..8.min(user_hash.len())],
1457 servers.join(", "),
1458 built.tool_count
1459 );
1460 Some(std::sync::Arc::new(built))
1461 }
1462 _ => None,
1463 };
1464
1465 Ok(McpLoaderEntry {
1466 loader,
1467 fingerprint,
1468 built_at: chrono::Utc::now(),
1469 degraded: None,
1470 failed_at: None,
1471 })
1472 }
1473
1474 fn spawn_user_drain_task(
1476 &self,
1477 session_id: String,
1478 session: Arc<Mutex<Session>>,
1479 ws_tx: broadcast::Sender<String>,
1480 mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
1481 ) {
1482 let mut last_broadcast_state: Option<String> = None;
1488 tokio::spawn(async move {
1489 while let Some(msg) = workflow_rx.recv().await {
1490 {
1491 let mut session = session.lock().await;
1492 session.handle_workflow_message(msg.clone());
1493
1494 let state_str = match session.workflow_state {
1495 trustee_core::types::WorkflowState::Idle => "Idle",
1496 trustee_core::types::WorkflowState::Running => "Running",
1497 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1498 };
1499 if last_broadcast_state.as_deref() != Some(state_str) {
1500 last_broadcast_state = Some(state_str.to_string());
1501 let state_msg = serde_json::json!({
1502 "type": "StateChanged",
1503 "state": state_str
1504 });
1505 let _ = ws_tx.send(state_msg.to_string());
1506 }
1507 }
1508
1509 let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1510 let _ = ws_tx.send(json);
1511 }
1512 tracing::debug!("Drain task ended for session: {}", session_id);
1513 });
1514 }
1515
1516 pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1519 let default_user = self
1521 .sessions
1522 .get("default")
1523 .expect("default user must exist");
1524 let first_entry = default_user
1525 .sessions
1526 .iter()
1527 .next()
1528 .expect("default user must have at least one session");
1529 let session = first_entry.session.clone();
1530 let ws_tx = first_entry.ws_tx.clone();
1531 let session_id = first_entry.key().clone();
1532 drop(first_entry);
1533 drop(default_user);
1534
1535 let mut last_broadcast_state = Some("Running".to_string());
1539 tokio::spawn(async move {
1540 while let Some(msg) = workflow_rx.recv().await {
1541 {
1542 let mut session = session.lock().await;
1543 session.handle_workflow_message(msg.clone());
1544
1545 let state_str = match session.workflow_state {
1546 trustee_core::types::WorkflowState::Idle => "Idle",
1547 trustee_core::types::WorkflowState::Running => "Running",
1548 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1549 };
1550 if last_broadcast_state.as_deref() != Some(state_str) {
1551 last_broadcast_state = Some(state_str.to_string());
1552 let state_msg = serde_json::json!({
1553 "type": "StateChanged",
1554 "state": state_str
1555 });
1556 let _ = ws_tx.send(state_msg.to_string());
1557 }
1558 }
1559
1560 let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1561 let _ = ws_tx.send(json);
1562 }
1563 tracing::debug!("Drain task ended for session: {}", session_id);
1564 });
1565 }
1566
1567 pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
1569 let Some(ref auth) = self.auth else {
1570 return "default".to_string();
1571 };
1572
1573 if let Some(token) = headers
1575 .get(axum::http::header::AUTHORIZATION)
1576 .and_then(|v| v.to_str().ok())
1577 .and_then(|v| v.strip_prefix("Bearer "))
1578 .map(|s| s.to_string())
1579 {
1580 if token.starts_with("dev:") {
1581 let parts: Vec<&str> = token.splitn(4, ':').collect();
1582 if parts.len() >= 4 {
1583 return format!("dev:{}", parts[1]);
1584 }
1585 }
1586 if let Ok(claims) = auth.validate_token(&token).await {
1587 return claims.sub;
1588 }
1589 }
1590
1591 let cookie_session_id = headers
1593 .get(axum::http::header::COOKIE)
1594 .and_then(|v| v.to_str().ok())
1595 .and_then(|cookies| {
1596 cookies.split(';').map(|c| c.trim()).find_map(|c| {
1597 c.strip_prefix(&format!("{}=", auth.config.cookie_name))
1598 .map(|s| s.to_string())
1599 })
1600 });
1601
1602 if let Some(session_id) = cookie_session_id {
1603 if session_id.starts_with("dev:") {
1604 let parts: Vec<&str> = session_id.splitn(4, ':').collect();
1605 if parts.len() >= 4 {
1606 return format!("dev:{}", parts[1]);
1607 }
1608 }
1609
1610 if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
1611 if let Ok(claims) = auth.validate_token(&access_token).await {
1612 return claims.sub;
1613 }
1614 }
1615 }
1616
1617 "default".to_string()
1618 }
1619}
1620
1621struct SerializableMessage<'a>(&'a TuiMessage);
1627
1628impl<'a> serde::Serialize for SerializableMessage<'a> {
1629 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1630 where
1631 S: serde::Serializer,
1632 {
1633 use serde::ser::SerializeStruct;
1634
1635 match self.0 {
1636 TuiMessage::OutputLine(line) => {
1637 let mut s = serializer.serialize_struct("msg", 2)?;
1638 s.serialize_field("type", "OutputLine")?;
1639 s.serialize_field("line", line)?;
1640 s.end()
1641 }
1642 TuiMessage::StreamDelta(delta) => {
1643 let mut s = serializer.serialize_struct("msg", 2)?;
1644 s.serialize_field("type", "StreamDelta")?;
1645 s.serialize_field("delta", delta)?;
1646 s.end()
1647 }
1648 TuiMessage::ReasoningDelta(delta) => {
1649 let mut s = serializer.serialize_struct("msg", 2)?;
1650 s.serialize_field("type", "ReasoningDelta")?;
1651 s.serialize_field("delta", delta)?;
1652 s.end()
1653 }
1654 TuiMessage::WorkflowCompleted => {
1655 let mut s = serializer.serialize_struct("msg", 2)?;
1656 s.serialize_field("type", "WorkflowCompleted")?;
1657 s.serialize_field("state", "Idle")?;
1658 s.end()
1659 }
1660 TuiMessage::WorkflowError(err) => {
1661 let mut s = serializer.serialize_struct("msg", 2)?;
1662 s.serialize_field("type", "WorkflowError")?;
1663 s.serialize_field("error", err)?;
1664 s.end()
1665 }
1666 TuiMessage::ResumeInfo(info) => match info {
1667 Some(ri) => {
1668 let mut s = serializer.serialize_struct("msg", 5)?;
1669 s.serialize_field("type", "ResumeInfo")?;
1670 s.serialize_field("state", "Idle")?;
1671 s.serialize_field("session_id", &ri.session_id)?;
1672 s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
1673 s.serialize_field("iteration", &ri.iteration)?;
1674 s.end()
1675 }
1676 None => {
1677 let mut s = serializer.serialize_struct("msg", 2)?;
1678 s.serialize_field("type", "ResumeInfo")?;
1679 s.serialize_field("state", "Idle")?;
1680 s.end()
1681 }
1682 },
1683 TuiMessage::TodoUpdate(content) => {
1684 let mut s = serializer.serialize_struct("msg", 2)?;
1685 s.serialize_field("type", "TodoUpdate")?;
1686 s.serialize_field("content", content)?;
1687 s.end()
1688 }
1689 TuiMessage::WorkflowCancelled => {
1690 let mut s = serializer.serialize_struct("msg", 2)?;
1691 s.serialize_field("type", "WorkflowCancelled")?;
1692 s.serialize_field("state", "Idle")?;
1693 s.end()
1694 }
1695 TuiMessage::HandoffReady(briefing) => {
1696 let mut s = serializer.serialize_struct("msg", 3)?;
1697 s.serialize_field("type", "HandoffReady")?;
1698 s.serialize_field("state", "Idle")?;
1699 s.serialize_field("briefing", briefing)?;
1700 s.end()
1701 }
1702 TuiMessage::SessionRotated { old, new } => {
1703 let mut s = serializer.serialize_struct("msg", 3)?;
1704 s.serialize_field("type", "SessionRotated")?;
1705 s.serialize_field("old", old)?;
1706 s.serialize_field("new", new)?;
1707 s.end()
1708 }
1709 TuiMessage::HandoffFailed => {
1710 let mut s = serializer.serialize_struct("msg", 2)?;
1711 s.serialize_field("type", "HandoffFailed")?;
1712 s.serialize_field("state", "Idle")?;
1713 s.end()
1714 }
1715 TuiMessage::ToolPending { tool_name, hint } => {
1716 let mut s = serializer.serialize_struct("msg", 3)?;
1717 s.serialize_field("type", "ToolPending")?;
1718 s.serialize_field("tool_name", tool_name)?;
1719 s.serialize_field("hint", hint)?;
1720 s.end()
1721 }
1722 TuiMessage::ToolDone {
1723 tool_name,
1724 success,
1725 hint,
1726 } => {
1727 let mut s = serializer.serialize_struct("msg", 4)?;
1728 s.serialize_field("type", "ToolDone")?;
1729 s.serialize_field("tool_name", tool_name)?;
1730 s.serialize_field("success", success)?;
1731 s.serialize_field("hint", hint)?;
1732 s.end()
1733 }
1734 TuiMessage::ContextTokensUpdated(count) => {
1735 let mut s = serializer.serialize_struct("msg", 2)?;
1736 s.serialize_field("type", "ContextTokensUpdated")?;
1737 s.serialize_field("count", count)?;
1738 s.end()
1739 }
1740 TuiMessage::McpServerStatus {
1741 name,
1742 connected,
1743 tool_count,
1744 error,
1745 } => {
1746 let mut s = serializer.serialize_struct("msg", 5)?;
1747 s.serialize_field("type", "McpServerStatus")?;
1748 s.serialize_field("name", name)?;
1749 s.serialize_field("connected", connected)?;
1750 s.serialize_field("tool_count", tool_count)?;
1751 s.serialize_field("error", error)?;
1752 s.end()
1753 }
1754 TuiMessage::SessionTitleUpdated(title) => {
1755 let mut s = serializer.serialize_struct("msg", 2)?;
1756 s.serialize_field("type", "SessionTitleUpdated")?;
1757 s.serialize_field("title", title)?;
1758 s.end()
1759 }
1760 }
1761 }
1762}
1763
1764fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1775 match (base, overlay) {
1776 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1777 for (key, overlay_val) in overlay_table {
1778 match base_table.get_mut(key) {
1779 Some(base_val) => {
1780 deep_merge_toml(base_val, overlay_val);
1782 }
1783 None => {
1784 base_table.insert(key.clone(), overlay_val.clone());
1786 }
1787 }
1788 }
1789 }
1790 (base, overlay) => {
1792 *base = overlay.clone();
1793 }
1794 }
1795}
1796
1797fn collect_unresolved_placeholders(value: &toml::Value, out: &mut Vec<String>) {
1804 match value {
1805 toml::Value::String(s) => {
1806 if let Some(start) = s.find("${") {
1807 if let Some(end_rel) = s[start + 2..].find('}') {
1808 let name = &s[start + 2..start + 2 + end_rel];
1809 let rendered = format!("${{{name}}}");
1810 if !out.contains(&rendered) {
1811 out.push(rendered);
1812 }
1813 }
1814 }
1815 }
1816 toml::Value::Table(t) => {
1817 for v in t.values() {
1818 collect_unresolved_placeholders(v, out);
1819 }
1820 }
1821 toml::Value::Array(a) => {
1822 for v in a {
1823 collect_unresolved_placeholders(v, out);
1824 }
1825 }
1826 _ => {}
1827 }
1828}
1829
1830#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1832pub(crate) enum SecretSource {
1833 SecretsMap,
1836 ProcessEnv,
1842 Unresolved,
1844}
1845
1846#[derive(Debug, Clone, PartialEq, Eq)]
1848pub(crate) struct EnvVarResolution {
1849 pub name: String,
1850 pub source: SecretSource,
1851}
1852
1853fn substitute_env_vars(
1854 s: &mut String,
1855 secrets: &std::collections::HashMap<String, String>,
1856 ctx: &str,
1857) -> Vec<EnvVarResolution> {
1858 let mut result = String::with_capacity(s.len());
1860 let bytes = s.as_bytes();
1861 let mut i = 0;
1862 let mut report: Vec<EnvVarResolution> = Vec::new();
1865
1866 while i < bytes.len() {
1867 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1868 if let Some(end) = s[i + 2..].find('}') {
1870 let var_name = &s[i + 2..i + 2 + end];
1871 let (replacement, source) = if let Some(value) = secrets.get(var_name) {
1873 (value.clone(), SecretSource::SecretsMap)
1874 } else if let Ok(value) = std::env::var(var_name) {
1875 (value, SecretSource::ProcessEnv)
1876 } else {
1877 (s[i..i + 2 + end + 1].to_string(), SecretSource::Unresolved)
1882 };
1883 if !report.iter().any(|r| r.name == var_name) {
1884 report.push(EnvVarResolution {
1885 name: var_name.to_string(),
1886 source,
1887 });
1888 }
1889 result.push_str(&replacement);
1890 i = i + 2 + end + 1;
1891 } else {
1892 result.push('$');
1894 i += 1;
1895 }
1896 } else {
1897 result.push(bytes[i] as char);
1898 i += 1;
1899 }
1900 }
1901
1902 for r in &report {
1907 match r.source {
1908 SecretSource::ProcessEnv => tracing::error!(
1909 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from PROCESS ENV \
1910 (global ~/.trustee/.env at boot) — no per-user/profile secret \
1911 answered; calls using it execute under the GLOBAL principal. \
1912 Supply the exact-case key in the per-user .env / profile \
1913 secrets to restore attribution.",
1914 ctx,
1915 r.name
1916 ),
1917 SecretSource::Unresolved => {
1918 }
1920 SecretSource::SecretsMap => tracing::debug!(
1921 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from profile/user secrets",
1922 ctx,
1923 r.name
1924 ),
1925 }
1926 }
1927 let unresolved: Vec<&str> = report
1928 .iter()
1929 .filter(|r| r.source == SecretSource::Unresolved)
1930 .map(|r| r.name.as_str())
1931 .collect();
1932 if !unresolved.is_empty() {
1933 tracing::warn!(
1934 "ENV SUBSTITUTION LEFT {} UNRESOLVED [{}]: {} — the literal ${{…}} remains in the \
1935 effective config; supply the key in the profile secrets (exact-case KEY=VALUE) \
1936 or the environment",
1937 unresolved.len(),
1938 ctx,
1939 unresolved
1940 .iter()
1941 .map(|v| format!("${{{v}}}"))
1942 .collect::<Vec<_>>()
1943 .join(", ")
1944 );
1945 }
1946
1947 *s = result;
1948 report
1949}
1950
1951pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1956 user_home.is_dir() && !user_home.join(".env").exists()
1957}
1958
1959fn fingerprint_mcp_section(resolved: Option<&str>) -> u64 {
1969 use sha2::{Digest, Sha256};
1970 let section = resolved
1971 .and_then(|s| s.parse::<toml::Value>().ok())
1972 .and_then(|v| v.get("mcp").cloned());
1973 let bytes = match section {
1974 Some(v) => v.to_string().into_bytes(),
1975 None => b"<no-mcp>".to_vec(),
1976 };
1977 let digest = Sha256::digest(&bytes);
1978 u64::from_be_bytes(digest[..8].try_into().expect("sha256 digest >= 8 bytes"))
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983 use super::*;
1984
1985 fn test_state() -> ServerState {
1987 let (session, _rx) = Session::new();
1988 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
1989 ServerState::new(session, ws_tx, None)
1990 }
1991
1992 #[tokio::test]
1993 async fn dispatch_scope_pins_user_key_resolution() {
1994 let state = test_state();
1998 let caller = "some-human-sub";
1999 let plain_home = state.get_user_home_dir(caller).unwrap();
2000 let pinned_key = "prof-1234";
2001 let pinned_home = in_dispatch_scope(pinned_key, async {
2002 state.get_user_home_dir(caller).unwrap()
2003 })
2004 .await;
2005 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2006 assert_eq!(plain_home, users_root.join(trustee_core::user_hash(caller)));
2007 assert_eq!(
2008 pinned_home,
2009 users_root.join(trustee_core::user_hash(pinned_key)),
2010 "dispatch scope must pin resolution to the binding key"
2011 );
2012 assert_ne!(plain_home, pinned_home);
2013 }
2014
2015 fn temp_user_home(tag: &str) -> std::path::PathBuf {
2017 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2018 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2019 let dir = std::env::temp_dir().join(format!(
2020 "trustee-state-test-{}-{}-{}",
2021 tag,
2022 std::process::id(),
2023 n
2024 ));
2025 std::fs::create_dir_all(dir.join("config")).expect("create temp user home");
2026 dir
2027 }
2028
2029 fn parse(toml_str: &str) -> toml::Value {
2030 toml_str.parse::<toml::Value>().expect("valid test TOML")
2031 }
2032
2033 #[test]
2036 fn overlay_allowlist_drops_non_allowlisted_sections() {
2037 let state = test_state();
2038 let home = temp_user_home("allowlist");
2039 std::fs::write(
2040 home.join("config").join("trustee.toml"),
2041 "[server]\nport = 1\n\n[auth]\nmode = \"kanidm\"\n\n[mcp]\nmode = \"user\"\n",
2042 )
2043 .expect("write overlay");
2044
2045 let shared = "[server]\nport = 8080\n\n[mcp]\nmode = \"shared\"\n";
2046 let merged = state
2047 .merge_user_config(&home, Some(shared))
2048 .expect("overlay has allowlisted content");
2049
2050 let merged_val = parse(&merged);
2051 let expected = parse("[server]\nport = 8080\n\n[mcp]\nmode = \"user\"\n");
2052 assert_eq!(
2053 merged_val, expected,
2054 "merged config must be shared + [mcp] overlay only"
2055 );
2056 assert!(
2057 merged_val.get("auth").is_none(),
2058 "overlay [auth] must be dropped"
2059 );
2060 }
2061
2062 #[test]
2064 fn no_overlay_file_returns_none() {
2065 let state = test_state();
2066 let home = temp_user_home("empty");
2067 assert!(state
2068 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2069 .is_none());
2070 }
2071
2072 #[test]
2074 fn overlay_with_no_allowlisted_sections_is_noop() {
2075 let state = test_state();
2076 let home = temp_user_home("all-dropped");
2077 std::fs::write(
2078 home.join("config").join("trustee.toml"),
2079 "[server]\nport = 1\n\n[storage]\npath = \"/tmp/x\"\n",
2080 )
2081 .expect("write overlay");
2082 assert!(state
2083 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2084 .is_none());
2085 }
2086
2087 #[test]
2090 fn llm_overlay_dropped_by_default_and_kept_when_enabled() {
2091 let shared = "[llm]\nprovider = \"openai\"\n\n[mcp]\nmode = \"shared\"\n";
2092 let overlay = "[llm]\nprovider = \"anthropic\"\n";
2093
2094 let state = test_state();
2096 assert!(!state.allow_llm_overlay);
2097 let home = temp_user_home("llm-off");
2098 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2099 assert!(state.merge_user_config(&home, Some(shared)).is_none());
2100
2101 let state = test_state().with_allow_llm_overlay(true);
2103 assert!(state.allow_llm_overlay);
2104 let home = temp_user_home("llm-on");
2105 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2106 let merged = state
2107 .merge_user_config(&home, Some(shared))
2108 .expect("[llm] overlay applies when opted in");
2109 let merged_val = parse(&merged);
2110 assert_eq!(merged_val["llm"]["provider"].as_str(), Some("anthropic"));
2111 assert_eq!(merged_val["mcp"]["mode"].as_str(), Some("shared"));
2112 }
2113
2114 #[test]
2118 fn user_home_dir_uses_consolidated_hash() {
2119 let state = test_state();
2120 if let Some(home) = state.get_user_home_dir("farzan@example.com") {
2121 assert_eq!(
2122 home.file_name().and_then(|n| n.to_str()),
2123 Some(trustee_core::user_hash("farzan@example.com")).as_deref()
2124 );
2125 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2126 assert_eq!(home.parent(), Some(&users_root).map(|p| p.as_path()));
2127 }
2128 }
2130
2131 #[test]
2134 fn lifecycle_overlay_survives_allowlist_enabled_stripped() {
2135 let state = test_state();
2136 let home = temp_user_home("lifecycle-persona");
2137 std::fs::write(
2138 home.join("config").join("trustee.toml"),
2139 "[lifecycle]\nsystem_template = \"You are Saman.\"\nenabled = true\n",
2140 )
2141 .expect("write overlay");
2142
2143 let merged = state
2144 .merge_user_config(
2145 &home,
2146 Some("[lifecycle]\nsystem_template = \"shared default\"\n"),
2147 )
2148 .expect("lifecycle overlay is allowlisted");
2149
2150 let v = merged.parse::<toml::Value>().expect("valid TOML");
2151 assert_eq!(
2152 v["lifecycle"]["system_template"].as_str(),
2153 Some("You are Saman."),
2154 "agent persona wins over shared default"
2155 );
2156 assert_eq!(
2157 v["lifecycle"].get("enabled"),
2158 None,
2159 "WASM extension switch is not user-settable"
2160 );
2161 }
2162
2163 #[tokio::test]
2168 async fn agent_principals_get_isolated_session_buckets() {
2169 let state = test_state();
2170 let key_a = "agent-farzan";
2171 let key_b = "agent-paydar";
2172
2173 let sid_a = state
2174 .create_session(key_a, None, None)
2175 .await
2176 .expect("create session");
2177 let _sid_b = state
2178 .create_session(key_b, None, None)
2179 .await
2180 .expect("create session");
2181
2182 assert!(
2184 state.get_session_by_any_id(key_a, &sid_a).await.is_some(),
2185 "owner bucket resolves its own session"
2186 );
2187 assert!(
2188 state.get_session_by_any_id(key_b, &sid_a).await.is_none(),
2189 "cross-agent session access must be 404/None"
2190 );
2191
2192 if let Some(home) = state.get_user_home_dir(key_a) {
2194 assert_eq!(
2195 home.file_name().and_then(|n| n.to_str()),
2196 Some(trustee_core::user_hash(key_a)).as_deref()
2197 );
2198 }
2199 }
2200}
2201
2202#[cfg(test)]
2207mod mcp_loader_cache_tests {
2208 use super::*;
2209
2210 fn state_with_shared(shared: &str) -> ServerState {
2211 let (session, _rx) = Session::new();
2212 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2213 let mut state = ServerState::new(session, ws_tx, None);
2214 state.config_toml = Some(shared.to_string());
2215 state
2216 }
2217
2218 struct TempUser {
2222 key: String,
2223 home: std::path::PathBuf,
2224 }
2225
2226 impl TempUser {
2227 fn new(tag: &str) -> Self {
2228 let key = format!("16c-{tag}-{}@test.invalid", std::process::id());
2229 let home = dirs::home_dir()
2230 .expect("HOME available in test env")
2231 .join(".trustee")
2232 .join("users")
2233 .join(trustee_core::user_hash(&key));
2234 std::fs::create_dir_all(home.join("config")).expect("create user home");
2235 Self { key, home }
2236 }
2237
2238 fn write_overlay(&self, toml_str: &str) {
2239 std::fs::write(self.home.join("config").join("trustee.toml"), toml_str)
2240 .expect("write overlay");
2241 }
2242 }
2243
2244 impl Drop for TempUser {
2245 fn drop(&mut self) {
2246 let _ = std::fs::remove_dir_all(&self.home);
2247 }
2248 }
2249
2250 #[tokio::test]
2251 async fn no_mcp_config_caches_disabled_marker() {
2252 let state = state_with_shared("[server]\nport = 8080\n");
2253 let user = TempUser::new("nomcp");
2254 let ts = Arc::new(pep::MemoryTokenStore::new());
2255
2256 let first = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2257 assert!(first.is_none(), "no [mcp] anywhere → disabled marker");
2258 assert_eq!(state.mcp_loaders.len(), 1, "exactly one cache entry");
2259
2260 let second = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2262 assert!(second.is_none());
2263 assert_eq!(state.mcp_loaders.len(), 1);
2264 }
2265
2266 #[tokio::test]
2267 async fn concurrent_cold_builds_single_flight() {
2268 let state = Arc::new(state_with_shared("[server]\nport = 8080\n"));
2269 let user = TempUser::new("singleflight");
2270 let ts = Arc::new(pep::MemoryTokenStore::new());
2271
2272 let mut handles = Vec::new();
2273 for _ in 0..5 {
2274 let state = state.clone();
2275 let key = user.key.clone();
2276 let ts = ts.clone();
2277 handles.push(tokio::spawn(async move {
2278 state.get_or_build_mcp_loader(&key, &ts).await
2279 }));
2280 }
2281 for h in handles {
2282 h.await.unwrap().expect("all five succeed");
2283 }
2284 assert_eq!(state.mcp_loaders.len(), 1, "single-flight → one entry");
2285 }
2286
2287 #[tokio::test]
2288 async fn fingerprint_change_triggers_rebuild() {
2289 let state = state_with_shared("[server]\nport = 8080\n");
2290 let user = TempUser::new("fpchange");
2291 let ts = Arc::new(pep::MemoryTokenStore::new());
2292
2293 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2296 let v1 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2297 assert!(v1.is_some(), "enabled [mcp] → real loader");
2298 let fp1 = state
2299 .mcp_loaders
2300 .get(&trustee_core::user_hash(&user.key))
2301 .unwrap()
2302 .fingerprint;
2303
2304 std::thread::sleep(std::time::Duration::from_millis(5));
2306 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v2\"\nurl = \"http://127.0.0.1:9/other\"\n");
2307 let v2 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2308 assert!(v2.is_some());
2309 let entry = state
2310 .mcp_loaders
2311 .get(&trustee_core::user_hash(&user.key))
2312 .unwrap();
2313 assert_ne!(
2314 entry.fingerprint, fp1,
2315 "fingerprint must change with content"
2316 );
2317 assert!(entry.degraded.is_none());
2318
2319 let _still_usable = v1.as_ref().unwrap().tool_count;
2321 }
2322
2323 #[tokio::test]
2324 async fn degraded_entry_fails_loud_within_backoff_and_isolates_users() {
2325 let state = state_with_shared("[server]\nport = 8080\n");
2326 let bad = TempUser::new("degraded-bad");
2327 let good = TempUser::new("degraded-good");
2328 let ts = Arc::new(pep::MemoryTokenStore::new());
2329
2330 bad.write_overlay("[mcp]\nenabled = \"not-a-bool\"\n");
2332 let err = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2333 Ok(_) => panic!("invalid [mcp] must fail loud"),
2334 Err(e) => e,
2335 };
2336 assert!(
2337 err.contains("invalid [mcp]"),
2338 "surfaces the parse error: {err}"
2339 );
2340
2341 let entry = state
2342 .mcp_loaders
2343 .get(&trustee_core::user_hash(&bad.key))
2344 .unwrap();
2345 assert!(entry.degraded.is_some(), "poison entry recorded");
2346
2347 let err2 = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2349 Ok(_) => panic!("still within backoff"),
2350 Err(e) => e,
2351 };
2352 assert_eq!(err, err2, "same cached error");
2353
2354 let good_loader = state
2356 .get_or_build_mcp_loader(&good.key, &ts)
2357 .await
2358 .expect("other user unaffected");
2359 assert!(
2360 good_loader.is_none(),
2361 "good user has no [mcp] → disabled marker"
2362 );
2363 }
2364
2365 #[tokio::test]
2366 async fn aged_out_entry_rebuilds_without_restart() {
2367 let state = state_with_shared("[server]\nport = 8080\n");
2372 let user = TempUser::new("agedout");
2373 let ts = Arc::new(pep::MemoryTokenStore::new());
2374
2375 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2376 let v1 = state
2377 .get_or_build_mcp_loader(&user.key, &ts)
2378 .await
2379 .expect("initial build");
2380 let v1 = v1.expect("enabled [mcp] → loader");
2381
2382 {
2385 let mut entry = state
2386 .mcp_loaders
2387 .get_mut(&trustee_core::user_hash(&user.key))
2388 .unwrap();
2389 entry.built_at = chrono::Utc::now() - chrono::Duration::seconds(601);
2390 }
2391
2392 let v2 = state
2393 .get_or_build_mcp_loader(&user.key, &ts)
2394 .await
2395 .expect("aged-out dispatch rebuilds");
2396 let v2 = v2.expect("rebuilt loader present");
2397 assert!(
2398 !Arc::ptr_eq(&v1, &v2),
2399 "aged-out entry must NOT be reused — fresh connect re-lists the served surface"
2400 );
2401 let entry = state
2402 .mcp_loaders
2403 .get(&trustee_core::user_hash(&user.key))
2404 .unwrap();
2405 assert!(
2406 chrono::Utc::now() - entry.built_at < chrono::Duration::seconds(5),
2407 "built_at refreshed by the rebuild"
2408 );
2409
2410 let v3 = state
2412 .get_or_build_mcp_loader(&user.key, &ts)
2413 .await
2414 .expect("fresh dispatch");
2415 assert!(Arc::ptr_eq(&v2, &v3.unwrap()), "young entry hits the cache");
2416 }
2417}
2418
2419#[cfg(test)]
2420mod restore_tests {
2421 use super::*;
2422
2423 fn fresh_state() -> ServerState {
2424 let (session, _rx) = Session::new();
2425 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2426 let mut state = ServerState::new(session, ws_tx, None);
2427 state.config_toml = Some("[agent]\nname = \"trustee\"\n".to_string());
2428 state
2429 }
2430
2431 struct TempUser {
2433 key: String,
2434 }
2435
2436 impl TempUser {
2437 fn new(tag: &str) -> Self {
2438 let key = format!("restore-{tag}-{}@test.invalid", std::process::id());
2439 let home = dirs::home_dir()
2440 .expect("HOME available in test env")
2441 .join(".trustee")
2442 .join("users")
2443 .join(trustee_core::user_hash(&key));
2444 std::fs::create_dir_all(home).expect("create user home");
2445 Self { key }
2446 }
2447 }
2448
2449 #[tokio::test]
2450 async fn restore_unknown_chain_returns_none() {
2451 let state = fresh_state();
2452 let user = TempUser::new("unknown");
2453 let out = state
2454 .get_or_restore_session_by_any_id(&user.key, "session_2099_01_01_00_00_deadbeef")
2455 .await;
2456 assert!(out.is_none(), "unknown chain must 404, not restore");
2457 }
2458
2459 #[tokio::test]
2460 async fn destroy_tombstones_chain_id() {
2461 let state = fresh_state();
2462 let user = TempUser::new("tombstone");
2463 let sid = state
2464 .create_session(&user.key, None, None)
2465 .await
2466 .expect("create session");
2467
2468 let (session, _ws) = state.get_session(&user.key, &sid).await.unwrap();
2470 {
2471 let mut s = session.lock().await;
2472 s.session_id = Some("session_2026_09_04_10_00_aaaabbbb".to_string());
2473 }
2474
2475 state
2476 .destroy_session(&user.key, &sid)
2477 .await
2478 .expect("destroy");
2479
2480 let path = state
2481 .tombstone_path(&user.key, "session_2026_09_04_10_00_aaaabbbb")
2482 .expect("tombstone path");
2483 assert!(path.exists(), "destroy must write a chain tombstone");
2484
2485 let sid2 = state
2487 .create_session(&user.key, None, None)
2488 .await
2489 .expect("create second session");
2490 state
2491 .destroy_session(&user.key, &sid2)
2492 .await
2493 .expect("destroy 2");
2494 }
2496
2497 #[tokio::test]
2498 async fn restore_refuses_registry_key_shapes() {
2499 let state = fresh_state();
2503 let user = TempUser::new("shapes");
2504 assert!(state.tombstone_path(&user.key, "default").is_none());
2505 let out = state
2506 .get_or_restore_session_by_any_id(&user.key, "default")
2507 .await;
2508 assert!(out.is_none());
2509 }
2510}
2511
2512#[test]
2513fn collect_unresolved_placeholders_walks_the_tree() {
2514 let section: toml::Value = toml::from_str(
2515 r#"
2516[credentials.farzan_service_account]
2517service_token = "${FARZAN_SERVICE_ACCOUNT}"
2518issuer_url = "https://ok.example"
2519
2520[[servers]]
2521name = "fame"
2522url = "https://fame.example"
2523credentials = "farzan_service_account"
2524
2525[[servers]]
2526name = "other"
2527url = "https://other.example"
2528token = "${OTHER_TOKEN}"
2529"#,
2530 )
2531 .unwrap();
2532 let mut out = Vec::new();
2533 collect_unresolved_placeholders(§ion, &mut out);
2534 out.sort();
2535 assert_eq!(
2536 out,
2537 vec![
2538 "${FARZAN_SERVICE_ACCOUNT}".to_string(),
2539 "${OTHER_TOKEN}".to_string()
2540 ],
2541 "every leftover placeholder in the [mcp] tree must be collected, deduped"
2542 );
2543 let clean: toml::Value = toml::from_str("[x]\ny = \"z\"\n").unwrap();
2545 let mut out2 = Vec::new();
2546 collect_unresolved_placeholders(&clean, &mut out2);
2547 assert!(out2.is_empty());
2548}
2549
2550#[test]
2551fn substitute_env_vars_resolves_and_keeps_leftovers_deterministically() {
2552 let mut secrets = std::collections::HashMap::new();
2553 secrets.insert("A_TOKEN".to_string(), "resolved-value".to_string());
2554 let mut s = "x = \"${A_TOKEN}\" y = \"${MISSING_TOKEN}\"".to_string();
2555 let report = substitute_env_vars(&mut s, &secrets, "test");
2556 assert!(s.contains("resolved-value"), "declared key resolves: {s}");
2557 assert!(
2558 s.contains("${MISSING_TOKEN}"),
2559 "undeclared key keeps the literal (the loader guard turns this into a loud failure): {s}"
2560 );
2561 assert_eq!(report.len(), 2, "one entry per distinct var: {report:?}");
2563 assert!(report.contains(&EnvVarResolution {
2564 name: "A_TOKEN".to_string(),
2565 source: SecretSource::SecretsMap
2566 }));
2567 assert!(report.contains(&EnvVarResolution {
2568 name: "MISSING_TOKEN".to_string(),
2569 source: SecretSource::Unresolved
2570 }));
2571}
2572
2573#[test]
2574fn substitute_env_vars_reports_process_env_shadow() {
2575 let var = format!("RAVAND_PROVENANCE_TEST_{}", std::process::id());
2580 std::env::set_var(&var, "global-env-answer");
2581
2582 let mut secrets = std::collections::HashMap::new();
2584 let mut s = format!("x = \"${{{var}}}\"");
2585 let report = substitute_env_vars(&mut s, &secrets, "test-shadow");
2586 assert!(s.contains("global-env-answer"), "process env answered: {s}");
2587 assert_eq!(
2588 report,
2589 vec![EnvVarResolution {
2590 name: var.clone(),
2591 source: SecretSource::ProcessEnv
2592 }]
2593 );
2594
2595 secrets.insert(var.clone(), "per-user-answer".to_string());
2598 let mut s2 = format!("x = \"${{{var}}}\"");
2599 let report2 = substitute_env_vars(&mut s2, &secrets, "test-wins");
2600 assert!(s2.contains("per-user-answer"), "user secrets win: {s2}");
2601 assert_eq!(
2602 report2,
2603 vec![EnvVarResolution {
2604 name: var.clone(),
2605 source: SecretSource::SecretsMap
2606 }]
2607 );
2608
2609 std::env::remove_var(&var);
2610}
2611
2612#[test]
2613fn materialized_user_missing_env_only_fires_on_dir_without_env() {
2614 let base = std::env::temp_dir().join(format!("trustee-envcheck-{}", std::process::id()));
2616 let _ = std::fs::remove_dir_all(&base);
2617
2618 assert!(!materialized_user_missing_env(&base.join("ghost")));
2620
2621 std::fs::create_dir_all(&base).unwrap();
2623 assert!(materialized_user_missing_env(&base));
2624
2625 std::fs::write(base.join(".env"), "K=V\n").unwrap();
2627 assert!(!materialized_user_missing_env(&base));
2628
2629 let _ = std::fs::remove_dir_all(&base);
2630}
2631
2632#[test]
2633fn loader_freshness_decides_all_four_states() {
2634 let now = chrono::Utc::now();
2635 let fp = 0xABCD_u64;
2636 let young = now - chrono::Duration::seconds(60);
2637 let old = now - chrono::Duration::seconds(601);
2638
2639 assert_eq!(
2641 loader_freshness(fp, young, None, None, fp, now),
2642 LoaderFreshness::Fresh
2643 );
2644 assert_eq!(
2646 loader_freshness(fp, old, None, None, 0x1234, now),
2647 LoaderFreshness::ConfigChanged
2648 );
2649 assert_eq!(
2651 loader_freshness(fp, old, None, None, fp, now),
2652 LoaderFreshness::AgedOut
2653 );
2654 let failed = now - chrono::Duration::seconds(10);
2656 match loader_freshness(fp, old, Some("boom"), Some(failed), fp, now) {
2657 LoaderFreshness::Backoff(remaining) => {
2658 assert!(remaining.as_secs() >= 15, "~20s left: {remaining:?}");
2659 }
2660 other => panic!("expected Backoff, got {other:?}"),
2661 }
2662 let failed_long = now - chrono::Duration::seconds(120);
2664 assert_eq!(
2665 loader_freshness(fp, old, Some("boom"), Some(failed_long), fp, now),
2666 LoaderFreshness::AgedOut
2667 );
2668}