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