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