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).layer(axum::extract::DefaultBodyLimit::max(
308 routes::ATTACH_BODY_LIMIT,
309 )),
310 )
311 .route(
312 "/xagent/{agent}/api/v1/sessions/{id}/cancel",
313 post(x_post_cancel_session),
314 )
315 .route(
316 "/xagent/{agent}/api/v1/sessions/{id}/handoff",
317 post(x_post_handoff_session),
318 )
319 .route(
320 "/xagent/{agent}/api/v1/sessions/{id}/stream",
321 get(x_ws_session_handler),
322 )
323 .route("/xagent/{agent}/api/v1/models", get(x_list_models))
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::state::ThqDispatchEntry;
330 use std::collections::HashMap;
331
332 fn open_state() -> ServerState {
333 let (session, _rx) = trustee_core::session::Session::new();
334 let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
335 ServerState::new(session, ws_tx, None)
336 }
337
338 fn hdrs(pairs: &[(&str, &str)]) -> HeaderMap {
339 let mut h = HeaderMap::new();
340 for (k, v) in pairs {
341 h.insert(
342 header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
343 header::HeaderValue::from_str(v).unwrap(),
344 );
345 }
346 h
347 }
348
349 #[test]
350 fn create_without_identity_stays_config_driven() {
351 let req: routes::CreateSessionRequest =
356 serde_json::from_str(r#"{"session_name": "probe"}"#).unwrap();
357 assert!(
358 req.identity.is_none(),
359 "wrapper must not invent identities — persona is config-owned"
360 );
361 }
362
363 #[tokio::test]
364 async fn dispatch_unknown_agent_is_404() {
365 let state = open_state();
366 let err = dispatch_context(&state, "nobody", &hdrs(&[]))
367 .await
368 .unwrap_err();
369 assert_eq!(err.0, StatusCode::NOT_FOUND);
370 assert!(err.1.contains("unknown agent"));
371 }
372
373 #[tokio::test]
374 async fn dispatch_entry_without_owner_id_is_not_dispatchable() {
375 let state = open_state();
376 state.thq_dispatch.insert(
377 "ghost".to_string(),
378 ThqDispatchEntry {
379 user_key: String::new(),
380 service_token: None,
381 issuer_url: None,
382 },
383 );
384 let err = dispatch_context(&state, "ghost", &hdrs(&[]))
385 .await
386 .unwrap_err();
387 assert_eq!(err.0, StatusCode::NOT_FOUND);
388 assert!(err.1.contains("not dispatchable"));
389 }
390
391 #[tokio::test]
392 async fn open_mode_dispatch_strips_auth_and_cookie() {
393 let state = open_state();
396 state.thq_dispatch.insert(
397 "saman".to_string(),
398 ThqDispatchEntry {
399 user_key: "f27de518-a647-4ea2-85ec-8ecc4d61e658".to_string(),
400 service_token: None,
401 issuer_url: Some("https://idp.tanbal.ir/oauth2/openid/pdt-api".to_string()),
402 },
403 );
404 let inner = dispatch_context(
405 &state,
406 "saman",
407 &hdrs(&[
408 ("Authorization", "Bearer caller-jwt"),
409 ("Cookie", "trustee_token=owner-session"),
410 ]),
411 )
412 .await
413 .unwrap();
414 assert!(
415 inner.get(header::AUTHORIZATION).is_none(),
416 "caller Bearer stripped"
417 );
418 assert!(
419 inner.get(header::COOKIE).is_none(),
420 "caller cookie stripped"
421 );
422 }
423
424 #[tokio::test]
425 async fn dispatch_table_missing_service_token_still_resolves_context_in_open_mode() {
426 let state = open_state();
428 state.thq_dispatch.insert(
429 "ravand".to_string(),
430 ThqDispatchEntry {
431 user_key: "1a71c077-b3b3-4581-b605-925c3f276f30".to_string(),
432 service_token: None,
433 issuer_url: None,
434 },
435 );
436 let inner = dispatch_context(&state, "ravand", &hdrs(&[]))
437 .await
438 .unwrap();
439 assert!(inner.get(header::AUTHORIZATION).is_none());
440 }
441
442 #[test]
445 fn dispatch_allowed_only_for_human_admins() {
446 use crate::auth::{dispatch_allowed, PrincipalKind};
447 assert!(dispatch_allowed(PrincipalKind::Human, Some("admin")));
448 assert!(!dispatch_allowed(PrincipalKind::Human, Some("user")));
449 assert!(
450 !dispatch_allowed(PrincipalKind::Agent, Some("admin")),
451 "agents never dispatch agents"
452 );
453 assert!(!dispatch_allowed(PrincipalKind::Human, None));
454 assert!(
455 !dispatch_allowed(PrincipalKind::Human, Some("Admin")),
456 "case-sensitive"
457 );
458 }
459
460 #[test]
461 fn secrets_map_is_unused_but_type_stable() {
462 let _m: HashMap<String, String> = HashMap::new();
466 }
467}