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;
40
41/// Minimum identity for dispatched sessions whose create body carries none.
42/// Charters replace this per agent once drafted (owner-approved content);
43/// until then the agent at least knows its own name.
44fn default_identity(agent: &str) -> String {
45    format!("You are {agent}, an agent on the Tanbal platform.")
46}
47
48/// Resolve the dispatch target and rebuild the inner request headers with
49/// the AGENT's Bearer (cookie stripped). Shared prelude of every wrapper.
50async fn dispatch_context(
51    state: &ServerState,
52    agent: &str,
53    headers: &HeaderMap,
54) -> Result<HeaderMap, (StatusCode, String)> {
55    let Some(entry) = state.thq_dispatch.get(agent).map(|e| e.clone()) else {
56        return Err((StatusCode::NOT_FOUND, format!("unknown agent: {agent}")));
57    };
58    if entry.user_key.is_empty() {
59        return Err((
60            StatusCode::NOT_FOUND,
61            format!("agent {agent} has no owner_id — not dispatchable"),
62        ));
63    }
64
65    // Outer gate: human admin (open mode allowed, same posture as check_auth).
66    crate::auth::check_dispatch_admin(&state.auth, headers)
67        .await
68        .map_err(|s| {
69            (
70                s,
71                "xagent dispatch requires an admin Bearer token".to_string(),
72            )
73        })?;
74
75    // Inner identity: the agent's own short-lived Bearer.
76    let mut inner = headers.clone();
77
78    let Some(auth) = state.auth.as_ref() else {
79        // Open mode: no IdP to mint from — run the inner call unauthenticated,
80        // which check_auth resolves as the open-mode "default" user.
81        inner.remove(header::AUTHORIZATION);
82        inner.remove(header::COOKIE);
83        return Ok(inner);
84    };
85
86    let Some(ref service_token) = entry.service_token else {
87        return Err((
88            StatusCode::BAD_GATEWAY,
89            format!(
90                "agent {agent} has no service token provisioned (per-user .env) — cannot impersonate"
91            ),
92        ));
93    };
94
95    // Cache: (token, expires_at) with a 60s safety buffer (pep 0.5.6 lesson).
96    if let Some(kv) = state.agent_dispatch_tokens.get(&entry.user_key) {
97        let (tok, exp) = kv.value();
98        if std::time::Instant::now() < *exp {
99            inner.insert(
100                header::AUTHORIZATION,
101                format!("Bearer {tok}").parse().map_err(|_| {
102                    (
103                        StatusCode::INTERNAL_SERVER_ERROR,
104                        "header build".to_string(),
105                    )
106                })?,
107            );
108            inner.remove(header::COOKIE);
109            return Ok(inner);
110        }
111    }
112
113    // Kanidm accepts a token exchange only on the origin the token was
114    // minted for: use the issuer captured from the agent's OWN overlay
115    // credential; fall back to the shared config's service issuer, then the
116    // auth issuer.
117    let issuer = entry
118        .issuer_url
119        .clone()
120        .or_else(|| state.service_issuer())
121        .unwrap_or_else(|| auth.config.issuer_url.clone());
122    let (token, expires_in) = auth
123        .exchange_agent_token(&issuer, service_token)
124        .await
125        .map_err(|s| (s, "agent token exchange failed".to_string()))?;
126    let buffered = std::time::Duration::from_secs(expires_in.saturating_sub(60))
127        .max(std::time::Duration::from_secs(30));
128    state.agent_dispatch_tokens.insert(
129        entry.user_key.clone(),
130        (token.clone(), std::time::Instant::now() + buffered),
131    );
132
133    inner.insert(
134        header::AUTHORIZATION,
135        format!("Bearer {token}").parse().map_err(|_| {
136            (
137                StatusCode::INTERNAL_SERVER_ERROR,
138                "header build".to_string(),
139            )
140        })?,
141    );
142    inner.remove(header::COOKIE);
143    Ok(inner)
144}
145
146/// THQ polls `{advertise_url}/api/v1/health` for liveness — resolve the agent
147/// (unknown → 404 → THQ marks it offline) then answer with the shared health.
148pub async fn x_health(State(state): State<ServerState>, Path(agent): Path<String>) -> Response {
149    if !state.thq_dispatch.contains_key(&agent) {
150        return (StatusCode::NOT_FOUND, format!("unknown agent: {agent}")).into_response();
151    }
152    routes::health().await.into_response()
153}
154
155pub async fn x_list_sessions(
156    State(state): State<ServerState>,
157    Path(agent): Path<String>,
158    headers: HeaderMap,
159) -> Result<Response, (StatusCode, String)> {
160    let inner = dispatch_context(&state, &agent, &headers).await?;
161    routes::list_sessions(State(state), inner).await
162}
163
164pub async fn x_create_session(
165    State(state): State<ServerState>,
166    Path(agent): Path<String>,
167    headers: HeaderMap,
168    Json(mut req): Json<routes::CreateSessionRequest>,
169) -> Result<Response, (StatusCode, String)> {
170    let inner = dispatch_context(&state, &agent, &headers).await?;
171    if req.identity.is_none() {
172        req.identity = Some(default_identity(&agent));
173    }
174    routes::create_session(State(state), inner, Json(req)).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 = dispatch_context(&state, &agent, &headers).await?;
183    routes::list_live_sessions(State(state), inner).await
184}
185
186pub async fn x_get_session_detail(
187    State(state): State<ServerState>,
188    Path((agent, session_id)): Path<(String, String)>,
189    headers: HeaderMap,
190) -> Result<Response, (StatusCode, String)> {
191    let inner = dispatch_context(&state, &agent, &headers).await?;
192    routes::get_session_detail(State(state), Path(session_id), inner).await
193}
194
195pub async fn x_destroy_session(
196    State(state): State<ServerState>,
197    Path((agent, session_id)): Path<(String, String)>,
198    headers: HeaderMap,
199) -> Result<Response, (StatusCode, String)> {
200    let inner = dispatch_context(&state, &agent, &headers).await?;
201    routes::destroy_session(State(state), inner, Path(session_id)).await
202}
203
204pub async fn x_get_live_session(
205    State(state): State<ServerState>,
206    Path((agent, session_id)): Path<(String, String)>,
207    headers: HeaderMap,
208) -> Result<Response, (StatusCode, String)> {
209    let inner = dispatch_context(&state, &agent, &headers).await?;
210    routes::get_live_session(State(state), inner, Path(session_id)).await
211}
212
213pub async fn x_resume_session(
214    State(state): State<ServerState>,
215    Path((agent, checkpoint_session_id)): Path<(String, String)>,
216    headers: HeaderMap,
217    body: Option<Json<routes::ResumeRequestBody>>,
218) -> Result<Response, (StatusCode, String)> {
219    let inner = dispatch_context(&state, &agent, &headers).await?;
220    routes::resume_session(State(state), Path(checkpoint_session_id), inner, body).await
221}
222
223pub async fn x_get_session_history(
224    State(state): State<ServerState>,
225    Path((agent, session_id)): Path<(String, String)>,
226    headers: HeaderMap,
227) -> Result<Response, (StatusCode, String)> {
228    let inner = dispatch_context(&state, &agent, &headers).await?;
229    routes::get_session_history(State(state), Path(session_id), inner).await
230}
231
232pub async fn x_post_command_session(
233    State(state): State<ServerState>,
234    Path((agent, session_id)): Path<(String, String)>,
235    headers: HeaderMap,
236    Json(req): Json<routes::CommandRequest>,
237) -> Result<Response, (StatusCode, String)> {
238    let inner = dispatch_context(&state, &agent, &headers).await?;
239    routes::post_command_session(State(state), inner, Path(session_id), Json(req)).await
240}
241
242pub async fn x_post_cancel_session(
243    State(state): State<ServerState>,
244    Path((agent, session_id)): Path<(String, String)>,
245    headers: HeaderMap,
246) -> Result<Response, (StatusCode, String)> {
247    let inner = dispatch_context(&state, &agent, &headers).await?;
248    routes::post_cancel_session(State(state), inner, Path(session_id)).await
249}
250
251pub async fn x_post_handoff_session(
252    State(state): State<ServerState>,
253    Path((agent, session_id)): Path<(String, String)>,
254    headers: HeaderMap,
255) -> Result<Response, (StatusCode, String)> {
256    let inner = dispatch_context(&state, &agent, &headers).await?;
257    routes::post_handoff_session(State(state), inner, Path(session_id)).await
258}
259
260pub async fn x_ws_session_handler(
261    ws: WebSocketUpgrade,
262    State(state): State<ServerState>,
263    Path((agent, session_id)): Path<(String, String)>,
264    headers: HeaderMap,
265) -> Result<Response, StatusCode> {
266    let inner = dispatch_context(&state, &agent, &headers)
267        .await
268        .map_err(|(s, _)| s)?;
269    routes::ws_session_handler(ws, State(state), inner, Path(session_id)).await
270}
271
272pub async fn x_list_models(
273    State(state): State<ServerState>,
274    Path(agent): Path<String>,
275    headers: HeaderMap,
276) -> Result<Response, (StatusCode, String)> {
277    let inner = dispatch_context(&state, &agent, &headers).await?;
278    routes::list_models(State(state), inner).await
279}
280
281/// The `/xagent/{agent}/api/v1` route tree — merged into the main router.
282pub fn router() -> axum::Router<ServerState> {
283    use axum::routing::{get, post};
284    axum::Router::new()
285        .route("/xagent/{agent}/api/v1/health", get(x_health))
286        .route(
287            "/xagent/{agent}/api/v1/sessions",
288            get(x_list_sessions).post(x_create_session),
289        )
290        .route(
291            "/xagent/{agent}/api/v1/sessions/live",
292            get(x_list_live_sessions),
293        )
294        .route(
295            "/xagent/{agent}/api/v1/sessions/{id}",
296            get(x_get_session_detail).delete(x_destroy_session),
297        )
298        .route(
299            "/xagent/{agent}/api/v1/sessions/{id}/live",
300            get(x_get_live_session),
301        )
302        .route(
303            "/xagent/{agent}/api/v1/sessions/{id}/resume",
304            post(x_resume_session),
305        )
306        .route(
307            "/xagent/{agent}/api/v1/sessions/{id}/history",
308            get(x_get_session_history),
309        )
310        .route(
311            "/xagent/{agent}/api/v1/sessions/{id}/command",
312            post(x_post_command_session),
313        )
314        .route(
315            "/xagent/{agent}/api/v1/sessions/{id}/cancel",
316            post(x_post_cancel_session),
317        )
318        .route(
319            "/xagent/{agent}/api/v1/sessions/{id}/handoff",
320            post(x_post_handoff_session),
321        )
322        .route(
323            "/xagent/{agent}/api/v1/sessions/{id}/stream",
324            get(x_ws_session_handler),
325        )
326        .route("/xagent/{agent}/api/v1/models", get(x_list_models))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::state::ThqDispatchEntry;
333    use std::collections::HashMap;
334
335    fn open_state() -> ServerState {
336        let (session, _rx) = trustee_core::session::Session::new();
337        let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
338        ServerState::new(session, ws_tx, None)
339    }
340
341    fn hdrs(pairs: &[(&str, &str)]) -> HeaderMap {
342        let mut h = HeaderMap::new();
343        for (k, v) in pairs {
344            h.insert(
345                header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
346                header::HeaderValue::from_str(v).unwrap(),
347            );
348        }
349        h
350    }
351
352    #[test]
353    fn default_identity_names_the_agent() {
354        assert_eq!(
355            default_identity("saman"),
356            "You are saman, an agent on the Tanbal platform."
357        );
358    }
359
360    #[tokio::test]
361    async fn dispatch_unknown_agent_is_404() {
362        let state = open_state();
363        let err = dispatch_context(&state, "nobody", &hdrs(&[]))
364            .await
365            .unwrap_err();
366        assert_eq!(err.0, StatusCode::NOT_FOUND);
367        assert!(err.1.contains("unknown agent"));
368    }
369
370    #[tokio::test]
371    async fn dispatch_entry_without_owner_id_is_not_dispatchable() {
372        let state = open_state();
373        state.thq_dispatch.insert(
374            "ghost".to_string(),
375            ThqDispatchEntry {
376                user_key: String::new(),
377                service_token: None,
378                issuer_url: None,
379            },
380        );
381        let err = dispatch_context(&state, "ghost", &hdrs(&[]))
382            .await
383            .unwrap_err();
384        assert_eq!(err.0, StatusCode::NOT_FOUND);
385        assert!(err.1.contains("not dispatchable"));
386    }
387
388    #[tokio::test]
389    async fn open_mode_dispatch_strips_auth_and_cookie() {
390        // Open mode (auth=None): the inner request must carry NO caller
391        // credentials — the inner check_auth resolves the open "default" user.
392        let state = open_state();
393        state.thq_dispatch.insert(
394            "saman".to_string(),
395            ThqDispatchEntry {
396                user_key: "f27de518-a647-4ea2-85ec-8ecc4d61e658".to_string(),
397                service_token: None,
398                issuer_url: Some("https://idp.tanbal.ir/oauth2/openid/pdt-api".to_string()),
399            },
400        );
401        let inner = dispatch_context(
402            &state,
403            "saman",
404            &hdrs(&[
405                ("Authorization", "Bearer caller-jwt"),
406                ("Cookie", "trustee_token=owner-session"),
407            ]),
408        )
409        .await
410        .unwrap();
411        assert!(
412            inner.get(header::AUTHORIZATION).is_none(),
413            "caller Bearer stripped"
414        );
415        assert!(
416            inner.get(header::COOKIE).is_none(),
417            "caller cookie stripped"
418        );
419    }
420
421    #[tokio::test]
422    async fn dispatch_table_missing_service_token_still_resolves_context_in_open_mode() {
423        // Open mode never mints (no IdP) — a None service_token is fine there.
424        let state = open_state();
425        state.thq_dispatch.insert(
426            "ravand".to_string(),
427            ThqDispatchEntry {
428                user_key: "1a71c077-b3b3-4581-b605-925c3f276f30".to_string(),
429                service_token: None,
430                issuer_url: None,
431            },
432        );
433        let inner = dispatch_context(&state, "ravand", &hdrs(&[]))
434            .await
435            .unwrap();
436        assert!(inner.get(header::AUTHORIZATION).is_none());
437    }
438
439    // ── admin decision core (no IdP needed) ─────────────────────────────
440
441    #[test]
442    fn dispatch_allowed_only_for_human_admins() {
443        use crate::auth::{dispatch_allowed, PrincipalKind};
444        assert!(dispatch_allowed(PrincipalKind::Human, Some("admin")));
445        assert!(!dispatch_allowed(PrincipalKind::Human, Some("user")));
446        assert!(
447            !dispatch_allowed(PrincipalKind::Agent, Some("admin")),
448            "agents never dispatch agents"
449        );
450        assert!(!dispatch_allowed(PrincipalKind::Human, None));
451        assert!(
452            !dispatch_allowed(PrincipalKind::Human, Some("Admin")),
453            "case-sensitive"
454        );
455    }
456
457    #[test]
458    fn secrets_map_is_unused_but_type_stable() {
459        // Guards the merge-secrets typing used by ServerState::with_secrets —
460        // xagent must never need per-user secrets for impersonation (the
461        // service token travels in the dispatch entry, not the secrets map).
462        let _m: HashMap<String, String> = HashMap::new();
463    }
464}