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    /// Look up a live session by any id; on a miss, transparently restore
685    /// the checkpoint chain from disk.
686    ///
687    /// Restart resilience: trustee-api holds live sessions in memory only,
688    /// so a process restart orphans every open console. When a client asks
689    /// for a chain id that is not in memory but EXISTS on disk
690    /// (checkpoints present, not tombstoned by destroy), a live session is
691    /// rebuilt with `resume_info` pointing at the latest checkpoint and
692    /// registered under the chain id. The next command continues the
693    /// conversation; the console's alive-check and WS reconnect succeed.
694    ///
695    /// Returns `(live_registry_key, session_arc, ws_tx)` — the key is the
696    /// chain id for restored sessions.
697    pub async fn get_or_restore_session_by_any_id(
698        &self,
699        user_key: &str,
700        id: &str,
701    ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
702        if let Some(found) = self.get_session_by_any_id(user_key, id).await {
703            return Some(found);
704        }
705        self.restore_session_from_checkpoint(user_key, id).await
706    }
707
708    /// Rebuild a live session from an on-disk checkpoint chain.
709    ///
710    /// Persona note: dispatched (THQ/xagent) sessions carry `identity:
711    /// None` by design — their persona is the per-user overlay config
712    /// ([lifecycle].system_template), which `apply_user_isolation` +
713    /// `get_user_config_and_home` re-apply here exactly as at creation.
714    /// Explicit per-request identity overrides (rare) are not persisted
715    /// and therefore not restored.
716    async fn restore_session_from_checkpoint(
717        &self,
718        user_key: &str,
719        chain_id: &str,
720    ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
721        // Never resurrect an explicitly destroyed chain.
722        if let Some(path) = self.tombstone_path(user_key, chain_id) {
723            if path.exists() {
724                return None;
725            }
726        }
727
728        // Restore needs the caller's resolved config (persona) and home.
729        let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
730        let config_toml = config_toml?;
731
732        // The chain must exist on disk with at least one checkpoint;
733        // otherwise this id is genuinely unknown → 404 semantics.
734        let info = trustee_core::sessions::create_resume_info(
735            &config_toml,
736            chain_id,
737            home_dir.as_deref(),
738        )
739        .await
740        .ok()??;
741
742        // Single-flight per chain id: racing lookups dedupe on the DashMap
743        // entry — the first insert wins, the loser adopts the winner.
744        let user_sessions = self
745            .sessions
746            .entry(user_key.to_string())
747            .or_insert_with(|| UserSessions {
748                sessions: DashMap::new(),
749                token_store: Arc::new(pep::MemoryTokenStore::new()),
750                active_session_id: Mutex::new(String::new()),
751            });
752
753        if let Some(existing) = user_sessions.sessions.get(chain_id) {
754            let now = chrono::Utc::now();
755            *existing.last_active.lock().await = now;
756            return Some((
757                chain_id.to_string(),
758                existing.session.clone(),
759                existing.ws_tx.clone(),
760            ));
761        }
762
763        // Respect the per-user cap: evict the least-recently-active Idle
764        // live session to make room (restores are user-initiated
765        // reattaches, so refusing them would recreate the lost-session
766        // bug for heavy users). If nothing is evictable, refuse.
767        if user_sessions.sessions.len() >= self.max_sessions_per_user {
768            let mut evict: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
769            for entry in user_sessions.sessions.iter() {
770                let is_idle = entry.session.lock().await.workflow_state
771                    == trustee_core::types::WorkflowState::Idle;
772                if !is_idle {
773                    continue;
774                }
775                let la = entry.last_active.lock().await;
776                if evict.as_ref().is_none_or(|(_, t)| *la < *t) {
777                    evict = Some((entry.key().clone(), *la));
778                }
779            }
780            if let Some((victim, _)) = evict {
781                user_sessions.sessions.remove(&victim);
782            } else {
783                return None;
784            }
785        }
786
787        // Build the session exactly like create_session, then attach the
788        // on-disk chain identity and resume pointer.
789        let (mut session, workflow_rx) = Session::new();
790        session.config_toml = Some(config_toml.clone());
791        session.parse_auto_handoff_config();
792        if let Ok(table) = config_toml.parse::<toml::Value>() {
793            if let Some(name) = table
794                .get("agent")
795                .and_then(|a| a.get("name"))
796                .and_then(|n| n.as_str())
797            {
798                session.agent_name = name.to_string();
799            }
800        }
801        session.secrets = self.secrets.clone();
802        session.build_info = self.build_info.clone();
803        self.apply_user_isolation(&mut session, user_key);
804        session.session_id = Some(chain_id.to_string());
805        session.resume_info = Some(info);
806        session.session_name = None; // display name re-derives from metadata
807
808        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
809        let now = chrono::Utc::now();
810
811        user_sessions.sessions.insert(
812            chain_id.to_string(),
813            UserSessionEntry {
814                session: Arc::new(Mutex::new(session)),
815                ws_tx: ws_tx_entry.clone(),
816                created_at: now,
817                last_active: Arc::new(Mutex::new(now)),
818            },
819        );
820
821        // Drain task: forward workflow output into the WS broadcast channel
822        // (same contract as create_session — without this the restored
823        // session would run silently).
824        let session_arc = user_sessions
825            .sessions
826            .get(chain_id)
827            .map(|e| e.session.clone())
828            .ok_or_else(|| SessionError::NotFound(chain_id.to_string()))
829            .ok()?;
830        self.spawn_user_drain_task(
831            chain_id.to_string(),
832            session_arc.clone(),
833            ws_tx_entry.clone(),
834            workflow_rx,
835        );
836
837        Some((chain_id.to_string(), session_arc, ws_tx_entry))
838    }
839
840    /// List all active sessions for a user, sorted by last_active desc.
841    pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> {        let Some(user_sessions) = self.sessions.get(user_key) else {
842            return Vec::new();
843        };
844
845        let mut items = Vec::new();
846        for entry in user_sessions.sessions.iter() {
847            let session = entry.session.lock().await;
848            let workflow_state = match session.workflow_state {
849                trustee_core::types::WorkflowState::Idle => "Idle",
850                trustee_core::types::WorkflowState::Running => "Running",
851                trustee_core::types::WorkflowState::Cancelling => "Cancelling",
852            };
853            let last_active = entry.last_active.lock().await;
854            items.push(SessionListItem {
855                session_id: entry.key().clone(),
856                session_name: session.session_name.clone(),
857                workflow_state: workflow_state.to_string(),
858                created_at: entry.created_at.to_rfc3339(),
859                last_active: last_active.to_rfc3339(),
860                handoff_count: session.handoff_count,
861            });
862        }
863        drop(user_sessions);
864
865        // Sort by last_active descending
866        items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
867        items
868    }
869
870    /// Destroy a session. The session must be Idle.
871    pub async fn destroy_session(
872        &self,
873        user_key: &str,
874        session_id: &str,
875    ) -> Result<(), SessionError> {
876        let user_sessions = self
877            .sessions
878            .get(user_key)
879            .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
880
881        // Check workflow state before removing; capture the on-disk chain
882        // id (if any) so destroy can tombstone it against later restores.
883        let chain_id_for_tombstone = {
884            let entry = user_sessions
885                .sessions
886                .get(session_id)
887                .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
888            let session = entry.session.lock().await;
889            if session.workflow_state != trustee_core::types::WorkflowState::Idle {
890                let state_str = match session.workflow_state {
891                    trustee_core::types::WorkflowState::Running => "Running",
892                    trustee_core::types::WorkflowState::Cancelling => "Cancelling",
893                    _ => "Unknown",
894                };
895                return Err(SessionError::NotIdle(state_str.to_string()));
896            }
897            session.session_id.clone()
898        };
899
900        // Remove from DashMap
901        user_sessions.sessions.remove(session_id);
902
903        // If the destroyed live session was attached to an on-disk chain,
904        // tombstone it so a later get_or_restore lookup does not resurrect
905        // a session the user explicitly terminated.
906        if let Some(chain_id) = chain_id_for_tombstone {
907            self.write_tombstone(user_key, &chain_id);
908        }
909
910        // If this was the active session, pick a new active
911        let mut active_id = user_sessions.active_session_id.lock().await;
912        if &*active_id == session_id {
913            // Pick the most recently active remaining session
914            let mut newest: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
915            for entry in user_sessions.sessions.iter() {
916                let la = entry.last_active.lock().await;
917                if newest.as_ref().map_or(true, |(_, t)| *la > *t) {
918                    newest = Some((entry.key().clone(), *la));
919                }
920            }
921            *active_id = newest.map(|(id, _)| id).unwrap_or_default();
922        }
923
924        Ok(())
925    }
926
927    /// Get or create the user's "active" session for legacy routes.
928    ///
929    /// Behavior:
930    /// 1. If user has no sessions → create one
931    /// 2. If active session exists → return it
932    /// 3. If active session was destroyed → create a new one
933    ///
934    /// Returns: (session_id, session_arc, ws_tx, token_store)
935    pub async fn ensure_active_session(
936        &self,
937        user_key: &str,
938    ) -> (
939        String,
940        Arc<Mutex<Session>>,
941        broadcast::Sender<String>,
942        Arc<pep::MemoryTokenStore>,
943    ) {
944        // Get or create user's UserSessions
945        let token_store = {
946            let user_sessions = self
947                .sessions
948                .entry(user_key.to_string())
949                .or_insert_with(|| UserSessions {
950                    sessions: DashMap::new(),
951                    token_store: Arc::new(pep::MemoryTokenStore::new()),
952                    active_session_id: Mutex::new(String::new()),
953                });
954            user_sessions.token_store.clone()
955        };
956
957        // Check if active session exists
958        let active_id = {
959            let user_sessions = self.sessions.get(user_key).unwrap();
960            let guard = user_sessions.active_session_id.lock().await;
961            guard.clone()
962        };
963
964        if !active_id.is_empty() {
965            if let Some((session, ws_tx)) = self.get_session(user_key, &active_id).await {
966                return (active_id, session, ws_tx, token_store);
967            }
968            // Active session was destroyed, fall through to create
969        }
970
971        // Need to create a new session
972        // For the "default" user, we may already have a "default" session entry
973        // from ServerState::new() — check for it
974        let existing_session: Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> = {
975            let user_sessions = self.sessions.get(user_key).unwrap();
976            let result = user_sessions.sessions.iter().next().map(|first| {
977                (
978                    first.key().clone(),
979                    first.session.clone(),
980                    first.ws_tx.clone(),
981                )
982            });
983            result
984        };
985        if let Some((id, session, ws_tx)) = existing_session {
986            let now = chrono::Utc::now();
987            if let Some(entry) = self.sessions.get(user_key) {
988                if let Some(e) = entry.sessions.get(&id) {
989                    *e.last_active.lock().await = now;
990                }
991                *entry.active_session_id.lock().await = id.clone();
992            }
993
994            return (id, session, ws_tx, token_store);
995        }
996
997        // Create a brand new session
998        let session_id = self
999            .create_session(user_key, None, None, true)
1000            .await
1001            .unwrap_or_else(|_| "default".to_string());
1002
1003        let (session, ws_tx) = self
1004            .get_session(user_key, &session_id)
1005            .await
1006            .expect("just-created session must exist");
1007
1008        (session_id, session, ws_tx, token_store)
1009    }
1010
1011    /// DEPRECATED: Use ensure_active_session() instead.
1012    /// Kept for backward compatibility — same 3-tuple return type.
1013    pub async fn ensure_user_session(
1014        &self,
1015        user_key: &str,
1016    ) -> (Arc<Mutex<Session>>, broadcast::Sender<String>, Arc<pep::MemoryTokenStore>) {
1017        let (_id, session, ws_tx, token_store) = self.ensure_active_session(user_key).await;
1018        (session, ws_tx, token_store)
1019    }
1020
1021    /// Set a session as the user's active session.
1022    pub async fn set_active_session(&self, user_key: &str, session_id: &str) {
1023        if let Some(user_sessions) = self.sessions.get(user_key) {
1024            if user_sessions.sessions.contains_key(session_id) {
1025                *user_sessions.active_session_id.lock().await = session_id.to_string();
1026            }
1027        }
1028    }
1029
1030    // -----------------------------------------------------------------------
1031    // Read-only helpers (no session creation side effects)
1032    // -----------------------------------------------------------------------
1033
1034    /// Resolve a user's home_dir without creating an in-memory session.
1035    ///
1036    /// This is the read-only equivalent of the isolation logic in
1037    /// `apply_user_isolation`. Used by endpoints that only need to read
1038    /// checkpoint data from disk (history, session list, session detail)
1039    /// and must NOT create ghost sessions as a side effect.
1040    pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
1041        let user_key = effective_user_key(user_key);
1042        let hash = trustee_core::user_hash(&user_key);
1043        dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&hash))
1044    }
1045
1046    /// Resolve config_toml and home_dir without creating an in-memory session.
1047    ///
1048    /// Returns `(config_toml, home_dir)`. If config is not loaded,
1049    /// config_toml will be None.
1050    pub fn get_user_config_and_home(&self, user_key: &str) -> (Option<String>, Option<std::path::PathBuf>) {
1051        (self.config_toml.clone(), self.get_user_home_dir(user_key))
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // Private helpers
1056    // -----------------------------------------------------------------------
1057
1058    /// Apply per-user isolation: SHA-256 hash → home_dir + project_id.
1059    ///
1060    /// Hashing goes through the single consolidated [`trustee_core::user_hash`]
1061    /// so the web path and the CLI path can never drift apart.
1062    fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
1063        let user_key = effective_user_key(user_key);
1064        let user_hash = trustee_core::user_hash(&user_key);
1065
1066        // Set per-user home directory for checkpoint isolation
1067        let user_home = if let Some(home) = dirs::home_dir() {
1068            let user_home = home.join(".trustee").join("users").join(&user_hash);
1069            session.home_dir = Some(user_home.clone());
1070            Some(user_home)
1071        } else {
1072            None
1073        };
1074
1075        // 92a36073 boot-surface: a MATERIALIZED namespace without per-user
1076        // credentials is the shadowing scenario — say so NOW, not in
1077        // forensics after a global token answered for it.
1078        if let Some(ref user_home) = user_home {
1079            if materialized_user_missing_env(user_home) {
1080                tracing::warn!(
1081                    "user namespace {} is materialized but has NO per-user .env — \
1082                     ${{…}} references will fall through to process env \
1083                     (global ~/.trustee/.env) and execute under the GLOBAL principal",
1084                    &user_hash[..8.min(user_hash.len())]
1085                );
1086            }
1087        }
1088
1089        session.project_id = Some(format!("web{}", &user_hash[..16]));
1090
1091        // ── Per-user .env (Task 2) ──────────────────────────────────────
1092        //
1093        // Load per-user secrets from ~/.trustee/users/{hash}/.env
1094        // These are merged on top of shared secrets (per-user wins).
1095        // They are NEVER set as process env vars — used only for ${VAR}
1096        // substitution in the config TOML below.
1097        let shared_secrets = session.secrets.clone().unwrap_or_default();
1098        let mut merged_secrets = shared_secrets.clone();
1099
1100        if let Some(ref user_home) = user_home {
1101            if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
1102                merged_secrets = merged;
1103            }
1104        }
1105
1106        // ── Per-user config overlay (Task 3) ────────────────────────────
1107        if let Some(ref user_home) = user_home {
1108            if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref()) {
1109                session.config_toml = Some(merged);
1110                tracing::debug!("Merged per-user config into session");
1111            }
1112        }
1113
1114        // ── ${VAR} substitution (Task 4) ────────────────────────────────
1115        if let Some(ref mut config_toml) = session.config_toml {
1116            substitute_env_vars(
1117                config_toml,
1118                &merged_secrets,
1119                &format!("user {}", &user_hash[..8.min(user_hash.len())]),
1120            );
1121        }
1122
1123        // ── Strip per-user secrets (Task 5) ────────────────────────────
1124        session.secrets = Some(shared_secrets);
1125    }
1126
1127    /// Load secrets from a user's ~/.trustee/users/{hash}/.env and merge
1128    /// on top of `base` (user wins). Returns the merged map.
1129    fn load_user_secrets(
1130        &self,
1131        user_home: &std::path::Path,
1132        base: &std::collections::HashMap<String, String>,
1133    ) -> std::io::Result<std::collections::HashMap<String, String>> {
1134        let user_env_path = user_home.join(".env");
1135        if !user_env_path.exists() {
1136            return Ok(base.clone());
1137        }
1138        let content = std::fs::read_to_string(&user_env_path)?;
1139        let mut merged = base.clone();
1140        for line in content.lines() {
1141            let line = line.trim();
1142            if line.is_empty() || line.starts_with('#') {
1143                continue;
1144            }
1145            if let Some((key, value)) = line.split_once('=') {
1146                let key = key.trim().to_string();
1147                let value = value
1148                    .trim()
1149                    .trim_matches('"')
1150                    .trim_matches('\'')
1151                    .to_string();
1152                merged.insert(key, value);
1153            }
1154        }
1155        tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
1156        Ok(merged)
1157    }
1158
1159    /// Deep-merge a user's per-user config overlay on top of the shared
1160    /// config. Returns the merged TOML string, or None if no overlay exists.
1161    ///
1162    /// Overlay allowlist (task 16B): only allowlisted top-level sections of
1163    /// the user overlay are merged; anything else is dropped loudly.
1164    /// - `[mcp]` is always allowed — per-user MCP tool sets are the point of
1165    ///   the overlay convention.
1166    /// - `[llm]` is allowed only when the instance opted in via the
1167    ///   `[users].allow_llm_overlay` knob (default **false**).
1168    ///
1169    /// Everything else (`[server]`, `[auth]`, `[storage]`, `[web]`, …) is
1170    /// boot-time, instance-level config and must not be rewritable through a
1171    /// per-user file. This is predictability hardening, not a security
1172    /// boundary: those sections were never re-read from session config.
1173    fn merge_user_config(
1174        &self,
1175        user_home: &std::path::Path,
1176        shared_config: Option<&str>,
1177    ) -> Option<String> {
1178        let user_config_path = user_home.join("config").join("trustee.toml");
1179        if !user_config_path.exists() {
1180            return None;
1181        }
1182        let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
1183        let shared = shared_config
1184            .unwrap_or("")
1185            .parse::<toml::Value>()
1186            .ok()?;
1187        let overlay = user_config_toml.parse::<toml::Value>().ok()?;
1188
1189        let allowed = |section: &str| {
1190            section == "mcp"
1191                // 16E: [thq] is per-user identity config (read from the overlay
1192                // file at boot); keeping it in the merged session config is
1193                // inert but avoids a false "dropping" warn on every dispatch.
1194                || section == "thq"
1195                // xagent personas: an agent's [lifecycle].system_template IS
1196                // her identity — user-owned by design. The WASM extension
1197                // switch ([lifecycle].enabled) is stripped below: persona is
1198                // user-owned, extension control is not.
1199                || section == "lifecycle"
1200                || (self.allow_llm_overlay && section == "llm")
1201        };
1202        // Mask the user in logs: user_home's dir name IS the hash — never
1203        // log the raw key (keys may be emails). Log a short hash prefix.
1204        let dir_name = user_home
1205            .file_name()
1206            .and_then(|n| n.to_str())
1207            .unwrap_or("<unknown>");
1208        let masked_user = dir_name.get(..8).unwrap_or(dir_name);
1209
1210        let mut filtered_overlay = toml::map::Map::new();
1211        if let Some(table) = overlay.as_table() {
1212            for (section, value) in table {
1213                if section == "lifecycle" {
1214                    let mut v = value.clone();
1215                    if let Some(t) = v.as_table_mut() {
1216                        if t.remove("enabled").is_some() {
1217                            tracing::info!(
1218                                "user config overlay: [lifecycle].enabled is not user-settable — stripped for user {}",
1219                                masked_user
1220                            );
1221                        }
1222                    }
1223                    filtered_overlay.insert(section.clone(), v);
1224                } else if allowed(section) {
1225                    filtered_overlay.insert(section.clone(), value.clone());
1226                } else {
1227                    tracing::warn!(
1228                        "user config overlay: dropping non-allowlisted section [{}] for user {}",
1229                        section,
1230                        masked_user
1231                    );
1232                }
1233            }
1234        }
1235
1236        if filtered_overlay.is_empty() {
1237            // Nothing survived the allowlist — no-op, keep shared config as-is.
1238            return None;
1239        }
1240        let overlay = toml::Value::Table(filtered_overlay);
1241
1242        let mut shared = shared;
1243        deep_merge_toml(&mut shared, &overlay);
1244        let merged = toml::to_string(&shared).ok()?;
1245        tracing::debug!("Merged per-user config from {}", user_config_path.display());
1246        Some(merged)
1247    }
1248
1249    /// Resolve the fully-merged config TOML for a user WITHOUT creating a session.
1250    ///
1251    /// This is the read-only equivalent of the config resolution in
1252    /// `apply_user_isolation`: shared config + per-user overlay + ${VAR}
1253    /// substitution. Used by endpoints that need to inspect config (e.g.
1254    /// listing available LLM models) without spawning a ghost session.
1255    ///
1256    /// Returns None if no shared config is loaded.
1257    pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1258        let config_toml = self.config_toml.clone()?;
1259
1260        // Resolve user home dir (same hash scheme as apply_user_isolation)
1261        let user_home = self.get_user_home_dir(user_key)?;
1262
1263        // Start from shared secrets; merge per-user .env on top
1264        let mut merged_secrets = self.secrets.clone().unwrap_or_default();
1265        if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
1266            merged_secrets = merged;
1267        }
1268
1269        // Merge per-user config overlay
1270        let mut resolved = config_toml;
1271        if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
1272            resolved = merged;
1273        }
1274
1275        // Substitute ${VAR} from merged secrets
1276        substitute_env_vars(
1277            &mut resolved,
1278            &merged_secrets,
1279            &format!(
1280                "resolve_user_config user {}",
1281                &trustee_core::user_hash(user_key)[..8]
1282            ),
1283        );
1284
1285        Some(resolved)
1286    }
1287
1288    /// Get or build the per-user MCP tool loader (16C).
1289    ///
1290    /// Cache semantics:
1291    /// - fingerprint = content hash of the effective `[mcp]` section
1292    ///   (shared + allowlist-filtered overlay + ${VAR} substitution, i.e.
1293    ///   exactly what `resolve_user_config` produces) — stale on ANY change.
1294    /// - hit + match → `Arc` clone, zero network I/O.
1295    /// - miss/stale → single-flight build (one build per user at a time;
1296    ///   late arrivals re-check and reuse the winner's entry).
1297    /// - `Ok(None)` = MCP disabled for this user → agent runs MCP-less via
1298    ///   abk `McpSource::Prebuilt(None)` (same semantics as
1299    ///   `[mcp] enabled = false`, no per-task re-evaluation).
1300    /// - build failure → cached degraded entry with
1301    ///   [`MCP_BUILD_RETRY_BACKOFF`]; the error is returned so THIS user's
1302    ///   dispatch fails loud while other users are unaffected.
1303    pub async fn get_or_build_mcp_loader(
1304        &self,
1305        user_key: &str,
1306        token_store: &Arc<pep::MemoryTokenStore>,
1307    ) -> Result<Option<std::sync::Arc<abk::agent::McpToolLoader>>, String> {
1308        let user_key = effective_user_key(user_key);
1309        let user_hash = trustee_core::user_hash(&user_key);
1310
1311        // Effective per-user config — the fingerprint source of truth.
1312        let resolved = self.resolve_user_config(&user_key);
1313        let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1314
1315        // Fast path: non-degraded entry that is fresh AND content-current.
1316        if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1317            match loader_freshness(
1318                entry.fingerprint,
1319                entry.built_at,
1320                entry.degraded.as_deref(),
1321                entry.failed_at,
1322                fingerprint,
1323                chrono::Utc::now(),
1324            ) {
1325                LoaderFreshness::Fresh => return Ok(entry.loader.clone()),
1326                LoaderFreshness::ConfigChanged => {
1327                    // fall through to the rebuild path below
1328                }
1329                LoaderFreshness::AgedOut => {
1330                    // 42131deb: the served surface may have changed behind
1331                    // a byte-identical config — rebuild (re-lists tools).
1332                    tracing::info!(
1333                        "MCP toolset for user {} is {}s old (max {}s) — rebuilding \
1334                         to re-list the served surface (phantom-tool guard)",
1335                        &user_hash[..8.min(user_hash.len())],
1336                        (chrono::Utc::now() - entry.built_at).num_seconds(),
1337                        MCP_TOOLSET_MAX_AGE.as_secs()
1338                    );
1339                }
1340                LoaderFreshness::Backoff(remaining) => {
1341                    let err = entry
1342                        .degraded
1343                        .clone()
1344                        .unwrap_or_else(|| "previous MCP loader build failed; retry after backoff".to_string());
1345                    let _ = remaining;
1346                    return Err(err);
1347                }
1348            }
1349        }
1350
1351        // Single-flight: one build per user at a time.
1352        let lock = self
1353            .mcp_build_locks
1354            .entry(user_hash.clone())
1355            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1356            .clone();
1357        let _guard = lock.lock().await;
1358
1359        // Double-check: another task may have built while we waited.
1360        if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1361            if loader_freshness(
1362                entry.fingerprint,
1363                entry.built_at,
1364                entry.degraded.as_deref(),
1365                entry.failed_at,
1366                fingerprint,
1367                chrono::Utc::now(),
1368            ) == LoaderFreshness::Fresh
1369            {
1370                return Ok(entry.loader.clone());
1371            }
1372        }
1373
1374        match self
1375            .build_mcp_loader(&user_hash, resolved.as_deref(), fingerprint, token_store)
1376            .await
1377        {
1378            Ok(entry) => {
1379                self.mcp_loaders.insert(user_hash.clone(), entry);
1380                Ok(self.mcp_loaders.get(&user_hash).unwrap().loader.clone())
1381            }
1382            Err(err) => {
1383                tracing::warn!(
1384                    "MCP loader build FAILED for user {}; dispatch fails loud, retry after {:?}",
1385                    &user_hash[..8.min(user_hash.len())],
1386                    MCP_BUILD_RETRY_BACKOFF
1387                );
1388                self.mcp_loaders.insert(
1389                    user_hash,
1390                    McpLoaderEntry {
1391                        loader: None,
1392                        fingerprint,
1393                        built_at: chrono::Utc::now(),
1394                        degraded: Some(err.clone()),
1395                        failed_at: Some(chrono::Utc::now()),
1396                    },
1397                );
1398                Err(err)
1399            }
1400        }
1401    }
1402
1403    /// Build a loader entry from the effective config. No caching here —
1404    /// the caller owns insertion and degraded handling.
1405    async fn build_mcp_loader(
1406        &self,
1407        user_hash: &str,
1408        resolved: Option<&str>,
1409        fingerprint: u64,
1410        token_store: &Arc<pep::MemoryTokenStore>,
1411    ) -> Result<McpLoaderEntry, String> {
1412        let mcp_config: Option<abk::config::McpConfig> = match resolved {
1413            Some(toml_str) => {
1414                let value = toml_str
1415                    .parse::<toml::Value>()
1416                    .map_err(|e| format!("config parse failed: {}", e))?;
1417                match value.get("mcp") {
1418                    Some(section) => {
1419                        // 0.19.6 (incident fc94aea9, T3): a leftover ${…} in
1420                        // the effective [mcp] section means the credential
1421                        // reference is UNRESOLVED — the literal placeholder
1422                        // must never reach an agent (it once rode to
1423                        // production auth as the Bearer). Fail the build
1424                        // loud; the degraded path handles retry/backoff.
1425                        let mut leftovers: Vec<String> = Vec::new();
1426                        collect_unresolved_placeholders(section, &mut leftovers);
1427                        if !leftovers.is_empty() {
1428                            return Err(format!(
1429                                "unresolved {} placeholder(s) in [mcp]: {} — supply the \
1430                                 exact-case key in the profile secrets",
1431                                leftovers.len(),
1432                                leftovers.join(", ")
1433                            ));
1434                        }
1435                        use serde::Deserialize as _;
1436                        Some(
1437                            abk::config::McpConfig::deserialize(section.clone())
1438                                .map_err(|e| format!("invalid [mcp] config: {}", e))?,
1439                        )
1440                    }
1441                    None => None,
1442                }
1443            }
1444            None => None,
1445        };
1446
1447        let loader = match mcp_config {
1448            Some(cfg) if cfg.enabled => {
1449                let built = abk::agent::McpToolLoader::with_token_store(
1450                    &cfg,
1451                    Some(token_store.clone() as std::sync::Arc<dyn pep::token_store::TokenStore>),
1452                )
1453                .await
1454                .map_err(|e| format!("MCP loader build failed: {}", e))?;
1455
1456                // THE parity-evidence log line (16C acceptance + migration task):
1457                // one INFO line per build; a task loop that reuses the cache
1458                // shows exactly one line per user per fingerprint.
1459                let servers: Vec<String> = built
1460                    .server_statuses
1461                    .iter()
1462                    .map(|s| {
1463                        if s.connected {
1464                            format!("{}(up,{}tools)", s.name, s.tool_count)
1465                        } else {
1466                            format!("{}(DOWN)", s.name)
1467                        }
1468                    })
1469                    .collect();
1470                tracing::info!(
1471                    "MCP loader built for user {}: servers=[{}] total_tools={}",
1472                    &user_hash[..8.min(user_hash.len())],
1473                    servers.join(", "),
1474                    built.tool_count
1475                );
1476                Some(std::sync::Arc::new(built))
1477            }
1478            _ => None,
1479        };
1480
1481        Ok(McpLoaderEntry {
1482            loader,
1483            fingerprint,
1484            built_at: chrono::Utc::now(),
1485            degraded: None,
1486            failed_at: None,
1487        })
1488    }
1489
1490    /// Spawn a background drain task for a specific session's workflow receiver.
1491    fn spawn_user_drain_task(
1492        &self,
1493        session_id: String,
1494        session: Arc<Mutex<Session>>,
1495        ws_tx: broadcast::Sender<String>,
1496        mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
1497    ) {
1498        // Bug 6: broadcast StateChanged only on actual transitions. Without
1499        // this, every WS message was accompanied by a duplicate StateChanged,
1500        // doubling traffic and re-triggering frontend updateState on hot
1501        // StreamDelta/ReasoningDelta bursts. New subscribers learn the current
1502        // state from the WS snapshot, so the initial None is safe.
1503        let mut last_broadcast_state: Option<String> = None;
1504        tokio::spawn(async move {
1505            while let Some(msg) = workflow_rx.recv().await {
1506                {
1507                    let mut session = session.lock().await;
1508                    session.handle_workflow_message(msg.clone());
1509
1510                    let state_str = match session.workflow_state {
1511                        trustee_core::types::WorkflowState::Idle => "Idle",
1512                        trustee_core::types::WorkflowState::Running => "Running",
1513                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1514                    };
1515                    if last_broadcast_state.as_deref() != Some(state_str) {
1516                        last_broadcast_state = Some(state_str.to_string());
1517                        let state_msg = serde_json::json!({
1518                            "type": "StateChanged",
1519                            "state": state_str
1520                        });
1521                        let _ = ws_tx.send(state_msg.to_string());
1522                    }
1523                }
1524
1525                let json =
1526                    serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1527                let _ = ws_tx.send(json);
1528            }
1529            tracing::debug!("Drain task ended for session: {}", session_id);
1530        });
1531    }
1532
1533    /// Spawn the default user's drain task (backward compatibility).
1534    /// Called during server startup for the initial session.
1535    pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1536        // Get the default user's first session
1537        let default_user = self
1538            .sessions
1539            .get("default")
1540            .expect("default user must exist");
1541        let first_entry = default_user
1542            .sessions
1543            .iter()
1544            .next()
1545            .expect("default user must have at least one session");
1546        let session = first_entry.session.clone();
1547        let ws_tx = first_entry.ws_tx.clone();
1548        let session_id = first_entry.key().clone();
1549        drop(first_entry);
1550        drop(default_user);
1551
1552        // Bug 6: transition-only StateChanged broadcasts (see
1553        // spawn_user_drain_task). The task starts in Running state because
1554        // spawn_drain_task is only called after a command began executing.
1555        let mut last_broadcast_state = Some("Running".to_string());
1556        tokio::spawn(async move {
1557            while let Some(msg) = workflow_rx.recv().await {
1558                {
1559                    let mut session = session.lock().await;
1560                    session.handle_workflow_message(msg.clone());
1561
1562                    let state_str = match session.workflow_state {
1563                        trustee_core::types::WorkflowState::Idle => "Idle",
1564                        trustee_core::types::WorkflowState::Running => "Running",
1565                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1566                    };
1567                    if last_broadcast_state.as_deref() != Some(state_str) {
1568                        last_broadcast_state = Some(state_str.to_string());
1569                        let state_msg = serde_json::json!({
1570                            "type": "StateChanged",
1571                            "state": state_str
1572                        });
1573                        let _ = ws_tx.send(state_msg.to_string());
1574                    }
1575                }
1576
1577                let json =
1578                    serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1579                let _ = ws_tx.send(json);
1580            }
1581            tracing::debug!("Drain task ended for session: {}", session_id);
1582        });
1583    }
1584
1585    /// Resolve the user key from request headers.
1586    pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
1587        let Some(ref auth) = self.auth else {
1588            return "default".to_string();
1589        };
1590
1591        // Try Bearer header first
1592        if let Some(token) = headers
1593            .get(axum::http::header::AUTHORIZATION)
1594            .and_then(|v| v.to_str().ok())
1595            .and_then(|v| v.strip_prefix("Bearer "))
1596            .map(|s| s.to_string())
1597        {
1598            if token.starts_with("dev:") {
1599                let parts: Vec<&str> = token.splitn(4, ':').collect();
1600                if parts.len() >= 4 {
1601                    return format!("dev:{}", parts[1]);
1602                }
1603            }
1604            if let Ok(claims) = auth.validate_token(&token).await {
1605                return claims.sub;
1606            }
1607        }
1608
1609        // Try cookie
1610        let cookie_session_id = headers
1611            .get(axum::http::header::COOKIE)
1612            .and_then(|v| v.to_str().ok())
1613            .and_then(|cookies| {
1614                cookies
1615                    .split(';')
1616                    .map(|c| c.trim())
1617                    .find_map(|c| {
1618                        c.strip_prefix(&format!("{}=", auth.config.cookie_name))
1619                            .map(|s| s.to_string())
1620                    })
1621            });
1622
1623        if let Some(session_id) = cookie_session_id {
1624            if session_id.starts_with("dev:") {
1625                let parts: Vec<&str> = session_id.splitn(4, ':').collect();
1626                if parts.len() >= 4 {
1627                    return format!("dev:{}", parts[1]);
1628                }
1629            }
1630
1631            if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
1632                if let Ok(claims) = auth.validate_token(&access_token).await {
1633                    return claims.sub;
1634                }
1635            }
1636        }
1637
1638        "default".to_string()
1639    }
1640}
1641
1642// ---------------------------------------------------------------------------
1643// SerializableMessage (unchanged)
1644// ---------------------------------------------------------------------------
1645
1646/// Wrapper to serialize `TuiMessage` as JSON with a `type` discriminator.
1647struct SerializableMessage<'a>(&'a TuiMessage);
1648
1649impl<'a> serde::Serialize for SerializableMessage<'a> {
1650    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1651    where
1652        S: serde::Serializer,
1653    {
1654        use serde::ser::SerializeStruct;
1655
1656        match self.0 {
1657            TuiMessage::OutputLine(line) => {
1658                let mut s = serializer.serialize_struct("msg", 2)?;
1659                s.serialize_field("type", "OutputLine")?;
1660                s.serialize_field("line", line)?;
1661                s.end()
1662            }
1663            TuiMessage::StreamDelta(delta) => {
1664                let mut s = serializer.serialize_struct("msg", 2)?;
1665                s.serialize_field("type", "StreamDelta")?;
1666                s.serialize_field("delta", delta)?;
1667                s.end()
1668            }
1669            TuiMessage::ReasoningDelta(delta) => {
1670                let mut s = serializer.serialize_struct("msg", 2)?;
1671                s.serialize_field("type", "ReasoningDelta")?;
1672                s.serialize_field("delta", delta)?;
1673                s.end()
1674            }
1675            TuiMessage::WorkflowCompleted => {
1676                let mut s = serializer.serialize_struct("msg", 2)?;
1677                s.serialize_field("type", "WorkflowCompleted")?;
1678                s.serialize_field("state", "Idle")?;
1679                s.end()
1680            }
1681            TuiMessage::WorkflowError(err) => {
1682                let mut s = serializer.serialize_struct("msg", 2)?;
1683                s.serialize_field("type", "WorkflowError")?;
1684                s.serialize_field("error", err)?;
1685                s.end()
1686            }
1687            TuiMessage::ResumeInfo(info) => match info {
1688                Some(ri) => {
1689                    let mut s = serializer.serialize_struct("msg", 5)?;
1690                    s.serialize_field("type", "ResumeInfo")?;
1691                    s.serialize_field("state", "Idle")?;
1692                    s.serialize_field("session_id", &ri.session_id)?;
1693                    s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
1694                    s.serialize_field("iteration", &ri.iteration)?;
1695                    s.end()
1696                }
1697                None => {
1698                    let mut s = serializer.serialize_struct("msg", 2)?;
1699                    s.serialize_field("type", "ResumeInfo")?;
1700                    s.serialize_field("state", "Idle")?;
1701                    s.end()
1702                }
1703            },
1704            TuiMessage::TodoUpdate(content) => {
1705                let mut s = serializer.serialize_struct("msg", 2)?;
1706                s.serialize_field("type", "TodoUpdate")?;
1707                s.serialize_field("content", content)?;
1708                s.end()
1709            }
1710            TuiMessage::WorkflowCancelled => {
1711                let mut s = serializer.serialize_struct("msg", 2)?;
1712                s.serialize_field("type", "WorkflowCancelled")?;
1713                s.serialize_field("state", "Idle")?;
1714                s.end()
1715            }
1716            TuiMessage::HandoffReady(briefing) => {
1717                let mut s = serializer.serialize_struct("msg", 3)?;
1718                s.serialize_field("type", "HandoffReady")?;
1719                s.serialize_field("state", "Idle")?;
1720                s.serialize_field("briefing", briefing)?;
1721                s.end()
1722            }
1723            TuiMessage::SessionRotated { old, new } => {
1724                let mut s = serializer.serialize_struct("msg", 3)?;
1725                s.serialize_field("type", "SessionRotated")?;
1726                s.serialize_field("old", old)?;
1727                s.serialize_field("new", new)?;
1728                s.end()
1729            }
1730            TuiMessage::HandoffFailed => {
1731                let mut s = serializer.serialize_struct("msg", 2)?;
1732                s.serialize_field("type", "HandoffFailed")?;
1733                s.serialize_field("state", "Idle")?;
1734                s.end()
1735            }
1736            TuiMessage::ToolPending {
1737                tool_name,
1738                hint,
1739            } => {
1740                let mut s = serializer.serialize_struct("msg", 3)?;
1741                s.serialize_field("type", "ToolPending")?;
1742                s.serialize_field("tool_name", tool_name)?;
1743                s.serialize_field("hint", hint)?;
1744                s.end()
1745            }
1746            TuiMessage::ToolDone {
1747                tool_name,
1748                success,
1749                hint,
1750            } => {
1751                let mut s = serializer.serialize_struct("msg", 4)?;
1752                s.serialize_field("type", "ToolDone")?;
1753                s.serialize_field("tool_name", tool_name)?;
1754                s.serialize_field("success", success)?;
1755                s.serialize_field("hint", hint)?;
1756                s.end()
1757            }
1758            TuiMessage::ContextTokensUpdated(count) => {
1759                let mut s = serializer.serialize_struct("msg", 2)?;
1760                s.serialize_field("type", "ContextTokensUpdated")?;
1761                s.serialize_field("count", count)?;
1762                s.end()
1763            }
1764            TuiMessage::McpServerStatus {
1765                name,
1766                connected,
1767                tool_count,
1768                error,
1769            } => {
1770                let mut s = serializer.serialize_struct("msg", 5)?;
1771                s.serialize_field("type", "McpServerStatus")?;
1772                s.serialize_field("name", name)?;
1773                s.serialize_field("connected", connected)?;
1774                s.serialize_field("tool_count", tool_count)?;
1775                s.serialize_field("error", error)?;
1776                s.end()
1777            }
1778            TuiMessage::SessionTitleUpdated(title) => {
1779                let mut s = serializer.serialize_struct("msg", 2)?;
1780                s.serialize_field("type", "SessionTitleUpdated")?;
1781                s.serialize_field("title", title)?;
1782                s.end()
1783            }
1784        }
1785    }
1786}
1787
1788// ---------------------------------------------------------------------------
1789// Per-user config helpers
1790// ---------------------------------------------------------------------------
1791
1792/// Deep-merge a TOML overlay on top of a base value (in-place).
1793///
1794/// - Tables: recursively merge key-by-key (overlay wins on conflict).
1795/// - Arrays: overlay replaces base entirely (no merging).
1796/// - Scalars: overlay replaces base.
1797/// - If a key exists in overlay but not base, it's added.
1798fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1799    match (base, overlay) {
1800        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1801            for (key, overlay_val) in overlay_table {
1802                match base_table.get_mut(key) {
1803                    Some(base_val) => {
1804                        // Both exist — recurse if both are tables, else replace
1805                        deep_merge_toml(base_val, overlay_val);
1806                    }
1807                    None => {
1808                        // Key only in overlay — insert
1809                        base_table.insert(key.clone(), overlay_val.clone());
1810                    }
1811                }
1812            }
1813        }
1814        // Non-table: overlay replaces base
1815        (base, overlay) => {
1816            *base = overlay.clone();
1817        }
1818    }
1819}
1820
1821/// Replace `${VAR_NAME}` references in a string with values from a secrets map.
1822///
1823/// Falls back to process environment if the variable is not in the map.
1824/// Variables not found in either are left as-is.
1825/// Walk a TOML value tree; collect every string that still contains a
1826/// `${…}` placeholder (post-substitution leftovers — unresolved references).
1827fn collect_unresolved_placeholders(value: &toml::Value, out: &mut Vec<String>) {
1828    match value {
1829        toml::Value::String(s) => {
1830            if let Some(start) = s.find("${") {
1831                if let Some(end_rel) = s[start + 2..].find('}') {
1832                    let name = &s[start + 2..start + 2 + end_rel];
1833                    let rendered = format!("${{{name}}}");
1834                    if !out.contains(&rendered) {
1835                        out.push(rendered);
1836                    }
1837                }
1838            }
1839        }
1840        toml::Value::Table(t) => {
1841            for v in t.values() {
1842                collect_unresolved_placeholders(v, out);
1843            }
1844        }
1845        toml::Value::Array(a) => {
1846            for v in a {
1847                collect_unresolved_placeholders(v, out);
1848            }
1849        }
1850        _ => {}
1851    }
1852}
1853
1854/// Where a `${VAR}` answer came from (92a36073 credential provenance).
1855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1856pub(crate) enum SecretSource {
1857    /// The secrets map (shared secrets + per-user `.env`, user wins) — the
1858    /// attributed, per-identity answer.
1859    SecretsMap,
1860    /// The PROCESS environment — for the trustee service that is the global
1861    /// `~/.trustee/.env` loaded at boot. When this answers for a per-user
1862    /// namespace, the per-user credential did NOT answer and a global
1863    /// credential did: actions execute under the global principal. Never
1864    /// silent — every occurrence is logged at ERROR with the variable named.
1865    ProcessEnv,
1866    /// Found nowhere; the literal `${…}` remains (the fc94aea9 guard).
1867    Unresolved,
1868}
1869
1870/// One resolved (or unresolved) variable from [`substitute_env_vars`].
1871#[derive(Debug, Clone, PartialEq, Eq)]
1872pub(crate) struct EnvVarResolution {
1873    pub name: String,
1874    pub source: SecretSource,
1875}
1876
1877fn substitute_env_vars(s: &mut String, secrets: &std::collections::HashMap<String, String>, ctx: &str) -> Vec<EnvVarResolution> {
1878    // Simple state machine: scan for ${, read until }, replace.
1879    let mut result = String::with_capacity(s.len());
1880    let bytes = s.as_bytes();
1881    let mut i = 0;
1882    // One entry per DISTINCT variable, first sighting wins (a var used
1883    // twice resolves from the same source both times).
1884    let mut report: Vec<EnvVarResolution> = Vec::new();
1885
1886    while i < bytes.len() {
1887        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1888            // Find closing }
1889            if let Some(end) = s[i + 2..].find('}') {
1890                let var_name = &s[i + 2..i + 2 + end];
1891                // Look up in per-user secrets first, then process env
1892                let (replacement, source) = if let Some(value) = secrets.get(var_name) {
1893                    (value.clone(), SecretSource::SecretsMap)
1894                } else if let Ok(value) = std::env::var(var_name) {
1895                    (value, SecretSource::ProcessEnv)
1896                } else {
1897                    // Not found — leave as-is BUT NOT SILENTLY (incident
1898                    // fc94aea9, T3): an unresolved ${VAR} once rode to
1899                    // production authentication as the literal credential.
1900                    // Every leftover is named, every time.
1901                    (
1902                        s[i..i + 2 + end + 1].to_string(),
1903                        SecretSource::Unresolved,
1904                    )
1905                };
1906                if !report.iter().any(|r| r.name == var_name) {
1907                    report.push(EnvVarResolution {
1908                        name: var_name.to_string(),
1909                        source,
1910                    });
1911                }
1912                result.push_str(&replacement);
1913                i = i + 2 + end + 1;
1914            } else {
1915                // No closing } — copy as-is
1916                result.push('$');
1917                i += 1;
1918            }
1919        } else {
1920            result.push(bytes[i] as char);
1921            i += 1;
1922        }
1923    }
1924
1925    // 92a36073 — never-silent credential provenance. A process-env answer
1926    // for a per-user namespace is the global-token shadowing hazard: the
1927    // global principal is about to execute what the per-user identity
1928    // should have. ERROR level, EVERY occurrence (no dedup across calls).
1929    for r in &report {
1930        match r.source {
1931            SecretSource::ProcessEnv => tracing::error!(
1932                "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from PROCESS ENV \
1933                 (global ~/.trustee/.env at boot) — no per-user/profile secret \
1934                 answered; calls using it execute under the GLOBAL principal. \
1935                 Supply the exact-case key in the per-user .env / profile \
1936                 secrets to restore attribution.",
1937                ctx,
1938                r.name
1939            ),
1940            SecretSource::Unresolved => {
1941                // Compose the leftover list for the existing loud line.
1942            }
1943            SecretSource::SecretsMap => tracing::debug!(
1944                "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from profile/user secrets",
1945                ctx,
1946                r.name
1947            ),
1948        }
1949    }
1950    let unresolved: Vec<&str> = report
1951        .iter()
1952        .filter(|r| r.source == SecretSource::Unresolved)
1953        .map(|r| r.name.as_str())
1954        .collect();
1955    if !unresolved.is_empty() {
1956        tracing::warn!(
1957            "ENV SUBSTITUTION LEFT {} UNRESOLVED [{}]: {} — the literal ${{…}} remains in the \
1958             effective config; supply the key in the profile secrets (exact-case KEY=VALUE) \
1959             or the environment",
1960            unresolved.len(),
1961            ctx,
1962            unresolved
1963                .iter()
1964                .map(|v| format!("${{{v}}}"))
1965                .collect::<Vec<_>>()
1966                .join(", ")
1967        );
1968    }
1969
1970    *s = result;
1971    report
1972}
1973
1974/// 92a36073 boot-surface check: is a MATERIALIZED namespace missing its
1975/// per-user credentials? A directory without a `.env` is exactly the
1976/// shadowing scenario (rotated/empty home + global token downstream) —
1977/// surface it at boot, not in forensics. Pure so it stays unit-tested.
1978pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1979    user_home.is_dir() && !user_home.join(".env").exists()
1980}
1981
1982// ---------------------------------------------------------------------------
1983// Tests (task 16B: overlay allowlist + consolidated user hash)
1984// ---------------------------------------------------------------------------
1985
1986/// Fingerprint the effective `[mcp]` section of a resolved config (16C).
1987///
1988/// Content hash (SHA-256, first 8 bytes as u64) — never mtime: overlays are
1989/// rewritten in place. Configs without `[mcp]` fingerprint to a stable
1990/// constant, so the disabled state caches too.
1991fn fingerprint_mcp_section(resolved: Option<&str>) -> u64 {
1992    use sha2::{Digest, Sha256};
1993    let section = resolved
1994        .and_then(|s| s.parse::<toml::Value>().ok())
1995        .and_then(|v| v.get("mcp").cloned());
1996    let bytes = match section {
1997        Some(v) => v.to_string().into_bytes(),
1998        None => b"<no-mcp>".to_vec(),
1999    };
2000    let digest = Sha256::digest(&bytes);
2001    u64::from_be_bytes(digest[..8].try_into().expect("sha256 digest >= 8 bytes"))
2002}
2003
2004#[cfg(test)]
2005mod tests {
2006    use super::*;
2007
2008    /// Default-knob ServerState for overlay tests.
2009    fn test_state() -> ServerState {
2010        let (session, _rx) = Session::new();
2011        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2012        ServerState::new(session, ws_tx, None)
2013    }
2014
2015    #[tokio::test]
2016    async fn dispatch_scope_pins_user_key_resolution() {
2017        // THE 16F pin (ec3e0622 companion): inside the dispatch scope, EVERY
2018        // user-key-derived resolution lands in the binding's home — the
2019        // caller's own key (and its claims) become irrelevant.
2020        let state = test_state();
2021        let caller = "some-human-sub";
2022        let plain_home = state.get_user_home_dir(caller).unwrap();
2023        let pinned_key = "prof-1234";
2024        let pinned_home = in_dispatch_scope(pinned_key, async {
2025            state.get_user_home_dir(caller).unwrap()
2026        })
2027        .await;
2028        let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2029        assert_eq!(plain_home, users_root.join(trustee_core::user_hash(caller)));
2030        assert_eq!(
2031            pinned_home,
2032            users_root.join(trustee_core::user_hash(pinned_key)),
2033            "dispatch scope must pin resolution to the binding key"
2034        );
2035        assert_ne!(plain_home, pinned_home);
2036    }
2037
2038    /// Unique temp dir with a `config/` subdir (std-only; no tempfile dep).
2039    fn temp_user_home(tag: &str) -> std::path::PathBuf {
2040        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2041        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2042        let dir = std::env::temp_dir().join(format!(
2043            "trustee-state-test-{}-{}-{}",
2044            tag,
2045            std::process::id(),
2046            n
2047        ));
2048        std::fs::create_dir_all(dir.join("config")).expect("create temp user home");
2049        dir
2050    }
2051
2052    fn parse(toml_str: &str) -> toml::Value {
2053        toml_str.parse::<toml::Value>().expect("valid test TOML")
2054    }
2055
2056    /// (a) Overlay with [mcp] + [server] + [auth]: [mcp] applied (user wins),
2057    /// shared [server] preserved untouched, overlay [auth] dropped entirely.
2058    #[test]
2059    fn overlay_allowlist_drops_non_allowlisted_sections() {
2060        let state = test_state();
2061        let home = temp_user_home("allowlist");
2062        std::fs::write(
2063            home.join("config").join("trustee.toml"),
2064            "[server]\nport = 1\n\n[auth]\nmode = \"kanidm\"\n\n[mcp]\nmode = \"user\"\n",
2065        )
2066        .expect("write overlay");
2067
2068        let shared = "[server]\nport = 8080\n\n[mcp]\nmode = \"shared\"\n";
2069        let merged = state
2070            .merge_user_config(&home, Some(shared))
2071            .expect("overlay has allowlisted content");
2072
2073        let merged_val = parse(&merged);
2074        let expected = parse("[server]\nport = 8080\n\n[mcp]\nmode = \"user\"\n");
2075        assert_eq!(merged_val, expected, "merged config must be shared + [mcp] overlay only");
2076        assert!(merged_val.get("auth").is_none(), "overlay [auth] must be dropped");
2077    }
2078
2079    /// (b) No overlay file → merge is a no-op (None), shared config untouched.
2080    #[test]
2081    fn no_overlay_file_returns_none() {
2082        let state = test_state();
2083        let home = temp_user_home("empty");
2084        assert!(state
2085            .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2086            .is_none());
2087    }
2088
2089    /// Overlay whose sections are ALL non-allowlisted → no-op (None).
2090    #[test]
2091    fn overlay_with_no_allowlisted_sections_is_noop() {
2092        let state = test_state();
2093        let home = temp_user_home("all-dropped");
2094        std::fs::write(
2095            home.join("config").join("trustee.toml"),
2096            "[server]\nport = 1\n\n[storage]\npath = \"/tmp/x\"\n",
2097        )
2098        .expect("write overlay");
2099        assert!(state
2100            .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2101            .is_none());
2102    }
2103
2104    /// (c) [llm] dropped when allow_llm_overlay=false (default), applied when
2105    /// the instance opted in via with_allow_llm_overlay(true).
2106    #[test]
2107    fn llm_overlay_dropped_by_default_and_kept_when_enabled() {
2108        let shared = "[llm]\nprovider = \"openai\"\n\n[mcp]\nmode = \"shared\"\n";
2109        let overlay = "[llm]\nprovider = \"anthropic\"\n";
2110
2111        // Default: knob false → [llm]-only overlay is a no-op.
2112        let state = test_state();
2113        assert!(!state.allow_llm_overlay);
2114        let home = temp_user_home("llm-off");
2115        std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2116        assert!(state.merge_user_config(&home, Some(shared)).is_none());
2117
2118        // Opted in: [llm] kept, user value wins; shared [mcp] untouched.
2119        let state = test_state().with_allow_llm_overlay(true);
2120        assert!(state.allow_llm_overlay);
2121        let home = temp_user_home("llm-on");
2122        std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2123        let merged = state
2124            .merge_user_config(&home, Some(shared))
2125            .expect("[llm] overlay applies when opted in");
2126        let merged_val = parse(&merged);
2127        assert_eq!(merged_val["llm"]["provider"].as_str(), Some("anthropic"));
2128        assert_eq!(merged_val["mcp"]["mode"].as_str(), Some("shared"));
2129    }
2130
2131    /// (d) Web-path home dir resolves through the consolidated
2132    /// trustee_core::user_hash (determinism/known-vectors covered in
2133    /// trustee-core's own tests).
2134    #[test]
2135    fn user_home_dir_uses_consolidated_hash() {
2136        let state = test_state();
2137        if let Some(home) = state.get_user_home_dir("farzan@example.com") {
2138            assert_eq!(
2139                home.file_name().and_then(|n| n.to_str()),
2140                Some(trustee_core::user_hash("farzan@example.com")).as_deref()
2141            );
2142            let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2143            assert_eq!(home.parent(), Some(&users_root).map(|p| p.as_path()));
2144        }
2145        // dirs::home_dir() unavailable in sandbox → covered by core tests.
2146    }
2147
2148    /// xagent personas: [lifecycle] survives the overlay allowlist (persona
2149    /// is user-owned) but the WASM extension switch does not.
2150    #[test]
2151    fn lifecycle_overlay_survives_allowlist_enabled_stripped() {
2152        let state = test_state();
2153        let home = temp_user_home("lifecycle-persona");
2154        std::fs::write(
2155            home.join("config").join("trustee.toml"),
2156            "[lifecycle]\nsystem_template = \"You are Saman.\"\nenabled = true\n",
2157        )
2158        .expect("write overlay");
2159
2160        let merged = state
2161            .merge_user_config(
2162                &home,
2163                Some("[lifecycle]\nsystem_template = \"shared default\"\n"),
2164            )
2165            .expect("lifecycle overlay is allowlisted");
2166
2167        let v = merged.parse::<toml::Value>().expect("valid TOML");
2168        assert_eq!(
2169            v["lifecycle"]["system_template"].as_str(),
2170            Some("You are Saman."),
2171            "agent persona wins over shared default"
2172        );
2173        assert_eq!(
2174            v["lifecycle"].get("enabled"),
2175            None,
2176            "WASM extension switch is not user-settable"
2177        );
2178    }
2179
2180    /// 16D integration guard: dev-agent principals (user_key `agent-<name>`)
2181    /// get their OWN session bucket and home-dir namespace — one agent can
2182    /// never see another's sessions, and both go through the same
2183    /// user_hash isolation path as humans.
2184    #[tokio::test]
2185    async fn agent_principals_get_isolated_session_buckets() {
2186        let state = test_state();
2187        let key_a = "agent-farzan";
2188        let key_b = "agent-paydar";
2189
2190        let (sid_a, session_a, _tx_a, _ts_a) = state.ensure_active_session(key_a).await;
2191        let (_sid_b, _session_b, _tx_b, _ts_b) = state.ensure_active_session(key_b).await;
2192
2193        // Each agent resolves its own session; B cannot see A's by id.
2194        assert!(
2195            state.get_session_by_any_id(key_a, &sid_a).await.is_some(),
2196            "owner bucket resolves its own session"
2197        );
2198        assert!(
2199            state.get_session_by_any_id(key_b, &sid_a).await.is_none(),
2200            "cross-agent session access must be 404/None"
2201        );
2202        assert!(Arc::strong_count(&session_a) >= 1);
2203
2204        // Same isolation path as humans: home dir = users_root/user_hash(key).
2205        if let Some(home) = state.get_user_home_dir(key_a) {
2206            assert_eq!(
2207                home.file_name().and_then(|n| n.to_str()),
2208                Some(trustee_core::user_hash(key_a)).as_deref()
2209            );
2210        }
2211    }
2212}
2213
2214// ---------------------------------------------------------------------------
2215// 16C — per-user McpToolLoader cache
2216// ---------------------------------------------------------------------------
2217
2218#[cfg(test)]
2219mod mcp_loader_cache_tests {
2220    use super::*;
2221
2222    fn state_with_shared(shared: &str) -> ServerState {
2223        let (session, _rx) = Session::new();
2224        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2225        let mut state = ServerState::new(session, ws_tx, None);
2226        state.config_toml = Some(shared.to_string());
2227        state
2228    }
2229
2230    /// Deterministic throwaway user: real user-home path under
2231    /// ~/.trustee/users/{hash} (that IS the resolution path under test),
2232    /// with config/ subdir; caller cleans up.
2233    struct TempUser {
2234        key: String,
2235        home: std::path::PathBuf,
2236    }
2237
2238    impl TempUser {
2239        fn new(tag: &str) -> Self {
2240            let key = format!("16c-{tag}-{}@test.invalid", std::process::id());
2241            let home = dirs::home_dir()
2242                .expect("HOME available in test env")
2243                .join(".trustee")
2244                .join("users")
2245                .join(trustee_core::user_hash(&key));
2246            std::fs::create_dir_all(home.join("config")).expect("create user home");
2247            Self { key, home }
2248        }
2249
2250        fn write_overlay(&self, toml_str: &str) {
2251            std::fs::write(self.home.join("config").join("trustee.toml"), toml_str)
2252                .expect("write overlay");
2253        }
2254    }
2255
2256    impl Drop for TempUser {
2257        fn drop(&mut self) {
2258            let _ = std::fs::remove_dir_all(&self.home);
2259        }
2260    }
2261
2262    #[tokio::test]
2263    async fn no_mcp_config_caches_disabled_marker() {
2264        let state = state_with_shared("[server]\nport = 8080\n");
2265        let user = TempUser::new("nomcp");
2266        let ts = Arc::new(pep::MemoryTokenStore::new());
2267
2268        let first = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2269        assert!(first.is_none(), "no [mcp] anywhere → disabled marker");
2270        assert_eq!(state.mcp_loaders.len(), 1, "exactly one cache entry");
2271
2272        // Second call is a fingerprint hit — same disabled marker, still one entry.
2273        let second = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2274        assert!(second.is_none());
2275        assert_eq!(state.mcp_loaders.len(), 1);
2276    }
2277
2278    #[tokio::test]
2279    async fn concurrent_cold_builds_single_flight() {
2280        let state = Arc::new(state_with_shared("[server]\nport = 8080\n"));
2281        let user = TempUser::new("singleflight");
2282        let ts = Arc::new(pep::MemoryTokenStore::new());
2283
2284        let mut handles = Vec::new();
2285        for _ in 0..5 {
2286            let state = state.clone();
2287            let key = user.key.clone();
2288            let ts = ts.clone();
2289            handles.push(tokio::spawn(async move {
2290                state.get_or_build_mcp_loader(&key, &ts).await
2291            }));
2292        }
2293        for h in handles {
2294            h.await.unwrap().expect("all five succeed");
2295        }
2296        assert_eq!(state.mcp_loaders.len(), 1, "single-flight → one entry");
2297    }
2298
2299    #[tokio::test]
2300    async fn fingerprint_change_triggers_rebuild() {
2301        let state = state_with_shared("[server]\nport = 8080\n");
2302        let user = TempUser::new("fpchange");
2303        let ts = Arc::new(pep::MemoryTokenStore::new());
2304
2305        // v1: an enabled MCP server pointing at a blackhole port — abk keeps
2306        // the loader with a DOWN status (connect refused is fast on loopback).
2307        user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2308        let v1 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2309        assert!(v1.is_some(), "enabled [mcp] → real loader");
2310        let fp1 = state
2311            .mcp_loaders
2312            .get(&trustee_core::user_hash(&user.key))
2313            .unwrap()
2314            .fingerprint;
2315
2316        // v2: different server URL → different content fingerprint → rebuild.
2317        std::thread::sleep(std::time::Duration::from_millis(5));
2318        user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v2\"\nurl = \"http://127.0.0.1:9/other\"\n");
2319        let v2 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2320        assert!(v2.is_some());
2321        let entry = state
2322            .mcp_loaders
2323            .get(&trustee_core::user_hash(&user.key))
2324            .unwrap();
2325        assert_ne!(
2326            entry.fingerprint, fp1,
2327            "fingerprint must change with content"
2328        );
2329        assert!(entry.degraded.is_none());
2330
2331        // The old Arc stays valid for in-flight sessions (no panic, no revoke).
2332        let _still_usable = v1.as_ref().unwrap().tool_count;
2333    }
2334
2335    #[tokio::test]
2336    async fn degraded_entry_fails_loud_within_backoff_and_isolates_users() {
2337        let state = state_with_shared("[server]\nport = 8080\n");
2338        let bad = TempUser::new("degraded-bad");
2339        let good = TempUser::new("degraded-good");
2340        let ts = Arc::new(pep::MemoryTokenStore::new());
2341
2342        // [mcp] present but not a table → McpConfig deserialize fails → Err.
2343        bad.write_overlay("[mcp]\nenabled = \"not-a-bool\"\n");
2344        let err = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2345            Ok(_) => panic!("invalid [mcp] must fail loud"),
2346            Err(e) => e,
2347        };
2348        assert!(
2349            err.contains("invalid [mcp]"),
2350            "surfaces the parse error: {err}"
2351        );
2352
2353        let entry = state
2354            .mcp_loaders
2355            .get(&trustee_core::user_hash(&bad.key))
2356            .unwrap();
2357        assert!(entry.degraded.is_some(), "poison entry recorded");
2358
2359        // Within backoff: fast-fail again (cached error).
2360        let err2 = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2361            Ok(_) => panic!("still within backoff"),
2362            Err(e) => e,
2363        };
2364        assert_eq!(err, err2, "same cached error");
2365
2366        // Other users are completely unaffected.
2367        let good_loader = state
2368            .get_or_build_mcp_loader(&good.key, &ts)
2369            .await
2370            .expect("other user unaffected");
2371        assert!(
2372            good_loader.is_none(),
2373            "good user has no [mcp] → disabled marker"
2374        );
2375    }
2376
2377    #[tokio::test]
2378    async fn aged_out_entry_rebuilds_without_restart() {
2379        // THE 42131deb guard: a served-surface change behind a byte-identical
2380        // config must be reflected in the NEXT fresh session's manifest —
2381        // simulated by rewinding built_at past MCP_TOOLSET_MAX_AGE; the next
2382        // dispatch must NOT reuse the stale Arc (it rebuilds + re-lists).
2383        let state = state_with_shared("[server]\nport = 8080\n");
2384        let user = TempUser::new("agedout");
2385        let ts = Arc::new(pep::MemoryTokenStore::new());
2386
2387        user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2388        let v1 = state
2389            .get_or_build_mcp_loader(&user.key, &ts)
2390            .await
2391            .expect("initial build");
2392        let v1 = v1.expect("enabled [mcp] → loader");
2393
2394        // Simulate time: the entry is now older than the max age, config
2395        // byte-identical (fingerprint UNCHANGED — that's the trap).
2396        {
2397            let mut entry = state
2398                .mcp_loaders
2399                .get_mut(&trustee_core::user_hash(&user.key))
2400                .unwrap();
2401            entry.built_at =
2402                chrono::Utc::now() - chrono::Duration::seconds(601);
2403        }
2404
2405        let v2 = state
2406            .get_or_build_mcp_loader(&user.key, &ts)
2407            .await
2408            .expect("aged-out dispatch rebuilds");
2409        let v2 = v2.expect("rebuilt loader present");
2410        assert!(
2411            !Arc::ptr_eq(&v1, &v2),
2412            "aged-out entry must NOT be reused — fresh connect re-lists the served surface"
2413        );
2414        let entry = state
2415            .mcp_loaders
2416            .get(&trustee_core::user_hash(&user.key))
2417            .unwrap();
2418        assert!(
2419            chrono::Utc::now() - entry.built_at < chrono::Duration::seconds(5),
2420            "built_at refreshed by the rebuild"
2421        );
2422
2423        // And a young, unchanged entry still hits (no rebuild churn).
2424        let v3 = state
2425            .get_or_build_mcp_loader(&user.key, &ts)
2426            .await
2427            .expect("fresh dispatch");
2428        assert!(Arc::ptr_eq(&v2, &v3.unwrap()), "young entry hits the cache");
2429    }
2430}
2431
2432#[cfg(test)]
2433mod restore_tests {
2434    use super::*;
2435
2436    fn fresh_state() -> ServerState {
2437        let (session, _rx) = Session::new();
2438        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2439        let mut state = ServerState::new(session, ws_tx, None);
2440        state.config_toml = Some("[agent]\nname = \"trustee\"\n".to_string());
2441        state
2442    }
2443
2444    /// Deterministic throwaway user (same pattern as mcp_loader_cache_tests).
2445    struct TempUser {
2446        key: String,
2447    }
2448
2449    impl TempUser {
2450        fn new(tag: &str) -> Self {
2451            let key = format!("restore-{tag}-{}@test.invalid", std::process::id());
2452            let home = dirs::home_dir()
2453                .expect("HOME available in test env")
2454                .join(".trustee")
2455                .join("users")
2456                .join(trustee_core::user_hash(&key));
2457            std::fs::create_dir_all(home).expect("create user home");
2458            Self { key }
2459        }
2460    }
2461
2462    #[tokio::test]
2463    async fn restore_unknown_chain_returns_none() {
2464        let state = fresh_state();
2465        let user = TempUser::new("unknown");
2466        let out = state
2467            .get_or_restore_session_by_any_id(&user.key, "session_2099_01_01_00_00_deadbeef")
2468            .await;
2469        assert!(out.is_none(), "unknown chain must 404, not restore");
2470    }
2471
2472    #[tokio::test]
2473    async fn destroy_tombstones_chain_id() {
2474        let state = fresh_state();
2475        let user = TempUser::new("tombstone");
2476        let sid = state
2477            .create_session(&user.key, None, None, true)
2478            .await
2479            .expect("create session");
2480
2481        // Attach the live session to an on-disk-style chain id.
2482        let (session, _ws) = state.get_session(&user.key, &sid).await.unwrap();
2483        {
2484            let mut s = session.lock().await;
2485            s.session_id = Some("session_2026_09_04_10_00_aaaabbbb".to_string());
2486        }
2487
2488        state.destroy_session(&user.key, &sid).await.expect("destroy");
2489
2490        let path = state
2491            .tombstone_path(&user.key, "session_2026_09_04_10_00_aaaabbbb")
2492            .expect("tombstone path");
2493        assert!(path.exists(), "destroy must write a chain tombstone");
2494
2495        // And a fresh live session with NO chain id must not tombstone anything.
2496        let sid2 = state
2497            .create_session(&user.key, None, None, true)
2498            .await
2499            .expect("create second session");
2500        state.destroy_session(&user.key, &sid2).await.expect("destroy 2");
2501        // (No assertion beyond "no panic": session_id was None → no new markers.)
2502    }
2503
2504    #[tokio::test]
2505    async fn restore_refuses_registry_key_shapes() {
2506        // Registry keys ("default", "web…") are not checkpoint chains; even
2507        // if create_resume_info could not find them anyway, the tombstone
2508        // guard short-circuits non-`session_` ids without touching disk.
2509        let state = fresh_state();
2510        let user = TempUser::new("shapes");
2511        assert!(state.tombstone_path(&user.key, "default").is_none());
2512        let out = state.get_or_restore_session_by_any_id(&user.key, "default").await;
2513        assert!(out.is_none());
2514    }
2515}
2516
2517    #[test]
2518    fn collect_unresolved_placeholders_walks_the_tree() {
2519        let section: toml::Value = toml::from_str(
2520            r#"
2521[credentials.farzan_service_account]
2522service_token = "${FARZAN_SERVICE_ACCOUNT}"
2523issuer_url = "https://ok.example"
2524
2525[[servers]]
2526name = "fame"
2527url = "https://fame.example"
2528credentials = "farzan_service_account"
2529
2530[[servers]]
2531name = "other"
2532url = "https://other.example"
2533token = "${OTHER_TOKEN}"
2534"#,
2535        )
2536        .unwrap();
2537        let mut out = Vec::new();
2538        collect_unresolved_placeholders(&section, &mut out);
2539        out.sort();
2540        assert_eq!(
2541            out,
2542            vec!["${FARZAN_SERVICE_ACCOUNT}".to_string(), "${OTHER_TOKEN}".to_string()],
2543            "every leftover placeholder in the [mcp] tree must be collected, deduped"
2544        );
2545        // clean section → nothing
2546        let clean: toml::Value = toml::from_str("[x]\ny = \"z\"\n").unwrap();
2547        let mut out2 = Vec::new();
2548        collect_unresolved_placeholders(&clean, &mut out2);
2549        assert!(out2.is_empty());
2550    }
2551
2552    #[test]
2553    fn substitute_env_vars_resolves_and_keeps_leftovers_deterministically() {
2554        let mut secrets = std::collections::HashMap::new();
2555        secrets.insert("A_TOKEN".to_string(), "resolved-value".to_string());
2556        let mut s = "x = \"${A_TOKEN}\" y = \"${MISSING_TOKEN}\"".to_string();
2557        let report = substitute_env_vars(&mut s, &secrets, "test");
2558        assert!(
2559            s.contains("resolved-value"),
2560            "declared key resolves: {s}"
2561        );
2562        assert!(
2563            s.contains("${MISSING_TOKEN}"),
2564            "undeclared key keeps the literal (the loader guard turns this into a loud failure): {s}"
2565        );
2566        // 92a36073: the report names each distinct variable and its source.
2567        assert_eq!(report.len(), 2, "one entry per distinct var: {report:?}");
2568        assert!(report.contains(&EnvVarResolution {
2569            name: "A_TOKEN".to_string(),
2570            source: SecretSource::SecretsMap
2571        }));
2572        assert!(report.contains(&EnvVarResolution {
2573            name: "MISSING_TOKEN".to_string(),
2574            source: SecretSource::Unresolved
2575        }));
2576    }
2577
2578    #[test]
2579    fn substitute_env_vars_reports_process_env_shadow() {
2580        // 92a36073: a var ABSENT from the per-user/profile secrets but set
2581        // in the process environment is the global-token shadowing shape —
2582        // it must be REPORTED as ProcessEnv, and the secrets map must still
2583        // win when it DOES carry the key.
2584        let var = format!(
2585            "RAVAND_PROVENANCE_TEST_{}",
2586            std::process::id()
2587        );
2588        std::env::set_var(&var, "global-env-answer");
2589
2590        // Absent from secrets → process env answers → ProcessEnv reported.
2591        let mut secrets = std::collections::HashMap::new();
2592        let mut s = format!("x = \"${{{var}}}\"");
2593        let report = substitute_env_vars(&mut s, &secrets, "test-shadow");
2594        assert!(s.contains("global-env-answer"), "process env answered: {s}");
2595        assert_eq!(
2596            report,
2597            vec![EnvVarResolution {
2598                name: var.clone(),
2599                source: SecretSource::ProcessEnv
2600            }]
2601        );
2602
2603        // Same var present in secrets → secrets WIN (per-user precedence
2604        // unchanged), reported as SecretsMap.
2605        secrets.insert(var.clone(), "per-user-answer".to_string());
2606        let mut s2 = format!("x = \"${{{var}}}\"");
2607        let report2 = substitute_env_vars(&mut s2, &secrets, "test-wins");
2608        assert!(s2.contains("per-user-answer"), "user secrets win: {s2}");
2609        assert_eq!(
2610            report2,
2611            vec![EnvVarResolution {
2612                name: var.clone(),
2613                source: SecretSource::SecretsMap
2614            }]
2615        );
2616
2617        std::env::remove_var(&var);
2618    }
2619
2620    #[test]
2621    fn materialized_user_missing_env_only_fires_on_dir_without_env() {
2622        // 92a36073 boot-surface: warn shape is dir-present + .env-absent.
2623        let base = std::env::temp_dir().join(format!(
2624            "trustee-envcheck-{}",
2625            std::process::id()
2626        ));
2627        let _ = std::fs::remove_dir_all(&base);
2628
2629        // Not materialized at all → NOT the warning shape.
2630        assert!(!materialized_user_missing_env(&base.join("ghost")));
2631
2632        // Materialized WITHOUT .env → the warning shape.
2633        std::fs::create_dir_all(&base).unwrap();
2634        assert!(materialized_user_missing_env(&base));
2635
2636        // Materialized WITH .env → not the warning shape.
2637        std::fs::write(base.join(".env"), "K=V\n").unwrap();
2638        assert!(!materialized_user_missing_env(&base));
2639
2640        let _ = std::fs::remove_dir_all(&base);
2641    }
2642
2643    #[test]
2644    fn loader_freshness_decides_all_four_states() {
2645        let now = chrono::Utc::now();
2646        let fp = 0xABCD_u64;
2647        let young = now - chrono::Duration::seconds(60);
2648        let old = now - chrono::Duration::seconds(601);
2649
2650        // Fresh: same fingerprint, young entry.
2651        assert_eq!(
2652            loader_freshness(fp, young, None, None, fp, now),
2653            LoaderFreshness::Fresh
2654        );
2655        // ConfigChanged: fingerprint differs wins over age.
2656        assert_eq!(
2657            loader_freshness(fp, old, None, None, 0x1234, now),
2658            LoaderFreshness::ConfigChanged
2659        );
2660        // AgedOut: same fingerprint past the max age — the 42131deb guard.
2661        assert_eq!(
2662            loader_freshness(fp, old, None, None, fp, now),
2663            LoaderFreshness::AgedOut
2664        );
2665        // Backoff: degraded inside the retry window, even old + unchanged.
2666        let failed = now - chrono::Duration::seconds(10);
2667        match loader_freshness(fp, old, Some("boom"), Some(failed), fp, now) {
2668            LoaderFreshness::Backoff(remaining) => {
2669                assert!(remaining.as_secs() >= 15, "~20s left: {remaining:?}");
2670            }
2671            other => panic!("expected Backoff, got {other:?}"),
2672        }
2673        // Degraded but PAST backoff → age rules apply (rebuild proceeds).
2674        let failed_long = now - chrono::Duration::seconds(120);
2675        assert_eq!(
2676            loader_freshness(fp, old, Some("boom"), Some(failed_long), fp, now),
2677            LoaderFreshness::AgedOut
2678        );
2679    }
2680