1use 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
42fn 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
61fn 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
77const DISPATCH_REFRESH_HORIZON: std::time::Duration = std::time::Duration::from_secs(120);
83
84fn 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
94async 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 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
138async 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 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 let mut inner = headers.clone();
176
177 let Some(auth) = state.auth.as_ref() else {
178 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 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 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
231async 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
262pub 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 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
370pub 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 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
474pub 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
489pub 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 .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 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 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 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 #[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 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 let _m: HashMap<String, String> = HashMap::new();
702 }
703
704 #[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 assert!(within_refresh_horizon(
739 now + std::time::Duration::from_secs(200),
740 now,
741 std::time::Duration::from_secs(0)
742 ));
743 assert!(!within_refresh_horizon(
745 now + std::time::Duration::from_secs(100),
746 now,
747 std::time::Duration::from_secs(0)
748 ));
749 assert!(within_refresh_horizon(
751 now + std::time::Duration::from_secs(200),
752 now,
753 std::time::Duration::from_secs(15)
754 ));
755 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 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}