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
41fn default_identity(agent: &str) -> String {
45 format!("You are {agent}, an agent on the Tanbal platform.")
46}
47
48async 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 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 let mut inner = headers.clone();
77
78 let Some(auth) = state.auth.as_ref() else {
79 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 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 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
146pub 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
281pub 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 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 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 #[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 let _m: HashMap<String, String> = HashMap::new();
463 }
464}