Skip to main content

trustee_api/
xagent.rs

1//! 16F: per-agent dispatch surface — `/xagent/{name}/api/v1/...`
2//!
3//! Closes the THQ dispatch gap: torpi's proxy forwards
4//! `/thq/api/agents/{id}/...` to `{endpoint}/api/v1/...` with the CALLER's
5//! Bearer injected, so until now THQ-dispatched sessions ran under the
6//! CALLER's identity (the owner), not the agent's. This module exposes the
7//! same handler surface under a per-agent path prefix and re-keys every
8//! request to the agent-user the THQ entry represents.
9//!
10//! Mechanism (impersonation by Bearer swap — zero handler duplication):
11//! 1. `{name}` resolves through the boot-time dispatch table
12//!    (`ServerState.thq_dispatch`, populated by 16E discovery) to the
13//!    agent-user's stable key (= its Kanidm `sub`, per the 16E pin).
14//! 2. The OUTER caller is gate-checked: human THQ roles (admin/user/
15//!    developer — v0.23.10, issue eb385e4f; was human admin only)
16//!    ([`crate::auth::check_dispatch_admin`]). Agents can never dispatch
17//!    agents; open mode follows the same open posture as `check_auth`.
18//! 3. The agent's service token (captured from its per-user `.env` at boot)
19//!    is exchanged for a short-lived `role=agent` access token (RFC 8693,
20//!    expiry-buffered cache). Open mode passes through unauthenticated,
21//!    matching `check_auth`'s open posture.
22//! 4. The inner request is rebuilt with `Authorization: Bearer <agent>` and
23//!    the caller's cookie STRIPPED, then the STANDARD handler runs: `check_auth`
24//!    authenticates the AGENT, Cedar applies the per-action agent matrix
25//!    (working set minus DeleteSession), `user_key` resolves to the agent's
26//!    sub, and session bucket / per-user home / MCP loader all resolve to the
27//!    agent's own namespace — her own fame, never the caller's tools.
28//!
29//! THQ-side wiring: set each agent-user's `[thq].advertise_url` to
30//! `https://<host>:<port>/xagent/<agent_name>` — torpi appends
31//! `/api/v1/...` to the advertised origin, landing here.
32
33use axum::extract::ws::WebSocketUpgrade;
34use axum::extract::{Path, State};
35use axum::http::{header, HeaderMap, StatusCode};
36use axum::response::{IntoResponse, Response};
37use axum::Json;
38
39use crate::routes;
40use crate::state::{ServerState, ThqDispatchEntry};
41
42/// Parse the REAL `exp` claim (epoch seconds) from a JWT's payload —
43/// base64url-decode the middle segment, read `exp`. The `expires_in`
44/// estimate is trusted only when the payload carries no parsable `exp`
45/// (the estimate class: 199c4801; pep 849e7528 lineage).
46fn jwt_real_exp(token: &str) -> Option<u64> {
47    use base64::Engine as _;
48    let payload = token.split('.').nth(1)?;
49    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
50        .decode(payload)
51        .ok()
52        .or_else(|| {
53            base64::engine::general_purpose::URL_SAFE
54                .decode(payload)
55                .ok()
56        })?;
57    let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
58    v.get("exp")?.as_u64()
59}
60
61/// Fixed per-process refresh jitter in [0,15]s — drawn ONCE per process
62/// (no rand dep), applied consistently to every freshness check: fleet
63/// mint phases desynchronize while each process's horizon is stable (the
64/// pep 0.5.15 fleet-jitter pattern).
65fn refresh_jitter() -> std::time::Duration {
66    static JITTER: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
67    let secs = *JITTER.get_or_init(|| {
68        std::time::SystemTime::now()
69            .duration_since(std::time::UNIX_EPOCH)
70            .map(|d| d.as_nanos() as u64)
71            .unwrap_or(0)
72            % 16
73    });
74    std::time::Duration::from_secs(secs)
75}
76
77/// Proactive-refresh horizon: a cached dispatch Bearer is presented only
78/// while it has MORE than this much life left; inside the horizon the next
79/// dispatch re-exchanges BEFORE the expiry edge (elimination spec
80/// fb82b866 response c35042bb leg L1 shape: T-120s ±15s jitter on the
81/// real exp, never the estimate).
82const DISPATCH_REFRESH_HORIZON: std::time::Duration = std::time::Duration::from_secs(120);
83
84/// Freshness gate: present the cached Bearer only while more than the
85/// proactive horizon (T-120s − per-process jitter) of life remains.
86fn within_refresh_horizon(
87    expires_at: std::time::Instant,
88    now: std::time::Instant,
89    jitter: std::time::Duration,
90) -> bool {
91    now + DISPATCH_REFRESH_HORIZON + jitter < expires_at
92}
93
94/// Exchange the agent's service token for a short-lived `role=agent`
95/// Bearer and cache it keyed on the REAL exp (payload-parsed; the
96/// `expires_in` estimate is only the unparsable-payload fallback, floored
97/// at 30s so a degenerate mint never caches a dead token).
98async fn mint_dispatch_token(
99    state: &ServerState,
100    auth: &crate::auth::AuthState,
101    entry: &ThqDispatchEntry,
102) -> Result<String, (StatusCode, String)> {
103    let Some(ref service_token) = entry.service_token else {
104        return Err((
105            StatusCode::BAD_GATEWAY,
106            "agent has no service token provisioned (per-user .env) — cannot impersonate"
107                .to_string(),
108        ));
109    };
110    // Kanidm accepts a token exchange only on the origin the token was
111    // minted for: use the issuer captured from the agent's OWN overlay
112    // credential (16F — verified: same token, 200 on its vhost, 400 on the
113    // auth issuer's vhost). Falls back to the auth issuer only if the
114    // overlay declares none.
115    let issuer = entry
116        .issuer_url
117        .clone()
118        .unwrap_or_else(|| auth.config.issuer_url.clone());
119    let (token, expires_in) = auth
120        .exchange_agent_token(&issuer, service_token)
121        .await
122        .map_err(|s| (s, "agent token exchange failed".to_string()))?;
123    let now_epoch = std::time::SystemTime::now()
124        .duration_since(std::time::UNIX_EPOCH)
125        .map(|d| d.as_secs())
126        .unwrap_or(0);
127    let life = jwt_real_exp(&token)
128        .map(|exp| exp.saturating_sub(now_epoch))
129        .unwrap_or(expires_in);
130    let ttl = std::time::Duration::from_secs(life).max(std::time::Duration::from_secs(30));
131    state.agent_dispatch_tokens.insert(
132        entry.user_key.clone(),
133        (token.clone(), std::time::Instant::now() + ttl),
134    );
135    Ok(token)
136}
137
138/// Resolve the dispatch target and rebuild the inner request headers with
139/// the AGENT's Bearer (cookie stripped). Shared prelude of every wrapper.
140async fn dispatch_context(
141    state: &ServerState,
142    agent: &str,
143    headers: &HeaderMap,
144) -> Result<(HeaderMap, ThqDispatchEntry), (StatusCode, String)> {
145    dispatch_context_inner(state, agent, headers, false).await
146}
147
148async fn dispatch_context_inner(
149    state: &ServerState,
150    agent: &str,
151    headers: &HeaderMap,
152    force_refresh: bool,
153) -> Result<(HeaderMap, ThqDispatchEntry), (StatusCode, String)> {
154    let Some(entry) = state.thq_dispatch.get(agent).map(|e| e.clone()) else {
155        return Err((StatusCode::NOT_FOUND, format!("unknown agent: {agent}")));
156    };
157    if entry.user_key.is_empty() {
158        return Err((
159            StatusCode::NOT_FOUND,
160            format!("agent {agent} has no owner_id — not dispatchable"),
161        ));
162    }
163
164    // Outer gate: human admin (open mode allowed, same posture as check_auth).
165    crate::auth::check_dispatch_admin(&state.auth, headers)
166        .await
167        .map_err(|s| {
168            (
169                s,
170                "xagent dispatch requires an admin Bearer token".to_string(),
171            )
172        })?;
173
174    // Inner identity: the agent's own short-lived Bearer.
175    let mut inner = headers.clone();
176
177    let Some(auth) = state.auth.as_ref() else {
178        // Open mode: no IdP to mint from — run the inner call unauthenticated,
179        // which check_auth resolves as the open-mode "default" user.
180        inner.remove(header::AUTHORIZATION);
181        inner.remove(header::COOKIE);
182        return Ok((inner, entry.clone()));
183    };
184
185    if entry.service_token.is_none() {
186        return Err((
187            StatusCode::BAD_GATEWAY,
188            format!(
189                "agent {agent} has no service token provisioned (per-user .env) — cannot impersonate"
190            ),
191        ));
192    }
193
194    if force_refresh {
195        // The 401 retry's fresh exchange must not race the stale entry.
196        state.agent_dispatch_tokens.remove(&entry.user_key);
197    } else if let Some(kv) = state.agent_dispatch_tokens.get(&entry.user_key) {
198        let (tok, exp) = kv.value();
199        // Proactive refresh: present the cached Bearer only while it has
200        // more than T-120s (± per-process jitter) of REAL life left; inside
201        // the horizon the next dispatch re-exchanges before the edge.
202        if within_refresh_horizon(*exp, std::time::Instant::now(), refresh_jitter()) {
203            inner.insert(
204                header::AUTHORIZATION,
205                format!("Bearer {tok}").parse().map_err(|_| {
206                    (
207                        StatusCode::INTERNAL_SERVER_ERROR,
208                        "header build".to_string(),
209                    )
210                })?,
211            );
212            inner.remove(header::COOKIE);
213            return Ok((inner, entry.clone()));
214        }
215    }
216
217    let token = mint_dispatch_token(state, auth, &entry).await?;
218    inner.insert(
219        header::AUTHORIZATION,
220        format!("Bearer {token}").parse().map_err(|_| {
221            (
222                StatusCode::INTERNAL_SERVER_ERROR,
223                "header build".to_string(),
224            )
225        })?,
226    );
227    inner.remove(header::COOKIE);
228    Ok((inner, entry))
229}
230
231/// Run a dispatch wrapper with the budget-of-one 401 retry (elimination
232/// spec fb82b866 response c35042bb leg L2; abk `send_with_retry`
233/// semantics): a 401 on the inner call means the presented Bearer was
234/// rejected mid-TTL (the kanidm orphan-sweep class) — invalidate the
235/// cached dispatch token, re-exchange fresh, and re-run the handler
236/// EXACTLY once. A persistent 401 surfaces as-is (no loop). Open mode
237/// presents no credential and is single-shot; WS upgrades (the
238/// [`WebSocketUpgrade`] extractor is not clonable) rely on the proactive
239/// refresh plus the client's own reconnect.
240async fn run_dispatch<F, Fut>(
241    state: &ServerState,
242    agent: &str,
243    headers: &HeaderMap,
244    run: F,
245) -> Result<Response, (StatusCode, String)>
246where
247    F: Fn(HeaderMap) -> Fut,
248    Fut: std::future::Future<Output = Result<Response, (StatusCode, String)>>,
249{
250    let (inner, entry) = dispatch_context(state, agent, headers).await?;
251    let resp = crate::state::in_dispatch_scope(&entry.user_key, run(inner)).await?;
252    if resp.status() != StatusCode::UNAUTHORIZED || state.auth.is_none() {
253        return Ok(resp);
254    }
255    tracing::warn!(
256        "xagent dispatch: 401 on the inner call for agent '{agent}' — cached Bearer rejected mid-TTL; re-exchanging fresh and retrying once"
257    );
258    let (inner2, entry2) = dispatch_context_inner(state, agent, headers, true).await?;
259    crate::state::in_dispatch_scope(&entry2.user_key, run(inner2)).await
260}
261
262/// THQ polls `{advertise_url}/api/v1/health` for liveness — resolve the agent
263/// (unknown → 404 → THQ marks it offline) then answer with the shared health.
264pub async fn x_health(State(state): State<ServerState>, Path(agent): Path<String>) -> Response {
265    if !state.thq_dispatch.contains_key(&agent) {
266        return (StatusCode::NOT_FOUND, format!("unknown agent: {agent}")).into_response();
267    }
268    routes::health().await.into_response()
269}
270
271pub async fn x_list_sessions(
272    State(state): State<ServerState>,
273    Path(agent): Path<String>,
274    headers: HeaderMap,
275) -> Result<Response, (StatusCode, String)> {
276    run_dispatch(&state, &agent, &headers, |inner| {
277        let state = state.clone();
278        async move { routes::list_sessions(State(state), inner).await }
279    })
280    .await
281}
282
283pub async fn x_create_session(
284    State(state): State<ServerState>,
285    Path(agent): Path<String>,
286    headers: HeaderMap,
287    Json(req): Json<routes::CreateSessionRequest>,
288) -> Result<Response, (StatusCode, String)> {
289    // Identity is NOT injected here: an dispatched session's persona comes
290    // from the agent's OWN overlay config ([lifecycle].system_template in
291    // her ~/.trustee/users/<hash>/config/trustee.toml — allowlisted since
292    // the xagent-persona change), falling back to the shared config default.
293    // An explicit identity in the create body still wins (caller override).
294    run_dispatch(&state, &agent, &headers, |inner| {
295        let state = state.clone();
296        let req = req.clone();
297        async move { routes::create_session(State(state), inner, Json(req)).await }
298    })
299    .await
300}
301
302pub async fn x_list_live_sessions(
303    State(state): State<ServerState>,
304    Path(agent): Path<String>,
305    headers: HeaderMap,
306) -> Result<Response, (StatusCode, String)> {
307    run_dispatch(&state, &agent, &headers, |inner| {
308        let state = state.clone();
309        async move { routes::list_live_sessions(State(state), inner).await }
310    })
311    .await
312}
313
314pub async fn x_get_session_detail(
315    State(state): State<ServerState>,
316    Path((agent, session_id)): Path<(String, String)>,
317    headers: HeaderMap,
318) -> Result<Response, (StatusCode, String)> {
319    run_dispatch(&state, &agent, &headers, |inner| {
320        let state = state.clone();
321        let session_id = session_id.clone();
322        async move { routes::get_session_detail(State(state), Path(session_id), inner).await }
323    })
324    .await
325}
326
327pub async fn x_destroy_session(
328    State(state): State<ServerState>,
329    Path((agent, session_id)): Path<(String, String)>,
330    headers: HeaderMap,
331) -> Result<Response, (StatusCode, String)> {
332    run_dispatch(&state, &agent, &headers, |inner| {
333        let state = state.clone();
334        let session_id = session_id.clone();
335        async move { routes::destroy_session(State(state), inner, Path(session_id)).await }
336    })
337    .await
338}
339
340pub async fn x_get_live_session(
341    State(state): State<ServerState>,
342    Path((agent, session_id)): Path<(String, String)>,
343    headers: HeaderMap,
344) -> Result<Response, (StatusCode, String)> {
345    run_dispatch(&state, &agent, &headers, |inner| {
346        let state = state.clone();
347        let session_id = session_id.clone();
348        async move { routes::get_live_session(State(state), inner, Path(session_id)).await }
349    })
350    .await
351}
352
353pub async fn x_resume_session(
354    State(state): State<ServerState>,
355    Path((agent, checkpoint_session_id)): Path<(String, String)>,
356    headers: HeaderMap,
357    body: Option<Json<routes::ResumeRequestBody>>,
358) -> Result<Response, (StatusCode, String)> {
359    run_dispatch(&state, &agent, &headers, |inner| {
360        let state = state.clone();
361        let checkpoint_session_id = checkpoint_session_id.clone();
362        let body = body.clone();
363        async move {
364            routes::resume_session(State(state), Path(checkpoint_session_id), inner, body).await
365        }
366    })
367    .await
368}
369
370/// POST /xagent/{agent}/api/v1/sessions/{id}/fork — explicit chain fork
371/// (the owner's feature; NEVER a side effect of resume).
372pub async fn x_fork_session(
373    State(state): State<ServerState>,
374    Path((agent, source_session_id)): Path<(String, String)>,
375    headers: HeaderMap,
376) -> Result<Response, (StatusCode, String)> {
377    run_dispatch(&state, &agent, &headers, |inner| {
378        let state = state.clone();
379        let source_session_id = source_session_id.clone();
380        async move { routes::fork_session(State(state), Path(source_session_id), inner).await }
381    })
382    .await
383}
384
385pub async fn x_get_session_history(
386    State(state): State<ServerState>,
387    Path((agent, session_id)): Path<(String, String)>,
388    headers: HeaderMap,
389) -> Result<Response, (StatusCode, String)> {
390    run_dispatch(&state, &agent, &headers, |inner| {
391        let state = state.clone();
392        let session_id = session_id.clone();
393        async move { routes::get_session_history(State(state), Path(session_id), inner).await }
394    })
395    .await
396}
397
398pub async fn x_post_command_session(
399    State(state): State<ServerState>,
400    Path((agent, session_id)): Path<(String, String)>,
401    headers: HeaderMap,
402    Json(req): Json<routes::CommandRequest>,
403) -> Result<Response, (StatusCode, String)> {
404    run_dispatch(&state, &agent, &headers, |inner| {
405        let state = state.clone();
406        let session_id = session_id.clone();
407        let req = req.clone();
408        async move {
409            routes::post_command_session(State(state), inner, Path(session_id), Json(req)).await
410        }
411    })
412    .await
413}
414
415pub async fn x_post_cancel_session(
416    State(state): State<ServerState>,
417    Path((agent, session_id)): Path<(String, String)>,
418    headers: HeaderMap,
419) -> Result<Response, (StatusCode, String)> {
420    run_dispatch(&state, &agent, &headers, |inner| {
421        let state = state.clone();
422        let session_id = session_id.clone();
423        async move { routes::post_cancel_session(State(state), inner, Path(session_id)).await }
424    })
425    .await
426}
427
428pub async fn x_post_handoff_session(
429    State(state): State<ServerState>,
430    Path((agent, session_id)): Path<(String, String)>,
431    headers: HeaderMap,
432) -> Result<Response, (StatusCode, String)> {
433    run_dispatch(&state, &agent, &headers, |inner| {
434        let state = state.clone();
435        let session_id = session_id.clone();
436        async move { routes::post_handoff_session(State(state), inner, Path(session_id)).await }
437    })
438    .await
439}
440
441pub async fn x_ws_session_handler(
442    ws: WebSocketUpgrade,
443    State(state): State<ServerState>,
444    Path((agent, session_id)): Path<(String, String)>,
445    headers: HeaderMap,
446) -> Result<Response, StatusCode> {
447    // Single-shot by nature: the WebSocketUpgrade extractor is not clonable,
448    // so the budget-of-one 401 retry cannot re-run this wrapper — the
449    // proactive refresh (T-120s ± jitter on the real exp) keeps the
450    // presented Bearer away from the expiry edge, and the console client's
451    // own reconnect covers a mid-TTL revocation.
452    let (inner, entry) = dispatch_context(&state, &agent, &headers)
453        .await
454        .map_err(|(s, _)| s)?;
455    crate::state::in_dispatch_scope(
456        &entry.user_key,
457        routes::ws_session_handler(ws, State(state), inner, Path(session_id)),
458    )
459    .await
460}
461
462pub async fn x_list_models(
463    State(state): State<ServerState>,
464    Path(agent): Path<String>,
465    headers: HeaderMap,
466) -> Result<Response, (StatusCode, String)> {
467    run_dispatch(&state, &agent, &headers, |inner| {
468        let state = state.clone();
469        async move { routes::list_models(State(state), inner).await }
470    })
471    .await
472}
473
474/// v0.19.8: impersonated MCP credential status — the same handler the
475/// trustee web serves at /auth/mcp/status, resolved in the agent's dispatch
476/// scope (per-credential connected/warn/fail dots for the thq console).
477pub async fn x_mcp_status(
478    State(state): State<ServerState>,
479    Path(agent): Path<String>,
480    headers: HeaderMap,
481) -> Result<axum::response::Response, (StatusCode, String)> {
482    run_dispatch(&state, &agent, &headers, |inner| {
483        let state = state.clone();
484        async move { Ok(crate::auth::mcp_status_handler(State(state), inner).await) }
485    })
486    .await
487}
488
489/// The `/xagent/{agent}/api/v1` route tree — merged into the main router.
490pub fn router() -> axum::Router<ServerState> {
491    use axum::routing::{get, post};
492    axum::Router::new()
493        .route("/xagent/{agent}/api/v1/health", get(x_health))
494        .route(
495            "/xagent/{agent}/api/v1/sessions",
496            get(x_list_sessions).post(x_create_session),
497        )
498        .route(
499            "/xagent/{agent}/api/v1/sessions/live",
500            get(x_list_live_sessions),
501        )
502        .route(
503            "/xagent/{agent}/api/v1/sessions/{id}",
504            get(x_get_session_detail).delete(x_destroy_session),
505        )
506        .route(
507            "/xagent/{agent}/api/v1/sessions/{id}/live",
508            get(x_get_live_session),
509        )
510        .route(
511            "/xagent/{agent}/api/v1/sessions/{id}/resume",
512            post(x_resume_session),
513        )
514        .route(
515            "/xagent/{agent}/api/v1/sessions/{id}/fork",
516            post(x_fork_session),
517        )
518        .route(
519            "/xagent/{agent}/api/v1/sessions/{id}/history",
520            get(x_get_session_history),
521        )
522        .route(
523            "/xagent/{agent}/api/v1/sessions/{id}/command",
524            post(x_post_command_session).layer(axum::extract::DefaultBodyLimit::max(
525                routes::ATTACH_BODY_LIMIT,
526            )),
527        )
528        .route(
529            "/xagent/{agent}/api/v1/sessions/{id}/cancel",
530            post(x_post_cancel_session),
531        )
532        .route(
533            "/xagent/{agent}/api/v1/sessions/{id}/handoff",
534            post(x_post_handoff_session),
535        )
536        .route(
537            "/xagent/{agent}/api/v1/sessions/{id}/stream",
538            get(x_ws_session_handler),
539        )
540        .route("/xagent/{agent}/api/v1/models", get(x_list_models))
541        // v0.19.8: the thq console's MCP Connections overlay relays
542        // /api/v1/console/agents/{agent}/mcp/status → THIS route. Without it
543        // the overlay 404s into a misleading "No MCP credentials" message.
544        .route("/xagent/{agent}/api/v1/mcp/status", get(x_mcp_status))
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::state::ThqDispatchEntry;
551    use std::collections::HashMap;
552
553    fn open_state() -> ServerState {
554        let (session, _rx) = trustee_core::session::Session::new();
555        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
556        ServerState::new(session, ws_tx, None)
557    }
558
559    fn hdrs(pairs: &[(&str, &str)]) -> HeaderMap {
560        let mut h = HeaderMap::new();
561        for (k, v) in pairs {
562            h.insert(
563                header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
564                header::HeaderValue::from_str(v).unwrap(),
565            );
566        }
567        h
568    }
569
570    #[test]
571    fn create_without_identity_stays_config_driven() {
572        // Contract: the xagent create wrapper does NOT synthesize an
573        // identity — the agent's own overlay [lifecycle].system_template
574        // flows through the standard config path (verified in state.rs
575        // lifecycle_overlay tests). This test only pins the request shape.
576        let req: routes::CreateSessionRequest =
577            serde_json::from_str(r#"{"session_name": "probe"}"#).unwrap();
578        assert!(
579            req.identity.is_none(),
580            "wrapper must not invent identities — persona is config-owned"
581        );
582    }
583
584    #[tokio::test]
585    async fn dispatch_unknown_agent_is_404() {
586        let state = open_state();
587        let err = dispatch_context(&state, "nobody", &hdrs(&[]))
588            .await
589            .unwrap_err();
590        assert_eq!(err.0, StatusCode::NOT_FOUND);
591        assert!(err.1.contains("unknown agent"));
592    }
593
594    #[tokio::test]
595    async fn dispatch_entry_without_owner_id_is_not_dispatchable() {
596        let state = open_state();
597        state.thq_dispatch.insert(
598            "ghost".to_string(),
599            ThqDispatchEntry {
600                user_key: String::new(),
601                service_token: None,
602                issuer_url: None,
603            },
604        );
605        let err = dispatch_context(&state, "ghost", &hdrs(&[]))
606            .await
607            .unwrap_err();
608        assert_eq!(err.0, StatusCode::NOT_FOUND);
609        assert!(err.1.contains("not dispatchable"));
610    }
611
612    #[tokio::test]
613    async fn open_mode_dispatch_strips_auth_and_cookie() {
614        // Open mode (auth=None): the inner request must carry NO caller
615        // credentials — the inner check_auth resolves the open "default" user.
616        let state = open_state();
617        state.thq_dispatch.insert(
618            "saman".to_string(),
619            ThqDispatchEntry {
620                user_key: "f27de518-a647-4ea2-85ec-8ecc4d61e658".to_string(),
621                service_token: None,
622                issuer_url: Some("https://idp.tanbal.ir/oauth2/openid/pdt-api".to_string()),
623            },
624        );
625        let (inner, _entry) = dispatch_context(
626            &state,
627            "saman",
628            &hdrs(&[
629                ("Authorization", "Bearer caller-jwt"),
630                ("Cookie", "trustee_token=owner-session"),
631            ]),
632        )
633        .await
634        .unwrap();
635        assert!(
636            inner.get(header::AUTHORIZATION).is_none(),
637            "caller Bearer stripped"
638        );
639        assert!(
640            inner.get(header::COOKIE).is_none(),
641            "caller cookie stripped"
642        );
643    }
644
645    #[tokio::test]
646    async fn dispatch_table_missing_service_token_still_resolves_context_in_open_mode() {
647        // Open mode never mints (no IdP) — a None service_token is fine there.
648        let state = open_state();
649        state.thq_dispatch.insert(
650            "ravand".to_string(),
651            ThqDispatchEntry {
652                user_key: "1a71c077-b3b3-4581-b605-925c3f276f30".to_string(),
653                service_token: None,
654                issuer_url: None,
655            },
656        );
657        let (inner, _entry) = dispatch_context(&state, "ravand", &hdrs(&[]))
658            .await
659            .unwrap();
660        assert!(inner.get(header::AUTHORIZATION).is_none());
661    }
662
663    // ── admin decision core (no IdP needed) ─────────────────────────────
664
665    #[test]
666    fn dispatch_allowed_for_human_thq_roles() {
667        use crate::auth::{dispatch_allowed, PrincipalKind};
668        assert!(dispatch_allowed(PrincipalKind::Human, Some("admin")));
669        // v0.23.10 (issue eb385e4f): user/developer humans dispatch — the
670        // THQ console is the owner-scoped plane; the old human-admin-only
671        // gate contradicted this repo's own Cedar ("user — full session
672        // management").
673        assert!(
674            dispatch_allowed(PrincipalKind::Human, Some("user")),
675            "user-role humans dispatch (the console's owner-scoped plane)"
676        );
677        assert!(dispatch_allowed(PrincipalKind::Human, Some("developer")));
678        assert!(
679            !dispatch_allowed(PrincipalKind::Agent, Some("admin")),
680            "agents never dispatch agents"
681        );
682        assert!(
683            !dispatch_allowed(PrincipalKind::Human, Some("service")),
684            "service is read-only by design"
685        );
686        assert!(
687            !dispatch_allowed(PrincipalKind::Human, None),
688            "roleless principals are default-denied (torpi parity)"
689        );
690        assert!(
691            !dispatch_allowed(PrincipalKind::Human, Some("Admin")),
692            "case-sensitive"
693        );
694    }
695
696    #[test]
697    fn secrets_map_is_unused_but_type_stable() {
698        // Guards the merge-secrets typing used by ServerState::with_secrets —
699        // xagent must never need per-user secrets for impersonation (the
700        // service token travels in the dispatch entry, not the secrets map).
701        let _m: HashMap<String, String> = HashMap::new();
702    }
703
704    // ── credential-layer hardening (199c4801/9618b41c; spec c35042bb) ────
705
706    #[test]
707    fn jwt_real_exp_parses_the_payload_exp() {
708        use base64::Engine as _;
709        let payload = serde_json::json!({ "exp": 1_234_567_890_u64 }).to_string();
710        let token = format!(
711            "hdr.{}.sig",
712            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload)
713        );
714        assert_eq!(jwt_real_exp(&token), Some(1_234_567_890));
715    }
716
717    #[test]
718    fn jwt_real_exp_returns_none_for_unparsable_payloads() {
719        assert_eq!(jwt_real_exp("not-a-jwt"), None);
720        assert_eq!(jwt_real_exp("hdr.!!!not-base64!!!.sig"), None);
721        use base64::Engine as _;
722        let no_exp = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"sub":"x"}"#);
723        assert_eq!(jwt_real_exp(&format!("hdr.{no_exp}.sig")), None);
724    }
725
726    #[test]
727    fn refresh_jitter_is_stable_per_process_and_bounded() {
728        let a = refresh_jitter();
729        let b = refresh_jitter();
730        assert_eq!(a, b, "one draw per process, applied consistently");
731        assert!(a <= std::time::Duration::from_secs(15));
732    }
733
734    #[test]
735    fn within_refresh_horizon_gates_proactive_refresh() {
736        let now = std::time::Instant::now();
737        // 200s of real life: outside the T-120s horizon — present the cache.
738        assert!(within_refresh_horizon(
739            now + std::time::Duration::from_secs(200),
740            now,
741            std::time::Duration::from_secs(0)
742        ));
743        // 100s of real life: inside the horizon — re-exchange proactively.
744        assert!(!within_refresh_horizon(
745            now + std::time::Duration::from_secs(100),
746            now,
747            std::time::Duration::from_secs(0)
748        ));
749        // The jitter can only move the horizon EARLIER (never past T-120s).
750        assert!(within_refresh_horizon(
751            now + std::time::Duration::from_secs(200),
752            now,
753            std::time::Duration::from_secs(15)
754        ));
755        // 130s of life with a 15s jitter is inside the horizon (130 < 135).
756        assert!(!within_refresh_horizon(
757            now + std::time::Duration::from_secs(130),
758            now,
759            std::time::Duration::from_secs(15)
760        ));
761    }
762
763    #[tokio::test]
764    async fn run_dispatch_open_mode_401_is_single_shot() {
765        // Open mode presents no credential — a 401 from the inner handler is
766        // never a token problem, so the budget-of-one retry must NOT fire.
767        let state = open_state();
768        state.thq_dispatch.insert(
769            "saman".to_string(),
770            ThqDispatchEntry {
771                user_key: "f27de518-a647-4ea2-85ec-8ecc4d61e658".to_string(),
772                service_token: None,
773                issuer_url: None,
774            },
775        );
776        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
777        let counter = calls.clone();
778        let resp = run_dispatch(&state, "saman", &hdrs(&[]), move |inner| {
779            let counter = counter.clone();
780            async move {
781                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
782                let _ = inner;
783                Ok::<_, (StatusCode, String)>((StatusCode::UNAUTHORIZED, "denied").into_response())
784            }
785        })
786        .await
787        .unwrap();
788        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
789        assert_eq!(
790            calls.load(std::sync::atomic::Ordering::SeqCst),
791            1,
792            "open mode is single-shot — no credential, no retry"
793        );
794    }
795}