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 admin only
15//!    ([`crate::auth::check_dispatch_admin`]). Agents can never dispatch
16//!    agents; open mode follows the same open posture as `check_auth`.
17//! 3. The agent's service token (captured from its per-user `.env` at boot)
18//!    is exchanged for a short-lived `role=agent` access token (RFC 8693,
19//!    expiry-buffered cache). Open mode passes through unauthenticated,
20//!    matching `check_auth`'s open posture.
21//! 4. The inner request is rebuilt with `Authorization: Bearer <agent>` and
22//!    the caller's cookie STRIPPED, then the STANDARD handler runs: `check_auth`
23//!    authenticates the AGENT, Cedar applies the per-action agent matrix
24//!    (working set minus DeleteSession), `user_key` resolves to the agent's
25//!    sub, and session bucket / per-user home / MCP loader all resolve to the
26//!    agent's own namespace — her own fame, never the caller's tools.
27//!
28//! THQ-side wiring: set each agent-user's `[thq].advertise_url` to
29//! `https://<host>:<port>/xagent/<agent_name>` — torpi appends
30//! `/api/v1/...` to the advertised origin, landing here.
31
32use axum::extract::ws::WebSocketUpgrade;
33use axum::extract::{Path, State};
34use axum::http::{header, HeaderMap, StatusCode};
35use axum::response::{IntoResponse, Response};
36use axum::Json;
37
38use crate::routes;
39use crate::state::{ServerState, ThqDispatchEntry};
40
41/// Resolve the dispatch target and rebuild the inner request headers with
42/// the AGENT's Bearer (cookie stripped). Shared prelude of every wrapper.
43async fn dispatch_context(
44    state: &ServerState,
45    agent: &str,
46    headers: &HeaderMap,
47) -> Result<(HeaderMap, ThqDispatchEntry), (StatusCode, String)> {
48    let Some(entry) = state.thq_dispatch.get(agent).map(|e| e.clone()) else {
49        return Err((StatusCode::NOT_FOUND, format!("unknown agent: {agent}")));
50    };
51    if entry.user_key.is_empty() {
52        return Err((
53            StatusCode::NOT_FOUND,
54            format!("agent {agent} has no owner_id — not dispatchable"),
55        ));
56    }
57
58    // Outer gate: human admin (open mode allowed, same posture as check_auth).
59    crate::auth::check_dispatch_admin(&state.auth, headers)
60        .await
61        .map_err(|s| {
62            (
63                s,
64                "xagent dispatch requires an admin Bearer token".to_string(),
65            )
66        })?;
67
68    // Inner identity: the agent's own short-lived Bearer.
69    let mut inner = headers.clone();
70
71    let Some(auth) = state.auth.as_ref() else {
72        // Open mode: no IdP to mint from — run the inner call unauthenticated,
73        // which check_auth resolves as the open-mode "default" user.
74        inner.remove(header::AUTHORIZATION);
75        inner.remove(header::COOKIE);
76        return Ok((inner, entry.clone()));
77    };
78
79    let Some(ref service_token) = entry.service_token else {
80        return Err((
81            StatusCode::BAD_GATEWAY,
82            format!(
83                "agent {agent} has no service token provisioned (per-user .env) — cannot impersonate"
84            ),
85        ));
86    };
87
88    // Cache: (token, expires_at) with a 60s safety buffer (pep 0.5.6 lesson).
89    if let Some(kv) = state.agent_dispatch_tokens.get(&entry.user_key) {
90        let (tok, exp) = kv.value();
91        if std::time::Instant::now() < *exp {
92            inner.insert(
93                header::AUTHORIZATION,
94                format!("Bearer {tok}").parse().map_err(|_| {
95                    (
96                        StatusCode::INTERNAL_SERVER_ERROR,
97                        "header build".to_string(),
98                    )
99                })?,
100            );
101            inner.remove(header::COOKIE);
102            return Ok((inner, entry.clone()));
103        }
104    }
105
106    // Kanidm accepts a token exchange only on the origin the token was
107    // minted for: use the issuer captured from the agent's OWN overlay
108    // credential (16F — verified: same token, 200 on its vhost, 400 on the
109    // auth issuer's vhost). Falls back to the auth issuer only if the
110    // overlay declares none.
111    let issuer = entry
112        .issuer_url
113        .clone()
114        .unwrap_or_else(|| auth.config.issuer_url.clone());
115    let (token, expires_in) = auth
116        .exchange_agent_token(&issuer, service_token)
117        .await
118        .map_err(|s| (s, "agent token exchange failed".to_string()))?;
119    let buffered = std::time::Duration::from_secs(expires_in.saturating_sub(60))
120        .max(std::time::Duration::from_secs(30));
121    state.agent_dispatch_tokens.insert(
122        entry.user_key.clone(),
123        (token.clone(), std::time::Instant::now() + buffered),
124    );
125
126    inner.insert(
127        header::AUTHORIZATION,
128        format!("Bearer {token}").parse().map_err(|_| {
129            (
130                StatusCode::INTERNAL_SERVER_ERROR,
131                "header build".to_string(),
132            )
133        })?,
134    );
135    inner.remove(header::COOKIE);
136    Ok((inner, entry))
137}
138
139/// THQ polls `{advertise_url}/api/v1/health` for liveness — resolve the agent
140/// (unknown → 404 → THQ marks it offline) then answer with the shared health.
141pub async fn x_health(State(state): State<ServerState>, Path(agent): Path<String>) -> Response {
142    if !state.thq_dispatch.contains_key(&agent) {
143        return (StatusCode::NOT_FOUND, format!("unknown agent: {agent}")).into_response();
144    }
145    routes::health().await.into_response()
146}
147
148pub async fn x_list_sessions(
149    State(state): State<ServerState>,
150    Path(agent): Path<String>,
151    headers: HeaderMap,
152) -> Result<Response, (StatusCode, String)> {
153    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
154    crate::state::in_dispatch_scope(&entry.user_key, routes::list_sessions(State(state), inner))
155        .await
156}
157
158pub async fn x_create_session(
159    State(state): State<ServerState>,
160    Path(agent): Path<String>,
161    headers: HeaderMap,
162    Json(mut req): Json<routes::CreateSessionRequest>,
163) -> Result<Response, (StatusCode, String)> {
164    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
165    // Identity is NOT injected here: an dispatched session's persona comes
166    // from the agent's OWN overlay config ([lifecycle].system_template in
167    // her ~/.trustee/users/<hash>/config/trustee.toml — allowlisted since
168    // the xagent-persona change), falling back to the shared config default.
169    // An explicit identity in the create body still wins (caller override).
170    crate::state::in_dispatch_scope(
171        &entry.user_key,
172        routes::create_session(State(state), inner, Json(req)),
173    )
174    .await
175}
176
177pub async fn x_list_live_sessions(
178    State(state): State<ServerState>,
179    Path(agent): Path<String>,
180    headers: HeaderMap,
181) -> Result<Response, (StatusCode, String)> {
182    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
183    crate::state::in_dispatch_scope(
184        &entry.user_key,
185        routes::list_live_sessions(State(state), inner),
186    )
187    .await
188}
189
190pub async fn x_get_session_detail(
191    State(state): State<ServerState>,
192    Path((agent, session_id)): Path<(String, String)>,
193    headers: HeaderMap,
194) -> Result<Response, (StatusCode, String)> {
195    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
196    crate::state::in_dispatch_scope(
197        &entry.user_key,
198        routes::get_session_detail(State(state), Path(session_id), inner),
199    )
200    .await
201}
202
203pub async fn x_destroy_session(
204    State(state): State<ServerState>,
205    Path((agent, session_id)): Path<(String, String)>,
206    headers: HeaderMap,
207) -> Result<Response, (StatusCode, String)> {
208    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
209    crate::state::in_dispatch_scope(
210        &entry.user_key,
211        routes::destroy_session(State(state), inner, Path(session_id)),
212    )
213    .await
214}
215
216pub async fn x_get_live_session(
217    State(state): State<ServerState>,
218    Path((agent, session_id)): Path<(String, String)>,
219    headers: HeaderMap,
220) -> Result<Response, (StatusCode, String)> {
221    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
222    crate::state::in_dispatch_scope(
223        &entry.user_key,
224        routes::get_live_session(State(state), inner, Path(session_id)),
225    )
226    .await
227}
228
229pub async fn x_resume_session(
230    State(state): State<ServerState>,
231    Path((agent, checkpoint_session_id)): Path<(String, String)>,
232    headers: HeaderMap,
233    body: Option<Json<routes::ResumeRequestBody>>,
234) -> Result<Response, (StatusCode, String)> {
235    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
236    crate::state::in_dispatch_scope(
237        &entry.user_key,
238        routes::resume_session(State(state), Path(checkpoint_session_id), inner, body),
239    )
240    .await
241}
242
243/// POST /xagent/{agent}/api/v1/sessions/{id}/fork — explicit chain fork
244/// (the owner's feature; NEVER a side effect of resume).
245pub async fn x_fork_session(
246    State(state): State<ServerState>,
247    Path((agent, source_session_id)): Path<(String, String)>,
248    headers: HeaderMap,
249) -> Result<Response, (StatusCode, String)> {
250    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
251    crate::state::in_dispatch_scope(
252        &entry.user_key,
253        routes::fork_session(State(state), Path(source_session_id), inner),
254    )
255    .await
256}
257
258pub async fn x_get_session_history(
259    State(state): State<ServerState>,
260    Path((agent, session_id)): Path<(String, String)>,
261    headers: HeaderMap,
262) -> Result<Response, (StatusCode, String)> {
263    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
264    crate::state::in_dispatch_scope(
265        &entry.user_key,
266        routes::get_session_history(State(state), Path(session_id), inner),
267    )
268    .await
269}
270
271pub async fn x_post_command_session(
272    State(state): State<ServerState>,
273    Path((agent, session_id)): Path<(String, String)>,
274    headers: HeaderMap,
275    Json(req): Json<routes::CommandRequest>,
276) -> Result<Response, (StatusCode, String)> {
277    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
278    crate::state::in_dispatch_scope(
279        &entry.user_key,
280        routes::post_command_session(State(state), inner, Path(session_id), Json(req)),
281    )
282    .await
283}
284
285pub async fn x_post_cancel_session(
286    State(state): State<ServerState>,
287    Path((agent, session_id)): Path<(String, String)>,
288    headers: HeaderMap,
289) -> Result<Response, (StatusCode, String)> {
290    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
291    crate::state::in_dispatch_scope(
292        &entry.user_key,
293        routes::post_cancel_session(State(state), inner, Path(session_id)),
294    )
295    .await
296}
297
298pub async fn x_post_handoff_session(
299    State(state): State<ServerState>,
300    Path((agent, session_id)): Path<(String, String)>,
301    headers: HeaderMap,
302) -> Result<Response, (StatusCode, String)> {
303    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
304    crate::state::in_dispatch_scope(
305        &entry.user_key,
306        routes::post_handoff_session(State(state), inner, Path(session_id)),
307    )
308    .await
309}
310
311pub async fn x_ws_session_handler(
312    ws: WebSocketUpgrade,
313    State(state): State<ServerState>,
314    Path((agent, session_id)): Path<(String, String)>,
315    headers: HeaderMap,
316) -> Result<Response, StatusCode> {
317    let (inner, entry) = dispatch_context(&state, &agent, &headers)
318        .await
319        .map_err(|(s, _)| s)?;
320    crate::state::in_dispatch_scope(
321        &entry.user_key,
322        routes::ws_session_handler(ws, State(state), inner, Path(session_id)),
323    )
324    .await
325}
326
327pub async fn x_list_models(
328    State(state): State<ServerState>,
329    Path(agent): Path<String>,
330    headers: HeaderMap,
331) -> Result<Response, (StatusCode, String)> {
332    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
333    crate::state::in_dispatch_scope(&entry.user_key, routes::list_models(State(state), inner)).await
334}
335
336/// v0.19.8: impersonated MCP credential status — the same handler the
337/// trustee web serves at /auth/mcp/status, resolved in the agent's dispatch
338/// scope (per-credential connected/warn/fail dots for the thq console).
339pub async fn x_mcp_status(
340    State(state): State<ServerState>,
341    Path(agent): Path<String>,
342    headers: HeaderMap,
343) -> Result<axum::response::Response, (StatusCode, String)> {
344    let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
345    Ok(crate::state::in_dispatch_scope(
346        &entry.user_key,
347        crate::auth::mcp_status_handler(State(state), inner),
348    )
349    .await)
350}
351
352/// The `/xagent/{agent}/api/v1` route tree — merged into the main router.
353pub fn router() -> axum::Router<ServerState> {
354    use axum::routing::{get, post};
355    axum::Router::new()
356        .route("/xagent/{agent}/api/v1/health", get(x_health))
357        .route(
358            "/xagent/{agent}/api/v1/sessions",
359            get(x_list_sessions).post(x_create_session),
360        )
361        .route(
362            "/xagent/{agent}/api/v1/sessions/live",
363            get(x_list_live_sessions),
364        )
365        .route(
366            "/xagent/{agent}/api/v1/sessions/{id}",
367            get(x_get_session_detail).delete(x_destroy_session),
368        )
369        .route(
370            "/xagent/{agent}/api/v1/sessions/{id}/live",
371            get(x_get_live_session),
372        )
373        .route(
374            "/xagent/{agent}/api/v1/sessions/{id}/resume",
375            post(x_resume_session),
376        )
377        .route(
378            "/xagent/{agent}/api/v1/sessions/{id}/fork",
379            post(x_fork_session),
380        )
381        .route(
382            "/xagent/{agent}/api/v1/sessions/{id}/history",
383            get(x_get_session_history),
384        )
385        .route(
386            "/xagent/{agent}/api/v1/sessions/{id}/command",
387            post(x_post_command_session).layer(axum::extract::DefaultBodyLimit::max(
388                routes::ATTACH_BODY_LIMIT,
389            )),
390        )
391        .route(
392            "/xagent/{agent}/api/v1/sessions/{id}/cancel",
393            post(x_post_cancel_session),
394        )
395        .route(
396            "/xagent/{agent}/api/v1/sessions/{id}/handoff",
397            post(x_post_handoff_session),
398        )
399        .route(
400            "/xagent/{agent}/api/v1/sessions/{id}/stream",
401            get(x_ws_session_handler),
402        )
403        .route("/xagent/{agent}/api/v1/models", get(x_list_models))
404        // v0.19.8: the thq console's MCP Connections overlay relays
405        // /api/v1/console/agents/{agent}/mcp/status → THIS route. Without it
406        // the overlay 404s into a misleading "No MCP credentials" message.
407        .route("/xagent/{agent}/api/v1/mcp/status", get(x_mcp_status))
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::state::ThqDispatchEntry;
414    use std::collections::HashMap;
415
416    fn open_state() -> ServerState {
417        let (session, _rx) = trustee_core::session::Session::new();
418        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
419        ServerState::new(session, ws_tx, None)
420    }
421
422    fn hdrs(pairs: &[(&str, &str)]) -> HeaderMap {
423        let mut h = HeaderMap::new();
424        for (k, v) in pairs {
425            h.insert(
426                header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
427                header::HeaderValue::from_str(v).unwrap(),
428            );
429        }
430        h
431    }
432
433    #[test]
434    fn create_without_identity_stays_config_driven() {
435        // Contract: the xagent create wrapper does NOT synthesize an
436        // identity — the agent's own overlay [lifecycle].system_template
437        // flows through the standard config path (verified in state.rs
438        // lifecycle_overlay tests). This test only pins the request shape.
439        let req: routes::CreateSessionRequest =
440            serde_json::from_str(r#"{"session_name": "probe"}"#).unwrap();
441        assert!(
442            req.identity.is_none(),
443            "wrapper must not invent identities — persona is config-owned"
444        );
445    }
446
447    #[tokio::test]
448    async fn dispatch_unknown_agent_is_404() {
449        let state = open_state();
450        let err = dispatch_context(&state, "nobody", &hdrs(&[]))
451            .await
452            .unwrap_err();
453        assert_eq!(err.0, StatusCode::NOT_FOUND);
454        assert!(err.1.contains("unknown agent"));
455    }
456
457    #[tokio::test]
458    async fn dispatch_entry_without_owner_id_is_not_dispatchable() {
459        let state = open_state();
460        state.thq_dispatch.insert(
461            "ghost".to_string(),
462            ThqDispatchEntry {
463                user_key: String::new(),
464                service_token: None,
465                issuer_url: None,
466            },
467        );
468        let err = dispatch_context(&state, "ghost", &hdrs(&[]))
469            .await
470            .unwrap_err();
471        assert_eq!(err.0, StatusCode::NOT_FOUND);
472        assert!(err.1.contains("not dispatchable"));
473    }
474
475    #[tokio::test]
476    async fn open_mode_dispatch_strips_auth_and_cookie() {
477        // Open mode (auth=None): the inner request must carry NO caller
478        // credentials — the inner check_auth resolves the open "default" user.
479        let state = open_state();
480        state.thq_dispatch.insert(
481            "saman".to_string(),
482            ThqDispatchEntry {
483                user_key: "f27de518-a647-4ea2-85ec-8ecc4d61e658".to_string(),
484                service_token: None,
485                issuer_url: Some("https://idp.tanbal.ir/oauth2/openid/pdt-api".to_string()),
486            },
487        );
488        let (inner, _entry) = dispatch_context(
489            &state,
490            "saman",
491            &hdrs(&[
492                ("Authorization", "Bearer caller-jwt"),
493                ("Cookie", "trustee_token=owner-session"),
494            ]),
495        )
496        .await
497        .unwrap();
498        assert!(
499            inner.get(header::AUTHORIZATION).is_none(),
500            "caller Bearer stripped"
501        );
502        assert!(
503            inner.get(header::COOKIE).is_none(),
504            "caller cookie stripped"
505        );
506    }
507
508    #[tokio::test]
509    async fn dispatch_table_missing_service_token_still_resolves_context_in_open_mode() {
510        // Open mode never mints (no IdP) — a None service_token is fine there.
511        let state = open_state();
512        state.thq_dispatch.insert(
513            "ravand".to_string(),
514            ThqDispatchEntry {
515                user_key: "1a71c077-b3b3-4581-b605-925c3f276f30".to_string(),
516                service_token: None,
517                issuer_url: None,
518            },
519        );
520        let (inner, _entry) = dispatch_context(&state, "ravand", &hdrs(&[]))
521            .await
522            .unwrap();
523        assert!(inner.get(header::AUTHORIZATION).is_none());
524    }
525
526    // ── admin decision core (no IdP needed) ─────────────────────────────
527
528    #[test]
529    fn dispatch_allowed_only_for_human_admins() {
530        use crate::auth::{dispatch_allowed, PrincipalKind};
531        assert!(dispatch_allowed(PrincipalKind::Human, Some("admin")));
532        assert!(!dispatch_allowed(PrincipalKind::Human, Some("user")));
533        assert!(
534            !dispatch_allowed(PrincipalKind::Agent, Some("admin")),
535            "agents never dispatch agents"
536        );
537        assert!(!dispatch_allowed(PrincipalKind::Human, None));
538        assert!(
539            !dispatch_allowed(PrincipalKind::Human, Some("Admin")),
540            "case-sensitive"
541        );
542    }
543
544    #[test]
545    fn secrets_map_is_unused_but_type_stable() {
546        // Guards the merge-secrets typing used by ServerState::with_secrets —
547        // xagent must never need per-user secrets for impersonation (the
548        // service token travels in the dispatch entry, not the secrets map).
549        let _m: HashMap<String, String> = HashMap::new();
550    }
551}