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// 16F console dispatch: user-key pinning (nghr ec3e0622 companion)
26// ---------------------------------------------------------------------------
27
28tokio::task_local! {
29    /// When set (by the `/xagent/{agent}` dispatch prelude), ALL
30    /// user-key-derived resolution — home dir, session bucket, project id,
31    /// secrets merge, MCP loader cache — pins to THIS key: the THQ profile
32    /// binding id (the stable identity anchor), NOT the token's claims.
33    /// Old-world 16E agents carry their Kanidm sub here, making the override
34    /// a bit-identical no-op for them. The ec3e0622 guarantee: token claims
35    /// can never re-home a principal.
36    static USER_KEY_OVERRIDE: String;
37}
38
39/// The effective namespace key for `user_key` under the active dispatch
40/// scope (see [`USER_KEY_OVERRIDE`]).
41pub(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
47/// Wrap a dispatched handler so every user-key-derived resolution inside it
48/// pins to the binding's key (see [`USER_KEY_OVERRIDE`]).
49pub(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
56/// Deterministic re-link of pre-rotation homes (nghr ec3e0622 acceptance 3).
57///
58/// `users/home_links.json`: `{ "links": [ { "from_hash": "<data dir>",
59/// "to_hash": "<current claims-derived dir>" } ] }` — the data lives under
60/// `from_hash`; the claims-derived `to_hash` home is the (empty) one the
61/// rotation created. Applied at startup, idempotent:
62/// - `to` missing → rename `from` → `to` (config + sessions restored);
63/// - `to` exists but has ZERO entries → remove it, then rename;
64/// - `to` exists with content → LOUD skip (manual reconciliation required).
65pub 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; // already applied (or never existed)
94            }
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
128// ---------------------------------------------------------------------------
129// Multi-session types
130// ---------------------------------------------------------------------------
131
132/// A single session with its own broadcast channel.
133pub struct UserSessionEntry {
134    /// The agent session, protected by a mutex.
135    pub session: Arc<Mutex<Session>>,
136    /// Broadcast sender for this session's WebSocket fan-out.
137    pub ws_tx: broadcast::Sender<String>,
138    /// When this session was created.
139    pub created_at: chrono::DateTime<chrono::Utc>,
140    /// Last time a command was submitted or state changed.
141    /// Updated on every /sessions/{id}/command and /sessions/{id}/cancel call.
142    pub last_active: Arc<Mutex<chrono::DateTime<chrono::Utc>>>,
143}
144
145/// All sessions belonging to one authenticated user.
146pub struct UserSessions {
147    /// session_id → session entry
148    pub sessions: DashMap<String, UserSessionEntry>,
149    /// Shared token store for all this user's sessions (MCP credential isolation).
150    pub token_store: Arc<pep::MemoryTokenStore>,
151    /// Which session_id is "active" for legacy /session/* routes.
152    pub active_session_id: Mutex<String>,
153}
154
155/// Summary of an active session for listing (serializable for API responses).
156#[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    /// Number of handoff rotations this session has performed (Bug 5). The
164    /// in-memory registry key never changes across a rotation — only the
165    /// checkpoint-chain name does — so a card whose name changed without this
166    /// hint looks like it silently renamed. 0 = never rotated.
167    pub handoff_count: u32,
168}
169
170/// Errors from multi-session operations.
171#[derive(Debug)]
172pub enum SessionError {
173    /// User has reached max_sessions_per_user limit.
174    MaxSessionsReached(usize),
175    /// Session ID not found for this user.
176    NotFound(String),
177    /// Session is not Idle (cannot destroy/overwrite a running session).
178    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
195/// Top-level registry: user_key → user's session collection.
196pub type SessionRegistry = Arc<DashMap<String, UserSessions>>;
197
198// ---------------------------------------------------------------------------
199// ServerState
200// ---------------------------------------------------------------------------
201
202/// Cached per-user MCP tool loader (16C).
203///
204/// One entry per user hash. `loader: None` means the user's effective
205/// config has MCP disabled — agents run MCP-less via abk's
206/// `McpSource::Prebuilt(None)` (semantically identical to
207/// `[mcp] enabled = false` today, but with zero config re-parsing).
208pub struct McpLoaderEntry {
209    /// Built loader; `None` = MCP disabled for this user.
210    pub loader: Option<std::sync::Arc<abk::agent::McpToolLoader>>,
211    /// Content fingerprint of the effective `[mcp]` config (SHA-256, first
212    /// 8 bytes as u64). Content, never mtime — overlays are rewritten in place.
213    pub fingerprint: u64,
214    pub built_at: chrono::DateTime<chrono::Utc>,
215    /// Set when the last build failed; the message surfaces to the user's
216    /// next dispatch (fail loud). Other users are never affected.
217    pub degraded: Option<String>,
218    /// When `degraded` was set. Rebuilds are held back for
219    /// [`MCP_BUILD_RETRY_BACKOFF`] — a poison entry never sticks forever.
220    pub failed_at: Option<chrono::DateTime<chrono::Utc>>,
221}
222
223/// Minimum delay before retrying a failed MCP loader build (16C).
224pub const MCP_BUILD_RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
225
226/// Maximum age of a cached MCP toolset manifest (42131deb).
227///
228/// The 16C fingerprint covers the LOCAL config only — a served-surface
229/// change behind a byte-identical config (bridge/spec deploy) leaves the
230/// fingerprint matching while the cached tool list goes stale: phantom
231/// tools for every fresh session until a restart. Past this age the next
232/// fresh-session dispatch REBUILDS the loader (fresh connect re-lists the
233/// server's tools), bounding the phantom window to ≤ 10 minutes with no
234/// operator action.
235pub const MCP_TOOLSET_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(600);
236
237/// Why a cached MCP loader entry was (or wasn't) reused (42131deb).
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub(crate) enum LoaderFreshness {
240    /// Fingerprint matches and the entry is young — reuse.
241    Fresh,
242    /// Effective config content changed — rebuild (existing 16C behavior).
243    ConfigChanged,
244    /// Entry older than [`MCP_TOOLSET_MAX_AGE`] — the SERVED surface may
245    /// have changed under an unchanged local config; rebuild so the next
246    /// fresh session re-lists the server's tools.
247    AgedOut,
248    /// Degraded entry still inside the retry backoff — fail loud.
249    Backoff(std::time::Duration),
250}
251
252/// Pure staleness decision for a cache entry — unit-testable; the async
253/// cache path consumes it verbatim (b597c9e9 decide-fn pattern).
254pub(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    // Degraded entries first: a poison entry never sticks forever, but it
263    // is held back for the retry window regardless of age/changes.
264    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/// 16F: per-agent dispatch target — THQ agent_name → the agent-user that
290/// THQ-dispatched sessions must run AS.
291///
292/// Populated once at boot by the 16E discovery scan
293/// (`thq_dispatch::populate`); restart-only lifecycle, exactly like the
294/// enrollment lane itself.
295#[derive(Debug, Clone)]
296pub struct ThqDispatchEntry {
297    /// The agent-user's stable key = its Kanidm `sub` (16E sub-pin). This is
298    /// the identity impersonated on the inner dispatch: session bucket,
299    /// per-user home, MCP loader cache, and Cedar principal all resolve
300    /// through it.
301    pub user_key: String,
302    /// The agent's Kanidm service token from its per-user `.env` — exchanged
303    /// for a short-lived `role=agent` Bearer at dispatch time. `None` = the
304    /// agent has no provisioned credential (dispatch fails loud, 502).
305    pub service_token: Option<String>,
306    /// The issuer the token's own credential declares (`[mcp.credentials.*]
307    /// .issuer_url` from the agent's overlay). Kanidm accepts a token
308    /// exchange only on the origin the token was minted for — the issuer
309    /// MUST travel with the credential that owns the token (verified live
310    /// 2026-08-30: same token, 200 on its own vhost, 400 elsewhere).
311    pub issuer_url: Option<String>,
312}
313
314/// 16F: the service-account issuer declared by the shared config's first
315/// `[mcp.credentials.*]` entry with `type = "service-account"` (see
316/// `ServerState::service_issuer` for why this exists).
317pub 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/// Shared state accessible by all axum handlers.
339#[derive(Clone)]
340pub struct ServerState {
341    /// Per-user multi-session registry (MSU).
342    pub sessions: SessionRegistry,
343    /// Broadcast sender for backward compat — delegates to the default user's channel.
344    pub ws_tx: broadcast::Sender<String>,
345    /// Auth state (None = auth disabled, all endpoints open).
346    pub auth: Option<Arc<AuthState>>,
347    /// Shared config TOML (all users share the same agent config).
348    pub config_toml: Option<String>,
349    /// Shared secrets (injected into every per-user session).
350    pub secrets: Option<std::collections::HashMap<String, String>>,
351    /// Shared build info (injected into every per-user session).
352    pub build_info: Option<trustee_core::types::BuildInfo>,
353    /// Global concurrency limiter — limits the number of simultaneous workflows
354    /// across all users. Default: 8 concurrent workflows.
355    pub workflow_semaphore: Arc<tokio::sync::Semaphore>,
356    /// Maximum number of concurrent sessions per user. Default: 4.
357    pub max_sessions_per_user: usize,
358    /// Whether per-user config overlays may override the `[llm]` section.
359    /// Default: `false` — overlays are limited to `[mcp]`.
360    pub allow_llm_overlay: bool,
361    /// Per-user McpToolLoader cache (16C), keyed by user hash (never the raw key).
362    pub mcp_loaders: Arc<DashMap<String, McpLoaderEntry>>,
363    /// Single-flight build locks per user hash (16C).
364    mcp_build_locks: Arc<DashMap<String, Arc<tokio::sync::Mutex<()>>>>,
365    /// 16F: THQ per-agent dispatch table (agent_name → target), boot-populated.
366    pub thq_dispatch: Arc<DashMap<String, ThqDispatchEntry>>,
367    /// 16F: cached impersonation Bearers per agent user_key
368    /// `(token, expires_at)` — expiry-buffered, re-minted on 401.
369    pub agent_dispatch_tokens: Arc<DashMap<String, (String, std::time::Instant)>>,
370}
371
372impl ServerState {
373    /// Create new shared state from a default session, broadcast sender, and optional auth.
374    pub fn new(
375        session: Session,
376        ws_tx: broadcast::Sender<String>,
377        auth: Option<Arc<AuthState>>,
378    ) -> Self {
379        // nghr ec3e0622 acceptance 3: deterministic re-link of pre-rotation
380        // homes, applied once at startup (idempotent; every action logged).
381        apply_home_links();
382
383        let sessions = Arc::new(DashMap::new());
384
385        // Store the default user's UserSessions with an initial session
386        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    /// 16F: the issuer URL Kanidm accepts SERVICE-ACCOUNT token exchange on.
429    ///
430    /// Kanidm binds exchange to the OAuth2 client's configured origin: the
431    /// `[oidc]` issuer host serves logins/validation fine but REJECTS token
432    /// exchange with `invalid_request`, while the `idp` vhost — the issuer
433    /// every working `[mcp.credentials.*]` service-account entry already
434    /// uses — accepts it (verified live 2026-08-30: same token, 200 vs 400).
435    /// The exchange therefore takes its issuer from the shared config's
436    /// first `service-account` credential; callers fall back to the auth
437    /// issuer when absent.
438    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    /// Set the max sessions per user.
458    pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
459        self.max_sessions_per_user = max;
460        self
461    }
462
463    /// Allow per-user config overlays to override the `[llm]` section.
464    /// Default: `false` (overlays limited to `[mcp]`).
465    pub fn with_allow_llm_overlay(mut self, allow: bool) -> Self {
466        self.allow_llm_overlay = allow;
467        self
468    }
469
470    // -----------------------------------------------------------------------
471    // MSU: Multi-session methods
472    // -----------------------------------------------------------------------
473
474    /// Create a new session for a user. Returns the session_id.
475    ///
476    /// Creates a fresh `Session::new()`, copies shared config, sets per-user
477    /// isolation, creates a broadcast channel, spawns a drain task, and inserts
478    /// into the user's session DashMap. The new session becomes the "active" one.
479    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        // Get or create the user's UserSessions entry
487        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        // Check session limit
497        if user_sessions.sessions.len() >= self.max_sessions_per_user {
498            return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
499        }
500
501        // Create new Session
502        let (mut session, workflow_rx) = Session::new();
503
504        // Copy shared config
505        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        // Per-user isolation
523        self.apply_user_isolation(&mut session, user_key);
524
525        // Apply session_name if provided
526        session.session_name = session_name;
527
528        // Apply agent identity if provided
529        session.identity = identity;
530
531        // Create broadcast channel
532        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
533
534        // Generate session_id
535        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        // Insert into user's sessions DashMap
544        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        // Set as active session only when the caller opts in.
555        //
556        // `resume_session` passes `activate: false` so resuming an arbitrary
557        // checkpoint never hijacks the caller's current active live session
558        // pointer. `new_session` / `create_session` (fresh-start paths) pass
559        // `activate: true` to keep the existing "new session becomes active"
560        // behavior. This keeps the per-user active pointer under UI control
561        // rather than letting any authenticated client overwrite it.
562        if activate {
563            *user_sessions.active_session_id.lock().await = session_id.clone();
564        }
565
566        // Spawn drain task
567        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    /// Get a specific session by user_key + session_id.
584    /// Updates last_active on the session entry.
585    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        // Update last_active
594        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    /// Get a session by EITHER its live MSU registry key OR its
601    /// checkpoint/session identity (`session.session_id`).
602    ///
603    /// The web frontend tracks `currentSessionId` from the `ResumeInfo` WS
604    /// message, which carries the auto-derived checkpoint id
605    /// (`session_YYYY_MM_DD_HH_MM_uuid8`) — NOT the live MSU registry key
606    /// (`"default"` or the key from `create_session()`). External clients
607    /// like Torpi/THQ pass the live registry key. This resolver accepts both:
608    ///
609    /// 1. Try registry-key lookup first (precise, used by Torpi/THQ).
610    /// 2. Fall back to scanning the user's live sessions for one whose
611    ///    `session.session_id` matches the requested id (used by the
612    ///    embedded web UI after a command or resume).
613    ///
614    /// Returns `(live_registry_key, session_arc, ws_tx)`, or `None` if not
615    /// found. The live key is returned so callers that need to set it as
616    /// active (or otherwise reference the registry) use the real key.
617    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        // Fast path: registry key match.
623        let user_sessions = self.sessions.get(user_key)?;
624        if let Some(entry) = user_sessions.sessions.get(id) {
625            // Update last_active
626            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        // Slow path: scan live sessions for a matching session.session_id.
632        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                // Update last_active
639                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    /// Tombstone directory for destroyed-but-on-disk session chains.
649    ///
650    /// Destroy removes the live session but deliberately keeps checkpoints
651    /// ("checkpoint data is preserved"). Restoring must NOT resurrect a
652    /// chain the user explicitly terminated, so destroy writes a marker
653    /// here and [`Self::restore_session_from_checkpoint`] refuses to
654    /// hydrate tombstoned chains. Markers are tiny and intentionally
655    /// permanent.
656    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    /// Write a destroy tombstone for a checkpoint chain (best effort).
672    ///
673    /// Called from [`Self::destroy_session`] when the destroyed live
674    /// session was attached to an on-disk chain.
675    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    /// Read the destroy-tombstone stamp for a chain, if one exists.
685    ///
686    /// 7df3cae2 (tombstone-shadow class): a destroyed chain whose
687    /// checkpoints sit intact on disk must be DISCLOSED, never shadowed by
688    /// a lying generic absence — readers use this stamp to answer
689    /// tombstoned-but-non-empty loudly. `None` = no marker (chain either
690    /// alive/resumable or genuinely absent).
691    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    /// Look up a live session by any id; on a miss, transparently restore
703    /// the checkpoint chain from disk.
704    ///
705    /// Restart resilience: trustee-api holds live sessions in memory only,
706    /// so a process restart orphans every open console. When a client asks
707    /// for a chain id that is not in memory but EXISTS on disk
708    /// (checkpoints present, not tombstoned by destroy), a live session is
709    /// rebuilt with `resume_info` pointing at the latest checkpoint and
710    /// registered under the chain id. The next command continues the
711    /// conversation; the console's alive-check and WS reconnect succeed.
712    ///
713    /// Returns `(live_registry_key, session_arc, ws_tx)` — the key is the
714    /// chain id for restored sessions.
715    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    /// Rebuild a live session from an on-disk checkpoint chain.
727    ///
728    /// Persona note: dispatched (THQ/xagent) sessions carry `identity:
729    /// None` by design — their persona is the per-user overlay config
730    /// ([lifecycle].system_template), which `apply_user_isolation` +
731    /// `get_user_config_and_home` re-apply here exactly as at creation.
732    /// Explicit per-request identity overrides (rare) are not persisted
733    /// and therefore not restored.
734    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        // Never resurrect an explicitly destroyed chain.
740        if let Some(path) = self.tombstone_path(user_key, chain_id) {
741            if path.exists() {
742                return None;
743            }
744        }
745
746        // Restore needs the caller's resolved config (persona) and home.
747        let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
748        let config_toml = config_toml?;
749
750        // The chain must exist on disk with at least one checkpoint;
751        // otherwise this id is genuinely unknown → 404 semantics.
752        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        // Single-flight per chain id: racing lookups dedupe on the DashMap
761        // entry — the first insert wins, the loser adopts the winner.
762        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        // Respect the per-user cap: evict the least-recently-active Idle
782        // live session to make room (restores are user-initiated
783        // reattaches, so refusing them would recreate the lost-session
784        // bug for heavy users). If nothing is evictable, refuse.
785        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        // Build the session exactly like create_session, then attach the
806        // on-disk chain identity and resume pointer.
807        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; // display name re-derives from metadata
825
826        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        // Drain task: forward workflow output into the WS broadcast channel
840        // (same contract as create_session — without this the restored
841        // session would run silently).
842        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    /// List all active sessions for a user, sorted by last_active desc.
859    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        // Sort by last_active descending
884        items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
885        items
886    }
887
888    /// Destroy a session. The session must be Idle.
889    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        // Check workflow state before removing; capture the on-disk chain
900        // id (if any) so destroy can tombstone it against later restores.
901        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        // Remove from DashMap
919        user_sessions.sessions.remove(session_id);
920
921        // If the destroyed live session was attached to an on-disk chain,
922        // tombstone it so a later get_or_restore lookup does not resurrect
923        // a session the user explicitly terminated.
924        if let Some(chain_id) = chain_id_for_tombstone {
925            self.write_tombstone(user_key, &chain_id);
926        }
927
928        // If this was the active session, pick a new active
929        let mut active_id = user_sessions.active_session_id.lock().await;
930        if &*active_id == session_id {
931            // Pick the most recently active remaining session
932            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    /// Get or create the user's "active" session for legacy routes.
946    ///
947    /// Behavior:
948    /// 1. If user has no sessions → create one
949    /// 2. If active session exists → return it
950    /// 3. If active session was destroyed → create a new one
951    ///
952    /// Returns: (session_id, session_arc, ws_tx, token_store)
953    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        // Get or create user's UserSessions
963        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        // Check if active session exists
976        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            // Active session was destroyed, fall through to create
987        }
988
989        // Need to create a new session
990        // For the "default" user, we may already have a "default" session entry
991        // from ServerState::new() — check for it
992        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        // Create a brand new session
1016        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    /// DEPRECATED: Use ensure_active_session() instead.
1030    /// Kept for backward compatibility — same 3-tuple return type.
1031    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    /// Set a session as the user's active session.
1040    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    // -----------------------------------------------------------------------
1049    // Read-only helpers (no session creation side effects)
1050    // -----------------------------------------------------------------------
1051
1052    /// Resolve a user's home_dir without creating an in-memory session.
1053    ///
1054    /// This is the read-only equivalent of the isolation logic in
1055    /// `apply_user_isolation`. Used by endpoints that only need to read
1056    /// checkpoint data from disk (history, session list, session detail)
1057    /// and must NOT create ghost sessions as a side effect.
1058    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    /// Resolve config_toml and home_dir without creating an in-memory session.
1065    ///
1066    /// Returns `(config_toml, home_dir)`. If config is not loaded,
1067    /// config_toml will be None.
1068    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    // -----------------------------------------------------------------------
1073    // Private helpers
1074    // -----------------------------------------------------------------------
1075
1076    /// Apply per-user isolation: SHA-256 hash → home_dir + project_id.
1077    ///
1078    /// Hashing goes through the single consolidated [`trustee_core::user_hash`]
1079    /// so the web path and the CLI path can never drift apart.
1080    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        // Set per-user home directory for checkpoint isolation
1085        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        // 92a36073 boot-surface: a MATERIALIZED namespace without per-user
1094        // credentials is the shadowing scenario — say so NOW, not in
1095        // forensics after a global token answered for it.
1096        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        // ── Per-user .env (Task 2) ──────────────────────────────────────
1110        //
1111        // Load per-user secrets from ~/.trustee/users/{hash}/.env
1112        // These are merged on top of shared secrets (per-user wins).
1113        // They are NEVER set as process env vars — used only for ${VAR}
1114        // substitution in the config TOML below.
1115        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        // ── Per-user config overlay (Task 3) ────────────────────────────
1125        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        // ── ${VAR} substitution (Task 4) ────────────────────────────────
1133        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        // ── Strip per-user secrets (Task 5) ────────────────────────────
1142        session.secrets = Some(shared_secrets);
1143    }
1144
1145    /// Load secrets from a user's ~/.trustee/users/{hash}/.env and merge
1146    /// on top of `base` (user wins). Returns the merged map.
1147    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    /// Deep-merge a user's per-user config overlay on top of the shared
1178    /// config. Returns the merged TOML string, or None if no overlay exists.
1179    ///
1180    /// Overlay allowlist (task 16B): only allowlisted top-level sections of
1181    /// the user overlay are merged; anything else is dropped loudly.
1182    /// - `[mcp]` is always allowed — per-user MCP tool sets are the point of
1183    ///   the overlay convention.
1184    /// - `[llm]` is allowed only when the instance opted in via the
1185    ///   `[users].allow_llm_overlay` knob (default **false**).
1186    ///
1187    /// Everything else (`[server]`, `[auth]`, `[storage]`, `[web]`, …) is
1188    /// boot-time, instance-level config and must not be rewritable through a
1189    /// per-user file. This is predictability hardening, not a security
1190    /// boundary: those sections were never re-read from session config.
1191    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                // 16E: [thq] is per-user identity config (read from the overlay
1210                // file at boot); keeping it in the merged session config is
1211                // inert but avoids a false "dropping" warn on every dispatch.
1212                || section == "thq"
1213                // xagent personas: an agent's [lifecycle].system_template IS
1214                // her identity — user-owned by design. The WASM extension
1215                // switch ([lifecycle].enabled) is stripped below: persona is
1216                // user-owned, extension control is not.
1217                || section == "lifecycle"
1218                || (self.allow_llm_overlay && section == "llm")
1219        };
1220        // Mask the user in logs: user_home's dir name IS the hash — never
1221        // log the raw key (keys may be emails). Log a short hash prefix.
1222        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            // Nothing survived the allowlist — no-op, keep shared config as-is.
1256            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    /// Resolve the fully-merged config TOML for a user WITHOUT creating a session.
1268    ///
1269    /// This is the read-only equivalent of the config resolution in
1270    /// `apply_user_isolation`: shared config + per-user overlay + ${VAR}
1271    /// substitution. Used by endpoints that need to inspect config (e.g.
1272    /// listing available LLM models) without spawning a ghost session.
1273    ///
1274    /// Returns None if no shared config is loaded.
1275    pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1276        let config_toml = self.config_toml.clone()?;
1277
1278        // Resolve user home dir (same hash scheme as apply_user_isolation)
1279        let user_home = self.get_user_home_dir(user_key)?;
1280
1281        // Start from shared secrets; merge per-user .env on top
1282        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        // Merge per-user config overlay
1288        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 ${VAR} from merged secrets
1294        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    /// Get or build the per-user MCP tool loader (16C).
1307    ///
1308    /// Cache semantics:
1309    /// - fingerprint = content hash of the effective `[mcp]` section
1310    ///   (shared + allowlist-filtered overlay + ${VAR} substitution, i.e.
1311    ///   exactly what `resolve_user_config` produces) — stale on ANY change.
1312    /// - hit + match → `Arc` clone, zero network I/O.
1313    /// - miss/stale → single-flight build (one build per user at a time;
1314    ///   late arrivals re-check and reuse the winner's entry).
1315    /// - `Ok(None)` = MCP disabled for this user → agent runs MCP-less via
1316    ///   abk `McpSource::Prebuilt(None)` (same semantics as
1317    ///   `[mcp] enabled = false`, no per-task re-evaluation).
1318    /// - build failure → cached degraded entry with
1319    ///   [`MCP_BUILD_RETRY_BACKOFF`]; the error is returned so THIS user's
1320    ///   dispatch fails loud while other users are unaffected.
1321    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        // Effective per-user config — the fingerprint source of truth.
1330        let resolved = self.resolve_user_config(&user_key);
1331        let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1332
1333        // Fast path: non-degraded entry that is fresh AND content-current.
1334        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                    // fall through to the rebuild path below
1346                }
1347                LoaderFreshness::AgedOut => {
1348                    // 42131deb: the served surface may have changed behind
1349                    // a byte-identical config — rebuild (re-lists tools).
1350                    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        // Single-flight: one build per user at a time.
1370        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        // Double-check: another task may have built while we waited.
1378        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    /// Build a loader entry from the effective config. No caching here —
1422    /// the caller owns insertion and degraded handling.
1423    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                        // 0.19.6 (incident fc94aea9, T3): a leftover ${…} in
1438                        // the effective [mcp] section means the credential
1439                        // reference is UNRESOLVED — the literal placeholder
1440                        // must never reach an agent (it once rode to
1441                        // production auth as the Bearer). Fail the build
1442                        // loud; the degraded path handles retry/backoff.
1443                        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                // THE parity-evidence log line (16C acceptance + migration task):
1475                // one INFO line per build; a task loop that reuses the cache
1476                // shows exactly one line per user per fingerprint.
1477                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    /// Spawn a background drain task for a specific session's workflow receiver.
1509    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        // Bug 6: broadcast StateChanged only on actual transitions. Without
1517        // this, every WS message was accompanied by a duplicate StateChanged,
1518        // doubling traffic and re-triggering frontend updateState on hot
1519        // StreamDelta/ReasoningDelta bursts. New subscribers learn the current
1520        // state from the WS snapshot, so the initial None is safe.
1521        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    /// Spawn the default user's drain task (backward compatibility).
1552    /// Called during server startup for the initial session.
1553    pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1554        // Get the default user's first session
1555        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        // Bug 6: transition-only StateChanged broadcasts (see
1571        // spawn_user_drain_task). The task starts in Running state because
1572        // spawn_drain_task is only called after a command began executing.
1573        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    /// Resolve the user key from request headers.
1604    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        // Try Bearer header first
1610        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        // Try cookie
1628        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
1660// ---------------------------------------------------------------------------
1661// SerializableMessage (unchanged)
1662// ---------------------------------------------------------------------------
1663
1664/// Wrapper to serialize `TuiMessage` as JSON with a `type` discriminator.
1665struct 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
1806// ---------------------------------------------------------------------------
1807// Per-user config helpers
1808// ---------------------------------------------------------------------------
1809
1810/// Deep-merge a TOML overlay on top of a base value (in-place).
1811///
1812/// - Tables: recursively merge key-by-key (overlay wins on conflict).
1813/// - Arrays: overlay replaces base entirely (no merging).
1814/// - Scalars: overlay replaces base.
1815/// - If a key exists in overlay but not base, it's added.
1816fn 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                        // Both exist — recurse if both are tables, else replace
1823                        deep_merge_toml(base_val, overlay_val);
1824                    }
1825                    None => {
1826                        // Key only in overlay — insert
1827                        base_table.insert(key.clone(), overlay_val.clone());
1828                    }
1829                }
1830            }
1831        }
1832        // Non-table: overlay replaces base
1833        (base, overlay) => {
1834            *base = overlay.clone();
1835        }
1836    }
1837}
1838
1839/// Replace `${VAR_NAME}` references in a string with values from a secrets map.
1840///
1841/// Falls back to process environment if the variable is not in the map.
1842/// Variables not found in either are left as-is.
1843/// Walk a TOML value tree; collect every string that still contains a
1844/// `${…}` placeholder (post-substitution leftovers — unresolved references).
1845fn 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/// Where a `${VAR}` answer came from (92a36073 credential provenance).
1873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1874pub(crate) enum SecretSource {
1875    /// The secrets map (shared secrets + per-user `.env`, user wins) — the
1876    /// attributed, per-identity answer.
1877    SecretsMap,
1878    /// The PROCESS environment — for the trustee service that is the global
1879    /// `~/.trustee/.env` loaded at boot. When this answers for a per-user
1880    /// namespace, the per-user credential did NOT answer and a global
1881    /// credential did: actions execute under the global principal. Never
1882    /// silent — every occurrence is logged at ERROR with the variable named.
1883    ProcessEnv,
1884    /// Found nowhere; the literal `${…}` remains (the fc94aea9 guard).
1885    Unresolved,
1886}
1887
1888/// One resolved (or unresolved) variable from [`substitute_env_vars`].
1889#[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    // Simple state machine: scan for ${, read until }, replace.
1897    let mut result = String::with_capacity(s.len());
1898    let bytes = s.as_bytes();
1899    let mut i = 0;
1900    // One entry per DISTINCT variable, first sighting wins (a var used
1901    // twice resolves from the same source both times).
1902    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            // Find closing }
1907            if let Some(end) = s[i + 2..].find('}') {
1908                let var_name = &s[i + 2..i + 2 + end];
1909                // Look up in per-user secrets first, then process env
1910                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                    // Not found — leave as-is BUT NOT SILENTLY (incident
1916                    // fc94aea9, T3): an unresolved ${VAR} once rode to
1917                    // production authentication as the literal credential.
1918                    // Every leftover is named, every time.
1919                    (
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                // No closing } — copy as-is
1934                result.push('$');
1935                i += 1;
1936            }
1937        } else {
1938            result.push(bytes[i] as char);
1939            i += 1;
1940        }
1941    }
1942
1943    // 92a36073 — never-silent credential provenance. A process-env answer
1944    // for a per-user namespace is the global-token shadowing hazard: the
1945    // global principal is about to execute what the per-user identity
1946    // should have. ERROR level, EVERY occurrence (no dedup across calls).
1947    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                // Compose the leftover list for the existing loud line.
1960            }
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
1992/// 92a36073 boot-surface check: is a MATERIALIZED namespace missing its
1993/// per-user credentials? A directory without a `.env` is exactly the
1994/// shadowing scenario (rotated/empty home + global token downstream) —
1995/// surface it at boot, not in forensics. Pure so it stays unit-tested.
1996pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1997    user_home.is_dir() && !user_home.join(".env").exists()
1998}
1999
2000// ---------------------------------------------------------------------------
2001// Tests (task 16B: overlay allowlist + consolidated user hash)
2002// ---------------------------------------------------------------------------
2003
2004/// Fingerprint the effective `[mcp]` section of a resolved config (16C).
2005///
2006/// Content hash (SHA-256, first 8 bytes as u64) — never mtime: overlays are
2007/// rewritten in place. Configs without `[mcp]` fingerprint to a stable
2008/// constant, so the disabled state caches too.
2009fn 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    /// Default-knob ServerState for overlay tests.
2027    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        // THE 16F pin (ec3e0622 companion): inside the dispatch scope, EVERY
2036        // user-key-derived resolution lands in the binding's home — the
2037        // caller's own key (and its claims) become irrelevant.
2038        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    /// Unique temp dir with a `config/` subdir (std-only; no tempfile dep).
2057    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    /// (a) Overlay with [mcp] + [server] + [auth]: [mcp] applied (user wins),
2075    /// shared [server] preserved untouched, overlay [auth] dropped entirely.
2076    #[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    /// (b) No overlay file → merge is a no-op (None), shared config untouched.
2098    #[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    /// Overlay whose sections are ALL non-allowlisted → no-op (None).
2108    #[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    /// (c) [llm] dropped when allow_llm_overlay=false (default), applied when
2123    /// the instance opted in via with_allow_llm_overlay(true).
2124    #[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        // Default: knob false → [llm]-only overlay is a no-op.
2130        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        // Opted in: [llm] kept, user value wins; shared [mcp] untouched.
2137        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    /// (d) Web-path home dir resolves through the consolidated
2150    /// trustee_core::user_hash (determinism/known-vectors covered in
2151    /// trustee-core's own tests).
2152    #[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        // dirs::home_dir() unavailable in sandbox → covered by core tests.
2164    }
2165
2166    /// xagent personas: [lifecycle] survives the overlay allowlist (persona
2167    /// is user-owned) but the WASM extension switch does not.
2168    #[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    /// 16D integration guard: dev-agent principals (user_key `agent-<name>`)
2199    /// get their OWN session bucket and home-dir namespace — one agent can
2200    /// never see another's sessions, and both go through the same
2201    /// user_hash isolation path as humans.
2202    #[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        // Each agent resolves its own session; B cannot see A's by id.
2212        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        // Same isolation path as humans: home dir = users_root/user_hash(key).
2223        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// ---------------------------------------------------------------------------
2233// 16C — per-user McpToolLoader cache
2234// ---------------------------------------------------------------------------
2235
2236#[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    /// Deterministic throwaway user: real user-home path under
2249    /// ~/.trustee/users/{hash} (that IS the resolution path under test),
2250    /// with config/ subdir; caller cleans up.
2251    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        // Second call is a fingerprint hit — same disabled marker, still one entry.
2291        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        // v1: an enabled MCP server pointing at a blackhole port — abk keeps
2324        // the loader with a DOWN status (connect refused is fast on loopback).
2325        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        // v2: different server URL → different content fingerprint → rebuild.
2335        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        // The old Arc stays valid for in-flight sessions (no panic, no revoke).
2350        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        // [mcp] present but not a table → McpConfig deserialize fails → Err.
2361        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        // Within backoff: fast-fail again (cached error).
2378        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        // Other users are completely unaffected.
2385        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        // THE 42131deb guard: a served-surface change behind a byte-identical
2398        // config must be reflected in the NEXT fresh session's manifest —
2399        // simulated by rewinding built_at past MCP_TOOLSET_MAX_AGE; the next
2400        // dispatch must NOT reuse the stale Arc (it rebuilds + re-lists).
2401        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        // Simulate time: the entry is now older than the max age, config
2413        // byte-identical (fingerprint UNCHANGED — that's the trap).
2414        {
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        // And a young, unchanged entry still hits (no rebuild churn).
2442        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    /// Deterministic throwaway user (same pattern as mcp_loader_cache_tests).
2463    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        // Attach the live session to an on-disk-style chain id.
2500        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        // And a fresh live session with NO chain id must not tombstone anything.
2514        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        // (No assertion beyond "no panic": session_id was None → no new markers.)
2520    }
2521
2522    #[tokio::test]
2523    async fn restore_refuses_registry_key_shapes() {
2524        // Registry keys ("default", "web…") are not checkpoint chains; even
2525        // if create_resume_info could not find them anyway, the tombstone
2526        // guard short-circuits non-`session_` ids without touching disk.
2527        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(&section, &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        // clean section → nothing
2564        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        // 92a36073: the report names each distinct variable and its source.
2585        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        // 92a36073: a var ABSENT from the per-user/profile secrets but set
2599        // in the process environment is the global-token shadowing shape —
2600        // it must be REPORTED as ProcessEnv, and the secrets map must still
2601        // win when it DOES carry the key.
2602        let var = format!(
2603            "RAVAND_PROVENANCE_TEST_{}",
2604            std::process::id()
2605        );
2606        std::env::set_var(&var, "global-env-answer");
2607
2608        // Absent from secrets → process env answers → ProcessEnv reported.
2609        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        // Same var present in secrets → secrets WIN (per-user precedence
2622        // unchanged), reported as SecretsMap.
2623        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        // 92a36073 boot-surface: warn shape is dir-present + .env-absent.
2641        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        // Not materialized at all → NOT the warning shape.
2648        assert!(!materialized_user_missing_env(&base.join("ghost")));
2649
2650        // Materialized WITHOUT .env → the warning shape.
2651        std::fs::create_dir_all(&base).unwrap();
2652        assert!(materialized_user_missing_env(&base));
2653
2654        // Materialized WITH .env → not the warning shape.
2655        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        // Fresh: same fingerprint, young entry.
2669        assert_eq!(
2670            loader_freshness(fp, young, None, None, fp, now),
2671            LoaderFreshness::Fresh
2672        );
2673        // ConfigChanged: fingerprint differs wins over age.
2674        assert_eq!(
2675            loader_freshness(fp, old, None, None, 0x1234, now),
2676            LoaderFreshness::ConfigChanged
2677        );
2678        // AgedOut: same fingerprint past the max age — the 42131deb guard.
2679        assert_eq!(
2680            loader_freshness(fp, old, None, None, fp, now),
2681            LoaderFreshness::AgedOut
2682        );
2683        // Backoff: degraded inside the retry window, even old + unchanged.
2684        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        // Degraded but PAST backoff → age rules apply (rebuild proceeds).
2692        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