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