Skip to main content

trustee_api/
state.rs

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