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>(
50 user_key: &str,
51 fut: F,
52) -> F::Output {
53 USER_KEY_OVERRIDE.scope(user_key.to_string(), fut).await
54}
55
56pub fn apply_home_links() -> Vec<String> {
66 let Some(home) = dirs::home_dir() else {
67 return Vec::new();
68 };
69 let users = home.join(".trustee").join("users");
70 let map_path = users.join("home_links.json");
71 let Ok(text) = std::fs::read_to_string(&map_path) else {
72 return Vec::new();
73 };
74 let Ok(doc) = serde_json::from_str::<serde_json::Value>(&text) else {
75 tracing::warn!(
76 "home_links.json is not valid JSON — ignored: {}",
77 map_path.display()
78 );
79 return Vec::new();
80 };
81 let mut report = Vec::new();
82 if let Some(links) = doc.get("links").and_then(|v| v.as_array()) {
83 for l in links {
84 let (Some(from), Some(to)) = (
85 l.get("from_hash").and_then(|v| v.as_str()),
86 l.get("to_hash").and_then(|v| v.as_str()),
87 ) else {
88 continue;
89 };
90 let from_dir = users.join(from);
91 let to_dir = users.join(to);
92 if !from_dir.exists() {
93 continue; }
95 if to_dir.exists() {
96 let empty = std::fs::read_dir(&to_dir)
97 .map(|it| it.count() == 0)
98 .unwrap_or(false);
99 if !empty {
100 let msg = format!(
101 "home link {from} → {to}: target exists with content — NOT touched, reconcile manually"
102 );
103 tracing::warn!("home_links: {msg}");
104 report.push(msg);
105 continue;
106 }
107 let _ = std::fs::remove_dir(&to_dir);
108 }
109 match std::fs::rename(&from_dir, &to_dir) {
110 Ok(()) => {
111 let msg = format!(
112 "home link applied: {from} → {to} (config/sessions restored)"
113 );
114 tracing::info!("home_links: {msg}");
115 report.push(msg);
116 }
117 Err(e) => {
118 let msg = format!("home link {from} → {to} FAILED: {e}");
119 tracing::warn!("home_links: {msg}");
120 report.push(msg);
121 }
122 }
123 }
124 }
125 report
126}
127
128pub struct UserSessionEntry {
134 pub session: Arc<Mutex<Session>>,
136 pub ws_tx: broadcast::Sender<String>,
138 pub created_at: chrono::DateTime<chrono::Utc>,
140 pub last_active: Arc<Mutex<chrono::DateTime<chrono::Utc>>>,
143}
144
145pub struct UserSessions {
147 pub sessions: DashMap<String, UserSessionEntry>,
149 pub token_store: Arc<pep::MemoryTokenStore>,
151 pub active_session_id: Mutex<String>,
153}
154
155#[derive(Debug, serde::Serialize)]
157pub struct SessionListItem {
158 pub session_id: String,
159 pub session_name: Option<String>,
160 pub workflow_state: String,
161 pub created_at: String,
162 pub last_active: String,
163 pub handoff_count: u32,
168}
169
170#[derive(Debug)]
172pub enum SessionError {
173 MaxSessionsReached(usize),
175 NotFound(String),
177 NotIdle(String),
179}
180
181impl std::fmt::Display for SessionError {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 match self {
184 SessionError::MaxSessionsReached(n) => {
185 write!(f, "Maximum {} sessions per user reached", n)
186 }
187 SessionError::NotFound(id) => write!(f, "Session {} not found", id),
188 SessionError::NotIdle(state) => write!(f, "Session is not idle (state: {})", state),
189 }
190 }
191}
192
193impl std::error::Error for SessionError {}
194
195pub type SessionRegistry = Arc<DashMap<String, UserSessions>>;
197
198pub struct McpLoaderEntry {
209 pub loader: Option<std::sync::Arc<abk::agent::McpToolLoader>>,
211 pub fingerprint: u64,
214 pub built_at: chrono::DateTime<chrono::Utc>,
215 pub degraded: Option<String>,
218 pub failed_at: Option<chrono::DateTime<chrono::Utc>>,
221}
222
223pub const MCP_BUILD_RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
225
226pub const MCP_TOOLSET_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(600);
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub(crate) enum LoaderFreshness {
240 Fresh,
242 ConfigChanged,
244 AgedOut,
248 Backoff(std::time::Duration),
250}
251
252pub(crate) fn loader_freshness(
255 entry_fingerprint: u64,
256 entry_built_at: chrono::DateTime<chrono::Utc>,
257 entry_degraded: Option<&str>,
258 entry_failed_at: Option<chrono::DateTime<chrono::Utc>>,
259 wanted_fingerprint: u64,
260 now: chrono::DateTime<chrono::Utc>,
261) -> LoaderFreshness {
262 if entry_degraded.is_some() {
265 if let Some(failed_at) = entry_failed_at {
266 let backoff = chrono::Duration::from_std(MCP_BUILD_RETRY_BACKOFF)
267 .unwrap_or_else(|_| chrono::Duration::seconds(30));
268 if now < failed_at + backoff {
269 return LoaderFreshness::Backoff(
270 (failed_at + backoff - now)
271 .to_std()
272 .unwrap_or_default(),
273 );
274 }
275 }
276 }
277 if wanted_fingerprint != entry_fingerprint {
278 return LoaderFreshness::ConfigChanged;
279 }
280 let max_age = chrono::Duration::from_std(MCP_TOOLSET_MAX_AGE)
281 .unwrap_or_else(|_| chrono::Duration::seconds(600));
282 if now > entry_built_at + max_age {
283 LoaderFreshness::AgedOut
284 } else {
285 LoaderFreshness::Fresh
286 }
287}
288
289#[derive(Debug, Clone)]
296pub struct ThqDispatchEntry {
297 pub user_key: String,
302 pub service_token: Option<String>,
306 pub issuer_url: Option<String>,
312}
313
314pub fn service_issuers_from_config(config_toml: &str) -> Vec<String> {
318 let Ok(v) = config_toml.parse::<toml::Value>() else {
319 return Vec::new();
320 };
321 let Some(creds) = v.get("mcp").and_then(|m| m.get("credentials")).and_then(|c| c.as_table())
322 else {
323 return Vec::new();
324 };
325 let mut out = Vec::new();
326 for (_name, cred) in creds {
327 if cred.get("type").and_then(|t| t.as_str()) == Some("service-account") {
328 if let Some(issuer) = cred.get("issuer_url").and_then(|i| i.as_str()) {
329 if !out.iter().any(|e: &String| e == issuer) {
330 out.push(issuer.to_string());
331 }
332 }
333 }
334 }
335 out
336}
337
338#[derive(Clone)]
340pub struct ServerState {
341 pub sessions: SessionRegistry,
343 pub ws_tx: broadcast::Sender<String>,
345 pub auth: Option<Arc<AuthState>>,
347 pub config_toml: Option<String>,
349 pub secrets: Option<std::collections::HashMap<String, String>>,
351 pub build_info: Option<trustee_core::types::BuildInfo>,
353 pub workflow_semaphore: Arc<tokio::sync::Semaphore>,
356 pub max_sessions_per_user: usize,
358 pub allow_llm_overlay: bool,
361 pub mcp_loaders: Arc<DashMap<String, McpLoaderEntry>>,
363 mcp_build_locks: Arc<DashMap<String, Arc<tokio::sync::Mutex<()>>>>,
365 pub thq_dispatch: Arc<DashMap<String, ThqDispatchEntry>>,
367 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 active_session_id: Mutex::new(String::new()),
401 };
402 user_sessions.sessions.insert("default".to_string(), initial_entry);
403
404 sessions.insert("default".to_string(), user_sessions);
405
406 Self {
407 sessions,
408 ws_tx,
409 auth,
410 config_toml: None,
411 secrets: None,
412 build_info: None,
413 workflow_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
414 max_sessions_per_user: 4,
415 allow_llm_overlay: false,
416 mcp_loaders: Arc::new(DashMap::new()),
417 mcp_build_locks: Arc::new(DashMap::new()),
418 thq_dispatch: Arc::new(DashMap::new()),
419 agent_dispatch_tokens: Arc::new(DashMap::new()),
420 }
421 }
422
423 pub fn with_config_toml(mut self, config_toml: String) -> Self {
424 self.config_toml = Some(config_toml);
425 self
426 }
427
428 pub fn service_issuers(&self) -> Vec<String> {
439 service_issuers_from_config(self.config_toml.as_deref().unwrap_or(""))
440 }
441
442 pub fn with_secrets(mut self, secrets: std::collections::HashMap<String, String>) -> Self {
443 self.secrets = Some(secrets);
444 self
445 }
446
447 pub fn with_build_info(mut self, build_info: trustee_core::types::BuildInfo) -> Self {
448 self.build_info = Some(build_info);
449 self
450 }
451
452 pub fn with_max_concurrent_workflows(mut self, max: usize) -> Self {
453 self.workflow_semaphore = Arc::new(tokio::sync::Semaphore::new(max));
454 self
455 }
456
457 pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
459 self.max_sessions_per_user = max;
460 self
461 }
462
463 pub fn with_allow_llm_overlay(mut self, allow: bool) -> Self {
466 self.allow_llm_overlay = allow;
467 self
468 }
469
470 pub async fn create_session(
480 &self,
481 user_key: &str,
482 session_name: Option<String>,
483 identity: Option<String>,
484 activate: bool,
485 ) -> Result<String, SessionError> {
486 let user_sessions = self
488 .sessions
489 .entry(user_key.to_string())
490 .or_insert_with(|| UserSessions {
491 sessions: DashMap::new(),
492 token_store: Arc::new(pep::MemoryTokenStore::new()),
493 active_session_id: Mutex::new(String::new()),
494 });
495
496 if user_sessions.sessions.len() >= self.max_sessions_per_user {
498 return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
499 }
500
501 let (mut session, workflow_rx) = Session::new();
503
504 if let Some(ref config_toml) = self.config_toml {
506 session.config_toml = Some(config_toml.clone());
507 session.parse_auto_handoff_config();
508 if let Ok(table) = config_toml.parse::<toml::Value>() {
509 if let Some(name) = table
510 .get("agent")
511 .and_then(|a| a.get("name"))
512 .and_then(|n| n.as_str())
513 {
514 session.agent_name = name.to_string();
515 }
516 }
517 }
518
519 session.secrets = self.secrets.clone();
520 session.build_info = self.build_info.clone();
521
522 self.apply_user_isolation(&mut session, user_key);
524
525 session.session_name = session_name;
527
528 session.identity = identity;
530
531 let (ws_tx_entry, _) = broadcast::channel::<String>(256);
533
534 let session_id = format!(
536 "session_{}_{}",
537 chrono::Utc::now().format("%Y_%m_%d_%H_%M"),
538 &uuid::Uuid::new_v4().to_string()[..8]
539 );
540
541 let now = chrono::Utc::now();
542
543 user_sessions.sessions.insert(
545 session_id.clone(),
546 UserSessionEntry {
547 session: Arc::new(Mutex::new(session)),
548 ws_tx: ws_tx_entry.clone(),
549 created_at: now,
550 last_active: Arc::new(Mutex::new(now)),
551 },
552 );
553
554 if activate {
563 *user_sessions.active_session_id.lock().await = session_id.clone();
564 }
565
566 let session_arc = user_sessions
568 .sessions
569 .get(&session_id)
570 .map(|e| e.session.clone());
571 if let Some(session_arc) = session_arc {
572 self.spawn_user_drain_task(
573 session_id.clone(),
574 session_arc,
575 ws_tx_entry,
576 workflow_rx,
577 );
578 }
579
580 Ok(session_id)
581 }
582
583 pub async fn get_session(
586 &self,
587 user_key: &str,
588 session_id: &str,
589 ) -> Option<(Arc<Mutex<Session>>, broadcast::Sender<String>)> {
590 let user_sessions = self.sessions.get(user_key)?;
591 let entry = user_sessions.sessions.get(session_id)?;
592
593 let now = chrono::Utc::now();
595 *entry.last_active.lock().await = now;
596
597 Some((entry.session.clone(), entry.ws_tx.clone()))
598 }
599
600 pub async fn get_session_by_any_id(
618 &self,
619 user_key: &str,
620 id: &str,
621 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
622 let user_sessions = self.sessions.get(user_key)?;
624 if let Some(entry) = user_sessions.sessions.get(id) {
625 let now = chrono::Utc::now();
627 *entry.last_active.lock().await = now;
628 return Some((id.to_string(), entry.session.clone(), entry.ws_tx.clone()));
629 }
630
631 for entry in user_sessions.sessions.iter() {
633 let session = entry.session.lock().await;
634 if session.session_id.as_deref() == Some(id) {
635 let key = entry.key().clone();
636 let ws_tx = entry.ws_tx.clone();
637 drop(session);
638 let now = chrono::Utc::now();
640 *entry.last_active.lock().await = now;
641 return Some((key, entry.session.clone(), ws_tx));
642 }
643 }
644
645 None
646 }
647
648 fn tombstone_path(&self, user_key: &str, chain_id: &str) -> Option<std::path::PathBuf> {
657 if chain_id.is_empty() || !chain_id.starts_with("session_") {
658 return None;
659 }
660 let user_key = effective_user_key(user_key);
661 let hash = trustee_core::user_hash(&user_key);
662 dirs::home_dir().map(|home| {
663 home.join(".trustee")
664 .join("users")
665 .join(&hash)
666 .join("session_tombstones")
667 .join(chain_id)
668 })
669 }
670
671 fn write_tombstone(&self, user_key: &str, chain_id: &str) {
676 if let Some(path) = self.tombstone_path(user_key, chain_id) {
677 if let Some(parent) = path.parent() {
678 let _ = std::fs::create_dir_all(parent);
679 }
680 let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
681 }
682 }
683
684 pub async fn get_or_restore_session_by_any_id(
698 &self,
699 user_key: &str,
700 id: &str,
701 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
702 if let Some(found) = self.get_session_by_any_id(user_key, id).await {
703 return Some(found);
704 }
705 self.restore_session_from_checkpoint(user_key, id).await
706 }
707
708 async fn restore_session_from_checkpoint(
717 &self,
718 user_key: &str,
719 chain_id: &str,
720 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
721 if let Some(path) = self.tombstone_path(user_key, chain_id) {
723 if path.exists() {
724 return None;
725 }
726 }
727
728 let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
730 let config_toml = config_toml?;
731
732 let info = trustee_core::sessions::create_resume_info(
735 &config_toml,
736 chain_id,
737 home_dir.as_deref(),
738 )
739 .await
740 .ok()??;
741
742 let user_sessions = self
745 .sessions
746 .entry(user_key.to_string())
747 .or_insert_with(|| UserSessions {
748 sessions: DashMap::new(),
749 token_store: Arc::new(pep::MemoryTokenStore::new()),
750 active_session_id: Mutex::new(String::new()),
751 });
752
753 if let Some(existing) = user_sessions.sessions.get(chain_id) {
754 let now = chrono::Utc::now();
755 *existing.last_active.lock().await = now;
756 return Some((
757 chain_id.to_string(),
758 existing.session.clone(),
759 existing.ws_tx.clone(),
760 ));
761 }
762
763 if user_sessions.sessions.len() >= self.max_sessions_per_user {
768 let mut evict: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
769 for entry in user_sessions.sessions.iter() {
770 let is_idle = entry.session.lock().await.workflow_state
771 == trustee_core::types::WorkflowState::Idle;
772 if !is_idle {
773 continue;
774 }
775 let la = entry.last_active.lock().await;
776 if evict.as_ref().is_none_or(|(_, t)| *la < *t) {
777 evict = Some((entry.key().clone(), *la));
778 }
779 }
780 if let Some((victim, _)) = evict {
781 user_sessions.sessions.remove(&victim);
782 } else {
783 return None;
784 }
785 }
786
787 let (mut session, workflow_rx) = Session::new();
790 session.config_toml = Some(config_toml.clone());
791 session.parse_auto_handoff_config();
792 if let Ok(table) = config_toml.parse::<toml::Value>() {
793 if let Some(name) = table
794 .get("agent")
795 .and_then(|a| a.get("name"))
796 .and_then(|n| n.as_str())
797 {
798 session.agent_name = name.to_string();
799 }
800 }
801 session.secrets = self.secrets.clone();
802 session.build_info = self.build_info.clone();
803 self.apply_user_isolation(&mut session, user_key);
804 session.session_id = Some(chain_id.to_string());
805 session.resume_info = Some(info);
806 session.session_name = None; let (ws_tx_entry, _) = broadcast::channel::<String>(256);
809 let now = chrono::Utc::now();
810
811 user_sessions.sessions.insert(
812 chain_id.to_string(),
813 UserSessionEntry {
814 session: Arc::new(Mutex::new(session)),
815 ws_tx: ws_tx_entry.clone(),
816 created_at: now,
817 last_active: Arc::new(Mutex::new(now)),
818 },
819 );
820
821 let session_arc = user_sessions
825 .sessions
826 .get(chain_id)
827 .map(|e| e.session.clone())
828 .ok_or_else(|| SessionError::NotFound(chain_id.to_string()))
829 .ok()?;
830 self.spawn_user_drain_task(
831 chain_id.to_string(),
832 session_arc.clone(),
833 ws_tx_entry.clone(),
834 workflow_rx,
835 );
836
837 Some((chain_id.to_string(), session_arc, ws_tx_entry))
838 }
839
840 pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> { let Some(user_sessions) = self.sessions.get(user_key) else {
842 return Vec::new();
843 };
844
845 let mut items = Vec::new();
846 for entry in user_sessions.sessions.iter() {
847 let session = entry.session.lock().await;
848 let workflow_state = match session.workflow_state {
849 trustee_core::types::WorkflowState::Idle => "Idle",
850 trustee_core::types::WorkflowState::Running => "Running",
851 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
852 };
853 let last_active = entry.last_active.lock().await;
854 items.push(SessionListItem {
855 session_id: entry.key().clone(),
856 session_name: session.session_name.clone(),
857 workflow_state: workflow_state.to_string(),
858 created_at: entry.created_at.to_rfc3339(),
859 last_active: last_active.to_rfc3339(),
860 handoff_count: session.handoff_count,
861 });
862 }
863 drop(user_sessions);
864
865 items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
867 items
868 }
869
870 pub async fn destroy_session(
872 &self,
873 user_key: &str,
874 session_id: &str,
875 ) -> Result<(), SessionError> {
876 let user_sessions = self
877 .sessions
878 .get(user_key)
879 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
880
881 let chain_id_for_tombstone = {
884 let entry = user_sessions
885 .sessions
886 .get(session_id)
887 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
888 let session = entry.session.lock().await;
889 if session.workflow_state != trustee_core::types::WorkflowState::Idle {
890 let state_str = match session.workflow_state {
891 trustee_core::types::WorkflowState::Running => "Running",
892 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
893 _ => "Unknown",
894 };
895 return Err(SessionError::NotIdle(state_str.to_string()));
896 }
897 session.session_id.clone()
898 };
899
900 user_sessions.sessions.remove(session_id);
902
903 if let Some(chain_id) = chain_id_for_tombstone {
907 self.write_tombstone(user_key, &chain_id);
908 }
909
910 let mut active_id = user_sessions.active_session_id.lock().await;
912 if &*active_id == session_id {
913 let mut newest: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
915 for entry in user_sessions.sessions.iter() {
916 let la = entry.last_active.lock().await;
917 if newest.as_ref().map_or(true, |(_, t)| *la > *t) {
918 newest = Some((entry.key().clone(), *la));
919 }
920 }
921 *active_id = newest.map(|(id, _)| id).unwrap_or_default();
922 }
923
924 Ok(())
925 }
926
927 pub async fn ensure_active_session(
936 &self,
937 user_key: &str,
938 ) -> (
939 String,
940 Arc<Mutex<Session>>,
941 broadcast::Sender<String>,
942 Arc<pep::MemoryTokenStore>,
943 ) {
944 let token_store = {
946 let user_sessions = self
947 .sessions
948 .entry(user_key.to_string())
949 .or_insert_with(|| UserSessions {
950 sessions: DashMap::new(),
951 token_store: Arc::new(pep::MemoryTokenStore::new()),
952 active_session_id: Mutex::new(String::new()),
953 });
954 user_sessions.token_store.clone()
955 };
956
957 let active_id = {
959 let user_sessions = self.sessions.get(user_key).unwrap();
960 let guard = user_sessions.active_session_id.lock().await;
961 guard.clone()
962 };
963
964 if !active_id.is_empty() {
965 if let Some((session, ws_tx)) = self.get_session(user_key, &active_id).await {
966 return (active_id, session, ws_tx, token_store);
967 }
968 }
970
971 let existing_session: Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> = {
975 let user_sessions = self.sessions.get(user_key).unwrap();
976 let result = user_sessions.sessions.iter().next().map(|first| {
977 (
978 first.key().clone(),
979 first.session.clone(),
980 first.ws_tx.clone(),
981 )
982 });
983 result
984 };
985 if let Some((id, session, ws_tx)) = existing_session {
986 let now = chrono::Utc::now();
987 if let Some(entry) = self.sessions.get(user_key) {
988 if let Some(e) = entry.sessions.get(&id) {
989 *e.last_active.lock().await = now;
990 }
991 *entry.active_session_id.lock().await = id.clone();
992 }
993
994 return (id, session, ws_tx, token_store);
995 }
996
997 let session_id = self
999 .create_session(user_key, None, None, true)
1000 .await
1001 .unwrap_or_else(|_| "default".to_string());
1002
1003 let (session, ws_tx) = self
1004 .get_session(user_key, &session_id)
1005 .await
1006 .expect("just-created session must exist");
1007
1008 (session_id, session, ws_tx, token_store)
1009 }
1010
1011 pub async fn ensure_user_session(
1014 &self,
1015 user_key: &str,
1016 ) -> (Arc<Mutex<Session>>, broadcast::Sender<String>, Arc<pep::MemoryTokenStore>) {
1017 let (_id, session, ws_tx, token_store) = self.ensure_active_session(user_key).await;
1018 (session, ws_tx, token_store)
1019 }
1020
1021 pub async fn set_active_session(&self, user_key: &str, session_id: &str) {
1023 if let Some(user_sessions) = self.sessions.get(user_key) {
1024 if user_sessions.sessions.contains_key(session_id) {
1025 *user_sessions.active_session_id.lock().await = session_id.to_string();
1026 }
1027 }
1028 }
1029
1030 pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
1041 let user_key = effective_user_key(user_key);
1042 let hash = trustee_core::user_hash(&user_key);
1043 dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&hash))
1044 }
1045
1046 pub fn get_user_config_and_home(&self, user_key: &str) -> (Option<String>, Option<std::path::PathBuf>) {
1051 (self.config_toml.clone(), self.get_user_home_dir(user_key))
1052 }
1053
1054 fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
1063 let user_key = effective_user_key(user_key);
1064 let user_hash = trustee_core::user_hash(&user_key);
1065
1066 let user_home = if let Some(home) = dirs::home_dir() {
1068 let user_home = home.join(".trustee").join("users").join(&user_hash);
1069 session.home_dir = Some(user_home.clone());
1070 Some(user_home)
1071 } else {
1072 None
1073 };
1074
1075 if let Some(ref user_home) = user_home {
1079 if materialized_user_missing_env(user_home) {
1080 tracing::warn!(
1081 "user namespace {} is materialized but has NO per-user .env — \
1082 ${{…}} references will fall through to process env \
1083 (global ~/.trustee/.env) and execute under the GLOBAL principal",
1084 &user_hash[..8.min(user_hash.len())]
1085 );
1086 }
1087 }
1088
1089 session.project_id = Some(format!("web{}", &user_hash[..16]));
1090
1091 let shared_secrets = session.secrets.clone().unwrap_or_default();
1098 let mut merged_secrets = shared_secrets.clone();
1099
1100 if let Some(ref user_home) = user_home {
1101 if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
1102 merged_secrets = merged;
1103 }
1104 }
1105
1106 if let Some(ref user_home) = user_home {
1108 if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref()) {
1109 session.config_toml = Some(merged);
1110 tracing::debug!("Merged per-user config into session");
1111 }
1112 }
1113
1114 if let Some(ref mut config_toml) = session.config_toml {
1116 substitute_env_vars(
1117 config_toml,
1118 &merged_secrets,
1119 &format!("user {}", &user_hash[..8.min(user_hash.len())]),
1120 );
1121 }
1122
1123 session.secrets = Some(shared_secrets);
1125 }
1126
1127 fn load_user_secrets(
1130 &self,
1131 user_home: &std::path::Path,
1132 base: &std::collections::HashMap<String, String>,
1133 ) -> std::io::Result<std::collections::HashMap<String, String>> {
1134 let user_env_path = user_home.join(".env");
1135 if !user_env_path.exists() {
1136 return Ok(base.clone());
1137 }
1138 let content = std::fs::read_to_string(&user_env_path)?;
1139 let mut merged = base.clone();
1140 for line in content.lines() {
1141 let line = line.trim();
1142 if line.is_empty() || line.starts_with('#') {
1143 continue;
1144 }
1145 if let Some((key, value)) = line.split_once('=') {
1146 let key = key.trim().to_string();
1147 let value = value
1148 .trim()
1149 .trim_matches('"')
1150 .trim_matches('\'')
1151 .to_string();
1152 merged.insert(key, value);
1153 }
1154 }
1155 tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
1156 Ok(merged)
1157 }
1158
1159 fn merge_user_config(
1174 &self,
1175 user_home: &std::path::Path,
1176 shared_config: Option<&str>,
1177 ) -> Option<String> {
1178 let user_config_path = user_home.join("config").join("trustee.toml");
1179 if !user_config_path.exists() {
1180 return None;
1181 }
1182 let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
1183 let shared = shared_config
1184 .unwrap_or("")
1185 .parse::<toml::Value>()
1186 .ok()?;
1187 let overlay = user_config_toml.parse::<toml::Value>().ok()?;
1188
1189 let allowed = |section: &str| {
1190 section == "mcp"
1191 || section == "thq"
1195 || section == "lifecycle"
1200 || (self.allow_llm_overlay && section == "llm")
1201 };
1202 let dir_name = user_home
1205 .file_name()
1206 .and_then(|n| n.to_str())
1207 .unwrap_or("<unknown>");
1208 let masked_user = dir_name.get(..8).unwrap_or(dir_name);
1209
1210 let mut filtered_overlay = toml::map::Map::new();
1211 if let Some(table) = overlay.as_table() {
1212 for (section, value) in table {
1213 if section == "lifecycle" {
1214 let mut v = value.clone();
1215 if let Some(t) = v.as_table_mut() {
1216 if t.remove("enabled").is_some() {
1217 tracing::info!(
1218 "user config overlay: [lifecycle].enabled is not user-settable — stripped for user {}",
1219 masked_user
1220 );
1221 }
1222 }
1223 filtered_overlay.insert(section.clone(), v);
1224 } else if allowed(section) {
1225 filtered_overlay.insert(section.clone(), value.clone());
1226 } else {
1227 tracing::warn!(
1228 "user config overlay: dropping non-allowlisted section [{}] for user {}",
1229 section,
1230 masked_user
1231 );
1232 }
1233 }
1234 }
1235
1236 if filtered_overlay.is_empty() {
1237 return None;
1239 }
1240 let overlay = toml::Value::Table(filtered_overlay);
1241
1242 let mut shared = shared;
1243 deep_merge_toml(&mut shared, &overlay);
1244 let merged = toml::to_string(&shared).ok()?;
1245 tracing::debug!("Merged per-user config from {}", user_config_path.display());
1246 Some(merged)
1247 }
1248
1249 pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1258 let config_toml = self.config_toml.clone()?;
1259
1260 let user_home = self.get_user_home_dir(user_key)?;
1262
1263 let mut merged_secrets = self.secrets.clone().unwrap_or_default();
1265 if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
1266 merged_secrets = merged;
1267 }
1268
1269 let mut resolved = config_toml;
1271 if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
1272 resolved = merged;
1273 }
1274
1275 substitute_env_vars(
1277 &mut resolved,
1278 &merged_secrets,
1279 &format!(
1280 "resolve_user_config user {}",
1281 &trustee_core::user_hash(user_key)[..8]
1282 ),
1283 );
1284
1285 Some(resolved)
1286 }
1287
1288 pub async fn get_or_build_mcp_loader(
1304 &self,
1305 user_key: &str,
1306 token_store: &Arc<pep::MemoryTokenStore>,
1307 ) -> Result<Option<std::sync::Arc<abk::agent::McpToolLoader>>, String> {
1308 let user_key = effective_user_key(user_key);
1309 let user_hash = trustee_core::user_hash(&user_key);
1310
1311 let resolved = self.resolve_user_config(&user_key);
1313 let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1314
1315 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1317 match loader_freshness(
1318 entry.fingerprint,
1319 entry.built_at,
1320 entry.degraded.as_deref(),
1321 entry.failed_at,
1322 fingerprint,
1323 chrono::Utc::now(),
1324 ) {
1325 LoaderFreshness::Fresh => return Ok(entry.loader.clone()),
1326 LoaderFreshness::ConfigChanged => {
1327 }
1329 LoaderFreshness::AgedOut => {
1330 tracing::info!(
1333 "MCP toolset for user {} is {}s old (max {}s) — rebuilding \
1334 to re-list the served surface (phantom-tool guard)",
1335 &user_hash[..8.min(user_hash.len())],
1336 (chrono::Utc::now() - entry.built_at).num_seconds(),
1337 MCP_TOOLSET_MAX_AGE.as_secs()
1338 );
1339 }
1340 LoaderFreshness::Backoff(remaining) => {
1341 let err = entry
1342 .degraded
1343 .clone()
1344 .unwrap_or_else(|| "previous MCP loader build failed; retry after backoff".to_string());
1345 let _ = remaining;
1346 return Err(err);
1347 }
1348 }
1349 }
1350
1351 let lock = self
1353 .mcp_build_locks
1354 .entry(user_hash.clone())
1355 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1356 .clone();
1357 let _guard = lock.lock().await;
1358
1359 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1361 if loader_freshness(
1362 entry.fingerprint,
1363 entry.built_at,
1364 entry.degraded.as_deref(),
1365 entry.failed_at,
1366 fingerprint,
1367 chrono::Utc::now(),
1368 ) == LoaderFreshness::Fresh
1369 {
1370 return Ok(entry.loader.clone());
1371 }
1372 }
1373
1374 match self
1375 .build_mcp_loader(&user_hash, resolved.as_deref(), fingerprint, token_store)
1376 .await
1377 {
1378 Ok(entry) => {
1379 self.mcp_loaders.insert(user_hash.clone(), entry);
1380 Ok(self.mcp_loaders.get(&user_hash).unwrap().loader.clone())
1381 }
1382 Err(err) => {
1383 tracing::warn!(
1384 "MCP loader build FAILED for user {}; dispatch fails loud, retry after {:?}",
1385 &user_hash[..8.min(user_hash.len())],
1386 MCP_BUILD_RETRY_BACKOFF
1387 );
1388 self.mcp_loaders.insert(
1389 user_hash,
1390 McpLoaderEntry {
1391 loader: None,
1392 fingerprint,
1393 built_at: chrono::Utc::now(),
1394 degraded: Some(err.clone()),
1395 failed_at: Some(chrono::Utc::now()),
1396 },
1397 );
1398 Err(err)
1399 }
1400 }
1401 }
1402
1403 async fn build_mcp_loader(
1406 &self,
1407 user_hash: &str,
1408 resolved: Option<&str>,
1409 fingerprint: u64,
1410 token_store: &Arc<pep::MemoryTokenStore>,
1411 ) -> Result<McpLoaderEntry, String> {
1412 let mcp_config: Option<abk::config::McpConfig> = match resolved {
1413 Some(toml_str) => {
1414 let value = toml_str
1415 .parse::<toml::Value>()
1416 .map_err(|e| format!("config parse failed: {}", e))?;
1417 match value.get("mcp") {
1418 Some(section) => {
1419 let mut leftovers: Vec<String> = Vec::new();
1426 collect_unresolved_placeholders(section, &mut leftovers);
1427 if !leftovers.is_empty() {
1428 return Err(format!(
1429 "unresolved {} placeholder(s) in [mcp]: {} — supply the \
1430 exact-case key in the profile secrets",
1431 leftovers.len(),
1432 leftovers.join(", ")
1433 ));
1434 }
1435 use serde::Deserialize as _;
1436 Some(
1437 abk::config::McpConfig::deserialize(section.clone())
1438 .map_err(|e| format!("invalid [mcp] config: {}", e))?,
1439 )
1440 }
1441 None => None,
1442 }
1443 }
1444 None => None,
1445 };
1446
1447 let loader = match mcp_config {
1448 Some(cfg) if cfg.enabled => {
1449 let built = abk::agent::McpToolLoader::with_token_store(
1450 &cfg,
1451 Some(token_store.clone() as std::sync::Arc<dyn pep::token_store::TokenStore>),
1452 )
1453 .await
1454 .map_err(|e| format!("MCP loader build failed: {}", e))?;
1455
1456 let servers: Vec<String> = built
1460 .server_statuses
1461 .iter()
1462 .map(|s| {
1463 if s.connected {
1464 format!("{}(up,{}tools)", s.name, s.tool_count)
1465 } else {
1466 format!("{}(DOWN)", s.name)
1467 }
1468 })
1469 .collect();
1470 tracing::info!(
1471 "MCP loader built for user {}: servers=[{}] total_tools={}",
1472 &user_hash[..8.min(user_hash.len())],
1473 servers.join(", "),
1474 built.tool_count
1475 );
1476 Some(std::sync::Arc::new(built))
1477 }
1478 _ => None,
1479 };
1480
1481 Ok(McpLoaderEntry {
1482 loader,
1483 fingerprint,
1484 built_at: chrono::Utc::now(),
1485 degraded: None,
1486 failed_at: None,
1487 })
1488 }
1489
1490 fn spawn_user_drain_task(
1492 &self,
1493 session_id: String,
1494 session: Arc<Mutex<Session>>,
1495 ws_tx: broadcast::Sender<String>,
1496 mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
1497 ) {
1498 let mut last_broadcast_state: Option<String> = None;
1504 tokio::spawn(async move {
1505 while let Some(msg) = workflow_rx.recv().await {
1506 {
1507 let mut session = session.lock().await;
1508 session.handle_workflow_message(msg.clone());
1509
1510 let state_str = match session.workflow_state {
1511 trustee_core::types::WorkflowState::Idle => "Idle",
1512 trustee_core::types::WorkflowState::Running => "Running",
1513 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1514 };
1515 if last_broadcast_state.as_deref() != Some(state_str) {
1516 last_broadcast_state = Some(state_str.to_string());
1517 let state_msg = serde_json::json!({
1518 "type": "StateChanged",
1519 "state": state_str
1520 });
1521 let _ = ws_tx.send(state_msg.to_string());
1522 }
1523 }
1524
1525 let json =
1526 serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1527 let _ = ws_tx.send(json);
1528 }
1529 tracing::debug!("Drain task ended for session: {}", session_id);
1530 });
1531 }
1532
1533 pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1536 let default_user = self
1538 .sessions
1539 .get("default")
1540 .expect("default user must exist");
1541 let first_entry = default_user
1542 .sessions
1543 .iter()
1544 .next()
1545 .expect("default user must have at least one session");
1546 let session = first_entry.session.clone();
1547 let ws_tx = first_entry.ws_tx.clone();
1548 let session_id = first_entry.key().clone();
1549 drop(first_entry);
1550 drop(default_user);
1551
1552 let mut last_broadcast_state = Some("Running".to_string());
1556 tokio::spawn(async move {
1557 while let Some(msg) = workflow_rx.recv().await {
1558 {
1559 let mut session = session.lock().await;
1560 session.handle_workflow_message(msg.clone());
1561
1562 let state_str = match session.workflow_state {
1563 trustee_core::types::WorkflowState::Idle => "Idle",
1564 trustee_core::types::WorkflowState::Running => "Running",
1565 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1566 };
1567 if last_broadcast_state.as_deref() != Some(state_str) {
1568 last_broadcast_state = Some(state_str.to_string());
1569 let state_msg = serde_json::json!({
1570 "type": "StateChanged",
1571 "state": state_str
1572 });
1573 let _ = ws_tx.send(state_msg.to_string());
1574 }
1575 }
1576
1577 let json =
1578 serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1579 let _ = ws_tx.send(json);
1580 }
1581 tracing::debug!("Drain task ended for session: {}", session_id);
1582 });
1583 }
1584
1585 pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
1587 let Some(ref auth) = self.auth else {
1588 return "default".to_string();
1589 };
1590
1591 if let Some(token) = headers
1593 .get(axum::http::header::AUTHORIZATION)
1594 .and_then(|v| v.to_str().ok())
1595 .and_then(|v| v.strip_prefix("Bearer "))
1596 .map(|s| s.to_string())
1597 {
1598 if token.starts_with("dev:") {
1599 let parts: Vec<&str> = token.splitn(4, ':').collect();
1600 if parts.len() >= 4 {
1601 return format!("dev:{}", parts[1]);
1602 }
1603 }
1604 if let Ok(claims) = auth.validate_token(&token).await {
1605 return claims.sub;
1606 }
1607 }
1608
1609 let cookie_session_id = headers
1611 .get(axum::http::header::COOKIE)
1612 .and_then(|v| v.to_str().ok())
1613 .and_then(|cookies| {
1614 cookies
1615 .split(';')
1616 .map(|c| c.trim())
1617 .find_map(|c| {
1618 c.strip_prefix(&format!("{}=", auth.config.cookie_name))
1619 .map(|s| s.to_string())
1620 })
1621 });
1622
1623 if let Some(session_id) = cookie_session_id {
1624 if session_id.starts_with("dev:") {
1625 let parts: Vec<&str> = session_id.splitn(4, ':').collect();
1626 if parts.len() >= 4 {
1627 return format!("dev:{}", parts[1]);
1628 }
1629 }
1630
1631 if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
1632 if let Ok(claims) = auth.validate_token(&access_token).await {
1633 return claims.sub;
1634 }
1635 }
1636 }
1637
1638 "default".to_string()
1639 }
1640}
1641
1642struct SerializableMessage<'a>(&'a TuiMessage);
1648
1649impl<'a> serde::Serialize for SerializableMessage<'a> {
1650 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1651 where
1652 S: serde::Serializer,
1653 {
1654 use serde::ser::SerializeStruct;
1655
1656 match self.0 {
1657 TuiMessage::OutputLine(line) => {
1658 let mut s = serializer.serialize_struct("msg", 2)?;
1659 s.serialize_field("type", "OutputLine")?;
1660 s.serialize_field("line", line)?;
1661 s.end()
1662 }
1663 TuiMessage::StreamDelta(delta) => {
1664 let mut s = serializer.serialize_struct("msg", 2)?;
1665 s.serialize_field("type", "StreamDelta")?;
1666 s.serialize_field("delta", delta)?;
1667 s.end()
1668 }
1669 TuiMessage::ReasoningDelta(delta) => {
1670 let mut s = serializer.serialize_struct("msg", 2)?;
1671 s.serialize_field("type", "ReasoningDelta")?;
1672 s.serialize_field("delta", delta)?;
1673 s.end()
1674 }
1675 TuiMessage::WorkflowCompleted => {
1676 let mut s = serializer.serialize_struct("msg", 2)?;
1677 s.serialize_field("type", "WorkflowCompleted")?;
1678 s.serialize_field("state", "Idle")?;
1679 s.end()
1680 }
1681 TuiMessage::WorkflowError(err) => {
1682 let mut s = serializer.serialize_struct("msg", 2)?;
1683 s.serialize_field("type", "WorkflowError")?;
1684 s.serialize_field("error", err)?;
1685 s.end()
1686 }
1687 TuiMessage::ResumeInfo(info) => match info {
1688 Some(ri) => {
1689 let mut s = serializer.serialize_struct("msg", 5)?;
1690 s.serialize_field("type", "ResumeInfo")?;
1691 s.serialize_field("state", "Idle")?;
1692 s.serialize_field("session_id", &ri.session_id)?;
1693 s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
1694 s.serialize_field("iteration", &ri.iteration)?;
1695 s.end()
1696 }
1697 None => {
1698 let mut s = serializer.serialize_struct("msg", 2)?;
1699 s.serialize_field("type", "ResumeInfo")?;
1700 s.serialize_field("state", "Idle")?;
1701 s.end()
1702 }
1703 },
1704 TuiMessage::TodoUpdate(content) => {
1705 let mut s = serializer.serialize_struct("msg", 2)?;
1706 s.serialize_field("type", "TodoUpdate")?;
1707 s.serialize_field("content", content)?;
1708 s.end()
1709 }
1710 TuiMessage::WorkflowCancelled => {
1711 let mut s = serializer.serialize_struct("msg", 2)?;
1712 s.serialize_field("type", "WorkflowCancelled")?;
1713 s.serialize_field("state", "Idle")?;
1714 s.end()
1715 }
1716 TuiMessage::HandoffReady(briefing) => {
1717 let mut s = serializer.serialize_struct("msg", 3)?;
1718 s.serialize_field("type", "HandoffReady")?;
1719 s.serialize_field("state", "Idle")?;
1720 s.serialize_field("briefing", briefing)?;
1721 s.end()
1722 }
1723 TuiMessage::SessionRotated { old, new } => {
1724 let mut s = serializer.serialize_struct("msg", 3)?;
1725 s.serialize_field("type", "SessionRotated")?;
1726 s.serialize_field("old", old)?;
1727 s.serialize_field("new", new)?;
1728 s.end()
1729 }
1730 TuiMessage::HandoffFailed => {
1731 let mut s = serializer.serialize_struct("msg", 2)?;
1732 s.serialize_field("type", "HandoffFailed")?;
1733 s.serialize_field("state", "Idle")?;
1734 s.end()
1735 }
1736 TuiMessage::ToolPending {
1737 tool_name,
1738 hint,
1739 } => {
1740 let mut s = serializer.serialize_struct("msg", 3)?;
1741 s.serialize_field("type", "ToolPending")?;
1742 s.serialize_field("tool_name", tool_name)?;
1743 s.serialize_field("hint", hint)?;
1744 s.end()
1745 }
1746 TuiMessage::ToolDone {
1747 tool_name,
1748 success,
1749 hint,
1750 } => {
1751 let mut s = serializer.serialize_struct("msg", 4)?;
1752 s.serialize_field("type", "ToolDone")?;
1753 s.serialize_field("tool_name", tool_name)?;
1754 s.serialize_field("success", success)?;
1755 s.serialize_field("hint", hint)?;
1756 s.end()
1757 }
1758 TuiMessage::ContextTokensUpdated(count) => {
1759 let mut s = serializer.serialize_struct("msg", 2)?;
1760 s.serialize_field("type", "ContextTokensUpdated")?;
1761 s.serialize_field("count", count)?;
1762 s.end()
1763 }
1764 TuiMessage::McpServerStatus {
1765 name,
1766 connected,
1767 tool_count,
1768 error,
1769 } => {
1770 let mut s = serializer.serialize_struct("msg", 5)?;
1771 s.serialize_field("type", "McpServerStatus")?;
1772 s.serialize_field("name", name)?;
1773 s.serialize_field("connected", connected)?;
1774 s.serialize_field("tool_count", tool_count)?;
1775 s.serialize_field("error", error)?;
1776 s.end()
1777 }
1778 TuiMessage::SessionTitleUpdated(title) => {
1779 let mut s = serializer.serialize_struct("msg", 2)?;
1780 s.serialize_field("type", "SessionTitleUpdated")?;
1781 s.serialize_field("title", title)?;
1782 s.end()
1783 }
1784 }
1785 }
1786}
1787
1788fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1799 match (base, overlay) {
1800 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1801 for (key, overlay_val) in overlay_table {
1802 match base_table.get_mut(key) {
1803 Some(base_val) => {
1804 deep_merge_toml(base_val, overlay_val);
1806 }
1807 None => {
1808 base_table.insert(key.clone(), overlay_val.clone());
1810 }
1811 }
1812 }
1813 }
1814 (base, overlay) => {
1816 *base = overlay.clone();
1817 }
1818 }
1819}
1820
1821fn collect_unresolved_placeholders(value: &toml::Value, out: &mut Vec<String>) {
1828 match value {
1829 toml::Value::String(s) => {
1830 if let Some(start) = s.find("${") {
1831 if let Some(end_rel) = s[start + 2..].find('}') {
1832 let name = &s[start + 2..start + 2 + end_rel];
1833 let rendered = format!("${{{name}}}");
1834 if !out.contains(&rendered) {
1835 out.push(rendered);
1836 }
1837 }
1838 }
1839 }
1840 toml::Value::Table(t) => {
1841 for v in t.values() {
1842 collect_unresolved_placeholders(v, out);
1843 }
1844 }
1845 toml::Value::Array(a) => {
1846 for v in a {
1847 collect_unresolved_placeholders(v, out);
1848 }
1849 }
1850 _ => {}
1851 }
1852}
1853
1854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1856pub(crate) enum SecretSource {
1857 SecretsMap,
1860 ProcessEnv,
1866 Unresolved,
1868}
1869
1870#[derive(Debug, Clone, PartialEq, Eq)]
1872pub(crate) struct EnvVarResolution {
1873 pub name: String,
1874 pub source: SecretSource,
1875}
1876
1877fn substitute_env_vars(s: &mut String, secrets: &std::collections::HashMap<String, String>, ctx: &str) -> Vec<EnvVarResolution> {
1878 let mut result = String::with_capacity(s.len());
1880 let bytes = s.as_bytes();
1881 let mut i = 0;
1882 let mut report: Vec<EnvVarResolution> = Vec::new();
1885
1886 while i < bytes.len() {
1887 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1888 if let Some(end) = s[i + 2..].find('}') {
1890 let var_name = &s[i + 2..i + 2 + end];
1891 let (replacement, source) = if let Some(value) = secrets.get(var_name) {
1893 (value.clone(), SecretSource::SecretsMap)
1894 } else if let Ok(value) = std::env::var(var_name) {
1895 (value, SecretSource::ProcessEnv)
1896 } else {
1897 (
1902 s[i..i + 2 + end + 1].to_string(),
1903 SecretSource::Unresolved,
1904 )
1905 };
1906 if !report.iter().any(|r| r.name == var_name) {
1907 report.push(EnvVarResolution {
1908 name: var_name.to_string(),
1909 source,
1910 });
1911 }
1912 result.push_str(&replacement);
1913 i = i + 2 + end + 1;
1914 } else {
1915 result.push('$');
1917 i += 1;
1918 }
1919 } else {
1920 result.push(bytes[i] as char);
1921 i += 1;
1922 }
1923 }
1924
1925 for r in &report {
1930 match r.source {
1931 SecretSource::ProcessEnv => tracing::error!(
1932 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from PROCESS ENV \
1933 (global ~/.trustee/.env at boot) — no per-user/profile secret \
1934 answered; calls using it execute under the GLOBAL principal. \
1935 Supply the exact-case key in the per-user .env / profile \
1936 secrets to restore attribution.",
1937 ctx,
1938 r.name
1939 ),
1940 SecretSource::Unresolved => {
1941 }
1943 SecretSource::SecretsMap => tracing::debug!(
1944 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from profile/user secrets",
1945 ctx,
1946 r.name
1947 ),
1948 }
1949 }
1950 let unresolved: Vec<&str> = report
1951 .iter()
1952 .filter(|r| r.source == SecretSource::Unresolved)
1953 .map(|r| r.name.as_str())
1954 .collect();
1955 if !unresolved.is_empty() {
1956 tracing::warn!(
1957 "ENV SUBSTITUTION LEFT {} UNRESOLVED [{}]: {} — the literal ${{…}} remains in the \
1958 effective config; supply the key in the profile secrets (exact-case KEY=VALUE) \
1959 or the environment",
1960 unresolved.len(),
1961 ctx,
1962 unresolved
1963 .iter()
1964 .map(|v| format!("${{{v}}}"))
1965 .collect::<Vec<_>>()
1966 .join(", ")
1967 );
1968 }
1969
1970 *s = result;
1971 report
1972}
1973
1974pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1979 user_home.is_dir() && !user_home.join(".env").exists()
1980}
1981
1982fn fingerprint_mcp_section(resolved: Option<&str>) -> u64 {
1992 use sha2::{Digest, Sha256};
1993 let section = resolved
1994 .and_then(|s| s.parse::<toml::Value>().ok())
1995 .and_then(|v| v.get("mcp").cloned());
1996 let bytes = match section {
1997 Some(v) => v.to_string().into_bytes(),
1998 None => b"<no-mcp>".to_vec(),
1999 };
2000 let digest = Sha256::digest(&bytes);
2001 u64::from_be_bytes(digest[..8].try_into().expect("sha256 digest >= 8 bytes"))
2002}
2003
2004#[cfg(test)]
2005mod tests {
2006 use super::*;
2007
2008 fn test_state() -> ServerState {
2010 let (session, _rx) = Session::new();
2011 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2012 ServerState::new(session, ws_tx, None)
2013 }
2014
2015 #[tokio::test]
2016 async fn dispatch_scope_pins_user_key_resolution() {
2017 let state = test_state();
2021 let caller = "some-human-sub";
2022 let plain_home = state.get_user_home_dir(caller).unwrap();
2023 let pinned_key = "prof-1234";
2024 let pinned_home = in_dispatch_scope(pinned_key, async {
2025 state.get_user_home_dir(caller).unwrap()
2026 })
2027 .await;
2028 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2029 assert_eq!(plain_home, users_root.join(trustee_core::user_hash(caller)));
2030 assert_eq!(
2031 pinned_home,
2032 users_root.join(trustee_core::user_hash(pinned_key)),
2033 "dispatch scope must pin resolution to the binding key"
2034 );
2035 assert_ne!(plain_home, pinned_home);
2036 }
2037
2038 fn temp_user_home(tag: &str) -> std::path::PathBuf {
2040 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2041 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2042 let dir = std::env::temp_dir().join(format!(
2043 "trustee-state-test-{}-{}-{}",
2044 tag,
2045 std::process::id(),
2046 n
2047 ));
2048 std::fs::create_dir_all(dir.join("config")).expect("create temp user home");
2049 dir
2050 }
2051
2052 fn parse(toml_str: &str) -> toml::Value {
2053 toml_str.parse::<toml::Value>().expect("valid test TOML")
2054 }
2055
2056 #[test]
2059 fn overlay_allowlist_drops_non_allowlisted_sections() {
2060 let state = test_state();
2061 let home = temp_user_home("allowlist");
2062 std::fs::write(
2063 home.join("config").join("trustee.toml"),
2064 "[server]\nport = 1\n\n[auth]\nmode = \"kanidm\"\n\n[mcp]\nmode = \"user\"\n",
2065 )
2066 .expect("write overlay");
2067
2068 let shared = "[server]\nport = 8080\n\n[mcp]\nmode = \"shared\"\n";
2069 let merged = state
2070 .merge_user_config(&home, Some(shared))
2071 .expect("overlay has allowlisted content");
2072
2073 let merged_val = parse(&merged);
2074 let expected = parse("[server]\nport = 8080\n\n[mcp]\nmode = \"user\"\n");
2075 assert_eq!(merged_val, expected, "merged config must be shared + [mcp] overlay only");
2076 assert!(merged_val.get("auth").is_none(), "overlay [auth] must be dropped");
2077 }
2078
2079 #[test]
2081 fn no_overlay_file_returns_none() {
2082 let state = test_state();
2083 let home = temp_user_home("empty");
2084 assert!(state
2085 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2086 .is_none());
2087 }
2088
2089 #[test]
2091 fn overlay_with_no_allowlisted_sections_is_noop() {
2092 let state = test_state();
2093 let home = temp_user_home("all-dropped");
2094 std::fs::write(
2095 home.join("config").join("trustee.toml"),
2096 "[server]\nport = 1\n\n[storage]\npath = \"/tmp/x\"\n",
2097 )
2098 .expect("write overlay");
2099 assert!(state
2100 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2101 .is_none());
2102 }
2103
2104 #[test]
2107 fn llm_overlay_dropped_by_default_and_kept_when_enabled() {
2108 let shared = "[llm]\nprovider = \"openai\"\n\n[mcp]\nmode = \"shared\"\n";
2109 let overlay = "[llm]\nprovider = \"anthropic\"\n";
2110
2111 let state = test_state();
2113 assert!(!state.allow_llm_overlay);
2114 let home = temp_user_home("llm-off");
2115 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2116 assert!(state.merge_user_config(&home, Some(shared)).is_none());
2117
2118 let state = test_state().with_allow_llm_overlay(true);
2120 assert!(state.allow_llm_overlay);
2121 let home = temp_user_home("llm-on");
2122 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2123 let merged = state
2124 .merge_user_config(&home, Some(shared))
2125 .expect("[llm] overlay applies when opted in");
2126 let merged_val = parse(&merged);
2127 assert_eq!(merged_val["llm"]["provider"].as_str(), Some("anthropic"));
2128 assert_eq!(merged_val["mcp"]["mode"].as_str(), Some("shared"));
2129 }
2130
2131 #[test]
2135 fn user_home_dir_uses_consolidated_hash() {
2136 let state = test_state();
2137 if let Some(home) = state.get_user_home_dir("farzan@example.com") {
2138 assert_eq!(
2139 home.file_name().and_then(|n| n.to_str()),
2140 Some(trustee_core::user_hash("farzan@example.com")).as_deref()
2141 );
2142 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2143 assert_eq!(home.parent(), Some(&users_root).map(|p| p.as_path()));
2144 }
2145 }
2147
2148 #[test]
2151 fn lifecycle_overlay_survives_allowlist_enabled_stripped() {
2152 let state = test_state();
2153 let home = temp_user_home("lifecycle-persona");
2154 std::fs::write(
2155 home.join("config").join("trustee.toml"),
2156 "[lifecycle]\nsystem_template = \"You are Saman.\"\nenabled = true\n",
2157 )
2158 .expect("write overlay");
2159
2160 let merged = state
2161 .merge_user_config(
2162 &home,
2163 Some("[lifecycle]\nsystem_template = \"shared default\"\n"),
2164 )
2165 .expect("lifecycle overlay is allowlisted");
2166
2167 let v = merged.parse::<toml::Value>().expect("valid TOML");
2168 assert_eq!(
2169 v["lifecycle"]["system_template"].as_str(),
2170 Some("You are Saman."),
2171 "agent persona wins over shared default"
2172 );
2173 assert_eq!(
2174 v["lifecycle"].get("enabled"),
2175 None,
2176 "WASM extension switch is not user-settable"
2177 );
2178 }
2179
2180 #[tokio::test]
2185 async fn agent_principals_get_isolated_session_buckets() {
2186 let state = test_state();
2187 let key_a = "agent-farzan";
2188 let key_b = "agent-paydar";
2189
2190 let (sid_a, session_a, _tx_a, _ts_a) = state.ensure_active_session(key_a).await;
2191 let (_sid_b, _session_b, _tx_b, _ts_b) = state.ensure_active_session(key_b).await;
2192
2193 assert!(
2195 state.get_session_by_any_id(key_a, &sid_a).await.is_some(),
2196 "owner bucket resolves its own session"
2197 );
2198 assert!(
2199 state.get_session_by_any_id(key_b, &sid_a).await.is_none(),
2200 "cross-agent session access must be 404/None"
2201 );
2202 assert!(Arc::strong_count(&session_a) >= 1);
2203
2204 if let Some(home) = state.get_user_home_dir(key_a) {
2206 assert_eq!(
2207 home.file_name().and_then(|n| n.to_str()),
2208 Some(trustee_core::user_hash(key_a)).as_deref()
2209 );
2210 }
2211 }
2212}
2213
2214#[cfg(test)]
2219mod mcp_loader_cache_tests {
2220 use super::*;
2221
2222 fn state_with_shared(shared: &str) -> ServerState {
2223 let (session, _rx) = Session::new();
2224 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2225 let mut state = ServerState::new(session, ws_tx, None);
2226 state.config_toml = Some(shared.to_string());
2227 state
2228 }
2229
2230 struct TempUser {
2234 key: String,
2235 home: std::path::PathBuf,
2236 }
2237
2238 impl TempUser {
2239 fn new(tag: &str) -> Self {
2240 let key = format!("16c-{tag}-{}@test.invalid", std::process::id());
2241 let home = dirs::home_dir()
2242 .expect("HOME available in test env")
2243 .join(".trustee")
2244 .join("users")
2245 .join(trustee_core::user_hash(&key));
2246 std::fs::create_dir_all(home.join("config")).expect("create user home");
2247 Self { key, home }
2248 }
2249
2250 fn write_overlay(&self, toml_str: &str) {
2251 std::fs::write(self.home.join("config").join("trustee.toml"), toml_str)
2252 .expect("write overlay");
2253 }
2254 }
2255
2256 impl Drop for TempUser {
2257 fn drop(&mut self) {
2258 let _ = std::fs::remove_dir_all(&self.home);
2259 }
2260 }
2261
2262 #[tokio::test]
2263 async fn no_mcp_config_caches_disabled_marker() {
2264 let state = state_with_shared("[server]\nport = 8080\n");
2265 let user = TempUser::new("nomcp");
2266 let ts = Arc::new(pep::MemoryTokenStore::new());
2267
2268 let first = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2269 assert!(first.is_none(), "no [mcp] anywhere → disabled marker");
2270 assert_eq!(state.mcp_loaders.len(), 1, "exactly one cache entry");
2271
2272 let second = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2274 assert!(second.is_none());
2275 assert_eq!(state.mcp_loaders.len(), 1);
2276 }
2277
2278 #[tokio::test]
2279 async fn concurrent_cold_builds_single_flight() {
2280 let state = Arc::new(state_with_shared("[server]\nport = 8080\n"));
2281 let user = TempUser::new("singleflight");
2282 let ts = Arc::new(pep::MemoryTokenStore::new());
2283
2284 let mut handles = Vec::new();
2285 for _ in 0..5 {
2286 let state = state.clone();
2287 let key = user.key.clone();
2288 let ts = ts.clone();
2289 handles.push(tokio::spawn(async move {
2290 state.get_or_build_mcp_loader(&key, &ts).await
2291 }));
2292 }
2293 for h in handles {
2294 h.await.unwrap().expect("all five succeed");
2295 }
2296 assert_eq!(state.mcp_loaders.len(), 1, "single-flight → one entry");
2297 }
2298
2299 #[tokio::test]
2300 async fn fingerprint_change_triggers_rebuild() {
2301 let state = state_with_shared("[server]\nport = 8080\n");
2302 let user = TempUser::new("fpchange");
2303 let ts = Arc::new(pep::MemoryTokenStore::new());
2304
2305 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2308 let v1 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2309 assert!(v1.is_some(), "enabled [mcp] → real loader");
2310 let fp1 = state
2311 .mcp_loaders
2312 .get(&trustee_core::user_hash(&user.key))
2313 .unwrap()
2314 .fingerprint;
2315
2316 std::thread::sleep(std::time::Duration::from_millis(5));
2318 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v2\"\nurl = \"http://127.0.0.1:9/other\"\n");
2319 let v2 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2320 assert!(v2.is_some());
2321 let entry = state
2322 .mcp_loaders
2323 .get(&trustee_core::user_hash(&user.key))
2324 .unwrap();
2325 assert_ne!(
2326 entry.fingerprint, fp1,
2327 "fingerprint must change with content"
2328 );
2329 assert!(entry.degraded.is_none());
2330
2331 let _still_usable = v1.as_ref().unwrap().tool_count;
2333 }
2334
2335 #[tokio::test]
2336 async fn degraded_entry_fails_loud_within_backoff_and_isolates_users() {
2337 let state = state_with_shared("[server]\nport = 8080\n");
2338 let bad = TempUser::new("degraded-bad");
2339 let good = TempUser::new("degraded-good");
2340 let ts = Arc::new(pep::MemoryTokenStore::new());
2341
2342 bad.write_overlay("[mcp]\nenabled = \"not-a-bool\"\n");
2344 let err = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2345 Ok(_) => panic!("invalid [mcp] must fail loud"),
2346 Err(e) => e,
2347 };
2348 assert!(
2349 err.contains("invalid [mcp]"),
2350 "surfaces the parse error: {err}"
2351 );
2352
2353 let entry = state
2354 .mcp_loaders
2355 .get(&trustee_core::user_hash(&bad.key))
2356 .unwrap();
2357 assert!(entry.degraded.is_some(), "poison entry recorded");
2358
2359 let err2 = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2361 Ok(_) => panic!("still within backoff"),
2362 Err(e) => e,
2363 };
2364 assert_eq!(err, err2, "same cached error");
2365
2366 let good_loader = state
2368 .get_or_build_mcp_loader(&good.key, &ts)
2369 .await
2370 .expect("other user unaffected");
2371 assert!(
2372 good_loader.is_none(),
2373 "good user has no [mcp] → disabled marker"
2374 );
2375 }
2376
2377 #[tokio::test]
2378 async fn aged_out_entry_rebuilds_without_restart() {
2379 let state = state_with_shared("[server]\nport = 8080\n");
2384 let user = TempUser::new("agedout");
2385 let ts = Arc::new(pep::MemoryTokenStore::new());
2386
2387 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2388 let v1 = state
2389 .get_or_build_mcp_loader(&user.key, &ts)
2390 .await
2391 .expect("initial build");
2392 let v1 = v1.expect("enabled [mcp] → loader");
2393
2394 {
2397 let mut entry = state
2398 .mcp_loaders
2399 .get_mut(&trustee_core::user_hash(&user.key))
2400 .unwrap();
2401 entry.built_at =
2402 chrono::Utc::now() - chrono::Duration::seconds(601);
2403 }
2404
2405 let v2 = state
2406 .get_or_build_mcp_loader(&user.key, &ts)
2407 .await
2408 .expect("aged-out dispatch rebuilds");
2409 let v2 = v2.expect("rebuilt loader present");
2410 assert!(
2411 !Arc::ptr_eq(&v1, &v2),
2412 "aged-out entry must NOT be reused — fresh connect re-lists the served surface"
2413 );
2414 let entry = state
2415 .mcp_loaders
2416 .get(&trustee_core::user_hash(&user.key))
2417 .unwrap();
2418 assert!(
2419 chrono::Utc::now() - entry.built_at < chrono::Duration::seconds(5),
2420 "built_at refreshed by the rebuild"
2421 );
2422
2423 let v3 = state
2425 .get_or_build_mcp_loader(&user.key, &ts)
2426 .await
2427 .expect("fresh dispatch");
2428 assert!(Arc::ptr_eq(&v2, &v3.unwrap()), "young entry hits the cache");
2429 }
2430}
2431
2432#[cfg(test)]
2433mod restore_tests {
2434 use super::*;
2435
2436 fn fresh_state() -> ServerState {
2437 let (session, _rx) = Session::new();
2438 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2439 let mut state = ServerState::new(session, ws_tx, None);
2440 state.config_toml = Some("[agent]\nname = \"trustee\"\n".to_string());
2441 state
2442 }
2443
2444 struct TempUser {
2446 key: String,
2447 }
2448
2449 impl TempUser {
2450 fn new(tag: &str) -> Self {
2451 let key = format!("restore-{tag}-{}@test.invalid", std::process::id());
2452 let home = dirs::home_dir()
2453 .expect("HOME available in test env")
2454 .join(".trustee")
2455 .join("users")
2456 .join(trustee_core::user_hash(&key));
2457 std::fs::create_dir_all(home).expect("create user home");
2458 Self { key }
2459 }
2460 }
2461
2462 #[tokio::test]
2463 async fn restore_unknown_chain_returns_none() {
2464 let state = fresh_state();
2465 let user = TempUser::new("unknown");
2466 let out = state
2467 .get_or_restore_session_by_any_id(&user.key, "session_2099_01_01_00_00_deadbeef")
2468 .await;
2469 assert!(out.is_none(), "unknown chain must 404, not restore");
2470 }
2471
2472 #[tokio::test]
2473 async fn destroy_tombstones_chain_id() {
2474 let state = fresh_state();
2475 let user = TempUser::new("tombstone");
2476 let sid = state
2477 .create_session(&user.key, None, None, true)
2478 .await
2479 .expect("create session");
2480
2481 let (session, _ws) = state.get_session(&user.key, &sid).await.unwrap();
2483 {
2484 let mut s = session.lock().await;
2485 s.session_id = Some("session_2026_09_04_10_00_aaaabbbb".to_string());
2486 }
2487
2488 state.destroy_session(&user.key, &sid).await.expect("destroy");
2489
2490 let path = state
2491 .tombstone_path(&user.key, "session_2026_09_04_10_00_aaaabbbb")
2492 .expect("tombstone path");
2493 assert!(path.exists(), "destroy must write a chain tombstone");
2494
2495 let sid2 = state
2497 .create_session(&user.key, None, None, true)
2498 .await
2499 .expect("create second session");
2500 state.destroy_session(&user.key, &sid2).await.expect("destroy 2");
2501 }
2503
2504 #[tokio::test]
2505 async fn restore_refuses_registry_key_shapes() {
2506 let state = fresh_state();
2510 let user = TempUser::new("shapes");
2511 assert!(state.tombstone_path(&user.key, "default").is_none());
2512 let out = state.get_or_restore_session_by_any_id(&user.key, "default").await;
2513 assert!(out.is_none());
2514 }
2515}
2516
2517 #[test]
2518 fn collect_unresolved_placeholders_walks_the_tree() {
2519 let section: toml::Value = toml::from_str(
2520 r#"
2521[credentials.farzan_service_account]
2522service_token = "${FARZAN_SERVICE_ACCOUNT}"
2523issuer_url = "https://ok.example"
2524
2525[[servers]]
2526name = "fame"
2527url = "https://fame.example"
2528credentials = "farzan_service_account"
2529
2530[[servers]]
2531name = "other"
2532url = "https://other.example"
2533token = "${OTHER_TOKEN}"
2534"#,
2535 )
2536 .unwrap();
2537 let mut out = Vec::new();
2538 collect_unresolved_placeholders(§ion, &mut out);
2539 out.sort();
2540 assert_eq!(
2541 out,
2542 vec!["${FARZAN_SERVICE_ACCOUNT}".to_string(), "${OTHER_TOKEN}".to_string()],
2543 "every leftover placeholder in the [mcp] tree must be collected, deduped"
2544 );
2545 let clean: toml::Value = toml::from_str("[x]\ny = \"z\"\n").unwrap();
2547 let mut out2 = Vec::new();
2548 collect_unresolved_placeholders(&clean, &mut out2);
2549 assert!(out2.is_empty());
2550 }
2551
2552 #[test]
2553 fn substitute_env_vars_resolves_and_keeps_leftovers_deterministically() {
2554 let mut secrets = std::collections::HashMap::new();
2555 secrets.insert("A_TOKEN".to_string(), "resolved-value".to_string());
2556 let mut s = "x = \"${A_TOKEN}\" y = \"${MISSING_TOKEN}\"".to_string();
2557 let report = substitute_env_vars(&mut s, &secrets, "test");
2558 assert!(
2559 s.contains("resolved-value"),
2560 "declared key resolves: {s}"
2561 );
2562 assert!(
2563 s.contains("${MISSING_TOKEN}"),
2564 "undeclared key keeps the literal (the loader guard turns this into a loud failure): {s}"
2565 );
2566 assert_eq!(report.len(), 2, "one entry per distinct var: {report:?}");
2568 assert!(report.contains(&EnvVarResolution {
2569 name: "A_TOKEN".to_string(),
2570 source: SecretSource::SecretsMap
2571 }));
2572 assert!(report.contains(&EnvVarResolution {
2573 name: "MISSING_TOKEN".to_string(),
2574 source: SecretSource::Unresolved
2575 }));
2576 }
2577
2578 #[test]
2579 fn substitute_env_vars_reports_process_env_shadow() {
2580 let var = format!(
2585 "RAVAND_PROVENANCE_TEST_{}",
2586 std::process::id()
2587 );
2588 std::env::set_var(&var, "global-env-answer");
2589
2590 let mut secrets = std::collections::HashMap::new();
2592 let mut s = format!("x = \"${{{var}}}\"");
2593 let report = substitute_env_vars(&mut s, &secrets, "test-shadow");
2594 assert!(s.contains("global-env-answer"), "process env answered: {s}");
2595 assert_eq!(
2596 report,
2597 vec![EnvVarResolution {
2598 name: var.clone(),
2599 source: SecretSource::ProcessEnv
2600 }]
2601 );
2602
2603 secrets.insert(var.clone(), "per-user-answer".to_string());
2606 let mut s2 = format!("x = \"${{{var}}}\"");
2607 let report2 = substitute_env_vars(&mut s2, &secrets, "test-wins");
2608 assert!(s2.contains("per-user-answer"), "user secrets win: {s2}");
2609 assert_eq!(
2610 report2,
2611 vec![EnvVarResolution {
2612 name: var.clone(),
2613 source: SecretSource::SecretsMap
2614 }]
2615 );
2616
2617 std::env::remove_var(&var);
2618 }
2619
2620 #[test]
2621 fn materialized_user_missing_env_only_fires_on_dir_without_env() {
2622 let base = std::env::temp_dir().join(format!(
2624 "trustee-envcheck-{}",
2625 std::process::id()
2626 ));
2627 let _ = std::fs::remove_dir_all(&base);
2628
2629 assert!(!materialized_user_missing_env(&base.join("ghost")));
2631
2632 std::fs::create_dir_all(&base).unwrap();
2634 assert!(materialized_user_missing_env(&base));
2635
2636 std::fs::write(base.join(".env"), "K=V\n").unwrap();
2638 assert!(!materialized_user_missing_env(&base));
2639
2640 let _ = std::fs::remove_dir_all(&base);
2641 }
2642
2643 #[test]
2644 fn loader_freshness_decides_all_four_states() {
2645 let now = chrono::Utc::now();
2646 let fp = 0xABCD_u64;
2647 let young = now - chrono::Duration::seconds(60);
2648 let old = now - chrono::Duration::seconds(601);
2649
2650 assert_eq!(
2652 loader_freshness(fp, young, None, None, fp, now),
2653 LoaderFreshness::Fresh
2654 );
2655 assert_eq!(
2657 loader_freshness(fp, old, None, None, 0x1234, now),
2658 LoaderFreshness::ConfigChanged
2659 );
2660 assert_eq!(
2662 loader_freshness(fp, old, None, None, fp, now),
2663 LoaderFreshness::AgedOut
2664 );
2665 let failed = now - chrono::Duration::seconds(10);
2667 match loader_freshness(fp, old, Some("boom"), Some(failed), fp, now) {
2668 LoaderFreshness::Backoff(remaining) => {
2669 assert!(remaining.as_secs() >= 15, "~20s left: {remaining:?}");
2670 }
2671 other => panic!("expected Backoff, got {other:?}"),
2672 }
2673 let failed_long = now - chrono::Duration::seconds(120);
2675 assert_eq!(
2676 loader_freshness(fp, old, Some("boom"), Some(failed_long), fp, now),
2677 LoaderFreshness::AgedOut
2678 );
2679 }
2680