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