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;
40
41async fn dispatch_context(
44 state: &ServerState,
45 agent: &str,
46 headers: &HeaderMap,
47) -> Result<HeaderMap, (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);
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);
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)
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 = dispatch_context(&state, &agent, &headers).await?;
154 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 = dispatch_context(&state, &agent, &headers).await?;
164 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 = dispatch_context(&state, &agent, &headers).await?;
178 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 = dispatch_context(&state, &agent, &headers).await?;
187 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 = dispatch_context(&state, &agent, &headers).await?;
196 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 = dispatch_context(&state, &agent, &headers).await?;
205 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 = dispatch_context(&state, &agent, &headers).await?;
215 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 = dispatch_context(&state, &agent, &headers).await?;
224 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 = dispatch_context(&state, &agent, &headers).await?;
234 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 = dispatch_context(&state, &agent, &headers).await?;
243 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 = dispatch_context(&state, &agent, &headers).await?;
252 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 = dispatch_context(&state, &agent, &headers)
262 .await
263 .map_err(|(s, _)| s)?;
264 routes::ws_session_handler(ws, State(state), inner, Path(session_id)).await
265}
266
267pub async fn x_list_models(
268 State(state): State<ServerState>,
269 Path(agent): Path<String>,
270 headers: HeaderMap,
271) -> Result<Response, (StatusCode, String)> {
272 let inner = dispatch_context(&state, &agent, &headers).await?;
273 routes::list_models(State(state), inner).await
274}
275
276pub fn router() -> axum::Router<ServerState> {
278 use axum::routing::{get, post};
279 axum::Router::new()
280 .route("/xagent/{agent}/api/v1/health", get(x_health))
281 .route(
282 "/xagent/{agent}/api/v1/sessions",
283 get(x_list_sessions).post(x_create_session),
284 )
285 .route(
286 "/xagent/{agent}/api/v1/sessions/live",
287 get(x_list_live_sessions),
288 )
289 .route(
290 "/xagent/{agent}/api/v1/sessions/{id}",
291 get(x_get_session_detail).delete(x_destroy_session),
292 )
293 .route(
294 "/xagent/{agent}/api/v1/sessions/{id}/live",
295 get(x_get_live_session),
296 )
297 .route(
298 "/xagent/{agent}/api/v1/sessions/{id}/resume",
299 post(x_resume_session),
300 )
301 .route(
302 "/xagent/{agent}/api/v1/sessions/{id}/history",
303 get(x_get_session_history),
304 )
305 .route(
306 "/xagent/{agent}/api/v1/sessions/{id}/command",
307 post(x_post_command_session),
308 )
309 .route(
310 "/xagent/{agent}/api/v1/sessions/{id}/cancel",
311 post(x_post_cancel_session),
312 )
313 .route(
314 "/xagent/{agent}/api/v1/sessions/{id}/handoff",
315 post(x_post_handoff_session),
316 )
317 .route(
318 "/xagent/{agent}/api/v1/sessions/{id}/stream",
319 get(x_ws_session_handler),
320 )
321 .route("/xagent/{agent}/api/v1/models", get(x_list_models))
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::state::ThqDispatchEntry;
328 use std::collections::HashMap;
329
330 fn open_state() -> ServerState {
331 let (session, _rx) = trustee_core::session::Session::new();
332 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
333 ServerState::new(session, ws_tx, None)
334 }
335
336 fn hdrs(pairs: &[(&str, &str)]) -> HeaderMap {
337 let mut h = HeaderMap::new();
338 for (k, v) in pairs {
339 h.insert(
340 header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
341 header::HeaderValue::from_str(v).unwrap(),
342 );
343 }
344 h
345 }
346
347 #[test]
348 fn create_without_identity_stays_config_driven() {
349 let req: routes::CreateSessionRequest =
354 serde_json::from_str(r#"{"session_name": "probe"}"#).unwrap();
355 assert!(
356 req.identity.is_none(),
357 "wrapper must not invent identities — persona is config-owned"
358 );
359 }
360
361 #[tokio::test]
362 async fn dispatch_unknown_agent_is_404() {
363 let state = open_state();
364 let err = dispatch_context(&state, "nobody", &hdrs(&[]))
365 .await
366 .unwrap_err();
367 assert_eq!(err.0, StatusCode::NOT_FOUND);
368 assert!(err.1.contains("unknown agent"));
369 }
370
371 #[tokio::test]
372 async fn dispatch_entry_without_owner_id_is_not_dispatchable() {
373 let state = open_state();
374 state.thq_dispatch.insert(
375 "ghost".to_string(),
376 ThqDispatchEntry {
377 user_key: String::new(),
378 service_token: None,
379 issuer_url: None,
380 },
381 );
382 let err = dispatch_context(&state, "ghost", &hdrs(&[]))
383 .await
384 .unwrap_err();
385 assert_eq!(err.0, StatusCode::NOT_FOUND);
386 assert!(err.1.contains("not dispatchable"));
387 }
388
389 #[tokio::test]
390 async fn open_mode_dispatch_strips_auth_and_cookie() {
391 let state = open_state();
394 state.thq_dispatch.insert(
395 "saman".to_string(),
396 ThqDispatchEntry {
397 user_key: "f27de518-a647-4ea2-85ec-8ecc4d61e658".to_string(),
398 service_token: None,
399 issuer_url: Some("https://idp.tanbal.ir/oauth2/openid/pdt-api".to_string()),
400 },
401 );
402 let inner = dispatch_context(
403 &state,
404 "saman",
405 &hdrs(&[
406 ("Authorization", "Bearer caller-jwt"),
407 ("Cookie", "trustee_token=owner-session"),
408 ]),
409 )
410 .await
411 .unwrap();
412 assert!(
413 inner.get(header::AUTHORIZATION).is_none(),
414 "caller Bearer stripped"
415 );
416 assert!(
417 inner.get(header::COOKIE).is_none(),
418 "caller cookie stripped"
419 );
420 }
421
422 #[tokio::test]
423 async fn dispatch_table_missing_service_token_still_resolves_context_in_open_mode() {
424 let state = open_state();
426 state.thq_dispatch.insert(
427 "ravand".to_string(),
428 ThqDispatchEntry {
429 user_key: "1a71c077-b3b3-4581-b605-925c3f276f30".to_string(),
430 service_token: None,
431 issuer_url: None,
432 },
433 );
434 let inner = dispatch_context(&state, "ravand", &hdrs(&[]))
435 .await
436 .unwrap();
437 assert!(inner.get(header::AUTHORIZATION).is_none());
438 }
439
440 #[test]
443 fn dispatch_allowed_only_for_human_admins() {
444 use crate::auth::{dispatch_allowed, PrincipalKind};
445 assert!(dispatch_allowed(PrincipalKind::Human, Some("admin")));
446 assert!(!dispatch_allowed(PrincipalKind::Human, Some("user")));
447 assert!(
448 !dispatch_allowed(PrincipalKind::Agent, Some("admin")),
449 "agents never dispatch agents"
450 );
451 assert!(!dispatch_allowed(PrincipalKind::Human, None));
452 assert!(
453 !dispatch_allowed(PrincipalKind::Human, Some("Admin")),
454 "case-sensitive"
455 );
456 }
457
458 #[test]
459 fn secrets_map_is_unused_but_type_stable() {
460 let _m: HashMap<String, String> = HashMap::new();
464 }
465}