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 fn chain_tombstone_stamp(&self, user_key: &str, chain_id: &str) -> Option<String> {
692 let path = self.tombstone_path(user_key, chain_id)?;
693 let text = std::fs::read_to_string(&path).ok()?;
694 let trimmed = text.trim();
695 if trimmed.is_empty() {
696 None
697 } else {
698 Some(trimmed.to_string())
699 }
700 }
701
702 pub async fn get_or_restore_session_by_any_id(
716 &self,
717 user_key: &str,
718 id: &str,
719 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
720 if let Some(found) = self.get_session_by_any_id(user_key, id).await {
721 return Some(found);
722 }
723 self.restore_session_from_checkpoint(user_key, id).await
724 }
725
726 async fn restore_session_from_checkpoint(
735 &self,
736 user_key: &str,
737 chain_id: &str,
738 ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
739 if let Some(path) = self.tombstone_path(user_key, chain_id) {
741 if path.exists() {
742 return None;
743 }
744 }
745
746 let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
748 let config_toml = config_toml?;
749
750 let info = trustee_core::sessions::create_resume_info(
753 &config_toml,
754 chain_id,
755 home_dir.as_deref(),
756 )
757 .await
758 .ok()??;
759
760 let user_sessions = self
763 .sessions
764 .entry(user_key.to_string())
765 .or_insert_with(|| UserSessions {
766 sessions: DashMap::new(),
767 token_store: Arc::new(pep::MemoryTokenStore::new()),
768 active_session_id: Mutex::new(String::new()),
769 });
770
771 if let Some(existing) = user_sessions.sessions.get(chain_id) {
772 let now = chrono::Utc::now();
773 *existing.last_active.lock().await = now;
774 return Some((
775 chain_id.to_string(),
776 existing.session.clone(),
777 existing.ws_tx.clone(),
778 ));
779 }
780
781 if user_sessions.sessions.len() >= self.max_sessions_per_user {
786 let mut evict: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
787 for entry in user_sessions.sessions.iter() {
788 let is_idle = entry.session.lock().await.workflow_state
789 == trustee_core::types::WorkflowState::Idle;
790 if !is_idle {
791 continue;
792 }
793 let la = entry.last_active.lock().await;
794 if evict.as_ref().is_none_or(|(_, t)| *la < *t) {
795 evict = Some((entry.key().clone(), *la));
796 }
797 }
798 if let Some((victim, _)) = evict {
799 user_sessions.sessions.remove(&victim);
800 } else {
801 return None;
802 }
803 }
804
805 let (mut session, workflow_rx) = Session::new();
808 session.config_toml = Some(config_toml.clone());
809 session.parse_auto_handoff_config();
810 if let Ok(table) = config_toml.parse::<toml::Value>() {
811 if let Some(name) = table
812 .get("agent")
813 .and_then(|a| a.get("name"))
814 .and_then(|n| n.as_str())
815 {
816 session.agent_name = name.to_string();
817 }
818 }
819 session.secrets = self.secrets.clone();
820 session.build_info = self.build_info.clone();
821 self.apply_user_isolation(&mut session, user_key);
822 session.session_id = Some(chain_id.to_string());
823 session.resume_info = Some(info);
824 session.session_name = None; let (ws_tx_entry, _) = broadcast::channel::<String>(256);
827 let now = chrono::Utc::now();
828
829 user_sessions.sessions.insert(
830 chain_id.to_string(),
831 UserSessionEntry {
832 session: Arc::new(Mutex::new(session)),
833 ws_tx: ws_tx_entry.clone(),
834 created_at: now,
835 last_active: Arc::new(Mutex::new(now)),
836 },
837 );
838
839 let session_arc = user_sessions
843 .sessions
844 .get(chain_id)
845 .map(|e| e.session.clone())
846 .ok_or_else(|| SessionError::NotFound(chain_id.to_string()))
847 .ok()?;
848 self.spawn_user_drain_task(
849 chain_id.to_string(),
850 session_arc.clone(),
851 ws_tx_entry.clone(),
852 workflow_rx,
853 );
854
855 Some((chain_id.to_string(), session_arc, ws_tx_entry))
856 }
857
858 pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> { let Some(user_sessions) = self.sessions.get(user_key) else {
860 return Vec::new();
861 };
862
863 let mut items = Vec::new();
864 for entry in user_sessions.sessions.iter() {
865 let session = entry.session.lock().await;
866 let workflow_state = match session.workflow_state {
867 trustee_core::types::WorkflowState::Idle => "Idle",
868 trustee_core::types::WorkflowState::Running => "Running",
869 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
870 };
871 let last_active = entry.last_active.lock().await;
872 items.push(SessionListItem {
873 session_id: entry.key().clone(),
874 session_name: session.session_name.clone(),
875 workflow_state: workflow_state.to_string(),
876 created_at: entry.created_at.to_rfc3339(),
877 last_active: last_active.to_rfc3339(),
878 handoff_count: session.handoff_count,
879 });
880 }
881 drop(user_sessions);
882
883 items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
885 items
886 }
887
888 pub async fn destroy_session(
890 &self,
891 user_key: &str,
892 session_id: &str,
893 ) -> Result<(), SessionError> {
894 let user_sessions = self
895 .sessions
896 .get(user_key)
897 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
898
899 let chain_id_for_tombstone = {
902 let entry = user_sessions
903 .sessions
904 .get(session_id)
905 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
906 let session = entry.session.lock().await;
907 if session.workflow_state != trustee_core::types::WorkflowState::Idle {
908 let state_str = match session.workflow_state {
909 trustee_core::types::WorkflowState::Running => "Running",
910 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
911 _ => "Unknown",
912 };
913 return Err(SessionError::NotIdle(state_str.to_string()));
914 }
915 session.session_id.clone()
916 };
917
918 user_sessions.sessions.remove(session_id);
920
921 if let Some(chain_id) = chain_id_for_tombstone {
925 self.write_tombstone(user_key, &chain_id);
926 }
927
928 let mut active_id = user_sessions.active_session_id.lock().await;
930 if &*active_id == session_id {
931 let mut newest: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
933 for entry in user_sessions.sessions.iter() {
934 let la = entry.last_active.lock().await;
935 if newest.as_ref().map_or(true, |(_, t)| *la > *t) {
936 newest = Some((entry.key().clone(), *la));
937 }
938 }
939 *active_id = newest.map(|(id, _)| id).unwrap_or_default();
940 }
941
942 Ok(())
943 }
944
945 pub async fn ensure_active_session(
954 &self,
955 user_key: &str,
956 ) -> (
957 String,
958 Arc<Mutex<Session>>,
959 broadcast::Sender<String>,
960 Arc<pep::MemoryTokenStore>,
961 ) {
962 let token_store = {
964 let user_sessions = self
965 .sessions
966 .entry(user_key.to_string())
967 .or_insert_with(|| UserSessions {
968 sessions: DashMap::new(),
969 token_store: Arc::new(pep::MemoryTokenStore::new()),
970 active_session_id: Mutex::new(String::new()),
971 });
972 user_sessions.token_store.clone()
973 };
974
975 let active_id = {
977 let user_sessions = self.sessions.get(user_key).unwrap();
978 let guard = user_sessions.active_session_id.lock().await;
979 guard.clone()
980 };
981
982 if !active_id.is_empty() {
983 if let Some((session, ws_tx)) = self.get_session(user_key, &active_id).await {
984 return (active_id, session, ws_tx, token_store);
985 }
986 }
988
989 let existing_session: Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> = {
993 let user_sessions = self.sessions.get(user_key).unwrap();
994 let result = user_sessions.sessions.iter().next().map(|first| {
995 (
996 first.key().clone(),
997 first.session.clone(),
998 first.ws_tx.clone(),
999 )
1000 });
1001 result
1002 };
1003 if let Some((id, session, ws_tx)) = existing_session {
1004 let now = chrono::Utc::now();
1005 if let Some(entry) = self.sessions.get(user_key) {
1006 if let Some(e) = entry.sessions.get(&id) {
1007 *e.last_active.lock().await = now;
1008 }
1009 *entry.active_session_id.lock().await = id.clone();
1010 }
1011
1012 return (id, session, ws_tx, token_store);
1013 }
1014
1015 let session_id = self
1017 .create_session(user_key, None, None, true)
1018 .await
1019 .unwrap_or_else(|_| "default".to_string());
1020
1021 let (session, ws_tx) = self
1022 .get_session(user_key, &session_id)
1023 .await
1024 .expect("just-created session must exist");
1025
1026 (session_id, session, ws_tx, token_store)
1027 }
1028
1029 pub async fn ensure_user_session(
1032 &self,
1033 user_key: &str,
1034 ) -> (Arc<Mutex<Session>>, broadcast::Sender<String>, Arc<pep::MemoryTokenStore>) {
1035 let (_id, session, ws_tx, token_store) = self.ensure_active_session(user_key).await;
1036 (session, ws_tx, token_store)
1037 }
1038
1039 pub async fn set_active_session(&self, user_key: &str, session_id: &str) {
1041 if let Some(user_sessions) = self.sessions.get(user_key) {
1042 if user_sessions.sessions.contains_key(session_id) {
1043 *user_sessions.active_session_id.lock().await = session_id.to_string();
1044 }
1045 }
1046 }
1047
1048 pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
1059 let user_key = effective_user_key(user_key);
1060 let hash = trustee_core::user_hash(&user_key);
1061 dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&hash))
1062 }
1063
1064 pub fn get_user_config_and_home(&self, user_key: &str) -> (Option<String>, Option<std::path::PathBuf>) {
1069 (self.config_toml.clone(), self.get_user_home_dir(user_key))
1070 }
1071
1072 fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
1081 let user_key = effective_user_key(user_key);
1082 let user_hash = trustee_core::user_hash(&user_key);
1083
1084 let user_home = if let Some(home) = dirs::home_dir() {
1086 let user_home = home.join(".trustee").join("users").join(&user_hash);
1087 session.home_dir = Some(user_home.clone());
1088 Some(user_home)
1089 } else {
1090 None
1091 };
1092
1093 if let Some(ref user_home) = user_home {
1097 if materialized_user_missing_env(user_home) {
1098 tracing::warn!(
1099 "user namespace {} is materialized but has NO per-user .env — \
1100 ${{…}} references will fall through to process env \
1101 (global ~/.trustee/.env) and execute under the GLOBAL principal",
1102 &user_hash[..8.min(user_hash.len())]
1103 );
1104 }
1105 }
1106
1107 session.project_id = Some(format!("web{}", &user_hash[..16]));
1108
1109 let shared_secrets = session.secrets.clone().unwrap_or_default();
1116 let mut merged_secrets = shared_secrets.clone();
1117
1118 if let Some(ref user_home) = user_home {
1119 if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
1120 merged_secrets = merged;
1121 }
1122 }
1123
1124 if let Some(ref user_home) = user_home {
1126 if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref()) {
1127 session.config_toml = Some(merged);
1128 tracing::debug!("Merged per-user config into session");
1129 }
1130 }
1131
1132 if let Some(ref mut config_toml) = session.config_toml {
1134 substitute_env_vars(
1135 config_toml,
1136 &merged_secrets,
1137 &format!("user {}", &user_hash[..8.min(user_hash.len())]),
1138 );
1139 }
1140
1141 session.secrets = Some(shared_secrets);
1143 }
1144
1145 fn load_user_secrets(
1148 &self,
1149 user_home: &std::path::Path,
1150 base: &std::collections::HashMap<String, String>,
1151 ) -> std::io::Result<std::collections::HashMap<String, String>> {
1152 let user_env_path = user_home.join(".env");
1153 if !user_env_path.exists() {
1154 return Ok(base.clone());
1155 }
1156 let content = std::fs::read_to_string(&user_env_path)?;
1157 let mut merged = base.clone();
1158 for line in content.lines() {
1159 let line = line.trim();
1160 if line.is_empty() || line.starts_with('#') {
1161 continue;
1162 }
1163 if let Some((key, value)) = line.split_once('=') {
1164 let key = key.trim().to_string();
1165 let value = value
1166 .trim()
1167 .trim_matches('"')
1168 .trim_matches('\'')
1169 .to_string();
1170 merged.insert(key, value);
1171 }
1172 }
1173 tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
1174 Ok(merged)
1175 }
1176
1177 fn merge_user_config(
1192 &self,
1193 user_home: &std::path::Path,
1194 shared_config: Option<&str>,
1195 ) -> Option<String> {
1196 let user_config_path = user_home.join("config").join("trustee.toml");
1197 if !user_config_path.exists() {
1198 return None;
1199 }
1200 let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
1201 let shared = shared_config
1202 .unwrap_or("")
1203 .parse::<toml::Value>()
1204 .ok()?;
1205 let overlay = user_config_toml.parse::<toml::Value>().ok()?;
1206
1207 let allowed = |section: &str| {
1208 section == "mcp"
1209 || section == "thq"
1213 || section == "lifecycle"
1218 || (self.allow_llm_overlay && section == "llm")
1219 };
1220 let dir_name = user_home
1223 .file_name()
1224 .and_then(|n| n.to_str())
1225 .unwrap_or("<unknown>");
1226 let masked_user = dir_name.get(..8).unwrap_or(dir_name);
1227
1228 let mut filtered_overlay = toml::map::Map::new();
1229 if let Some(table) = overlay.as_table() {
1230 for (section, value) in table {
1231 if section == "lifecycle" {
1232 let mut v = value.clone();
1233 if let Some(t) = v.as_table_mut() {
1234 if t.remove("enabled").is_some() {
1235 tracing::info!(
1236 "user config overlay: [lifecycle].enabled is not user-settable — stripped for user {}",
1237 masked_user
1238 );
1239 }
1240 }
1241 filtered_overlay.insert(section.clone(), v);
1242 } else if allowed(section) {
1243 filtered_overlay.insert(section.clone(), value.clone());
1244 } else {
1245 tracing::warn!(
1246 "user config overlay: dropping non-allowlisted section [{}] for user {}",
1247 section,
1248 masked_user
1249 );
1250 }
1251 }
1252 }
1253
1254 if filtered_overlay.is_empty() {
1255 return None;
1257 }
1258 let overlay = toml::Value::Table(filtered_overlay);
1259
1260 let mut shared = shared;
1261 deep_merge_toml(&mut shared, &overlay);
1262 let merged = toml::to_string(&shared).ok()?;
1263 tracing::debug!("Merged per-user config from {}", user_config_path.display());
1264 Some(merged)
1265 }
1266
1267 pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1276 let config_toml = self.config_toml.clone()?;
1277
1278 let user_home = self.get_user_home_dir(user_key)?;
1280
1281 let mut merged_secrets = self.secrets.clone().unwrap_or_default();
1283 if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
1284 merged_secrets = merged;
1285 }
1286
1287 let mut resolved = config_toml;
1289 if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
1290 resolved = merged;
1291 }
1292
1293 substitute_env_vars(
1295 &mut resolved,
1296 &merged_secrets,
1297 &format!(
1298 "resolve_user_config user {}",
1299 &trustee_core::user_hash(user_key)[..8]
1300 ),
1301 );
1302
1303 Some(resolved)
1304 }
1305
1306 pub async fn get_or_build_mcp_loader(
1322 &self,
1323 user_key: &str,
1324 token_store: &Arc<pep::MemoryTokenStore>,
1325 ) -> Result<Option<std::sync::Arc<abk::agent::McpToolLoader>>, String> {
1326 let user_key = effective_user_key(user_key);
1327 let user_hash = trustee_core::user_hash(&user_key);
1328
1329 let resolved = self.resolve_user_config(&user_key);
1331 let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1332
1333 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1335 match loader_freshness(
1336 entry.fingerprint,
1337 entry.built_at,
1338 entry.degraded.as_deref(),
1339 entry.failed_at,
1340 fingerprint,
1341 chrono::Utc::now(),
1342 ) {
1343 LoaderFreshness::Fresh => return Ok(entry.loader.clone()),
1344 LoaderFreshness::ConfigChanged => {
1345 }
1347 LoaderFreshness::AgedOut => {
1348 tracing::info!(
1351 "MCP toolset for user {} is {}s old (max {}s) — rebuilding \
1352 to re-list the served surface (phantom-tool guard)",
1353 &user_hash[..8.min(user_hash.len())],
1354 (chrono::Utc::now() - entry.built_at).num_seconds(),
1355 MCP_TOOLSET_MAX_AGE.as_secs()
1356 );
1357 }
1358 LoaderFreshness::Backoff(remaining) => {
1359 let err = entry
1360 .degraded
1361 .clone()
1362 .unwrap_or_else(|| "previous MCP loader build failed; retry after backoff".to_string());
1363 let _ = remaining;
1364 return Err(err);
1365 }
1366 }
1367 }
1368
1369 let lock = self
1371 .mcp_build_locks
1372 .entry(user_hash.clone())
1373 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1374 .clone();
1375 let _guard = lock.lock().await;
1376
1377 if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1379 if loader_freshness(
1380 entry.fingerprint,
1381 entry.built_at,
1382 entry.degraded.as_deref(),
1383 entry.failed_at,
1384 fingerprint,
1385 chrono::Utc::now(),
1386 ) == LoaderFreshness::Fresh
1387 {
1388 return Ok(entry.loader.clone());
1389 }
1390 }
1391
1392 match self
1393 .build_mcp_loader(&user_hash, resolved.as_deref(), fingerprint, token_store)
1394 .await
1395 {
1396 Ok(entry) => {
1397 self.mcp_loaders.insert(user_hash.clone(), entry);
1398 Ok(self.mcp_loaders.get(&user_hash).unwrap().loader.clone())
1399 }
1400 Err(err) => {
1401 tracing::warn!(
1402 "MCP loader build FAILED for user {}; dispatch fails loud, retry after {:?}",
1403 &user_hash[..8.min(user_hash.len())],
1404 MCP_BUILD_RETRY_BACKOFF
1405 );
1406 self.mcp_loaders.insert(
1407 user_hash,
1408 McpLoaderEntry {
1409 loader: None,
1410 fingerprint,
1411 built_at: chrono::Utc::now(),
1412 degraded: Some(err.clone()),
1413 failed_at: Some(chrono::Utc::now()),
1414 },
1415 );
1416 Err(err)
1417 }
1418 }
1419 }
1420
1421 async fn build_mcp_loader(
1424 &self,
1425 user_hash: &str,
1426 resolved: Option<&str>,
1427 fingerprint: u64,
1428 token_store: &Arc<pep::MemoryTokenStore>,
1429 ) -> Result<McpLoaderEntry, String> {
1430 let mcp_config: Option<abk::config::McpConfig> = match resolved {
1431 Some(toml_str) => {
1432 let value = toml_str
1433 .parse::<toml::Value>()
1434 .map_err(|e| format!("config parse failed: {}", e))?;
1435 match value.get("mcp") {
1436 Some(section) => {
1437 let mut leftovers: Vec<String> = Vec::new();
1444 collect_unresolved_placeholders(section, &mut leftovers);
1445 if !leftovers.is_empty() {
1446 return Err(format!(
1447 "unresolved {} placeholder(s) in [mcp]: {} — supply the \
1448 exact-case key in the profile secrets",
1449 leftovers.len(),
1450 leftovers.join(", ")
1451 ));
1452 }
1453 use serde::Deserialize as _;
1454 Some(
1455 abk::config::McpConfig::deserialize(section.clone())
1456 .map_err(|e| format!("invalid [mcp] config: {}", e))?,
1457 )
1458 }
1459 None => None,
1460 }
1461 }
1462 None => None,
1463 };
1464
1465 let loader = match mcp_config {
1466 Some(cfg) if cfg.enabled => {
1467 let built = abk::agent::McpToolLoader::with_token_store(
1468 &cfg,
1469 Some(token_store.clone() as std::sync::Arc<dyn pep::token_store::TokenStore>),
1470 )
1471 .await
1472 .map_err(|e| format!("MCP loader build failed: {}", e))?;
1473
1474 let servers: Vec<String> = built
1478 .server_statuses
1479 .iter()
1480 .map(|s| {
1481 if s.connected {
1482 format!("{}(up,{}tools)", s.name, s.tool_count)
1483 } else {
1484 format!("{}(DOWN)", s.name)
1485 }
1486 })
1487 .collect();
1488 tracing::info!(
1489 "MCP loader built for user {}: servers=[{}] total_tools={}",
1490 &user_hash[..8.min(user_hash.len())],
1491 servers.join(", "),
1492 built.tool_count
1493 );
1494 Some(std::sync::Arc::new(built))
1495 }
1496 _ => None,
1497 };
1498
1499 Ok(McpLoaderEntry {
1500 loader,
1501 fingerprint,
1502 built_at: chrono::Utc::now(),
1503 degraded: None,
1504 failed_at: None,
1505 })
1506 }
1507
1508 fn spawn_user_drain_task(
1510 &self,
1511 session_id: String,
1512 session: Arc<Mutex<Session>>,
1513 ws_tx: broadcast::Sender<String>,
1514 mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
1515 ) {
1516 let mut last_broadcast_state: Option<String> = None;
1522 tokio::spawn(async move {
1523 while let Some(msg) = workflow_rx.recv().await {
1524 {
1525 let mut session = session.lock().await;
1526 session.handle_workflow_message(msg.clone());
1527
1528 let state_str = match session.workflow_state {
1529 trustee_core::types::WorkflowState::Idle => "Idle",
1530 trustee_core::types::WorkflowState::Running => "Running",
1531 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1532 };
1533 if last_broadcast_state.as_deref() != Some(state_str) {
1534 last_broadcast_state = Some(state_str.to_string());
1535 let state_msg = serde_json::json!({
1536 "type": "StateChanged",
1537 "state": state_str
1538 });
1539 let _ = ws_tx.send(state_msg.to_string());
1540 }
1541 }
1542
1543 let json =
1544 serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1545 let _ = ws_tx.send(json);
1546 }
1547 tracing::debug!("Drain task ended for session: {}", session_id);
1548 });
1549 }
1550
1551 pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1554 let default_user = self
1556 .sessions
1557 .get("default")
1558 .expect("default user must exist");
1559 let first_entry = default_user
1560 .sessions
1561 .iter()
1562 .next()
1563 .expect("default user must have at least one session");
1564 let session = first_entry.session.clone();
1565 let ws_tx = first_entry.ws_tx.clone();
1566 let session_id = first_entry.key().clone();
1567 drop(first_entry);
1568 drop(default_user);
1569
1570 let mut last_broadcast_state = Some("Running".to_string());
1574 tokio::spawn(async move {
1575 while let Some(msg) = workflow_rx.recv().await {
1576 {
1577 let mut session = session.lock().await;
1578 session.handle_workflow_message(msg.clone());
1579
1580 let state_str = match session.workflow_state {
1581 trustee_core::types::WorkflowState::Idle => "Idle",
1582 trustee_core::types::WorkflowState::Running => "Running",
1583 trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1584 };
1585 if last_broadcast_state.as_deref() != Some(state_str) {
1586 last_broadcast_state = Some(state_str.to_string());
1587 let state_msg = serde_json::json!({
1588 "type": "StateChanged",
1589 "state": state_str
1590 });
1591 let _ = ws_tx.send(state_msg.to_string());
1592 }
1593 }
1594
1595 let json =
1596 serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1597 let _ = ws_tx.send(json);
1598 }
1599 tracing::debug!("Drain task ended for session: {}", session_id);
1600 });
1601 }
1602
1603 pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
1605 let Some(ref auth) = self.auth else {
1606 return "default".to_string();
1607 };
1608
1609 if let Some(token) = headers
1611 .get(axum::http::header::AUTHORIZATION)
1612 .and_then(|v| v.to_str().ok())
1613 .and_then(|v| v.strip_prefix("Bearer "))
1614 .map(|s| s.to_string())
1615 {
1616 if token.starts_with("dev:") {
1617 let parts: Vec<&str> = token.splitn(4, ':').collect();
1618 if parts.len() >= 4 {
1619 return format!("dev:{}", parts[1]);
1620 }
1621 }
1622 if let Ok(claims) = auth.validate_token(&token).await {
1623 return claims.sub;
1624 }
1625 }
1626
1627 let cookie_session_id = headers
1629 .get(axum::http::header::COOKIE)
1630 .and_then(|v| v.to_str().ok())
1631 .and_then(|cookies| {
1632 cookies
1633 .split(';')
1634 .map(|c| c.trim())
1635 .find_map(|c| {
1636 c.strip_prefix(&format!("{}=", auth.config.cookie_name))
1637 .map(|s| s.to_string())
1638 })
1639 });
1640
1641 if let Some(session_id) = cookie_session_id {
1642 if session_id.starts_with("dev:") {
1643 let parts: Vec<&str> = session_id.splitn(4, ':').collect();
1644 if parts.len() >= 4 {
1645 return format!("dev:{}", parts[1]);
1646 }
1647 }
1648
1649 if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
1650 if let Ok(claims) = auth.validate_token(&access_token).await {
1651 return claims.sub;
1652 }
1653 }
1654 }
1655
1656 "default".to_string()
1657 }
1658}
1659
1660struct SerializableMessage<'a>(&'a TuiMessage);
1666
1667impl<'a> serde::Serialize for SerializableMessage<'a> {
1668 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1669 where
1670 S: serde::Serializer,
1671 {
1672 use serde::ser::SerializeStruct;
1673
1674 match self.0 {
1675 TuiMessage::OutputLine(line) => {
1676 let mut s = serializer.serialize_struct("msg", 2)?;
1677 s.serialize_field("type", "OutputLine")?;
1678 s.serialize_field("line", line)?;
1679 s.end()
1680 }
1681 TuiMessage::StreamDelta(delta) => {
1682 let mut s = serializer.serialize_struct("msg", 2)?;
1683 s.serialize_field("type", "StreamDelta")?;
1684 s.serialize_field("delta", delta)?;
1685 s.end()
1686 }
1687 TuiMessage::ReasoningDelta(delta) => {
1688 let mut s = serializer.serialize_struct("msg", 2)?;
1689 s.serialize_field("type", "ReasoningDelta")?;
1690 s.serialize_field("delta", delta)?;
1691 s.end()
1692 }
1693 TuiMessage::WorkflowCompleted => {
1694 let mut s = serializer.serialize_struct("msg", 2)?;
1695 s.serialize_field("type", "WorkflowCompleted")?;
1696 s.serialize_field("state", "Idle")?;
1697 s.end()
1698 }
1699 TuiMessage::WorkflowError(err) => {
1700 let mut s = serializer.serialize_struct("msg", 2)?;
1701 s.serialize_field("type", "WorkflowError")?;
1702 s.serialize_field("error", err)?;
1703 s.end()
1704 }
1705 TuiMessage::ResumeInfo(info) => match info {
1706 Some(ri) => {
1707 let mut s = serializer.serialize_struct("msg", 5)?;
1708 s.serialize_field("type", "ResumeInfo")?;
1709 s.serialize_field("state", "Idle")?;
1710 s.serialize_field("session_id", &ri.session_id)?;
1711 s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
1712 s.serialize_field("iteration", &ri.iteration)?;
1713 s.end()
1714 }
1715 None => {
1716 let mut s = serializer.serialize_struct("msg", 2)?;
1717 s.serialize_field("type", "ResumeInfo")?;
1718 s.serialize_field("state", "Idle")?;
1719 s.end()
1720 }
1721 },
1722 TuiMessage::TodoUpdate(content) => {
1723 let mut s = serializer.serialize_struct("msg", 2)?;
1724 s.serialize_field("type", "TodoUpdate")?;
1725 s.serialize_field("content", content)?;
1726 s.end()
1727 }
1728 TuiMessage::WorkflowCancelled => {
1729 let mut s = serializer.serialize_struct("msg", 2)?;
1730 s.serialize_field("type", "WorkflowCancelled")?;
1731 s.serialize_field("state", "Idle")?;
1732 s.end()
1733 }
1734 TuiMessage::HandoffReady(briefing) => {
1735 let mut s = serializer.serialize_struct("msg", 3)?;
1736 s.serialize_field("type", "HandoffReady")?;
1737 s.serialize_field("state", "Idle")?;
1738 s.serialize_field("briefing", briefing)?;
1739 s.end()
1740 }
1741 TuiMessage::SessionRotated { old, new } => {
1742 let mut s = serializer.serialize_struct("msg", 3)?;
1743 s.serialize_field("type", "SessionRotated")?;
1744 s.serialize_field("old", old)?;
1745 s.serialize_field("new", new)?;
1746 s.end()
1747 }
1748 TuiMessage::HandoffFailed => {
1749 let mut s = serializer.serialize_struct("msg", 2)?;
1750 s.serialize_field("type", "HandoffFailed")?;
1751 s.serialize_field("state", "Idle")?;
1752 s.end()
1753 }
1754 TuiMessage::ToolPending {
1755 tool_name,
1756 hint,
1757 } => {
1758 let mut s = serializer.serialize_struct("msg", 3)?;
1759 s.serialize_field("type", "ToolPending")?;
1760 s.serialize_field("tool_name", tool_name)?;
1761 s.serialize_field("hint", hint)?;
1762 s.end()
1763 }
1764 TuiMessage::ToolDone {
1765 tool_name,
1766 success,
1767 hint,
1768 } => {
1769 let mut s = serializer.serialize_struct("msg", 4)?;
1770 s.serialize_field("type", "ToolDone")?;
1771 s.serialize_field("tool_name", tool_name)?;
1772 s.serialize_field("success", success)?;
1773 s.serialize_field("hint", hint)?;
1774 s.end()
1775 }
1776 TuiMessage::ContextTokensUpdated(count) => {
1777 let mut s = serializer.serialize_struct("msg", 2)?;
1778 s.serialize_field("type", "ContextTokensUpdated")?;
1779 s.serialize_field("count", count)?;
1780 s.end()
1781 }
1782 TuiMessage::McpServerStatus {
1783 name,
1784 connected,
1785 tool_count,
1786 error,
1787 } => {
1788 let mut s = serializer.serialize_struct("msg", 5)?;
1789 s.serialize_field("type", "McpServerStatus")?;
1790 s.serialize_field("name", name)?;
1791 s.serialize_field("connected", connected)?;
1792 s.serialize_field("tool_count", tool_count)?;
1793 s.serialize_field("error", error)?;
1794 s.end()
1795 }
1796 TuiMessage::SessionTitleUpdated(title) => {
1797 let mut s = serializer.serialize_struct("msg", 2)?;
1798 s.serialize_field("type", "SessionTitleUpdated")?;
1799 s.serialize_field("title", title)?;
1800 s.end()
1801 }
1802 }
1803 }
1804}
1805
1806fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1817 match (base, overlay) {
1818 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1819 for (key, overlay_val) in overlay_table {
1820 match base_table.get_mut(key) {
1821 Some(base_val) => {
1822 deep_merge_toml(base_val, overlay_val);
1824 }
1825 None => {
1826 base_table.insert(key.clone(), overlay_val.clone());
1828 }
1829 }
1830 }
1831 }
1832 (base, overlay) => {
1834 *base = overlay.clone();
1835 }
1836 }
1837}
1838
1839fn collect_unresolved_placeholders(value: &toml::Value, out: &mut Vec<String>) {
1846 match value {
1847 toml::Value::String(s) => {
1848 if let Some(start) = s.find("${") {
1849 if let Some(end_rel) = s[start + 2..].find('}') {
1850 let name = &s[start + 2..start + 2 + end_rel];
1851 let rendered = format!("${{{name}}}");
1852 if !out.contains(&rendered) {
1853 out.push(rendered);
1854 }
1855 }
1856 }
1857 }
1858 toml::Value::Table(t) => {
1859 for v in t.values() {
1860 collect_unresolved_placeholders(v, out);
1861 }
1862 }
1863 toml::Value::Array(a) => {
1864 for v in a {
1865 collect_unresolved_placeholders(v, out);
1866 }
1867 }
1868 _ => {}
1869 }
1870}
1871
1872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1874pub(crate) enum SecretSource {
1875 SecretsMap,
1878 ProcessEnv,
1884 Unresolved,
1886}
1887
1888#[derive(Debug, Clone, PartialEq, Eq)]
1890pub(crate) struct EnvVarResolution {
1891 pub name: String,
1892 pub source: SecretSource,
1893}
1894
1895fn substitute_env_vars(s: &mut String, secrets: &std::collections::HashMap<String, String>, ctx: &str) -> Vec<EnvVarResolution> {
1896 let mut result = String::with_capacity(s.len());
1898 let bytes = s.as_bytes();
1899 let mut i = 0;
1900 let mut report: Vec<EnvVarResolution> = Vec::new();
1903
1904 while i < bytes.len() {
1905 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1906 if let Some(end) = s[i + 2..].find('}') {
1908 let var_name = &s[i + 2..i + 2 + end];
1909 let (replacement, source) = if let Some(value) = secrets.get(var_name) {
1911 (value.clone(), SecretSource::SecretsMap)
1912 } else if let Ok(value) = std::env::var(var_name) {
1913 (value, SecretSource::ProcessEnv)
1914 } else {
1915 (
1920 s[i..i + 2 + end + 1].to_string(),
1921 SecretSource::Unresolved,
1922 )
1923 };
1924 if !report.iter().any(|r| r.name == var_name) {
1925 report.push(EnvVarResolution {
1926 name: var_name.to_string(),
1927 source,
1928 });
1929 }
1930 result.push_str(&replacement);
1931 i = i + 2 + end + 1;
1932 } else {
1933 result.push('$');
1935 i += 1;
1936 }
1937 } else {
1938 result.push(bytes[i] as char);
1939 i += 1;
1940 }
1941 }
1942
1943 for r in &report {
1948 match r.source {
1949 SecretSource::ProcessEnv => tracing::error!(
1950 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from PROCESS ENV \
1951 (global ~/.trustee/.env at boot) — no per-user/profile secret \
1952 answered; calls using it execute under the GLOBAL principal. \
1953 Supply the exact-case key in the per-user .env / profile \
1954 secrets to restore attribution.",
1955 ctx,
1956 r.name
1957 ),
1958 SecretSource::Unresolved => {
1959 }
1961 SecretSource::SecretsMap => tracing::debug!(
1962 "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from profile/user secrets",
1963 ctx,
1964 r.name
1965 ),
1966 }
1967 }
1968 let unresolved: Vec<&str> = report
1969 .iter()
1970 .filter(|r| r.source == SecretSource::Unresolved)
1971 .map(|r| r.name.as_str())
1972 .collect();
1973 if !unresolved.is_empty() {
1974 tracing::warn!(
1975 "ENV SUBSTITUTION LEFT {} UNRESOLVED [{}]: {} — the literal ${{…}} remains in the \
1976 effective config; supply the key in the profile secrets (exact-case KEY=VALUE) \
1977 or the environment",
1978 unresolved.len(),
1979 ctx,
1980 unresolved
1981 .iter()
1982 .map(|v| format!("${{{v}}}"))
1983 .collect::<Vec<_>>()
1984 .join(", ")
1985 );
1986 }
1987
1988 *s = result;
1989 report
1990}
1991
1992pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1997 user_home.is_dir() && !user_home.join(".env").exists()
1998}
1999
2000fn fingerprint_mcp_section(resolved: Option<&str>) -> u64 {
2010 use sha2::{Digest, Sha256};
2011 let section = resolved
2012 .and_then(|s| s.parse::<toml::Value>().ok())
2013 .and_then(|v| v.get("mcp").cloned());
2014 let bytes = match section {
2015 Some(v) => v.to_string().into_bytes(),
2016 None => b"<no-mcp>".to_vec(),
2017 };
2018 let digest = Sha256::digest(&bytes);
2019 u64::from_be_bytes(digest[..8].try_into().expect("sha256 digest >= 8 bytes"))
2020}
2021
2022#[cfg(test)]
2023mod tests {
2024 use super::*;
2025
2026 fn test_state() -> ServerState {
2028 let (session, _rx) = Session::new();
2029 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2030 ServerState::new(session, ws_tx, None)
2031 }
2032
2033 #[tokio::test]
2034 async fn dispatch_scope_pins_user_key_resolution() {
2035 let state = test_state();
2039 let caller = "some-human-sub";
2040 let plain_home = state.get_user_home_dir(caller).unwrap();
2041 let pinned_key = "prof-1234";
2042 let pinned_home = in_dispatch_scope(pinned_key, async {
2043 state.get_user_home_dir(caller).unwrap()
2044 })
2045 .await;
2046 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2047 assert_eq!(plain_home, users_root.join(trustee_core::user_hash(caller)));
2048 assert_eq!(
2049 pinned_home,
2050 users_root.join(trustee_core::user_hash(pinned_key)),
2051 "dispatch scope must pin resolution to the binding key"
2052 );
2053 assert_ne!(plain_home, pinned_home);
2054 }
2055
2056 fn temp_user_home(tag: &str) -> std::path::PathBuf {
2058 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2059 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2060 let dir = std::env::temp_dir().join(format!(
2061 "trustee-state-test-{}-{}-{}",
2062 tag,
2063 std::process::id(),
2064 n
2065 ));
2066 std::fs::create_dir_all(dir.join("config")).expect("create temp user home");
2067 dir
2068 }
2069
2070 fn parse(toml_str: &str) -> toml::Value {
2071 toml_str.parse::<toml::Value>().expect("valid test TOML")
2072 }
2073
2074 #[test]
2077 fn overlay_allowlist_drops_non_allowlisted_sections() {
2078 let state = test_state();
2079 let home = temp_user_home("allowlist");
2080 std::fs::write(
2081 home.join("config").join("trustee.toml"),
2082 "[server]\nport = 1\n\n[auth]\nmode = \"kanidm\"\n\n[mcp]\nmode = \"user\"\n",
2083 )
2084 .expect("write overlay");
2085
2086 let shared = "[server]\nport = 8080\n\n[mcp]\nmode = \"shared\"\n";
2087 let merged = state
2088 .merge_user_config(&home, Some(shared))
2089 .expect("overlay has allowlisted content");
2090
2091 let merged_val = parse(&merged);
2092 let expected = parse("[server]\nport = 8080\n\n[mcp]\nmode = \"user\"\n");
2093 assert_eq!(merged_val, expected, "merged config must be shared + [mcp] overlay only");
2094 assert!(merged_val.get("auth").is_none(), "overlay [auth] must be dropped");
2095 }
2096
2097 #[test]
2099 fn no_overlay_file_returns_none() {
2100 let state = test_state();
2101 let home = temp_user_home("empty");
2102 assert!(state
2103 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2104 .is_none());
2105 }
2106
2107 #[test]
2109 fn overlay_with_no_allowlisted_sections_is_noop() {
2110 let state = test_state();
2111 let home = temp_user_home("all-dropped");
2112 std::fs::write(
2113 home.join("config").join("trustee.toml"),
2114 "[server]\nport = 1\n\n[storage]\npath = \"/tmp/x\"\n",
2115 )
2116 .expect("write overlay");
2117 assert!(state
2118 .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2119 .is_none());
2120 }
2121
2122 #[test]
2125 fn llm_overlay_dropped_by_default_and_kept_when_enabled() {
2126 let shared = "[llm]\nprovider = \"openai\"\n\n[mcp]\nmode = \"shared\"\n";
2127 let overlay = "[llm]\nprovider = \"anthropic\"\n";
2128
2129 let state = test_state();
2131 assert!(!state.allow_llm_overlay);
2132 let home = temp_user_home("llm-off");
2133 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2134 assert!(state.merge_user_config(&home, Some(shared)).is_none());
2135
2136 let state = test_state().with_allow_llm_overlay(true);
2138 assert!(state.allow_llm_overlay);
2139 let home = temp_user_home("llm-on");
2140 std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2141 let merged = state
2142 .merge_user_config(&home, Some(shared))
2143 .expect("[llm] overlay applies when opted in");
2144 let merged_val = parse(&merged);
2145 assert_eq!(merged_val["llm"]["provider"].as_str(), Some("anthropic"));
2146 assert_eq!(merged_val["mcp"]["mode"].as_str(), Some("shared"));
2147 }
2148
2149 #[test]
2153 fn user_home_dir_uses_consolidated_hash() {
2154 let state = test_state();
2155 if let Some(home) = state.get_user_home_dir("farzan@example.com") {
2156 assert_eq!(
2157 home.file_name().and_then(|n| n.to_str()),
2158 Some(trustee_core::user_hash("farzan@example.com")).as_deref()
2159 );
2160 let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2161 assert_eq!(home.parent(), Some(&users_root).map(|p| p.as_path()));
2162 }
2163 }
2165
2166 #[test]
2169 fn lifecycle_overlay_survives_allowlist_enabled_stripped() {
2170 let state = test_state();
2171 let home = temp_user_home("lifecycle-persona");
2172 std::fs::write(
2173 home.join("config").join("trustee.toml"),
2174 "[lifecycle]\nsystem_template = \"You are Saman.\"\nenabled = true\n",
2175 )
2176 .expect("write overlay");
2177
2178 let merged = state
2179 .merge_user_config(
2180 &home,
2181 Some("[lifecycle]\nsystem_template = \"shared default\"\n"),
2182 )
2183 .expect("lifecycle overlay is allowlisted");
2184
2185 let v = merged.parse::<toml::Value>().expect("valid TOML");
2186 assert_eq!(
2187 v["lifecycle"]["system_template"].as_str(),
2188 Some("You are Saman."),
2189 "agent persona wins over shared default"
2190 );
2191 assert_eq!(
2192 v["lifecycle"].get("enabled"),
2193 None,
2194 "WASM extension switch is not user-settable"
2195 );
2196 }
2197
2198 #[tokio::test]
2203 async fn agent_principals_get_isolated_session_buckets() {
2204 let state = test_state();
2205 let key_a = "agent-farzan";
2206 let key_b = "agent-paydar";
2207
2208 let (sid_a, session_a, _tx_a, _ts_a) = state.ensure_active_session(key_a).await;
2209 let (_sid_b, _session_b, _tx_b, _ts_b) = state.ensure_active_session(key_b).await;
2210
2211 assert!(
2213 state.get_session_by_any_id(key_a, &sid_a).await.is_some(),
2214 "owner bucket resolves its own session"
2215 );
2216 assert!(
2217 state.get_session_by_any_id(key_b, &sid_a).await.is_none(),
2218 "cross-agent session access must be 404/None"
2219 );
2220 assert!(Arc::strong_count(&session_a) >= 1);
2221
2222 if let Some(home) = state.get_user_home_dir(key_a) {
2224 assert_eq!(
2225 home.file_name().and_then(|n| n.to_str()),
2226 Some(trustee_core::user_hash(key_a)).as_deref()
2227 );
2228 }
2229 }
2230}
2231
2232#[cfg(test)]
2237mod mcp_loader_cache_tests {
2238 use super::*;
2239
2240 fn state_with_shared(shared: &str) -> ServerState {
2241 let (session, _rx) = Session::new();
2242 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2243 let mut state = ServerState::new(session, ws_tx, None);
2244 state.config_toml = Some(shared.to_string());
2245 state
2246 }
2247
2248 struct TempUser {
2252 key: String,
2253 home: std::path::PathBuf,
2254 }
2255
2256 impl TempUser {
2257 fn new(tag: &str) -> Self {
2258 let key = format!("16c-{tag}-{}@test.invalid", std::process::id());
2259 let home = dirs::home_dir()
2260 .expect("HOME available in test env")
2261 .join(".trustee")
2262 .join("users")
2263 .join(trustee_core::user_hash(&key));
2264 std::fs::create_dir_all(home.join("config")).expect("create user home");
2265 Self { key, home }
2266 }
2267
2268 fn write_overlay(&self, toml_str: &str) {
2269 std::fs::write(self.home.join("config").join("trustee.toml"), toml_str)
2270 .expect("write overlay");
2271 }
2272 }
2273
2274 impl Drop for TempUser {
2275 fn drop(&mut self) {
2276 let _ = std::fs::remove_dir_all(&self.home);
2277 }
2278 }
2279
2280 #[tokio::test]
2281 async fn no_mcp_config_caches_disabled_marker() {
2282 let state = state_with_shared("[server]\nport = 8080\n");
2283 let user = TempUser::new("nomcp");
2284 let ts = Arc::new(pep::MemoryTokenStore::new());
2285
2286 let first = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2287 assert!(first.is_none(), "no [mcp] anywhere → disabled marker");
2288 assert_eq!(state.mcp_loaders.len(), 1, "exactly one cache entry");
2289
2290 let second = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2292 assert!(second.is_none());
2293 assert_eq!(state.mcp_loaders.len(), 1);
2294 }
2295
2296 #[tokio::test]
2297 async fn concurrent_cold_builds_single_flight() {
2298 let state = Arc::new(state_with_shared("[server]\nport = 8080\n"));
2299 let user = TempUser::new("singleflight");
2300 let ts = Arc::new(pep::MemoryTokenStore::new());
2301
2302 let mut handles = Vec::new();
2303 for _ in 0..5 {
2304 let state = state.clone();
2305 let key = user.key.clone();
2306 let ts = ts.clone();
2307 handles.push(tokio::spawn(async move {
2308 state.get_or_build_mcp_loader(&key, &ts).await
2309 }));
2310 }
2311 for h in handles {
2312 h.await.unwrap().expect("all five succeed");
2313 }
2314 assert_eq!(state.mcp_loaders.len(), 1, "single-flight → one entry");
2315 }
2316
2317 #[tokio::test]
2318 async fn fingerprint_change_triggers_rebuild() {
2319 let state = state_with_shared("[server]\nport = 8080\n");
2320 let user = TempUser::new("fpchange");
2321 let ts = Arc::new(pep::MemoryTokenStore::new());
2322
2323 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2326 let v1 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2327 assert!(v1.is_some(), "enabled [mcp] → real loader");
2328 let fp1 = state
2329 .mcp_loaders
2330 .get(&trustee_core::user_hash(&user.key))
2331 .unwrap()
2332 .fingerprint;
2333
2334 std::thread::sleep(std::time::Duration::from_millis(5));
2336 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v2\"\nurl = \"http://127.0.0.1:9/other\"\n");
2337 let v2 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2338 assert!(v2.is_some());
2339 let entry = state
2340 .mcp_loaders
2341 .get(&trustee_core::user_hash(&user.key))
2342 .unwrap();
2343 assert_ne!(
2344 entry.fingerprint, fp1,
2345 "fingerprint must change with content"
2346 );
2347 assert!(entry.degraded.is_none());
2348
2349 let _still_usable = v1.as_ref().unwrap().tool_count;
2351 }
2352
2353 #[tokio::test]
2354 async fn degraded_entry_fails_loud_within_backoff_and_isolates_users() {
2355 let state = state_with_shared("[server]\nport = 8080\n");
2356 let bad = TempUser::new("degraded-bad");
2357 let good = TempUser::new("degraded-good");
2358 let ts = Arc::new(pep::MemoryTokenStore::new());
2359
2360 bad.write_overlay("[mcp]\nenabled = \"not-a-bool\"\n");
2362 let err = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2363 Ok(_) => panic!("invalid [mcp] must fail loud"),
2364 Err(e) => e,
2365 };
2366 assert!(
2367 err.contains("invalid [mcp]"),
2368 "surfaces the parse error: {err}"
2369 );
2370
2371 let entry = state
2372 .mcp_loaders
2373 .get(&trustee_core::user_hash(&bad.key))
2374 .unwrap();
2375 assert!(entry.degraded.is_some(), "poison entry recorded");
2376
2377 let err2 = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2379 Ok(_) => panic!("still within backoff"),
2380 Err(e) => e,
2381 };
2382 assert_eq!(err, err2, "same cached error");
2383
2384 let good_loader = state
2386 .get_or_build_mcp_loader(&good.key, &ts)
2387 .await
2388 .expect("other user unaffected");
2389 assert!(
2390 good_loader.is_none(),
2391 "good user has no [mcp] → disabled marker"
2392 );
2393 }
2394
2395 #[tokio::test]
2396 async fn aged_out_entry_rebuilds_without_restart() {
2397 let state = state_with_shared("[server]\nport = 8080\n");
2402 let user = TempUser::new("agedout");
2403 let ts = Arc::new(pep::MemoryTokenStore::new());
2404
2405 user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2406 let v1 = state
2407 .get_or_build_mcp_loader(&user.key, &ts)
2408 .await
2409 .expect("initial build");
2410 let v1 = v1.expect("enabled [mcp] → loader");
2411
2412 {
2415 let mut entry = state
2416 .mcp_loaders
2417 .get_mut(&trustee_core::user_hash(&user.key))
2418 .unwrap();
2419 entry.built_at =
2420 chrono::Utc::now() - chrono::Duration::seconds(601);
2421 }
2422
2423 let v2 = state
2424 .get_or_build_mcp_loader(&user.key, &ts)
2425 .await
2426 .expect("aged-out dispatch rebuilds");
2427 let v2 = v2.expect("rebuilt loader present");
2428 assert!(
2429 !Arc::ptr_eq(&v1, &v2),
2430 "aged-out entry must NOT be reused — fresh connect re-lists the served surface"
2431 );
2432 let entry = state
2433 .mcp_loaders
2434 .get(&trustee_core::user_hash(&user.key))
2435 .unwrap();
2436 assert!(
2437 chrono::Utc::now() - entry.built_at < chrono::Duration::seconds(5),
2438 "built_at refreshed by the rebuild"
2439 );
2440
2441 let v3 = state
2443 .get_or_build_mcp_loader(&user.key, &ts)
2444 .await
2445 .expect("fresh dispatch");
2446 assert!(Arc::ptr_eq(&v2, &v3.unwrap()), "young entry hits the cache");
2447 }
2448}
2449
2450#[cfg(test)]
2451mod restore_tests {
2452 use super::*;
2453
2454 fn fresh_state() -> ServerState {
2455 let (session, _rx) = Session::new();
2456 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2457 let mut state = ServerState::new(session, ws_tx, None);
2458 state.config_toml = Some("[agent]\nname = \"trustee\"\n".to_string());
2459 state
2460 }
2461
2462 struct TempUser {
2464 key: String,
2465 }
2466
2467 impl TempUser {
2468 fn new(tag: &str) -> Self {
2469 let key = format!("restore-{tag}-{}@test.invalid", std::process::id());
2470 let home = dirs::home_dir()
2471 .expect("HOME available in test env")
2472 .join(".trustee")
2473 .join("users")
2474 .join(trustee_core::user_hash(&key));
2475 std::fs::create_dir_all(home).expect("create user home");
2476 Self { key }
2477 }
2478 }
2479
2480 #[tokio::test]
2481 async fn restore_unknown_chain_returns_none() {
2482 let state = fresh_state();
2483 let user = TempUser::new("unknown");
2484 let out = state
2485 .get_or_restore_session_by_any_id(&user.key, "session_2099_01_01_00_00_deadbeef")
2486 .await;
2487 assert!(out.is_none(), "unknown chain must 404, not restore");
2488 }
2489
2490 #[tokio::test]
2491 async fn destroy_tombstones_chain_id() {
2492 let state = fresh_state();
2493 let user = TempUser::new("tombstone");
2494 let sid = state
2495 .create_session(&user.key, None, None, true)
2496 .await
2497 .expect("create session");
2498
2499 let (session, _ws) = state.get_session(&user.key, &sid).await.unwrap();
2501 {
2502 let mut s = session.lock().await;
2503 s.session_id = Some("session_2026_09_04_10_00_aaaabbbb".to_string());
2504 }
2505
2506 state.destroy_session(&user.key, &sid).await.expect("destroy");
2507
2508 let path = state
2509 .tombstone_path(&user.key, "session_2026_09_04_10_00_aaaabbbb")
2510 .expect("tombstone path");
2511 assert!(path.exists(), "destroy must write a chain tombstone");
2512
2513 let sid2 = state
2515 .create_session(&user.key, None, None, true)
2516 .await
2517 .expect("create second session");
2518 state.destroy_session(&user.key, &sid2).await.expect("destroy 2");
2519 }
2521
2522 #[tokio::test]
2523 async fn restore_refuses_registry_key_shapes() {
2524 let state = fresh_state();
2528 let user = TempUser::new("shapes");
2529 assert!(state.tombstone_path(&user.key, "default").is_none());
2530 let out = state.get_or_restore_session_by_any_id(&user.key, "default").await;
2531 assert!(out.is_none());
2532 }
2533}
2534
2535 #[test]
2536 fn collect_unresolved_placeholders_walks_the_tree() {
2537 let section: toml::Value = toml::from_str(
2538 r#"
2539[credentials.farzan_service_account]
2540service_token = "${FARZAN_SERVICE_ACCOUNT}"
2541issuer_url = "https://ok.example"
2542
2543[[servers]]
2544name = "fame"
2545url = "https://fame.example"
2546credentials = "farzan_service_account"
2547
2548[[servers]]
2549name = "other"
2550url = "https://other.example"
2551token = "${OTHER_TOKEN}"
2552"#,
2553 )
2554 .unwrap();
2555 let mut out = Vec::new();
2556 collect_unresolved_placeholders(§ion, &mut out);
2557 out.sort();
2558 assert_eq!(
2559 out,
2560 vec!["${FARZAN_SERVICE_ACCOUNT}".to_string(), "${OTHER_TOKEN}".to_string()],
2561 "every leftover placeholder in the [mcp] tree must be collected, deduped"
2562 );
2563 let clean: toml::Value = toml::from_str("[x]\ny = \"z\"\n").unwrap();
2565 let mut out2 = Vec::new();
2566 collect_unresolved_placeholders(&clean, &mut out2);
2567 assert!(out2.is_empty());
2568 }
2569
2570 #[test]
2571 fn substitute_env_vars_resolves_and_keeps_leftovers_deterministically() {
2572 let mut secrets = std::collections::HashMap::new();
2573 secrets.insert("A_TOKEN".to_string(), "resolved-value".to_string());
2574 let mut s = "x = \"${A_TOKEN}\" y = \"${MISSING_TOKEN}\"".to_string();
2575 let report = substitute_env_vars(&mut s, &secrets, "test");
2576 assert!(
2577 s.contains("resolved-value"),
2578 "declared key resolves: {s}"
2579 );
2580 assert!(
2581 s.contains("${MISSING_TOKEN}"),
2582 "undeclared key keeps the literal (the loader guard turns this into a loud failure): {s}"
2583 );
2584 assert_eq!(report.len(), 2, "one entry per distinct var: {report:?}");
2586 assert!(report.contains(&EnvVarResolution {
2587 name: "A_TOKEN".to_string(),
2588 source: SecretSource::SecretsMap
2589 }));
2590 assert!(report.contains(&EnvVarResolution {
2591 name: "MISSING_TOKEN".to_string(),
2592 source: SecretSource::Unresolved
2593 }));
2594 }
2595
2596 #[test]
2597 fn substitute_env_vars_reports_process_env_shadow() {
2598 let var = format!(
2603 "RAVAND_PROVENANCE_TEST_{}",
2604 std::process::id()
2605 );
2606 std::env::set_var(&var, "global-env-answer");
2607
2608 let mut secrets = std::collections::HashMap::new();
2610 let mut s = format!("x = \"${{{var}}}\"");
2611 let report = substitute_env_vars(&mut s, &secrets, "test-shadow");
2612 assert!(s.contains("global-env-answer"), "process env answered: {s}");
2613 assert_eq!(
2614 report,
2615 vec![EnvVarResolution {
2616 name: var.clone(),
2617 source: SecretSource::ProcessEnv
2618 }]
2619 );
2620
2621 secrets.insert(var.clone(), "per-user-answer".to_string());
2624 let mut s2 = format!("x = \"${{{var}}}\"");
2625 let report2 = substitute_env_vars(&mut s2, &secrets, "test-wins");
2626 assert!(s2.contains("per-user-answer"), "user secrets win: {s2}");
2627 assert_eq!(
2628 report2,
2629 vec![EnvVarResolution {
2630 name: var.clone(),
2631 source: SecretSource::SecretsMap
2632 }]
2633 );
2634
2635 std::env::remove_var(&var);
2636 }
2637
2638 #[test]
2639 fn materialized_user_missing_env_only_fires_on_dir_without_env() {
2640 let base = std::env::temp_dir().join(format!(
2642 "trustee-envcheck-{}",
2643 std::process::id()
2644 ));
2645 let _ = std::fs::remove_dir_all(&base);
2646
2647 assert!(!materialized_user_missing_env(&base.join("ghost")));
2649
2650 std::fs::create_dir_all(&base).unwrap();
2652 assert!(materialized_user_missing_env(&base));
2653
2654 std::fs::write(base.join(".env"), "K=V\n").unwrap();
2656 assert!(!materialized_user_missing_env(&base));
2657
2658 let _ = std::fs::remove_dir_all(&base);
2659 }
2660
2661 #[test]
2662 fn loader_freshness_decides_all_four_states() {
2663 let now = chrono::Utc::now();
2664 let fp = 0xABCD_u64;
2665 let young = now - chrono::Duration::seconds(60);
2666 let old = now - chrono::Duration::seconds(601);
2667
2668 assert_eq!(
2670 loader_freshness(fp, young, None, None, fp, now),
2671 LoaderFreshness::Fresh
2672 );
2673 assert_eq!(
2675 loader_freshness(fp, old, None, None, 0x1234, now),
2676 LoaderFreshness::ConfigChanged
2677 );
2678 assert_eq!(
2680 loader_freshness(fp, old, None, None, fp, now),
2681 LoaderFreshness::AgedOut
2682 );
2683 let failed = now - chrono::Duration::seconds(10);
2685 match loader_freshness(fp, old, Some("boom"), Some(failed), fp, now) {
2686 LoaderFreshness::Backoff(remaining) => {
2687 assert!(remaining.as_secs() >= 15, "~20s left: {remaining:?}");
2688 }
2689 other => panic!("expected Backoff, got {other:?}"),
2690 }
2691 let failed_long = now - chrono::Duration::seconds(120);
2693 assert_eq!(
2694 loader_freshness(fp, old, Some("boom"), Some(failed_long), fp, now),
2695 LoaderFreshness::AgedOut
2696 );
2697 }
2698