1use 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
41async 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 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 let mut inner = headers.clone();
70
71 let Some(auth) = state.auth.as_ref() else {
72 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 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 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
139pub 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)).await
155}
156
157pub async fn x_create_session(
158 State(state): State<ServerState>,
159 Path(agent): Path<String>,
160 headers: HeaderMap,
161 Json(mut req): Json<routes::CreateSessionRequest>,
162) -> Result<Response, (StatusCode, String)> {
163 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
164 crate::state::in_dispatch_scope(&entry.user_key, routes::create_session(State(state), inner, Json(req))).await
170}
171
172pub async fn x_list_live_sessions(
173 State(state): State<ServerState>,
174 Path(agent): Path<String>,
175 headers: HeaderMap,
176) -> Result<Response, (StatusCode, String)> {
177 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
178 crate::state::in_dispatch_scope(&entry.user_key, routes::list_live_sessions(State(state), inner)).await
179}
180
181pub async fn x_get_session_detail(
182 State(state): State<ServerState>,
183 Path((agent, session_id)): Path<(String, String)>,
184 headers: HeaderMap,
185) -> Result<Response, (StatusCode, String)> {
186 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
187 crate::state::in_dispatch_scope(&entry.user_key, routes::get_session_detail(State(state), Path(session_id), inner)).await
188}
189
190pub async fn x_destroy_session(
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(&entry.user_key, routes::destroy_session(State(state), inner, Path(session_id))).await
197}
198
199pub async fn x_get_live_session(
200 State(state): State<ServerState>,
201 Path((agent, session_id)): Path<(String, String)>,
202 headers: HeaderMap,
203) -> Result<Response, (StatusCode, String)> {
204 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
205 crate::state::in_dispatch_scope(&entry.user_key, routes::get_live_session(State(state), inner, Path(session_id))).await
206}
207
208pub async fn x_resume_session(
209 State(state): State<ServerState>,
210 Path((agent, checkpoint_session_id)): Path<(String, String)>,
211 headers: HeaderMap,
212 body: Option<Json<routes::ResumeRequestBody>>,
213) -> Result<Response, (StatusCode, String)> {
214 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
215 crate::state::in_dispatch_scope(&entry.user_key, routes::resume_session(State(state), Path(checkpoint_session_id), inner, body)).await
216}
217
218pub async fn x_get_session_history(
219 State(state): State<ServerState>,
220 Path((agent, session_id)): Path<(String, String)>,
221 headers: HeaderMap,
222) -> Result<Response, (StatusCode, String)> {
223 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
224 crate::state::in_dispatch_scope(&entry.user_key, routes::get_session_history(State(state), Path(session_id), inner)).await
225}
226
227pub async fn x_post_command_session(
228 State(state): State<ServerState>,
229 Path((agent, session_id)): Path<(String, String)>,
230 headers: HeaderMap,
231 Json(req): Json<routes::CommandRequest>,
232) -> Result<Response, (StatusCode, String)> {
233 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
234 crate::state::in_dispatch_scope(&entry.user_key, routes::post_command_session(State(state), inner, Path(session_id), Json(req))).await
235}
236
237pub async fn x_post_cancel_session(
238 State(state): State<ServerState>,
239 Path((agent, session_id)): Path<(String, String)>,
240 headers: HeaderMap,
241) -> Result<Response, (StatusCode, String)> {
242 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
243 crate::state::in_dispatch_scope(&entry.user_key, routes::post_cancel_session(State(state), inner, Path(session_id))).await
244}
245
246pub async fn x_post_handoff_session(
247 State(state): State<ServerState>,
248 Path((agent, session_id)): Path<(String, String)>,
249 headers: HeaderMap,
250) -> Result<Response, (StatusCode, String)> {
251 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
252 crate::state::in_dispatch_scope(&entry.user_key, routes::post_handoff_session(State(state), inner, Path(session_id))).await
253}
254
255pub async fn x_ws_session_handler(
256 ws: WebSocketUpgrade,
257 State(state): State<ServerState>,
258 Path((agent, session_id)): Path<(String, String)>,
259 headers: HeaderMap,
260) -> Result<Response, StatusCode> {
261 let (inner, entry) = dispatch_context(&state, &agent, &headers)
262 .await
263 .map_err(|(s, _)| s)?;
264 crate::state::in_dispatch_scope(
265 &entry.user_key,
266 routes::ws_session_handler(ws, State(state), inner, Path(session_id)),
267 )
268 .await
269}
270
271pub async fn x_list_models(
272 State(state): State<ServerState>,
273 Path(agent): Path<String>,
274 headers: HeaderMap,
275) -> Result<Response, (StatusCode, String)> {
276 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
277 crate::state::in_dispatch_scope(&entry.user_key, routes::list_models(State(state), inner)).await
278}
279
280pub async fn x_mcp_status(
284 State(state): State<ServerState>,
285 Path(agent): Path<String>,
286 headers: HeaderMap,
287) -> Result<axum::response::Response, (StatusCode, String)> {
288 let (inner, entry) = dispatch_context(&state, &agent, &headers).await?;
289 Ok(
290 crate::state::in_dispatch_scope(
291 &entry.user_key,
292 crate::auth::mcp_status_handler(State(state), inner),
293 )
294 .await,
295 )
296}
297
298pub fn router() -> axum::Router<ServerState> {
300 use axum::routing::{get, post};
301 axum::Router::new()
302 .route("/xagent/{agent}/api/v1/health", get(x_health))
303 .route(
304 "/xagent/{agent}/api/v1/sessions",
305 get(x_list_sessions).post(x_create_session),
306 )
307 .route(
308 "/xagent/{agent}/api/v1/sessions/live",
309 get(x_list_live_sessions),
310 )
311 .route(
312 "/xagent/{agent}/api/v1/sessions/{id}",
313 get(x_get_session_detail).delete(x_destroy_session),
314 )
315 .route(
316 "/xagent/{agent}/api/v1/sessions/{id}/live",
317 get(x_get_live_session),
318 )
319 .route(
320 "/xagent/{agent}/api/v1/sessions/{id}/resume",
321 post(x_resume_session),
322 )
323 .route(
324 "/xagent/{agent}/api/v1/sessions/{id}/history",
325 get(x_get_session_history),
326 )
327 .route(
328 "/xagent/{agent}/api/v1/sessions/{id}/command",
329 post(x_post_command_session).layer(axum::extract::DefaultBodyLimit::max(
330 routes::ATTACH_BODY_LIMIT,
331 )),
332 )
333 .route(
334 "/xagent/{agent}/api/v1/sessions/{id}/cancel",
335 post(x_post_cancel_session),
336 )
337 .route(
338 "/xagent/{agent}/api/v1/sessions/{id}/handoff",
339 post(x_post_handoff_session),
340 )
341 .route(
342 "/xagent/{agent}/api/v1/sessions/{id}/stream",
343 get(x_ws_session_handler),
344 )
345 .route("/xagent/{agent}/api/v1/models", get(x_list_models))
346 .route(
350 "/xagent/{agent}/api/v1/mcp/status",
351 get(x_mcp_status),
352 )
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::state::ThqDispatchEntry;
359 use std::collections::HashMap;
360
361 fn open_state() -> ServerState {
362 let (session, _rx) = trustee_core::session::Session::new();
363 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
364 ServerState::new(session, ws_tx, None)
365 }
366
367 fn hdrs(pairs: &[(&str, &str)]) -> HeaderMap {
368 let mut h = HeaderMap::new();
369 for (k, v) in pairs {
370 h.insert(
371 header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
372 header::HeaderValue::from_str(v).unwrap(),
373 );
374 }
375 h
376 }
377
378 #[test]
379 fn create_without_identity_stays_config_driven() {
380 let req: routes::CreateSessionRequest =
385 serde_json::from_str(r#"{"session_name": "probe"}"#).unwrap();
386 assert!(
387 req.identity.is_none(),
388 "wrapper must not invent identities — persona is config-owned"
389 );
390 }
391
392 #[tokio::test]
393 async fn dispatch_unknown_agent_is_404() {
394 let state = open_state();
395 let err = dispatch_context(&state, "nobody", &hdrs(&[]))
396 .await
397 .unwrap_err();
398 assert_eq!(err.0, StatusCode::NOT_FOUND);
399 assert!(err.1.contains("unknown agent"));
400 }
401
402 #[tokio::test]
403 async fn dispatch_entry_without_owner_id_is_not_dispatchable() {
404 let state = open_state();
405 state.thq_dispatch.insert(
406 "ghost".to_string(),
407 ThqDispatchEntry {
408 user_key: String::new(),
409 service_token: None,
410 issuer_url: None,
411 },
412 );
413 let err = dispatch_context(&state, "ghost", &hdrs(&[]))
414 .await
415 .unwrap_err();
416 assert_eq!(err.0, StatusCode::NOT_FOUND);
417 assert!(err.1.contains("not dispatchable"));
418 }
419
420 #[tokio::test]
421 async fn open_mode_dispatch_strips_auth_and_cookie() {
422 let state = open_state();
425 state.thq_dispatch.insert(
426 "saman".to_string(),
427 ThqDispatchEntry {
428 user_key: "f27de518-a647-4ea2-85ec-8ecc4d61e658".to_string(),
429 service_token: None,
430 issuer_url: Some("https://idp.tanbal.ir/oauth2/openid/pdt-api".to_string()),
431 },
432 );
433 let (inner, _entry) = dispatch_context(
434 &state,
435 "saman",
436 &hdrs(&[
437 ("Authorization", "Bearer caller-jwt"),
438 ("Cookie", "trustee_token=owner-session"),
439 ]),
440 )
441 .await
442 .unwrap();
443 assert!(
444 inner.get(header::AUTHORIZATION).is_none(),
445 "caller Bearer stripped"
446 );
447 assert!(
448 inner.get(header::COOKIE).is_none(),
449 "caller cookie stripped"
450 );
451 }
452
453 #[tokio::test]
454 async fn dispatch_table_missing_service_token_still_resolves_context_in_open_mode() {
455 let state = open_state();
457 state.thq_dispatch.insert(
458 "ravand".to_string(),
459 ThqDispatchEntry {
460 user_key: "1a71c077-b3b3-4581-b605-925c3f276f30".to_string(),
461 service_token: None,
462 issuer_url: None,
463 },
464 );
465 let (inner, _entry) = dispatch_context(&state, "ravand", &hdrs(&[]))
466 .await
467 .unwrap();
468 assert!(inner.get(header::AUTHORIZATION).is_none());
469 }
470
471 #[test]
474 fn dispatch_allowed_only_for_human_admins() {
475 use crate::auth::{dispatch_allowed, PrincipalKind};
476 assert!(dispatch_allowed(PrincipalKind::Human, Some("admin")));
477 assert!(!dispatch_allowed(PrincipalKind::Human, Some("user")));
478 assert!(
479 !dispatch_allowed(PrincipalKind::Agent, Some("admin")),
480 "agents never dispatch agents"
481 );
482 assert!(!dispatch_allowed(PrincipalKind::Human, None));
483 assert!(
484 !dispatch_allowed(PrincipalKind::Human, Some("Admin")),
485 "case-sensitive"
486 );
487 }
488
489 #[test]
490 fn secrets_map_is_unused_but_type_stable() {
491 let _m: HashMap<String, String> = HashMap::new();
495 }
496}