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>(user_key: &str, fut: F) -> F::Output {
50    USER_KEY_OVERRIDE.scope(user_key.to_string(), fut).await
51}
52
53/// Deterministic re-link of pre-rotation homes (nghr ec3e0622 acceptance 3).
54///
55/// `users/home_links.json`: `{ "links": [ { "from_hash": "<data dir>",
56/// "to_hash": "<current claims-derived dir>" } ] }` — the data lives under
57/// `from_hash`; the claims-derived `to_hash` home is the (empty) one the
58/// rotation created. Applied at startup, idempotent:
59/// - `to` missing → rename `from` → `to` (config + sessions restored);
60/// - `to` exists but has ZERO entries → remove it, then rename;
61/// - `to` exists with content → LOUD skip (manual reconciliation required).
62pub fn apply_home_links() -> Vec<String> {
63    let Some(home) = dirs::home_dir() else {
64        return Vec::new();
65    };
66    let users = home.join(".trustee").join("users");
67    let map_path = users.join("home_links.json");
68    let Ok(text) = std::fs::read_to_string(&map_path) else {
69        return Vec::new();
70    };
71    let Ok(doc) = serde_json::from_str::<serde_json::Value>(&text) else {
72        tracing::warn!(
73            "home_links.json is not valid JSON — ignored: {}",
74            map_path.display()
75        );
76        return Vec::new();
77    };
78    let mut report = Vec::new();
79    if let Some(links) = doc.get("links").and_then(|v| v.as_array()) {
80        for l in links {
81            let (Some(from), Some(to)) = (
82                l.get("from_hash").and_then(|v| v.as_str()),
83                l.get("to_hash").and_then(|v| v.as_str()),
84            ) else {
85                continue;
86            };
87            let from_dir = users.join(from);
88            let to_dir = users.join(to);
89            if !from_dir.exists() {
90                continue; // already applied (or never existed)
91            }
92            if to_dir.exists() {
93                let empty = std::fs::read_dir(&to_dir)
94                    .map(|it| it.count() == 0)
95                    .unwrap_or(false);
96                if !empty {
97                    let msg = format!(
98                        "home link {from} → {to}: target exists with content — NOT touched, reconcile manually"
99                    );
100                    tracing::warn!("home_links: {msg}");
101                    report.push(msg);
102                    continue;
103                }
104                let _ = std::fs::remove_dir(&to_dir);
105            }
106            match std::fs::rename(&from_dir, &to_dir) {
107                Ok(()) => {
108                    let msg =
109                        format!("home link applied: {from} → {to} (config/sessions restored)");
110                    tracing::info!("home_links: {msg}");
111                    report.push(msg);
112                }
113                Err(e) => {
114                    let msg = format!("home link {from} → {to} FAILED: {e}");
115                    tracing::warn!("home_links: {msg}");
116                    report.push(msg);
117                }
118            }
119        }
120    }
121    report
122}
123
124// ---------------------------------------------------------------------------
125// Multi-session types
126// ---------------------------------------------------------------------------
127
128/// A single session with its own broadcast channel.
129pub struct UserSessionEntry {
130    /// The agent session, protected by a mutex.
131    pub session: Arc<Mutex<Session>>,
132    /// Broadcast sender for this session's WebSocket fan-out.
133    pub ws_tx: broadcast::Sender<String>,
134    /// When this session was created.
135    pub created_at: chrono::DateTime<chrono::Utc>,
136    /// Last time a command was submitted or state changed.
137    /// Updated on every /sessions/{id}/command and /sessions/{id}/cancel call.
138    pub last_active: Arc<Mutex<chrono::DateTime<chrono::Utc>>>,
139}
140
141/// All sessions belonging to one authenticated user.
142pub struct UserSessions {
143    /// session_id → session entry
144    pub sessions: DashMap<String, UserSessionEntry>,
145    /// Shared token store for all this user's sessions (MCP credential isolation).
146    pub token_store: Arc<pep::MemoryTokenStore>,
147}
148
149/// Summary of an active session for listing (serializable for API responses).
150#[derive(Debug, serde::Serialize)]
151pub struct SessionListItem {
152    pub session_id: String,
153    pub session_name: Option<String>,
154    pub workflow_state: String,
155    pub created_at: String,
156    pub last_active: String,
157    /// Number of handoff rotations this session has performed (Bug 5). The
158    /// in-memory registry key never changes across a rotation — only the
159    /// checkpoint-chain name does — so a card whose name changed without this
160    /// hint looks like it silently renamed. 0 = never rotated.
161    pub handoff_count: u32,
162}
163
164/// Errors from multi-session operations.
165#[derive(Debug)]
166pub enum SessionError {
167    /// User has reached max_sessions_per_user limit.
168    MaxSessionsReached(usize),
169    /// Session ID not found for this user.
170    NotFound(String),
171    /// Session is not Idle (cannot destroy/overwrite a running session).
172    NotIdle(String),
173}
174
175impl std::fmt::Display for SessionError {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self {
178            SessionError::MaxSessionsReached(n) => {
179                write!(f, "Maximum {} sessions per user reached", n)
180            }
181            SessionError::NotFound(id) => write!(f, "Session {} not found", id),
182            SessionError::NotIdle(state) => write!(f, "Session is not idle (state: {})", state),
183        }
184    }
185}
186
187impl std::error::Error for SessionError {}
188
189/// Top-level registry: user_key → user's session collection.
190pub type SessionRegistry = Arc<DashMap<String, UserSessions>>;
191
192// ---------------------------------------------------------------------------
193// ServerState
194// ---------------------------------------------------------------------------
195
196/// Cached per-user MCP tool loader (16C).
197///
198/// One entry per user hash. `loader: None` means the user's effective
199/// config has MCP disabled — agents run MCP-less via abk's
200/// `McpSource::Prebuilt(None)` (semantically identical to
201/// `[mcp] enabled = false` today, but with zero config re-parsing).
202pub struct McpLoaderEntry {
203    /// Built loader; `None` = MCP disabled for this user.
204    pub loader: Option<std::sync::Arc<abk::agent::McpToolLoader>>,
205    /// Content fingerprint of the effective `[mcp]` config (SHA-256, first
206    /// 8 bytes as u64). Content, never mtime — overlays are rewritten in place.
207    pub fingerprint: u64,
208    pub built_at: chrono::DateTime<chrono::Utc>,
209    /// Set when the last build failed; the message surfaces to the user's
210    /// next dispatch (fail loud). Other users are never affected.
211    pub degraded: Option<String>,
212    /// When `degraded` was set. Rebuilds are held back for
213    /// [`MCP_BUILD_RETRY_BACKOFF`] — a poison entry never sticks forever.
214    pub failed_at: Option<chrono::DateTime<chrono::Utc>>,
215}
216
217/// Minimum delay before retrying a failed MCP loader build (16C).
218pub const MCP_BUILD_RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
219
220/// Maximum age of a cached MCP toolset manifest (42131deb).
221///
222/// The 16C fingerprint covers the LOCAL config only — a served-surface
223/// change behind a byte-identical config (bridge/spec deploy) leaves the
224/// fingerprint matching while the cached tool list goes stale: phantom
225/// tools for every fresh session until a restart. Past this age the next
226/// fresh-session dispatch REBUILDS the loader (fresh connect re-lists the
227/// server's tools), bounding the phantom window to ≤ 10 minutes with no
228/// operator action.
229pub const MCP_TOOLSET_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(600);
230
231/// Why a cached MCP loader entry was (or wasn't) reused (42131deb).
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub(crate) enum LoaderFreshness {
234    /// Fingerprint matches and the entry is young — reuse.
235    Fresh,
236    /// Effective config content changed — rebuild (existing 16C behavior).
237    ConfigChanged,
238    /// Entry older than [`MCP_TOOLSET_MAX_AGE`] — the SERVED surface may
239    /// have changed under an unchanged local config; rebuild so the next
240    /// fresh session re-lists the server's tools.
241    AgedOut,
242    /// Degraded entry still inside the retry backoff — fail loud.
243    Backoff(std::time::Duration),
244}
245
246/// Pure staleness decision for a cache entry — unit-testable; the async
247/// cache path consumes it verbatim (b597c9e9 decide-fn pattern).
248pub(crate) fn loader_freshness(
249    entry_fingerprint: u64,
250    entry_built_at: chrono::DateTime<chrono::Utc>,
251    entry_degraded: Option<&str>,
252    entry_failed_at: Option<chrono::DateTime<chrono::Utc>>,
253    wanted_fingerprint: u64,
254    now: chrono::DateTime<chrono::Utc>,
255) -> LoaderFreshness {
256    // Degraded entries first: a poison entry never sticks forever, but it
257    // is held back for the retry window regardless of age/changes.
258    if entry_degraded.is_some() {
259        if let Some(failed_at) = entry_failed_at {
260            let backoff = chrono::Duration::from_std(MCP_BUILD_RETRY_BACKOFF)
261                .unwrap_or_else(|_| chrono::Duration::seconds(30));
262            if now < failed_at + backoff {
263                return LoaderFreshness::Backoff(
264                    (failed_at + backoff - now).to_std().unwrap_or_default(),
265                );
266            }
267        }
268    }
269    if wanted_fingerprint != entry_fingerprint {
270        return LoaderFreshness::ConfigChanged;
271    }
272    let max_age = chrono::Duration::from_std(MCP_TOOLSET_MAX_AGE)
273        .unwrap_or_else(|_| chrono::Duration::seconds(600));
274    if now > entry_built_at + max_age {
275        LoaderFreshness::AgedOut
276    } else {
277        LoaderFreshness::Fresh
278    }
279}
280
281/// 16F: per-agent dispatch target — THQ agent_name → the agent-user that
282/// THQ-dispatched sessions must run AS.
283///
284/// Populated once at boot by the 16E discovery scan
285/// (`thq_dispatch::populate`); restart-only lifecycle, exactly like the
286/// enrollment lane itself.
287#[derive(Debug, Clone)]
288pub struct ThqDispatchEntry {
289    /// The agent-user's stable key = its Kanidm `sub` (16E sub-pin). This is
290    /// the identity impersonated on the inner dispatch: session bucket,
291    /// per-user home, MCP loader cache, and Cedar principal all resolve
292    /// through it.
293    pub user_key: String,
294    /// The agent's Kanidm service token from its per-user `.env` — exchanged
295    /// for a short-lived `role=agent` Bearer at dispatch time. `None` = the
296    /// agent has no provisioned credential (dispatch fails loud, 502).
297    pub service_token: Option<String>,
298    /// The issuer the token's own credential declares (`[mcp.credentials.*]
299    /// .issuer_url` from the agent's overlay). Kanidm accepts a token
300    /// exchange only on the origin the token was minted for — the issuer
301    /// MUST travel with the credential that owns the token (verified live
302    /// 2026-08-30: same token, 200 on its own vhost, 400 elsewhere).
303    pub issuer_url: Option<String>,
304}
305
306/// 16F: the service-account issuer declared by the shared config's first
307/// `[mcp.credentials.*]` entry with `type = "service-account"` (see
308/// `ServerState::service_issuer` for why this exists).
309pub fn service_issuers_from_config(config_toml: &str) -> Vec<String> {
310    let Ok(v) = config_toml.parse::<toml::Value>() else {
311        return Vec::new();
312    };
313    let Some(creds) = v
314        .get("mcp")
315        .and_then(|m| m.get("credentials"))
316        .and_then(|c| c.as_table())
317    else {
318        return Vec::new();
319    };
320    let mut out = Vec::new();
321    for (_name, cred) in creds {
322        if cred.get("type").and_then(|t| t.as_str()) == Some("service-account") {
323            if let Some(issuer) = cred.get("issuer_url").and_then(|i| i.as_str()) {
324                if !out.iter().any(|e: &String| e == issuer) {
325                    out.push(issuer.to_string());
326                }
327            }
328        }
329    }
330    out
331}
332
333/// Shared state accessible by all axum handlers.
334#[derive(Clone)]
335pub struct ServerState {
336    /// Per-user multi-session registry (MSU).
337    pub sessions: SessionRegistry,
338    /// Broadcast sender for backward compat — delegates to the default user's channel.
339    pub ws_tx: broadcast::Sender<String>,
340    /// Auth state (None = auth disabled, all endpoints open).
341    pub auth: Option<Arc<AuthState>>,
342    /// Shared config TOML (all users share the same agent config).
343    pub config_toml: Option<String>,
344    /// Shared secrets (injected into every per-user session).
345    pub secrets: Option<std::collections::HashMap<String, String>>,
346    /// Shared build info (injected into every per-user session).
347    pub build_info: Option<trustee_core::types::BuildInfo>,
348    /// Global concurrency limiter — limits the number of simultaneous workflows
349    /// across all users. Default: 8 concurrent workflows.
350    pub workflow_semaphore: Arc<tokio::sync::Semaphore>,
351    /// Maximum number of concurrent sessions per user. Default: 4.
352    pub max_sessions_per_user: usize,
353    /// Whether per-user config overlays may override the `[llm]` section.
354    /// Default: `false` — overlays are limited to `[mcp]`.
355    pub allow_llm_overlay: bool,
356    /// Per-user McpToolLoader cache (16C), keyed by user hash (never the raw key).
357    pub mcp_loaders: Arc<DashMap<String, McpLoaderEntry>>,
358    /// Single-flight build locks per user hash (16C).
359    mcp_build_locks: Arc<DashMap<String, Arc<tokio::sync::Mutex<()>>>>,
360    /// 16F: THQ per-agent dispatch table (agent_name → target), boot-populated.
361    pub thq_dispatch: Arc<DashMap<String, ThqDispatchEntry>>,
362    /// 16F: cached impersonation Bearers per agent user_key
363    /// `(token, expires_at)` — keyed on the exchanged JWT's REAL `exp` claim
364    /// (payload-parsed, never the `expires_in` estimate), proactively
365    /// refreshed at T-120s before expiry (±15s per-process jitter), and
366    /// invalidated + re-minted by the budget-of-one 401 retry in the
367    /// xagent dispatch wrappers (199c4801/9618b41c; elimination spec
368    /// fb82b866 response c35042bb leg L2).
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        };
401        user_sessions
402            .sessions
403            .insert("default".to_string(), initial_entry);
404
405        sessions.insert("default".to_string(), user_sessions);
406
407        Self {
408            sessions,
409            ws_tx,
410            auth,
411            config_toml: None,
412            secrets: None,
413            build_info: None,
414            workflow_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
415            max_sessions_per_user: 4,
416            allow_llm_overlay: false,
417            mcp_loaders: Arc::new(DashMap::new()),
418            mcp_build_locks: Arc::new(DashMap::new()),
419            thq_dispatch: Arc::new(DashMap::new()),
420            agent_dispatch_tokens: Arc::new(DashMap::new()),
421        }
422    }
423
424    pub fn with_config_toml(mut self, config_toml: String) -> Self {
425        // fffe9141 F3: loud boot WARN when [llm.provider].model is
426        // missing/empty — the silent fictional fallback is gone (abk 0.22.8
427        // F5), so a missing model explodes at the first LLM call with the
428        // honest 'no model configured' error. THQ-runtime instances
429        // legitimately boot without serving LLM sessions — warn, never
430        // refuse.
431        let model = config_toml
432            .parse::<toml::Value>()
433            .ok()
434            .and_then(|v| {
435                v.get("llm")
436                    .and_then(|l| l.get("provider"))
437                    .and_then(|p| p.get("model"))
438                    .and_then(|m| m.as_str())
439                    .map(|s| s.trim().to_string())
440            })
441            .unwrap_or_default();
442        if model.is_empty() {
443            tracing::warn!(
444                "[llm.provider].model is missing or empty — LLM calls will fail with 'no model configured: set [llm.provider].model or OPENAI_DEFAULT_MODEL' until set (THQ-runtime instances that never serve LLM sessions may ignore this)"
445            );
446        }
447        self.config_toml = Some(config_toml);
448        self
449    }
450
451    /// 16F: the issuer URL Kanidm accepts SERVICE-ACCOUNT token exchange on.
452    ///
453    /// Kanidm binds exchange to the OAuth2 client's configured origin: the
454    /// `[oidc]` issuer host serves logins/validation fine but REJECTS token
455    /// exchange with `invalid_request`, while the `idp` vhost — the issuer
456    /// every working `[mcp.credentials.*]` service-account entry already
457    /// uses — accepts it (verified live 2026-08-30: same token, 200 vs 400).
458    /// The exchange therefore takes its issuer from the shared config's
459    /// first `service-account` credential; callers fall back to the auth
460    /// issuer when absent.
461    pub fn service_issuers(&self) -> Vec<String> {
462        service_issuers_from_config(self.config_toml.as_deref().unwrap_or(""))
463    }
464
465    pub fn with_secrets(mut self, secrets: std::collections::HashMap<String, String>) -> Self {
466        self.secrets = Some(secrets);
467        self
468    }
469
470    pub fn with_build_info(mut self, build_info: trustee_core::types::BuildInfo) -> Self {
471        self.build_info = Some(build_info);
472        self
473    }
474
475    pub fn with_max_concurrent_workflows(mut self, max: usize) -> Self {
476        self.workflow_semaphore = Arc::new(tokio::sync::Semaphore::new(max));
477        self
478    }
479
480    /// Set the max sessions per user.
481    pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
482        self.max_sessions_per_user = max;
483        self
484    }
485
486    /// Allow per-user config overlays to override the `[llm]` section.
487    /// Default: `false` (overlays limited to `[mcp]`).
488    pub fn with_allow_llm_overlay(mut self, allow: bool) -> Self {
489        self.allow_llm_overlay = allow;
490        self
491    }
492
493    // -----------------------------------------------------------------------
494    // MSU: Multi-session methods
495    // -----------------------------------------------------------------------
496
497    /// Create a new session for a user. Returns the session_id.
498    ///
499    /// Creates a fresh `Session::new()`, copies shared config, sets per-user
500    /// isolation, creates a broadcast channel, spawns a drain task, and inserts
501    /// into the user's session DashMap.
502    ///
503    /// The session identity is PINNED here (c2f9508, restore-reattach fix):
504    /// `session.session_id` = the registry key, so the disk checkpoint chain id
505    /// and the live session id coincide and `get_or_restore_session_by_any_id`
506    /// can re-attach a client by the id it already holds after an engine
507    /// restart. The abk first-command auto-derive still fires for TUI/desktop
508    /// sessions (no registry key there). Resumed sessions overwrite the pin
509    /// with the resumed chain id (routes::resume_session) — chain semantics
510    /// preserved.
511    ///
512    /// There is NO "active session" concept anymore — the legacy
513    /// active-pointer surface was retired (2026-10-27, owner order); every
514    /// consumer targets a session id via the session-scoped routes.
515    pub async fn create_session(
516        &self,
517        user_key: &str,
518        session_name: Option<String>,
519        identity: Option<String>,
520    ) -> Result<String, SessionError> {
521        // Get or create the user's UserSessions entry
522        let user_sessions = self
523            .sessions
524            .entry(user_key.to_string())
525            .or_insert_with(|| UserSessions {
526                sessions: DashMap::new(),
527                token_store: Arc::new(pep::MemoryTokenStore::new()),
528            });
529
530        // Check session limit
531        if user_sessions.sessions.len() >= self.max_sessions_per_user {
532            return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
533        }
534
535        // Create new Session
536        let (mut session, workflow_rx) = Session::new();
537
538        // Copy shared config
539        if let Some(ref config_toml) = self.config_toml {
540            session.config_toml = Some(config_toml.clone());
541            session.parse_auto_handoff_config();
542            if let Ok(table) = config_toml.parse::<toml::Value>() {
543                if let Some(name) = table
544                    .get("agent")
545                    .and_then(|a| a.get("name"))
546                    .and_then(|n| n.as_str())
547                {
548                    session.agent_name = name.to_string();
549                }
550            }
551        }
552
553        session.secrets = self.secrets.clone();
554        session.build_info = self.build_info.clone();
555
556        // Per-user isolation
557        self.apply_user_isolation(&mut session, user_key);
558
559        // Apply session_name if provided
560        session.session_name = session_name;
561
562        // Apply agent identity if provided
563        session.identity = identity;
564
565        // Create broadcast channel
566        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
567
568        // Generate session_id
569        let session_id = format!(
570            "session_{}_{}",
571            chrono::Utc::now().format("%Y_%m_%d_%H_%M"),
572            &uuid::Uuid::new_v4().to_string()[..8]
573        );
574
575        // PIN the session identity (Hamrah 2026-10-26, restore-reattach fix):
576        // the registry key IS the disk chain id. Without this, the first
577        // command's abk run auto-generates a DIFFERENT session_id for the
578        // on-disk checkpoint chain, and get_or_restore_session_by_any_id can
579        // never find the chain by the live id after an engine restart.
580        session.session_id = Some(session_id.clone());
581
582        let now = chrono::Utc::now();
583
584        // Insert into user's sessions DashMap
585        user_sessions.sessions.insert(
586            session_id.clone(),
587            UserSessionEntry {
588                session: Arc::new(Mutex::new(session)),
589                ws_tx: ws_tx_entry.clone(),
590                created_at: now,
591                last_active: Arc::new(Mutex::new(now)),
592            },
593        );
594
595        // Spawn drain task
596        let session_arc = user_sessions
597            .sessions
598            .get(&session_id)
599            .map(|e| e.session.clone());
600        if let Some(session_arc) = session_arc {
601            self.spawn_user_drain_task(session_id.clone(), session_arc, ws_tx_entry, workflow_rx);
602        }
603
604        Ok(session_id)
605    }
606
607    /// Get a specific session by user_key + session_id.
608    /// Updates last_active on the session entry.
609    pub async fn get_session(
610        &self,
611        user_key: &str,
612        session_id: &str,
613    ) -> Option<(Arc<Mutex<Session>>, broadcast::Sender<String>)> {
614        let user_sessions = self.sessions.get(user_key)?;
615        let entry = user_sessions.sessions.get(session_id)?;
616
617        // Update last_active
618        let now = chrono::Utc::now();
619        *entry.last_active.lock().await = now;
620
621        Some((entry.session.clone(), entry.ws_tx.clone()))
622    }
623
624    /// Get a session by EITHER its live MSU registry key OR its
625    /// checkpoint/session identity (`session.session_id`).
626    ///
627    /// The web frontend tracks `currentSessionId` from the `ResumeInfo` WS
628    /// message, which carries the auto-derived checkpoint id
629    /// (`session_YYYY_MM_DD_HH_MM_uuid8`) — NOT the live MSU registry key
630    /// (`"default"` or the key from `create_session()`). External clients
631    /// like Torpi/THQ pass the live registry key. This resolver accepts both:
632    ///
633    /// 1. Try registry-key lookup first (precise, used by Torpi/THQ).
634    /// 2. Fall back to scanning the user's live sessions for one whose
635    ///    `session.session_id` matches the requested id (used by the
636    ///    embedded web UI after a command or resume).
637    ///
638    /// Returns `(live_registry_key, session_arc, ws_tx)`, or `None` if not
639    /// found. The live key is returned so callers that need to set it as
640    /// active (or otherwise reference the registry) use the real key.
641    pub async fn get_session_by_any_id(
642        &self,
643        user_key: &str,
644        id: &str,
645    ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
646        // Fast path: registry key match.
647        let user_sessions = self.sessions.get(user_key)?;
648        if let Some(entry) = user_sessions.sessions.get(id) {
649            // Update last_active
650            let now = chrono::Utc::now();
651            *entry.last_active.lock().await = now;
652            return Some((id.to_string(), entry.session.clone(), entry.ws_tx.clone()));
653        }
654
655        // Slow path: scan live sessions for a matching session.session_id.
656        for entry in user_sessions.sessions.iter() {
657            let session = entry.session.lock().await;
658            if session.session_id.as_deref() == Some(id) {
659                let key = entry.key().clone();
660                let ws_tx = entry.ws_tx.clone();
661                drop(session);
662                // Update last_active
663                let now = chrono::Utc::now();
664                *entry.last_active.lock().await = now;
665                return Some((key, entry.session.clone(), ws_tx));
666            }
667        }
668
669        None
670    }
671
672    /// Tombstone directory for destroyed-but-on-disk session chains.
673    ///
674    /// Destroy removes the live session but deliberately keeps checkpoints
675    /// ("checkpoint data is preserved"). Restoring must NOT resurrect a
676    /// chain the user explicitly terminated, so destroy writes a marker
677    /// here and [`Self::restore_session_from_checkpoint`] refuses to
678    /// hydrate tombstoned chains. Markers are tiny and intentionally
679    /// permanent.
680    fn tombstone_path(&self, user_key: &str, chain_id: &str) -> Option<std::path::PathBuf> {
681        if chain_id.is_empty() || !chain_id.starts_with("session_") {
682            return None;
683        }
684        let user_key = effective_user_key(user_key);
685        let hash = trustee_core::user_hash(&user_key);
686        dirs::home_dir().map(|home| {
687            home.join(".trustee")
688                .join("users")
689                .join(&hash)
690                .join("session_tombstones")
691                .join(chain_id)
692        })
693    }
694
695    /// Write a destroy tombstone for a checkpoint chain (best effort).
696    ///
697    /// Called from [`Self::destroy_session`] when the destroyed live
698    /// session was attached to an on-disk chain.
699    fn write_tombstone(&self, user_key: &str, chain_id: &str) {
700        if let Some(path) = self.tombstone_path(user_key, chain_id) {
701            if let Some(parent) = path.parent() {
702                let _ = std::fs::create_dir_all(parent);
703            }
704            let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
705        }
706    }
707
708    /// Read the destroy-tombstone stamp for a chain, if one exists.
709    ///
710    /// 7df3cae2 (tombstone-shadow class): a destroyed chain whose
711    /// checkpoints sit intact on disk must be DISCLOSED, never shadowed by
712    /// a lying generic absence — readers use this stamp to answer
713    /// tombstoned-but-non-empty loudly. `None` = no marker (chain either
714    /// alive/resumable or genuinely absent).
715    pub fn chain_tombstone_stamp(&self, user_key: &str, chain_id: &str) -> Option<String> {
716        let path = self.tombstone_path(user_key, chain_id)?;
717        let text = std::fs::read_to_string(&path).ok()?;
718        let trimmed = text.trim();
719        if trimmed.is_empty() {
720            None
721        } else {
722            Some(trimmed.to_string())
723        }
724    }
725
726    /// Look up a live session by any id; on a miss, transparently restore
727    /// the checkpoint chain from disk.
728    ///
729    /// Restart resilience: trustee-api holds live sessions in memory only,
730    /// so a process restart orphans every open console. When a client asks
731    /// for a chain id that is not in memory but EXISTS on disk
732    /// (checkpoints present, not tombstoned by destroy), a live session is
733    /// rebuilt with `resume_info` pointing at the latest checkpoint and
734    /// registered under the chain id. The next command continues the
735    /// conversation; the console's alive-check and WS reconnect succeed.
736    ///
737    /// Returns `(live_registry_key, session_arc, ws_tx)` — the key is the
738    /// chain id for restored sessions.
739    pub async fn get_or_restore_session_by_any_id(
740        &self,
741        user_key: &str,
742        id: &str,
743    ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
744        if let Some(found) = self.get_session_by_any_id(user_key, id).await {
745            return Some(found);
746        }
747        self.restore_session_from_checkpoint(user_key, id).await
748    }
749
750    /// Rebuild a live session from an on-disk checkpoint chain.
751    ///
752    /// Persona note: dispatched (THQ/xagent) sessions carry `identity:
753    /// None` by design — their persona is the per-user overlay config
754    /// ([lifecycle].system_template), which `apply_user_isolation` +
755    /// `get_user_config_and_home` re-apply here exactly as at creation.
756    /// Explicit per-request identity overrides (rare) are not persisted
757    /// and therefore not restored.
758    async fn restore_session_from_checkpoint(
759        &self,
760        user_key: &str,
761        chain_id: &str,
762    ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
763        // Never resurrect an explicitly destroyed chain.
764        if let Some(path) = self.tombstone_path(user_key, chain_id) {
765            if path.exists() {
766                return None;
767            }
768        }
769
770        // Restore needs the caller's resolved config (persona) and home.
771        let (config_toml, home_dir) = self.get_user_config_and_home(user_key);
772        let config_toml = config_toml?;
773
774        // The chain must exist on disk with at least one checkpoint;
775        // otherwise this id is genuinely unknown → 404 semantics.
776        //
777        // Resume-handle resolution (Ravand 2026-10-27): a resumed session's
778        // live id is a fresh registry key — its disk dir carries a
779        // `resumed_from` constant naming the SOURCE chain (recorded at
780        // resume time by write_resume_pointer). Follow it so
781        // restore-by-held-id works after an engine restart: the SOURCE
782        // chain is restored under the HELD id, and the next command
783        // continues the source conversation.
784        let info = match trustee_core::sessions::create_resume_info(
785            &config_toml,
786            chain_id,
787            home_dir.as_deref(),
788        )
789        .await
790        .ok()
791        .flatten()
792        {
793            Some(info) => info,
794            None => {
795                let source = trustee_core::sessions::resolve_resume_handle(
796                    &config_toml,
797                    chain_id,
798                    home_dir.as_deref(),
799                )
800                .await
801                .ok()
802                .flatten()?;
803                trustee_core::sessions::create_resume_info(
804                    &config_toml,
805                    &source,
806                    home_dir.as_deref(),
807                )
808                .await
809                .ok()
810                .flatten()?
811            }
812        };
813
814        // Single-flight per chain id: racing lookups dedupe on the DashMap
815        // entry — the first insert wins, the loser adopts the winner.
816        let user_sessions = self
817            .sessions
818            .entry(user_key.to_string())
819            .or_insert_with(|| UserSessions {
820                sessions: DashMap::new(),
821                token_store: Arc::new(pep::MemoryTokenStore::new()),
822            });
823
824        if let Some(existing) = user_sessions.sessions.get(chain_id) {
825            let now = chrono::Utc::now();
826            *existing.last_active.lock().await = now;
827            return Some((
828                chain_id.to_string(),
829                existing.session.clone(),
830                existing.ws_tx.clone(),
831            ));
832        }
833
834        // Respect the per-user cap: evict the least-recently-active Idle
835        // live session to make room (restores are user-initiated
836        // reattaches, so refusing them would recreate the lost-session
837        // bug for heavy users). If nothing is evictable, refuse.
838        if user_sessions.sessions.len() >= self.max_sessions_per_user {
839            let mut evict: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
840            for entry in user_sessions.sessions.iter() {
841                let is_idle = entry.session.lock().await.workflow_state
842                    == trustee_core::types::WorkflowState::Idle;
843                if !is_idle {
844                    continue;
845                }
846                let la = entry.last_active.lock().await;
847                if evict.as_ref().is_none_or(|(_, t)| *la < *t) {
848                    evict = Some((entry.key().clone(), *la));
849                }
850            }
851            if let Some((victim, _)) = evict {
852                user_sessions.sessions.remove(&victim);
853            } else {
854                return None;
855            }
856        }
857
858        // Build the session exactly like create_session, then attach the
859        // on-disk chain identity and resume pointer.
860        let (mut session, workflow_rx) = Session::new();
861        session.config_toml = Some(config_toml.clone());
862        session.parse_auto_handoff_config();
863        if let Ok(table) = config_toml.parse::<toml::Value>() {
864            if let Some(name) = table
865                .get("agent")
866                .and_then(|a| a.get("name"))
867                .and_then(|n| n.as_str())
868            {
869                session.agent_name = name.to_string();
870            }
871        }
872        session.secrets = self.secrets.clone();
873        session.build_info = self.build_info.clone();
874        self.apply_user_isolation(&mut session, user_key);
875        session.session_id = Some(chain_id.to_string());
876        session.resume_info = Some(info);
877        session.session_name = None; // display name re-derives from metadata
878
879        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
880        let now = chrono::Utc::now();
881
882        user_sessions.sessions.insert(
883            chain_id.to_string(),
884            UserSessionEntry {
885                session: Arc::new(Mutex::new(session)),
886                ws_tx: ws_tx_entry.clone(),
887                created_at: now,
888                last_active: Arc::new(Mutex::new(now)),
889            },
890        );
891
892        // Drain task: forward workflow output into the WS broadcast channel
893        // (same contract as create_session — without this the restored
894        // session would run silently).
895        let session_arc = user_sessions
896            .sessions
897            .get(chain_id)
898            .map(|e| e.session.clone())
899            .ok_or_else(|| SessionError::NotFound(chain_id.to_string()))
900            .ok()?;
901        self.spawn_user_drain_task(
902            chain_id.to_string(),
903            session_arc.clone(),
904            ws_tx_entry.clone(),
905            workflow_rx,
906        );
907
908        Some((chain_id.to_string(), session_arc, ws_tx_entry))
909    }
910
911    /// List all active sessions for a user, sorted by last_active desc.
912    pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> {
913        let Some(user_sessions) = self.sessions.get(user_key) else {
914            return Vec::new();
915        };
916
917        let mut items = Vec::new();
918        for entry in user_sessions.sessions.iter() {
919            let session = entry.session.lock().await;
920            let workflow_state = match session.workflow_state {
921                trustee_core::types::WorkflowState::Idle => "Idle",
922                trustee_core::types::WorkflowState::Running => "Running",
923                trustee_core::types::WorkflowState::Cancelling => "Cancelling",
924            };
925            let last_active = entry.last_active.lock().await;
926            items.push(SessionListItem {
927                session_id: entry.key().clone(),
928                session_name: session.session_name.clone(),
929                workflow_state: workflow_state.to_string(),
930                created_at: entry.created_at.to_rfc3339(),
931                last_active: last_active.to_rfc3339(),
932                handoff_count: session.handoff_count,
933            });
934        }
935        drop(user_sessions);
936
937        // Sort by last_active descending
938        items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
939        items
940    }
941
942    /// Destroy a session. The session must be Idle.
943    pub async fn destroy_session(
944        &self,
945        user_key: &str,
946        session_id: &str,
947    ) -> Result<(), SessionError> {
948        let user_sessions = self
949            .sessions
950            .get(user_key)
951            .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
952
953        // Check workflow state before removing; capture the on-disk chain
954        // id (if any) so destroy can tombstone it against later restores.
955        let chain_id_for_tombstone = {
956            let entry = user_sessions
957                .sessions
958                .get(session_id)
959                .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
960            let session = entry.session.lock().await;
961            if session.workflow_state != trustee_core::types::WorkflowState::Idle {
962                let state_str = match session.workflow_state {
963                    trustee_core::types::WorkflowState::Running => "Running",
964                    trustee_core::types::WorkflowState::Cancelling => "Cancelling",
965                    _ => "Unknown",
966                };
967                return Err(SessionError::NotIdle(state_str.to_string()));
968            }
969            session.session_id.clone()
970        };
971
972        // Remove from DashMap
973        user_sessions.sessions.remove(session_id);
974
975        // If the destroyed live session was attached to an on-disk chain,
976        // tombstone it so a later get_or_restore lookup does not resurrect
977        // a session the user explicitly terminated.
978        if let Some(chain_id) = chain_id_for_tombstone {
979            self.write_tombstone(user_key, &chain_id);
980        }
981
982        Ok(())
983    }
984
985    // -----------------------------------------------------------------------
986    // Read-only helpers (no session creation side effects)
987    // -----------------------------------------------------------------------
988
989    /// Resolve a user's home_dir without creating an in-memory session.
990    ///
991    /// This is the read-only equivalent of the isolation logic in
992    /// `apply_user_isolation`. Used by endpoints that only need to read
993    /// checkpoint data from disk (history, session list, session detail)
994    /// and must NOT create ghost sessions as a side effect.
995    pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
996        // TRUSTEE_CLASSIC_HOME=1 (Android single-user loopback, Hamrah
997        // 2026-10-26): use the CLASSIC home — the same one the command path
998        // and the CLI write to — so the session list/resume/history see the
999        // real sessions instead of an empty users/{hash}/ namespace.
1000        //
1001        // LEAK GUARD (Ravand 2026-10-27 review): under this gate EVERY user
1002        // shares the classic home — per-user checkpoint isolation is OFF.
1003        // That is correct ONLY for the single-user loopback. If the env is
1004        // set on a multi-user server by accident, all users would see each
1005        // other's checkpoint chains — say so LOUD, once, at first use.
1006        if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
1007            static GATE_WARNED: std::sync::Once = std::sync::Once::new();
1008            GATE_WARNED.call_once(|| {
1009                tracing::warn!(
1010                    "TRUSTEE_CLASSIC_HOME=1 — ALL users share the classic home \
1011                     (~/.trustee); per-user checkpoint isolation is OFF. \
1012                     Single-user loopback ONLY — unset this env on multi-user servers."
1013                );
1014            });
1015            return dirs::home_dir().map(|home| home.join(".trustee"));
1016        }
1017        let user_key = effective_user_key(user_key);
1018        let hash = trustee_core::user_hash(&user_key);
1019        dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&hash))
1020    }
1021
1022    /// Resolve config_toml and home_dir without creating an in-memory session.
1023    ///
1024    /// Returns `(config_toml, home_dir)`. If config is not loaded,
1025    /// config_toml will be None.
1026    pub fn get_user_config_and_home(
1027        &self,
1028        user_key: &str,
1029    ) -> (Option<String>, Option<std::path::PathBuf>) {
1030        (self.config_toml.clone(), self.get_user_home_dir(user_key))
1031    }
1032
1033    // -----------------------------------------------------------------------
1034    // Private helpers
1035    // -----------------------------------------------------------------------
1036
1037    /// Apply per-user isolation: SHA-256 hash → home_dir + project_id.
1038    ///
1039    /// Hashing goes through the single consolidated [`trustee_core::user_hash`]
1040    /// so the web path and the CLI path can never drift apart.
1041    fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
1042        // TRUSTEE_CLASSIC_HOME=1 (Android single-user loopback): the classic
1043        // home — the per-user isolation only applies in multi-user web mode.
1044        if std::env::var("TRUSTEE_CLASSIC_HOME").as_deref() == Ok("1") {
1045            if let Some(home) = dirs::home_dir() {
1046                session.home_dir = Some(home.join(".trustee"));
1047            }
1048            return;
1049        }
1050        let user_key = effective_user_key(user_key);
1051        let user_hash = trustee_core::user_hash(&user_key);
1052
1053        // Set per-user home directory for checkpoint isolation
1054        let user_home = if let Some(home) = dirs::home_dir() {
1055            let user_home = home.join(".trustee").join("users").join(&user_hash);
1056            session.home_dir = Some(user_home.clone());
1057            Some(user_home)
1058        } else {
1059            None
1060        };
1061
1062        // 92a36073 boot-surface: a MATERIALIZED namespace without per-user
1063        // credentials is the shadowing scenario — say so NOW, not in
1064        // forensics after a global token answered for it.
1065        if let Some(ref user_home) = user_home {
1066            if materialized_user_missing_env(user_home) {
1067                tracing::warn!(
1068                    "user namespace {} is materialized but has NO per-user .env — \
1069                     ${{…}} references will fall through to process env \
1070                     (global ~/.trustee/.env) and execute under the GLOBAL principal",
1071                    &user_hash[..8.min(user_hash.len())]
1072                );
1073            }
1074        }
1075
1076        session.project_id = Some(format!("web{}", &user_hash[..16]));
1077
1078        // ── Per-user .env (Task 2) ──────────────────────────────────────
1079        //
1080        // Load per-user secrets from ~/.trustee/users/{hash}/.env
1081        // These are merged on top of shared secrets (per-user wins).
1082        // They are NEVER set as process env vars — used only for ${VAR}
1083        // substitution in the config TOML below.
1084        let shared_secrets = session.secrets.clone().unwrap_or_default();
1085        let mut merged_secrets = shared_secrets.clone();
1086
1087        if let Some(ref user_home) = user_home {
1088            if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
1089                merged_secrets = merged;
1090            }
1091        }
1092
1093        // ── Per-user config overlay (Task 3) ────────────────────────────
1094        if let Some(ref user_home) = user_home {
1095            if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref())
1096            {
1097                session.config_toml = Some(merged);
1098                tracing::debug!("Merged per-user config into session");
1099            }
1100        }
1101
1102        // ── ${VAR} substitution (Task 4) ────────────────────────────────
1103        if let Some(ref mut config_toml) = session.config_toml {
1104            substitute_env_vars(
1105                config_toml,
1106                &merged_secrets,
1107                &format!("user {}", &user_hash[..8.min(user_hash.len())]),
1108            );
1109        }
1110
1111        // ── Strip per-user secrets (Task 5) ────────────────────────────
1112        session.secrets = Some(shared_secrets);
1113    }
1114
1115    /// Load secrets from a user's ~/.trustee/users/{hash}/.env and merge
1116    /// on top of `base` (user wins). Returns the merged map.
1117    fn load_user_secrets(
1118        &self,
1119        user_home: &std::path::Path,
1120        base: &std::collections::HashMap<String, String>,
1121    ) -> std::io::Result<std::collections::HashMap<String, String>> {
1122        let user_env_path = user_home.join(".env");
1123        if !user_env_path.exists() {
1124            return Ok(base.clone());
1125        }
1126        let content = std::fs::read_to_string(&user_env_path)?;
1127        let mut merged = base.clone();
1128        for line in content.lines() {
1129            let line = line.trim();
1130            if line.is_empty() || line.starts_with('#') {
1131                continue;
1132            }
1133            if let Some((key, value)) = line.split_once('=') {
1134                let key = key.trim().to_string();
1135                let value = value
1136                    .trim()
1137                    .trim_matches('"')
1138                    .trim_matches('\'')
1139                    .to_string();
1140                merged.insert(key, value);
1141            }
1142        }
1143        tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
1144        Ok(merged)
1145    }
1146
1147    /// Deep-merge a user's per-user config overlay on top of the shared
1148    /// config. Returns the merged TOML string, or None if no overlay exists.
1149    ///
1150    /// Overlay allowlist (task 16B): only allowlisted top-level sections of
1151    /// the user overlay are merged; anything else is dropped loudly.
1152    /// - `[mcp]` is always allowed — per-user MCP tool sets are the point of
1153    ///   the overlay convention.
1154    /// - `[llm]` is allowed only when the instance opted in via the
1155    ///   `[users].allow_llm_overlay` knob (default **false**).
1156    ///
1157    /// Everything else (`[server]`, `[auth]`, `[storage]`, `[web]`, …) is
1158    /// boot-time, instance-level config and must not be rewritable through a
1159    /// per-user file. This is predictability hardening, not a security
1160    /// boundary: those sections were never re-read from session config.
1161    fn merge_user_config(
1162        &self,
1163        user_home: &std::path::Path,
1164        shared_config: Option<&str>,
1165    ) -> Option<String> {
1166        let user_config_path = user_home.join("config").join("trustee.toml");
1167        if !user_config_path.exists() {
1168            return None;
1169        }
1170        let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
1171        let shared = shared_config.unwrap_or("").parse::<toml::Value>().ok()?;
1172        let overlay = user_config_toml.parse::<toml::Value>().ok()?;
1173
1174        let allowed = |section: &str| {
1175            section == "mcp"
1176                // 16E: [thq] is per-user identity config (read from the overlay
1177                // file at boot); keeping it in the merged session config is
1178                // inert but avoids a false "dropping" warn on every dispatch.
1179                || section == "thq"
1180                // xagent personas: an agent's [lifecycle].system_template IS
1181                // her identity — user-owned by design. The WASM extension
1182                // switch ([lifecycle].enabled) is stripped below: persona is
1183                // user-owned, extension control is not.
1184                || section == "lifecycle"
1185                || (self.allow_llm_overlay && section == "llm")
1186        };
1187        // Mask the user in logs: user_home's dir name IS the hash — never
1188        // log the raw key (keys may be emails). Log a short hash prefix.
1189        let dir_name = user_home
1190            .file_name()
1191            .and_then(|n| n.to_str())
1192            .unwrap_or("<unknown>");
1193        let masked_user = dir_name.get(..8).unwrap_or(dir_name);
1194
1195        let mut filtered_overlay = toml::map::Map::new();
1196        if let Some(table) = overlay.as_table() {
1197            for (section, value) in table {
1198                if section == "lifecycle" {
1199                    let mut v = value.clone();
1200                    if let Some(t) = v.as_table_mut() {
1201                        if t.remove("enabled").is_some() {
1202                            tracing::info!(
1203                                "user config overlay: [lifecycle].enabled is not user-settable — stripped for user {}",
1204                                masked_user
1205                            );
1206                        }
1207                    }
1208                    filtered_overlay.insert(section.clone(), v);
1209                } else if allowed(section) {
1210                    filtered_overlay.insert(section.clone(), value.clone());
1211                } else {
1212                    tracing::warn!(
1213                        "user config overlay: dropping non-allowlisted section [{}] for user {}",
1214                        section,
1215                        masked_user
1216                    );
1217                }
1218            }
1219        }
1220
1221        if filtered_overlay.is_empty() {
1222            // Nothing survived the allowlist — no-op, keep shared config as-is.
1223            return None;
1224        }
1225        let overlay = toml::Value::Table(filtered_overlay);
1226
1227        let mut shared = shared;
1228        deep_merge_toml(&mut shared, &overlay);
1229        let merged = toml::to_string(&shared).ok()?;
1230        tracing::debug!("Merged per-user config from {}", user_config_path.display());
1231        Some(merged)
1232    }
1233
1234    /// Resolve the fully-merged config TOML for a user WITHOUT creating a session.
1235    ///
1236    /// This is the read-only equivalent of the config resolution in
1237    /// `apply_user_isolation`: shared config + per-user overlay + ${VAR}
1238    /// substitution. Used by endpoints that need to inspect config (e.g.
1239    /// listing available LLM models) without spawning a ghost session.
1240    ///
1241    /// Returns None if no shared config is loaded.
1242    pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
1243        let config_toml = self.config_toml.clone()?;
1244
1245        // Resolve user home dir (same hash scheme as apply_user_isolation)
1246        let user_home = self.get_user_home_dir(user_key)?;
1247
1248        // Start from shared secrets; merge per-user .env on top
1249        let mut merged_secrets = self.secrets.clone().unwrap_or_default();
1250        if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
1251            merged_secrets = merged;
1252        }
1253
1254        // Merge per-user config overlay
1255        let mut resolved = config_toml;
1256        if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
1257            resolved = merged;
1258        }
1259
1260        // Substitute ${VAR} from merged secrets
1261        substitute_env_vars(
1262            &mut resolved,
1263            &merged_secrets,
1264            &format!(
1265                "resolve_user_config user {}",
1266                &trustee_core::user_hash(user_key)[..8]
1267            ),
1268        );
1269
1270        Some(resolved)
1271    }
1272
1273    /// Get or build the per-user MCP tool loader (16C).
1274    ///
1275    /// Cache semantics:
1276    /// - fingerprint = content hash of the effective `[mcp]` section
1277    ///   (shared + allowlist-filtered overlay + ${VAR} substitution, i.e.
1278    ///   exactly what `resolve_user_config` produces) — stale on ANY change.
1279    /// - hit + match → `Arc` clone, zero network I/O.
1280    /// - miss/stale → single-flight build (one build per user at a time;
1281    ///   late arrivals re-check and reuse the winner's entry).
1282    /// - `Ok(None)` = MCP disabled for this user → agent runs MCP-less via
1283    ///   abk `McpSource::Prebuilt(None)` (same semantics as
1284    ///   `[mcp] enabled = false`, no per-task re-evaluation).
1285    /// - build failure → cached degraded entry with
1286    ///   [`MCP_BUILD_RETRY_BACKOFF`]; the error is returned so THIS user's
1287    ///   dispatch fails loud while other users are unaffected.
1288    pub async fn get_or_build_mcp_loader(
1289        &self,
1290        user_key: &str,
1291        token_store: &Arc<pep::MemoryTokenStore>,
1292    ) -> Result<Option<std::sync::Arc<abk::agent::McpToolLoader>>, String> {
1293        let user_key = effective_user_key(user_key);
1294        let user_hash = trustee_core::user_hash(&user_key);
1295
1296        // Effective per-user config — the fingerprint source of truth.
1297        let resolved = self.resolve_user_config(&user_key);
1298        let fingerprint = fingerprint_mcp_section(resolved.as_deref());
1299
1300        // Fast path: non-degraded entry that is fresh AND content-current.
1301        if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1302            match loader_freshness(
1303                entry.fingerprint,
1304                entry.built_at,
1305                entry.degraded.as_deref(),
1306                entry.failed_at,
1307                fingerprint,
1308                chrono::Utc::now(),
1309            ) {
1310                LoaderFreshness::Fresh => return Ok(entry.loader.clone()),
1311                LoaderFreshness::ConfigChanged => {
1312                    // fall through to the rebuild path below
1313                }
1314                LoaderFreshness::AgedOut => {
1315                    // 42131deb: the served surface may have changed behind
1316                    // a byte-identical config — rebuild (re-lists tools).
1317                    tracing::info!(
1318                        "MCP toolset for user {} is {}s old (max {}s) — rebuilding \
1319                         to re-list the served surface (phantom-tool guard)",
1320                        &user_hash[..8.min(user_hash.len())],
1321                        (chrono::Utc::now() - entry.built_at).num_seconds(),
1322                        MCP_TOOLSET_MAX_AGE.as_secs()
1323                    );
1324                }
1325                LoaderFreshness::Backoff(remaining) => {
1326                    let err = entry.degraded.clone().unwrap_or_else(|| {
1327                        "previous MCP loader build failed; retry after backoff".to_string()
1328                    });
1329                    let _ = remaining;
1330                    return Err(err);
1331                }
1332            }
1333        }
1334
1335        // Single-flight: one build per user at a time.
1336        let lock = self
1337            .mcp_build_locks
1338            .entry(user_hash.clone())
1339            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1340            .clone();
1341        let _guard = lock.lock().await;
1342
1343        // Double-check: another task may have built while we waited.
1344        if let Some(entry) = self.mcp_loaders.get(&user_hash) {
1345            if loader_freshness(
1346                entry.fingerprint,
1347                entry.built_at,
1348                entry.degraded.as_deref(),
1349                entry.failed_at,
1350                fingerprint,
1351                chrono::Utc::now(),
1352            ) == LoaderFreshness::Fresh
1353            {
1354                return Ok(entry.loader.clone());
1355            }
1356        }
1357
1358        match self
1359            .build_mcp_loader(&user_hash, resolved.as_deref(), fingerprint, token_store)
1360            .await
1361        {
1362            Ok(entry) => {
1363                self.mcp_loaders.insert(user_hash.clone(), entry);
1364                Ok(self.mcp_loaders.get(&user_hash).unwrap().loader.clone())
1365            }
1366            Err(err) => {
1367                tracing::warn!(
1368                    "MCP loader build FAILED for user {}; dispatch fails loud, retry after {:?}",
1369                    &user_hash[..8.min(user_hash.len())],
1370                    MCP_BUILD_RETRY_BACKOFF
1371                );
1372                self.mcp_loaders.insert(
1373                    user_hash,
1374                    McpLoaderEntry {
1375                        loader: None,
1376                        fingerprint,
1377                        built_at: chrono::Utc::now(),
1378                        degraded: Some(err.clone()),
1379                        failed_at: Some(chrono::Utc::now()),
1380                    },
1381                );
1382                Err(err)
1383            }
1384        }
1385    }
1386
1387    /// Build a loader entry from the effective config. No caching here —
1388    /// the caller owns insertion and degraded handling.
1389    async fn build_mcp_loader(
1390        &self,
1391        user_hash: &str,
1392        resolved: Option<&str>,
1393        fingerprint: u64,
1394        token_store: &Arc<pep::MemoryTokenStore>,
1395    ) -> Result<McpLoaderEntry, String> {
1396        let mcp_config: Option<abk::config::McpConfig> = match resolved {
1397            Some(toml_str) => {
1398                let value = toml_str
1399                    .parse::<toml::Value>()
1400                    .map_err(|e| format!("config parse failed: {}", e))?;
1401                match value.get("mcp") {
1402                    Some(section) => {
1403                        // 0.19.6 (incident fc94aea9, T3): a leftover ${…} in
1404                        // the effective [mcp] section means the credential
1405                        // reference is UNRESOLVED — the literal placeholder
1406                        // must never reach an agent (it once rode to
1407                        // production auth as the Bearer). Fail the build
1408                        // loud; the degraded path handles retry/backoff.
1409                        let mut leftovers: Vec<String> = Vec::new();
1410                        collect_unresolved_placeholders(section, &mut leftovers);
1411                        if !leftovers.is_empty() {
1412                            return Err(format!(
1413                                "unresolved {} placeholder(s) in [mcp]: {} — supply the \
1414                                 exact-case key in the profile secrets",
1415                                leftovers.len(),
1416                                leftovers.join(", ")
1417                            ));
1418                        }
1419                        use serde::Deserialize as _;
1420                        Some(
1421                            abk::config::McpConfig::deserialize(section.clone())
1422                                .map_err(|e| format!("invalid [mcp] config: {}", e))?,
1423                        )
1424                    }
1425                    None => None,
1426                }
1427            }
1428            None => None,
1429        };
1430
1431        let loader = match mcp_config {
1432            Some(cfg) if cfg.enabled => {
1433                let built = abk::agent::McpToolLoader::with_token_store(
1434                    &cfg,
1435                    Some(token_store.clone() as std::sync::Arc<dyn pep::token_store::TokenStore>),
1436                )
1437                .await
1438                .map_err(|e| format!("MCP loader build failed: {}", e))?;
1439
1440                // THE parity-evidence log line (16C acceptance + migration task):
1441                // one INFO line per build; a task loop that reuses the cache
1442                // shows exactly one line per user per fingerprint.
1443                let servers: Vec<String> = built
1444                    .server_statuses
1445                    .iter()
1446                    .map(|s| {
1447                        if s.connected {
1448                            format!("{}(up,{}tools)", s.name, s.tool_count)
1449                        } else {
1450                            format!("{}(DOWN)", s.name)
1451                        }
1452                    })
1453                    .collect();
1454                tracing::info!(
1455                    "MCP loader built for user {}: servers=[{}] total_tools={}",
1456                    &user_hash[..8.min(user_hash.len())],
1457                    servers.join(", "),
1458                    built.tool_count
1459                );
1460                Some(std::sync::Arc::new(built))
1461            }
1462            _ => None,
1463        };
1464
1465        Ok(McpLoaderEntry {
1466            loader,
1467            fingerprint,
1468            built_at: chrono::Utc::now(),
1469            degraded: None,
1470            failed_at: None,
1471        })
1472    }
1473
1474    /// Spawn a background drain task for a specific session's workflow receiver.
1475    fn spawn_user_drain_task(
1476        &self,
1477        session_id: String,
1478        session: Arc<Mutex<Session>>,
1479        ws_tx: broadcast::Sender<String>,
1480        mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
1481    ) {
1482        // Bug 6: broadcast StateChanged only on actual transitions. Without
1483        // this, every WS message was accompanied by a duplicate StateChanged,
1484        // doubling traffic and re-triggering frontend updateState on hot
1485        // StreamDelta/ReasoningDelta bursts. New subscribers learn the current
1486        // state from the WS snapshot, so the initial None is safe.
1487        let mut last_broadcast_state: Option<String> = None;
1488        tokio::spawn(async move {
1489            while let Some(msg) = workflow_rx.recv().await {
1490                {
1491                    let mut session = session.lock().await;
1492                    session.handle_workflow_message(msg.clone());
1493
1494                    let state_str = match session.workflow_state {
1495                        trustee_core::types::WorkflowState::Idle => "Idle",
1496                        trustee_core::types::WorkflowState::Running => "Running",
1497                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1498                    };
1499                    if last_broadcast_state.as_deref() != Some(state_str) {
1500                        last_broadcast_state = Some(state_str.to_string());
1501                        let state_msg = serde_json::json!({
1502                            "type": "StateChanged",
1503                            "state": state_str
1504                        });
1505                        let _ = ws_tx.send(state_msg.to_string());
1506                    }
1507                }
1508
1509                let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1510                let _ = ws_tx.send(json);
1511            }
1512            tracing::debug!("Drain task ended for session: {}", session_id);
1513        });
1514    }
1515
1516    /// Spawn the default user's drain task (backward compatibility).
1517    /// Called during server startup for the initial session.
1518    pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
1519        // Get the default user's first session
1520        let default_user = self
1521            .sessions
1522            .get("default")
1523            .expect("default user must exist");
1524        let first_entry = default_user
1525            .sessions
1526            .iter()
1527            .next()
1528            .expect("default user must have at least one session");
1529        let session = first_entry.session.clone();
1530        let ws_tx = first_entry.ws_tx.clone();
1531        let session_id = first_entry.key().clone();
1532        drop(first_entry);
1533        drop(default_user);
1534
1535        // Bug 6: transition-only StateChanged broadcasts (see
1536        // spawn_user_drain_task). The task starts in Running state because
1537        // spawn_drain_task is only called after a command began executing.
1538        let mut last_broadcast_state = Some("Running".to_string());
1539        tokio::spawn(async move {
1540            while let Some(msg) = workflow_rx.recv().await {
1541                {
1542                    let mut session = session.lock().await;
1543                    session.handle_workflow_message(msg.clone());
1544
1545                    let state_str = match session.workflow_state {
1546                        trustee_core::types::WorkflowState::Idle => "Idle",
1547                        trustee_core::types::WorkflowState::Running => "Running",
1548                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
1549                    };
1550                    if last_broadcast_state.as_deref() != Some(state_str) {
1551                        last_broadcast_state = Some(state_str.to_string());
1552                        let state_msg = serde_json::json!({
1553                            "type": "StateChanged",
1554                            "state": state_str
1555                        });
1556                        let _ = ws_tx.send(state_msg.to_string());
1557                    }
1558                }
1559
1560                let json = serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
1561                let _ = ws_tx.send(json);
1562            }
1563            tracing::debug!("Drain task ended for session: {}", session_id);
1564        });
1565    }
1566
1567    /// Resolve the user key from request headers.
1568    pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
1569        let Some(ref auth) = self.auth else {
1570            return "default".to_string();
1571        };
1572
1573        // Try Bearer header first
1574        if let Some(token) = headers
1575            .get(axum::http::header::AUTHORIZATION)
1576            .and_then(|v| v.to_str().ok())
1577            .and_then(|v| v.strip_prefix("Bearer "))
1578            .map(|s| s.to_string())
1579        {
1580            if token.starts_with("dev:") {
1581                let parts: Vec<&str> = token.splitn(4, ':').collect();
1582                if parts.len() >= 4 {
1583                    return format!("dev:{}", parts[1]);
1584                }
1585            }
1586            if let Ok(claims) = auth.validate_token(&token).await {
1587                return claims.sub;
1588            }
1589        }
1590
1591        // Try cookie
1592        let cookie_session_id = headers
1593            .get(axum::http::header::COOKIE)
1594            .and_then(|v| v.to_str().ok())
1595            .and_then(|cookies| {
1596                cookies.split(';').map(|c| c.trim()).find_map(|c| {
1597                    c.strip_prefix(&format!("{}=", auth.config.cookie_name))
1598                        .map(|s| s.to_string())
1599                })
1600            });
1601
1602        if let Some(session_id) = cookie_session_id {
1603            if session_id.starts_with("dev:") {
1604                let parts: Vec<&str> = session_id.splitn(4, ':').collect();
1605                if parts.len() >= 4 {
1606                    return format!("dev:{}", parts[1]);
1607                }
1608            }
1609
1610            if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
1611                if let Ok(claims) = auth.validate_token(&access_token).await {
1612                    return claims.sub;
1613                }
1614            }
1615        }
1616
1617        "default".to_string()
1618    }
1619}
1620
1621// ---------------------------------------------------------------------------
1622// SerializableMessage (unchanged)
1623// ---------------------------------------------------------------------------
1624
1625/// Wrapper to serialize `TuiMessage` as JSON with a `type` discriminator.
1626struct SerializableMessage<'a>(&'a TuiMessage);
1627
1628impl<'a> serde::Serialize for SerializableMessage<'a> {
1629    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1630    where
1631        S: serde::Serializer,
1632    {
1633        use serde::ser::SerializeStruct;
1634
1635        match self.0 {
1636            TuiMessage::OutputLine(line) => {
1637                let mut s = serializer.serialize_struct("msg", 2)?;
1638                s.serialize_field("type", "OutputLine")?;
1639                s.serialize_field("line", line)?;
1640                s.end()
1641            }
1642            TuiMessage::StreamDelta(delta) => {
1643                let mut s = serializer.serialize_struct("msg", 2)?;
1644                s.serialize_field("type", "StreamDelta")?;
1645                s.serialize_field("delta", delta)?;
1646                s.end()
1647            }
1648            TuiMessage::ReasoningDelta(delta) => {
1649                let mut s = serializer.serialize_struct("msg", 2)?;
1650                s.serialize_field("type", "ReasoningDelta")?;
1651                s.serialize_field("delta", delta)?;
1652                s.end()
1653            }
1654            TuiMessage::WorkflowCompleted => {
1655                let mut s = serializer.serialize_struct("msg", 2)?;
1656                s.serialize_field("type", "WorkflowCompleted")?;
1657                s.serialize_field("state", "Idle")?;
1658                s.end()
1659            }
1660            TuiMessage::WorkflowError(err) => {
1661                let mut s = serializer.serialize_struct("msg", 2)?;
1662                s.serialize_field("type", "WorkflowError")?;
1663                s.serialize_field("error", err)?;
1664                s.end()
1665            }
1666            TuiMessage::ResumeInfo(info) => match info {
1667                Some(ri) => {
1668                    let mut s = serializer.serialize_struct("msg", 5)?;
1669                    s.serialize_field("type", "ResumeInfo")?;
1670                    s.serialize_field("state", "Idle")?;
1671                    s.serialize_field("session_id", &ri.session_id)?;
1672                    s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
1673                    s.serialize_field("iteration", &ri.iteration)?;
1674                    s.end()
1675                }
1676                None => {
1677                    let mut s = serializer.serialize_struct("msg", 2)?;
1678                    s.serialize_field("type", "ResumeInfo")?;
1679                    s.serialize_field("state", "Idle")?;
1680                    s.end()
1681                }
1682            },
1683            TuiMessage::TodoUpdate(content) => {
1684                let mut s = serializer.serialize_struct("msg", 2)?;
1685                s.serialize_field("type", "TodoUpdate")?;
1686                s.serialize_field("content", content)?;
1687                s.end()
1688            }
1689            TuiMessage::WorkflowCancelled => {
1690                let mut s = serializer.serialize_struct("msg", 2)?;
1691                s.serialize_field("type", "WorkflowCancelled")?;
1692                s.serialize_field("state", "Idle")?;
1693                s.end()
1694            }
1695            TuiMessage::HandoffReady(briefing) => {
1696                let mut s = serializer.serialize_struct("msg", 3)?;
1697                s.serialize_field("type", "HandoffReady")?;
1698                s.serialize_field("state", "Idle")?;
1699                s.serialize_field("briefing", briefing)?;
1700                s.end()
1701            }
1702            TuiMessage::SessionRotated { old, new } => {
1703                let mut s = serializer.serialize_struct("msg", 3)?;
1704                s.serialize_field("type", "SessionRotated")?;
1705                s.serialize_field("old", old)?;
1706                s.serialize_field("new", new)?;
1707                s.end()
1708            }
1709            TuiMessage::HandoffFailed => {
1710                let mut s = serializer.serialize_struct("msg", 2)?;
1711                s.serialize_field("type", "HandoffFailed")?;
1712                s.serialize_field("state", "Idle")?;
1713                s.end()
1714            }
1715            TuiMessage::ToolPending { tool_name, hint } => {
1716                let mut s = serializer.serialize_struct("msg", 3)?;
1717                s.serialize_field("type", "ToolPending")?;
1718                s.serialize_field("tool_name", tool_name)?;
1719                s.serialize_field("hint", hint)?;
1720                s.end()
1721            }
1722            TuiMessage::ToolDone {
1723                tool_name,
1724                success,
1725                hint,
1726            } => {
1727                let mut s = serializer.serialize_struct("msg", 4)?;
1728                s.serialize_field("type", "ToolDone")?;
1729                s.serialize_field("tool_name", tool_name)?;
1730                s.serialize_field("success", success)?;
1731                s.serialize_field("hint", hint)?;
1732                s.end()
1733            }
1734            TuiMessage::ContextTokensUpdated(count) => {
1735                let mut s = serializer.serialize_struct("msg", 2)?;
1736                s.serialize_field("type", "ContextTokensUpdated")?;
1737                s.serialize_field("count", count)?;
1738                s.end()
1739            }
1740            TuiMessage::McpServerStatus {
1741                name,
1742                connected,
1743                tool_count,
1744                error,
1745            } => {
1746                let mut s = serializer.serialize_struct("msg", 5)?;
1747                s.serialize_field("type", "McpServerStatus")?;
1748                s.serialize_field("name", name)?;
1749                s.serialize_field("connected", connected)?;
1750                s.serialize_field("tool_count", tool_count)?;
1751                s.serialize_field("error", error)?;
1752                s.end()
1753            }
1754            TuiMessage::SessionTitleUpdated(title) => {
1755                let mut s = serializer.serialize_struct("msg", 2)?;
1756                s.serialize_field("type", "SessionTitleUpdated")?;
1757                s.serialize_field("title", title)?;
1758                s.end()
1759            }
1760        }
1761    }
1762}
1763
1764// ---------------------------------------------------------------------------
1765// Per-user config helpers
1766// ---------------------------------------------------------------------------
1767
1768/// Deep-merge a TOML overlay on top of a base value (in-place).
1769///
1770/// - Tables: recursively merge key-by-key (overlay wins on conflict).
1771/// - Arrays: overlay replaces base entirely (no merging).
1772/// - Scalars: overlay replaces base.
1773/// - If a key exists in overlay but not base, it's added.
1774fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1775    match (base, overlay) {
1776        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1777            for (key, overlay_val) in overlay_table {
1778                match base_table.get_mut(key) {
1779                    Some(base_val) => {
1780                        // Both exist — recurse if both are tables, else replace
1781                        deep_merge_toml(base_val, overlay_val);
1782                    }
1783                    None => {
1784                        // Key only in overlay — insert
1785                        base_table.insert(key.clone(), overlay_val.clone());
1786                    }
1787                }
1788            }
1789        }
1790        // Non-table: overlay replaces base
1791        (base, overlay) => {
1792            *base = overlay.clone();
1793        }
1794    }
1795}
1796
1797/// Replace `${VAR_NAME}` references in a string with values from a secrets map.
1798///
1799/// Falls back to process environment if the variable is not in the map.
1800/// Variables not found in either are left as-is.
1801/// Walk a TOML value tree; collect every string that still contains a
1802/// `${…}` placeholder (post-substitution leftovers — unresolved references).
1803fn collect_unresolved_placeholders(value: &toml::Value, out: &mut Vec<String>) {
1804    match value {
1805        toml::Value::String(s) => {
1806            if let Some(start) = s.find("${") {
1807                if let Some(end_rel) = s[start + 2..].find('}') {
1808                    let name = &s[start + 2..start + 2 + end_rel];
1809                    let rendered = format!("${{{name}}}");
1810                    if !out.contains(&rendered) {
1811                        out.push(rendered);
1812                    }
1813                }
1814            }
1815        }
1816        toml::Value::Table(t) => {
1817            for v in t.values() {
1818                collect_unresolved_placeholders(v, out);
1819            }
1820        }
1821        toml::Value::Array(a) => {
1822            for v in a {
1823                collect_unresolved_placeholders(v, out);
1824            }
1825        }
1826        _ => {}
1827    }
1828}
1829
1830/// Where a `${VAR}` answer came from (92a36073 credential provenance).
1831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1832pub(crate) enum SecretSource {
1833    /// The secrets map (shared secrets + per-user `.env`, user wins) — the
1834    /// attributed, per-identity answer.
1835    SecretsMap,
1836    /// The PROCESS environment — for the trustee service that is the global
1837    /// `~/.trustee/.env` loaded at boot. When this answers for a per-user
1838    /// namespace, the per-user credential did NOT answer and a global
1839    /// credential did: actions execute under the global principal. Never
1840    /// silent — every occurrence is logged at ERROR with the variable named.
1841    ProcessEnv,
1842    /// Found nowhere; the literal `${…}` remains (the fc94aea9 guard).
1843    Unresolved,
1844}
1845
1846/// One resolved (or unresolved) variable from [`substitute_env_vars`].
1847#[derive(Debug, Clone, PartialEq, Eq)]
1848pub(crate) struct EnvVarResolution {
1849    pub name: String,
1850    pub source: SecretSource,
1851}
1852
1853fn substitute_env_vars(
1854    s: &mut String,
1855    secrets: &std::collections::HashMap<String, String>,
1856    ctx: &str,
1857) -> Vec<EnvVarResolution> {
1858    // Simple state machine: scan for ${, read until }, replace.
1859    let mut result = String::with_capacity(s.len());
1860    let bytes = s.as_bytes();
1861    let mut i = 0;
1862    // One entry per DISTINCT variable, first sighting wins (a var used
1863    // twice resolves from the same source both times).
1864    let mut report: Vec<EnvVarResolution> = Vec::new();
1865
1866    while i < bytes.len() {
1867        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1868            // Find closing }
1869            if let Some(end) = s[i + 2..].find('}') {
1870                let var_name = &s[i + 2..i + 2 + end];
1871                // Look up in per-user secrets first, then process env
1872                let (replacement, source) = if let Some(value) = secrets.get(var_name) {
1873                    (value.clone(), SecretSource::SecretsMap)
1874                } else if let Ok(value) = std::env::var(var_name) {
1875                    (value, SecretSource::ProcessEnv)
1876                } else {
1877                    // Not found — leave as-is BUT NOT SILENTLY (incident
1878                    // fc94aea9, T3): an unresolved ${VAR} once rode to
1879                    // production authentication as the literal credential.
1880                    // Every leftover is named, every time.
1881                    (s[i..i + 2 + end + 1].to_string(), SecretSource::Unresolved)
1882                };
1883                if !report.iter().any(|r| r.name == var_name) {
1884                    report.push(EnvVarResolution {
1885                        name: var_name.to_string(),
1886                        source,
1887                    });
1888                }
1889                result.push_str(&replacement);
1890                i = i + 2 + end + 1;
1891            } else {
1892                // No closing } — copy as-is
1893                result.push('$');
1894                i += 1;
1895            }
1896        } else {
1897            result.push(bytes[i] as char);
1898            i += 1;
1899        }
1900    }
1901
1902    // 92a36073 — never-silent credential provenance. A process-env answer
1903    // for a per-user namespace is the global-token shadowing hazard: the
1904    // global principal is about to execute what the per-user identity
1905    // should have. ERROR level, EVERY occurrence (no dedup across calls).
1906    for r in &report {
1907        match r.source {
1908            SecretSource::ProcessEnv => tracing::error!(
1909                "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from PROCESS ENV \
1910                 (global ~/.trustee/.env at boot) — no per-user/profile secret \
1911                 answered; calls using it execute under the GLOBAL principal. \
1912                 Supply the exact-case key in the per-user .env / profile \
1913                 secrets to restore attribution.",
1914                ctx,
1915                r.name
1916            ),
1917            SecretSource::Unresolved => {
1918                // Compose the leftover list for the existing loud line.
1919            }
1920            SecretSource::SecretsMap => tracing::debug!(
1921                "CREDENTIAL PROVENANCE [{}]: ${{{}}} resolved from profile/user secrets",
1922                ctx,
1923                r.name
1924            ),
1925        }
1926    }
1927    let unresolved: Vec<&str> = report
1928        .iter()
1929        .filter(|r| r.source == SecretSource::Unresolved)
1930        .map(|r| r.name.as_str())
1931        .collect();
1932    if !unresolved.is_empty() {
1933        tracing::warn!(
1934            "ENV SUBSTITUTION LEFT {} UNRESOLVED [{}]: {} — the literal ${{…}} remains in the \
1935             effective config; supply the key in the profile secrets (exact-case KEY=VALUE) \
1936             or the environment",
1937            unresolved.len(),
1938            ctx,
1939            unresolved
1940                .iter()
1941                .map(|v| format!("${{{v}}}"))
1942                .collect::<Vec<_>>()
1943                .join(", ")
1944        );
1945    }
1946
1947    *s = result;
1948    report
1949}
1950
1951/// 92a36073 boot-surface check: is a MATERIALIZED namespace missing its
1952/// per-user credentials? A directory without a `.env` is exactly the
1953/// shadowing scenario (rotated/empty home + global token downstream) —
1954/// surface it at boot, not in forensics. Pure so it stays unit-tested.
1955pub(crate) fn materialized_user_missing_env(user_home: &std::path::Path) -> bool {
1956    user_home.is_dir() && !user_home.join(".env").exists()
1957}
1958
1959// ---------------------------------------------------------------------------
1960// Tests (task 16B: overlay allowlist + consolidated user hash)
1961// ---------------------------------------------------------------------------
1962
1963/// Fingerprint the effective `[mcp]` section of a resolved config (16C).
1964///
1965/// Content hash (SHA-256, first 8 bytes as u64) — never mtime: overlays are
1966/// rewritten in place. Configs without `[mcp]` fingerprint to a stable
1967/// constant, so the disabled state caches too.
1968fn fingerprint_mcp_section(resolved: Option<&str>) -> u64 {
1969    use sha2::{Digest, Sha256};
1970    let section = resolved
1971        .and_then(|s| s.parse::<toml::Value>().ok())
1972        .and_then(|v| v.get("mcp").cloned());
1973    let bytes = match section {
1974        Some(v) => v.to_string().into_bytes(),
1975        None => b"<no-mcp>".to_vec(),
1976    };
1977    let digest = Sha256::digest(&bytes);
1978    u64::from_be_bytes(digest[..8].try_into().expect("sha256 digest >= 8 bytes"))
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983    use super::*;
1984
1985    /// Default-knob ServerState for overlay tests.
1986    fn test_state() -> ServerState {
1987        let (session, _rx) = Session::new();
1988        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
1989        ServerState::new(session, ws_tx, None)
1990    }
1991
1992    #[tokio::test]
1993    async fn dispatch_scope_pins_user_key_resolution() {
1994        // THE 16F pin (ec3e0622 companion): inside the dispatch scope, EVERY
1995        // user-key-derived resolution lands in the binding's home — the
1996        // caller's own key (and its claims) become irrelevant.
1997        let state = test_state();
1998        let caller = "some-human-sub";
1999        let plain_home = state.get_user_home_dir(caller).unwrap();
2000        let pinned_key = "prof-1234";
2001        let pinned_home = in_dispatch_scope(pinned_key, async {
2002            state.get_user_home_dir(caller).unwrap()
2003        })
2004        .await;
2005        let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2006        assert_eq!(plain_home, users_root.join(trustee_core::user_hash(caller)));
2007        assert_eq!(
2008            pinned_home,
2009            users_root.join(trustee_core::user_hash(pinned_key)),
2010            "dispatch scope must pin resolution to the binding key"
2011        );
2012        assert_ne!(plain_home, pinned_home);
2013    }
2014
2015    /// Unique temp dir with a `config/` subdir (std-only; no tempfile dep).
2016    fn temp_user_home(tag: &str) -> std::path::PathBuf {
2017        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2018        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2019        let dir = std::env::temp_dir().join(format!(
2020            "trustee-state-test-{}-{}-{}",
2021            tag,
2022            std::process::id(),
2023            n
2024        ));
2025        std::fs::create_dir_all(dir.join("config")).expect("create temp user home");
2026        dir
2027    }
2028
2029    fn parse(toml_str: &str) -> toml::Value {
2030        toml_str.parse::<toml::Value>().expect("valid test TOML")
2031    }
2032
2033    /// (a) Overlay with [mcp] + [server] + [auth]: [mcp] applied (user wins),
2034    /// shared [server] preserved untouched, overlay [auth] dropped entirely.
2035    #[test]
2036    fn overlay_allowlist_drops_non_allowlisted_sections() {
2037        let state = test_state();
2038        let home = temp_user_home("allowlist");
2039        std::fs::write(
2040            home.join("config").join("trustee.toml"),
2041            "[server]\nport = 1\n\n[auth]\nmode = \"kanidm\"\n\n[mcp]\nmode = \"user\"\n",
2042        )
2043        .expect("write overlay");
2044
2045        let shared = "[server]\nport = 8080\n\n[mcp]\nmode = \"shared\"\n";
2046        let merged = state
2047            .merge_user_config(&home, Some(shared))
2048            .expect("overlay has allowlisted content");
2049
2050        let merged_val = parse(&merged);
2051        let expected = parse("[server]\nport = 8080\n\n[mcp]\nmode = \"user\"\n");
2052        assert_eq!(
2053            merged_val, expected,
2054            "merged config must be shared + [mcp] overlay only"
2055        );
2056        assert!(
2057            merged_val.get("auth").is_none(),
2058            "overlay [auth] must be dropped"
2059        );
2060    }
2061
2062    /// (b) No overlay file → merge is a no-op (None), shared config untouched.
2063    #[test]
2064    fn no_overlay_file_returns_none() {
2065        let state = test_state();
2066        let home = temp_user_home("empty");
2067        assert!(state
2068            .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2069            .is_none());
2070    }
2071
2072    /// Overlay whose sections are ALL non-allowlisted → no-op (None).
2073    #[test]
2074    fn overlay_with_no_allowlisted_sections_is_noop() {
2075        let state = test_state();
2076        let home = temp_user_home("all-dropped");
2077        std::fs::write(
2078            home.join("config").join("trustee.toml"),
2079            "[server]\nport = 1\n\n[storage]\npath = \"/tmp/x\"\n",
2080        )
2081        .expect("write overlay");
2082        assert!(state
2083            .merge_user_config(&home, Some("[server]\nport = 8080\n"))
2084            .is_none());
2085    }
2086
2087    /// (c) [llm] dropped when allow_llm_overlay=false (default), applied when
2088    /// the instance opted in via with_allow_llm_overlay(true).
2089    #[test]
2090    fn llm_overlay_dropped_by_default_and_kept_when_enabled() {
2091        let shared = "[llm]\nprovider = \"openai\"\n\n[mcp]\nmode = \"shared\"\n";
2092        let overlay = "[llm]\nprovider = \"anthropic\"\n";
2093
2094        // Default: knob false → [llm]-only overlay is a no-op.
2095        let state = test_state();
2096        assert!(!state.allow_llm_overlay);
2097        let home = temp_user_home("llm-off");
2098        std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2099        assert!(state.merge_user_config(&home, Some(shared)).is_none());
2100
2101        // Opted in: [llm] kept, user value wins; shared [mcp] untouched.
2102        let state = test_state().with_allow_llm_overlay(true);
2103        assert!(state.allow_llm_overlay);
2104        let home = temp_user_home("llm-on");
2105        std::fs::write(home.join("config").join("trustee.toml"), overlay).expect("write overlay");
2106        let merged = state
2107            .merge_user_config(&home, Some(shared))
2108            .expect("[llm] overlay applies when opted in");
2109        let merged_val = parse(&merged);
2110        assert_eq!(merged_val["llm"]["provider"].as_str(), Some("anthropic"));
2111        assert_eq!(merged_val["mcp"]["mode"].as_str(), Some("shared"));
2112    }
2113
2114    /// (d) Web-path home dir resolves through the consolidated
2115    /// trustee_core::user_hash (determinism/known-vectors covered in
2116    /// trustee-core's own tests).
2117    #[test]
2118    fn user_home_dir_uses_consolidated_hash() {
2119        let state = test_state();
2120        if let Some(home) = state.get_user_home_dir("farzan@example.com") {
2121            assert_eq!(
2122                home.file_name().and_then(|n| n.to_str()),
2123                Some(trustee_core::user_hash("farzan@example.com")).as_deref()
2124            );
2125            let users_root = dirs::home_dir().unwrap().join(".trustee").join("users");
2126            assert_eq!(home.parent(), Some(&users_root).map(|p| p.as_path()));
2127        }
2128        // dirs::home_dir() unavailable in sandbox → covered by core tests.
2129    }
2130
2131    /// xagent personas: [lifecycle] survives the overlay allowlist (persona
2132    /// is user-owned) but the WASM extension switch does not.
2133    #[test]
2134    fn lifecycle_overlay_survives_allowlist_enabled_stripped() {
2135        let state = test_state();
2136        let home = temp_user_home("lifecycle-persona");
2137        std::fs::write(
2138            home.join("config").join("trustee.toml"),
2139            "[lifecycle]\nsystem_template = \"You are Saman.\"\nenabled = true\n",
2140        )
2141        .expect("write overlay");
2142
2143        let merged = state
2144            .merge_user_config(
2145                &home,
2146                Some("[lifecycle]\nsystem_template = \"shared default\"\n"),
2147            )
2148            .expect("lifecycle overlay is allowlisted");
2149
2150        let v = merged.parse::<toml::Value>().expect("valid TOML");
2151        assert_eq!(
2152            v["lifecycle"]["system_template"].as_str(),
2153            Some("You are Saman."),
2154            "agent persona wins over shared default"
2155        );
2156        assert_eq!(
2157            v["lifecycle"].get("enabled"),
2158            None,
2159            "WASM extension switch is not user-settable"
2160        );
2161    }
2162
2163    /// 16D integration guard: dev-agent principals (user_key `agent-<name>`)
2164    /// get their OWN session bucket and home-dir namespace — one agent can
2165    /// never see another's sessions, and both go through the same
2166    /// user_hash isolation path as humans.
2167    #[tokio::test]
2168    async fn agent_principals_get_isolated_session_buckets() {
2169        let state = test_state();
2170        let key_a = "agent-farzan";
2171        let key_b = "agent-paydar";
2172
2173        let sid_a = state
2174            .create_session(key_a, None, None)
2175            .await
2176            .expect("create session");
2177        let _sid_b = state
2178            .create_session(key_b, None, None)
2179            .await
2180            .expect("create session");
2181
2182        // Each agent resolves its own session; B cannot see A's by id.
2183        assert!(
2184            state.get_session_by_any_id(key_a, &sid_a).await.is_some(),
2185            "owner bucket resolves its own session"
2186        );
2187        assert!(
2188            state.get_session_by_any_id(key_b, &sid_a).await.is_none(),
2189            "cross-agent session access must be 404/None"
2190        );
2191
2192        // Same isolation path as humans: home dir = users_root/user_hash(key).
2193        if let Some(home) = state.get_user_home_dir(key_a) {
2194            assert_eq!(
2195                home.file_name().and_then(|n| n.to_str()),
2196                Some(trustee_core::user_hash(key_a)).as_deref()
2197            );
2198        }
2199    }
2200}
2201
2202// ---------------------------------------------------------------------------
2203// 16C — per-user McpToolLoader cache
2204// ---------------------------------------------------------------------------
2205
2206#[cfg(test)]
2207mod mcp_loader_cache_tests {
2208    use super::*;
2209
2210    fn state_with_shared(shared: &str) -> ServerState {
2211        let (session, _rx) = Session::new();
2212        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2213        let mut state = ServerState::new(session, ws_tx, None);
2214        state.config_toml = Some(shared.to_string());
2215        state
2216    }
2217
2218    /// Deterministic throwaway user: real user-home path under
2219    /// ~/.trustee/users/{hash} (that IS the resolution path under test),
2220    /// with config/ subdir; caller cleans up.
2221    struct TempUser {
2222        key: String,
2223        home: std::path::PathBuf,
2224    }
2225
2226    impl TempUser {
2227        fn new(tag: &str) -> Self {
2228            let key = format!("16c-{tag}-{}@test.invalid", std::process::id());
2229            let home = dirs::home_dir()
2230                .expect("HOME available in test env")
2231                .join(".trustee")
2232                .join("users")
2233                .join(trustee_core::user_hash(&key));
2234            std::fs::create_dir_all(home.join("config")).expect("create user home");
2235            Self { key, home }
2236        }
2237
2238        fn write_overlay(&self, toml_str: &str) {
2239            std::fs::write(self.home.join("config").join("trustee.toml"), toml_str)
2240                .expect("write overlay");
2241        }
2242    }
2243
2244    impl Drop for TempUser {
2245        fn drop(&mut self) {
2246            let _ = std::fs::remove_dir_all(&self.home);
2247        }
2248    }
2249
2250    #[tokio::test]
2251    async fn no_mcp_config_caches_disabled_marker() {
2252        let state = state_with_shared("[server]\nport = 8080\n");
2253        let user = TempUser::new("nomcp");
2254        let ts = Arc::new(pep::MemoryTokenStore::new());
2255
2256        let first = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2257        assert!(first.is_none(), "no [mcp] anywhere → disabled marker");
2258        assert_eq!(state.mcp_loaders.len(), 1, "exactly one cache entry");
2259
2260        // Second call is a fingerprint hit — same disabled marker, still one entry.
2261        let second = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2262        assert!(second.is_none());
2263        assert_eq!(state.mcp_loaders.len(), 1);
2264    }
2265
2266    #[tokio::test]
2267    async fn concurrent_cold_builds_single_flight() {
2268        let state = Arc::new(state_with_shared("[server]\nport = 8080\n"));
2269        let user = TempUser::new("singleflight");
2270        let ts = Arc::new(pep::MemoryTokenStore::new());
2271
2272        let mut handles = Vec::new();
2273        for _ in 0..5 {
2274            let state = state.clone();
2275            let key = user.key.clone();
2276            let ts = ts.clone();
2277            handles.push(tokio::spawn(async move {
2278                state.get_or_build_mcp_loader(&key, &ts).await
2279            }));
2280        }
2281        for h in handles {
2282            h.await.unwrap().expect("all five succeed");
2283        }
2284        assert_eq!(state.mcp_loaders.len(), 1, "single-flight → one entry");
2285    }
2286
2287    #[tokio::test]
2288    async fn fingerprint_change_triggers_rebuild() {
2289        let state = state_with_shared("[server]\nport = 8080\n");
2290        let user = TempUser::new("fpchange");
2291        let ts = Arc::new(pep::MemoryTokenStore::new());
2292
2293        // v1: an enabled MCP server pointing at a blackhole port — abk keeps
2294        // the loader with a DOWN status (connect refused is fast on loopback).
2295        user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2296        let v1 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2297        assert!(v1.is_some(), "enabled [mcp] → real loader");
2298        let fp1 = state
2299            .mcp_loaders
2300            .get(&trustee_core::user_hash(&user.key))
2301            .unwrap()
2302            .fingerprint;
2303
2304        // v2: different server URL → different content fingerprint → rebuild.
2305        std::thread::sleep(std::time::Duration::from_millis(5));
2306        user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v2\"\nurl = \"http://127.0.0.1:9/other\"\n");
2307        let v2 = state.get_or_build_mcp_loader(&user.key, &ts).await.unwrap();
2308        assert!(v2.is_some());
2309        let entry = state
2310            .mcp_loaders
2311            .get(&trustee_core::user_hash(&user.key))
2312            .unwrap();
2313        assert_ne!(
2314            entry.fingerprint, fp1,
2315            "fingerprint must change with content"
2316        );
2317        assert!(entry.degraded.is_none());
2318
2319        // The old Arc stays valid for in-flight sessions (no panic, no revoke).
2320        let _still_usable = v1.as_ref().unwrap().tool_count;
2321    }
2322
2323    #[tokio::test]
2324    async fn degraded_entry_fails_loud_within_backoff_and_isolates_users() {
2325        let state = state_with_shared("[server]\nport = 8080\n");
2326        let bad = TempUser::new("degraded-bad");
2327        let good = TempUser::new("degraded-good");
2328        let ts = Arc::new(pep::MemoryTokenStore::new());
2329
2330        // [mcp] present but not a table → McpConfig deserialize fails → Err.
2331        bad.write_overlay("[mcp]\nenabled = \"not-a-bool\"\n");
2332        let err = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2333            Ok(_) => panic!("invalid [mcp] must fail loud"),
2334            Err(e) => e,
2335        };
2336        assert!(
2337            err.contains("invalid [mcp]"),
2338            "surfaces the parse error: {err}"
2339        );
2340
2341        let entry = state
2342            .mcp_loaders
2343            .get(&trustee_core::user_hash(&bad.key))
2344            .unwrap();
2345        assert!(entry.degraded.is_some(), "poison entry recorded");
2346
2347        // Within backoff: fast-fail again (cached error).
2348        let err2 = match state.get_or_build_mcp_loader(&bad.key, &ts).await {
2349            Ok(_) => panic!("still within backoff"),
2350            Err(e) => e,
2351        };
2352        assert_eq!(err, err2, "same cached error");
2353
2354        // Other users are completely unaffected.
2355        let good_loader = state
2356            .get_or_build_mcp_loader(&good.key, &ts)
2357            .await
2358            .expect("other user unaffected");
2359        assert!(
2360            good_loader.is_none(),
2361            "good user has no [mcp] → disabled marker"
2362        );
2363    }
2364
2365    #[tokio::test]
2366    async fn aged_out_entry_rebuilds_without_restart() {
2367        // THE 42131deb guard: a served-surface change behind a byte-identical
2368        // config must be reflected in the NEXT fresh session's manifest —
2369        // simulated by rewinding built_at past MCP_TOOLSET_MAX_AGE; the next
2370        // dispatch must NOT reuse the stale Arc (it rebuilds + re-lists).
2371        let state = state_with_shared("[server]\nport = 8080\n");
2372        let user = TempUser::new("agedout");
2373        let ts = Arc::new(pep::MemoryTokenStore::new());
2374
2375        user.write_overlay("[mcp]\nenabled = true\n\n[[mcp.servers]]\nname = \"v1\"\nurl = \"http://127.0.0.1:9/sse\"\n");
2376        let v1 = state
2377            .get_or_build_mcp_loader(&user.key, &ts)
2378            .await
2379            .expect("initial build");
2380        let v1 = v1.expect("enabled [mcp] → loader");
2381
2382        // Simulate time: the entry is now older than the max age, config
2383        // byte-identical (fingerprint UNCHANGED — that's the trap).
2384        {
2385            let mut entry = state
2386                .mcp_loaders
2387                .get_mut(&trustee_core::user_hash(&user.key))
2388                .unwrap();
2389            entry.built_at = chrono::Utc::now() - chrono::Duration::seconds(601);
2390        }
2391
2392        let v2 = state
2393            .get_or_build_mcp_loader(&user.key, &ts)
2394            .await
2395            .expect("aged-out dispatch rebuilds");
2396        let v2 = v2.expect("rebuilt loader present");
2397        assert!(
2398            !Arc::ptr_eq(&v1, &v2),
2399            "aged-out entry must NOT be reused — fresh connect re-lists the served surface"
2400        );
2401        let entry = state
2402            .mcp_loaders
2403            .get(&trustee_core::user_hash(&user.key))
2404            .unwrap();
2405        assert!(
2406            chrono::Utc::now() - entry.built_at < chrono::Duration::seconds(5),
2407            "built_at refreshed by the rebuild"
2408        );
2409
2410        // And a young, unchanged entry still hits (no rebuild churn).
2411        let v3 = state
2412            .get_or_build_mcp_loader(&user.key, &ts)
2413            .await
2414            .expect("fresh dispatch");
2415        assert!(Arc::ptr_eq(&v2, &v3.unwrap()), "young entry hits the cache");
2416    }
2417}
2418
2419#[cfg(test)]
2420mod restore_tests {
2421    use super::*;
2422
2423    fn fresh_state() -> ServerState {
2424        let (session, _rx) = Session::new();
2425        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
2426        let mut state = ServerState::new(session, ws_tx, None);
2427        state.config_toml = Some("[agent]\nname = \"trustee\"\n".to_string());
2428        state
2429    }
2430
2431    /// Deterministic throwaway user (same pattern as mcp_loader_cache_tests).
2432    struct TempUser {
2433        key: String,
2434    }
2435
2436    impl TempUser {
2437        fn new(tag: &str) -> Self {
2438            let key = format!("restore-{tag}-{}@test.invalid", std::process::id());
2439            let home = dirs::home_dir()
2440                .expect("HOME available in test env")
2441                .join(".trustee")
2442                .join("users")
2443                .join(trustee_core::user_hash(&key));
2444            std::fs::create_dir_all(home).expect("create user home");
2445            Self { key }
2446        }
2447    }
2448
2449    #[tokio::test]
2450    async fn restore_unknown_chain_returns_none() {
2451        let state = fresh_state();
2452        let user = TempUser::new("unknown");
2453        let out = state
2454            .get_or_restore_session_by_any_id(&user.key, "session_2099_01_01_00_00_deadbeef")
2455            .await;
2456        assert!(out.is_none(), "unknown chain must 404, not restore");
2457    }
2458
2459    #[tokio::test]
2460    async fn destroy_tombstones_chain_id() {
2461        let state = fresh_state();
2462        let user = TempUser::new("tombstone");
2463        let sid = state
2464            .create_session(&user.key, None, None)
2465            .await
2466            .expect("create session");
2467
2468        // Attach the live session to an on-disk-style chain id.
2469        let (session, _ws) = state.get_session(&user.key, &sid).await.unwrap();
2470        {
2471            let mut s = session.lock().await;
2472            s.session_id = Some("session_2026_09_04_10_00_aaaabbbb".to_string());
2473        }
2474
2475        state
2476            .destroy_session(&user.key, &sid)
2477            .await
2478            .expect("destroy");
2479
2480        let path = state
2481            .tombstone_path(&user.key, "session_2026_09_04_10_00_aaaabbbb")
2482            .expect("tombstone path");
2483        assert!(path.exists(), "destroy must write a chain tombstone");
2484
2485        // And a fresh live session with NO chain id must not tombstone anything.
2486        let sid2 = state
2487            .create_session(&user.key, None, None)
2488            .await
2489            .expect("create second session");
2490        state
2491            .destroy_session(&user.key, &sid2)
2492            .await
2493            .expect("destroy 2");
2494        // (No assertion beyond "no panic": session_id was None → no new markers.)
2495    }
2496
2497    #[tokio::test]
2498    async fn restore_refuses_registry_key_shapes() {
2499        // Registry keys ("default", "web…") are not checkpoint chains; even
2500        // if create_resume_info could not find them anyway, the tombstone
2501        // guard short-circuits non-`session_` ids without touching disk.
2502        let state = fresh_state();
2503        let user = TempUser::new("shapes");
2504        assert!(state.tombstone_path(&user.key, "default").is_none());
2505        let out = state
2506            .get_or_restore_session_by_any_id(&user.key, "default")
2507            .await;
2508        assert!(out.is_none());
2509    }
2510}
2511
2512#[test]
2513fn collect_unresolved_placeholders_walks_the_tree() {
2514    let section: toml::Value = toml::from_str(
2515        r#"
2516[credentials.farzan_service_account]
2517service_token = "${FARZAN_SERVICE_ACCOUNT}"
2518issuer_url = "https://ok.example"
2519
2520[[servers]]
2521name = "fame"
2522url = "https://fame.example"
2523credentials = "farzan_service_account"
2524
2525[[servers]]
2526name = "other"
2527url = "https://other.example"
2528token = "${OTHER_TOKEN}"
2529"#,
2530    )
2531    .unwrap();
2532    let mut out = Vec::new();
2533    collect_unresolved_placeholders(&section, &mut out);
2534    out.sort();
2535    assert_eq!(
2536        out,
2537        vec![
2538            "${FARZAN_SERVICE_ACCOUNT}".to_string(),
2539            "${OTHER_TOKEN}".to_string()
2540        ],
2541        "every leftover placeholder in the [mcp] tree must be collected, deduped"
2542    );
2543    // clean section → nothing
2544    let clean: toml::Value = toml::from_str("[x]\ny = \"z\"\n").unwrap();
2545    let mut out2 = Vec::new();
2546    collect_unresolved_placeholders(&clean, &mut out2);
2547    assert!(out2.is_empty());
2548}
2549
2550#[test]
2551fn substitute_env_vars_resolves_and_keeps_leftovers_deterministically() {
2552    let mut secrets = std::collections::HashMap::new();
2553    secrets.insert("A_TOKEN".to_string(), "resolved-value".to_string());
2554    let mut s = "x = \"${A_TOKEN}\" y = \"${MISSING_TOKEN}\"".to_string();
2555    let report = substitute_env_vars(&mut s, &secrets, "test");
2556    assert!(s.contains("resolved-value"), "declared key resolves: {s}");
2557    assert!(
2558        s.contains("${MISSING_TOKEN}"),
2559        "undeclared key keeps the literal (the loader guard turns this into a loud failure): {s}"
2560    );
2561    // 92a36073: the report names each distinct variable and its source.
2562    assert_eq!(report.len(), 2, "one entry per distinct var: {report:?}");
2563    assert!(report.contains(&EnvVarResolution {
2564        name: "A_TOKEN".to_string(),
2565        source: SecretSource::SecretsMap
2566    }));
2567    assert!(report.contains(&EnvVarResolution {
2568        name: "MISSING_TOKEN".to_string(),
2569        source: SecretSource::Unresolved
2570    }));
2571}
2572
2573#[test]
2574fn substitute_env_vars_reports_process_env_shadow() {
2575    // 92a36073: a var ABSENT from the per-user/profile secrets but set
2576    // in the process environment is the global-token shadowing shape —
2577    // it must be REPORTED as ProcessEnv, and the secrets map must still
2578    // win when it DOES carry the key.
2579    let var = format!("RAVAND_PROVENANCE_TEST_{}", std::process::id());
2580    std::env::set_var(&var, "global-env-answer");
2581
2582    // Absent from secrets → process env answers → ProcessEnv reported.
2583    let mut secrets = std::collections::HashMap::new();
2584    let mut s = format!("x = \"${{{var}}}\"");
2585    let report = substitute_env_vars(&mut s, &secrets, "test-shadow");
2586    assert!(s.contains("global-env-answer"), "process env answered: {s}");
2587    assert_eq!(
2588        report,
2589        vec![EnvVarResolution {
2590            name: var.clone(),
2591            source: SecretSource::ProcessEnv
2592        }]
2593    );
2594
2595    // Same var present in secrets → secrets WIN (per-user precedence
2596    // unchanged), reported as SecretsMap.
2597    secrets.insert(var.clone(), "per-user-answer".to_string());
2598    let mut s2 = format!("x = \"${{{var}}}\"");
2599    let report2 = substitute_env_vars(&mut s2, &secrets, "test-wins");
2600    assert!(s2.contains("per-user-answer"), "user secrets win: {s2}");
2601    assert_eq!(
2602        report2,
2603        vec![EnvVarResolution {
2604            name: var.clone(),
2605            source: SecretSource::SecretsMap
2606        }]
2607    );
2608
2609    std::env::remove_var(&var);
2610}
2611
2612#[test]
2613fn materialized_user_missing_env_only_fires_on_dir_without_env() {
2614    // 92a36073 boot-surface: warn shape is dir-present + .env-absent.
2615    let base = std::env::temp_dir().join(format!("trustee-envcheck-{}", std::process::id()));
2616    let _ = std::fs::remove_dir_all(&base);
2617
2618    // Not materialized at all → NOT the warning shape.
2619    assert!(!materialized_user_missing_env(&base.join("ghost")));
2620
2621    // Materialized WITHOUT .env → the warning shape.
2622    std::fs::create_dir_all(&base).unwrap();
2623    assert!(materialized_user_missing_env(&base));
2624
2625    // Materialized WITH .env → not the warning shape.
2626    std::fs::write(base.join(".env"), "K=V\n").unwrap();
2627    assert!(!materialized_user_missing_env(&base));
2628
2629    let _ = std::fs::remove_dir_all(&base);
2630}
2631
2632#[test]
2633fn loader_freshness_decides_all_four_states() {
2634    let now = chrono::Utc::now();
2635    let fp = 0xABCD_u64;
2636    let young = now - chrono::Duration::seconds(60);
2637    let old = now - chrono::Duration::seconds(601);
2638
2639    // Fresh: same fingerprint, young entry.
2640    assert_eq!(
2641        loader_freshness(fp, young, None, None, fp, now),
2642        LoaderFreshness::Fresh
2643    );
2644    // ConfigChanged: fingerprint differs wins over age.
2645    assert_eq!(
2646        loader_freshness(fp, old, None, None, 0x1234, now),
2647        LoaderFreshness::ConfigChanged
2648    );
2649    // AgedOut: same fingerprint past the max age — the 42131deb guard.
2650    assert_eq!(
2651        loader_freshness(fp, old, None, None, fp, now),
2652        LoaderFreshness::AgedOut
2653    );
2654    // Backoff: degraded inside the retry window, even old + unchanged.
2655    let failed = now - chrono::Duration::seconds(10);
2656    match loader_freshness(fp, old, Some("boom"), Some(failed), fp, now) {
2657        LoaderFreshness::Backoff(remaining) => {
2658            assert!(remaining.as_secs() >= 15, "~20s left: {remaining:?}");
2659        }
2660        other => panic!("expected Backoff, got {other:?}"),
2661    }
2662    // Degraded but PAST backoff → age rules apply (rebuild proceeds).
2663    let failed_long = now - chrono::Duration::seconds(120);
2664    assert_eq!(
2665        loader_freshness(fp, old, Some("boom"), Some(failed_long), fp, now),
2666        LoaderFreshness::AgedOut
2667    );
2668}