Skip to main content

trustee_api/
state.rs

1//! Shared server state: per-user multi-session registry, broadcast channels, and auth state.
2//!
3//! ## Multi-Session Per User (MSU)
4//!
5//! Each authenticated user gets their own [`UserSessions`] containing N independent
6//! [`UserSessionEntry`] instances (default max 4). Each entry has:
7//! - An independent `Session` (workflow state, output, etc.)
8//! - A dedicated broadcast channel for WebSocket fan-out
9//! - Creation and last-active timestamps
10//!
11//! Sessions are keyed by user identity (`sub` claim from JWT, or `dev:email` for
12//! dev mode). Unauthenticated deployments use a single `"default"` key, preserving
13//! backward compatibility with single-user CLI operation.
14
15use 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
24// ---------------------------------------------------------------------------
25// Multi-session types
26// ---------------------------------------------------------------------------
27
28/// A single session with its own broadcast channel.
29pub struct UserSessionEntry {
30    /// The agent session, protected by a mutex.
31    pub session: Arc<Mutex<Session>>,
32    /// Broadcast sender for this session's WebSocket fan-out.
33    pub ws_tx: broadcast::Sender<String>,
34    /// When this session was created.
35    pub created_at: chrono::DateTime<chrono::Utc>,
36    /// Last time a command was submitted or state changed.
37    /// Updated on every /sessions/{id}/command and /sessions/{id}/cancel call.
38    pub last_active: Arc<Mutex<chrono::DateTime<chrono::Utc>>>,
39}
40
41/// All sessions belonging to one authenticated user.
42pub struct UserSessions {
43    /// session_id → session entry
44    pub sessions: DashMap<String, UserSessionEntry>,
45    /// Shared token store for all this user's sessions (MCP credential isolation).
46    pub token_store: Arc<pep::MemoryTokenStore>,
47    /// Which session_id is "active" for legacy /session/* routes.
48    pub active_session_id: Mutex<String>,
49}
50
51/// Summary of an active session for listing (serializable for API responses).
52#[derive(Debug, serde::Serialize)]
53pub struct SessionListItem {
54    pub session_id: String,
55    pub session_name: Option<String>,
56    pub workflow_state: String,
57    pub created_at: String,
58    pub last_active: String,
59}
60
61/// Errors from multi-session operations.
62#[derive(Debug)]
63pub enum SessionError {
64    /// User has reached max_sessions_per_user limit.
65    MaxSessionsReached(usize),
66    /// Session ID not found for this user.
67    NotFound(String),
68    /// Session is not Idle (cannot destroy/overwrite a running session).
69    NotIdle(String),
70}
71
72impl std::fmt::Display for SessionError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            SessionError::MaxSessionsReached(n) => {
76                write!(f, "Maximum {} sessions per user reached", n)
77            }
78            SessionError::NotFound(id) => write!(f, "Session {} not found", id),
79            SessionError::NotIdle(state) => write!(f, "Session is not idle (state: {})", state),
80        }
81    }
82}
83
84impl std::error::Error for SessionError {}
85
86/// Top-level registry: user_key → user's session collection.
87pub type SessionRegistry = Arc<DashMap<String, UserSessions>>;
88
89// ---------------------------------------------------------------------------
90// ServerState
91// ---------------------------------------------------------------------------
92
93/// Shared state accessible by all axum handlers.
94#[derive(Clone)]
95pub struct ServerState {
96    /// Per-user multi-session registry (MSU).
97    pub sessions: SessionRegistry,
98    /// Broadcast sender for backward compat — delegates to the default user's channel.
99    pub ws_tx: broadcast::Sender<String>,
100    /// Auth state (None = auth disabled, all endpoints open).
101    pub auth: Option<Arc<AuthState>>,
102    /// Shared config TOML (all users share the same agent config).
103    pub config_toml: Option<String>,
104    /// Shared secrets (injected into every per-user session).
105    pub secrets: Option<std::collections::HashMap<String, String>>,
106    /// Shared build info (injected into every per-user session).
107    pub build_info: Option<trustee_core::types::BuildInfo>,
108    /// Global concurrency limiter — limits the number of simultaneous workflows
109    /// across all users. Default: 8 concurrent workflows.
110    pub workflow_semaphore: Arc<tokio::sync::Semaphore>,
111    /// Maximum number of concurrent sessions per user. Default: 4.
112    pub max_sessions_per_user: usize,
113}
114
115impl ServerState {
116    /// Create new shared state from a default session, broadcast sender, and optional auth.
117    pub fn new(
118        session: Session,
119        ws_tx: broadcast::Sender<String>,
120        auth: Option<Arc<AuthState>>,
121    ) -> Self {
122        let sessions = Arc::new(DashMap::new());
123
124        // Store the default user's UserSessions with an initial session
125        let token_store = Arc::new(pep::MemoryTokenStore::new());
126        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
127
128        let now = chrono::Utc::now();
129        let initial_entry = UserSessionEntry {
130            session: Arc::new(Mutex::new(session)),
131            ws_tx: ws_tx_entry,
132            created_at: now,
133            last_active: Arc::new(Mutex::new(now)),
134        };
135
136        let user_sessions = UserSessions {
137            sessions: DashMap::new(),
138            token_store,
139            active_session_id: Mutex::new(String::new()),
140        };
141        user_sessions.sessions.insert("default".to_string(), initial_entry);
142
143        sessions.insert("default".to_string(), user_sessions);
144
145        Self {
146            sessions,
147            ws_tx,
148            auth,
149            config_toml: None,
150            secrets: None,
151            build_info: None,
152            workflow_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
153            max_sessions_per_user: 4,
154        }
155    }
156
157    pub fn with_config_toml(mut self, config_toml: String) -> Self {
158        self.config_toml = Some(config_toml);
159        self
160    }
161
162    pub fn with_secrets(mut self, secrets: std::collections::HashMap<String, String>) -> Self {
163        self.secrets = Some(secrets);
164        self
165    }
166
167    pub fn with_build_info(mut self, build_info: trustee_core::types::BuildInfo) -> Self {
168        self.build_info = Some(build_info);
169        self
170    }
171
172    pub fn with_max_concurrent_workflows(mut self, max: usize) -> Self {
173        self.workflow_semaphore = Arc::new(tokio::sync::Semaphore::new(max));
174        self
175    }
176
177    /// Set the max sessions per user.
178    pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
179        self.max_sessions_per_user = max;
180        self
181    }
182
183    // -----------------------------------------------------------------------
184    // MSU: Multi-session methods
185    // -----------------------------------------------------------------------
186
187    /// Create a new session for a user. Returns the session_id.
188    ///
189    /// Creates a fresh `Session::new()`, copies shared config, sets per-user
190    /// isolation, creates a broadcast channel, spawns a drain task, and inserts
191    /// into the user's session DashMap. The new session becomes the "active" one.
192    pub async fn create_session(
193        &self,
194        user_key: &str,
195        session_name: Option<String>,
196        identity: Option<String>,
197    ) -> Result<String, SessionError> {
198        // Get or create the user's UserSessions entry
199        let user_sessions = self
200            .sessions
201            .entry(user_key.to_string())
202            .or_insert_with(|| UserSessions {
203                sessions: DashMap::new(),
204                token_store: Arc::new(pep::MemoryTokenStore::new()),
205                active_session_id: Mutex::new(String::new()),
206            });
207
208        // Check session limit
209        if user_sessions.sessions.len() >= self.max_sessions_per_user {
210            return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
211        }
212
213        // Create new Session
214        let (mut session, workflow_rx) = Session::new();
215
216        // Copy shared config
217        if let Some(ref config_toml) = self.config_toml {
218            session.config_toml = Some(config_toml.clone());
219            session.parse_auto_handoff_config();
220            if let Ok(table) = config_toml.parse::<toml::Value>() {
221                if let Some(name) = table
222                    .get("agent")
223                    .and_then(|a| a.get("name"))
224                    .and_then(|n| n.as_str())
225                {
226                    session.agent_name = name.to_string();
227                }
228            }
229        }
230
231        session.secrets = self.secrets.clone();
232        session.build_info = self.build_info.clone();
233
234        // Per-user isolation
235        self.apply_user_isolation(&mut session, user_key);
236
237        // Apply session_name if provided
238        session.session_name = session_name;
239
240        // Apply agent identity if provided
241        session.identity = identity;
242
243        // Create broadcast channel
244        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
245
246        // Generate session_id
247        let session_id = format!(
248            "session_{}_{}",
249            chrono::Utc::now().format("%Y_%m_%d_%H_%M"),
250            &uuid::Uuid::new_v4().to_string()[..8]
251        );
252
253        let now = chrono::Utc::now();
254
255        // Insert into user's sessions DashMap
256        user_sessions.sessions.insert(
257            session_id.clone(),
258            UserSessionEntry {
259                session: Arc::new(Mutex::new(session)),
260                ws_tx: ws_tx_entry.clone(),
261                created_at: now,
262                last_active: Arc::new(Mutex::new(now)),
263            },
264        );
265
266        // Set as active session
267        *user_sessions.active_session_id.lock().await = session_id.clone();
268
269        // Spawn drain task
270        let session_arc = user_sessions
271            .sessions
272            .get(&session_id)
273            .map(|e| e.session.clone());
274        if let Some(session_arc) = session_arc {
275            self.spawn_user_drain_task(
276                session_id.clone(),
277                session_arc,
278                ws_tx_entry,
279                workflow_rx,
280            );
281        }
282
283        Ok(session_id)
284    }
285
286    /// Get a specific session by user_key + session_id.
287    /// Updates last_active on the session entry.
288    pub async fn get_session(
289        &self,
290        user_key: &str,
291        session_id: &str,
292    ) -> Option<(Arc<Mutex<Session>>, broadcast::Sender<String>)> {
293        let user_sessions = self.sessions.get(user_key)?;
294        let entry = user_sessions.sessions.get(session_id)?;
295
296        // Update last_active
297        let now = chrono::Utc::now();
298        *entry.last_active.lock().await = now;
299
300        Some((entry.session.clone(), entry.ws_tx.clone()))
301    }
302
303    /// Get a session by EITHER its live MSU registry key OR its
304    /// checkpoint/session identity (`session.session_id`).
305    ///
306    /// The web frontend tracks `currentSessionId` from the `ResumeInfo` WS
307    /// message, which carries the auto-derived checkpoint id
308    /// (`session_YYYY_MM_DD_HH_MM_uuid8`) — NOT the live MSU registry key
309    /// (`"default"` or the key from `create_session()`). External clients
310    /// like Torpi/THQ pass the live registry key. This resolver accepts both:
311    ///
312    /// 1. Try registry-key lookup first (precise, used by Torpi/THQ).
313    /// 2. Fall back to scanning the user's live sessions for one whose
314    ///    `session.session_id` matches the requested id (used by the
315    ///    embedded web UI after a command or resume).
316    ///
317    /// Returns `(live_registry_key, session_arc, ws_tx)`, or `None` if not
318    /// found. The live key is returned so callers that need to set it as
319    /// active (or otherwise reference the registry) use the real key.
320    pub async fn get_session_by_any_id(
321        &self,
322        user_key: &str,
323        id: &str,
324    ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
325        // Fast path: registry key match.
326        let user_sessions = self.sessions.get(user_key)?;
327        if let Some(entry) = user_sessions.sessions.get(id) {
328            // Update last_active
329            let now = chrono::Utc::now();
330            *entry.last_active.lock().await = now;
331            return Some((id.to_string(), entry.session.clone(), entry.ws_tx.clone()));
332        }
333
334        // Slow path: scan live sessions for a matching session.session_id.
335        for entry in user_sessions.sessions.iter() {
336            let session = entry.session.lock().await;
337            if session.session_id.as_deref() == Some(id) {
338                let key = entry.key().clone();
339                let ws_tx = entry.ws_tx.clone();
340                drop(session);
341                // Update last_active
342                let now = chrono::Utc::now();
343                *entry.last_active.lock().await = now;
344                return Some((key, entry.session.clone(), ws_tx));
345            }
346        }
347
348        None
349    }
350
351    /// List all active sessions for a user, sorted by last_active desc.
352    pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> {
353        let Some(user_sessions) = self.sessions.get(user_key) else {
354            return Vec::new();
355        };
356
357        let mut items = Vec::new();
358        for entry in user_sessions.sessions.iter() {
359            let session = entry.session.lock().await;
360            let workflow_state = match session.workflow_state {
361                trustee_core::types::WorkflowState::Idle => "Idle",
362                trustee_core::types::WorkflowState::Running => "Running",
363                trustee_core::types::WorkflowState::Cancelling => "Cancelling",
364            };
365            let last_active = entry.last_active.lock().await;
366            items.push(SessionListItem {
367                session_id: entry.key().clone(),
368                session_name: session.session_name.clone(),
369                workflow_state: workflow_state.to_string(),
370                created_at: entry.created_at.to_rfc3339(),
371                last_active: last_active.to_rfc3339(),
372            });
373        }
374        drop(user_sessions);
375
376        // Sort by last_active descending
377        items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
378        items
379    }
380
381    /// Destroy a session. The session must be Idle.
382    pub async fn destroy_session(
383        &self,
384        user_key: &str,
385        session_id: &str,
386    ) -> Result<(), SessionError> {
387        let user_sessions = self
388            .sessions
389            .get(user_key)
390            .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
391
392        // Check workflow state before removing
393        {
394            let entry = user_sessions
395                .sessions
396                .get(session_id)
397                .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
398            let session = entry.session.lock().await;
399            if session.workflow_state != trustee_core::types::WorkflowState::Idle {
400                let state_str = match session.workflow_state {
401                    trustee_core::types::WorkflowState::Running => "Running",
402                    trustee_core::types::WorkflowState::Cancelling => "Cancelling",
403                    _ => "Unknown",
404                };
405                return Err(SessionError::NotIdle(state_str.to_string()));
406            }
407        }
408
409        // Remove from DashMap
410        user_sessions.sessions.remove(session_id);
411
412        // If this was the active session, pick a new active
413        let mut active_id = user_sessions.active_session_id.lock().await;
414        if &*active_id == session_id {
415            // Pick the most recently active remaining session
416            let mut newest: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
417            for entry in user_sessions.sessions.iter() {
418                let la = entry.last_active.lock().await;
419                if newest.as_ref().map_or(true, |(_, t)| *la > *t) {
420                    newest = Some((entry.key().clone(), *la));
421                }
422            }
423            *active_id = newest.map(|(id, _)| id).unwrap_or_default();
424        }
425
426        Ok(())
427    }
428
429    /// Get or create the user's "active" session for legacy routes.
430    ///
431    /// Behavior:
432    /// 1. If user has no sessions → create one
433    /// 2. If active session exists → return it
434    /// 3. If active session was destroyed → create a new one
435    ///
436    /// Returns: (session_id, session_arc, ws_tx, token_store)
437    pub async fn ensure_active_session(
438        &self,
439        user_key: &str,
440    ) -> (
441        String,
442        Arc<Mutex<Session>>,
443        broadcast::Sender<String>,
444        Arc<pep::MemoryTokenStore>,
445    ) {
446        // Get or create user's UserSessions
447        let token_store = {
448            let user_sessions = self
449                .sessions
450                .entry(user_key.to_string())
451                .or_insert_with(|| UserSessions {
452                    sessions: DashMap::new(),
453                    token_store: Arc::new(pep::MemoryTokenStore::new()),
454                    active_session_id: Mutex::new(String::new()),
455                });
456            user_sessions.token_store.clone()
457        };
458
459        // Check if active session exists
460        let active_id = {
461            let user_sessions = self.sessions.get(user_key).unwrap();
462            let guard = user_sessions.active_session_id.lock().await;
463            guard.clone()
464        };
465
466        if !active_id.is_empty() {
467            if let Some((session, ws_tx)) = self.get_session(user_key, &active_id).await {
468                return (active_id, session, ws_tx, token_store);
469            }
470            // Active session was destroyed, fall through to create
471        }
472
473        // Need to create a new session
474        // For the "default" user, we may already have a "default" session entry
475        // from ServerState::new() — check for it
476        let existing_session: Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> = {
477            let user_sessions = self.sessions.get(user_key).unwrap();
478            let result = user_sessions.sessions.iter().next().map(|first| {
479                (
480                    first.key().clone(),
481                    first.session.clone(),
482                    first.ws_tx.clone(),
483                )
484            });
485            result
486        };
487        if let Some((id, session, ws_tx)) = existing_session {
488            let now = chrono::Utc::now();
489            if let Some(entry) = self.sessions.get(user_key) {
490                if let Some(e) = entry.sessions.get(&id) {
491                    *e.last_active.lock().await = now;
492                }
493                *entry.active_session_id.lock().await = id.clone();
494            }
495
496            return (id, session, ws_tx, token_store);
497        }
498
499        // Create a brand new session
500        let session_id = self
501            .create_session(user_key, None, None)
502            .await
503            .unwrap_or_else(|_| "default".to_string());
504
505        let (session, ws_tx) = self
506            .get_session(user_key, &session_id)
507            .await
508            .expect("just-created session must exist");
509
510        (session_id, session, ws_tx, token_store)
511    }
512
513    /// DEPRECATED: Use ensure_active_session() instead.
514    /// Kept for backward compatibility — same 3-tuple return type.
515    pub async fn ensure_user_session(
516        &self,
517        user_key: &str,
518    ) -> (Arc<Mutex<Session>>, broadcast::Sender<String>, Arc<pep::MemoryTokenStore>) {
519        let (_id, session, ws_tx, token_store) = self.ensure_active_session(user_key).await;
520        (session, ws_tx, token_store)
521    }
522
523    /// Set a session as the user's active session.
524    pub async fn set_active_session(&self, user_key: &str, session_id: &str) {
525        if let Some(user_sessions) = self.sessions.get(user_key) {
526            if user_sessions.sessions.contains_key(session_id) {
527                *user_sessions.active_session_id.lock().await = session_id.to_string();
528            }
529        }
530    }
531
532    // -----------------------------------------------------------------------
533    // Read-only helpers (no session creation side effects)
534    // -----------------------------------------------------------------------
535
536    /// Resolve a user's home_dir without creating an in-memory session.
537    ///
538    /// This is the read-only equivalent of the isolation logic in
539    /// `apply_user_isolation`. Used by endpoints that only need to read
540    /// checkpoint data from disk (history, session list, session detail)
541    /// and must NOT create ghost sessions as a side effect.
542    pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
543        use sha2::{Digest, Sha256};
544        let mut hasher = Sha256::new();
545        hasher.update(user_key.as_bytes());
546        let hash_bytes = hasher.finalize();
547        let user_hash = format!(
548            "{:016x}",
549            u64::from_be_bytes(hash_bytes[..8].try_into().unwrap())
550        );
551        dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&user_hash))
552    }
553
554    /// Resolve config_toml and home_dir without creating an in-memory session.
555    ///
556    /// Returns `(config_toml, home_dir)`. If config is not loaded,
557    /// config_toml will be None.
558    pub fn get_user_config_and_home(&self, user_key: &str) -> (Option<String>, Option<std::path::PathBuf>) {
559        (self.config_toml.clone(), self.get_user_home_dir(user_key))
560    }
561
562    // -----------------------------------------------------------------------
563    // Private helpers
564    // -----------------------------------------------------------------------
565
566    /// Apply per-user isolation: SHA-256 hash → home_dir + project_id.
567    fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
568        use sha2::{Digest, Sha256};
569        let mut hasher = Sha256::new();
570        hasher.update(user_key.as_bytes());
571        let hash_bytes = hasher.finalize();
572        let user_hash = format!(
573            "{:016x}",
574            u64::from_be_bytes(hash_bytes[..8].try_into().unwrap())
575        );
576
577        // Set per-user home directory for checkpoint isolation
578        let user_home = if let Some(home) = dirs::home_dir() {
579            let user_home = home.join(".trustee").join("users").join(&user_hash);
580            session.home_dir = Some(user_home.clone());
581            Some(user_home)
582        } else {
583            None
584        };
585
586        session.project_id = Some(format!("web{}", &user_hash[..16]));
587
588        // ── Per-user .env (Task 2) ──────────────────────────────────────
589        //
590        // Load per-user secrets from ~/.trustee/users/{hash}/.env
591        // These are merged on top of shared secrets (per-user wins).
592        // They are NEVER set as process env vars — used only for ${VAR}
593        // substitution in the config TOML below.
594        let shared_secrets = session.secrets.clone().unwrap_or_default();
595        let mut merged_secrets = shared_secrets.clone();
596
597        if let Some(ref user_home) = user_home {
598            if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
599                merged_secrets = merged;
600            }
601        }
602
603        // ── Per-user config overlay (Task 3) ────────────────────────────
604        if let Some(ref user_home) = user_home {
605            if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref()) {
606                session.config_toml = Some(merged);
607                tracing::debug!("Merged per-user config into session");
608            }
609        }
610
611        // ── ${VAR} substitution (Task 4) ────────────────────────────────
612        if let Some(ref mut config_toml) = session.config_toml {
613            substitute_env_vars(config_toml, &merged_secrets);
614        }
615
616        // ── Strip per-user secrets (Task 5) ────────────────────────────
617        session.secrets = Some(shared_secrets);
618    }
619
620    /// Load secrets from a user's ~/.trustee/users/{hash}/.env and merge
621    /// on top of `base` (user wins). Returns the merged map.
622    fn load_user_secrets(
623        &self,
624        user_home: &std::path::Path,
625        base: &std::collections::HashMap<String, String>,
626    ) -> std::io::Result<std::collections::HashMap<String, String>> {
627        let user_env_path = user_home.join(".env");
628        if !user_env_path.exists() {
629            return Ok(base.clone());
630        }
631        let content = std::fs::read_to_string(&user_env_path)?;
632        let mut merged = base.clone();
633        for line in content.lines() {
634            let line = line.trim();
635            if line.is_empty() || line.starts_with('#') {
636                continue;
637            }
638            if let Some((key, value)) = line.split_once('=') {
639                let key = key.trim().to_string();
640                let value = value
641                    .trim()
642                    .trim_matches('"')
643                    .trim_matches('\'')
644                    .to_string();
645                merged.insert(key, value);
646            }
647        }
648        tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
649        Ok(merged)
650    }
651
652    /// Deep-merge a user's per-user config overlay on top of the shared
653    /// config. Returns the merged TOML string, or None if no overlay exists.
654    fn merge_user_config(
655        &self,
656        user_home: &std::path::Path,
657        shared_config: Option<&str>,
658    ) -> Option<String> {
659        let user_config_path = user_home.join("config").join("trustee.toml");
660        if !user_config_path.exists() {
661            return None;
662        }
663        let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
664        let shared = shared_config
665            .unwrap_or("")
666            .parse::<toml::Value>()
667            .ok()?;
668        let overlay = user_config_toml.parse::<toml::Value>().ok()?;
669        let mut shared = shared;
670        deep_merge_toml(&mut shared, &overlay);
671        let merged = toml::to_string(&shared).ok()?;
672        tracing::debug!("Merged per-user config from {}", user_config_path.display());
673        Some(merged)
674    }
675
676    /// Resolve the fully-merged config TOML for a user WITHOUT creating a session.
677    ///
678    /// This is the read-only equivalent of the config resolution in
679    /// `apply_user_isolation`: shared config + per-user overlay + ${VAR}
680    /// substitution. Used by endpoints that need to inspect config (e.g.
681    /// listing available LLM models) without spawning a ghost session.
682    ///
683    /// Returns None if no shared config is loaded.
684    pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
685        let config_toml = self.config_toml.clone()?;
686
687        // Resolve user home dir (same hash scheme as apply_user_isolation)
688        let user_home = self.get_user_home_dir(user_key)?;
689
690        // Start from shared secrets; merge per-user .env on top
691        let mut merged_secrets = self.secrets.clone().unwrap_or_default();
692        if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
693            merged_secrets = merged;
694        }
695
696        // Merge per-user config overlay
697        let mut resolved = config_toml;
698        if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
699            resolved = merged;
700        }
701
702        // Substitute ${VAR} from merged secrets
703        substitute_env_vars(&mut resolved, &merged_secrets);
704
705        Some(resolved)
706    }
707
708    /// Spawn a background drain task for a specific session's workflow receiver.
709    fn spawn_user_drain_task(
710        &self,
711        session_id: String,
712        session: Arc<Mutex<Session>>,
713        ws_tx: broadcast::Sender<String>,
714        mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
715    ) {
716        tokio::spawn(async move {
717            while let Some(msg) = workflow_rx.recv().await {
718                {
719                    let mut session = session.lock().await;
720                    session.handle_workflow_message(msg.clone());
721
722                    let state_str = match session.workflow_state {
723                        trustee_core::types::WorkflowState::Idle => "Idle",
724                        trustee_core::types::WorkflowState::Running => "Running",
725                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
726                    };
727                    let state_msg = serde_json::json!({
728                        "type": "StateChanged",
729                        "state": state_str
730                    });
731                    let _ = ws_tx.send(state_msg.to_string());
732                }
733
734                let json =
735                    serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
736                let _ = ws_tx.send(json);
737            }
738            tracing::debug!("Drain task ended for session: {}", session_id);
739        });
740    }
741
742    /// Spawn the default user's drain task (backward compatibility).
743    /// Called during server startup for the initial session.
744    pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
745        // Get the default user's first session
746        let default_user = self
747            .sessions
748            .get("default")
749            .expect("default user must exist");
750        let first_entry = default_user
751            .sessions
752            .iter()
753            .next()
754            .expect("default user must have at least one session");
755        let session = first_entry.session.clone();
756        let ws_tx = first_entry.ws_tx.clone();
757        let session_id = first_entry.key().clone();
758        drop(first_entry);
759        drop(default_user);
760
761        tokio::spawn(async move {
762            while let Some(msg) = workflow_rx.recv().await {
763                {
764                    let mut session = session.lock().await;
765                    session.handle_workflow_message(msg.clone());
766
767                    let state_str = match session.workflow_state {
768                        trustee_core::types::WorkflowState::Idle => "Idle",
769                        trustee_core::types::WorkflowState::Running => "Running",
770                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
771                    };
772                    let state_msg = serde_json::json!({
773                        "type": "StateChanged",
774                        "state": state_str
775                    });
776                    let _ = ws_tx.send(state_msg.to_string());
777                }
778
779                let json =
780                    serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
781                let _ = ws_tx.send(json);
782            }
783            tracing::debug!("Drain task ended for session: {}", session_id);
784        });
785    }
786
787    /// Resolve the user key from request headers.
788    pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
789        let Some(ref auth) = self.auth else {
790            return "default".to_string();
791        };
792
793        // Try Bearer header first
794        if let Some(token) = headers
795            .get(axum::http::header::AUTHORIZATION)
796            .and_then(|v| v.to_str().ok())
797            .and_then(|v| v.strip_prefix("Bearer "))
798            .map(|s| s.to_string())
799        {
800            if token.starts_with("dev:") {
801                let parts: Vec<&str> = token.splitn(4, ':').collect();
802                if parts.len() >= 4 {
803                    return format!("dev:{}", parts[1]);
804                }
805            }
806            if let Ok(claims) = auth.validate_token(&token).await {
807                return claims.sub;
808            }
809        }
810
811        // Try cookie
812        let cookie_session_id = headers
813            .get(axum::http::header::COOKIE)
814            .and_then(|v| v.to_str().ok())
815            .and_then(|cookies| {
816                cookies
817                    .split(';')
818                    .map(|c| c.trim())
819                    .find_map(|c| {
820                        c.strip_prefix(&format!("{}=", auth.config.cookie_name))
821                            .map(|s| s.to_string())
822                    })
823            });
824
825        if let Some(session_id) = cookie_session_id {
826            if session_id.starts_with("dev:") {
827                let parts: Vec<&str> = session_id.splitn(4, ':').collect();
828                if parts.len() >= 4 {
829                    return format!("dev:{}", parts[1]);
830                }
831            }
832
833            if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
834                if let Ok(claims) = auth.validate_token(&access_token).await {
835                    return claims.sub;
836                }
837            }
838        }
839
840        "default".to_string()
841    }
842}
843
844// ---------------------------------------------------------------------------
845// SerializableMessage (unchanged)
846// ---------------------------------------------------------------------------
847
848/// Wrapper to serialize `TuiMessage` as JSON with a `type` discriminator.
849struct SerializableMessage<'a>(&'a TuiMessage);
850
851impl<'a> serde::Serialize for SerializableMessage<'a> {
852    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
853    where
854        S: serde::Serializer,
855    {
856        use serde::ser::SerializeStruct;
857
858        match self.0 {
859            TuiMessage::OutputLine(line) => {
860                let mut s = serializer.serialize_struct("msg", 2)?;
861                s.serialize_field("type", "OutputLine")?;
862                s.serialize_field("line", line)?;
863                s.end()
864            }
865            TuiMessage::StreamDelta(delta) => {
866                let mut s = serializer.serialize_struct("msg", 2)?;
867                s.serialize_field("type", "StreamDelta")?;
868                s.serialize_field("delta", delta)?;
869                s.end()
870            }
871            TuiMessage::ReasoningDelta(delta) => {
872                let mut s = serializer.serialize_struct("msg", 2)?;
873                s.serialize_field("type", "ReasoningDelta")?;
874                s.serialize_field("delta", delta)?;
875                s.end()
876            }
877            TuiMessage::WorkflowCompleted => {
878                let mut s = serializer.serialize_struct("msg", 2)?;
879                s.serialize_field("type", "WorkflowCompleted")?;
880                s.serialize_field("state", "Idle")?;
881                s.end()
882            }
883            TuiMessage::WorkflowError(err) => {
884                let mut s = serializer.serialize_struct("msg", 2)?;
885                s.serialize_field("type", "WorkflowError")?;
886                s.serialize_field("error", err)?;
887                s.end()
888            }
889            TuiMessage::ResumeInfo(info) => match info {
890                Some(ri) => {
891                    let mut s = serializer.serialize_struct("msg", 5)?;
892                    s.serialize_field("type", "ResumeInfo")?;
893                    s.serialize_field("state", "Idle")?;
894                    s.serialize_field("session_id", &ri.session_id)?;
895                    s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
896                    s.serialize_field("iteration", &ri.iteration)?;
897                    s.end()
898                }
899                None => {
900                    let mut s = serializer.serialize_struct("msg", 2)?;
901                    s.serialize_field("type", "ResumeInfo")?;
902                    s.serialize_field("state", "Idle")?;
903                    s.end()
904                }
905            },
906            TuiMessage::TodoUpdate(content) => {
907                let mut s = serializer.serialize_struct("msg", 2)?;
908                s.serialize_field("type", "TodoUpdate")?;
909                s.serialize_field("content", content)?;
910                s.end()
911            }
912            TuiMessage::WorkflowCancelled => {
913                let mut s = serializer.serialize_struct("msg", 2)?;
914                s.serialize_field("type", "WorkflowCancelled")?;
915                s.serialize_field("state", "Idle")?;
916                s.end()
917            }
918            TuiMessage::HandoffReady(briefing) => {
919                let mut s = serializer.serialize_struct("msg", 3)?;
920                s.serialize_field("type", "HandoffReady")?;
921                s.serialize_field("state", "Idle")?;
922                s.serialize_field("briefing", briefing)?;
923                s.end()
924            }
925            TuiMessage::HandoffFailed => {
926                let mut s = serializer.serialize_struct("msg", 2)?;
927                s.serialize_field("type", "HandoffFailed")?;
928                s.serialize_field("state", "Idle")?;
929                s.end()
930            }
931            TuiMessage::ToolPending {
932                tool_name,
933                hint,
934            } => {
935                let mut s = serializer.serialize_struct("msg", 3)?;
936                s.serialize_field("type", "ToolPending")?;
937                s.serialize_field("tool_name", tool_name)?;
938                s.serialize_field("hint", hint)?;
939                s.end()
940            }
941            TuiMessage::ToolDone {
942                tool_name,
943                success,
944                hint,
945            } => {
946                let mut s = serializer.serialize_struct("msg", 4)?;
947                s.serialize_field("type", "ToolDone")?;
948                s.serialize_field("tool_name", tool_name)?;
949                s.serialize_field("success", success)?;
950                s.serialize_field("hint", hint)?;
951                s.end()
952            }
953            TuiMessage::ContextTokensUpdated(count) => {
954                let mut s = serializer.serialize_struct("msg", 2)?;
955                s.serialize_field("type", "ContextTokensUpdated")?;
956                s.serialize_field("count", count)?;
957                s.end()
958            }
959            TuiMessage::McpServerStatus {
960                name,
961                connected,
962                tool_count,
963                error,
964            } => {
965                let mut s = serializer.serialize_struct("msg", 5)?;
966                s.serialize_field("type", "McpServerStatus")?;
967                s.serialize_field("name", name)?;
968                s.serialize_field("connected", connected)?;
969                s.serialize_field("tool_count", tool_count)?;
970                s.serialize_field("error", error)?;
971                s.end()
972            }
973            TuiMessage::SessionTitleUpdated(title) => {
974                let mut s = serializer.serialize_struct("msg", 2)?;
975                s.serialize_field("type", "SessionTitleUpdated")?;
976                s.serialize_field("title", title)?;
977                s.end()
978            }
979        }
980    }
981}
982
983// ---------------------------------------------------------------------------
984// Per-user config helpers
985// ---------------------------------------------------------------------------
986
987/// Deep-merge a TOML overlay on top of a base value (in-place).
988///
989/// - Tables: recursively merge key-by-key (overlay wins on conflict).
990/// - Arrays: overlay replaces base entirely (no merging).
991/// - Scalars: overlay replaces base.
992/// - If a key exists in overlay but not base, it's added.
993fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
994    match (base, overlay) {
995        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
996            for (key, overlay_val) in overlay_table {
997                match base_table.get_mut(key) {
998                    Some(base_val) => {
999                        // Both exist — recurse if both are tables, else replace
1000                        deep_merge_toml(base_val, overlay_val);
1001                    }
1002                    None => {
1003                        // Key only in overlay — insert
1004                        base_table.insert(key.clone(), overlay_val.clone());
1005                    }
1006                }
1007            }
1008        }
1009        // Non-table: overlay replaces base
1010        (base, overlay) => {
1011            *base = overlay.clone();
1012        }
1013    }
1014}
1015
1016/// Replace `${VAR_NAME}` references in a string with values from a secrets map.
1017///
1018/// Falls back to process environment if the variable is not in the map.
1019/// Variables not found in either are left as-is.
1020fn substitute_env_vars(s: &mut String, secrets: &std::collections::HashMap<String, String>) {
1021    // Simple state machine: scan for ${, read until }, replace.
1022    let mut result = String::with_capacity(s.len());
1023    let bytes = s.as_bytes();
1024    let mut i = 0;
1025
1026    while i < bytes.len() {
1027        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1028            // Find closing }
1029            if let Some(end) = s[i + 2..].find('}') {
1030                let var_name = &s[i + 2..i + 2 + end];
1031                // Look up in per-user secrets first, then process env
1032                if let Some(value) = secrets.get(var_name) {
1033                    result.push_str(value);
1034                } else if let Ok(value) = std::env::var(var_name) {
1035                    result.push_str(&value);
1036                } else {
1037                    // Not found — leave as-is
1038                    result.push_str(&s[i..i + 2 + end + 1]);
1039                }
1040                i = i + 2 + end + 1;
1041            } else {
1042                // No closing } — copy as-is
1043                result.push('$');
1044                i += 1;
1045            }
1046        } else {
1047            result.push(bytes[i] as char);
1048            i += 1;
1049        }
1050    }
1051
1052    *s = result;
1053}