1use crate::pipeline::AppState;
4use av_core::time::elapsed_us;
5use av_events::StopReason;
6use av_sandbox::ToolVerdict;
7use axum::body::{Body, Bytes};
8use axum::extract::{Path, State};
9use axum::http::{HeaderMap, HeaderValue, Request, StatusCode};
10use axum::middleware::Next;
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use futures::stream::BoxStream;
15use futures::{FutureExt, Stream, StreamExt};
16use serde_json::{json, Value};
17use std::future::Future as _;
18use std::pin::Pin;
19use std::sync::Arc;
20use std::task::{Context, Poll};
21use tracing::Instrument as _;
22
23pub fn build_router(state: AppState) -> Router {
25 let max_body = state.config.max_request_bytes;
26 let dashboard_enabled = state.config.dashboard_enabled;
27 let mut router = Router::new()
28 .route("/health", get(health))
29 .route("/livez", get(livez))
30 .route("/readyz", get(readyz))
31 .route("/metrics", get(metrics))
32 .route("/v1/chat/completions", post(chat_completions).options(cors_deny))
33 .route(
34 "/v1/mcp",
35 post(mcp_call).get(mcp_get).delete(mcp_delete).options(cors_deny),
36 )
37 .route(
38 "/mcp",
39 post(mcp_call).get(mcp_get).delete(mcp_delete).options(cors_deny),
40 )
41 .route(
42 "/v1/mcp/{backend}",
43 post(mcp_backend_call)
44 .get(mcp_get)
45 .delete(mcp_delete)
46 .options(cors_deny),
47 )
48 .route(
49 "/mcp/{backend}",
50 post(mcp_backend_call)
51 .get(mcp_get)
52 .delete(mcp_delete)
53 .options(cors_deny),
54 )
55 .route("/v1/sessions/{id}/close", post(close_session).options(cors_deny))
56 .route(
57 "/v1/sessions/{id}/promote",
58 post(promote_session).options(cors_deny),
59 )
60 .route("/v1/token", post(token_exchange).options(cors_deny))
61 .route("/v1/revoke", post(crate::token_routes::revoke).options(cors_deny))
62 .route("/.well-known/jwks.json", get(jwks));
63 if !state.config.introspection_tokens.is_empty() {
64 router = router.route(
65 "/v1/introspect",
66 post(crate::token_routes::introspect).options(cors_deny),
67 );
68 }
69 if !state.config.operator_tokens.is_empty() {
70 router = router.route(
71 "/admin/v1/revocations",
72 post(crate::token_routes::admin_revoke).options(cors_deny),
73 );
74 }
75 if dashboard_enabled {
76 router = router
77 .route("/dashboard", get(crate::dashboard::index))
78 .route("/dashboard/", get(crate::dashboard::index))
79 .route("/dashboard/style.css", get(crate::dashboard::style_css))
80 .route("/dashboard/app.js", get(crate::dashboard::app_js))
81 .route("/api/v1/dashboard/stats", get(crate::dashboard::stats))
82 .route("/api/v1/dashboard/sessions", get(crate::dashboard::list_sessions))
83 .route(
84 "/api/v1/dashboard/sessions/{id}",
85 get(crate::dashboard::session_detail),
86 );
87 }
88 router
89 .layer(axum::middleware::from_fn_with_state(
90 state.clone(),
91 enforce_allowed_hosts,
92 ))
93 .layer(axum::middleware::from_fn_with_state(
94 state.clone(),
95 request_metrics,
96 ))
97 .layer(axum::middleware::from_fn(trace_request))
98 .layer(axum::middleware::from_fn_with_state(state.clone(), explain_body_limit))
106 .layer(axum::extract::DefaultBodyLimit::max(max_body))
112 .with_state(state)
113}
114
115async fn explain_body_limit(
119 State(state): State<AppState>,
120 request: Request<Body>,
121 next: axum::middleware::Next,
122) -> Response {
123 let response = next.run(request).await;
124 if response.status() != StatusCode::PAYLOAD_TOO_LARGE {
125 return response;
126 }
127 let is_json = response
130 .headers()
131 .get(axum::http::header::CONTENT_TYPE)
132 .and_then(|value| value.to_str().ok())
133 .is_some_and(|value| value.starts_with("application/json"));
134 if is_json {
135 return response;
136 }
137 let limit = state.config.max_request_bytes;
138 (
139 StatusCode::PAYLOAD_TOO_LARGE,
140 Json(serde_json::json!({
141 "error": {
142 "message": format!(
143 "request body exceeds max_request_bytes ({limit} bytes); \
144 raise `max_request_bytes` in the harness config if larger \
145 payloads are intended"
146 ),
147 "type": "invalid_request_error",
148 "param": serde_json::Value::Null,
149 "code": StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
155 }
156 })),
157 )
158 .into_response()
159}
160
161async fn enforce_allowed_hosts(
170 State(state): State<AppState>,
171 request: Request<Body>,
172 next: Next,
173) -> Response {
174 if state.config.allowed_hosts.is_empty() {
175 return next.run(request).await;
176 }
177 let host = request
178 .headers()
179 .get(axum::http::header::HOST)
180 .and_then(|value| value.to_str().ok())
181 .map(|value| {
182 let stripped = value
184 .rsplit_once(':')
185 .filter(|(head, port)| !head.is_empty() && port.chars().all(|c| c.is_ascii_digit()))
186 .map_or(value, |(head, _)| head);
187 stripped.trim_start_matches('[').trim_end_matches(']')
188 });
189 let allowed = host.is_some_and(|host| {
190 state.config.allowed_hosts.iter().any(|entry| {
191 entry
192 .trim_start_matches('[')
193 .trim_end_matches(']')
194 .eq_ignore_ascii_case(host)
195 })
196 });
197 if !allowed {
198 state
199 .metrics
200 .counter(
201 "av_host_header_refused_total",
202 "Requests refused by the allowed_hosts Host-header allowlist",
203 )
204 .inc();
205 return (
206 StatusCode::FORBIDDEN,
207 Json(json!({
208 "error": {
209 "message": "Host header is not in allowed_hosts",
210 "type": "permission_error",
211 "param": null,
212 "code": 403,
213 }
214 })),
215 )
216 .into_response();
217 }
218 next.run(request).await
219}
220
221async fn request_metrics(State(state): State<AppState>, request: Request<Body>, next: Next) -> Response {
239 let route = route(request.uri().path());
240 let started = std::time::Instant::now();
241 let response = next.run(request).await;
242 let status_class = crate::pipeline::StatusClass::from_u16(response.status().as_u16());
243 #[allow(clippy::indexing_slicing)]
250 {
251 state.hot_metrics.request_counters[route.index()][status_class.index()].inc();
252 state.hot_metrics.request_duration_histograms[route.index()].observe_us(elapsed_us(started));
253 }
254 response
255}
256
257fn route(path: &str) -> crate::pipeline::Route {
261 use crate::pipeline::Route;
262 match path {
263 "/v1/chat/completions" => Route::Chat,
264 "/v1/mcp" | "/mcp" => Route::Mcp,
265 "/health" => Route::Health,
266 "/livez" => Route::Livez,
267 "/readyz" => Route::Readyz,
268 "/metrics" => Route::Metrics,
269 "/v1/token" => Route::TokenExchange,
270 "/v1/revoke" => Route::Revoke,
271 "/v1/introspect" => Route::Introspect,
272 "/admin/v1/revocations" => Route::AdminRevocation,
273 "/.well-known/jwks.json" => Route::Jwks,
274 _ if path.starts_with("/v1/mcp/") || path.starts_with("/mcp/") => Route::Mcp,
275 _ if path.starts_with("/v1/sessions/") && path.ends_with("/close") => Route::SessionClose,
276 _ if path.starts_with("/v1/sessions/") && path.ends_with("/promote") => Route::SessionPromote,
277 _ if path.starts_with("/dashboard") || path.starts_with("/api/v1/dashboard") => Route::Dashboard,
278 _ => Route::Other,
279 }
280}
281
282async fn trace_request(request: Request<Body>, next: Next) -> Response {
283 let method = request.method().clone();
284 let path = request.uri().path().to_owned();
285 let headers = request.headers();
304 let session_id = match crate::pipeline::single_header(headers, crate::pipeline::SESSION_HEADER) {
305 Ok(Some(value)) => value
306 .to_str()
307 .ok()
308 .and_then(|v| av_core::SessionId::parse(v).ok())
309 .map(|id| id.to_string())
310 .unwrap_or_else(|| " rejected".to_owned()),
311 Ok(None) => " unbound".to_owned(),
312 Err(_) => " duplicate-header".to_owned(),
313 };
314 let span = tracing::info_span!(
315 "agentvisor.request",
316 otel.kind = "server",
317 http.request.method = %method,
318 url.path = %path,
319 session.id = %session_id,
320 http.response.status_code = tracing::field::Empty,
321 );
322 let response = next.run(request).instrument(span.clone()).await;
323 span.record("http.response.status_code", response.status().as_u16());
324 response
325}
326
327async fn health() -> impl IntoResponse {
328 Json(json!({
336 "status": "ok",
337 "service": "agentvisor",
342 }))
343}
344
345async fn livez() -> impl IntoResponse {
355 Json(json!({
356 "status": "alive",
357 "service": "agentvisor",
358 }))
359}
360
361async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
375 let draining = state.draining.load(std::sync::atomic::Ordering::SeqCst);
376 let spool_writable = {
393 let dir = state.config.atif_spool_dir.clone();
394 tokio::task::spawn_blocking(move || {
395 let probe = std::path::Path::new(&dir).join(format!(".readyz-{}.tmp", av_core::new_event_uid()));
396 std::fs::write(&probe, b"readyz")
397 .and_then(|()| {
398 std::fs::remove_file(&probe)
402 })
403 .is_ok()
404 })
405 .await
406 .unwrap_or(false)
407 };
408 let revocation_health: Vec<_> = state
411 .identity
412 .as_ref()
413 .and_then(|validator| validator.revocation_store())
414 .and_then(|store| store.health())
415 .into_iter()
416 .chain(state.exchanged_revocations.health())
417 .collect();
418 let revocation_available = revocation_health.iter().all(|health| health.available);
419 let local_revocations: usize = revocation_health.iter().map(|health| health.local_entries).sum();
420 let ready = !draining && spool_writable;
421 let body = Json(json!({
422 "status": if ready { "ready" } else { "not_ready" },
423 "service": "agentvisor",
424 "checks": {
425 "draining": draining,
426 "spool_dir_writable": spool_writable,
427 "revocation_available": revocation_available,
428 "revocation_local_entries": local_revocations,
429 },
430 }));
431 if ready {
432 (StatusCode::OK, body).into_response()
433 } else {
434 (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
435 }
436}
437
438async fn cors_deny() -> Response {
450 let mut response = Response::new(Body::empty());
451 *response.status_mut() = StatusCode::NO_CONTENT;
452 response.headers_mut().insert(
453 axum::http::header::CACHE_CONTROL,
454 HeaderValue::from_static("no-store"),
455 );
456 response
460}
461
462async fn metrics(State(state): State<AppState>) -> Response {
463 state
470 .metrics
471 .gauge(
472 "av_open_sessions",
473 "Currently-open sessions in the in-memory registry",
474 )
475 .set(state.sessions.len() as u64);
476 state
482 .metrics
483 .gauge(
484 "av_worker_queue_depth",
485 "Accepted-but-not-yet-completed worker jobs",
486 )
487 .set(state.worker.queue_depth());
488 let spool_dir = state.config.atif_spool_dir.clone();
502 let (spool_bytes, spool_files) =
503 tokio::task::spawn_blocking(move || spool_footprint(std::path::Path::new(&spool_dir)))
504 .await
505 .unwrap_or((0, 0));
506 state
507 .metrics
508 .gauge(
509 "av_spool_bytes",
510 "Total bytes in the top-level ATIF spool directory",
511 )
512 .set(spool_bytes);
513 state
514 .metrics
515 .gauge(
516 "av_spool_files",
517 "File count in the top-level ATIF spool directory",
518 )
519 .set(spool_files);
520 let mut response = Response::new(Body::from(state.metrics.render()));
521 response.headers_mut().insert(
522 axum::http::header::CONTENT_TYPE,
523 HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
524 );
525 response
526}
527
528fn spool_footprint(dir: &std::path::Path) -> (u64, u64) {
533 let Ok(entries) = std::fs::read_dir(dir) else {
534 return (0, 0);
535 };
536 let mut bytes = 0u64;
537 let mut files = 0u64;
538 for entry in entries.flatten() {
539 if let Ok(metadata) = entry.metadata() {
540 if metadata.is_file() {
541 bytes = bytes.saturating_add(metadata.len());
542 files = files.saturating_add(1);
543 }
544 }
545 }
546 (bytes, files)
547}
548
549fn refuse_non_json_content_type(headers: &HeaderMap, context_label: &str) -> Option<Response> {
560 let value = headers.get(axum::http::header::CONTENT_TYPE)?;
561 let is_json = value
562 .to_str()
563 .ok()
564 .and_then(|s| s.split(';').next())
565 .map(|main| main.trim().eq_ignore_ascii_case("application/json"))
566 .unwrap_or(false);
567 if is_json {
568 return None;
569 }
570 let display = value.to_str().unwrap_or("<non-ascii>");
571 Some(pipeline_error(crate::pipeline::PipelineError::bad_request(
572 format!("{context_label} requires Content-Type: application/json, got {display:?}"),
573 )))
574}
575
576async fn chat_completions(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
577 if let Some(err) = refuse_non_json_content_type(&headers, "chat completions") {
589 return err;
590 }
591 let caller = match state.authenticate_chat_nonblocking(&headers).await {
603 Ok(caller) => caller,
604 Err(error) => {
605 if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
606 return pipeline_error(audit_error);
607 }
608 return pipeline_error(error);
609 }
610 };
611 if let Err(reason) = av_sandbox::refuse_duplicate_json_keys(&body) {
623 return pipeline_error(crate::pipeline::PipelineError::bad_request(format!(
624 "chat request rejected: {reason}"
625 )));
626 }
627 let payload: Value = match serde_json::from_slice(&body) {
628 Ok(value) => value,
629 Err(error) => return pipeline_error(crate::pipeline::PipelineError::bad_request_source(error)),
630 };
631 if av_sandbox::rpc::depth_of(&payload, 0) > av_sandbox::rpc::MAX_JSON_DEPTH {
639 return pipeline_error(crate::pipeline::PipelineError::bad_request(format!(
640 "chat request nesting depth exceeds {}",
641 av_sandbox::rpc::MAX_JSON_DEPTH
642 )));
643 }
644 let admission_started = std::time::Instant::now();
645 let mut prepared = match state
646 .prepare_authenticated_chat(&headers, payload, body.len(), caller)
647 .await
648 {
649 Ok(prepared) => prepared,
650 Err(error) => return pipeline_error(error),
651 };
652 prepared.middleware_us = elapsed_us(admission_started);
653 let session = Arc::clone(&prepared.session);
654 let identity = prepared.identity.clone();
655 let session_id = prepared.session.id.clone();
656 let middleware_us = prepared.middleware_us;
657 let forwarded = match state.forward_chat(prepared).await {
658 Ok(response) => response,
659 Err(error) => return pipeline_error_for_session(error, &session_id),
660 };
661 let crate::pipeline::ForwardedResponse {
662 response: upstream,
663 billed_prompt_tokens,
664 lease,
665 mut capture_guard,
666 } = forwarded;
667 let Some(response_permit) = capture_guard.take_permit() else {
668 let (response_marker, response_attempt_id) = capture_guard.disarm();
676 let capture_session = Arc::clone(&session);
677 if state
678 .worker
679 .try_submit(crate::pipeline::refused_response_failure_job(
680 session,
681 identity,
682 "response_capture_permit_missing".to_owned(),
683 response_marker,
684 response_attempt_id,
685 ))
686 .is_err()
687 {
688 capture_session.mark_capture_failed();
689 }
690 let mut response = lifecycle_error("durable response capture permit is missing".to_owned());
691 if let Ok(value) = HeaderValue::from_str(&session_id) {
692 response
693 .headers_mut()
694 .insert(crate::pipeline::SESSION_HEADER, value);
695 }
696 return response;
697 };
698
699 let status = upstream.status();
700 let upstream_headers = upstream.headers().clone();
701 let mut refused_encoding: Option<(String, String)> = None;
718 'encodings: for value in upstream_headers.get_all(axum::http::header::CONTENT_ENCODING) {
719 let raw = value.to_str().unwrap_or_default();
720 for token in raw.split(',') {
721 let token = token.trim();
722 if token.is_empty() || token.eq_ignore_ascii_case("identity") {
723 continue;
724 }
725 refused_encoding = Some((token.to_owned(), raw.to_owned()));
726 break 'encodings;
727 }
728 }
729 if let Some((token, raw)) = refused_encoding {
730 let (response_marker, response_attempt_id) = capture_guard.disarm();
741 let capture_session = Arc::clone(&session);
742 if response_permit
743 .submit(
744 &state.worker,
745 crate::pipeline::refused_response_failure_job(
746 session,
747 identity,
748 "upstream_unsupported_content_encoding".to_owned(),
749 response_marker,
750 response_attempt_id,
751 ),
752 )
753 .is_err()
754 {
755 capture_session.mark_capture_failed();
756 }
757 return pipeline_error_for_session(
758 crate::pipeline::PipelineError::upstream(format!(
759 "upstream responded with unsupported Content-Encoding token {token:?} \
760 (full header: {raw:?}) — the proxy is built without decompression \
761 support; enable it upstream (Accept-Encoding: identity) or rebuild \
762 with the reqwest `gzip` feature"
763 )),
764 &session_id,
765 );
766 }
767 let is_sse = is_sse_content_type(&upstream_headers);
778 let stream = upstream
779 .bytes_stream()
780 .map(|chunk| chunk.map_err(std::io::Error::other));
781 let (response_marker, response_attempt_id) = capture_guard.disarm();
785 let principal_budget = state
790 .config
791 .principal_budget
792 .as_ref()
793 .map(|spec| (crate::pipeline::principal_id_for_budget(&identity), spec.clone()));
794 let stream = AbortFinalizingStream {
795 inner: stream.boxed(),
796 session,
797 identity,
798 response_permit: Some(response_permit),
799 worker: state.worker.clone(),
800 store: Arc::clone(&state.store),
801 budget: state.config.budget.clone(),
802 finalizer: state.finalizer.clone(),
803 _lease: lease,
804 response_marker,
805 response_attempt_id,
806 response_message: String::new(),
807 response_reasoning: String::new(),
808 response_model: None,
809 response_finish_reason: None,
810 upstream_status: status,
811 response_cost_usd_micros: 0,
812 response_tool_calls: std::collections::BTreeMap::new(),
813 response_metrics: av_events::EventMetrics::default(),
814 charged_completion_tokens: 0,
815 last_reported_completion_tokens: None,
816 last_reported_prompt_tokens: None,
817 last_reported_cached_tokens: None,
818 last_reported_cost_usd_micros: None,
819 saw_chunk: false,
820 capture_attempted: false,
821 is_sse,
822 protocol_buffer: Vec::new(),
823 frame_scratch: Vec::new(),
824 pending_output: std::collections::VecDeque::new(),
825 pending_budget: None,
826 principal_budget,
827 captured_bytes: 0,
828 billed_prompt_tokens,
829 provider_adapter: Arc::clone(&state.provider_adapter),
830 completed: false,
831 ephemeral: !headers.contains_key(crate::pipeline::SESSION_HEADER),
834 };
835 let mut response = if is_sse {
836 let mut relay = Box::pin(stream);
849 match relay.next().await {
850 Some(Err(error)) => {
851 let mapped = if error.kind() == std::io::ErrorKind::QuotaExceeded {
852 crate::pipeline::PipelineError::blocked_source(error)
853 } else {
854 crate::pipeline::PipelineError::upstream_source(error)
855 };
856 return pipeline_error_for_session(mapped, &session_id);
859 }
860 Some(Ok(first)) => {
861 let head = futures::stream::once(async move { Ok::<_, std::io::Error>(first) });
862 Response::new(Body::from_stream(head.chain(relay)))
863 }
864 None => Response::new(Body::empty()),
867 }
868 } else {
869 let mut relay = Box::pin(stream);
877 let mut buffered: Vec<u8> = Vec::new();
878 loop {
879 match relay.next().await {
880 Some(Ok(bytes)) => buffered.extend_from_slice(&bytes),
881 Some(Err(error)) => {
882 let mapped = if error.kind() == std::io::ErrorKind::QuotaExceeded {
892 crate::pipeline::PipelineError::blocked_source(error)
893 } else {
894 crate::pipeline::PipelineError::upstream_source(error)
895 };
896 return pipeline_error_for_session(mapped, &session_id);
900 }
901 None => break,
902 }
903 }
904 Response::new(Body::from(buffered))
905 };
906 *response.status_mut() = status;
907 for (name, value) in &upstream_headers {
908 if is_forwardable_upstream_header(name, &state.config.upstream_auth_header) {
909 response.headers_mut().append(name.clone(), value.clone());
913 }
914 }
915 if let Ok(value) = HeaderValue::from_str(&session_id) {
916 response
917 .headers_mut()
918 .insert(crate::pipeline::SESSION_HEADER, value);
919 }
920 if let Ok(value) = HeaderValue::from_str(&middleware_us.to_string()) {
921 response
922 .headers_mut()
923 .insert(crate::pipeline::MIDDLEWARE_US_HEADER, value);
924 }
925 response.headers_mut().insert(
937 axum::http::HeaderName::from_static("x-content-type-options"),
938 HeaderValue::from_static("nosniff"),
939 );
940 if is_sse {
950 response.headers_mut().insert(
951 axum::http::HeaderName::from_static("x-accel-buffering"),
952 HeaderValue::from_static("no"),
953 );
954 }
955 response
956}
957
958fn is_forwardable_upstream_header(name: &axum::http::HeaderName, upstream_auth_header: &str) -> bool {
990 use axum::http::header;
991 if name.as_str().eq_ignore_ascii_case(upstream_auth_header) {
995 return false;
996 }
997 let is_denied = *name == header::CONTENT_LENGTH
998 || *name == header::TRANSFER_ENCODING
999 || *name == header::CONNECTION
1000 || *name == header::UPGRADE
1001 || *name == header::TE
1002 || *name == header::TRAILER
1003 || *name == header::PROXY_AUTHENTICATE
1004 || *name == header::PROXY_AUTHORIZATION
1005 || *name == header::AUTHORIZATION
1009 || *name == header::SET_COOKIE
1010 || *name == header::SERVER
1011 || *name == header::VIA
1012 || name.as_str().eq_ignore_ascii_case("keep-alive")
1013 || name.as_str().eq_ignore_ascii_case("x-powered-by")
1014 || name.as_str().eq_ignore_ascii_case("x-request-id")
1015 || name.as_str().eq_ignore_ascii_case("api-key")
1020 || name.as_str().eq_ignore_ascii_case("x-api-key")
1021 || name.as_str().eq_ignore_ascii_case("x-goog-api-key")
1022 || name.as_str().eq_ignore_ascii_case("anthropic-api-key")
1023 || name.as_str().eq_ignore_ascii_case("openai-api-key")
1024 || name.as_str().eq_ignore_ascii_case("x-auth-token")
1025 || name.as_str().eq_ignore_ascii_case("x-amz-security-token")
1026 || name.as_str().to_ascii_lowercase().starts_with("access-control-");
1029 !is_denied
1030}
1031
1032fn is_sse_content_type(headers: &HeaderMap) -> bool {
1041 headers
1042 .get(axum::http::header::CONTENT_TYPE)
1043 .and_then(|value| value.to_str().ok())
1044 .is_some_and(|value| {
1045 let head = value.split(';').next().unwrap_or("").trim();
1046 head.eq_ignore_ascii_case("text/event-stream")
1047 })
1048}
1049
1050fn tool_audit_gate(state: &AppState, key: &str) -> Arc<tokio::sync::Mutex<()>> {
1054 let mut gates = state.tool_audit_gates.lock();
1055 gates.retain(|_, gate| Arc::strong_count(gate) > 1);
1056 Arc::clone(gates.entry(key.to_owned()).or_default())
1057}
1058
1059async fn mcp_call(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
1060 mcp_entry(state, headers, body, crate::mcp::Scope::All).await
1061}
1062
1063async fn mcp_backend_call(
1066 State(state): State<AppState>,
1067 Path(backend): Path<String>,
1068 headers: HeaderMap,
1069 body: Bytes,
1070) -> Response {
1071 if state.backend_router.get(&backend).is_none() {
1072 return (
1073 StatusCode::NOT_FOUND,
1074 Json(openai_error_body(StatusCode::NOT_FOUND, "unknown MCP backend")),
1075 )
1076 .into_response();
1077 }
1078 mcp_entry(state, headers, body, crate::mcp::Scope::Backend(backend)).await
1079}
1080
1081async fn mcp_get(State(state): State<AppState>, headers: HeaderMap) -> Response {
1084 if let Err(response) = crate::mcp::check_origin(&state, &headers) {
1085 return *response;
1086 }
1087 let mut response = StatusCode::METHOD_NOT_ALLOWED.into_response();
1088 response.headers_mut().insert(
1089 axum::http::header::ALLOW,
1090 HeaderValue::from_static("POST, DELETE"),
1091 );
1092 response
1093}
1094
1095async fn mcp_delete(State(state): State<AppState>, headers: HeaderMap) -> Response {
1099 if let Err(response) = crate::mcp::check_origin(&state, &headers) {
1100 return *response;
1101 }
1102 let caller = match lifecycle_caller(&state, &headers, &state.config.session_close_scope).await {
1103 Ok(caller) => caller,
1104 Err(error) => return pipeline_error(error),
1105 };
1106 let mut headers = headers;
1107 let session = match crate::mcp::bind_session(&state, &mut headers, &caller) {
1108 Ok(Some(session)) => session,
1109 Ok(None) => {
1110 return (
1111 StatusCode::BAD_REQUEST,
1112 Json(openai_error_body(
1113 StatusCode::BAD_REQUEST,
1114 "MCP-Session-Id is required",
1115 )),
1116 )
1117 .into_response()
1118 }
1119 Err(response) => return *response,
1120 };
1121 state.mcp_backends.forget_agent_session(&session.agent_session);
1122 let Some(agent_session) = state.sessions.get(&session.agent_session) else {
1123 return StatusCode::NO_CONTENT.into_response();
1125 };
1126 if let Err(error) = caller.authorize_session(&state, &agent_session, &state.config.session_close_scope) {
1127 return pipeline_error(error);
1128 }
1129 match state
1130 .finalizer
1131 .close_session(agent_session, StopReason::SessionClosed)
1132 .await
1133 {
1134 Ok(outcome) => Json(outcome).into_response(),
1135 Err(error) => finalize_error_response(&error),
1136 }
1137}
1138
1139async fn mcp_entry(state: AppState, headers: HeaderMap, body: Bytes, scope: crate::mcp::Scope) -> Response {
1140 if let Some(err) = refuse_non_json_content_type(&headers, "MCP") {
1149 return err;
1150 }
1151 let admission_permit = match Arc::clone(&state.mcp_admission).try_acquire_owned() {
1161 Ok(permit) => permit,
1162 Err(_) => {
1163 state.mcp_admission_refusals_counter.inc();
1168 let mut response = (
1169 StatusCode::SERVICE_UNAVAILABLE,
1170 Json(openai_error_body(
1171 StatusCode::SERVICE_UNAVAILABLE,
1172 "MCP admission at capacity; retry shortly",
1173 )),
1174 )
1175 .into_response();
1176 response
1177 .headers_mut()
1178 .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("1"));
1179 return response;
1180 }
1181 };
1182 let inflight_guard = state.mcp_inflight.enter();
1204 match tokio::spawn(async move {
1205 let _admission_permit = admission_permit;
1206 let _inflight_guard = inflight_guard;
1207 mcp_call_inner(state, headers, body, scope).await
1208 })
1209 .await
1210 {
1211 Ok(response) => response,
1212 Err(join_error) => lifecycle_error(format!("tool call task failed: {join_error}")),
1213 }
1214}
1215
1216async fn refund_tool_admission(
1219 state: &AppState,
1220 execution: &ToolExecution,
1221 payout_micros: u64,
1222 principal_id: Option<&str>,
1223) -> Result<(), String> {
1224 let store = Arc::clone(&state.store);
1225 let session_id = execution.session_id.clone();
1226 let tool = execution.tool.clone();
1227 let budget = state.config.budget.clone();
1228 let principal = principal_id
1229 .zip(state.config.principal_budget.as_ref())
1230 .map(|(id, spec)| (id.to_owned(), spec.clone()));
1231 tokio::task::spawn_blocking(move || {
1232 av_state::ActionBudget::new(store.as_ref(), &session_id, &budget)
1233 .refund_tool_call(&tool, payout_micros);
1234 if let Some((principal_id, spec)) = principal {
1235 av_state::ActionBudget::for_principal(store.as_ref(), &principal_id, &spec)
1236 .refund_tool_call(&tool, payout_micros);
1237 }
1238 })
1239 .await
1240 .map_err(|_| "tool admission refund task failed".to_owned())
1241}
1242
1243async fn mcp_call_inner(
1244 state: AppState,
1245 headers: HeaderMap,
1246 body: Bytes,
1247 scope: crate::mcp::Scope,
1248) -> Response {
1249 if let Err(response) = crate::mcp::check_origin(&state, &headers) {
1252 return *response;
1253 }
1254 if let Err(response) = crate::mcp::check_protocol_version(&headers) {
1255 return *response;
1256 }
1257 let mut headers = headers;
1258 let forwarding_configured = state.config.tool_upstream_url.is_some() || !state.backend_router.is_empty();
1262 let auth_state = state.clone();
1263 let auth_headers = headers.clone();
1264 let authenticated =
1265 match tokio::task::spawn_blocking(move || auth_state.authenticate(&auth_headers)).await {
1266 Ok(Ok(caller)) => caller,
1267 Ok(Err(error)) => {
1268 if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
1269 return pipeline_error(audit_error);
1270 }
1271 return pipeline_error(error);
1272 }
1273 Err(error) => return pipeline_error(crate::pipeline::PipelineError::unavailable_source(error)),
1274 };
1275 let mcp_session = match crate::mcp::bind_session(&state, &mut headers, &authenticated) {
1278 Ok(session) => session,
1279 Err(response) => return *response,
1280 };
1281 match crate::mcp::answer(
1282 &state,
1283 &headers,
1284 &authenticated,
1285 crate::mcp::classify(&body),
1286 &scope,
1287 mcp_session.as_ref(),
1288 )
1289 .await
1290 {
1291 Ok(Some(response)) => return response,
1292 Ok(None) => {}
1293 Err(error) => {
1294 if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
1295 return pipeline_error(audit_error);
1296 }
1297 return pipeline_error(error);
1298 }
1299 }
1300 let (execution, unaudited_outcome) = if forwarding_configured {
1301 if let Err(error) = state.refuse_unauthenticated_tool_call(&headers) {
1307 return pipeline_error(error);
1308 }
1309 match ToolExecution::from_request(&state.config.atif_spool_dir, &headers, &body, state.journal_key) {
1310 Ok(mut execution) => {
1311 let required_scope = crate::pipeline::tool_scope(&execution.tool);
1312 let identity = match authenticated.authorize(&state, Some(&required_scope)) {
1313 Ok(identity) => identity,
1314 Err(error) => {
1315 if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
1316 return pipeline_error(audit_error);
1317 }
1318 return pipeline_error(error);
1319 }
1320 };
1321 if let Err(error) = execution.bind_principal(&identity) {
1322 return pipeline_error(error);
1323 }
1324 if let Some(principal) = authenticated.principal_digest() {
1325 execution.principal_digest = av_core::digest::sha256_hex(
1326 format!("{}:{principal}", execution.principal_digest).as_bytes(),
1327 );
1328 }
1329 let authorized_session = |missing_context: &str| -> Result<(), Box<Response>> {
1336 let Some(session) = state.sessions.get(&execution.session_id) else {
1337 return Err(Box::new(pipeline_error(
1338 crate::pipeline::PipelineError::bad_request(format!(
1339 "unknown session for {missing_context}"
1340 )),
1341 )));
1342 };
1343 authenticated
1344 .authorize_session(&state, &session, &required_scope)
1345 .map_err(|error| Box::new(pipeline_error(error)))
1346 };
1347 match execution.load().await {
1348 Ok(ToolExecutionState::Completed(outcome)) => {
1349 if let Err(response) = authorized_session("cached tool result") {
1350 return *response;
1351 }
1352 return outcome.into_response();
1353 }
1354 Ok(ToolExecutionState::Unaudited(outcome)) => {
1355 if let Err(response) = authorized_session("pending tool audit") {
1356 return *response;
1357 }
1358 (Some(execution), Some(outcome))
1359 }
1360 Ok(ToolExecutionState::Pending) => {
1361 if let Err(response) = authorized_session("pending tool execution") {
1362 return *response;
1363 }
1364 return (
1365 StatusCode::CONFLICT,
1366 Json(json!({"error": TOOL_OUTCOME_UNCERTAIN})),
1367 )
1368 .into_response();
1369 }
1370 Ok(ToolExecutionState::Missing) => (Some(execution), None),
1371 Err(error) if error == TOOL_REQUEST_MISMATCH => {
1372 let Some(session) = state.sessions.get(&execution.session_id) else {
1381 return pipeline_error(crate::pipeline::PipelineError::bad_request(
1382 "unknown session for tool execution".to_owned(),
1383 ));
1384 };
1385 if let Err(error) = authenticated.authorize_session(
1386 &state,
1387 &session,
1388 &crate::pipeline::tool_scope(&execution.tool),
1389 ) {
1390 return pipeline_error(error);
1391 }
1392 return (
1393 StatusCode::CONFLICT,
1394 Json(json!({"error": TOOL_REQUEST_MISMATCH})),
1395 )
1396 .into_response();
1397 }
1398 Err(error) => return lifecycle_error(error),
1399 }
1400 }
1401 Err(error) => return pipeline_error(error),
1402 }
1403 } else {
1404 (None, None)
1405 };
1406 if let (Some(execution), Some(_)) = (execution.as_ref(), unaudited_outcome.as_ref()) {
1407 let _lease = match state.lease_session(&headers) {
1408 Ok(lease) => lease,
1409 Err(error) => return pipeline_error(error),
1410 };
1411 let gate = tool_audit_gate(&state, &execution.key);
1417 let _gate = gate.lock().await;
1418 let outcome = match execution.load().await {
1419 Ok(ToolExecutionState::Completed(outcome)) => return outcome.into_response(),
1420 Ok(ToolExecutionState::Unaudited(outcome)) => outcome,
1421 Ok(_) => return lifecycle_error("tool execution state changed during audit".to_owned()),
1422 Err(error) => return lifecycle_error(error),
1423 };
1424 let completion_permit = match state.worker.try_reserve(&execution.session_id) {
1425 Ok(permit) => permit,
1426 Err(error) => {
1427 return pipeline_error(crate::pipeline::PipelineError::unavailable_source(error));
1428 }
1429 };
1430 let Some(session) = state.sessions.get(&execution.session_id) else {
1431 return lifecycle_error("tool session disappeared".to_owned());
1432 };
1433 return complete_tool_audit(&state, execution, outcome, completion_permit, session).await;
1434 }
1435 let mut exchange_token = None;
1444 let mut preflight_denial = None;
1445 if let crate::mcp::Scope::Backend(name) = &scope {
1448 if let Ok(request) = av_sandbox::parse_tool_call(&body) {
1449 let routed = state
1450 .backend_router
1451 .resolve(&request.tool)
1452 .map(|backend| backend.name.as_str());
1453 if routed != Some(name.as_str()) {
1454 let code = av_sandbox::DenialCode::NoBackend;
1455 let reason = format!("tool {:?} is not served by backend {name:?}", request.tool);
1456 preflight_denial = Some(ToolVerdict::Blocked {
1457 tool: request.tool.clone(),
1458 stage: "policy",
1459 policy: Some("backend.route".into()),
1460 denial_code: code,
1461 response: av_sandbox::rpc::denial_error(request.id.as_ref(), code, &reason),
1462 reason,
1463 elapsed_us: 0,
1464 });
1465 }
1466 }
1467 }
1468 if let (None, Some(authzen)) = (&preflight_denial, &state.authzen) {
1473 if let Ok(request) = av_sandbox::parse_tool_call(&body) {
1474 let facts = authenticated.facts();
1475 let now_s = av_core::time::now_ms() / 1000;
1476 if let crate::authz::AuthzDecision::Permit { intent } =
1477 state.pdp.decide_for(&request.tool, now_s, &facts)
1478 {
1479 let missions: Vec<String> = state
1480 .pdp
1481 .applicable_missions(&facts)
1482 .into_iter()
1483 .map(|mission| mission.id.clone())
1484 .collect();
1485 let backend = state
1486 .backend_router
1487 .resolve(&request.tool)
1488 .map(|backend| backend.name.as_str());
1489 let session = crate::pipeline::single_header(&headers, crate::pipeline::SESSION_HEADER)
1490 .ok()
1491 .flatten()
1492 .and_then(|value| value.to_str().ok());
1493 let evaluation = crate::authzen::evaluation_request(
1494 &authenticated,
1495 &request.tool,
1496 &intent,
1497 backend,
1498 session,
1499 &missions,
1500 );
1501 match authzen.evaluate(&evaluation).await {
1502 Ok(crate::authzen::AuthzenDecision::Permit) => {
1503 state
1504 .metrics
1505 .counter(
1506 "av_authzen_decisions_total{decision=\"permit\"}",
1507 "External AuthZEN decisions by outcome",
1508 )
1509 .inc();
1510 }
1511 Ok(crate::authzen::AuthzenDecision::Deny(reason)) => {
1512 state
1513 .metrics
1514 .counter(
1515 "av_authzen_decisions_total{decision=\"deny\"}",
1516 "External AuthZEN decisions by outcome",
1517 )
1518 .inc();
1519 let code = av_sandbox::DenialCode::PdpDenied;
1520 preflight_denial = Some(ToolVerdict::Blocked {
1521 tool: request.tool.clone(),
1522 stage: "policy",
1523 policy: Some("pdp.authzen".into()),
1524 denial_code: code,
1525 response: av_sandbox::rpc::denial_error(request.id.as_ref(), code, &reason),
1526 reason,
1527 elapsed_us: 0,
1528 });
1529 }
1530 Err(error) => {
1531 state
1532 .metrics
1533 .counter(
1534 "av_authzen_decisions_total{decision=\"error\"}",
1535 "External AuthZEN decisions by outcome",
1536 )
1537 .inc();
1538 tracing::warn!(%error, "external policy decision point unavailable; refusing the call");
1539 return pipeline_error(crate::pipeline::PipelineError::unavailable(
1540 "the external policy decision point is unavailable; retry shortly".to_owned(),
1541 ));
1542 }
1543 }
1544 }
1545 }
1546 }
1547 if let (Some(execution), None) = (&execution, &preflight_denial) {
1548 if let Some(backend) = state
1549 .backend_router
1550 .resolve(&execution.tool)
1551 .filter(|backend| backend.auth_mode == crate::backend::BackendAuthMode::Exchange)
1552 {
1553 match exchange_for_backend(&state, &authenticated, &backend.name, &execution.tool) {
1554 Ok(token) => exchange_token = Some(token),
1555 Err(crate::pipeline::PipelineError::Blocked { context: reason, .. }) => {
1556 let request = av_sandbox::parse_tool_call(&body).ok();
1557 let code = av_sandbox::DenialCode::PolicyDenied;
1558 preflight_denial = Some(ToolVerdict::Blocked {
1559 tool: execution.tool.clone(),
1560 stage: "policy",
1561 policy: Some("backend.exchange".into()),
1562 denial_code: code,
1563 response: av_sandbox::rpc::denial_error(
1564 request.as_ref().and_then(|r| r.id.as_ref()),
1565 code,
1566 &reason,
1567 ),
1568 reason,
1569 elapsed_us: 0,
1570 });
1571 }
1572 Err(error) => return pipeline_error(error),
1573 }
1574 }
1575 }
1576 let intent_caller = authenticated.clone();
1578 match state
1579 .intercept_authenticated_nonblocking(&headers, &body, authenticated, preflight_denial)
1580 .await
1581 {
1582 Ok(ToolVerdict::Allowed {
1583 tool,
1584 budget_remaining,
1585 elapsed_us,
1586 payout_micros,
1587 principal_id,
1588 }) => {
1589 let resolved_backend = state.backend_router.resolve(&tool);
1590 if let Some(url) = resolved_backend
1591 .map(|b| b.url.as_str())
1592 .or(state.config.tool_upstream_url.as_deref())
1593 {
1594 let execution = match execution {
1595 Some(execution) => execution,
1596 None => return lifecycle_error("tool execution state is missing".to_owned()),
1597 };
1598 let refund_admission =
1607 || refund_tool_admission(&state, &execution, payout_micros, principal_id.as_deref());
1608 let now_s = av_core::time::now_ms() / 1000;
1609 let intent = state.pdp.intent_for(&tool);
1613 let audience =
1614 resolved_backend.map_or(crate::backend::DEFAULT_BACKEND_NAME, |b| b.name.as_str());
1615 let intent_token =
1616 state
1617 .pdp
1618 .mint_intent_token(&tool, &intent, &intent_caller.facts(), audience, now_s);
1619 let credential: Option<(axum::http::HeaderName, HeaderValue)> =
1623 if let Some(token) = &exchange_token {
1624 crate::mcp::bearer_header(token)
1625 } else if let Some(backend) = resolved_backend {
1626 if backend.auth_mode == crate::backend::BackendAuthMode::ForwardToken {
1627 let Some(header) = crate::mcp::caller_bearer(&headers) else {
1628 if let Err(error) = refund_admission().await {
1629 return lifecycle_error(error);
1630 }
1631 return pipeline_error(crate::pipeline::PipelineError::Unauthorized(
1632 "this backend requires the caller's bearer token".to_owned(),
1633 ));
1634 };
1635 Some(header)
1636 } else {
1637 backend.auth_header.clone()
1638 }
1639 } else {
1640 state
1641 .tool_auth
1642 .clone()
1643 .map(|bearer| (axum::http::header::AUTHORIZATION, bearer))
1644 };
1645 let intent_header = intent_token
1646 .as_deref()
1647 .and_then(|token| HeaderValue::from_str(token).ok())
1648 .map(|value| (axum::http::HeaderName::from_static("x-av-intent-token"), value));
1649 let _lease = match state.lease_session(&headers) {
1650 Ok(lease) => lease,
1651 Err(error) => {
1652 if let Err(error) = refund_admission().await {
1653 return lifecycle_error(error);
1654 }
1655 return pipeline_error(error);
1656 }
1657 };
1658 let completion_permit = match state.worker.try_reserve(&execution.session_id) {
1659 Ok(permit) => permit,
1660 Err(error) => {
1661 if let Err(error) = refund_admission().await {
1662 return lifecycle_error(error);
1663 }
1664 return pipeline_error(crate::pipeline::PipelineError::unavailable(
1665 error.to_string(),
1666 ));
1667 }
1668 };
1669 match execution.claim().await {
1670 Ok(()) => {}
1671 Err(ClaimError::Race) => {
1672 tracing::warn!(
1676 session = %execution.session_id,
1677 "concurrent tool execution claim lost; refunding budget"
1678 );
1679 if let Err(error) = refund_admission().await {
1683 return lifecycle_error(error);
1684 }
1685 return (
1686 StatusCode::CONFLICT,
1687 Json(json!({"error": TOOL_OUTCOME_UNCERTAIN})),
1688 )
1689 .into_response();
1690 }
1691 Err(ClaimError::Backend(error)) => {
1692 tracing::warn!(
1698 session = %execution.session_id,
1699 %error,
1700 "tool execution claim failed; refunding budget"
1701 );
1702 if let Err(error) = refund_admission().await {
1703 return lifecycle_error(error);
1704 }
1705 return pipeline_error(crate::pipeline::PipelineError::unavailable(
1706 "tool execution intent could not be persisted".to_owned(),
1707 ));
1708 }
1709 }
1710 let mark_incomplete = || {
1715 if let Some(session) = state.sessions.get(&execution.session_id) {
1716 session.mark_capture_failed();
1717 }
1718 };
1719 let tool_timeout = std::time::Duration::from_secs(
1723 state
1724 .config
1725 .mcp_request_timeout_s
1726 .unwrap_or(crate::config::DEFAULT_MCP_REQUEST_TIMEOUT_S),
1727 );
1728 if let Some(backend) = resolved_backend
1729 .filter(|backend| backend.transport == crate::config::BackendTransport::Mcp)
1730 {
1731 let request_id = serde_json::from_slice::<Value>(&body)
1735 .ok()
1736 .and_then(|request| request.get("id").cloned())
1737 .unwrap_or(Value::Null);
1738 let endpoint = crate::mcp_client::BackendEndpoint {
1739 url: url.to_owned(),
1740 auth: credential,
1741 extra: intent_header.into_iter().collect(),
1742 deadline: tokio::time::Instant::now() + tool_timeout,
1743 };
1744 let exchanged = state
1745 .mcp_backends
1746 .request(
1747 &execution.session_id,
1748 &backend.name,
1749 &endpoint,
1750 &body,
1751 &request_id,
1752 )
1753 .await;
1754 let outcome = match exchanged {
1755 Ok((status, bytes, content_type)) => ToolOutcome {
1756 status,
1757 body_hex: hex::encode(&bytes),
1758 content_type,
1759 },
1760 Err(crate::mcp_client::McpError::NotDelivered(category)) => {
1761 match execution.release_unexecuted().await {
1765 Ok(()) => {
1766 if let Err(error) = refund_admission().await {
1767 return lifecycle_error(error);
1768 }
1769 }
1770 Err(release_error) => {
1771 mark_incomplete();
1772 tracing::warn!(
1773 session = %execution.session_id,
1774 error = %release_error,
1775 "failed to release undelivered MCP tool intent; charge kept, key stays claimed"
1776 );
1777 }
1778 }
1779 return pipeline_error(crate::pipeline::PipelineError::upstream(format!(
1780 "forward tool call: {category}"
1781 )));
1782 }
1783 Err(crate::mcp_client::McpError::AfterDelivery(error)) => ToolOutcome {
1784 status: StatusCode::BAD_GATEWAY.as_u16(),
1785 body_hex: hex::encode(
1786 serde_json::to_vec(&json!({
1787 "error": format!("tool executed but its response could not be read: {error}"),
1788 }))
1789 .unwrap_or_default(),
1790 ),
1791 content_type: Some("application/json".to_owned()),
1792 },
1793 };
1794 let gate = tool_audit_gate(&state, &execution.key);
1795 let _gate = gate.lock().await;
1796 if let Err(error) = execution.persist(&outcome).await {
1797 mark_incomplete();
1798 return lifecycle_error(error);
1799 }
1800 let Some(session) = state.sessions.get(&execution.session_id) else {
1801 return lifecycle_error("tool session disappeared".to_owned());
1802 };
1803 return complete_tool_audit(&state, &execution, outcome, completion_permit, session)
1804 .await;
1805 }
1806 let mut tool_request = state
1807 .backend_client
1808 .post(url)
1809 .timeout(tool_timeout)
1810 .header(axum::http::header::CONTENT_TYPE, "application/json")
1811 .body(body);
1812 if let Some((name, value)) = &credential {
1813 tool_request = tool_request.header(name.clone(), value.clone());
1814 }
1815 if let Some((name, value)) = &intent_header {
1816 tool_request = tool_request.header(name.clone(), value.clone());
1817 }
1818 match tool_request.send().await {
1819 Ok(upstream) => {
1820 let status = upstream.status();
1821 let upstream_headers = upstream.headers();
1832 let mut refused_encoding: Option<(String, String)> = None;
1833 'tool_encodings: for value in
1834 upstream_headers.get_all(axum::http::header::CONTENT_ENCODING)
1835 {
1836 let raw = value.to_str().unwrap_or_default();
1837 for token in raw.split(',') {
1838 let token = token.trim();
1839 if token.is_empty() || token.eq_ignore_ascii_case("identity") {
1840 continue;
1841 }
1842 refused_encoding = Some((token.to_owned(), raw.to_owned()));
1843 break 'tool_encodings;
1844 }
1845 }
1846 if let Some((token, raw)) = refused_encoding {
1847 let failure = ToolOutcome {
1848 status: StatusCode::BAD_GATEWAY.as_u16(),
1849 body_hex: hex::encode(
1850 serde_json::to_vec(&json!({
1851 "error": format!(
1852 "tool upstream responded with unsupported Content-Encoding token {token:?} (full header: {raw:?})",
1853 ),
1854 }))
1855 .unwrap_or_default(),
1856 ),
1857 content_type: Some("application/json".to_owned()),
1858 };
1859 let gate = tool_audit_gate(&state, &execution.key);
1860 let _gate = gate.lock().await;
1861 if let Err(error) = execution.persist(&failure).await {
1862 mark_incomplete();
1863 return lifecycle_error(error);
1864 }
1865 let session = match state.sessions.get(&execution.session_id) {
1866 Some(session) => session,
1867 None => return lifecycle_error("tool session disappeared".to_owned()),
1868 };
1869 return complete_tool_audit(
1870 &state,
1871 &execution,
1872 failure,
1873 completion_permit,
1874 session,
1875 )
1876 .await;
1877 }
1878 match read_limited_tool_response(upstream).await {
1879 Ok((bytes, content_type)) => {
1880 let outcome = ToolOutcome {
1881 status: status.as_u16(),
1882 body_hex: hex::encode(&bytes),
1883 content_type,
1884 };
1885 let gate = tool_audit_gate(&state, &execution.key);
1891 let _gate = gate.lock().await;
1892 if let Err(error) = execution.persist(&outcome).await {
1893 mark_incomplete();
1894 return lifecycle_error(error);
1895 }
1896 let session = match state.sessions.get(&execution.session_id) {
1897 Some(session) => session,
1898 None => return lifecycle_error("tool session disappeared".to_owned()),
1899 };
1900 complete_tool_audit(&state, &execution, outcome, completion_permit, session)
1901 .await
1902 }
1903 Err(error) => {
1904 let failure = ToolOutcome {
1916 status: StatusCode::BAD_GATEWAY.as_u16(),
1917 body_hex: hex::encode(
1918 serde_json::to_vec(&json!({
1919 "error": format!("tool executed but its response could not be read: {error}"),
1920 }))
1921 .unwrap_or_default(),
1922 ),
1923 content_type: Some("application/json".to_owned()),
1924 };
1925 let gate = tool_audit_gate(&state, &execution.key);
1926 let _gate = gate.lock().await;
1927 if execution.persist(&failure).await.is_ok() {
1928 if let Some(session) = state.sessions.get(&execution.session_id) {
1929 return complete_tool_audit(
1930 &state,
1931 &execution,
1932 failure,
1933 completion_permit,
1934 session,
1935 )
1936 .await;
1937 }
1938 }
1939 mark_incomplete();
1940 pipeline_error(crate::pipeline::PipelineError::upstream(format!(
1941 "read tool response: {error}"
1942 )))
1943 }
1944 }
1945 }
1946 Err(error) => {
1947 let category = crate::pipeline::classify_upstream_error(&error);
1962 tracing::warn!(
1963 category = category,
1964 error.status = ?error.status(),
1965 error.is_timeout = error.is_timeout(),
1966 error.is_connect = error.is_connect(),
1967 error.is_request = error.is_request(),
1968 "tool upstream forwarding failed"
1969 );
1970 if error.is_connect() {
1981 match execution.release_unexecuted().await {
1982 Ok(()) => {
1983 if let Err(error) = refund_admission().await {
1984 return lifecycle_error(error);
1985 }
1986 }
1987 Err(release_error) => {
1988 mark_incomplete();
1989 tracing::warn!(
1990 session = %execution.session_id,
1991 error = %release_error,
1992 "failed to release unexecuted tool intent; charge kept, key stays claimed"
1993 );
1994 }
1995 }
1996 } else {
1997 mark_incomplete();
2000 }
2001 pipeline_error(crate::pipeline::PipelineError::upstream(format!(
2005 "forward tool call: {category}"
2006 )))
2007 }
2008 }
2009 } else {
2010 let request_id = serde_json::from_slice::<Value>(&body)
2016 .ok()
2017 .and_then(|request| request.get("id").cloned())
2018 .unwrap_or(Value::Null);
2019 (
2020 StatusCode::OK,
2021 Json(json!({
2022 "jsonrpc": "2.0",
2023 "id": request_id,
2024 "result": {
2025 "allowed": true,
2026 "tool": tool,
2027 "budget_remaining": budget_remaining,
2028 "decision_us": elapsed_us,
2029 },
2030 })),
2031 )
2032 .into_response()
2033 }
2034 }
2035 Ok(ToolVerdict::Blocked { response, stage, .. }) => {
2036 let status = if stage == "parse" {
2040 StatusCode::BAD_REQUEST
2041 } else {
2042 StatusCode::FORBIDDEN
2043 };
2044 (status, Json(response)).into_response()
2045 }
2046 Err(error) => pipeline_error(error),
2047 Ok(_) => {
2055 tracing::error!(
2056 "unhandled ToolVerdict variant reached routes::tool_forward; failing closed. \
2057 This is a bug — extend the match to handle the new variant."
2058 );
2059 pipeline_error(crate::pipeline::PipelineError::unavailable(
2060 "tool sandbox produced an unhandled verdict; refusing to proceed".to_owned(),
2061 ))
2062 }
2063 }
2064}
2065
2066const MAX_TOOL_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
2067
2068async fn read_limited_tool_response(response: reqwest::Response) -> Result<(Bytes, Option<String>), String> {
2077 let content_type = response
2078 .headers()
2079 .get(axum::http::header::CONTENT_TYPE)
2080 .and_then(|value| value.to_str().ok())
2081 .map(str::to_owned);
2082 let mut stream = response.bytes_stream();
2083 let mut body = Vec::new();
2084 while let Some(chunk) = stream.next().await {
2085 let chunk = chunk.map_err(|error| {
2091 let category = crate::pipeline::classify_upstream_error(&error);
2092 tracing::warn!(
2093 category = category,
2094 error.status = ?error.status(),
2095 error.is_timeout = error.is_timeout(),
2096 error.is_connect = error.is_connect(),
2097 error.is_body = error.is_body(),
2098 "tool upstream stream chunk failed"
2099 );
2100 category.to_owned()
2101 })?;
2102 let next = body
2103 .len()
2104 .checked_add(chunk.len())
2105 .ok_or_else(|| "tool response size overflow".to_owned())?;
2106 if next > MAX_TOOL_RESPONSE_BYTES {
2107 return Err(format!("tool response exceeds {MAX_TOOL_RESPONSE_BYTES} bytes"));
2108 }
2109 body.extend_from_slice(&chunk);
2110 }
2111 Ok((Bytes::from(body), content_type))
2112}
2113
2114fn tool_response(status: StatusCode, bytes: Bytes, content_type: Option<&str>) -> Response {
2119 let mut response = (status, bytes).into_response();
2120 let value = content_type
2121 .and_then(|ct| HeaderValue::from_str(ct).ok())
2122 .unwrap_or_else(|| HeaderValue::from_static("application/json"));
2123 response
2124 .headers_mut()
2125 .insert(axum::http::header::CONTENT_TYPE, value);
2126 response.headers_mut().insert(
2132 axum::http::HeaderName::from_static("x-content-type-options"),
2133 HeaderValue::from_static("nosniff"),
2134 );
2135 response
2136}
2137
2138async fn complete_tool_audit(
2139 state: &AppState,
2140 execution: &ToolExecution,
2141 outcome: ToolOutcome,
2142 completion_permit: crate::worker::WorkerPermit,
2143 session: Arc<crate::session::Session>,
2144) -> Response {
2145 let status = StatusCode::from_u16(outcome.status).unwrap_or(StatusCode::BAD_GATEWAY);
2146 let bytes = match decode_cached_tool_body(&outcome.body_hex) {
2147 Ok(bytes) => bytes,
2148 Err(response) => return *response,
2149 };
2150 let success = status.is_success();
2151 let already_emitted = state.tool_audits_emitted.lock().contains(&execution.key);
2156 if !already_emitted {
2157 completion_permit.submit(crate::worker::WorkerJob {
2158 session: Arc::clone(&session),
2159 identity: session.current_identity(),
2160 class: av_events::EventClass::Session,
2161 payload: json!({
2162 "action": "tool_completed",
2163 "execution_key": &execution.key,
2164 "status": status.as_u16(),
2165 "response_sha256": av_core::digest::sha256_hex(&bytes),
2166 }),
2167 text: String::new(),
2168 analyze_loop: false,
2169 status: if success {
2170 av_events::StatusId::Success
2171 } else {
2172 av_events::StatusId::Failure
2173 },
2174 stop_reason: (!success).then_some(StopReason::Other),
2175 native_stop_reason: None,
2176 metrics: av_events::EventMetrics::default(),
2177 cost_usd_micros: 0,
2178 prompt_token_correction: 0,
2179 atif: Some(crate::worker::AtifCapture {
2180 source: av_atif::Source::System,
2181 message: Value::String(String::from_utf8_lossy(&bytes).into_owned()),
2182 reasoning_content: None,
2183 model_name: None,
2184 tool_calls: None,
2185 observation: None,
2186 llm_call_count: None,
2187 }),
2188 response_marker: None,
2189 response_attempt: None,
2190 });
2191 session.wait_for_worker_jobs().await;
2192 if session.capture_failed() {
2193 return lifecycle_error("tool completed but completion audit failed".to_owned());
2194 }
2195 state.tool_audits_emitted.lock().insert(execution.key.clone());
2198 }
2199 if let Err(error) = execution.mark_audited().await {
2200 return lifecycle_error(error);
2201 }
2202 state.tool_audits_emitted.lock().remove(&execution.key);
2203 tool_response(status, bytes, outcome.content_type.as_deref())
2208}
2209
2210#[derive(Clone, serde::Serialize, serde::Deserialize)]
2211struct ToolOutcome {
2212 status: u16,
2213 body_hex: String,
2214 #[serde(default)]
2221 content_type: Option<String>,
2222}
2223
2224impl ToolOutcome {
2225 fn into_response(self) -> Response {
2226 let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::BAD_GATEWAY);
2227 match decode_cached_tool_body(&self.body_hex) {
2228 Ok(body) => tool_response(status, body, self.content_type.as_deref()),
2229 Err(response) => *response,
2230 }
2231 }
2232}
2233
2234fn decode_cached_tool_body(body_hex: &str) -> Result<Bytes, Box<Response>> {
2256 match hex::decode(body_hex) {
2257 Ok(bytes) => Ok(Bytes::from(bytes)),
2258 Err(error) => {
2259 tracing::error!(
2260 %error,
2261 "cached tool outcome body_hex failed to decode; \
2262 possible control-key compromise or on-disk MAC bypass"
2263 );
2264 Err(Box::new(lifecycle_error(
2265 "cached tool outcome is corrupt".to_owned(),
2266 )))
2267 }
2268 }
2269}
2270
2271enum ToolExecutionState {
2272 Missing,
2273 Pending,
2274 Unaudited(ToolOutcome),
2275 Completed(ToolOutcome),
2276}
2277
2278#[derive(Debug)]
2287enum ClaimError {
2288 Race,
2293 Backend(String),
2297}
2298
2299const TOOL_REQUEST_MISMATCH: &str = "JSON-RPC id is already bound to a different tool request or principal";
2300const TOOL_OUTCOME_UNCERTAIN: &str = "tool execution outcome is uncertain; refusing duplicate execution";
2304
2305pub(crate) async fn ensure_tool_executions_resolved(
2308 spool: &std::path::Path,
2309 session_id: &str,
2310 control_key: &[u8; 32],
2311) -> Result<(), String> {
2312 let directory = spool.join(crate::spool::TOOL_EXECUTIONS);
2313 let session_id = session_id.to_owned();
2314 let control_key = *control_key;
2315 tokio::task::spawn_blocking(move || {
2316 ensure_tool_executions_resolved_with_limit(
2317 &directory,
2318 &session_id,
2319 &control_key,
2320 crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK,
2321 )
2322 })
2323 .await
2324 .map_err(|_| "tool evidence lookup task failed".to_owned())?
2325}
2326
2327fn ensure_tool_executions_resolved_with_limit(
2328 directory: &std::path::Path,
2329 session_id: &str,
2330 control_key: &[u8; 32],
2331 max_dirents: usize,
2332) -> Result<(), String> {
2333 let entries = match std::fs::read_dir(directory) {
2334 Ok(entries) => entries,
2335 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2336 Err(_) => return Err("tool evidence directory is unavailable".to_owned()),
2337 };
2338 for (index, entry) in entries.enumerate() {
2339 if index >= max_dirents {
2340 return Err("tool evidence directory exceeds the bounded scan limit".to_owned());
2341 }
2342 let path = entry
2343 .map_err(|_| "tool evidence entry is unavailable".to_owned())?
2344 .path();
2345 let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
2346 continue;
2347 };
2348 let Some(key) = name.strip_suffix(crate::spool::TOOL_INTENT_SUFFIX) else {
2349 continue;
2350 };
2351 let bytes = av_core::fsutil::read_capped(&path, av_core::fsutil::MAX_CONTROL_BYTES)
2352 .map_err(|_| "tool execution intent is unavailable".to_owned())?;
2353 let intent: ToolIntent = crate::journal::open(
2354 control_key,
2355 &format!("{}:{key}", crate::journal::TOOL_INTENT_DOMAIN),
2356 0,
2357 &bytes,
2358 )
2359 .map_err(|_| "tool execution intent could not be authenticated".to_owned())?;
2360 if intent.execution_key != key {
2361 return Err("tool intent path does not match its authenticated key".to_owned());
2362 }
2363 if intent.session_id == session_id && !tool_completion_is_authenticated(directory, key, control_key) {
2364 return Err("tool execution outcome or completion audit is unresolved".to_owned());
2365 }
2366 }
2367 Ok(())
2368}
2369
2370pub(crate) fn tool_completion_is_authenticated(
2374 directory: &std::path::Path,
2375 key: &str,
2376 control_key: &[u8; 32],
2377) -> bool {
2378 let outcome_path = directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX));
2379 let Ok(outcome_bytes) = av_core::fsutil::read_capped(&outcome_path, av_core::fsutil::MAX_ATIF_BYTES)
2380 else {
2381 return false;
2382 };
2383 if crate::journal::open::<ToolOutcome>(
2384 control_key,
2385 &format!("{}:{key}", crate::journal::TOOL_OUTCOME_DOMAIN),
2386 0,
2387 &outcome_bytes,
2388 )
2389 .is_err()
2390 {
2391 return false;
2392 }
2393 let audited_path = directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX));
2394 let Ok(audited_bytes) = av_core::fsutil::read_capped(&audited_path, av_core::fsutil::MAX_CONTROL_BYTES)
2395 else {
2396 return false;
2397 };
2398 crate::journal::open::<serde_json::Value>(
2399 control_key,
2400 &format!("{}:{key}", crate::journal::TOOL_AUDITED_DOMAIN),
2401 0,
2402 &audited_bytes,
2403 )
2404 .is_ok()
2405}
2406
2407#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
2408struct ToolIntent {
2409 pub(crate) execution_key: String,
2410 pub(crate) session_id: String,
2411 pub(crate) tool: String,
2412 pub(crate) request_digest: String,
2413 pub(crate) principal_digest: String,
2414}
2415
2416#[cfg(test)]
2417pub(crate) async fn ensure_no_unresolved_tool_executions(
2418 spool: &std::path::Path,
2419 control_key: &[u8; 32],
2420) -> Result<(), String> {
2421 let sessions = unresolved_tool_sessions(spool, control_key).await?;
2422 if let Some(session_id) = sessions.into_iter().next() {
2423 Err(format!("session {session_id} has an unresolved tool execution"))
2424 } else {
2425 Ok(())
2426 }
2427}
2428
2429pub(crate) async fn unresolved_tool_sessions(
2430 spool: &std::path::Path,
2431 control_key: &[u8; 32],
2432) -> Result<std::collections::HashSet<String>, String> {
2433 let directory = spool.join(crate::spool::TOOL_EXECUTIONS);
2434 let control_key = *control_key;
2435 tokio::task::spawn_blocking(move || {
2436 let entries = match std::fs::read_dir(&directory) {
2437 Ok(entries) => entries,
2438 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2439 return Ok(std::collections::HashSet::new());
2440 }
2441 Err(error) => return Err(error.to_string()),
2442 };
2443 let mut intent_keys = std::collections::HashSet::new();
2444 let mut unresolved_sessions = std::collections::HashSet::new();
2445 let mut dirents_seen = 0usize;
2446 for entry in entries {
2447 dirents_seen = dirents_seen.saturating_add(1);
2460 if dirents_seen > crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK {
2461 return Err(format!(
2462 "tool-executions scan aborted: more than {} directory entries; \
2463 the spool directory is poisoned or leaking files",
2464 crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK
2465 ));
2466 }
2467 let path = entry.map_err(|error| error.to_string())?.path();
2468 let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
2469 continue;
2470 };
2471 let Some(key) = name.strip_suffix(crate::spool::TOOL_INTENT_SUFFIX) else {
2472 continue;
2473 };
2474 intent_keys.insert(key.to_owned());
2475 let intent_check: Result<ToolIntent, String> =
2492 match av_core::fsutil::read_capped(&path, av_core::fsutil::MAX_CONTROL_BYTES) {
2493 Ok(intent_bytes) => crate::journal::open(
2494 &control_key,
2495 &format!("{}:{key}", crate::journal::TOOL_INTENT_DOMAIN),
2496 0,
2497 &intent_bytes,
2498 ),
2499 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2502 intent_keys.remove(key);
2503 continue;
2504 }
2505 Err(error)
2506 if matches!(
2507 error.kind(),
2508 std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput
2509 ) =>
2510 {
2511 Err(error.to_string())
2512 }
2513 Err(error) => return Err(error.to_string()),
2514 };
2515 let intent: ToolIntent = match intent_check {
2516 Ok(intent) => intent,
2517 Err(error) => {
2518 let file_name = path
2529 .file_stem()
2530 .and_then(std::ffi::OsStr::to_str)
2531 .and_then(|stem| stem.strip_suffix(".intent"))
2532 .map(|key| format!("{key}.intent.torn"));
2533 let quarantine = match file_name {
2534 Some(name) => path.with_file_name(name),
2535 None => path.with_extension("intent.torn"),
2536 };
2537 tracing::warn!(
2538 %error,
2539 original = %av_core::fsutil::basename(&path),
2540 quarantine = %av_core::fsutil::basename(&quarantine),
2541 "torn tool-execution intent quarantined so recovery can proceed"
2542 );
2543 if let Err(rename_err) = std::fs::rename(&path, &quarantine) {
2544 tracing::warn!(%rename_err, "failed to quarantine torn intent — leaving in place");
2545 }
2546 quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_OUTCOME_SUFFIX, "outcome");
2553 quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2554 intent_keys.remove(key);
2555 continue;
2556 }
2557 };
2558 if intent.execution_key != key {
2559 return Err("tool intent path does not match authenticated execution key".to_owned());
2560 }
2561 let outcome_path = directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX));
2562 let outcome_check: Result<ToolOutcome, String> =
2570 match av_core::fsutil::read_capped(&outcome_path, av_core::fsutil::MAX_ATIF_BYTES) {
2571 Ok(outcome_bytes) => crate::journal::open(
2572 &control_key,
2573 &format!("{}:{key}", crate::journal::TOOL_OUTCOME_DOMAIN),
2574 0,
2575 &outcome_bytes,
2576 ),
2577 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2578 unresolved_sessions.insert(intent.session_id);
2579 continue;
2580 }
2581 Err(error)
2582 if matches!(
2583 error.kind(),
2584 std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput
2585 ) =>
2586 {
2587 Err(error.to_string())
2588 }
2589 Err(error) => return Err(error.to_string()),
2590 };
2591 let _: ToolOutcome = match outcome_check {
2592 Ok(outcome) => outcome,
2593 Err(error) => {
2594 tracing::warn!(
2599 %error,
2600 key = %key,
2601 "torn tool-execution outcome quarantined so recovery can proceed"
2602 );
2603 quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_OUTCOME_SUFFIX, "outcome");
2604 quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2605 unresolved_sessions.insert(intent.session_id);
2606 continue;
2607 }
2608 };
2609 let audited_path = directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX));
2610 let audited_check: Result<serde_json::Value, String> =
2611 match av_core::fsutil::read_capped(&audited_path, av_core::fsutil::MAX_CONTROL_BYTES) {
2612 Ok(audited_bytes) => crate::journal::open(
2613 &control_key,
2614 &format!("{}:{key}", crate::journal::TOOL_AUDITED_DOMAIN),
2615 0,
2616 &audited_bytes,
2617 ),
2618 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2619 unresolved_sessions.insert(intent.session_id);
2620 continue;
2621 }
2622 Err(error)
2623 if matches!(
2624 error.kind(),
2625 std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput
2626 ) =>
2627 {
2628 Err(error.to_string())
2629 }
2630 Err(error) => return Err(error.to_string()),
2631 };
2632 let _: serde_json::Value = match audited_check {
2633 Ok(value) => value,
2634 Err(error) => {
2635 tracing::warn!(
2636 %error,
2637 key = %key,
2638 "torn tool-execution audited record quarantined so recovery can proceed"
2639 );
2640 quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2641 unresolved_sessions.insert(intent.session_id);
2642 continue;
2643 }
2644 };
2645 }
2646 let mut orphan_dirents = 0usize;
2647 for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? {
2648 orphan_dirents = orphan_dirents.saturating_add(1);
2650 if orphan_dirents > crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK {
2651 return Err(format!(
2652 "tool-executions orphan scan aborted: more than {} directory entries; \
2653 the spool directory is poisoned or leaking files",
2654 crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK
2655 ));
2656 }
2657 let path = entry.map_err(|error| error.to_string())?.path();
2658 let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
2659 continue;
2660 };
2661 if let Some(key) = name.strip_suffix(crate::spool::TOOL_OUTCOME_SUFFIX) {
2662 if !intent_keys.contains(key) {
2663 tracing::warn!(
2670 key = %key,
2671 "orphan tool outcome without authenticated intent quarantined"
2672 );
2673 quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_OUTCOME_SUFFIX, "outcome");
2674 quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2675 }
2676 }
2677 }
2678 Ok(unresolved_sessions)
2679 })
2680 .await
2681 .map_err(|error| error.to_string())?
2682}
2683
2684fn quarantine_orphan_sibling(directory: &std::path::Path, key: &str, suffix: &str, role: &str) {
2688 let path = directory.join(format!("{key}{suffix}"));
2689 if !path.exists() {
2690 return;
2691 }
2692 let uid = av_core::new_event_uid();
2693 let quarantine = directory.join(format!("{key}{suffix}.corrupt-{uid}"));
2694 if let Err(rename_err) = std::fs::rename(&path, &quarantine) {
2695 tracing::warn!(
2696 %rename_err,
2697 role = %role,
2698 original = %av_core::fsutil::basename(&path),
2699 "failed to quarantine orphan tool-execution sibling — leaving in place"
2700 );
2701 } else {
2702 tracing::warn!(
2703 role = %role,
2704 original = %av_core::fsutil::basename(&path),
2705 quarantine = %av_core::fsutil::basename(&quarantine),
2706 "orphan tool-execution sibling quarantined"
2707 );
2708 }
2709}
2710
2711#[derive(Clone)]
2712struct ToolExecution {
2713 key: String,
2714 session_id: String,
2715 tool: String,
2716 request_digest: String,
2717 principal_digest: String,
2718 control_key: [u8; 32],
2719 intent_path: std::path::PathBuf,
2720 outcome_path: std::path::PathBuf,
2721 audited_path: std::path::PathBuf,
2722}
2723
2724impl ToolExecution {
2725 fn from_request(
2726 spool: &str,
2727 headers: &HeaderMap,
2728 body: &[u8],
2729 control_key: [u8; 32],
2730 ) -> Result<Self, crate::pipeline::PipelineError> {
2731 let raw_header = crate::pipeline::single_header(headers, crate::pipeline::SESSION_HEADER)?;
2743 let session_id = match raw_header {
2744 None => {
2745 return Err(crate::pipeline::PipelineError::bad_request(
2746 "missing x-av-session".to_owned(),
2747 ));
2748 }
2749 Some(value) => value.to_str().map_err(|_| {
2750 crate::pipeline::PipelineError::bad_request("X-AV-Session is not valid text".to_owned())
2751 })?,
2752 };
2753 let session_id = av_core::SessionId::parse(session_id)
2756 .map(|id| id.to_string())
2757 .map_err(crate::pipeline::PipelineError::bad_request_source)?;
2758 let call =
2759 av_sandbox::parse_tool_call(body).map_err(crate::pipeline::PipelineError::bad_request_source)?;
2760 let id = call.id.filter(|id| !id.is_null()).ok_or_else(|| {
2766 crate::pipeline::PipelineError::bad_request(
2767 "forwarded tool calls require a non-null JSON-RPC id for idempotency".to_owned(),
2768 )
2769 })?;
2770 let request: Value =
2771 serde_json::from_slice(body).map_err(crate::pipeline::PipelineError::bad_request_source)?;
2772 let canonical = av_receipts::canonicalize(&request)
2773 .map_err(crate::pipeline::PipelineError::bad_request_source)?;
2774 let request_digest = av_core::digest::sha256_hex(canonical.as_bytes());
2775 let key_material = format!("{session_id}:{}", id);
2776 let key = av_core::digest::sha256_hex(key_material.as_bytes());
2777 let directory = std::path::Path::new(spool).join(crate::spool::TOOL_EXECUTIONS);
2778 Ok(Self {
2779 intent_path: directory.join(format!("{key}{}", crate::spool::TOOL_INTENT_SUFFIX)),
2780 outcome_path: directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX)),
2781 audited_path: directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX)),
2782 key,
2783 session_id,
2784 tool: call.tool,
2785 request_digest,
2786 principal_digest: String::new(),
2787 control_key,
2788 })
2789 }
2790
2791 fn bind_principal(
2792 &mut self,
2793 identity: &av_events::AgentIdentity,
2794 ) -> Result<(), crate::pipeline::PipelineError> {
2795 let stable_identity = json!({
2796 "version": identity.version,
2797 "charter": identity.charter,
2798 "instance_uid": identity.instance_uid,
2799 });
2800 let canonical = av_receipts::canonicalize(&stable_identity)
2801 .map_err(crate::pipeline::PipelineError::bad_request_source)?;
2802 self.principal_digest = av_core::digest::sha256_hex(canonical.as_bytes());
2803 Ok(())
2804 }
2805
2806 async fn load(&self) -> Result<ToolExecutionState, String> {
2807 let execution = self.clone();
2808 tokio::task::spawn_blocking(move || execution.load_sync())
2809 .await
2810 .map_err(|error| error.to_string())?
2811 }
2812
2813 fn load_sync(&self) -> Result<ToolExecutionState, String> {
2814 let intent_bytes = match std::fs::read(&self.intent_path) {
2820 Ok(bytes) => Some(bytes),
2821 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2822 Err(error) => return Err(error.to_string()),
2823 };
2824 let Some(intent_bytes) = intent_bytes else {
2825 if self.outcome_path.exists() || self.audited_path.exists() {
2826 return Err("tool outcome exists without an authenticated intent".to_owned());
2827 }
2828 return Ok(ToolExecutionState::Missing);
2829 };
2830 let intent: ToolIntent = crate::journal::open(
2831 &self.control_key,
2832 &format!("{}:{}", crate::journal::TOOL_INTENT_DOMAIN, self.key),
2833 0,
2834 &intent_bytes,
2835 )?;
2836 if intent != self.intent() {
2837 return Err(TOOL_REQUEST_MISMATCH.to_owned());
2838 }
2839 let outcome_bytes = match std::fs::read(&self.outcome_path) {
2840 Ok(bytes) => Some(bytes),
2841 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2842 Err(error) => return Err(error.to_string()),
2843 };
2844 if let Some(outcome_bytes) = outcome_bytes {
2845 let outcome: ToolOutcome = crate::journal::open(
2846 &self.control_key,
2847 &format!("{}:{}", crate::journal::TOOL_OUTCOME_DOMAIN, self.key),
2848 0,
2849 &outcome_bytes,
2850 )?;
2851 let audited = match std::fs::read(&self.audited_path) {
2852 Ok(bytes) => crate::journal::open::<serde_json::Value>(
2853 &self.control_key,
2854 &format!("{}:{}", crate::journal::TOOL_AUDITED_DOMAIN, self.key),
2855 0,
2856 &bytes,
2857 )
2858 .is_ok(),
2859 Err(_) => false,
2860 };
2861 return if audited {
2862 Ok(ToolExecutionState::Completed(outcome))
2863 } else {
2864 Ok(ToolExecutionState::Unaudited(outcome))
2865 };
2866 }
2867 Ok(ToolExecutionState::Pending)
2868 }
2869
2870 async fn claim(&self) -> Result<(), ClaimError> {
2871 let execution = self.clone();
2872 tokio::task::spawn_blocking(move || execution.claim_sync())
2873 .await
2874 .map_err(|error| ClaimError::Backend(error.to_string()))?
2875 }
2876
2877 async fn release_unexecuted(&self) -> Result<(), String> {
2892 let execution = self.clone();
2893 tokio::task::spawn_blocking(move || {
2894 match std::fs::remove_file(&execution.intent_path) {
2895 Ok(()) => {}
2896 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2897 Err(error) => return Err(error.to_string()),
2898 }
2899 if let Some(directory) = execution.intent_path.parent() {
2900 if let Err(error) = std::fs::File::open(directory).and_then(|directory| directory.sync_all())
2901 {
2902 tracing::warn!(
2903 path = %av_core::fsutil::basename(&execution.intent_path),
2904 %error,
2905 "release_unexecuted: intent removed but directory fsync failed; treating as released"
2906 );
2907 }
2908 }
2909 Ok(())
2910 })
2911 .await
2912 .map_err(|error| error.to_string())?
2913 }
2914
2915 fn claim_sync(&self) -> Result<(), ClaimError> {
2916 use std::io::Write as _;
2917 let directory = self
2918 .intent_path
2919 .parent()
2920 .ok_or_else(|| ClaimError::Backend("tool execution directory is missing".to_owned()))?;
2921 if !directory.is_dir() {
2925 av_core::fsutil::create_dir_all_synced(directory)
2930 .map_err(|error| ClaimError::Backend(error.to_string()))?;
2931 }
2932 let mut open_options = std::fs::OpenOptions::new();
2954 open_options.write(true).create_new(true);
2955 #[cfg(unix)]
2956 {
2957 use std::os::unix::fs::OpenOptionsExt as _;
2958 open_options.mode(0o600);
2959 }
2960 let mut file = open_options
2961 .open(&self.intent_path)
2962 .map_err(|error| match error.kind() {
2963 std::io::ErrorKind::AlreadyExists => ClaimError::Race,
2964 _ => ClaimError::Backend(format!("tool execution intent unavailable: {error}")),
2965 })?;
2966 let intent = crate::journal::seal(
2967 &self.control_key,
2968 &format!("{}:{}", crate::journal::TOOL_INTENT_DOMAIN, self.key),
2969 0,
2970 &self.intent(),
2971 )
2972 .map_err(ClaimError::Backend)?;
2973 file.write_all(&intent)
2974 .map_err(|error| ClaimError::Backend(error.to_string()))?;
2975 file.sync_all()
2976 .map_err(|error| ClaimError::Backend(error.to_string()))?;
2977 std::fs::File::open(directory)
2978 .and_then(|directory| directory.sync_all())
2979 .map_err(|error| ClaimError::Backend(error.to_string()))
2980 }
2981
2982 async fn persist(&self, outcome: &ToolOutcome) -> Result<(), String> {
2983 let execution = self.clone();
2984 let outcome = outcome.clone();
2985 tokio::task::spawn_blocking(move || {
2986 let sealed = crate::journal::seal(
2987 &execution.control_key,
2988 &format!("{}:{}", crate::journal::TOOL_OUTCOME_DOMAIN, execution.key),
2989 0,
2990 &outcome,
2991 )?;
2992 write_atomic_bytes(&execution.outcome_path, &sealed)
2993 })
2994 .await
2995 .map_err(|error| error.to_string())?
2996 }
2997
2998 async fn mark_audited(&self) -> Result<(), String> {
2999 let execution = self.clone();
3000 tokio::task::spawn_blocking(move || {
3001 let sealed = crate::journal::seal(
3002 &execution.control_key,
3003 &format!("{}:{}", crate::journal::TOOL_AUDITED_DOMAIN, execution.key),
3004 0,
3005 &json!({"audited": true}),
3006 )?;
3007 write_atomic_bytes(&execution.audited_path, &sealed)
3008 })
3009 .await
3010 .map_err(|error| error.to_string())?
3011 }
3012
3013 fn intent(&self) -> ToolIntent {
3014 ToolIntent {
3015 execution_key: self.key.clone(),
3016 session_id: self.session_id.clone(),
3017 tool: self.tool.clone(),
3018 request_digest: self.request_digest.clone(),
3019 principal_digest: self.principal_digest.clone(),
3020 }
3021 }
3022}
3023
3024fn write_atomic_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
3025 av_core::fsutil::write_atomic(path, bytes).map_err(|error| error.to_string())
3026}
3027
3028async fn lifecycle_caller(
3029 state: &AppState,
3030 headers: &HeaderMap,
3031 scope: &str,
3032) -> Result<crate::pipeline::AuthenticatedCaller, crate::pipeline::PipelineError> {
3033 let state = state.clone();
3034 let headers = headers.clone();
3035 let scope = scope.to_owned();
3036 tokio::task::spawn_blocking(move || {
3037 let caller = state.authenticate(&headers)?;
3038 caller.authorize(&state, Some(&scope))?;
3039 Ok(caller)
3040 })
3041 .await
3042 .map_err(crate::pipeline::PipelineError::unavailable_source)?
3043}
3044
3045async fn close_session(
3046 State(state): State<AppState>,
3047 Path(id): Path<String>,
3048 headers: HeaderMap,
3049) -> Response {
3050 let caller = match lifecycle_caller(&state, &headers, &state.config.session_close_scope).await {
3051 Ok(caller) => caller,
3052 Err(error) => return pipeline_error(error),
3053 };
3054 let Some(session) = state.sessions.get(&id) else {
3055 return (
3056 StatusCode::NOT_FOUND,
3057 Json(openai_error_body(StatusCode::NOT_FOUND, "unknown session")),
3058 )
3059 .into_response();
3060 };
3061 if let Err(error) = caller.authorize_session(&state, &session, &state.config.session_close_scope) {
3062 return pipeline_error(error);
3063 }
3064 match state
3065 .finalizer
3066 .close_session(session, StopReason::SessionClosed)
3067 .await
3068 {
3069 Ok(outcome) => Json(outcome).into_response(),
3070 Err(error) => finalize_error_response(&error),
3071 }
3072}
3073
3074async fn promote_session(
3075 State(state): State<AppState>,
3076 Path(id): Path<String>,
3077 headers: HeaderMap,
3078) -> Response {
3079 let caller = match lifecycle_caller(&state, &headers, &state.config.session_promote_scope).await {
3080 Ok(caller) => caller,
3081 Err(error) => return pipeline_error(error),
3082 };
3083 let Some(session) = state.sessions.get(&id) else {
3084 return (
3085 StatusCode::NOT_FOUND,
3086 Json(openai_error_body(StatusCode::NOT_FOUND, "unknown session")),
3087 )
3088 .into_response();
3089 };
3090 if let Err(error) = caller.authorize_session(&state, &session, &state.config.session_promote_scope) {
3091 return pipeline_error(error);
3092 }
3093 if !session.is_closed() {
3102 if let Err(error) = caller.authorize_session(&state, &session, &state.config.session_close_scope) {
3103 return pipeline_error(error);
3104 }
3105 }
3106 match state.finalizer.promote(session).await {
3107 Ok(receipt) => Json(receipt).into_response(),
3108 Err(error) => finalize_error_response(&error),
3109 }
3110}
3111
3112async fn jwks(State(state): State<AppState>) -> Response {
3115 match &state.token_signer {
3116 Some(signer) => Json(signer.jwks_json()).into_response(),
3117 None => Json(serde_json::json!({"keys": []})).into_response(),
3118 }
3119}
3120
3121async fn token_exchange(State(state): State<AppState>, body: Bytes) -> Response {
3126 crate::token_routes::control_request(state, move |state| async move {
3127 tokio::task::spawn_blocking(move || token_exchange_inner(state, body))
3128 .await
3129 .unwrap_or_else(|_| oauth_unavailable())
3130 })
3131 .await
3132}
3133
3134fn token_exchange_inner(state: AppState, body: Bytes) -> Response {
3135 let grant_type = serde_urlencoded::from_bytes::<Vec<(String, String)>>(&body)
3138 .ok()
3139 .and_then(|pairs| pairs.into_iter().find(|(key, _)| key == "grant_type"))
3140 .map(|(_, value)| value);
3141 if grant_type.as_deref() == Some(av_identity::JWT_BEARER_GRANT_TYPE) {
3142 return crate::workload::grant(&state, &body);
3143 }
3144 if !state.config.token_exchange_enabled {
3145 return StatusCode::NOT_FOUND.into_response();
3146 }
3147 let validator = match &state.identity {
3148 Some(v) => v,
3149 None => {
3150 return (
3151 StatusCode::INTERNAL_SERVER_ERROR,
3152 Json(oauth_error(
3153 "server_error",
3154 "token exchange requires an identity validator",
3155 )),
3156 )
3157 .into_response();
3158 }
3159 };
3160 let req: av_identity::TokenExchangeRequest = match serde_urlencoded::from_bytes(&body) {
3161 Ok(r) => r,
3162 Err(e) => {
3163 return (
3164 StatusCode::BAD_REQUEST,
3165 Json(oauth_error(
3166 "invalid_request",
3167 &format!("invalid token exchange request: {e}"),
3168 )),
3169 )
3170 .into_response();
3171 }
3172 };
3173 if req.grant_type != av_identity::TOKEN_EXCHANGE_GRANT_TYPE {
3174 return (
3175 StatusCode::BAD_REQUEST,
3176 Json(oauth_error(
3177 "unsupported_grant_type",
3178 &format!("unsupported grant_type: {:?}", req.grant_type),
3179 )),
3180 )
3181 .into_response();
3182 }
3183 if req.subject_token_type != av_identity::exchange::JWT_TOKEN_TYPE {
3184 return (
3185 StatusCode::BAD_REQUEST,
3186 Json(oauth_error(
3187 "invalid_request",
3188 &format!("unsupported subject_token_type: {:?}", req.subject_token_type),
3189 )),
3190 )
3191 .into_response();
3192 }
3193 state
3194 .metrics
3195 .counter(
3196 "av_identity_validations_total",
3197 "Full inbound identity validations",
3198 )
3199 .inc();
3200 let validated = match validator.validate(&req.subject_token) {
3201 Ok(v) => v,
3202 Err(av_identity::IdentityError::RevocationUnavailable(detail)) => {
3203 tracing::error!(%detail, "identity revocation list unavailable; refusing token exchange");
3204 return oauth_unavailable();
3205 }
3206 Err(e) => {
3207 tracing::warn!(error = %e, "token exchange subject token rejected");
3208 return (
3211 StatusCode::BAD_REQUEST,
3212 Json(oauth_error(
3213 "invalid_request",
3214 crate::pipeline::classify_identity_error(&e),
3215 )),
3216 )
3217 .into_response();
3218 }
3219 };
3220 let now_s = av_core::time::now_ms() / 1000;
3221 let ttl = state.config.token_exchange_ttl_s.min(900);
3222 let azp = &validated.claims.instance_uid;
3223 let allowed_audiences: Vec<String> = state.config.backends.iter().map(|b| b.name.clone()).collect();
3224 if allowed_audiences.is_empty() {
3228 return (
3229 StatusCode::BAD_REQUEST,
3230 Json(oauth_error(
3231 "invalid_target",
3232 "no [[backends]] are configured, so no exchange audience is permitted",
3233 )),
3234 )
3235 .into_response();
3236 }
3237 let actor = match (req.actor_token.as_deref(), req.actor_token_type.as_deref()) {
3241 (None, None) => None,
3242 (Some(token), Some(av_identity::exchange::JWT_TOKEN_TYPE)) => match validator.validate(token) {
3243 Ok(actor) => Some(actor),
3244 Err(av_identity::IdentityError::RevocationUnavailable(detail)) => {
3245 tracing::error!(%detail, "identity revocation list unavailable; refusing token exchange");
3246 return oauth_unavailable();
3247 }
3248 Err(error) => {
3249 tracing::warn!(%error, "token exchange actor token rejected");
3250 return (
3251 StatusCode::BAD_REQUEST,
3252 Json(oauth_error(
3253 "invalid_request",
3254 crate::pipeline::classify_identity_error(&error),
3255 )),
3256 )
3257 .into_response();
3258 }
3259 },
3260 (Some(_), Some(other)) => {
3261 return (
3262 StatusCode::BAD_REQUEST,
3263 Json(oauth_error(
3264 "invalid_request",
3265 &format!("unsupported actor_token_type: {other:?}"),
3266 )),
3267 )
3268 .into_response();
3269 }
3270 _ => {
3271 return (
3272 StatusCode::BAD_REQUEST,
3273 Json(oauth_error(
3274 "invalid_request",
3275 "actor_token and actor_token_type must be sent together",
3276 )),
3277 )
3278 .into_response();
3279 }
3280 };
3281 let params = av_identity::ExchangeParams {
3282 subject: &validated.claims,
3283 subject_chain: &validated.chain_tokens,
3284 subject_delegation_depth: validated.total_delegation_depth,
3285 target_audience: &req.audience,
3286 requested_scopes: req.scope.as_deref(),
3287 azp,
3288 now_s,
3289 ttl_s: ttl,
3290 issuer: &state.config.audience,
3291 max_depth: state.config.max_delegation_depth,
3292 allowed_audiences: &allowed_audiences,
3293 };
3294 let actor = actor.as_ref().map(|actor| av_identity::ExchangeActor {
3295 claims: &actor.claims,
3296 chain: &actor.chain_tokens,
3297 });
3298 let exchanged = match av_identity::build_exchanged_claims_with_actor(¶ms, actor.as_ref()) {
3299 Ok(c) => c,
3300 Err(e) => {
3301 let code = match &e {
3302 av_identity::ExchangeError::EmptyIntersection
3303 | av_identity::ExchangeError::ScopeExceedsSubject(_)
3304 | av_identity::ExchangeError::InvalidScopes => "invalid_scope",
3305 av_identity::ExchangeError::DelegationExceeded { .. }
3306 | av_identity::ExchangeError::ActorNotDirect => "invalid_grant",
3307 av_identity::ExchangeError::TargetNotAllowed(_) => "invalid_target",
3308 _ => "invalid_request",
3309 };
3310 return (StatusCode::BAD_REQUEST, Json(oauth_error(code, &e.to_string()))).into_response();
3311 }
3312 };
3313 let signer = match &state.token_signer {
3314 Some(s) => s,
3315 None => {
3316 return (
3317 StatusCode::INTERNAL_SERVER_ERROR,
3318 Json(oauth_error(
3319 "server_error",
3320 "token exchange is enabled but no signing key is configured",
3321 )),
3322 )
3323 .into_response();
3324 }
3325 };
3326 let access_token = match signer.sign(&exchanged) {
3327 Ok(jwt) => jwt,
3328 Err(e) => {
3329 return (
3330 StatusCode::INTERNAL_SERVER_ERROR,
3331 Json(oauth_error("server_error", &format!("token signing failed: {e}"))),
3332 )
3333 .into_response();
3334 }
3335 };
3336 let resp = av_identity::TokenExchangeResponse {
3337 access_token,
3338 issued_token_type: av_identity::exchange::JWT_TOKEN_TYPE.to_owned(),
3339 token_type: "Bearer".to_owned(),
3340 expires_in: exchanged.exp.saturating_sub(now_s),
3341 scope: if exchanged.scopes.is_empty() {
3342 None
3343 } else {
3344 Some(exchanged.scopes.join(" "))
3345 },
3346 };
3347 (
3348 [
3349 (axum::http::header::CACHE_CONTROL, "no-store"),
3350 (axum::http::header::PRAGMA, "no-cache"),
3351 ],
3352 Json(resp),
3353 )
3354 .into_response()
3355}
3356
3357fn finalize_error_response(error: &crate::reconciler::FinalizeError) -> Response {
3364 use crate::reconciler::FinalizeError;
3365 if error.is_transient_io() {
3376 let mut response = (
3377 StatusCode::SERVICE_UNAVAILABLE,
3378 Json(openai_error_body(
3379 StatusCode::SERVICE_UNAVAILABLE,
3380 &error.to_string(),
3381 )),
3382 )
3383 .into_response();
3384 response
3385 .headers_mut()
3386 .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3387 return response;
3388 }
3389 let status = match error {
3390 FinalizeError::CaptureIncomplete | FinalizeError::Promotion(_) => StatusCode::CONFLICT,
3395 FinalizeError::BridgeConfig { .. } => StatusCode::BAD_REQUEST,
3401 FinalizeError::Bridge { .. } | FinalizeError::ToolEvidence(_) => StatusCode::SERVICE_UNAVAILABLE,
3404 _ => StatusCode::INTERNAL_SERVER_ERROR,
3406 };
3407 let mut response = (status, Json(openai_error_body(status, &error.to_string()))).into_response();
3408 if status == StatusCode::SERVICE_UNAVAILABLE {
3409 response
3411 .headers_mut()
3412 .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3413 }
3414 response
3415}
3416
3417fn openai_error_body(status: StatusCode, message: &str) -> serde_json::Value {
3428 let error_type = match status.as_u16() {
3429 400 | 404 | 409 | 413 => "invalid_request_error",
3430 401 => "authentication_error",
3431 403 => "permission_error",
3432 429 => "rate_limit_error",
3433 _ => "api_error",
3434 };
3435 json!({
3436 "error": {
3437 "message": message,
3438 "type": error_type,
3439 "param": null,
3440 "code": status.as_u16(),
3441 }
3442 })
3443}
3444
3445pub(crate) const TOKENS_REVOKED_METRIC: &str = "av_tokens_revoked_total";
3446pub(crate) const TOKENS_REVOKED_HELP: &str = "Successful holder revocations and operator revocation commands";
3447
3448fn exchange_for_backend(
3453 state: &AppState,
3454 caller: &crate::pipeline::AuthenticatedCaller,
3455 target_audience: &str,
3456 tool: &str,
3457) -> Result<String, crate::pipeline::PipelineError> {
3458 exchange_token_for(state, caller, target_audience, &crate::pipeline::tool_scope(tool))
3459}
3460
3461pub(crate) fn exchange_token_for(
3464 state: &AppState,
3465 caller: &crate::pipeline::AuthenticatedCaller,
3466 target_audience: &str,
3467 requested_scopes: &str,
3468) -> Result<String, crate::pipeline::PipelineError> {
3469 use crate::pipeline::PipelineError;
3470 let refused = |reason: String| PipelineError::blocked(format!("backend token exchange failed: {reason}"));
3471 let Some(signer) = state.token_signer.as_ref() else {
3472 return Err(PipelineError::unavailable(
3473 "backend token exchange is not configured on this gateway".to_owned(),
3474 ));
3475 };
3476 let validated = caller
3477 .validated
3478 .as_ref()
3479 .ok_or_else(|| refused("no validated caller identity".into()))?;
3480 let now_s = av_core::time::now_ms() / 1000;
3481 let ttl = state.config.token_exchange_ttl_s.min(900);
3482 let allowed_audiences: Vec<String> = state.config.backends.iter().map(|b| b.name.clone()).collect();
3483 let params = av_identity::ExchangeParams {
3484 subject: &validated.claims,
3485 subject_chain: &validated.chain_tokens,
3486 subject_delegation_depth: validated.total_delegation_depth,
3487 target_audience,
3488 requested_scopes: Some(requested_scopes),
3489 azp: &validated.claims.instance_uid,
3490 now_s,
3491 ttl_s: ttl,
3492 issuer: &state.config.audience,
3493 max_depth: state.config.max_delegation_depth,
3494 allowed_audiences: &allowed_audiences,
3495 };
3496 let exchanged = av_identity::build_exchanged_claims(¶ms).map_err(|e| refused(e.to_string()))?;
3497 signer.sign_with_typ(&exchanged, "av-tool+jwt").map_err(|error| {
3498 tracing::error!(%error, "exchanged token signing failed");
3499 PipelineError::unavailable("backend token exchange could not sign a token".to_owned())
3500 })
3501}
3502
3503fn oauth_error(error: &str, description: &str) -> serde_json::Value {
3504 json!({
3505 "error": error,
3506 "error_description": description,
3507 })
3508}
3509
3510fn oauth_unavailable() -> Response {
3513 let mut response = (
3514 StatusCode::SERVICE_UNAVAILABLE,
3515 Json(oauth_error(
3516 "temporarily_unavailable",
3517 "the token revocation list is unavailable; retry shortly",
3518 )),
3519 )
3520 .into_response();
3521 response
3522 .headers_mut()
3523 .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3524 response
3525}
3526
3527fn pipeline_error_for_session(error: crate::pipeline::PipelineError, session_id: &str) -> Response {
3536 let mut response = pipeline_error(error);
3537 if let Ok(value) = HeaderValue::from_str(session_id) {
3538 response
3539 .headers_mut()
3540 .insert(crate::pipeline::SESSION_HEADER, value);
3541 }
3542 response
3543}
3544
3545pub(crate) fn pipeline_error(error: crate::pipeline::PipelineError) -> Response {
3546 use crate::pipeline::PipelineError;
3547 let close = matches!(error, PipelineError::Abort(_));
3548 let status = error.status();
3549 let mut response = (status, Json(openai_error_body(status, &error.to_string()))).into_response();
3550 if close {
3551 response
3552 .headers_mut()
3553 .insert(axum::http::header::CONNECTION, HeaderValue::from_static("close"));
3554 }
3555 match status.as_u16() {
3564 429 | 502 | 503 | 504 => {
3569 response
3570 .headers_mut()
3571 .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3572 }
3573 401 => {
3576 response.headers_mut().insert(
3577 axum::http::header::WWW_AUTHENTICATE,
3578 HeaderValue::from_static("Bearer realm=\"agentvisor\", error=\"invalid_token\""),
3579 );
3580 }
3581 _ => {}
3582 }
3583 response
3584}
3585
3586fn lifecycle_error(error: String) -> Response {
3587 (
3591 StatusCode::INTERNAL_SERVER_ERROR,
3592 Json(openai_error_body(StatusCode::INTERNAL_SERVER_ERROR, &error)),
3593 )
3594 .into_response()
3595}
3596
3597struct AbortFinalizingStream {
3598 inner: BoxStream<'static, Result<Bytes, std::io::Error>>,
3599 session: Arc<crate::session::Session>,
3600 identity: av_events::AgentIdentity,
3601 response_permit: Option<crate::worker::ResponsePermit>,
3602 worker: crate::worker::WorkerHandle,
3607 store: Arc<dyn av_state::StateStore>,
3608 budget: av_state::BudgetSpec,
3609 finalizer: crate::reconciler::Finalizer,
3610 _lease: crate::session::SessionLease,
3611 response_marker: Option<String>,
3612 response_attempt_id: String,
3613 response_message: String,
3614 response_reasoning: String,
3615 response_model: Option<String>,
3616 response_finish_reason: Option<String>,
3617 upstream_status: StatusCode,
3618 response_cost_usd_micros: u64,
3619 response_tool_calls: std::collections::BTreeMap<(u64, u64), PartialToolCall>,
3620 response_metrics: av_events::EventMetrics,
3621 charged_completion_tokens: u64,
3622 last_reported_completion_tokens: Option<u64>,
3623 last_reported_prompt_tokens: Option<u64>,
3624 last_reported_cached_tokens: Option<u64>,
3625 last_reported_cost_usd_micros: Option<u64>,
3626 saw_chunk: bool,
3627 capture_attempted: bool,
3635 is_sse: bool,
3636 protocol_buffer: Vec<u8>,
3637 frame_scratch: Vec<u8>,
3647 pending_output: std::collections::VecDeque<Bytes>,
3648 pending_budget: Option<PendingBudget>,
3649 principal_budget: Option<(String, av_state::BudgetSpec)>,
3658 captured_bytes: usize,
3659 billed_prompt_tokens: u64,
3662 provider_adapter: Arc<dyn crate::provider::ProviderAdapter>,
3664 completed: bool,
3665 ephemeral: bool,
3674}
3675
3676const MAX_PROVIDER_CAPTURE_BYTES: usize = 16 * 1024 * 1024;
3677const MAX_PROVIDER_FIELD_BYTES: usize = 8 * 1024 * 1024;
3678const MAX_PROVIDER_TOOL_CALLS: usize = 128;
3679
3680impl AbortFinalizingStream {
3681 fn absorb_network_chunk(&mut self, raw: &[u8]) -> Result<u64, String> {
3682 self.saw_chunk |= !raw.is_empty();
3683 self.captured_bytes = self
3684 .captured_bytes
3685 .checked_add(raw.len())
3686 .ok_or_else(|| "provider response size overflow".to_owned())?;
3687 if self.captured_bytes > MAX_PROVIDER_CAPTURE_BYTES {
3688 return Err(format!(
3689 "provider response exceeds {MAX_PROVIDER_CAPTURE_BYTES} capture bytes"
3690 ));
3691 }
3692 self.protocol_buffer.extend_from_slice(raw);
3693 if !self.is_sse {
3694 return Ok(0);
3695 }
3696 if !self.upstream_status.is_success() {
3705 self.protocol_buffer.clear();
3706 return Ok(0);
3707 }
3708 let mut budget_delta = 0u64;
3709 let mut scratch: Vec<u8> = std::mem::take(&mut self.frame_scratch);
3720 let absorb_result = loop {
3721 let Some(end) = sse_frame_end(&self.protocol_buffer) else {
3722 break Ok(());
3723 };
3724 scratch.clear();
3725 #[allow(clippy::indexing_slicing)]
3728 scratch.extend_from_slice(&self.protocol_buffer[..end]);
3729 self.protocol_buffer.drain(..end);
3730 let frame = match std::str::from_utf8(&scratch) {
3731 Ok(frame) => frame,
3732 Err(error) => break Err(format!("provider SSE frame is not UTF-8: {error}")),
3733 };
3734 match self.absorb_frame(frame) {
3735 Ok(delta) => match budget_delta.checked_add(delta) {
3736 Some(total) => budget_delta = total,
3737 None => break Err("provider completion-token delta overflow".to_owned()),
3738 },
3739 Err(error) => break Err(error),
3740 }
3741 };
3742 self.frame_scratch = scratch;
3743 if let Err(error) = absorb_result {
3744 self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(budget_delta);
3756 return Err(error);
3757 }
3758 if self.protocol_buffer.len() > MAX_PROVIDER_FIELD_BYTES {
3759 self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(budget_delta);
3764 return Err("unterminated provider SSE frame exceeds limit".to_owned());
3765 }
3766 Ok(budget_delta)
3767 }
3768
3769 fn flush_protocol_buffer(&mut self) -> Result<u64, String> {
3770 if self.protocol_buffer.is_empty() {
3771 return Ok(0);
3772 }
3773 if !self.upstream_status.is_success() {
3786 let _ = std::mem::take(&mut self.protocol_buffer);
3787 return Ok(0);
3788 }
3789 let frame = std::mem::take(&mut self.protocol_buffer);
3790 let frame = std::str::from_utf8(&frame)
3791 .map_err(|error| format!("provider response is not UTF-8: {error}"))?;
3792 self.absorb_frame(frame)
3793 }
3794
3795 fn absorb_frame(&mut self, raw: &str) -> Result<u64, String> {
3796 let Some(parsed) = self.provider_adapter.parse_sse_chunk(raw)? else {
3797 return Ok(0);
3798 };
3799 if self.upstream_status.is_success() && !parsed.has_choices {
3800 return Err("successful provider response has no choices array".to_owned());
3801 }
3802 push_bounded(&mut self.response_message, &parsed.message, "response message")?;
3803 if let Some(reasoning) = parsed.reasoning {
3804 push_bounded(&mut self.response_reasoning, &reasoning, "response reasoning")?;
3805 }
3806 if self.response_model.is_none() {
3807 self.response_model = parsed.model_name;
3808 }
3809 if parsed.finish_reason.is_some() {
3810 self.response_finish_reason = parsed.finish_reason;
3811 }
3812 self.response_cost_usd_micros = self.response_cost_usd_micros.max(parsed.cost_usd_micros);
3827 for delta in parsed.tool_call_deltas {
3828 let key = if delta.complete {
3829 let next = self
3838 .response_tool_calls
3839 .range((delta.choice_index, 0)..=(delta.choice_index, u64::MAX))
3840 .next_back()
3841 .map_or(0, |(&(_, index), _)| index.saturating_add(1));
3842 (delta.choice_index, next)
3843 } else {
3844 if delta.index >= MAX_PROVIDER_TOOL_CALLS as u64 {
3845 return Err(format!(
3846 "provider tool-call index {} is out of range",
3847 delta.index
3848 ));
3849 }
3850 (delta.choice_index, delta.index)
3851 };
3852 if !self.response_tool_calls.contains_key(&key)
3853 && self.response_tool_calls.len() >= MAX_PROVIDER_TOOL_CALLS
3854 {
3855 return Err(format!(
3856 "provider response exceeds {MAX_PROVIDER_TOOL_CALLS} tool calls"
3857 ));
3858 }
3859 let partial = self.response_tool_calls.entry(key).or_default();
3860 if let (Some(existing), Some(new)) = (partial.id.as_deref(), delta.id.as_deref()) {
3868 if existing != new {
3869 return Err(format!(
3870 "provider reused tool-call index {} with conflicting id",
3871 delta.index
3872 ));
3873 }
3874 }
3875 if let (Some(existing), Some(new)) = (partial.name.as_deref(), delta.name.as_deref()) {
3876 if existing != new {
3877 return Err(format!(
3878 "provider reused tool-call index {} with conflicting name",
3879 delta.index
3880 ));
3881 }
3882 }
3883 if delta.id.is_some() {
3884 partial.id = delta.id;
3885 }
3886 if delta.name.is_some() {
3887 partial.name = delta.name;
3888 }
3889 push_bounded(&mut partial.arguments, &delta.arguments, "tool-call arguments")?;
3890 }
3891 if parsed.usage_reported {
3892 reject_metric_regression(
3893 "prompt tokens",
3894 &mut self.last_reported_prompt_tokens,
3895 parsed.metrics.prompt_tokens,
3896 )?;
3897 reject_metric_regression(
3898 "cached tokens",
3899 &mut self.last_reported_cached_tokens,
3900 parsed.metrics.cached_tokens,
3901 )?;
3902 }
3903 if parsed.cost_reported {
3904 reject_metric_regression(
3905 "cost",
3906 &mut self.last_reported_cost_usd_micros,
3907 Some(parsed.cost_usd_micros),
3908 )?;
3909 }
3910 if let Some(prompt) = parsed.metrics.prompt_tokens {
3911 self.response_metrics.prompt_tokens = Some(
3912 self.response_metrics
3913 .prompt_tokens
3914 .map_or(prompt, |current| current.max(prompt)),
3915 );
3916 }
3917 if let Some(cached) = parsed.metrics.cached_tokens {
3918 self.response_metrics.cached_tokens = Some(
3919 self.response_metrics
3920 .cached_tokens
3921 .map_or(cached, |current| current.max(cached)),
3922 );
3923 }
3924 let reported_completion = parsed.metrics.completion_tokens.unwrap_or(0);
3925 let delta = if parsed.usage_reported && parsed.completion_reported {
3936 let previous = self.last_reported_completion_tokens.unwrap_or(0);
3937 if reported_completion < previous {
3938 return Err("provider completion usage regressed".to_owned());
3939 }
3940 self.last_reported_completion_tokens = Some(reported_completion);
3941 let delta = reported_completion.saturating_sub(self.charged_completion_tokens);
3942 self.response_metrics.completion_tokens = Some(reported_completion);
3955 delta
3956 } else {
3957 let total = self
3958 .response_metrics
3959 .completion_tokens
3960 .unwrap_or(0)
3961 .checked_add(reported_completion)
3962 .filter(|total| *total <= av_core::error::JCS_SAFE_MAX)
3963 .ok_or_else(|| "provider completion-token total exceeds JCS-safe bounds".to_owned())?;
3964 self.response_metrics.completion_tokens = Some(total);
3965 reported_completion
3966 };
3967 if delta == 0 {
3968 return Ok(0);
3969 }
3970 self.charged_completion_tokens = self
3971 .charged_completion_tokens
3972 .checked_add(delta)
3973 .ok_or_else(|| "charged completion-token counter overflow".to_owned())?;
3974 Ok(delta)
3975 }
3976
3977 fn begin_budget_check(&mut self, delta: u64, continuation: BudgetContinuation) {
3978 let store = Arc::clone(&self.store);
3979 let session_id = self.session.id.clone();
3980 let budget = self.budget.clone();
3981 let principal = self.principal_budget.clone();
3982 let task = tokio::task::spawn_blocking(move || {
3983 if let Some((principal_id, spec)) = principal.as_ref() {
3988 match av_state::ActionBudget::for_principal(store.as_ref(), principal_id, spec)
3989 .try_tokens(delta)
3990 .map_err(|error| format!("token budget backend failed closed (principal): {error}"))?
3991 {
3992 av_state::BudgetDecision::Refused { limit, cap } => {
3993 return Ok(av_state::BudgetDecision::Refused {
3994 limit: format!("principal.{limit}"),
3995 cap,
3996 });
3997 }
3998 av_state::BudgetDecision::Allowed { .. } => {}
3999 }
4000 }
4001 let decision =
4008 match av_state::ActionBudget::new(store.as_ref(), &session_id, &budget).try_tokens(delta) {
4009 Ok(decision) => decision,
4010 Err(error) => {
4011 if let Some((principal_id, spec)) = principal.as_ref() {
4012 av_state::ActionBudget::for_principal(store.as_ref(), principal_id, spec)
4013 .refund_tokens(delta);
4014 }
4015 return Err(format!("token budget backend failed closed: {error}"));
4016 }
4017 };
4018 if matches!(decision, av_state::BudgetDecision::Refused { .. }) {
4019 if let Some((principal_id, spec)) = principal.as_ref() {
4020 av_state::ActionBudget::for_principal(store.as_ref(), principal_id, spec)
4021 .refund_tokens(delta);
4022 }
4023 }
4024 Ok(decision)
4025 });
4026 self.pending_budget = Some(PendingBudget {
4027 task,
4028 continuation,
4029 delta,
4030 });
4031 }
4032
4033 fn submit_response_capture(
4034 &mut self,
4035 failure: Option<(av_events::StopReason, String)>,
4036 ) -> Result<(), crate::worker::SubmitError> {
4037 if self.capture_attempted {
4038 return Ok(());
4039 }
4040 self.capture_attempted = true;
4041 let reasoning =
4042 (!self.response_reasoning.is_empty()).then(|| std::mem::take(&mut self.response_reasoning));
4043 let tool_calls: Vec<av_atif::ToolCall> = std::mem::take(&mut self.response_tool_calls)
4053 .into_values()
4054 .map(|partial| {
4055 let arguments = normalize_tool_call_arguments(&partial.arguments);
4056 av_atif::ToolCall {
4057 tool_call_id: partial.id.unwrap_or_else(av_core::new_event_uid),
4058 function_name: partial.name.unwrap_or_else(|| "unknown".to_owned()),
4059 arguments,
4060 extra: None,
4061 }
4062 })
4063 .collect();
4064 let analysis_text = if let Some(text) = reasoning.clone() {
4065 text
4066 } else if !self.response_message.is_empty() {
4067 self.response_message.clone()
4068 } else if !tool_calls.is_empty() {
4069 tool_calls
4070 .iter()
4071 .map(|tc| format!("{}({})", tc.function_name, tc.arguments))
4072 .collect::<Vec<_>>()
4073 .join(" ")
4074 } else {
4075 String::new()
4076 };
4077 let analyze_loop = !analysis_text.is_empty();
4081 let native_finish_reason = self.response_finish_reason.clone();
4082 let (class, status, stop_reason, payload) = if let Some((failure_reason, reason)) = failure {
4089 (
4090 av_events::EventClass::StopReason,
4091 av_events::StatusId::Failure,
4092 Some(failure_reason),
4093 json!({"reason": reason, "direction": "upstream_response"}),
4094 )
4095 } else if !self.upstream_status.is_success() {
4096 (
4097 av_events::EventClass::StopReason,
4098 av_events::StatusId::Failure,
4099 Some(av_events::StopReason::Other),
4100 json!({
4101 "direction": "upstream_response",
4102 "http_status": self.upstream_status.as_u16(),
4103 }),
4104 )
4105 } else if let Some(native) = &native_finish_reason {
4106 let mapped = map_finish_reason(native);
4122 let normalized = if mapped == av_events::StopReason::Stop && !tool_calls.is_empty() {
4123 av_events::StopReason::ToolUse
4124 } else {
4125 mapped
4126 };
4127 (
4128 av_events::EventClass::StopReason,
4129 av_events::StatusId::Success,
4130 Some(normalized),
4131 json!({
4132 "direction": "upstream_response",
4133 "finish_reason": native,
4134 "http_status": self.upstream_status.as_u16(),
4135 }),
4136 )
4137 } else if self.is_sse {
4138 (
4149 av_events::EventClass::StopReason,
4150 av_events::StatusId::Unknown,
4151 Some(av_events::StopReason::Other),
4152 json!({
4153 "direction": "upstream_response",
4154 "http_status": self.upstream_status.as_u16(),
4155 "truncated": true,
4156 "reason": "stream ended without a finish_reason chunk",
4157 }),
4158 )
4159 } else if self.response_message.is_empty()
4160 && self.response_reasoning.is_empty()
4161 && tool_calls.is_empty()
4162 {
4163 (
4173 av_events::EventClass::StopReason,
4174 av_events::StatusId::Unknown,
4175 Some(av_events::StopReason::Other),
4176 json!({
4177 "direction": "upstream_response",
4178 "http_status": self.upstream_status.as_u16(),
4179 "truncated": true,
4180 "reason": "response carried no choices, content, or finish_reason",
4181 }),
4182 )
4183 } else {
4184 (
4185 av_events::EventClass::Session,
4186 av_events::StatusId::Success,
4187 None,
4188 json!({
4189 "direction": "upstream_response",
4190 "http_status": self.upstream_status.as_u16(),
4191 }),
4192 )
4193 };
4194 if let Some(provider) = self.response_metrics.prompt_tokens {
4205 let over_charge = self.billed_prompt_tokens.saturating_sub(provider);
4206 if over_charge > 0 {
4207 av_state::ActionBudget::new(self.store.as_ref(), &self.session.id, &self.budget)
4208 .refund_tokens(over_charge);
4209 }
4210 }
4211 if let Some(provider) = self.response_metrics.completion_tokens {
4220 let over_charge = self.charged_completion_tokens.saturating_sub(provider);
4221 if over_charge > 0 {
4222 av_state::ActionBudget::new(self.store.as_ref(), &self.session.id, &self.budget)
4223 .refund_tokens(over_charge);
4224 }
4225 }
4226 let permit = self
4227 .response_permit
4228 .take()
4229 .ok_or(crate::worker::SubmitError::Closed)?;
4230 permit.submit(
4231 &self.worker,
4232 crate::worker::WorkerJob {
4233 session: Arc::clone(&self.session),
4234 identity: self.identity.clone(),
4235 class,
4236 payload,
4237 text: analysis_text,
4238 analyze_loop,
4239 status,
4240 stop_reason,
4241 native_stop_reason: native_finish_reason,
4242 metrics: self.response_metrics,
4243 cost_usd_micros: self.response_cost_usd_micros,
4244 prompt_token_correction: self.response_metrics.prompt_tokens.map_or(0i64, |provider| {
4252 let provider = i64::try_from(provider).unwrap_or(i64::MAX);
4253 let billed = i64::try_from(self.billed_prompt_tokens).unwrap_or(i64::MAX);
4254 provider.saturating_sub(billed)
4255 }),
4256 atif: Some(crate::worker::AtifCapture {
4257 source: av_atif::Source::Agent,
4258 message: Value::String(std::mem::take(&mut self.response_message)),
4259 reasoning_content: reasoning,
4260 model_name: self.response_model.take(),
4261 tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
4262 observation: None,
4263 llm_call_count: Some(1),
4264 }),
4265 response_marker: self.response_marker.take(),
4266 response_attempt: Some(crate::worker::ResponseAttempt {
4267 id: self.response_attempt_id.clone(),
4268 terminal: true,
4269 }),
4270 },
4271 )?;
4272 Ok(())
4273 }
4274}
4275
4276enum BudgetContinuation {
4277 Emit(Bytes),
4278 ContinueNonSse,
4279 FinishSse,
4280 FinishNonSse,
4281}
4282
4283struct PendingBudget {
4284 task: tokio::task::JoinHandle<Result<av_state::BudgetDecision, String>>,
4285 continuation: BudgetContinuation,
4286 delta: u64,
4295}
4296
4297#[derive(Default)]
4298struct PartialToolCall {
4299 id: Option<String>,
4300 name: Option<String>,
4301 arguments: String,
4302}
4303
4304#[derive(Debug)]
4305pub(crate) struct ProviderToolCallDelta {
4306 pub(crate) choice_index: u64,
4312 pub(crate) index: u64,
4318 pub(crate) complete: bool,
4324 pub(crate) id: Option<String>,
4325 pub(crate) name: Option<String>,
4326 pub(crate) arguments: String,
4327}
4328
4329#[derive(Debug)]
4330pub(crate) struct ParsedProviderChunk {
4331 pub(crate) message: String,
4332 pub(crate) reasoning: Option<String>,
4333 pub(crate) model_name: Option<String>,
4334 pub(crate) metrics: av_events::EventMetrics,
4335 pub(crate) usage_reported: bool,
4336 pub(crate) completion_reported: bool,
4343 pub(crate) finish_reason: Option<String>,
4344 pub(crate) cost_usd_micros: u64,
4345 pub(crate) cost_reported: bool,
4346 pub(crate) has_choices: bool,
4347 pub(crate) tool_call_deltas: Vec<ProviderToolCallDelta>,
4348}
4349
4350fn reject_metric_regression(
4351 field: &str,
4352 previous: &mut Option<u64>,
4353 current: Option<u64>,
4354) -> Result<(), String> {
4355 let Some(current) = current else {
4356 return Ok(());
4357 };
4358 if previous.is_some_and(|previous| current < previous) {
4359 return Err(format!("provider {field} regressed"));
4360 }
4361 *previous = Some(current);
4362 Ok(())
4363}
4364
4365fn push_bounded(target: &mut String, value: &str, field: &str) -> Result<(), String> {
4366 let size = target
4367 .len()
4368 .checked_add(value.len())
4369 .ok_or_else(|| format!("{field} size overflow"))?;
4370 if size > MAX_PROVIDER_FIELD_BYTES {
4371 return Err(format!("{field} exceeds {MAX_PROVIDER_FIELD_BYTES} bytes"));
4372 }
4373 target.push_str(value);
4374 Ok(())
4375}
4376
4377#[doc(hidden)]
4383pub fn __fuzz_parse_provider_chunk(raw: &[u8]) {
4384 if let Ok(s) = std::str::from_utf8(raw) {
4385 let _ = parse_provider_chunk(s);
4386 }
4387}
4388
4389#[doc(hidden)]
4392pub fn __fuzz_sse_frame_end(raw: &[u8]) {
4393 let _ = sse_frame_end(raw);
4394}
4395
4396fn sse_frame_end(buffer: &[u8]) -> Option<usize> {
4397 let mut line_start = 0usize;
4398 let mut index = 0usize;
4399 while index < buffer.len() {
4400 let byte = buffer.get(index).copied()?;
4401 let newline = match byte {
4402 b'\r' if buffer.get(index + 1) == Some(&b'\n') => 2,
4403 b'\r' if buffer.get(index + 1).is_none() => return None,
4404 b'\r' | b'\n' => 1,
4405 _ => {
4406 index += 1;
4407 continue;
4408 }
4409 };
4410 if index == line_start {
4411 return Some(index + newline);
4412 }
4413 index += newline;
4414 line_start = index;
4415 }
4416 None
4417}
4418
4419impl AbortFinalizingStream {
4420 fn abort_error(&self, reason: String) -> std::io::Error {
4426 tracing::warn!(
4427 session = %self.session.id,
4428 %reason,
4429 "aborting client response; upstream reply could not be captured"
4430 );
4431 std::io::Error::other(reason)
4432 }
4433
4434 fn record_response_failure(&mut self, reason: &str) {
4445 if let Err(error) =
4454 self.submit_response_capture(Some((av_events::StopReason::Other, reason.to_owned())))
4455 {
4456 tracing::warn!(
4457 %error,
4458 session = %self.session.id,
4459 "response-failure capture submit failed; sealing session capture"
4460 );
4461 self.session.mark_capture_failed();
4462 }
4463 }
4464}
4465
4466impl Stream for AbortFinalizingStream {
4467 type Item = Result<Bytes, std::io::Error>;
4468
4469 fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
4470 if self.pending_budget.is_some() {
4471 let result = {
4472 let Some(pending) = self.pending_budget.as_mut() else {
4473 self.session.mark_capture_failed();
4474 return Poll::Ready(Some(Err(std::io::Error::other(
4475 "pending completion budget state disappeared",
4476 ))));
4477 };
4478 match Pin::new(&mut pending.task).poll(context) {
4479 Poll::Pending => return Poll::Pending,
4480 Poll::Ready(result) => result,
4481 }
4482 };
4483 let Some(pending) = self.pending_budget.take() else {
4484 self.session.mark_capture_failed();
4485 return Poll::Ready(Some(Err(std::io::Error::other(
4486 "completed completion budget state disappeared",
4487 ))));
4488 };
4489 let failure = match result {
4490 Ok(Ok(av_state::BudgetDecision::Allowed { .. })) => None,
4491 Ok(Ok(av_state::BudgetDecision::Refused { limit, cap })) => {
4492 self.charged_completion_tokens =
4500 self.charged_completion_tokens.saturating_sub(pending.delta);
4501 self.session
4509 .latch_enforcement(av_events::StopReason::BudgetExceeded);
4510 Some((
4511 av_events::StopReason::BudgetExceeded,
4512 format!("{limit} exceeded (cap {cap})"),
4513 ))
4514 }
4515 Ok(Err(reason)) => {
4521 self.charged_completion_tokens =
4522 self.charged_completion_tokens.saturating_sub(pending.delta);
4523 Some((av_events::StopReason::Other, reason))
4524 }
4525 Err(error) => {
4526 self.session.mark_capture_failed();
4527 self.pending_output.clear();
4528 return Poll::Ready(Some(Err(std::io::Error::other(format!(
4529 "completion budget task failed: {error}"
4530 )))));
4531 }
4532 };
4533 if let Some((failure_reason, reason)) = failure {
4534 if let Err(error) = self.submit_response_capture(Some((failure_reason, reason.clone()))) {
4535 self.session.mark_capture_failed();
4536 self.pending_output.clear();
4537 return Poll::Ready(Some(Err(std::io::Error::other(format!(
4538 "response capture failed closed: {error}"
4539 )))));
4540 }
4541 self.pending_output.clear();
4542 return Poll::Ready(Some(Err(std::io::Error::new(
4549 std::io::ErrorKind::QuotaExceeded,
4550 format!("response blocked by token budget: {reason}"),
4551 ))));
4552 }
4553 match pending.continuation {
4554 BudgetContinuation::Emit(bytes) => return Poll::Ready(Some(Ok(bytes))),
4555 BudgetContinuation::ContinueNonSse => {}
4556 BudgetContinuation::FinishSse => {
4557 if let Err(error) = self.submit_response_capture(None) {
4558 self.session.mark_capture_failed();
4559 return Poll::Ready(Some(Err(std::io::Error::other(format!(
4560 "response capture failed closed: {error}"
4561 )))));
4562 }
4563 self.completed = true;
4564 return Poll::Ready(None);
4565 }
4566 BudgetContinuation::FinishNonSse => {
4567 if let Err(error) = self.submit_response_capture(None) {
4568 self.session.mark_capture_failed();
4569 self.pending_output.clear();
4570 return Poll::Ready(Some(Err(std::io::Error::other(format!(
4571 "response capture failed closed: {error}"
4572 )))));
4573 }
4574 self.completed = true;
4575 return Poll::Ready(self.pending_output.pop_front().map(Ok));
4576 }
4577 }
4578 }
4579
4580 if !self.is_sse {
4581 if self.completed {
4582 return Poll::Ready(self.pending_output.pop_front().map(Ok));
4583 }
4584 loop {
4585 match self.inner.as_mut().poll_next(context) {
4586 Poll::Ready(Some(Ok(bytes))) => {
4587 let delta = match self.absorb_network_chunk(&bytes) {
4588 Ok(delta) => delta,
4589 Err(error) => {
4590 self.record_response_failure(&error);
4591 self.pending_output.clear();
4592 let error = self.abort_error(error);
4593 return Poll::Ready(Some(Err(error)));
4594 }
4595 };
4596 self.pending_output.push_back(bytes);
4597 if delta > 0 {
4598 self.begin_budget_check(delta, BudgetContinuation::ContinueNonSse);
4599 context.waker().wake_by_ref();
4600 return Poll::Pending;
4601 }
4602 }
4603 Poll::Ready(Some(Err(error))) => {
4604 let reason = error.to_string();
4605 self.record_response_failure(&reason);
4606 self.pending_output.clear();
4607 let error = self.abort_error(reason);
4608 return Poll::Ready(Some(Err(error)));
4609 }
4610 Poll::Pending => return Poll::Pending,
4611 Poll::Ready(None) => {
4612 let delta = match self.flush_protocol_buffer() {
4613 Ok(delta) => delta,
4614 Err(error) => {
4615 self.record_response_failure(&error);
4616 self.pending_output.clear();
4617 let error = self.abort_error(error);
4618 return Poll::Ready(Some(Err(error)));
4619 }
4620 };
4621 if delta > 0 {
4622 self.begin_budget_check(delta, BudgetContinuation::FinishNonSse);
4623 context.waker().wake_by_ref();
4624 return Poll::Pending;
4625 }
4626 if self.upstream_status.is_success() && !self.saw_chunk {
4635 let reason = "successful provider response has an empty body".to_owned();
4643 self.record_response_failure(&reason);
4644 self.session.mark_capture_failed();
4645 self.pending_output.clear();
4646 let error = self.abort_error(reason);
4647 return Poll::Ready(Some(Err(error)));
4648 }
4649 if let Err(error) = self.submit_response_capture(None) {
4650 self.session.mark_capture_failed();
4651 self.pending_output.clear();
4652 return Poll::Ready(Some(Err(std::io::Error::other(format!(
4653 "response capture failed closed: {error}"
4654 )))));
4655 }
4656 self.completed = true;
4657 return Poll::Ready(self.pending_output.pop_front().map(Ok));
4658 }
4659 }
4660 }
4661 }
4662
4663 match self.inner.as_mut().poll_next(context) {
4664 Poll::Ready(Some(Ok(bytes))) => match self.absorb_network_chunk(&bytes) {
4665 Ok(0) => Poll::Ready(Some(Ok(bytes))),
4666 Ok(delta) => {
4667 self.begin_budget_check(delta, BudgetContinuation::Emit(bytes));
4668 context.waker().wake_by_ref();
4669 Poll::Pending
4670 }
4671 Err(error) => {
4672 self.record_response_failure(&error);
4673 let error = self.abort_error(error);
4674 Poll::Ready(Some(Err(error)))
4675 }
4676 },
4677 Poll::Ready(Some(Err(error))) => {
4678 let reason = error.to_string();
4679 self.record_response_failure(&reason);
4680 let error = self.abort_error(reason);
4681 Poll::Ready(Some(Err(error)))
4682 }
4683 Poll::Pending => Poll::Pending,
4684 Poll::Ready(None) => {
4685 let delta = match self.flush_protocol_buffer() {
4686 Ok(delta) => delta,
4687 Err(error) => {
4688 self.record_response_failure(&error);
4689 let error = self.abort_error(error);
4690 return Poll::Ready(Some(Err(error)));
4691 }
4692 };
4693 if delta > 0 {
4694 self.begin_budget_check(delta, BudgetContinuation::FinishSse);
4695 context.waker().wake_by_ref();
4696 return Poll::Pending;
4697 }
4698 if self.upstream_status.is_success() && !self.saw_chunk {
4702 let reason = "successful provider response has an empty body".to_owned();
4707 self.record_response_failure(&reason);
4708 self.session.mark_capture_failed();
4709 let error = self.abort_error(reason);
4710 return Poll::Ready(Some(Err(error)));
4711 }
4712 if let Err(error) = self.submit_response_capture(None) {
4713 self.session.mark_capture_failed();
4714 return Poll::Ready(Some(Err(std::io::Error::other(format!(
4715 "response capture failed closed: {error}"
4716 )))));
4717 }
4718 self.completed = true;
4719 Poll::Ready(None)
4720 }
4721 }
4722 }
4723}
4724
4725pub(crate) fn parse_provider_chunk(raw: &str) -> Result<Option<ParsedProviderChunk>, String> {
4726 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
4735 let mut message = String::new();
4736 let mut reasoning = String::new();
4737 let mut prompt_tokens = None;
4738 let mut completion_tokens = None;
4739 let mut cached_tokens = None;
4740 let mut usage_reported = false;
4741 let mut finish_reason = None;
4742 let mut cost_usd_micros = 0u64;
4743 let mut cost_reported = false;
4744 let mut tool_call_deltas = Vec::new();
4745 let mut is_sse = false;
4746 let mut data = Vec::new();
4747 let mut event_type = String::new();
4762 for line in raw.split(['\r', '\n']) {
4763 if let Some(value) = line.strip_prefix("data:") {
4764 is_sse = true;
4765 data.push(value.strip_prefix(' ').unwrap_or(value));
4771 } else if let Some(value) = line.strip_prefix("event:") {
4772 is_sse = true;
4773 event_type = value.strip_prefix(' ').unwrap_or(value).to_owned();
4774 } else if line == "data"
4775 || line.starts_with("id:")
4776 || line.starts_with("retry:")
4777 || line.starts_with(':')
4778 {
4779 is_sse = true;
4780 }
4781 }
4782 if is_sse && !event_type.is_empty() && event_type != "message" {
4783 if data.iter().all(|entry| {
4801 let trimmed = entry.trim();
4802 trimmed.is_empty() || trimmed == "[DONE]"
4803 }) {
4804 return Ok(None);
4805 }
4806 return Err(format!(
4807 "provider SSE frame carries unsupported event type {event_type:?}; \
4808 AgentVisor AI only captures the default `message` event because non-message \
4809 events (error, ping, custom) are dispatched to different client listeners \
4810 per SSE §9.2.8 and would be attributed to the wrong audit surface"
4811 ));
4812 }
4813 let candidate = if is_sse {
4814 if data.is_empty() {
4815 return Ok(None);
4816 }
4817 data.join("\n")
4818 } else {
4819 raw.trim().to_owned()
4820 };
4821 if candidate.is_empty()
4827 || candidate.trim() == "[DONE]"
4828 || data
4829 .iter()
4830 .all(|entry| entry.trim().is_empty() || entry.trim() == "[DONE]")
4831 && !data.is_empty()
4832 {
4833 return Ok(None);
4834 }
4835 let value = serde_json::from_str::<Value>(&candidate)
4836 .map_err(|error| format!("invalid provider JSON frame: {error}"))?;
4837 if let Some(err_obj) = value.get("error").filter(|v| !v.is_null()) {
4849 let message = err_obj
4850 .get("message")
4851 .and_then(Value::as_str)
4852 .unwrap_or("provider ended stream with an unspecified error");
4853 let code = err_obj.get("code").and_then(Value::as_str).unwrap_or("");
4854 return Err(if code.is_empty() {
4855 format!("provider stream error: {message}")
4856 } else {
4857 format!("provider stream error [{code}]: {message}")
4858 });
4859 }
4860 let model_name = value.get("model").and_then(Value::as_str).map(str::to_owned);
4861 if let Some(usage) = value.get("usage").filter(|usage| !usage.is_null()) {
4871 usage_reported = true;
4872 prompt_tokens = provider_u64(usage.get("prompt_tokens"), "prompt_tokens")?.or(prompt_tokens);
4873 completion_tokens =
4874 provider_u64(usage.get("completion_tokens"), "completion_tokens")?.or(completion_tokens);
4875 cached_tokens = provider_u64(
4876 usage.pointer("/prompt_tokens_details/cached_tokens"),
4877 "cached_tokens",
4878 )?
4879 .or(cached_tokens);
4880 }
4881 let cost_value = value.pointer("/usage/cost_usd").or_else(|| value.get("cost_usd"));
4882 if let Some(cost_value) = cost_value {
4883 let cost = cost_value
4884 .as_f64()
4885 .ok_or_else(|| "provider cost_usd is not a number".to_owned())?;
4886 if !cost.is_finite() || cost < 0.0 {
4887 return Err("provider cost_usd is not finite and nonnegative".to_owned());
4888 }
4889 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4890 {
4891 let micros = (cost * av_core::units::USD_MICROS_PER_DOLLAR as f64).round();
4892 if micros > av_core::error::JCS_SAFE_MAX as f64 {
4893 return Err("provider cost exceeds JCS-safe receipt bounds".to_owned());
4894 }
4895 cost_usd_micros = micros as u64;
4896 cost_reported = true;
4897 }
4898 }
4899 let has_choices = value.get("choices").is_some();
4900 let choices = match value.get("choices") {
4901 Some(Value::Array(choices)) => Some(choices),
4902 Some(_) => return Err("provider choices is not an array".to_owned()),
4903 None => None,
4904 };
4905 if let Some(choices) = choices {
4906 for (choice_position, choice) in choices.iter().enumerate() {
4907 let choice_index = choice
4913 .get("index")
4914 .and_then(Value::as_u64)
4915 .or_else(|| u64::try_from(choice_position).ok())
4916 .unwrap_or(u64::MAX);
4917 if let Some(reason) = choice
4918 .get("finish_reason")
4919 .and_then(Value::as_str)
4920 .filter(|s| !s.is_empty())
4921 {
4922 finish_reason = Some(reason.to_owned());
4929 }
4930 let content = choice
4931 .pointer("/delta/content")
4932 .or_else(|| choice.pointer("/message/content"))
4933 .and_then(Value::as_str);
4934 if let Some(content) = content {
4935 message.push_str(content);
4936 }
4937 let reasoning_content = choice
4938 .pointer("/delta/reasoning_content")
4939 .or_else(|| choice.pointer("/message/reasoning_content"))
4940 .and_then(Value::as_str);
4941 if let Some(content) = reasoning_content {
4942 reasoning.push_str(content);
4943 }
4944 let streaming_calls = choice.pointer("/delta/tool_calls");
4945 let calls = streaming_calls
4946 .or_else(|| choice.pointer("/message/tool_calls"))
4947 .map(|calls| {
4948 calls
4949 .as_array()
4950 .ok_or_else(|| "provider tool_calls is not an array".to_owned())
4951 })
4952 .transpose()?;
4953 if let Some(calls) = calls {
4954 for (position, call) in calls.iter().enumerate() {
4955 let index = match call.get("index").and_then(Value::as_u64) {
4956 Some(index) => index,
4957 None if streaming_calls.is_some() => {
4958 return Err("streaming provider tool call has no index".to_owned());
4959 }
4960 None => u64::try_from(position)
4961 .map_err(|_| "provider tool-call index overflow".to_owned())?,
4962 };
4963 tool_call_deltas.push(ProviderToolCallDelta {
4964 choice_index,
4965 index,
4966 complete: false,
4967 id: call.get("id").and_then(Value::as_str).map(str::to_owned),
4968 name: call
4969 .pointer("/function/name")
4970 .and_then(Value::as_str)
4971 .map(str::to_owned),
4972 arguments: call
4973 .pointer("/function/arguments")
4974 .and_then(Value::as_str)
4975 .unwrap_or_default()
4976 .to_owned(),
4977 });
4978 }
4979 }
4980 }
4981 }
4982 let completion_reported = completion_tokens.is_some();
4983 let completion_tokens = completion_tokens.unwrap_or_else(|| {
4984 let mut estimated = av_core::tokens::approx_tokens(&message)
4985 .saturating_add(av_core::tokens::approx_tokens(&reasoning));
4986 for call in &tool_call_deltas {
4987 estimated = estimated
4988 .saturating_add(call.name.as_deref().map_or(0, av_core::tokens::approx_tokens))
4989 .saturating_add(av_core::tokens::approx_tokens(&call.arguments));
4990 }
4991 estimated
4992 });
4993 Ok(Some(ParsedProviderChunk {
4994 message,
4995 reasoning: (!reasoning.is_empty()).then_some(reasoning),
4996 model_name,
4997 metrics: av_events::EventMetrics {
4998 prompt_tokens,
4999 completion_tokens: Some(completion_tokens),
5000 cached_tokens,
5001 pruned_tokens: None,
5002 pruning_ratio_millis: None,
5003 },
5004 usage_reported,
5005 completion_reported,
5006 finish_reason,
5007 cost_usd_micros,
5008 cost_reported,
5009 has_choices,
5010 tool_call_deltas,
5011 }))
5012}
5013
5014pub(crate) fn provider_u64(value: Option<&Value>, field: &str) -> Result<Option<u64>, String> {
5015 let Some(value) = value else {
5016 return Ok(None);
5017 };
5018 if value.is_null() {
5022 return Ok(None);
5023 }
5024 let value = value
5025 .as_u64()
5026 .ok_or_else(|| format!("provider {field} is not a nonnegative integer"))?;
5027 if value > av_core::error::JCS_SAFE_MAX {
5028 return Err(format!("provider {field} exceeds JCS-safe bounds"));
5029 }
5030 Ok(Some(value))
5031}
5032
5033pub(crate) fn normalize_tool_call_arguments(raw: &str) -> serde_json::Value {
5056 let candidate = if raw.is_empty() { "{}" } else { raw };
5057 serde_json::from_str(candidate).unwrap_or_else(|_| json!({"raw": raw}))
5058}
5059
5060pub(crate) fn map_finish_reason(native: &str) -> av_events::StopReason {
5061 match native {
5062 "stop" | "stop_sequence" | "end_turn" => av_events::StopReason::Stop,
5063 "length" | "max_tokens" | "model_context_window_exceeded" => av_events::StopReason::MaxTokens,
5069 "tool_calls" | "function_call" | "tool_use" => av_events::StopReason::ToolUse,
5070 "pause_turn" => av_events::StopReason::ToolUse,
5077 "content_filter" | "refusal" => av_events::StopReason::ContentFilter,
5085 _ => av_events::StopReason::Other,
5086 }
5087}
5088
5089impl Drop for AbortFinalizingStream {
5090 fn drop(&mut self) {
5091 if self.completed {
5096 if self.ephemeral && !self.session.is_closed() && self.session.active_streams_count() <= 1 {
5108 let session = Arc::clone(&self.session);
5109 let finalizer = self.finalizer.clone();
5110 let session_id = session.id.clone();
5111 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
5112 runtime.spawn(async move {
5113 let outcome = std::panic::AssertUnwindSafe(
5126 finalizer.close_session(session, StopReason::SessionClosed),
5127 )
5128 .catch_unwind()
5129 .await;
5130 match outcome {
5131 Ok(Ok(_)) => {}
5132 Ok(Err(error)) => {
5133 finalizer
5134 .metrics()
5135 .counter(
5136 "av_ephemeral_close_failures_total",
5137 "Auto-close of a completed ephemeral one-shot failed; \
5138 the session is left open until the idle sweeper reaps it",
5139 )
5140 .inc();
5141 tracing::warn!(
5142 %error,
5143 session = %session_id,
5144 "auto-close of completed ephemeral session failed"
5145 );
5146 }
5147 Err(panic) => {
5148 let msg = panic
5149 .downcast_ref::<&'static str>()
5150 .copied()
5151 .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
5152 .unwrap_or("panic payload was not a string");
5153 finalizer
5154 .metrics()
5155 .counter(
5156 "av_ephemeral_close_panics_total",
5157 "Auto-close of a completed ephemeral one-shot panicked \
5158 and was caught; the session is left open until the idle \
5159 sweeper reaps it",
5160 )
5161 .inc();
5162 tracing::warn!(
5163 session = %session_id,
5164 panic = %msg,
5165 "auto-close of completed ephemeral session PANICKED (caught)"
5166 );
5167 }
5168 }
5169 });
5170 }
5171 }
5174 return;
5175 }
5176 let is_closed = self.session.is_closed();
5196 let budget_incomplete = self.pending_budget.is_some();
5197 if let Some(pending) = self.pending_budget.take() {
5209 pending.task.abort();
5210 self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(pending.delta);
5221 }
5222 let (flush_reason, budget_delta) = match self.flush_protocol_buffer() {
5223 Ok(delta) => (None, delta),
5224 Err(error) => {
5225 tracing::warn!(%error, "provider stream flush failed on drop");
5226 (Some(format!("provider stream flush failed: {error}")), 0u64)
5227 }
5228 };
5229 self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(budget_delta);
5235 let response_failure: Option<String> = if budget_incomplete {
5242 Some("client disconnected before response budget check completed".to_owned())
5243 } else if let Some(reason) = flush_reason {
5244 Some(reason)
5245 } else if budget_delta > 0 {
5246 Some(format!(
5247 "client disconnected with {budget_delta} unbilled response bytes"
5248 ))
5249 } else {
5250 None
5251 };
5252 if let Err(error) = self.submit_response_capture(
5253 response_failure.map(|reason| (av_events::StopReason::Other, reason)),
5255 ) {
5256 tracing::warn!(%error, "response-capture submit failed on drop");
5257 if !is_closed {
5258 self.session.mark_capture_failed();
5262 }
5263 }
5264 let nothing_captured = !self.saw_chunk && self.captured_bytes == 0;
5289 let other_streams = self.session.active_streams_count() > 1;
5290 if self.ephemeral && !is_closed && nothing_captured && !other_streams {
5291 let session = Arc::clone(&self.session);
5292 let finalizer = self.finalizer.clone();
5293 let session_id = session.id.clone();
5294 let stop_reason = StopReason::Other;
5295 match tokio::runtime::Handle::try_current() {
5296 Ok(runtime) => {
5297 runtime.spawn(async move {
5310 let outcome =
5311 std::panic::AssertUnwindSafe(finalizer.close_session(session, stop_reason))
5312 .catch_unwind()
5313 .await;
5314 match outcome {
5315 Ok(Ok(_)) => {}
5316 Ok(Err(error)) => {
5317 finalizer
5318 .metrics()
5319 .counter(
5320 "av_stream_abort_close_failures_total",
5321 "Background close after a stream abort failed; \
5322 the session is left open until the idle sweeper reaps it",
5323 )
5324 .inc();
5325 tracing::warn!(
5326 %error,
5327 session = %session_id,
5328 "background close on stream abort failed"
5329 );
5330 }
5331 Err(panic) => {
5332 let msg = panic
5333 .downcast_ref::<&'static str>()
5334 .copied()
5335 .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
5336 .unwrap_or("panic payload was not a string");
5337 finalizer
5338 .metrics()
5339 .counter(
5340 "av_stream_abort_panics_total",
5341 "Background close after a stream abort panicked and was \
5342 caught; the session is left open until the idle sweeper \
5343 reaps it",
5344 )
5345 .inc();
5346 tracing::warn!(
5347 session = %session_id,
5348 panic = %msg,
5349 "background close on stream abort PANICKED (caught)"
5350 );
5351 }
5352 }
5353 });
5354 }
5355 Err(error) => {
5356 self.finalizer
5362 .metrics()
5363 .counter(
5364 "av_stream_abort_no_runtime_total",
5365 "Stream abort observed no tokio runtime; capture marked failed for \
5366 reconciler retry",
5367 )
5368 .inc();
5369 tracing::warn!(
5370 %error,
5371 session = %session_id,
5372 "no tokio runtime available for stream-abort close; marking capture failed for reconciler retry"
5373 );
5374 self.session.mark_capture_failed();
5375 }
5376 }
5377 }
5378 }
5379}
5380
5381#[cfg(test)]
5382mod tests {
5383 #![allow(
5384 clippy::expect_used,
5385 clippy::indexing_slicing,
5386 clippy::panic,
5387 clippy::unwrap_used
5388 )]
5389
5390 use super::*;
5391 use av_bridge::{BusError, EventBus, PublishAck, StoredEvent};
5392 use av_receipts::Ed25519Signer;
5393 use av_sandbox::{Sandbox, SandboxConfig};
5394 use av_state::{InMemoryStore, Spend, StateError, StateStore};
5395 use axum::http::Request;
5396 use std::time::Duration;
5397 use tower::ServiceExt;
5398
5399 #[test]
5413 fn openai_compatibility_documents_every_client_facing_route() {
5414 let doc = include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md");
5415 for (route, must_contain) in [
5428 ("/v1/chat/completions", "`/v1/chat/completions`"),
5429 ("/v1/mcp", "`/v1/mcp`"),
5430 ("/mcp (bare, MCP SDK convention)", "bare `/mcp`"),
5434 ("/v1/sessions/{id}/close", "`/v1/sessions/{id}/close`"),
5435 ("/v1/sessions/{id}/promote", "`/v1/sessions/{id}/promote`"),
5436 ] {
5437 assert!(
5438 doc.contains(must_contain),
5439 "route {route} is registered by build_router but the doc is missing \
5440 its distinguishing marker {must_contain:?} — an integration \
5441 engineer following the doc will not know it exists. Add a row to \
5442 the route table (or the /v1 vs /api/v1 split section) and re-run."
5443 );
5444 }
5445 }
5446
5447 #[tokio::test]
5455 async fn unsupported_openai_routes_stay_404() {
5456 let doc = include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md");
5457 let directory = tempfile::tempdir().unwrap();
5458 let (state, _reconciler) = test_state(directory.path()).await;
5459 let app = build_router(state);
5460 for (route, marker) in [
5461 ("/v1/embeddings", "`/v1/embeddings`"),
5462 ("/v1/audio/speech", "`/v1/audio/*`"),
5463 ("/v1/images/generations", "`/v1/images/*`"),
5464 ("/v1/completions", "`/v1/completions`"),
5465 ("/v1/models", "`/v1/models`"),
5466 ("/v1/files", "`/v1/files`"),
5467 ("/v1/moderations", "`/v1/moderations`"),
5468 ("/v1/assistants", "`/v1/assistants`"),
5469 ("/v1/fine_tuning/jobs", "`/v1/fine_tuning/*`"),
5470 ] {
5471 assert!(
5472 doc.contains(marker),
5473 "the Unsupported list lost {marker:?} — if {route} is now \
5474 implemented, update OPENAI-COMPATIBILITY.md; if not, restore it"
5475 );
5476 let response = app
5477 .clone()
5478 .oneshot(
5479 Request::builder()
5480 .method("POST")
5481 .uri(route)
5482 .header("content-type", "application/json")
5483 .body(Body::from("{}"))
5484 .unwrap(),
5485 )
5486 .await
5487 .unwrap();
5488 assert_eq!(
5489 response.status(),
5490 StatusCode::NOT_FOUND,
5491 "{route} no longer 404s — OPENAI-COMPATIBILITY.md's Unsupported \
5492 section promises it does; update the doc with the new behavior"
5493 );
5494 }
5495 }
5496
5497 #[test]
5511 fn product_console_pins_match_real_schema_and_taxonomy() {
5512 let console_js = include_str!("../../../docs/app/pitch/console.js");
5517 let console_html = include_str!("../../../docs/app/pitch/index.html");
5518 let combined = format!("{console_js}\n{console_html}");
5519
5520 assert_eq!(
5524 av_receipts::RECEIPT_VERSION,
5525 2,
5526 "receipt version bumped from 2; update docs/app/pitch/console.js's \
5527 receiptHtml() to render the new version",
5528 );
5529 assert!(
5530 combined.contains(r#""receipt_version""#) && combined.contains(">2<"),
5531 "the console must render receipt_version = 2 to match \
5532 av_receipts::RECEIPT_VERSION",
5533 );
5534
5535 for key in [
5539 "receipt_version",
5540 "receipt_id",
5541 "session_id",
5542 "issued_at",
5543 "issued_at_iso",
5544 "ai_agent",
5545 "subject",
5546 "tool_calls",
5547 "cost",
5548 "stop_reason_id",
5549 "stop_reason",
5550 "key_id",
5551 "public_key_b64",
5552 "signature_b64",
5553 ] {
5554 assert!(
5555 combined.contains(&format!(r#""{key}""#)),
5556 "receipt field `{key}` is not rendered by the product \
5557 console — either the field was renamed (update the \
5558 console) or the console was truncated (update this test)",
5559 );
5560 }
5561
5562 for cost_key in [
5567 "prompt_tokens",
5568 "completion_tokens",
5569 "cached_tokens",
5570 "cost_usd_micros",
5571 ] {
5572 assert!(
5573 combined.contains(&format!(r#""{cost_key}""#)),
5574 "cost field `{cost_key}` is not rendered by the console",
5575 );
5576 }
5577
5578 assert!(
5582 combined.contains("cost_usd_micros"),
5583 "console tampering demo relies on cost_usd_micros being \
5584 the integer field it changes; rename would break the demo",
5585 );
5586
5587 assert_eq!(
5591 av_events::StopReason::BudgetExceeded.id(),
5592 92,
5593 "BudgetExceeded id changed from 92; update the console's \
5594 sample receipt receiptHtml()",
5595 );
5596 assert_eq!(
5597 av_events::StopReason::BudgetExceeded.caption(),
5598 "Budget Exceeded",
5599 "BudgetExceeded caption changed; update the console",
5600 );
5601 assert!(
5602 combined.contains(r#""stop_reason_id""#) && combined.contains(">92<"),
5603 "console must render stop_reason_id = 92 to match \
5604 StopReason::BudgetExceeded",
5605 );
5606 assert!(
5607 combined.contains("Budget Exceeded"),
5608 "console must render the 'Budget Exceeded' caption for the \
5609 sample receipt",
5610 );
5611
5612 for topic in ["agent.tool_call", "agent.session"] {
5617 assert!(
5618 combined.contains(topic),
5619 "OCSF topic `{topic}` is not rendered by the console but is \
5620 shown as the file that lands in the operator's SIEM",
5621 );
5622 }
5623
5624 assert!(
5629 combined.contains("HTTP 403"),
5630 "console must render the 403 policy-refusal status the \
5631 pipeline emits (deliberately not 429, which SDKs auto-retry)",
5632 );
5633
5634 assert!(
5637 combined.contains("127.0.0.1:8484"),
5638 "console must render the default listen address 127.0.0.1:8484; \
5639 raise or lower the default and the setup/curl blocks lie",
5640 );
5641
5642 assert!(
5651 combined.contains("budget.max_tool_calls"),
5652 "console's TOML preview must render the per-tool cap as the \
5653 `[budget.max_tool_calls]` table the real BudgetSpec requires",
5654 );
5655 assert!(
5656 !console_js.contains(r#"max_tool_calls</span> = "#),
5657 "console's TOML preview renders `max_tool_calls = <scalar>`, \
5658 which the real config parser rejects (BudgetSpec.max_tool_calls \
5659 is a BTreeMap; the valid shape is a [budget.max_tool_calls] table)",
5660 );
5661 }
5662
5663 struct NullBus;
5664
5665 struct SlowStore {
5666 inner: InMemoryStore,
5667 calls: Arc<std::sync::atomic::AtomicUsize>,
5668 }
5669
5670 impl StateStore for SlowStore {
5671 fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
5672 self.inner.add(key, delta)
5673 }
5674
5675 fn get(&self, key: &str) -> Result<u64, StateError> {
5676 self.inner.get(key)
5677 }
5678
5679 fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError> {
5680 self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5681 std::thread::sleep(Duration::from_millis(100));
5682 self.inner.try_spend(key, amount, limit)
5683 }
5684
5685 fn try_spend_many(&self, spends: &[Spend]) -> Result<av_state::TrySpendOutcome, StateError> {
5686 self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5695 std::thread::sleep(Duration::from_millis(100));
5696 self.inner.try_spend_many(spends)
5697 }
5698
5699 fn remove(&self, key: &str) {
5700 self.inner.remove(key);
5701 }
5702 }
5703
5704 impl EventBus for NullBus {
5705 fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
5706 Ok(PublishAck {
5707 topic: topic.to_owned(),
5708 partition: 0,
5709 offset: 0,
5710 })
5711 }
5712
5713 fn fetch(
5714 &self,
5715 _topic: &str,
5716 _partition: u32,
5717 _offset: u64,
5718 _max: usize,
5719 ) -> Result<Vec<StoredEvent>, BusError> {
5720 Ok(Vec::new())
5721 }
5722
5723 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
5724 Ok(1)
5725 }
5726
5727 fn topics(&self) -> Vec<String> {
5728 av_events::EventClass::all()
5729 .iter()
5730 .map(|class| class.topic().to_owned())
5731 .collect()
5732 }
5733 }
5734
5735 async fn mock_chat(Json(payload): Json<Value>) -> Response {
5736 if payload.get("model").and_then(Value::as_str) == Some("empty-error") {
5737 let mut response = Response::new(Body::empty());
5738 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
5739 response.headers_mut().insert(
5740 axum::http::header::CONTENT_TYPE,
5741 HeaderValue::from_static("application/json"),
5742 );
5743 return response;
5744 }
5745 if payload.get("model").and_then(Value::as_str) == Some("json-error") {
5746 return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))).into_response();
5747 }
5748 if payload.get("model").and_then(Value::as_str) == Some("empty-success") {
5749 return Json(json!({})).into_response();
5750 }
5751 if payload.get("model").and_then(Value::as_str) == Some("split-sse") {
5752 let frame = "data: {\"choices\":[{\"delta\":{\"content\":\"héllo\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
5758 .as_bytes()
5759 .to_vec();
5760 let split = frame.iter().position(|byte| *byte == 0xc3).unwrap() + 1;
5761 let chunks = vec![
5762 Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&frame[..split])),
5763 Ok(Bytes::copy_from_slice(&frame[split..])),
5764 ];
5765 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5766 response.headers_mut().insert(
5767 axum::http::header::CONTENT_TYPE,
5768 HeaderValue::from_static("text/event-stream"),
5769 );
5770 return response;
5771 }
5772 if payload.get("model").and_then(Value::as_str) == Some("truncated-stream") {
5773 let mut response = Response::new(Body::from(
5777 "data: {\"choices\":[{\"delta\":{\"content\":\"partial ans\"}}]}\n\n",
5778 ));
5779 response.headers_mut().insert(
5780 axum::http::header::CONTENT_TYPE,
5781 HeaderValue::from_static("text/event-stream"),
5782 );
5783 return response;
5784 }
5785 if payload.get("model").and_then(Value::as_str) == Some("severed-stream") {
5786 let chunks = futures::stream::unfold(0u8, |state| async move {
5794 match state {
5795 0 => Some((
5796 Ok::<_, std::io::Error>(Bytes::from_static(
5797 b"data: {\"choices\":[{\"delta\":{\"content\":\"partial ans\"}}]}\n\n",
5798 )),
5799 1,
5800 )),
5801 1 => {
5802 tokio::time::sleep(Duration::from_millis(150)).await;
5803 Some((Err(std::io::Error::other("connection reset by peer")), 2))
5804 }
5805 _ => None,
5806 }
5807 });
5808 let mut response = Response::new(Body::from_stream(chunks));
5809 response.headers_mut().insert(
5810 axum::http::header::CONTENT_TYPE,
5811 HeaderValue::from_static("text/event-stream"),
5812 );
5813 return response;
5814 }
5815 if payload.get("model").and_then(Value::as_str) == Some("provisional-usage") {
5816 let chunks = vec![
5817 Ok::<_, std::convert::Infallible>(Bytes::from_static(
5818 b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
5819 )),
5820 Ok(Bytes::from_static(
5821 b"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2}}\n\n",
5822 )),
5823 ];
5824 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5825 response.headers_mut().insert(
5826 axum::http::header::CONTENT_TYPE,
5827 HeaderValue::from_static("text/event-stream"),
5828 );
5829 return response;
5830 }
5831 if payload.get("model").and_then(Value::as_str) == Some("tool-no-usage") {
5832 let arguments = "x".repeat(4_096);
5833 let event = format!(
5834 "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":0,\"function\":{{\"name\":\"read\",\"arguments\":\"{arguments}\"}}}}]}}}}]}}\n\n"
5835 );
5836 let mut response = Response::new(Body::from(event));
5837 response.headers_mut().insert(
5838 axum::http::header::CONTENT_TYPE,
5839 HeaderValue::from_static("text/event-stream"),
5840 );
5841 return response;
5842 }
5843 if payload.get("model").and_then(Value::as_str) == Some("tool-oob-index") {
5844 let event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":18446744073709551615,\"function\":{\"name\":\"read\",\"arguments\":\"{}\"}}]}}]}\n\n";
5845 let mut response = Response::new(Body::from(event));
5846 response.headers_mut().insert(
5847 axum::http::header::CONTENT_TYPE,
5848 HeaderValue::from_static("text/event-stream"),
5849 );
5850 return response;
5851 }
5852 if payload.get("model").and_then(Value::as_str) == Some("tool-conflicting-id") {
5853 let event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-a\",\"function\":{\"name\":\"read\",\"arguments\":\"{\"}},{\"index\":0,\"id\":\"call-b\",\"function\":{\"arguments\":\"}\"}}]}}]}\n\n";
5857 let mut response = Response::new(Body::from(event));
5858 response.headers_mut().insert(
5859 axum::http::header::CONTENT_TYPE,
5860 HeaderValue::from_static("text/event-stream"),
5861 );
5862 return response;
5863 }
5864 if payload.get("model").and_then(Value::as_str) == Some("tool-conflicting-name") {
5865 let event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-a\",\"function\":{\"name\":\"read\",\"arguments\":\"{\"}},{\"index\":0,\"id\":\"call-a\",\"function\":{\"name\":\"db_write\",\"arguments\":\"}\"}}]}}]}\n\n";
5868 let mut response = Response::new(Body::from(event));
5869 response.headers_mut().insert(
5870 axum::http::header::CONTENT_TYPE,
5871 HeaderValue::from_static("text/event-stream"),
5872 );
5873 return response;
5874 }
5875 if payload.get("model").and_then(Value::as_str) == Some("overcharged-completion") {
5876 let chunks = vec![
5880 Ok::<_, std::convert::Infallible>(Bytes::from_static(
5881 b"data: {\"choices\":[{\"delta\":{\"content\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"finish_reason\":\"stop\"}]}\n\n",
5882 )),
5883 Ok(Bytes::from_static(
5884 b"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\n",
5885 )),
5886 ];
5887 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5888 response.headers_mut().insert(
5889 axum::http::header::CONTENT_TYPE,
5890 HeaderValue::from_static("text/event-stream"),
5891 );
5892 return response;
5893 }
5894 if payload.get("model").and_then(Value::as_str) == Some("regressive-usage") {
5895 let chunks = vec![
5896 Ok::<_, std::convert::Infallible>(Bytes::from_static(
5897 b"data: {\"choices\":[{\"delta\":{\"content\":\"first\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":100,\"prompt_tokens_details\":{\"cached_tokens\":5}},\"cost_usd\":0.01}\n\n",
5898 )),
5899 Ok(Bytes::from_static(
5900 b"data: {\"choices\":[{\"delta\":{\"content\":\"second\"}}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"prompt_tokens_details\":{\"cached_tokens\":4}},\"cost_usd\":0.001}\n\n",
5901 )),
5902 ];
5903 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5904 response.headers_mut().insert(
5905 axum::http::header::CONTENT_TYPE,
5906 HeaderValue::from_static("text/event-stream"),
5907 );
5908 return response;
5909 }
5910 if payload.get("model").and_then(Value::as_str) == Some("split-json") {
5911 let body = serde_json::to_vec(&json!({
5912 "model": "split-json",
5913 "choices": [{"message": {"role": "assistant", "content": "héllo"}}],
5914 "usage": {"prompt_tokens": 5, "completion_tokens": 2}
5915 }))
5916 .unwrap();
5917 let split = body.iter().position(|byte| *byte == 0xc3).unwrap() + 1;
5918 let chunks = vec![
5919 Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&body[..split])),
5920 Ok(Bytes::copy_from_slice(&body[split..])),
5921 ];
5922 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5923 response.headers_mut().insert(
5924 axum::http::header::CONTENT_TYPE,
5925 HeaderValue::from_static("application/json"),
5926 );
5927 return response;
5928 }
5929 if payload.get("model").and_then(Value::as_str) == Some("gzip-encoded") {
5930 let mut response = Response::new(Body::from("compressed-bytes"));
5933 response.headers_mut().insert(
5934 axum::http::header::CONTENT_TYPE,
5935 HeaderValue::from_static("application/json"),
5936 );
5937 response.headers_mut().insert(
5938 axum::http::header::CONTENT_ENCODING,
5939 HeaderValue::from_static("gzip"),
5940 );
5941 return response;
5942 }
5943 if payload.get("model").and_then(Value::as_str) == Some("malformed-json") {
5944 let mut response = Response::new(Body::from("{\"choices\":["));
5945 response.headers_mut().insert(
5946 axum::http::header::CONTENT_TYPE,
5947 HeaderValue::from_static("application/json"),
5948 );
5949 return response;
5950 }
5951 if payload.get("stream").and_then(Value::as_bool).unwrap_or(false) {
5952 let event = "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.00125,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\ndata: [DONE]\n\n";
5953 let bytes = event.as_bytes();
5954 let chunks = vec![
5955 Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&bytes[..19])),
5956 Ok(Bytes::copy_from_slice(&bytes[19..97])),
5957 Ok(Bytes::copy_from_slice(&bytes[97..])),
5958 ];
5959 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5960 response.headers_mut().insert(
5961 axum::http::header::CONTENT_TYPE,
5962 HeaderValue::from_static("text/event-stream"),
5963 );
5964 response
5965 } else {
5966 Json(json!({"choices": [{"message": {"role": "assistant", "content": "hello"}}]})).into_response()
5967 }
5968 }
5969
5970 async fn counting_tool(
5971 State(calls): State<Arc<std::sync::atomic::AtomicUsize>>,
5972 body: Bytes,
5973 ) -> Response {
5974 calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5975 (StatusCode::OK, body).into_response()
5976 }
5977
5978 async fn redirecting_tool(State(calls): State<Arc<std::sync::atomic::AtomicUsize>>) -> Response {
5979 calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5980 (
5981 StatusCode::TEMPORARY_REDIRECT,
5982 [(axum::http::header::LOCATION, "/effect")],
5983 )
5984 .into_response()
5985 }
5986
5987 async fn redirected_effect(State(calls): State<Arc<std::sync::atomic::AtomicUsize>>) -> Response {
5988 calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5989 StatusCode::OK.into_response()
5990 }
5991
5992 struct HeldToolState {
5993 arrived: std::sync::atomic::AtomicBool,
5994 release: tokio::sync::Notify,
5995 }
5996
5997 async fn held_tool(State(state): State<Arc<HeldToolState>>, body: Bytes) -> Response {
5998 state.arrived.store(true, std::sync::atomic::Ordering::Release);
5999 state.release.notified().await;
6000 (StatusCode::OK, body).into_response()
6001 }
6002
6003 async fn test_state(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6004 test_state_with_token_cap(spool, None).await
6005 }
6006
6007 async fn mock_anthropic_chat() -> Response {
6010 let frames = concat!(
6011 "event: message_start\n",
6012 "data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5\",\"usage\":{\"input_tokens\":25,\"output_tokens\":1}}}\n\n",
6013 "event: ping\n",
6014 "data: {\"type\": \"ping\"}\n\n",
6015 "event: content_block_start\n",
6016 "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
6017 "event: content_block_delta\n",
6018 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello from anthropic\"}}\n\n",
6019 "event: content_block_stop\n",
6020 "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
6021 "event: message_delta\n",
6022 "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":15}}\n\n",
6023 "event: message_stop\n",
6024 "data: {\"type\":\"message_stop\"}\n\n",
6025 );
6026 let mut response = Response::new(Body::from(frames));
6027 response.headers_mut().insert(
6028 axum::http::header::CONTENT_TYPE,
6029 HeaderValue::from_static("text/event-stream"),
6030 );
6031 response
6032 }
6033
6034 async fn test_state_anthropic(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6035 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6036 let address = listener.local_addr().unwrap();
6037 let provider = Router::new().route("/v1/chat/completions", post(mock_anthropic_chat));
6038 let server = tokio::spawn(async move {
6039 axum::serve(listener, provider).await.unwrap();
6040 });
6041 let mut config = crate::config::HarnessConfig::for_tests(
6042 &format!("http://{address}"),
6043 &spool.to_string_lossy(),
6044 &spool.to_string_lossy(),
6045 );
6046 config.provider = "anthropic".to_owned();
6047 let state = AppState::new(
6048 config,
6049 Arc::new(InMemoryStore::new()),
6050 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6051 Arc::new(NullBus),
6052 None,
6053 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6054 )
6055 .unwrap();
6056 (state, server)
6057 }
6058
6059 #[tokio::test]
6063 async fn oversize_body_413_is_json_and_names_the_knob() {
6064 let directory = tempfile::tempdir().unwrap();
6065 let (state, provider) = test_state(directory.path()).await;
6066 let limit = state.config.max_request_bytes;
6067 let app = build_router(state);
6068 let oversize = "x".repeat(limit + 1);
6069 let response = app
6070 .oneshot(
6071 Request::builder()
6072 .method("POST")
6073 .uri("/v1/chat/completions")
6074 .header("content-type", "application/json")
6075 .header("x-av-session", "too-big")
6076 .body(Body::from(oversize))
6077 .unwrap(),
6078 )
6079 .await
6080 .unwrap();
6081 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
6082 let content_type = response
6083 .headers()
6084 .get(axum::http::header::CONTENT_TYPE)
6085 .and_then(|value| value.to_str().ok())
6086 .unwrap_or_default()
6087 .to_owned();
6088 assert!(
6089 content_type.starts_with("application/json"),
6090 "413 must honour the JSON error contract, got {content_type}"
6091 );
6092 let body: Value = serde_json::from_slice(
6093 &axum::body::to_bytes(response.into_body(), 64 * 1024)
6094 .await
6095 .unwrap(),
6096 )
6097 .unwrap();
6098 let message = body["error"]["message"].as_str().unwrap();
6099 assert!(
6100 message.contains("max_request_bytes") && message.contains(&limit.to_string()),
6101 "413 must name the knob and the configured limit: {message}"
6102 );
6103 provider.abort();
6104 }
6105
6106 async fn mock_gemini_chat() -> Response {
6109 let frames = concat!(
6110 "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello from gemini\"}],\"role\":\"model\"},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":25,\"candidatesTokenCount\":1},\"modelVersion\":\"gemini-2.0-flash\"}\n\n",
6111 "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"!\"}]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":25,\"candidatesTokenCount\":15}}\n\n",
6112 );
6113 let mut response = Response::new(Body::from(frames));
6114 response.headers_mut().insert(
6115 axum::http::header::CONTENT_TYPE,
6116 HeaderValue::from_static("text/event-stream"),
6117 );
6118 response
6119 }
6120
6121 async fn test_state_gemini(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6122 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6123 let address = listener.local_addr().unwrap();
6124 let provider = Router::new().route("/v1/chat/completions", post(mock_gemini_chat));
6125 let server = tokio::spawn(async move {
6126 axum::serve(listener, provider).await.unwrap();
6127 });
6128 let mut config = crate::config::HarnessConfig::for_tests(
6129 &format!("http://{address}"),
6130 &spool.to_string_lossy(),
6131 &spool.to_string_lossy(),
6132 );
6133 config.provider = "gemini".to_owned();
6134 let state = AppState::new(
6135 config,
6136 Arc::new(InMemoryStore::new()),
6137 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6138 Arc::new(NullBus),
6139 None,
6140 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6141 )
6142 .unwrap();
6143 (state, server)
6144 }
6145
6146 #[tokio::test]
6151 async fn gemini_session_records_the_same_audit_shape() {
6152 let directory = tempfile::tempdir().unwrap();
6153 let (state, provider) = test_state_gemini(directory.path()).await;
6154 let app = build_router(state.clone());
6155 let response = app.clone().oneshot(chat_request("gemini-flow")).await.unwrap();
6156 assert_eq!(response.status(), StatusCode::OK);
6157 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
6158 .await
6159 .unwrap();
6160 let body = String::from_utf8_lossy(&body);
6161 assert!(
6162 body.contains("hello from gemini"),
6163 "streamed bytes must relay verbatim: {body}"
6164 );
6165
6166 let session = state.sessions.get("gemini-flow").unwrap();
6167 session.wait_for_worker_jobs().await;
6168 use std::sync::atomic::Ordering;
6169 assert_eq!(session.totals.prompt_tokens.load(Ordering::Acquire), 25);
6170 assert_eq!(session.totals.completion_tokens.load(Ordering::Acquire), 15);
6171
6172 let close = app
6173 .clone()
6174 .oneshot(
6175 Request::builder()
6176 .method("POST")
6177 .uri("/v1/sessions/gemini-flow/close")
6178 .body(Body::empty())
6179 .unwrap(),
6180 )
6181 .await
6182 .unwrap();
6183 assert_eq!(close.status(), StatusCode::OK);
6184 let promoted = app
6185 .clone()
6186 .oneshot(
6187 Request::builder()
6188 .method("POST")
6189 .uri("/v1/sessions/gemini-flow/promote")
6190 .body(Body::empty())
6191 .unwrap(),
6192 )
6193 .await
6194 .unwrap();
6195 assert_eq!(promoted.status(), StatusCode::OK);
6196 let receipt: av_receipts::Receipt = serde_json::from_slice(
6197 &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6198 .await
6199 .unwrap(),
6200 )
6201 .unwrap();
6202 receipt.verify_embedded().unwrap();
6203 assert_eq!(receipt.body.stop_reason_id, av_events::StopReason::Stop.id());
6204 assert_eq!(receipt.body.cost.prompt_tokens, 25);
6205 assert_eq!(receipt.body.cost.completion_tokens, 15);
6206 provider.abort();
6207 }
6208
6209 async fn mock_gemini_tool_chat() -> Response {
6215 let frames = concat!(
6216 "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Paris\"}}}]},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":10},\"modelVersion\":\"gemini-2.0-flash\"}\n\n",
6217 "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":10}}\n\n",
6218 );
6219 let mut response = Response::new(Body::from(frames));
6220 response.headers_mut().insert(
6221 axum::http::header::CONTENT_TYPE,
6222 HeaderValue::from_static("text/event-stream"),
6223 );
6224 response
6225 }
6226
6227 async fn test_state_gemini_tool(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6228 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6229 let address = listener.local_addr().unwrap();
6230 let provider = Router::new().route("/v1/chat/completions", post(mock_gemini_tool_chat));
6231 let server = tokio::spawn(async move {
6232 axum::serve(listener, provider).await.unwrap();
6233 });
6234 let mut config = crate::config::HarnessConfig::for_tests(
6235 &format!("http://{address}"),
6236 &spool.to_string_lossy(),
6237 &spool.to_string_lossy(),
6238 );
6239 config.provider = "gemini".to_owned();
6240 let state = AppState::new(
6241 config,
6242 Arc::new(InMemoryStore::new()),
6243 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6244 Arc::new(NullBus),
6245 None,
6246 Arc::new(Ed25519Signer::from_seed(&[13; 32])),
6247 )
6248 .unwrap();
6249 (state, server)
6250 }
6251
6252 #[tokio::test]
6266 async fn gemini_tool_call_with_finish_stop_attests_tool_use() {
6267 let directory = tempfile::tempdir().unwrap();
6268 let (state, provider) = test_state_gemini_tool(directory.path()).await;
6269 let app = build_router(state.clone());
6270 let response = app.clone().oneshot(chat_request("gemini-tool")).await.unwrap();
6271 assert_eq!(response.status(), StatusCode::OK);
6272 let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6273 let session = state.sessions.get("gemini-tool").unwrap();
6274 session.wait_for_worker_jobs().await;
6275
6276 let close = app
6279 .clone()
6280 .oneshot(
6281 Request::builder()
6282 .method("POST")
6283 .uri("/v1/sessions/gemini-tool/close")
6284 .body(Body::empty())
6285 .unwrap(),
6286 )
6287 .await
6288 .unwrap();
6289 assert_eq!(close.status(), StatusCode::OK);
6290 let promoted = app
6291 .clone()
6292 .oneshot(
6293 Request::builder()
6294 .method("POST")
6295 .uri("/v1/sessions/gemini-tool/promote")
6296 .body(Body::empty())
6297 .unwrap(),
6298 )
6299 .await
6300 .unwrap();
6301 assert_eq!(promoted.status(), StatusCode::OK);
6302 let receipt: av_receipts::Receipt = serde_json::from_slice(
6303 &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6304 .await
6305 .unwrap(),
6306 )
6307 .unwrap();
6308 receipt.verify_embedded().unwrap();
6309 assert_eq!(
6310 receipt.body.stop_reason_id,
6311 av_events::StopReason::ToolUse.id(),
6312 "a Gemini session that ended with a functionCall part must attest \
6313 StopReason::ToolUse (id {}), not Stop (id {}) — the adapter must \
6314 normalize provider-specific quirks so downstream stop-reason \
6315 analytics are provider-agnostic. Received: stop_reason_id={}, \
6316 stop_reason={:?}.",
6317 av_events::StopReason::ToolUse.id(),
6318 av_events::StopReason::Stop.id(),
6319 receipt.body.stop_reason_id,
6320 receipt.body.stop_reason,
6321 );
6322 provider.abort();
6323 }
6324
6325 async fn mock_gemini_parallel_tool_chat() -> Response {
6334 let frames = concat!(
6335 "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Paris\"}}}]},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":10},\"modelVersion\":\"gemini-2.0-flash\"}\n\n",
6336 "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_time\",\"args\":{\"city\":\"London\"}}}]},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":20}}\n\n",
6337 "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":20}}\n\n",
6338 );
6339 let mut response = Response::new(Body::from(frames));
6340 response.headers_mut().insert(
6341 axum::http::header::CONTENT_TYPE,
6342 HeaderValue::from_static("text/event-stream"),
6343 );
6344 response
6345 }
6346
6347 #[tokio::test]
6348 async fn gemini_parallel_function_calls_across_chunks_attest_as_two_calls() {
6349 let directory = tempfile::tempdir().unwrap();
6350 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6351 let address = listener.local_addr().unwrap();
6352 let upstream = Router::new().route("/v1/chat/completions", post(mock_gemini_parallel_tool_chat));
6353 let provider = tokio::spawn(async move {
6354 axum::serve(listener, upstream).await.unwrap();
6355 });
6356 let mut config = crate::config::HarnessConfig::for_tests(
6357 &format!("http://{address}"),
6358 &directory.path().to_string_lossy(),
6359 &directory.path().to_string_lossy(),
6360 );
6361 config.provider = "gemini".to_owned();
6362 let state = AppState::new(
6363 config,
6364 Arc::new(InMemoryStore::new()),
6365 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6366 Arc::new(NullBus),
6367 None,
6368 Arc::new(Ed25519Signer::from_seed(&[17; 32])),
6369 )
6370 .unwrap();
6371 let app = build_router(state.clone());
6372 let response = app
6373 .clone()
6374 .oneshot(chat_request("gemini-parallel-tools"))
6375 .await
6376 .unwrap();
6377 assert_eq!(response.status(), StatusCode::OK, "the stream must not hard-fail");
6378 let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6379 let session = state.sessions.get("gemini-parallel-tools").unwrap();
6380 session.wait_for_worker_jobs().await;
6381 let close = app
6382 .clone()
6383 .oneshot(
6384 Request::builder()
6385 .method("POST")
6386 .uri("/v1/sessions/gemini-parallel-tools/close")
6387 .body(Body::empty())
6388 .unwrap(),
6389 )
6390 .await
6391 .unwrap();
6392 assert_eq!(close.status(), StatusCode::OK);
6393
6394 let digest = av_core::digest::sha256_hex(b"gemini-parallel-tools");
6396 let stem = digest.get(..32).unwrap();
6397 let trajectory: serde_json::Value =
6398 serde_json::from_slice(&std::fs::read(directory.path().join(format!("{stem}.json"))).unwrap())
6399 .unwrap();
6400 let mut calls: Vec<(String, serde_json::Value)> = Vec::new();
6401 for step in trajectory
6402 .pointer("/steps")
6403 .and_then(serde_json::Value::as_array)
6404 .cloned()
6405 .unwrap_or_default()
6406 {
6407 for call in step
6408 .pointer("/tool_calls")
6409 .and_then(serde_json::Value::as_array)
6410 .cloned()
6411 .unwrap_or_default()
6412 {
6413 calls.push((
6414 call.pointer("/function_name")
6415 .and_then(serde_json::Value::as_str)
6416 .unwrap_or_default()
6417 .to_owned(),
6418 call.pointer("/arguments").cloned().unwrap_or_default(),
6419 ));
6420 }
6421 }
6422 assert_eq!(
6423 calls.len(),
6424 2,
6425 "two functionCall parts in separate chunks must attest as two \
6426 distinct tool calls, not merge at frame-local index 0: {trajectory}"
6427 );
6428 assert_eq!(calls[0].0, "get_weather");
6429 assert_eq!(calls[0].1, serde_json::json!({"city": "Paris"}));
6430 assert_eq!(calls[1].0, "get_time");
6431 assert_eq!(calls[1].1, serde_json::json!({"city": "London"}));
6432 provider.abort();
6433 }
6434
6435 async fn mock_anthropic_refusal() -> Response {
6440 let frames = concat!(
6441 "event: message_start\n",
6442 "data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5\",\"usage\":{\"input_tokens\":15,\"output_tokens\":1}}}\n\n",
6443 "event: content_block_start\n",
6444 "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
6445 "event: content_block_delta\n",
6446 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I can't help with that.\"}}\n\n",
6447 "event: content_block_stop\n",
6448 "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
6449 "event: message_delta\n",
6450 "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"refusal\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":6}}\n\n",
6451 "event: message_stop\n",
6452 "data: {\"type\":\"message_stop\"}\n\n",
6453 );
6454 let mut response = Response::new(Body::from(frames));
6455 response.headers_mut().insert(
6456 axum::http::header::CONTENT_TYPE,
6457 HeaderValue::from_static("text/event-stream"),
6458 );
6459 response
6460 }
6461
6462 async fn test_state_anthropic_refusal(
6463 spool: &std::path::Path,
6464 ) -> (AppState, tokio::task::JoinHandle<()>) {
6465 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6466 let address = listener.local_addr().unwrap();
6467 let provider = Router::new().route("/v1/chat/completions", post(mock_anthropic_refusal));
6468 let server = tokio::spawn(async move {
6469 axum::serve(listener, provider).await.unwrap();
6470 });
6471 let mut config = crate::config::HarnessConfig::for_tests(
6472 &format!("http://{address}"),
6473 &spool.to_string_lossy(),
6474 &spool.to_string_lossy(),
6475 );
6476 config.provider = "anthropic".to_owned();
6477 let state = AppState::new(
6478 config,
6479 Arc::new(InMemoryStore::new()),
6480 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6481 Arc::new(NullBus),
6482 None,
6483 Arc::new(Ed25519Signer::from_seed(&[15; 32])),
6484 )
6485 .unwrap();
6486 (state, server)
6487 }
6488
6489 #[tokio::test]
6500 async fn anthropic_refusal_stop_reason_attests_content_filter() {
6501 let directory = tempfile::tempdir().unwrap();
6502 let (state, provider) = test_state_anthropic_refusal(directory.path()).await;
6503 let app = build_router(state.clone());
6504 let response = app
6505 .clone()
6506 .oneshot(chat_request("anthropic-refusal"))
6507 .await
6508 .unwrap();
6509 assert_eq!(response.status(), StatusCode::OK);
6510 let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6511 let session = state.sessions.get("anthropic-refusal").unwrap();
6512 session.wait_for_worker_jobs().await;
6513 let close = app
6514 .clone()
6515 .oneshot(
6516 Request::builder()
6517 .method("POST")
6518 .uri("/v1/sessions/anthropic-refusal/close")
6519 .body(Body::empty())
6520 .unwrap(),
6521 )
6522 .await
6523 .unwrap();
6524 assert_eq!(close.status(), StatusCode::OK);
6525 let promoted = app
6526 .clone()
6527 .oneshot(
6528 Request::builder()
6529 .method("POST")
6530 .uri("/v1/sessions/anthropic-refusal/promote")
6531 .body(Body::empty())
6532 .unwrap(),
6533 )
6534 .await
6535 .unwrap();
6536 assert_eq!(promoted.status(), StatusCode::OK);
6537 let receipt: av_receipts::Receipt = serde_json::from_slice(
6538 &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6539 .await
6540 .unwrap(),
6541 )
6542 .unwrap();
6543 receipt.verify_embedded().unwrap();
6544 assert_eq!(
6545 receipt.body.stop_reason_id,
6546 av_events::StopReason::ContentFilter.id(),
6547 "an Anthropic session that ended with stop_reason:refusal must attest \
6548 StopReason::ContentFilter (id {}), not Other (id {}). OpenAI's \
6549 content_filter and Gemini's SAFETY cluster both map to ContentFilter; \
6550 leaving Anthropic's refusal as Other silently splits safety-refusal \
6551 analytics along provider lines. Received: stop_reason_id={}, \
6552 stop_reason={:?}.",
6553 av_events::StopReason::ContentFilter.id(),
6554 av_events::StopReason::Other.id(),
6555 receipt.body.stop_reason_id,
6556 receipt.body.stop_reason,
6557 );
6558 provider.abort();
6559 }
6560
6561 async fn mock_anthropic_context_window_exceeded() -> Response {
6567 let frames = concat!(
6568 "event: message_start\n",
6569 "data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5\",\"usage\":{\"input_tokens\":200000,\"output_tokens\":1}}}\n\n",
6570 "event: content_block_start\n",
6571 "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
6572 "event: content_block_delta\n",
6573 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n",
6574 "event: content_block_stop\n",
6575 "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
6576 "event: message_delta\n",
6577 "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"model_context_window_exceeded\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":3}}\n\n",
6578 "event: message_stop\n",
6579 "data: {\"type\":\"message_stop\"}\n\n",
6580 );
6581 let mut response = Response::new(Body::from(frames));
6582 response.headers_mut().insert(
6583 axum::http::header::CONTENT_TYPE,
6584 HeaderValue::from_static("text/event-stream"),
6585 );
6586 response
6587 }
6588
6589 async fn test_state_anthropic_ctx_exceeded(
6590 spool: &std::path::Path,
6591 ) -> (AppState, tokio::task::JoinHandle<()>) {
6592 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6593 let address = listener.local_addr().unwrap();
6594 let provider = Router::new().route(
6595 "/v1/chat/completions",
6596 post(mock_anthropic_context_window_exceeded),
6597 );
6598 let server = tokio::spawn(async move {
6599 axum::serve(listener, provider).await.unwrap();
6600 });
6601 let mut config = crate::config::HarnessConfig::for_tests(
6602 &format!("http://{address}"),
6603 &spool.to_string_lossy(),
6604 &spool.to_string_lossy(),
6605 );
6606 config.provider = "anthropic".to_owned();
6607 let state = AppState::new(
6608 config,
6609 Arc::new(InMemoryStore::new()),
6610 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6611 Arc::new(NullBus),
6612 None,
6613 Arc::new(Ed25519Signer::from_seed(&[17; 32])),
6614 )
6615 .unwrap();
6616 (state, server)
6617 }
6618
6619 #[tokio::test]
6629 async fn anthropic_context_window_exceeded_attests_max_tokens() {
6630 let directory = tempfile::tempdir().unwrap();
6631 let (state, provider) = test_state_anthropic_ctx_exceeded(directory.path()).await;
6632 let app = build_router(state.clone());
6633 let response = app.clone().oneshot(chat_request("anthropic-ctx")).await.unwrap();
6634 assert_eq!(response.status(), StatusCode::OK);
6635 let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6636 let session = state.sessions.get("anthropic-ctx").unwrap();
6637 session.wait_for_worker_jobs().await;
6638 let close = app
6639 .clone()
6640 .oneshot(
6641 Request::builder()
6642 .method("POST")
6643 .uri("/v1/sessions/anthropic-ctx/close")
6644 .body(Body::empty())
6645 .unwrap(),
6646 )
6647 .await
6648 .unwrap();
6649 assert_eq!(close.status(), StatusCode::OK);
6650 let promoted = app
6651 .clone()
6652 .oneshot(
6653 Request::builder()
6654 .method("POST")
6655 .uri("/v1/sessions/anthropic-ctx/promote")
6656 .body(Body::empty())
6657 .unwrap(),
6658 )
6659 .await
6660 .unwrap();
6661 assert_eq!(promoted.status(), StatusCode::OK);
6662 let receipt: av_receipts::Receipt = serde_json::from_slice(
6663 &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6664 .await
6665 .unwrap(),
6666 )
6667 .unwrap();
6668 receipt.verify_embedded().unwrap();
6669 assert_eq!(
6670 receipt.body.stop_reason_id,
6671 av_events::StopReason::MaxTokens.id(),
6672 "an Anthropic session that ended with \
6673 stop_reason:model_context_window_exceeded must attest \
6674 StopReason::MaxTokens (id {}), not Other (id {}). OpenAI's `length` \
6675 and Gemini's MAX_TOKENS both map to MaxTokens; leaving Anthropic's \
6676 overflow variant as Other hides capacity signals from operators. \
6677 Received: stop_reason_id={}, stop_reason={:?}.",
6678 av_events::StopReason::MaxTokens.id(),
6679 av_events::StopReason::Other.id(),
6680 receipt.body.stop_reason_id,
6681 receipt.body.stop_reason,
6682 );
6683 provider.abort();
6684 }
6685
6686 #[tokio::test]
6691 async fn anthropic_session_records_the_same_audit_shape() {
6692 let directory = tempfile::tempdir().unwrap();
6693 let (state, provider) = test_state_anthropic(directory.path()).await;
6694 let app = build_router(state.clone());
6695 let response = app.clone().oneshot(chat_request("anthropic-flow")).await.unwrap();
6696 assert_eq!(response.status(), StatusCode::OK);
6697 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
6698 .await
6699 .unwrap();
6700 let body = String::from_utf8_lossy(&body);
6701 assert!(
6702 body.contains("hello from anthropic"),
6703 "streamed bytes must relay verbatim: {body}"
6704 );
6705
6706 let session = state.sessions.get("anthropic-flow").unwrap();
6707 session.wait_for_worker_jobs().await;
6708 use std::sync::atomic::Ordering;
6712 assert_eq!(session.totals.prompt_tokens.load(Ordering::Acquire), 25);
6713 assert_eq!(session.totals.completion_tokens.load(Ordering::Acquire), 15);
6714
6715 let close = app
6716 .clone()
6717 .oneshot(
6718 Request::builder()
6719 .method("POST")
6720 .uri("/v1/sessions/anthropic-flow/close")
6721 .body(Body::empty())
6722 .unwrap(),
6723 )
6724 .await
6725 .unwrap();
6726 assert_eq!(close.status(), StatusCode::OK);
6727 let promoted = app
6728 .clone()
6729 .oneshot(
6730 Request::builder()
6731 .method("POST")
6732 .uri("/v1/sessions/anthropic-flow/promote")
6733 .body(Body::empty())
6734 .unwrap(),
6735 )
6736 .await
6737 .unwrap();
6738 assert_eq!(promoted.status(), StatusCode::OK);
6739 let receipt: av_receipts::Receipt = serde_json::from_slice(
6740 &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6741 .await
6742 .unwrap(),
6743 )
6744 .unwrap();
6745 receipt.verify_embedded().unwrap();
6746 assert_eq!(receipt.body.stop_reason_id, av_events::StopReason::Stop.id());
6748 assert_eq!(receipt.body.cost.prompt_tokens, 25);
6749 assert_eq!(receipt.body.cost.completion_tokens, 15);
6750 provider.abort();
6751 }
6752
6753 async fn test_state_with_token_cap(
6754 spool: &std::path::Path,
6755 max_tokens: Option<u64>,
6756 ) -> (AppState, tokio::task::JoinHandle<()>) {
6757 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6758 let address = listener.local_addr().unwrap();
6759 let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
6760 let server = tokio::spawn(async move {
6761 axum::serve(listener, provider).await.unwrap();
6762 });
6763 let mut config = crate::config::HarnessConfig::for_tests(
6764 &format!("http://{address}"),
6765 &spool.to_string_lossy(),
6766 &spool.to_string_lossy(),
6767 );
6768 config.budget.max_tokens = max_tokens;
6769 let state = AppState::new(
6770 config,
6771 Arc::new(InMemoryStore::new()),
6772 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6773 Arc::new(NullBus),
6774 None,
6775 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6776 )
6777 .unwrap();
6778 (state, server)
6779 }
6780
6781 fn chat_request(session: &str) -> Request<Body> {
6782 chat_request_with_payload(session, chat_payload())
6783 }
6784
6785 #[tokio::test]
6792 async fn header_less_one_shot_closes_immediately_after_the_response() {
6793 let directory = tempfile::tempdir().unwrap();
6794 let (state, provider) = test_state(directory.path()).await;
6795 let request = Request::builder()
6796 .method("POST")
6797 .uri("/v1/chat/completions")
6798 .header(axum::http::header::CONTENT_TYPE, "application/json")
6799 .header("x-av-workflow", "unsigned")
6801 .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
6802 .unwrap();
6803 let response = build_router(state.clone()).oneshot(request).await.unwrap();
6804 assert_eq!(response.status(), StatusCode::OK);
6805 let session_id = response
6806 .headers()
6807 .get("x-av-session")
6808 .and_then(|v| v.to_str().ok())
6809 .expect("generated session id must be echoed in the response header")
6810 .to_owned();
6811 axum::body::to_bytes(response.into_body(), 1024 * 1024)
6812 .await
6813 .unwrap();
6814
6815 let session = state.sessions.get(&session_id).expect("session registered");
6816 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
6817 while !session.is_closed() {
6818 assert!(
6819 std::time::Instant::now() < deadline,
6820 "an ephemeral one-shot must close immediately after the response, not wait for the idle sweeper"
6821 );
6822 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6823 }
6824 provider.abort();
6825 }
6826
6827 #[tokio::test]
6839 async fn principal_budget_charges_completion_tokens_across_rotated_sessions() {
6840 let directory = tempfile::tempdir().unwrap();
6841 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6842 let address = listener.local_addr().unwrap();
6843 let provider = Router::new().route(
6844 "/v1/chat/completions",
6845 post(|| async {
6846 Json(json!({
6847 "model": "mock",
6848 "choices": [{
6849 "message": {"role": "assistant", "content": "big completion"},
6850 "finish_reason": "stop"
6851 }],
6852 "usage": {"prompt_tokens": 5, "completion_tokens": 1000}
6853 }))
6854 }),
6855 );
6856 let server = tokio::spawn(async move {
6857 axum::serve(listener, provider).await.unwrap();
6858 });
6859 let spool = directory.path().to_string_lossy().into_owned();
6860 let mut config =
6861 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
6862 config.budget.max_tokens = Some(1_000_000);
6864 config.principal_budget = Some(av_state::BudgetSpec {
6867 max_tokens: Some(1500),
6868 ..av_state::BudgetSpec::default()
6869 });
6870 let state = AppState::new(
6871 config,
6872 Arc::new(InMemoryStore::new()),
6873 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6874 Arc::new(NullBus),
6875 None,
6876 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6877 )
6878 .unwrap();
6879 let app = build_router(state);
6880 let non_streamed = json!({
6881 "model": "mock",
6882 "messages": [{"role": "user", "content": "hello"}]
6883 });
6884
6885 let first = app
6886 .clone()
6887 .oneshot(chat_request_with_payload("principal-rot-1", non_streamed.clone()))
6888 .await
6889 .unwrap();
6890 assert_eq!(
6891 first.status(),
6892 StatusCode::OK,
6893 "first rotated request fits the principal budget"
6894 );
6895 axum::body::to_bytes(first.into_body(), 1024 * 1024)
6896 .await
6897 .unwrap();
6898
6899 let second = app
6904 .oneshot(chat_request_with_payload("principal-rot-2", non_streamed))
6905 .await
6906 .unwrap();
6907 let status = second.status();
6908 let body = axum::body::to_bytes(second.into_body(), 1024 * 1024)
6909 .await
6910 .unwrap();
6911 let body = String::from_utf8_lossy(&body);
6912 assert_eq!(
6913 status,
6914 StatusCode::FORBIDDEN,
6915 "rotated completion spend must be refused by the principal ledger; got {status}: {body}"
6916 );
6917 assert!(
6918 body.contains("principal.max_tokens"),
6919 "refusal must name the principal-scoped limit: {body}"
6920 );
6921 server.abort();
6922 }
6923
6924 #[tokio::test]
6937 async fn upstream_authorization_contract_by_posture() {
6938 type Captured = Arc<parking_lot::Mutex<Vec<(Option<String>, Option<String>, Option<String>)>>>;
6939 for posture in ["keyless_ignore", "static_key_file", "passthrough"] {
6940 let directory = tempfile::tempdir().unwrap();
6941 let captured: Captured = Arc::default();
6942 let sink = Arc::clone(&captured);
6943 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6944 let address = listener.local_addr().unwrap();
6945 let provider = Router::new().route(
6946 "/v1/chat/completions",
6947 post(
6948 move |headers: axum::http::HeaderMap, Json(payload): Json<Value>| {
6949 let sink = Arc::clone(&sink);
6950 async move {
6951 let get = |name: &str| {
6952 headers.get(name).and_then(|v| v.to_str().ok()).map(str::to_owned)
6953 };
6954 sink.lock().push((
6955 get("authorization"),
6956 get("openai-beta"),
6957 get("x-custom-header"),
6958 ));
6959 mock_chat(Json(payload)).await
6960 }
6961 },
6962 ),
6963 );
6964 let server = tokio::spawn(async move {
6965 axum::serve(listener, provider).await.unwrap();
6966 });
6967 let spool = directory.path().to_string_lossy().into_owned();
6968 let mut config =
6969 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
6970 match posture {
6971 "keyless_ignore" => config.ignore_client_authorization = true,
6972 "static_key_file" => {
6973 let key_path = directory.path().join("upstream.key");
6974 std::fs::write(&key_path, "sk-SERVER-SECRET").unwrap();
6975 #[cfg(unix)]
6976 {
6977 use std::os::unix::fs::PermissionsExt as _;
6978 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
6979 }
6980 config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
6981 }
6982 "passthrough" => config.upstream_authorization_passthrough = true,
6983 _ => unreachable!(),
6984 }
6985 let state = AppState::new(
6986 config,
6987 Arc::new(InMemoryStore::new()),
6988 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6989 Arc::new(NullBus),
6990 None,
6991 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6992 )
6993 .unwrap();
6994 let request = Request::builder()
6995 .method("POST")
6996 .uri("/v1/chat/completions")
6997 .header(axum::http::header::CONTENT_TYPE, "application/json")
6998 .header("authorization", "Bearer sk-CLIENT-JUNK")
6999 .header("openai-beta", "assistants=v2")
7000 .header("x-custom-header", "evil")
7001 .header("x-av-session", format!("auth-{posture}"))
7002 .header("x-av-workflow", "unsigned")
7003 .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
7004 .unwrap();
7005 let response = build_router(state).oneshot(request).await.unwrap();
7006 assert_eq!(response.status(), StatusCode::OK, "posture {posture} must serve");
7007 axum::body::to_bytes(response.into_body(), 1024 * 1024)
7008 .await
7009 .unwrap();
7010 let seen = captured.lock().clone();
7011 let (auth, beta, custom) = seen
7012 .first()
7013 .cloned()
7014 .unwrap_or_else(|| panic!("posture {posture}: upstream never saw the request"));
7015 match posture {
7016 "keyless_ignore" => assert_eq!(
7017 auth, None,
7018 "keyless posture must DISCARD the client placeholder bearer"
7019 ),
7020 "static_key_file" => assert_eq!(
7021 auth.as_deref(),
7022 Some("Bearer sk-SERVER-SECRET"),
7023 "static-key posture must inject the server key, never the client's"
7024 ),
7025 "passthrough" => assert_eq!(
7026 auth.as_deref(),
7027 Some("Bearer sk-CLIENT-JUNK"),
7028 "passthrough posture must forward the client bearer verbatim"
7029 ),
7030 _ => unreachable!(),
7031 }
7032 assert_eq!(
7033 beta.as_deref(),
7034 Some("assistants=v2"),
7035 "posture {posture}: allowlisted provider header must relay"
7036 );
7037 assert_eq!(
7038 custom, None,
7039 "posture {posture}: non-allowlisted client headers must never reach the upstream"
7040 );
7041 server.abort();
7042 }
7043 }
7044
7045 #[tokio::test]
7051 async fn quarantined_session_close_and_promote_map_to_conflict_not_500() {
7052 let directory = tempfile::tempdir().unwrap();
7053 let (state, provider) = test_state(directory.path()).await;
7054 let app = build_router(state.clone());
7055 let opened = app.clone().oneshot(chat_request("quarantine-409")).await.unwrap();
7057 assert_eq!(opened.status(), StatusCode::OK);
7058 axum::body::to_bytes(opened.into_body(), 1024 * 1024)
7059 .await
7060 .unwrap();
7061 let session = state.sessions.get("quarantine-409").unwrap();
7062 session.wait_for_worker_jobs().await;
7063 session.mark_capture_failed();
7064
7065 let close = app
7066 .clone()
7067 .oneshot(
7068 Request::builder()
7069 .method("POST")
7070 .uri("/v1/sessions/quarantine-409/close")
7071 .body(Body::empty())
7072 .unwrap(),
7073 )
7074 .await
7075 .unwrap();
7076 assert_eq!(
7077 close.status(),
7078 StatusCode::CONFLICT,
7079 "capture-incomplete close refusal is a state conflict, not a server fault"
7080 );
7081 let promote = app
7082 .oneshot(
7083 Request::builder()
7084 .method("POST")
7085 .uri("/v1/sessions/quarantine-409/promote")
7086 .body(Body::empty())
7087 .unwrap(),
7088 )
7089 .await
7090 .unwrap();
7091 assert_eq!(
7092 promote.status(),
7093 StatusCode::CONFLICT,
7094 "unfulfillable promotion refusal is a state conflict, not a server fault"
7095 );
7096 provider.abort();
7097 }
7098
7099 fn chat_request_with_payload(session: &str, payload: Value) -> Request<Body> {
7100 Request::builder()
7101 .method("POST")
7102 .uri("/v1/chat/completions")
7103 .header(axum::http::header::CONTENT_TYPE, "application/json")
7104 .header("x-av-session", session)
7105 .header("x-av-workflow", "unsigned")
7106 .body(Body::from(serde_json::to_vec(&payload).unwrap()))
7107 .unwrap()
7108 }
7109
7110 fn chat_payload() -> Value {
7111 json!({
7112 "model": "mock",
7113 "stream": true,
7114 "messages": [{"role": "user", "content": "hello"}],
7115 })
7116 }
7117
7118 #[tokio::test]
7128 async fn chat_completions_refuses_duplicate_json_keys() {
7129 let directory = tempfile::tempdir().unwrap();
7130 let (state, provider) = test_state(directory.path()).await;
7131 let app = build_router(state.clone());
7132 let dup = br#"{"model":"mock","messages":[{"role":"user","content":"safe"}],"messages":[{"role":"user","content":"hostile"}]}"#;
7136 let request = Request::builder()
7137 .method("POST")
7138 .uri("/v1/chat/completions")
7139 .header(axum::http::header::CONTENT_TYPE, "application/json")
7140 .header("x-av-session", "dup-key")
7141 .header("x-av-workflow", "unsigned")
7142 .body(Body::from(&dup[..]))
7143 .unwrap();
7144 let response = app.oneshot(request).await.unwrap();
7145 assert_eq!(
7146 response.status(),
7147 StatusCode::BAD_REQUEST,
7148 "duplicate `messages` key must be refused with 400, not silently taken"
7149 );
7150 let body = axum::body::to_bytes(response.into_body(), 4 * 1024)
7151 .await
7152 .unwrap();
7153 let text = std::str::from_utf8(&body).unwrap();
7154 assert!(
7155 text.contains("duplicate"),
7156 "response body must name the reason, got {text:?}"
7157 );
7158 provider.abort();
7159 }
7160
7161 fn active_records(
7162 directory: &std::path::Path,
7163 state: &AppState,
7164 session_id: &str,
7165 ) -> Vec<crate::worker::ActiveJournalRecord> {
7166 let digest = av_core::digest::sha256_hex(session_id.as_bytes());
7167 let journal =
7168 std::fs::read_to_string(directory.join(format!("{}.events.ndjson", &digest[..32]))).unwrap();
7169 journal
7170 .lines()
7171 .enumerate()
7172 .map(|(index, line)| {
7173 crate::journal::open(
7174 &state.journal_key,
7175 &format!("{session_id}:active"),
7176 index as u64,
7177 line.as_bytes(),
7178 )
7179 .unwrap()
7180 })
7181 .collect()
7182 }
7183
7184 #[tokio::test]
7185 async fn full_chat_close_and_promotion_flow() {
7186 let directory = tempfile::tempdir().unwrap();
7187 let (state, provider) = test_state(directory.path()).await;
7188 let app = build_router(state.clone());
7189
7190 let response = app.clone().oneshot(chat_request("http-flow")).await.unwrap();
7191 assert_eq!(response.status(), StatusCode::OK);
7192 assert_eq!(response.headers().get("x-av-session").unwrap(), "http-flow");
7193 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
7194 .await
7195 .unwrap();
7196 assert!(String::from_utf8_lossy(&body).contains("[DONE]"));
7197
7198 let close = app
7199 .clone()
7200 .oneshot(
7201 Request::builder()
7202 .method("POST")
7203 .uri("/v1/sessions/http-flow/close")
7204 .body(Body::empty())
7205 .unwrap(),
7206 )
7207 .await
7208 .unwrap();
7209 let close_status = close.status();
7210 let close_bytes = axum::body::to_bytes(close.into_body(), 64 * 1024).await.unwrap();
7211 assert_eq!(
7212 close_status,
7213 StatusCode::OK,
7214 "{}",
7215 String::from_utf8_lossy(&close_bytes)
7216 );
7217 let close_body: Value = serde_json::from_slice(&close_bytes).unwrap();
7218 assert_eq!(close_body["kind"], "atif");
7219 let artifact = state
7220 .sessions
7221 .get("http-flow")
7222 .unwrap()
7223 .atif_path
7224 .lock()
7225 .clone()
7226 .unwrap();
7227 let trajectory: av_atif::Trajectory =
7228 serde_json::from_slice(&tokio::fs::read(&artifact).await.unwrap()).unwrap();
7229 if let Some(destination) = std::env::var_os("AV_HARBOR_INTEROP_OUT") {
7230 std::fs::copy(&artifact, destination).unwrap();
7231 }
7232 assert_eq!(
7233 trajectory.steps.len(),
7234 2,
7235 "request and response must both be captured"
7236 );
7237 assert_eq!(trajectory.steps[0].source, av_atif::Source::User);
7238 assert!(trajectory.steps[0].metrics.is_none());
7239 assert_eq!(trajectory.steps[1].source, av_atif::Source::Agent);
7240 assert_eq!(trajectory.steps[1].message, Value::String("hello".to_owned()));
7241 assert_eq!(
7242 trajectory.steps[1].metrics.as_ref().unwrap().cached_tokens,
7243 Some(4)
7244 );
7245
7246 let promoted = app
7247 .oneshot(
7248 Request::builder()
7249 .method("POST")
7250 .uri("/v1/sessions/http-flow/promote")
7251 .body(Body::empty())
7252 .unwrap(),
7253 )
7254 .await
7255 .unwrap();
7256 assert_eq!(promoted.status(), StatusCode::OK);
7257 let receipt: av_receipts::Receipt = serde_json::from_slice(
7258 &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
7259 .await
7260 .unwrap(),
7261 )
7262 .unwrap();
7263 receipt.verify_embedded().unwrap();
7264 assert_eq!(receipt.body.stop_reason_id, av_events::StopReason::Stop.id());
7265 assert_eq!(receipt.body.cost.completion_tokens, 3);
7266 assert_eq!(receipt.body.cost.cached_tokens, 4);
7267 assert_eq!(receipt.body.cost.cost_usd_micros, 1_250);
7268 assert_eq!(
7269 receipt.body.cost.prompt_tokens, 10,
7270 "prompt total must reconcile to the provider-reported usage, \
7271 not the admission-time heuristic (and never both summed)"
7272 );
7273 provider.abort();
7274 }
7275
7276 #[tokio::test]
7277 async fn mcp_metrics_and_abort_paths_are_enforced() {
7278 let directory = tempfile::tempdir().unwrap();
7279 let (state, provider) = test_state(directory.path()).await;
7280 let app = build_router(state.clone());
7281 let allowed = app
7282 .clone()
7283 .oneshot(
7284 Request::builder()
7285 .method("POST")
7286 .uri("/v1/mcp")
7287 .header("x-av-session", "mcp-flow")
7288 .body(Body::from(
7289 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
7290 ))
7291 .unwrap(),
7292 )
7293 .await
7294 .unwrap();
7295 assert_eq!(allowed.status(), StatusCode::OK);
7296 let allowed_body: Value = serde_json::from_slice(
7297 &axum::body::to_bytes(allowed.into_body(), 64 * 1024)
7298 .await
7299 .unwrap(),
7300 )
7301 .unwrap();
7302 assert_eq!(allowed_body["jsonrpc"], "2.0");
7305 assert_eq!(allowed_body["id"], 1);
7306 assert!(allowed_body["result"]["decision_us"].as_u64().unwrap() < 5_000);
7307
7308 let blocked = app
7309 .clone()
7310 .oneshot(
7311 Request::builder()
7312 .method("POST")
7313 .uri("/v1/mcp")
7314 .header("x-av-session", "mcp-flow")
7315 .body(Body::from("not-json"))
7316 .unwrap(),
7317 )
7318 .await
7319 .unwrap();
7320 assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
7323 let blocked_body: Value = serde_json::from_slice(
7324 &axum::body::to_bytes(blocked.into_body(), 64 * 1024)
7325 .await
7326 .unwrap(),
7327 )
7328 .unwrap();
7329 assert_eq!(blocked_body["error"]["code"], -32700);
7330
7331 let metrics_response = app
7332 .clone()
7333 .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
7334 .await
7335 .unwrap();
7336 let metrics = String::from_utf8_lossy(
7337 &axum::body::to_bytes(metrics_response.into_body(), 128 * 1024)
7338 .await
7339 .unwrap(),
7340 )
7341 .into_owned();
7342 assert!(metrics.contains("av_stage_duration_seconds") || metrics.contains("av_sessions"));
7343
7344 let aborted = app.clone().oneshot(chat_request("abort-flow")).await.unwrap();
7345 drop(aborted);
7346 let session = tokio::time::timeout(Duration::from_secs(1), async {
7353 loop {
7354 if let Some(session) = state.sessions.get("abort-flow") {
7355 if session.active_streams_count() == 0 {
7356 break session;
7357 }
7358 }
7359 tokio::task::yield_now().await;
7360 }
7361 })
7362 .await
7363 .expect("abort stream never drained");
7364 assert!(
7365 !session.is_closed(),
7366 "an abort after captured content must leave the conversation open"
7367 );
7368 provider.abort();
7369 }
7370
7371 #[tokio::test]
7383 async fn stream_drop_during_concurrent_close_still_captures_response() {
7384 let directory = tempfile::tempdir().unwrap();
7385 let (state, provider) = test_state(directory.path()).await;
7386 let app = build_router(state.clone());
7387
7388 let request = Request::builder()
7389 .method("POST")
7390 .uri("/v1/chat/completions")
7391 .header(axum::http::header::CONTENT_TYPE, "application/json")
7392 .header("x-av-session", "stream-close-race")
7393 .header("x-av-workflow", "signed")
7394 .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
7395 .unwrap();
7396 let response = app.oneshot(request).await.unwrap();
7397 assert_eq!(response.status(), StatusCode::OK);
7398
7399 let session = state.sessions.get("stream-close-race").unwrap();
7400
7401 let close_task = {
7402 let finalizer = state.finalizer.clone();
7403 let session = Arc::clone(&session);
7404 tokio::spawn(async move { finalizer.close_session(session, StopReason::SessionClosed).await })
7405 };
7406
7407 tokio::time::timeout(Duration::from_secs(1), async {
7408 while !session.is_closed() {
7409 tokio::task::yield_now().await;
7410 }
7411 })
7412 .await
7413 .expect("concurrent close did not initiate before the response was dropped");
7414
7415 drop(response);
7416
7417 close_task
7418 .await
7419 .expect("close_session task panicked")
7420 .expect("close_session returned an error");
7421
7422 let final_count = session.chain.lock().count();
7423 assert!(
7424 final_count >= 2,
7425 "AbortFinalizingStream::drop must submit the response capture even when the session is already being closed — otherwise a concurrent close during an active stream discards the response and the receipt attests to fewer events than actually flowed; got chain.count() = {final_count}",
7426 );
7427
7428 provider.abort();
7429 }
7430
7431 #[tokio::test]
7455 async fn drop_with_pending_budget_during_concurrent_close_does_not_stick_session() {
7456 let directory = tempfile::tempdir().unwrap();
7457 let (state, provider) = test_state(directory.path()).await;
7458
7459 let identity = av_events::AgentIdentity {
7460 version: "1".into(),
7461 charter: "c".into(),
7462 instance_uid: "i".into(),
7463 ttl_remaining_s: None,
7464 };
7465 let session = state.sessions.get_or_open(
7466 "pending-budget-close",
7467 crate::session::Workflow::Signed,
7468 &identity,
7469 &state.config.breaker,
7470 );
7471 let permits = state
7476 .worker
7477 .try_reserve_pair("pending-budget-close")
7478 .expect("test setup: fused worker/response permit");
7479 drop(permits.worker);
7483 let permit = permits.response;
7484 let lease = crate::session::SessionLease::new(Arc::clone(&session));
7487
7488 let budget_task =
7492 tokio::spawn(async { Ok::<_, String>(av_state::BudgetDecision::Allowed { remaining: 100 }) });
7493
7494 let stream = AbortFinalizingStream {
7495 inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7496 session: Arc::clone(&session),
7497 identity: identity.clone(),
7498 response_permit: Some(permit),
7499 worker: state.worker.clone(),
7500 store: Arc::clone(&state.store),
7501 budget: state.config.budget.clone(),
7502 billed_prompt_tokens: 0,
7503 principal_budget: None,
7504 provider_adapter: Arc::clone(&state.provider_adapter),
7505 finalizer: state.finalizer.clone(),
7506 _lease: lease,
7507 response_marker: None,
7508 response_attempt_id: "pending-budget-attempt".into(),
7509 response_message: "captured content".into(),
7510 response_reasoning: String::new(),
7511 response_model: None,
7512 response_finish_reason: Some("stop".into()),
7513 upstream_status: StatusCode::OK,
7514 response_cost_usd_micros: 0,
7515 response_tool_calls: std::collections::BTreeMap::new(),
7516 response_metrics: av_events::EventMetrics::default(),
7517 charged_completion_tokens: 3,
7518 last_reported_completion_tokens: Some(3),
7519 last_reported_prompt_tokens: None,
7520 last_reported_cached_tokens: None,
7521 last_reported_cost_usd_micros: None,
7522 saw_chunk: true,
7523 capture_attempted: false,
7524 is_sse: true,
7525 protocol_buffer: Vec::new(),
7526 frame_scratch: Vec::new(),
7527 pending_output: std::collections::VecDeque::new(),
7528 pending_budget: Some(PendingBudget {
7529 task: budget_task,
7530 continuation: BudgetContinuation::FinishSse,
7531 delta: 0,
7532 }),
7533 captured_bytes: 100,
7534 completed: false,
7535 ephemeral: false,
7536 };
7537
7538 assert!(session.try_close(), "test setup: try_close should have won");
7540 assert!(session.is_closed());
7541
7542 drop(stream);
7545
7546 assert!(
7547 !session.capture_failed(),
7548 "AbortFinalizingStream::drop must not fail the capture just because a completion-token budget check was still in flight while a concurrent close is already draining this stream via wait_for_streams — otherwise close_session_locked's capture_failed guard seals the session and returns CaptureIncomplete: the receipt is never signed and the capture is lost",
7549 );
7550
7551 let outcome = tokio::time::timeout(
7557 Duration::from_secs(3),
7558 state
7559 .finalizer
7560 .close_session(Arc::clone(&session), StopReason::SessionClosed),
7561 )
7562 .await
7563 .expect("close_session hung after drop released the stream");
7564 outcome.expect(
7565 "close_session must succeed after AbortFinalizingStream::drop submits the response capture — a session sealed without its artifact is worse than a marginally-over-budget response record, because the receipt is lost entirely",
7566 );
7567
7568 provider.abort();
7569 }
7570
7571 #[tokio::test]
7578 async fn absorb_error_rolls_back_undebited_completion_charges() {
7579 let directory = tempfile::tempdir().unwrap();
7580 let (state, provider) = test_state(directory.path()).await;
7581 let identity = av_events::AgentIdentity {
7582 version: "1".into(),
7583 charter: "c".into(),
7584 instance_uid: "i".into(),
7585 ttl_remaining_s: None,
7586 };
7587 let session = state.sessions.get_or_open(
7588 "absorb-rollback",
7589 crate::session::Workflow::Signed,
7590 &identity,
7591 &state.config.breaker,
7592 );
7593 let lease = crate::session::SessionLease::new(Arc::clone(&session));
7594 let mut stream = AbortFinalizingStream {
7595 inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7596 session: Arc::clone(&session),
7597 identity,
7598 response_permit: None,
7599 worker: state.worker.clone(),
7600 store: Arc::clone(&state.store),
7601 budget: state.config.budget.clone(),
7602 billed_prompt_tokens: 0,
7603 principal_budget: None,
7604 provider_adapter: Arc::clone(&state.provider_adapter),
7605 finalizer: state.finalizer.clone(),
7606 _lease: lease,
7607 response_marker: None,
7608 response_attempt_id: "absorb-rollback-attempt".into(),
7609 response_message: String::new(),
7610 response_reasoning: String::new(),
7611 response_model: None,
7612 response_finish_reason: None,
7613 upstream_status: StatusCode::OK,
7614 response_cost_usd_micros: 0,
7615 response_tool_calls: std::collections::BTreeMap::new(),
7616 response_metrics: av_events::EventMetrics::default(),
7617 charged_completion_tokens: 0,
7618 last_reported_completion_tokens: None,
7619 last_reported_prompt_tokens: None,
7620 last_reported_cached_tokens: None,
7621 last_reported_cost_usd_micros: None,
7622 saw_chunk: false,
7623 capture_attempted: true,
7624 is_sse: true,
7625 protocol_buffer: Vec::new(),
7626 frame_scratch: Vec::new(),
7627 pending_output: std::collections::VecDeque::new(),
7628 pending_budget: None,
7629 captured_bytes: 0,
7630 completed: false,
7631 ephemeral: false,
7632 };
7633 let chunk = concat!(
7638 "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"some streamed words here\"}}]}\n\n",
7639 "data: {not-json\n\n",
7640 );
7641 let result = stream.absorb_network_chunk(chunk.as_bytes());
7642 assert!(result.is_err(), "the malformed frame must fail the chunk");
7643 assert_eq!(
7644 stream.charged_completion_tokens, 0,
7645 "an errored chunk's never-debited frame deltas must be rolled back \
7646 from charged_completion_tokens, or the terminal refund credits \
7647 tokens that were never debited"
7648 );
7649 provider.abort();
7650 }
7651
7652 #[tokio::test]
7661 async fn refused_budget_check_rolls_back_undebited_charge() {
7662 use futures::StreamExt as _;
7663 let directory = tempfile::tempdir().unwrap();
7664 let (state, provider) = test_state(directory.path()).await;
7665 let identity = av_events::AgentIdentity {
7666 version: "1".into(),
7667 charter: "c".into(),
7668 instance_uid: "i".into(),
7669 ttl_remaining_s: None,
7670 };
7671 let session = state.sessions.get_or_open(
7672 "refused-rollback",
7673 crate::session::Workflow::Signed,
7674 &identity,
7675 &state.config.breaker,
7676 );
7677 let lease = crate::session::SessionLease::new(Arc::clone(&session));
7678 let refused = tokio::spawn(async {
7679 Ok::<_, String>(av_state::BudgetDecision::Refused {
7680 limit: "tokens".to_owned(),
7681 cap: 100,
7682 })
7683 });
7684 let mut stream = AbortFinalizingStream {
7685 inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7686 session: Arc::clone(&session),
7687 identity,
7688 response_permit: None,
7689 worker: state.worker.clone(),
7690 store: Arc::clone(&state.store),
7691 budget: state.config.budget.clone(),
7692 billed_prompt_tokens: 0,
7693 principal_budget: None,
7694 provider_adapter: Arc::clone(&state.provider_adapter),
7695 finalizer: state.finalizer.clone(),
7696 _lease: lease,
7697 response_marker: None,
7698 response_attempt_id: "refused-rollback-attempt".into(),
7699 response_message: String::new(),
7700 response_reasoning: String::new(),
7701 response_model: None,
7702 response_finish_reason: None,
7703 upstream_status: StatusCode::OK,
7704 response_cost_usd_micros: 0,
7705 response_tool_calls: std::collections::BTreeMap::new(),
7706 response_metrics: av_events::EventMetrics::default(),
7707 charged_completion_tokens: 10,
7710 last_reported_completion_tokens: None,
7711 last_reported_prompt_tokens: None,
7712 last_reported_cached_tokens: None,
7713 last_reported_cost_usd_micros: None,
7714 saw_chunk: true,
7715 capture_attempted: true,
7718 is_sse: true,
7719 protocol_buffer: Vec::new(),
7720 frame_scratch: Vec::new(),
7721 pending_output: std::collections::VecDeque::new(),
7722 pending_budget: Some(PendingBudget {
7723 task: refused,
7724 continuation: BudgetContinuation::FinishSse,
7725 delta: 4,
7726 }),
7727 captured_bytes: 0,
7728 completed: false,
7729 ephemeral: false,
7730 };
7731 let item = stream.next().await.expect("refusal must yield a terminal item");
7732 let error = item.expect_err("a refused budget check must surface as an error");
7733 assert_eq!(error.kind(), std::io::ErrorKind::QuotaExceeded);
7734 assert_eq!(
7735 stream.charged_completion_tokens, 6,
7736 "the refused (never-debited) delta must be rolled back from \
7737 charged_completion_tokens, or the terminal refund credits \
7738 tokens that were never spent"
7739 );
7740 assert!(
7741 stream.session.enforcement_tripped(),
7742 "a genuine mid-stream refusal still latches enforcement"
7743 );
7744 provider.abort();
7745 }
7746
7747 fn empty_abort_stream(
7753 state: &AppState,
7754 session: &Arc<crate::session::Session>,
7755 identity: av_events::AgentIdentity,
7756 ephemeral: bool,
7757 ) -> AbortFinalizingStream {
7758 AbortFinalizingStream {
7759 inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7760 session: Arc::clone(session),
7761 identity,
7762 response_permit: None,
7763 worker: state.worker.clone(),
7764 store: Arc::clone(&state.store),
7765 budget: state.config.budget.clone(),
7766 billed_prompt_tokens: 0,
7767 principal_budget: None,
7768 provider_adapter: Arc::clone(&state.provider_adapter),
7769 finalizer: state.finalizer.clone(),
7770 _lease: crate::session::SessionLease::new(Arc::clone(session)),
7771 response_marker: None,
7772 response_attempt_id: "empty-abort-attempt".into(),
7773 response_message: String::new(),
7774 response_reasoning: String::new(),
7775 response_model: None,
7776 response_finish_reason: None,
7777 upstream_status: StatusCode::OK,
7778 response_cost_usd_micros: 0,
7779 response_tool_calls: std::collections::BTreeMap::new(),
7780 response_metrics: av_events::EventMetrics::default(),
7781 charged_completion_tokens: 0,
7782 last_reported_completion_tokens: None,
7783 last_reported_prompt_tokens: None,
7784 last_reported_cached_tokens: None,
7785 last_reported_cost_usd_micros: None,
7786 saw_chunk: false,
7787 capture_attempted: true,
7788 is_sse: true,
7789 protocol_buffer: Vec::new(),
7790 frame_scratch: Vec::new(),
7791 pending_output: std::collections::VecDeque::new(),
7792 pending_budget: None,
7793 captured_bytes: 0,
7794 completed: false,
7795 ephemeral,
7796 }
7797 }
7798
7799 #[tokio::test]
7810 async fn non_ephemeral_empty_abort_leaves_session_open() {
7811 let directory = tempfile::tempdir().unwrap();
7812 let (state, provider) = test_state(directory.path()).await;
7813 let identity = av_events::AgentIdentity {
7814 version: "1".into(),
7815 charter: "c".into(),
7816 instance_uid: "i".into(),
7817 ttl_remaining_s: None,
7818 };
7819 let session = state.sessions.get_or_open(
7820 "client-managed-multi-turn",
7821 crate::session::Workflow::Signed,
7822 &identity,
7823 &state.config.breaker,
7824 );
7825 let stream = empty_abort_stream(&state, &session, identity, false);
7826 drop(stream);
7827 for _ in 0..20 {
7830 tokio::time::sleep(Duration::from_millis(10)).await;
7831 if session.is_closed() {
7832 break;
7833 }
7834 }
7835 assert!(
7836 !session.is_closed(),
7837 "a client-managed (non-ephemeral) session must NOT be auto-closed \
7838 when a turn aborts with nothing captured — multi-turn sessions \
7839 close only via POST /close or the idle sweeper, otherwise the \
7840 client's retry with the same id fragments the audit chain"
7841 );
7842 provider.abort();
7843 }
7844
7845 #[tokio::test]
7849 async fn ephemeral_empty_abort_closes_session_in_background() {
7850 let directory = tempfile::tempdir().unwrap();
7851 let (state, provider) = test_state(directory.path()).await;
7852 let identity = av_events::AgentIdentity {
7853 version: "1".into(),
7854 charter: "c".into(),
7855 instance_uid: "i".into(),
7856 ttl_remaining_s: None,
7857 };
7858 let session = state.sessions.get_or_open(
7859 "ephemeral-empty-abort",
7860 crate::session::Workflow::Signed,
7861 &identity,
7862 &state.config.breaker,
7863 );
7864 let stream = empty_abort_stream(&state, &session, identity, true);
7865 drop(stream);
7866 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
7867 while !session.is_closed() && tokio::time::Instant::now() < deadline {
7868 tokio::time::sleep(Duration::from_millis(10)).await;
7869 }
7870 assert!(
7871 session.is_closed(),
7872 "an ephemeral one-shot aborted before any upstream byte must be \
7873 closed by the background abort-cleanup spawn, not linger until \
7874 the idle sweeper"
7875 );
7876 provider.abort();
7877 }
7878
7879 #[tokio::test]
7880 async fn duplicate_tool_id_replays_outcome_without_reexecution() {
7881 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7882 let tool_address = tool_listener.local_addr().unwrap();
7883 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7884 let tool_router = Router::new()
7885 .route("/mcp", post(counting_tool))
7886 .with_state(Arc::clone(&calls));
7887 let tool_server = tokio::spawn(async move {
7888 axum::serve(tool_listener, tool_router).await.unwrap();
7889 });
7890 let directory = tempfile::tempdir().unwrap();
7891 let (mut state, provider) = test_state(directory.path()).await;
7892 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
7893 Some(format!("http://{tool_address}/mcp"));
7894 let app = build_router(state.clone());
7895 let body = r#"{"jsonrpc":"2.0","id":"once","method":"tools/call","params":{"name":"read","arguments":{"id":7}}}"#;
7896 for _ in 0..2 {
7897 let response = app
7898 .clone()
7899 .oneshot(
7900 Request::builder()
7901 .method("POST")
7902 .uri("/v1/mcp")
7903 .header("x-av-session", "tool-once")
7904 .body(Body::from(body))
7905 .unwrap(),
7906 )
7907 .await
7908 .unwrap();
7909 assert_eq!(response.status(), StatusCode::OK);
7910 assert_eq!(
7911 axum::body::to_bytes(response.into_body(), 64 * 1024)
7912 .await
7913 .unwrap(),
7914 body
7915 );
7916 }
7917 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
7918 let session = state.sessions.get("tool-once").unwrap();
7919 assert_eq!(
7920 session
7921 .totals
7922 .tool_calls
7923 .load(std::sync::atomic::Ordering::Acquire),
7924 1
7925 );
7926 let audited_path = std::fs::read_dir(directory.path().join(crate::spool::TOOL_EXECUTIONS))
7927 .unwrap()
7928 .map(|entry| entry.unwrap().path())
7929 .find(|path| path.extension().is_some_and(|extension| extension == "audited"))
7930 .unwrap();
7931 std::fs::remove_file(audited_path).unwrap();
7932 let unaudited_replay = app
7933 .clone()
7934 .oneshot(
7935 Request::builder()
7936 .method("POST")
7937 .uri("/v1/mcp")
7938 .header("x-av-session", "tool-once")
7939 .body(Body::from(body))
7940 .unwrap(),
7941 )
7942 .await
7943 .unwrap();
7944 assert_eq!(unaudited_replay.status(), StatusCode::OK);
7945 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
7946 assert_eq!(
7947 session
7948 .totals
7949 .tool_calls
7950 .load(std::sync::atomic::Ordering::Acquire),
7951 1,
7952 "unaudited outcome recovery must not spend tool budget again"
7953 );
7954
7955 let changed_request = app
7956 .clone()
7957 .oneshot(
7958 Request::builder()
7959 .method("POST")
7960 .uri("/v1/mcp")
7961 .header("x-av-session", "tool-once")
7962 .body(Body::from(
7963 r#"{"jsonrpc":"2.0","id":"once","method":"tools/call","params":{"name":"read","arguments":{"id":8}}}"#,
7964 ))
7965 .unwrap(),
7966 )
7967 .await
7968 .unwrap();
7969 assert_eq!(changed_request.status(), StatusCode::CONFLICT);
7970 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
7971 tool_server.abort();
7972 provider.abort();
7973 }
7974
7975 #[tokio::test]
7980 async fn upstream_auth_header_reaches_provider_wire() {
7981 type Captured = Arc<parking_lot::Mutex<Vec<(Option<String>, Option<String>)>>>;
7982 async fn capture_chat(
7983 State(captured): State<Captured>,
7984 headers: HeaderMap,
7985 Json(_): Json<Value>,
7986 ) -> Response {
7987 captured.lock().push((
7988 headers
7989 .get(axum::http::header::AUTHORIZATION)
7990 .and_then(|value| value.to_str().ok())
7991 .map(str::to_owned),
7992 headers
7993 .get("api-key")
7994 .and_then(|value| value.to_str().ok())
7995 .map(str::to_owned),
7996 ));
7997 Json(json!({"choices": [], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}))
7998 .into_response()
7999 }
8000 let captured: Captured = Arc::new(parking_lot::Mutex::new(Vec::new()));
8001 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8002 let address = listener.local_addr().unwrap();
8003 let provider_router = Router::new()
8004 .route("/v1/chat/completions", post(capture_chat))
8005 .route("/openai/deployments/d1/chat/completions", post(capture_chat))
8006 .with_state(Arc::clone(&captured));
8007 let provider = tokio::spawn(async move {
8008 axum::serve(listener, provider_router).await.unwrap();
8009 });
8010 let directory = tempfile::tempdir().unwrap();
8011 let key_path = directory.path().join("upstream.key");
8012 std::fs::write(&key_path, "sk-wire-test\n").unwrap();
8013 #[cfg(unix)]
8014 {
8015 use std::os::unix::fs::PermissionsExt as _;
8016 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
8017 }
8018 let spool = directory.path().to_string_lossy();
8019 let build_state = |config: crate::config::HarnessConfig| {
8020 AppState::new(
8021 config,
8022 Arc::new(InMemoryStore::new()),
8023 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
8024 Arc::new(NullBus),
8025 None,
8026 Arc::new(Ed25519Signer::from_seed(&[12; 32])),
8027 )
8028 .unwrap()
8029 };
8030 let send_chat = |state: AppState, authorization: Option<&'static str>| async move {
8031 let mut request = Request::builder()
8032 .method("POST")
8033 .uri("/v1/chat/completions")
8034 .header(axum::http::header::CONTENT_TYPE, "application/json")
8035 .header("x-av-session", "auth-wire");
8036 if let Some(value) = authorization {
8037 request = request.header(axum::http::header::AUTHORIZATION, value);
8038 }
8039 let response = build_router(state)
8040 .oneshot(request.body(Body::from(chat_payload().to_string())).unwrap())
8041 .await
8042 .unwrap();
8043 assert_eq!(response.status(), StatusCode::OK);
8044 };
8045
8046 let mut config =
8048 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8049 config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
8050 send_chat(build_state(config), None).await;
8051 assert_eq!(
8052 captured.lock().pop().unwrap(),
8053 (Some("Bearer sk-wire-test".to_owned()), None),
8054 "Bearer credential must reach the provider"
8055 );
8056
8057 let mut config =
8059 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8060 config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
8061 config.upstream_auth_header = "api-key".into();
8062 config.upstream_auth_scheme = String::new();
8063 config.upstream_chat_path = "/openai/deployments/d1/chat/completions".into();
8064 send_chat(build_state(config), None).await;
8065 assert_eq!(
8066 captured.lock().pop().unwrap(),
8067 (None, Some("sk-wire-test".to_owned())),
8068 "raw key must reach the provider on the configured path and header"
8069 );
8070
8071 let mut config =
8073 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8074 config.upstream_authorization_passthrough = true;
8075 send_chat(build_state(config), Some("Bearer client-owned-key")).await;
8076 assert_eq!(
8077 captured.lock().pop().unwrap(),
8078 (Some("Bearer client-owned-key".to_owned()), None),
8079 "passthrough must relay the client credential"
8080 );
8081
8082 let config = crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8089 let request = Request::builder()
8090 .method("POST")
8091 .uri("/v1/chat/completions")
8092 .header(axum::http::header::CONTENT_TYPE, "application/json")
8093 .header("x-av-session", "auth-wire")
8094 .header(axum::http::header::AUTHORIZATION, "Bearer client-owned-key")
8095 .body(Body::from(chat_payload().to_string()))
8096 .unwrap();
8097 let response = build_router(build_state(config)).oneshot(request).await.unwrap();
8098 assert_eq!(
8099 response.status(),
8100 StatusCode::UNAUTHORIZED,
8101 "presenting a bearer with no validator configured must be refused rather than \
8102 silently recorded as anonymous — that would let a signed request end up \
8103 attributed to `charter=anonymous` in the receipt"
8104 );
8105 assert!(
8106 captured.lock().is_empty(),
8107 "no credential must reach upstream when the request was refused"
8108 );
8109 provider.abort();
8110 }
8111
8112 #[tokio::test]
8115 async fn tool_upstream_bearer_reaches_tool_server() {
8116 type Captured = Arc<parking_lot::Mutex<Vec<Option<String>>>>;
8117 async fn capture_tool(State(captured): State<Captured>, headers: HeaderMap, body: Bytes) -> Response {
8118 captured.lock().push(
8119 headers
8120 .get(axum::http::header::AUTHORIZATION)
8121 .and_then(|value| value.to_str().ok())
8122 .map(str::to_owned),
8123 );
8124 (StatusCode::OK, body).into_response()
8125 }
8126 let captured: Captured = Arc::new(parking_lot::Mutex::new(Vec::new()));
8127 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8128 let address = listener.local_addr().unwrap();
8129 let tool_router = Router::new()
8130 .route("/mcp", post(capture_tool))
8131 .with_state(Arc::clone(&captured));
8132 let tool_server = tokio::spawn(async move {
8133 axum::serve(listener, tool_router).await.unwrap();
8134 });
8135 let directory = tempfile::tempdir().unwrap();
8136 let token_path = directory.path().join("mcp.token");
8137 std::fs::write(&token_path, "tool-secret\n").unwrap();
8138 #[cfg(unix)]
8139 {
8140 use std::os::unix::fs::PermissionsExt as _;
8141 std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600)).unwrap();
8142 }
8143 let (mut state, provider) = test_state(directory.path()).await;
8144 {
8145 let config = Arc::get_mut(&mut state.config).unwrap();
8146 config.tool_upstream_url = Some(format!("http://{address}/mcp"));
8147 config.tool_upstream_bearer_file = Some(token_path.to_string_lossy().into_owned());
8148 }
8149 state.tool_auth = crate::pipeline::resolve_tool_auth(&state.config).unwrap();
8151 let response = build_router(state)
8152 .oneshot(
8153 Request::builder()
8154 .method("POST")
8155 .uri("/v1/mcp")
8156 .header("x-av-session", "tool-auth")
8157 .body(Body::from(
8158 r#"{"jsonrpc":"2.0","id":"t1","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
8159 ))
8160 .unwrap(),
8161 )
8162 .await
8163 .unwrap();
8164 assert_eq!(response.status(), StatusCode::OK);
8165 assert_eq!(
8166 captured.lock().pop().unwrap().as_deref(),
8167 Some("Bearer tool-secret"),
8168 "tool bearer must reach the tool server"
8169 );
8170 tool_server.abort();
8171 provider.abort();
8172 }
8173
8174 #[tokio::test]
8175 async fn startup_refuses_pending_and_unaudited_tool_effects() {
8176 let directory = tempfile::tempdir().unwrap();
8177 let control_key = [31; 32];
8178 let mut headers = HeaderMap::new();
8179 headers.insert("x-av-session", HeaderValue::from_static("tool-recovery"));
8180 let body = br#"{"jsonrpc":"2.0","id":"recover","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8181 let mut execution =
8182 ToolExecution::from_request(&directory.path().to_string_lossy(), &headers, body, control_key)
8183 .unwrap();
8184 execution
8185 .bind_principal(&av_events::AgentIdentity {
8186 version: "dev".to_owned(),
8187 charter: "anonymous".into(),
8188 instance_uid: "anonymous".to_owned(),
8189 ttl_remaining_s: None,
8190 })
8191 .unwrap();
8192 execution.claim().await.unwrap();
8193 assert!(
8194 ensure_no_unresolved_tool_executions(directory.path(), &control_key)
8195 .await
8196 .is_err()
8197 );
8198 execution
8199 .persist(&ToolOutcome {
8200 status: 200,
8201 body_hex: hex::encode(body),
8202 content_type: Some("application/json".to_owned()),
8203 })
8204 .await
8205 .unwrap();
8206 assert!(
8207 ensure_no_unresolved_tool_executions(directory.path(), &control_key)
8208 .await
8209 .is_err()
8210 );
8211 execution.mark_audited().await.unwrap();
8212 ensure_no_unresolved_tool_executions(directory.path(), &control_key)
8213 .await
8214 .unwrap();
8215 }
8216
8217 #[tokio::test]
8225 async fn oversize_tool_intent_is_quarantined_not_loaded() {
8226 let directory = tempfile::tempdir().unwrap();
8227 let control_key = [32; 32];
8228 let executions = directory.path().join(crate::spool::TOOL_EXECUTIONS);
8229 std::fs::create_dir_all(&executions).unwrap();
8230 let plant = executions.join("oversizekey.intent.json");
8231 std::fs::write(&plant, vec![b'x'; 1024 * 1024 + 1]).unwrap();
8233
8234 let unresolved = unresolved_tool_sessions(directory.path(), &control_key)
8235 .await
8236 .expect("an oversize plant must not abort the recovery scan");
8237 assert!(unresolved.is_empty(), "{unresolved:?}");
8238 assert!(
8239 !plant.exists(),
8240 "the oversize intent must be quarantined out of the scan glob"
8241 );
8242 let quarantined = std::fs::read_dir(&executions)
8243 .unwrap()
8244 .filter_map(Result::ok)
8245 .any(|entry| {
8246 entry
8247 .file_name()
8248 .to_str()
8249 .is_some_and(|name| name.ends_with(".intent.torn"))
8250 });
8251 assert!(quarantined, "quarantined plant must stay on disk as evidence");
8252 }
8253
8254 #[cfg(unix)]
8261 #[tokio::test]
8262 async fn unreadable_tool_intent_aborts_the_scan_not_quarantines() {
8263 use std::os::unix::fs::PermissionsExt as _;
8264 let directory = tempfile::tempdir().unwrap();
8265 let control_key = [33; 32];
8266 let executions = directory.path().join(crate::spool::TOOL_EXECUTIONS);
8267 std::fs::create_dir_all(&executions).unwrap();
8268 let unreadable = executions.join("unreadablekey.intent.json");
8269 std::fs::write(&unreadable, b"sealed-bytes").unwrap();
8270 std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap();
8271 if std::fs::read(&unreadable).is_ok() {
8272 return;
8274 }
8275
8276 let outcome = unresolved_tool_sessions(directory.path(), &control_key).await;
8277 assert!(
8278 outcome.is_err(),
8279 "a permission fault must abort the scan, not quarantine evidence"
8280 );
8281 assert!(
8282 unreadable.exists(),
8283 "the unreadable intent must be left in place for the operator"
8284 );
8285 }
8286
8287 struct BlockingRefundStore {
8288 inner: InMemoryStore,
8289 batches: parking_lot::Mutex<Vec<Vec<av_state::Refund>>>,
8290 entered: tokio::sync::Notify,
8291 released: parking_lot::Mutex<bool>,
8292 wake: parking_lot::Condvar,
8293 fallback_used: std::sync::atomic::AtomicBool,
8294 }
8295
8296 impl StateStore for BlockingRefundStore {
8297 fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
8298 self.inner.add(key, delta)
8299 }
8300
8301 fn get(&self, key: &str) -> Result<u64, StateError> {
8302 self.inner.get(key)
8303 }
8304
8305 fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError> {
8306 self.inner.try_spend(key, amount, limit)
8307 }
8308
8309 fn try_spend_many(&self, spends: &[Spend]) -> Result<av_state::TrySpendOutcome, StateError> {
8310 self.inner.try_spend_many(spends)
8311 }
8312
8313 fn remove(&self, key: &str) {
8314 self.inner.remove(key);
8315 }
8316
8317 fn refund_many(&self, refunds: &[av_state::Refund]) {
8318 let first = {
8319 let mut batches = self.batches.lock();
8320 batches.push(refunds.to_vec());
8321 batches.len() == 1
8322 };
8323 if first {
8324 self.entered.notify_one();
8325 let deadline = std::time::Instant::now() + Duration::from_secs(3);
8326 let mut released = self.released.lock();
8327 while !*released {
8328 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
8329 if remaining.is_zero() || self.wake.wait_for(&mut released, remaining).timed_out() {
8330 self.fallback_used
8333 .store(true, std::sync::atomic::Ordering::Release);
8334 break;
8335 }
8336 }
8337 }
8338 self.inner.refund_many(refunds);
8339 }
8340 }
8341
8342 #[tokio::test(flavor = "current_thread")]
8343 async fn mcp_refund_keeps_reactor_live_and_survives_caller_cancellation() {
8344 let directory = tempfile::tempdir().unwrap();
8345 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8346 let address = listener.local_addr().unwrap();
8347 drop(listener);
8349 let mut config = crate::config::HarnessConfig::for_tests(
8350 &format!("http://{address}"),
8351 &directory.path().to_string_lossy(),
8352 &directory.path().to_string_lossy(),
8353 );
8354 config.tool_upstream_url = Some(format!("http://{address}/mcp"));
8355 config.budget.max_total_tool_calls = Some(1);
8356 config.budget.max_tool_calls.insert("read".into(), 1);
8357 config.principal_budget = Some(config.budget.clone());
8358 config.allow_anonymous_principal_budget = true;
8359 config.mcp_concurrency = 1;
8360 let store = Arc::new(BlockingRefundStore {
8361 inner: InMemoryStore::new(),
8362 batches: parking_lot::Mutex::new(Vec::new()),
8363 entered: tokio::sync::Notify::new(),
8364 released: parking_lot::Mutex::new(false),
8365 wake: parking_lot::Condvar::new(),
8366 fallback_used: std::sync::atomic::AtomicBool::new(false),
8367 });
8368 let sandbox = Arc::new(
8369 Sandbox::new(
8370 SandboxConfig {
8371 budget: config.budget.clone(),
8372 ..SandboxConfig::default()
8373 },
8374 Vec::new(),
8375 )
8376 .unwrap(),
8377 );
8378 let state = AppState::new(
8379 config,
8380 store.clone(),
8381 sandbox,
8382 Arc::new(NullBus),
8383 None,
8384 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
8385 )
8386 .unwrap();
8387 let app = build_router(state.clone());
8388 let request = || {
8389 Request::builder()
8390 .method("POST")
8391 .uri("/v1/mcp")
8392 .header("x-av-session", "refund-cancel")
8393 .body(Body::from(
8394 r#"{"jsonrpc":"2.0","id":"refund","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
8395 ))
8396 .unwrap()
8397 };
8398 let mut expected_keys = Vec::new();
8399 for prefix in [
8400 av_state::ActionBudget::session_prefix("refund-cancel"),
8401 av_state::ActionBudget::principal_prefix("anonymous"),
8402 ] {
8403 expected_keys.push(format!("{prefix}total_calls"));
8404 expected_keys.push(format!("{prefix}tool:read"));
8405 }
8406 let first = tokio::spawn(app.clone().oneshot(request()));
8407 tokio::time::timeout(Duration::from_secs(5), store.entered.notified())
8408 .await
8409 .unwrap();
8410 let progressed_while_refund_waited = !store.fallback_used.load(std::sync::atomic::Ordering::Acquire);
8414 let charged_before_release: Vec<_> =
8415 expected_keys.iter().map(|key| store.get(key).unwrap()).collect();
8416 first.abort();
8417 *store.released.lock() = true;
8418 store.wake.notify_all();
8419 let caller_cancelled = first.await.is_err_and(|error| error.is_cancelled());
8420 tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
8421 .await
8422 .unwrap();
8423 let first_batches = store.batches.lock().clone();
8424 let after_first: Vec<_> = expected_keys.iter().map(|key| store.get(key).unwrap()).collect();
8425 let retry = app.oneshot(request()).await.unwrap();
8426 let retry_status = retry.status();
8427 tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
8428 .await
8429 .unwrap();
8430 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8431 .await
8432 .unwrap();
8433 let batches = store.batches.lock().clone();
8434 let session = state.sessions.get("refund-cancel").unwrap();
8435
8436 assert!(
8437 progressed_while_refund_waited,
8438 "synchronous budget compensation blocked the only Tokio thread until the fallback released it"
8439 );
8440 assert!(
8441 charged_before_release.iter().all(|value| *value == 1),
8442 "both session and principal ledgers must actually be debited before compensation"
8443 );
8444 assert!(
8445 caller_cancelled,
8446 "the outer caller must be cancelled while compensation is in progress"
8447 );
8448 assert_eq!(
8449 first_batches.len(),
8450 2,
8451 "one session and one principal refund must complete despite cancellation"
8452 );
8453 assert!(
8454 after_first.iter().all(|value| *value == 0),
8455 "both ledgers must be refunded before retry"
8456 );
8457 assert_eq!(
8458 retry_status,
8459 StatusCode::BAD_GATEWAY,
8460 "retry must attempt the unexecuted call, not return a pending claim or spent-budget refusal"
8461 );
8462 assert_eq!(
8463 batches.len(),
8464 4,
8465 "each of two attempts refunds each ledger exactly once"
8466 );
8467 for key in expected_keys {
8468 let amounts: Vec<_> = batches
8469 .iter()
8470 .flatten()
8471 .filter(|refund| refund.key == key)
8472 .map(|refund| refund.amount)
8473 .collect();
8474 assert_eq!(amounts, [1, 1], "unexpected compensation for {key}");
8475 assert_eq!(store.get(&key).unwrap(), 0);
8476 }
8477 assert_eq!(state.mcp_admission.available_permits(), 1);
8478 assert_eq!(session.active_streams_count(), 0);
8479 assert_eq!(session.pending_jobs_count(), 0);
8480 }
8481
8482 async fn assert_orphan_mcp_intent_refuses_startup(unaudited: bool) {
8483 let directory = tempfile::tempdir().unwrap();
8484 let (state, provider) = test_state(directory.path()).await;
8485 let session_id = "orphan-uncertain";
8486 let mut headers = HeaderMap::new();
8487 headers.insert("x-av-session", HeaderValue::from_static("orphan-uncertain"));
8488 let body = br#"{"jsonrpc":"2.0","id":"orphan","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8489 let mut execution =
8490 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8491 .unwrap();
8492 execution
8493 .bind_principal(&av_events::AgentIdentity {
8494 version: "dev".to_owned(),
8495 charter: "anonymous".into(),
8496 instance_uid: "anonymous".to_owned(),
8497 ttl_remaining_s: None,
8498 })
8499 .unwrap();
8500 execution.claim().await.unwrap();
8504 if unaudited {
8505 execution
8506 .persist(&ToolOutcome {
8507 status: 200,
8508 body_hex: hex::encode(b"effect happened"),
8509 content_type: Some("text/plain".to_owned()),
8510 })
8511 .await
8512 .unwrap();
8513 }
8514 let intent_before = std::fs::read(&execution.intent_path).unwrap();
8515 assert!(state.sessions.get(session_id).is_none());
8516 assert!(!std::fs::read_dir(directory.path())
8517 .unwrap()
8518 .filter_map(Result::ok)
8519 .any(|entry| entry.file_name().to_string_lossy().ends_with(".session.json")));
8520 let recovered = tokio::time::timeout(
8521 Duration::from_secs(5),
8522 state
8523 .finalizer
8524 .recover_spooled_sessions(&state.sessions, &state.config.breaker),
8525 )
8526 .await
8527 .unwrap();
8528 let mut minted_after_new_traffic = false;
8529 if recovered.is_ok() {
8530 let app = build_router(state.clone());
8534 let chat = app.clone().oneshot(chat_request(session_id)).await.unwrap();
8535 axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8536 let _ = app
8537 .clone()
8538 .oneshot(
8539 Request::builder()
8540 .method("POST")
8541 .uri("/v1/sessions/orphan-uncertain/close")
8542 .body(Body::empty())
8543 .unwrap(),
8544 )
8545 .await
8546 .unwrap();
8547 let _ = app
8548 .oneshot(
8549 Request::builder()
8550 .method("POST")
8551 .uri("/v1/sessions/orphan-uncertain/promote")
8552 .body(Body::empty())
8553 .unwrap(),
8554 )
8555 .await
8556 .unwrap();
8557 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8558 .await
8559 .unwrap();
8560 minted_after_new_traffic = state
8561 .sessions
8562 .get(session_id)
8563 .is_some_and(|session| session.receipt.lock().is_some());
8564 }
8565 provider.abort();
8566 let _ = provider.await;
8567 assert!(!minted_after_new_traffic,
8568 "startup accepted an authenticated orphan intent, then new traffic minted a receipt omitting its uncertain effect");
8569 let error = recovered.expect_err(
8570 "an authenticated unresolved intent without recoverable session metadata must refuse startup",
8571 );
8572 assert!(error.to_string().contains("unresolved tool execution"));
8573 assert_eq!(std::fs::read(&execution.intent_path).unwrap(), intent_before);
8574 assert!(
8575 state.sessions.get(session_id).is_none(),
8576 "recovery must not invent session attribution"
8577 );
8578 }
8579
8580 #[tokio::test]
8581 async fn mcp_durable_orphan_pending_intent_refuses_startup_and_new_receipt() {
8582 assert_orphan_mcp_intent_refuses_startup(false).await;
8583 }
8584
8585 #[tokio::test]
8586 async fn mcp_durable_orphan_unaudited_intent_refuses_startup_and_new_receipt() {
8587 assert_orphan_mcp_intent_refuses_startup(true).await;
8588 }
8589
8590 async fn assert_evicted_mcp_intent_refuses_receipt(workflow: &str) {
8591 let directory = tempfile::tempdir().unwrap();
8592 let (state, provider) = test_state(directory.path()).await;
8593 let session_id = "evicted-uncertain";
8594 let identity = av_events::AgentIdentity {
8595 version: "dev".to_owned(),
8596 charter: "anonymous".into(),
8597 instance_uid: "anonymous".to_owned(),
8598 ttl_remaining_s: None,
8599 };
8600 let original = state.sessions.get_or_open(
8601 session_id,
8602 crate::session::Workflow::parse(workflow).unwrap(),
8603 &identity,
8604 &state.config.breaker,
8605 );
8606 let mut headers = HeaderMap::new();
8607 headers.insert("x-av-session", HeaderValue::from_static("evicted-uncertain"));
8608 let body = br#"{"jsonrpc":"2.0","id":"evicted","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8609 let mut execution =
8610 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8611 .unwrap();
8612 execution.bind_principal(&identity).unwrap();
8613 execution.claim().await.unwrap();
8614 let intent_before = std::fs::read(&execution.intent_path).unwrap();
8615 original.mark_capture_failed();
8616 state.sessions.remove(session_id);
8619 let app = build_router(state.clone());
8620 let request = Request::builder()
8621 .method("POST")
8622 .uri("/v1/chat/completions")
8623 .header("content-type", "application/json")
8624 .header("x-av-session", session_id)
8625 .header("x-av-workflow", workflow)
8626 .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
8627 .unwrap();
8628 let chat = app.clone().oneshot(request).await.unwrap();
8629 assert_eq!(chat.status(), StatusCode::OK);
8630 axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8631 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8632 .await
8633 .unwrap();
8634 let fresh = state.sessions.get(session_id).unwrap();
8635 assert!(!Arc::ptr_eq(&original, &fresh));
8636 assert!(
8637 !fresh.capture_failed(),
8638 "the durable guard cannot rely on the old RAM flag"
8639 );
8640 let close = app
8641 .clone()
8642 .oneshot(
8643 Request::builder()
8644 .method("POST")
8645 .uri("/v1/sessions/evicted-uncertain/close")
8646 .body(Body::empty())
8647 .unwrap(),
8648 )
8649 .await
8650 .unwrap()
8651 .status();
8652 let promote = app
8653 .oneshot(
8654 Request::builder()
8655 .method("POST")
8656 .uri("/v1/sessions/evicted-uncertain/promote")
8657 .body(Body::empty())
8658 .unwrap(),
8659 )
8660 .await
8661 .unwrap()
8662 .status();
8663 provider.abort();
8664 let _ = provider.await;
8665 assert!(
8666 fresh.receipt.lock().is_none(),
8667 "evicting the RAM quarantine must not permit a receipt omitting the durable uncertain effect"
8668 );
8669 assert_eq!(close, StatusCode::SERVICE_UNAVAILABLE);
8670 assert!(!promote.is_success());
8671 assert_eq!(std::fs::read(&execution.intent_path).unwrap(), intent_before);
8672 assert!(
8673 !fresh.capture_failed(),
8674 "the durable refusal must preserve retryable audit completion"
8675 );
8676 }
8677
8678 #[tokio::test]
8679 async fn mcp_durable_evicted_signed_quarantine_cannot_mint_receipt() {
8680 assert_evicted_mcp_intent_refuses_receipt("signed").await;
8681 }
8682
8683 #[tokio::test]
8684 async fn mcp_durable_evicted_unsigned_quarantine_cannot_promote() {
8685 assert_evicted_mcp_intent_refuses_receipt("unsigned").await;
8686 }
8687
8688 #[tokio::test]
8689 async fn mcp_durable_closed_unsigned_artifact_checks_durable_intent_before_promotion() {
8690 let directory = tempfile::tempdir().unwrap();
8691 let (state, provider) = test_state(directory.path()).await;
8692 let app = build_router(state.clone());
8693 let chat = app
8694 .clone()
8695 .oneshot(chat_request("closed-uncertain"))
8696 .await
8697 .unwrap();
8698 axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8699 let close = app
8700 .clone()
8701 .oneshot(
8702 Request::builder()
8703 .method("POST")
8704 .uri("/v1/sessions/closed-uncertain/close")
8705 .body(Body::empty())
8706 .unwrap(),
8707 )
8708 .await
8709 .unwrap();
8710 assert_eq!(close.status(), StatusCode::OK);
8711 let session = state.sessions.get("closed-uncertain").unwrap();
8712 assert!(session.is_closed());
8713 assert!(session.receipt.lock().is_none());
8714 let mut headers = HeaderMap::new();
8715 headers.insert("x-av-session", HeaderValue::from_static("closed-uncertain"));
8716 let body = br#"{"jsonrpc":"2.0","id":"closed","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8717 let mut execution =
8718 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8719 .unwrap();
8720 execution.bind_principal(&session.current_identity()).unwrap();
8721 execution.claim().await.unwrap();
8722 let before = std::fs::read(&execution.intent_path).unwrap();
8723 let promotion = app
8724 .oneshot(
8725 Request::builder()
8726 .method("POST")
8727 .uri("/v1/sessions/closed-uncertain/promote")
8728 .body(Body::empty())
8729 .unwrap(),
8730 )
8731 .await
8732 .unwrap()
8733 .status();
8734 provider.abort();
8735 let _ = provider.await;
8736 assert!(
8737 session.receipt.lock().is_none(),
8738 "a closed unsigned artifact must not bypass the durable uncertainty guard"
8739 );
8740 assert_eq!(promotion, StatusCode::SERVICE_UNAVAILABLE);
8741 assert_eq!(std::fs::read(&execution.intent_path).unwrap(), before);
8742 assert!(!session.capture_failed());
8743 }
8744
8745 #[tokio::test]
8746 async fn mcp_durable_missing_audit_marker_keeps_finalization_retryable() {
8747 let directory = tempfile::tempdir().unwrap();
8748 let (state, provider) = test_state(directory.path()).await;
8749 let app = build_router(state.clone());
8750 let chat = app.clone().oneshot(chat_request("audit-retry")).await.unwrap();
8751 axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8752 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8753 .await
8754 .unwrap();
8755 let session = state.sessions.get("audit-retry").unwrap();
8756 let mut headers = HeaderMap::new();
8757 headers.insert("x-av-session", HeaderValue::from_static("audit-retry"));
8758 let body = br#"{"jsonrpc":"2.0","id":"audit","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8759 let mut execution =
8760 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8761 .unwrap();
8762 execution.bind_principal(&session.current_identity()).unwrap();
8763 execution.claim().await.unwrap();
8764 execution
8765 .persist(&ToolOutcome {
8766 status: 200,
8767 body_hex: hex::encode(b"captured outcome"),
8768 content_type: None,
8769 })
8770 .await
8771 .unwrap();
8772 let close_request = || {
8773 Request::builder()
8774 .method("POST")
8775 .uri("/v1/sessions/audit-retry/close")
8776 .body(Body::empty())
8777 .unwrap()
8778 };
8779 let first = app.clone().oneshot(close_request()).await.unwrap().status();
8780 let refused_without_poison = !session.capture_failed()
8781 && !session.is_closed()
8782 && execution.intent_path.exists()
8783 && execution.outcome_path.exists();
8784 execution.mark_audited().await.unwrap();
8786 let second = app.clone().oneshot(close_request()).await.unwrap().status();
8787 let promotion = app
8788 .oneshot(
8789 Request::builder()
8790 .method("POST")
8791 .uri("/v1/sessions/audit-retry/promote")
8792 .body(Body::empty())
8793 .unwrap(),
8794 )
8795 .await
8796 .unwrap()
8797 .status();
8798 provider.abort();
8799 let _ = provider.await;
8800 assert_eq!(first, StatusCode::SERVICE_UNAVAILABLE);
8801 assert!(
8802 refused_without_poison,
8803 "an unfinished audit must remain retryable with its evidence intact"
8804 );
8805 assert_eq!(second, StatusCode::OK);
8806 assert_eq!(promotion, StatusCode::OK);
8807 assert!(session.receipt.lock().is_some());
8808 assert!(!session.capture_failed());
8809 }
8810
8811 #[tokio::test]
8812 async fn mcp_durable_lifecycle_scan_io_failure_preserves_evidence_and_can_retry() {
8813 let directory = tempfile::tempdir().unwrap();
8814 let (state, provider) = test_state(directory.path()).await;
8815 let app = build_router(state.clone());
8816 let chat = app.clone().oneshot(chat_request("scan-error")).await.unwrap();
8817 axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8818 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8819 .await
8820 .unwrap();
8821 let session = state.sessions.get("scan-error").unwrap();
8822 let execution_dir = directory.path().join(crate::spool::TOOL_EXECUTIONS);
8823 let evidence = b"operator recovery evidence at an invalid directory path";
8824 std::fs::write(&execution_dir, evidence).unwrap();
8825 let close_request = || {
8826 Request::builder()
8827 .method("POST")
8828 .uri("/v1/sessions/scan-error/close")
8829 .body(Body::empty())
8830 .unwrap()
8831 };
8832 let failed = app.clone().oneshot(close_request()).await.unwrap().status();
8833 let retained = std::fs::read(&execution_dir).unwrap() == evidence;
8834 let issued_on_failure = session.receipt.lock().is_some() || session.atif_path.lock().is_some();
8835 let retryable = !session.capture_failed() && !session.is_closed();
8836 std::fs::remove_file(&execution_dir).unwrap();
8837 let repaired = app.oneshot(close_request()).await.unwrap().status();
8838 provider.abort();
8839 let _ = provider.await;
8840 assert_eq!(failed, StatusCode::SERVICE_UNAVAILABLE);
8841 assert!(retained);
8842 assert!(!issued_on_failure);
8843 assert!(retryable);
8844 assert_eq!(repaired, StatusCode::OK);
8845 }
8846
8847 #[test]
8848 fn mcp_durable_readonly_scan_preserves_evidence_on_cap_and_authentication_errors() {
8849 let directory = tempfile::tempdir().unwrap();
8850 for name in ["one.evidence", "two.evidence", "three.evidence"] {
8851 std::fs::write(directory.path().join(name), b"retained evidence").unwrap();
8852 }
8853 let capped = ensure_tool_executions_resolved_with_limit(directory.path(), "session", &[7; 32], 2);
8854 assert!(capped.unwrap_err().contains("bounded scan limit"));
8855 for name in ["one.evidence", "two.evidence", "three.evidence"] {
8856 assert_eq!(
8857 std::fs::read(directory.path().join(name)).unwrap(),
8858 b"retained evidence"
8859 );
8860 }
8861 let writing_claim = directory.path().join("partial.intent.json");
8862 std::fs::write(&writing_claim, b"unfinished concurrent claim").unwrap();
8863 let unauthenticated =
8864 ensure_tool_executions_resolved_with_limit(directory.path(), "session", &[7; 32], 10);
8865 assert!(unauthenticated.unwrap_err().contains("authenticated"));
8866 assert_eq!(
8867 std::fs::read(writing_claim).unwrap(),
8868 b"unfinished concurrent claim"
8869 );
8870 assert_eq!(
8871 std::fs::read_dir(directory.path()).unwrap().count(),
8872 4,
8873 "the lifecycle scan must never rename or delete another live request's incomplete files"
8874 );
8875 }
8876
8877 struct WithheldHeadersToolState {
8878 effects: std::sync::atomic::AtomicUsize,
8879 release: tokio::sync::Notify,
8880 returned: std::sync::atomic::AtomicBool,
8881 terminated: std::sync::atomic::AtomicBool,
8882 }
8883
8884 struct WithheldHeadersGuard(Arc<WithheldHeadersToolState>);
8885
8886 impl Drop for WithheldHeadersGuard {
8887 fn drop(&mut self) {
8888 self.0
8889 .terminated
8890 .store(true, std::sync::atomic::Ordering::Release);
8891 }
8892 }
8893
8894 async fn tool_effect_before_headers(State(state): State<Arc<WithheldHeadersToolState>>) -> Response {
8895 let _guard = WithheldHeadersGuard(Arc::clone(&state));
8896 state.effects.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
8899 let _ = tokio::time::timeout(Duration::from_secs(6), state.release.notified()).await;
8900 state.returned.store(true, std::sync::atomic::Ordering::Release);
8901 Json(json!({"ok": true})).into_response()
8902 }
8903
8904 async fn assert_mcp_header_deadline(workflow: &str, cancel_caller: bool, restart: bool) {
8905 let directory = tempfile::tempdir().unwrap();
8906 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8907 let address = listener.local_addr().unwrap();
8908 let observed = Arc::new(WithheldHeadersToolState {
8909 effects: std::sync::atomic::AtomicUsize::new(0),
8910 release: tokio::sync::Notify::new(),
8911 returned: std::sync::atomic::AtomicBool::new(false),
8912 terminated: std::sync::atomic::AtomicBool::new(false),
8913 });
8914 let tool = Router::new()
8915 .route("/mcp", post(tool_effect_before_headers))
8916 .with_state(Arc::clone(&observed));
8917 let server = tokio::spawn(async move { axum::serve(listener, tool).await.unwrap() });
8918 let mut config = crate::config::HarnessConfig::for_tests(
8919 &format!("http://{address}"),
8920 &directory.path().to_string_lossy(),
8921 &directory.path().to_string_lossy(),
8922 );
8923 config.default_workflow = workflow.to_owned();
8924 config.tool_upstream_url = Some(format!("http://{address}/mcp"));
8925 config.upstream_read_timeout_s = Some(10);
8927 config.mcp_request_timeout_s = Some(1);
8928 config.mcp_concurrency = 1;
8929 config.budget.max_total_tool_calls = Some(2);
8930 config.budget.max_tool_calls.insert("read".into(), 2);
8931 config.principal_budget = Some(config.budget.clone());
8932 config.allow_anonymous_principal_budget = true;
8933 let make_state = |store: Arc<InMemoryStore>| {
8934 AppState::new(
8935 config.clone(),
8936 store,
8937 Arc::new(
8938 Sandbox::new(
8939 SandboxConfig {
8940 budget: config.budget.clone(),
8941 ..SandboxConfig::default()
8942 },
8943 Vec::new(),
8944 )
8945 .unwrap(),
8946 ),
8947 Arc::new(NullBus),
8948 None,
8949 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
8950 )
8951 .unwrap()
8952 };
8953 let store = Arc::new(InMemoryStore::new());
8954 let state = make_state(Arc::clone(&store));
8955 let app = build_router(state.clone());
8956 let body = br#"{"jsonrpc":"2.0","id":"headers","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8957 let request = || {
8958 Request::builder()
8959 .method("POST")
8960 .uri("/v1/mcp")
8961 .header("content-type", "application/json")
8962 .header("x-av-session", "withheld-headers")
8963 .body(Body::from(Bytes::from_static(body)))
8964 .unwrap()
8965 };
8966 let first = tokio::spawn(app.clone().oneshot(request()));
8967 tokio::time::timeout(Duration::from_secs(5), async {
8968 while observed.effects.load(std::sync::atomic::Ordering::Acquire) == 0 {
8969 tokio::time::sleep(Duration::from_millis(10)).await;
8970 }
8971 })
8972 .await
8973 .unwrap();
8974 if cancel_caller {
8975 first.abort();
8976 }
8977 tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
8978 .await
8979 .unwrap();
8980 let first_status = if cancel_caller {
8981 assert!(first.await.unwrap_err().is_cancelled());
8982 None
8983 } else {
8984 Some(first.await.unwrap().unwrap().status())
8985 };
8986 assert!(!observed.returned.load(std::sync::atomic::Ordering::Acquire));
8987 let retry = app.clone().oneshot(request()).await.unwrap();
8988 let retry_status = retry.status();
8989 let retry_body = axum::body::to_bytes(retry.into_body(), 64 * 1024).await.unwrap();
8990 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8991 .await
8992 .unwrap();
8993 let session = state.sessions.get("withheld-headers").unwrap();
8994 let mut headers = HeaderMap::new();
8995 headers.insert("x-av-session", HeaderValue::from_static("withheld-headers"));
8996 let mut execution =
8997 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8998 .unwrap();
8999 execution.bind_principal(&session.current_identity()).unwrap();
9000 assert!(matches!(
9001 execution.load().await.unwrap(),
9002 ToolExecutionState::Pending
9003 ));
9004 assert!(execution.intent_path.exists());
9005 assert!(!execution.outcome_path.exists());
9006 assert!(!execution.audited_path.exists());
9007 for prefix in [
9008 av_state::ActionBudget::session_prefix("withheld-headers"),
9009 av_state::ActionBudget::principal_prefix("anonymous"),
9010 ] {
9011 for suffix in ["total_calls", "tool:read"] {
9012 assert_eq!(
9013 store.get(&format!("{prefix}{suffix}")).unwrap(),
9014 1,
9015 "uncertain execution must retain its charge without charging the refused retry"
9016 );
9017 }
9018 }
9019 assert_eq!(state.mcp_inflight.count(), 0);
9020 assert_eq!(state.mcp_admission.available_permits(), 1);
9021 assert_eq!(session.active_streams_count(), 0);
9022 assert_eq!(session.pending_jobs_count(), 0);
9023 drop(session);
9024 drop(app);
9025 let lifecycle_state = if restart {
9028 drop(state);
9029 let recovered = make_state(Arc::new(InMemoryStore::new()));
9030 recovered
9031 .finalizer
9032 .recover_spooled_sessions(&recovered.sessions, &recovered.config.breaker)
9033 .await
9034 .unwrap();
9035 assert!(recovered
9036 .sessions
9037 .get("withheld-headers")
9038 .unwrap()
9039 .capture_failed());
9040 recovered
9041 } else {
9042 state
9043 };
9044 let lifecycle_app = build_router(lifecycle_state.clone());
9045 let replay = lifecycle_app.clone().oneshot(request()).await.unwrap();
9046 let replay_status = replay.status();
9047 let close = lifecycle_app
9048 .clone()
9049 .oneshot(
9050 Request::builder()
9051 .method("POST")
9052 .uri("/v1/sessions/withheld-headers/close")
9053 .body(Body::empty())
9054 .unwrap(),
9055 )
9056 .await
9057 .unwrap();
9058 let close_status = close.status();
9059 let close_body = axum::body::to_bytes(close.into_body(), 64 * 1024).await.unwrap();
9060 let promote = lifecycle_app
9061 .clone()
9062 .oneshot(
9063 Request::builder()
9064 .method("POST")
9065 .uri("/v1/sessions/withheld-headers/promote")
9066 .body(Body::empty())
9067 .unwrap(),
9068 )
9069 .await
9070 .unwrap();
9071 let promote_status = promote.status();
9072 tokio::time::timeout(Duration::from_secs(5), lifecycle_state.worker.wait_idle())
9073 .await
9074 .unwrap();
9075 let session = lifecycle_state.sessions.get("withheld-headers").unwrap();
9076 let receipt_minted = session.receipt.lock().is_some();
9077 let retained_intent_after_close = execution.intent_path.exists();
9078 let retry_after_close = lifecycle_app.clone().oneshot(request()).await.unwrap().status();
9079 drop(session);
9080 drop(lifecycle_app);
9081 drop(lifecycle_state);
9082 let recovered_again = make_state(Arc::new(InMemoryStore::new()));
9083 tokio::time::timeout(
9084 Duration::from_secs(5),
9085 recovered_again
9086 .finalizer
9087 .recover_spooled_sessions(&recovered_again.sessions, &recovered_again.config.breaker),
9088 )
9089 .await
9090 .unwrap()
9091 .unwrap();
9092 let session = recovered_again.sessions.get("withheld-headers").unwrap();
9093 let still_quarantined = session.capture_failed();
9094 let recovered_receipt = session.receipt.lock().is_some();
9095 let recovered_app = build_router(recovered_again.clone());
9096 let retry_after_restart = recovered_app.clone().oneshot(request()).await.unwrap().status();
9097 let close_after_restart = recovered_app
9098 .clone()
9099 .oneshot(
9100 Request::builder()
9101 .method("POST")
9102 .uri("/v1/sessions/withheld-headers/close")
9103 .body(Body::empty())
9104 .unwrap(),
9105 )
9106 .await
9107 .unwrap();
9108 let close_after_restart_status = close_after_restart.status();
9109 let close_after_restart_body = axum::body::to_bytes(close_after_restart.into_body(), 64 * 1024)
9110 .await
9111 .unwrap();
9112 let promote_after_restart = recovered_app
9113 .oneshot(
9114 Request::builder()
9115 .method("POST")
9116 .uri("/v1/sessions/withheld-headers/promote")
9117 .body(Body::empty())
9118 .unwrap(),
9119 )
9120 .await
9121 .unwrap()
9122 .status();
9123 tokio::time::timeout(Duration::from_secs(5), recovered_again.worker.wait_idle())
9124 .await
9125 .unwrap();
9126 observed.release.notify_one();
9129 tokio::time::timeout(Duration::from_secs(2), async {
9130 while !observed.terminated.load(std::sync::atomic::Ordering::Acquire) {
9131 tokio::time::sleep(Duration::from_millis(10)).await;
9132 }
9133 })
9134 .await
9135 .unwrap();
9136 server.abort();
9137 let _ = server.await;
9138
9139 if let Some(status) = first_status {
9140 assert_eq!(status, StatusCode::BAD_GATEWAY);
9141 }
9142 assert_eq!(retry_status, StatusCode::CONFLICT);
9143 assert!(String::from_utf8_lossy(&retry_body).contains(TOOL_OUTCOME_UNCERTAIN));
9144 assert_eq!(replay_status, StatusCode::CONFLICT);
9145 assert_eq!(observed.effects.load(std::sync::atomic::Ordering::Acquire), 1);
9146 assert!(
9147 !receipt_minted,
9148 "uncertain tool execution must never mint a complete receipt"
9149 );
9150 assert_eq!(promote_status, StatusCode::CONFLICT);
9151 assert!(
9152 retained_intent_after_close,
9153 "closing a quarantine must retain the authenticated uncertain intent"
9154 );
9155 assert_eq!(retry_after_close, StatusCode::CONFLICT);
9156 assert!(
9157 still_quarantined,
9158 "quarantine must survive closing and another restart"
9159 );
9160 assert!(
9161 !recovered_receipt,
9162 "restart must not turn an uncertain execution into a complete receipt"
9163 );
9164 assert_eq!(retry_after_restart, StatusCode::CONFLICT);
9165 assert_eq!(promote_after_restart, StatusCode::CONFLICT);
9166 if close_after_restart_status == StatusCode::OK {
9167 assert_eq!(
9168 serde_json::from_slice::<Value>(&close_after_restart_body).unwrap(),
9169 json!({"kind": "already_closed"})
9170 );
9171 } else {
9172 assert_eq!(close_after_restart_status, StatusCode::CONFLICT);
9173 }
9174 assert!(session.receipt.lock().is_none());
9175 assert!(session.atif_path.lock().is_none());
9176 if restart && close_status == StatusCode::OK {
9179 assert_eq!(
9180 serde_json::from_slice::<Value>(&close_body).unwrap(),
9181 json!({"kind": "already_closed"})
9182 );
9183 } else {
9184 assert_eq!(close_status, StatusCode::CONFLICT);
9185 }
9186 }
9187
9188 #[tokio::test]
9189 async fn mcp_header_deadline_refuses_live_signed_receipt() {
9190 assert_mcp_header_deadline("signed", false, false).await;
9191 }
9192
9193 #[tokio::test]
9194 async fn mcp_header_deadline_refuses_live_unsigned_promotion() {
9195 assert_mcp_header_deadline("unsigned", false, false).await;
9196 }
9197
9198 #[tokio::test]
9199 async fn mcp_header_deadline_cancellation_keeps_live_intent() {
9200 assert_mcp_header_deadline("unsigned", true, false).await;
9201 }
9202
9203 #[tokio::test]
9204 async fn mcp_header_deadline_restart_quarantines_signed_session() {
9205 assert_mcp_header_deadline("signed", false, true).await;
9206 }
9207
9208 #[tokio::test]
9209 async fn mcp_header_deadline_cancel_then_restart_quarantines_unsigned_session() {
9210 assert_mcp_header_deadline("unsigned", true, true).await;
9211 }
9212
9213 #[tokio::test]
9214 async fn mcp_outcome_persist_failure_refuses_receipt_and_keeps_charge() {
9215 let directory = tempfile::tempdir().unwrap();
9216 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9217 let address = listener.local_addr().unwrap();
9218 let observed = Arc::new(WithheldHeadersToolState {
9219 effects: std::sync::atomic::AtomicUsize::new(0),
9220 release: tokio::sync::Notify::new(),
9221 returned: std::sync::atomic::AtomicBool::new(false),
9222 terminated: std::sync::atomic::AtomicBool::new(false),
9223 });
9224 let tool = Router::new()
9225 .route("/mcp", post(tool_effect_before_headers))
9226 .with_state(Arc::clone(&observed));
9227 let server = tokio::spawn(async move { axum::serve(listener, tool).await.unwrap() });
9228 let mut config = crate::config::HarnessConfig::for_tests(
9229 &format!("http://{address}"),
9230 &directory.path().to_string_lossy(),
9231 &directory.path().to_string_lossy(),
9232 );
9233 config.default_workflow = "signed".to_owned();
9234 config.tool_upstream_url = Some(format!("http://{address}/mcp"));
9235 config.mcp_request_timeout_s = Some(5);
9236 config.mcp_concurrency = 1;
9237 config.budget.max_total_tool_calls = Some(2);
9238 config.principal_budget = Some(config.budget.clone());
9239 config.allow_anonymous_principal_budget = true;
9240 let store = Arc::new(InMemoryStore::new());
9241 let sandbox = Arc::new(
9242 Sandbox::new(
9243 SandboxConfig {
9244 budget: config.budget.clone(),
9245 ..SandboxConfig::default()
9246 },
9247 Vec::new(),
9248 )
9249 .unwrap(),
9250 );
9251 let state = AppState::new(
9252 config,
9253 store.clone(),
9254 sandbox,
9255 Arc::new(NullBus),
9256 None,
9257 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
9258 )
9259 .unwrap();
9260 let app = build_router(state.clone());
9261 let body = br#"{"jsonrpc":"2.0","id":"persist","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
9262 let request = || {
9263 Request::builder()
9264 .method("POST")
9265 .uri("/v1/mcp")
9266 .header("x-av-session", "persist-failure")
9267 .body(Body::from(Bytes::from_static(body)))
9268 .unwrap()
9269 };
9270 let first = tokio::spawn(app.clone().oneshot(request()));
9271 tokio::time::timeout(Duration::from_secs(5), async {
9272 while observed.effects.load(std::sync::atomic::Ordering::Acquire) == 0 {
9273 tokio::time::sleep(Duration::from_millis(10)).await;
9274 }
9275 })
9276 .await
9277 .unwrap();
9278 let session = state.sessions.get("persist-failure").unwrap();
9279 let mut headers = HeaderMap::new();
9280 headers.insert("x-av-session", HeaderValue::from_static("persist-failure"));
9281 let mut execution =
9282 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
9283 .unwrap();
9284 execution.bind_principal(&session.current_identity()).unwrap();
9285 std::fs::create_dir(&execution.outcome_path).unwrap();
9288 observed.release.notify_one();
9289 let first_status = tokio::time::timeout(Duration::from_secs(5), first)
9290 .await
9291 .unwrap()
9292 .unwrap()
9293 .unwrap()
9294 .status();
9295 tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
9296 .await
9297 .unwrap();
9298 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9299 .await
9300 .unwrap();
9301 std::fs::remove_dir(&execution.outcome_path).unwrap();
9302 assert!(matches!(
9303 execution.load().await.unwrap(),
9304 ToolExecutionState::Pending
9305 ));
9306 let capture_failed = session.capture_failed();
9307 let retry_status = app.clone().oneshot(request()).await.unwrap().status();
9308 for prefix in [
9309 av_state::ActionBudget::session_prefix("persist-failure"),
9310 av_state::ActionBudget::principal_prefix("anonymous"),
9311 ] {
9312 assert_eq!(store.get(&format!("{prefix}total_calls")).unwrap(), 1);
9313 }
9314 let close = app
9315 .clone()
9316 .oneshot(
9317 Request::builder()
9318 .method("POST")
9319 .uri("/v1/sessions/persist-failure/close")
9320 .body(Body::empty())
9321 .unwrap(),
9322 )
9323 .await
9324 .unwrap()
9325 .status();
9326 let promote = app
9327 .oneshot(
9328 Request::builder()
9329 .method("POST")
9330 .uri("/v1/sessions/persist-failure/promote")
9331 .body(Body::empty())
9332 .unwrap(),
9333 )
9334 .await
9335 .unwrap()
9336 .status();
9337 server.abort();
9338 let _ = server.await;
9339 assert_eq!(first_status, StatusCode::INTERNAL_SERVER_ERROR);
9340 assert_eq!(retry_status, StatusCode::CONFLICT);
9341 assert_eq!(observed.effects.load(std::sync::atomic::Ordering::Acquire), 1);
9342 assert!(
9343 capture_failed,
9344 "an executed tool whose outcome cannot persist must poison complete evidence"
9345 );
9346 assert_eq!(close, StatusCode::CONFLICT);
9347 assert_eq!(promote, StatusCode::CONFLICT);
9348 assert!(session.receipt.lock().is_none());
9349 assert!(execution.intent_path.exists());
9350 assert!(!execution.outcome_path.exists());
9351 assert!(!execution.audited_path.exists());
9352 assert_eq!(state.mcp_inflight.count(), 0);
9353 assert_eq!(state.mcp_admission.available_permits(), 1);
9354 assert_eq!(session.active_streams_count(), 0);
9355 assert_eq!(session.pending_jobs_count(), 0);
9356 }
9357
9358 #[tokio::test]
9359 async fn mcp_quarantine_retains_only_unresolved_execution_evidence() {
9360 let directory = tempfile::tempdir().unwrap();
9361 let (state, provider) = test_state(directory.path()).await;
9362 let app = build_router(state.clone());
9363 let opened = app.clone().oneshot(chat_request("retention")).await.unwrap();
9364 assert_eq!(opened.status(), StatusCode::OK);
9365 axum::body::to_bytes(opened.into_body(), 64 * 1024).await.unwrap();
9366 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9367 .await
9368 .unwrap();
9369 let session = state.sessions.get("retention").unwrap();
9370 let mut headers = HeaderMap::new();
9371 headers.insert("x-av-session", HeaderValue::from_static("retention"));
9372 let outcome = ToolOutcome {
9373 status: 502,
9374 body_hex: hex::encode(b"partial failure evidence"),
9375 content_type: Some("text/plain".to_owned()),
9376 };
9377 let mut executions = Vec::new();
9378 for kind in [
9379 "pending",
9380 "unaudited",
9381 "corrupt-outcome",
9382 "corrupt-audit",
9383 "completed",
9384 ] {
9385 let body = serde_json::to_vec(&json!({
9386 "jsonrpc": "2.0", "id": kind, "method": "tools/call",
9387 "params": {"name": "read", "arguments": {}}
9388 }))
9389 .unwrap();
9390 let mut execution =
9391 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, &body, state.journal_key)
9392 .unwrap();
9393 execution.bind_principal(&session.current_identity()).unwrap();
9394 execution.claim().await.unwrap();
9395 if kind != "pending" {
9396 execution.persist(&outcome).await.unwrap();
9397 }
9398 if matches!(kind, "completed" | "corrupt-outcome" | "corrupt-audit") {
9399 execution.mark_audited().await.unwrap();
9400 }
9401 if kind == "corrupt-outcome" {
9402 std::fs::write(&execution.outcome_path, b"not an authenticated outcome").unwrap();
9403 }
9404 if kind == "corrupt-audit" {
9405 std::fs::write(&execution.audited_path, b"not an authenticated audit marker").unwrap();
9406 }
9407 let intent_before = std::fs::read(&execution.intent_path).unwrap();
9408 let outcome_before = std::fs::read(&execution.outcome_path).ok();
9409 executions.push((kind, execution, intent_before, outcome_before));
9410 }
9411 session.mark_capture_failed();
9412 let refused = app
9413 .clone()
9414 .oneshot(
9415 Request::builder()
9416 .method("POST")
9417 .uri("/v1/sessions/retention/close")
9418 .body(Body::empty())
9419 .unwrap(),
9420 )
9421 .await
9422 .unwrap()
9423 .status();
9424 let other = app.clone().oneshot(chat_request("other-complete")).await.unwrap();
9427 let other_status = other.status();
9428 axum::body::to_bytes(other.into_body(), 64 * 1024).await.unwrap();
9429 let other_close = app
9430 .clone()
9431 .oneshot(
9432 Request::builder()
9433 .method("POST")
9434 .uri("/v1/sessions/other-complete/close")
9435 .body(Body::empty())
9436 .unwrap(),
9437 )
9438 .await
9439 .unwrap()
9440 .status();
9441 let other_promote = app
9442 .oneshot(
9443 Request::builder()
9444 .method("POST")
9445 .uri("/v1/sessions/other-complete/promote")
9446 .body(Body::empty())
9447 .unwrap(),
9448 )
9449 .await
9450 .unwrap()
9451 .status();
9452 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9453 .await
9454 .unwrap();
9455 provider.abort();
9456 let _ = provider.await;
9457 assert_eq!(refused, StatusCode::CONFLICT);
9458 assert!(session.receipt.lock().is_none());
9459 assert_eq!(other_status, StatusCode::OK);
9460 assert_eq!(other_close, StatusCode::OK);
9461 assert_eq!(other_promote, StatusCode::OK);
9462 for (kind, execution, intent_before, outcome_before) in executions {
9463 if kind == "completed" {
9464 assert!(!execution.intent_path.exists());
9465 assert!(!execution.outcome_path.exists());
9466 assert!(!execution.audited_path.exists());
9467 let archived_intent = std::fs::read_dir(execution.intent_path.parent().unwrap())
9468 .unwrap()
9469 .filter_map(Result::ok)
9470 .find(|entry| {
9471 entry
9472 .file_name()
9473 .to_string_lossy()
9474 .starts_with(&format!("{}.intent.json.capturefailed-", execution.key))
9475 })
9476 .expect("completed evidence must be archived, not deleted");
9477 assert_eq!(std::fs::read(archived_intent.path()).unwrap(), intent_before);
9478 } else {
9479 assert_eq!(
9480 std::fs::read(&execution.intent_path).unwrap(),
9481 intent_before,
9482 "{kind} must retain its authenticated primary intent"
9483 );
9484 assert_eq!(
9485 std::fs::read(&execution.outcome_path).ok(),
9486 outcome_before,
9487 "{kind} must preserve all partial outcome evidence"
9488 );
9489 }
9490 }
9491 }
9492
9493 async fn slow_active_chat() -> Response {
9494 let frames = futures::stream::unfold(0u8, |index| async move {
9495 if index == 7 {
9496 return None;
9497 }
9498 if index != 0 {
9499 tokio::time::sleep(Duration::from_millis(250)).await;
9500 }
9501 let frame = if index == 6 {
9502 format!(
9503 "data: {}\n\ndata: [DONE]\n\n",
9504 json!({
9505 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
9506 "usage": {"prompt_tokens": 2, "completion_tokens": 6}
9507 })
9508 )
9509 } else {
9510 format!(
9513 "data: {}\n\n",
9514 json!({
9515 "choices": [{"index": 0, "delta": {"content": format!("chat exceeds MCP {}", "x".repeat(280))}, "finish_reason": null}]
9516 })
9517 )
9518 };
9519 Some((Ok::<_, std::convert::Infallible>(Bytes::from(frame)), index + 1))
9520 });
9521 let mut response = Response::new(Body::from_stream(frames));
9522 response.headers_mut().insert(
9523 axum::http::header::CONTENT_TYPE,
9524 HeaderValue::from_static("text/event-stream"),
9525 );
9526 response
9527 }
9528
9529 #[tokio::test]
9530 async fn mcp_total_deadline_does_not_limit_active_chat_streams() {
9531 let directory = tempfile::tempdir().unwrap();
9532 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9533 let address = listener.local_addr().unwrap();
9534 let provider = Router::new().route("/v1/chat/completions", post(slow_active_chat));
9535 let server = tokio::spawn(async move { axum::serve(listener, provider).await.unwrap() });
9536 let mut config = crate::config::HarnessConfig::for_tests(
9537 &format!("http://{address}"),
9538 &directory.path().to_string_lossy(),
9539 &directory.path().to_string_lossy(),
9540 );
9541 config.upstream_read_timeout_s = Some(1);
9542 config.mcp_request_timeout_s = Some(1);
9543 let state = AppState::new(
9544 config,
9545 Arc::new(InMemoryStore::new()),
9546 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
9547 Arc::new(NullBus),
9548 None,
9549 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
9550 )
9551 .unwrap();
9552 let started = std::time::Instant::now();
9553 let response = build_router(state.clone())
9554 .oneshot(chat_request_with_payload("chat-idle-boundary", chat_payload()))
9555 .await
9556 .unwrap();
9557 let status = response.status();
9558 let body = tokio::time::timeout(
9559 Duration::from_secs(5),
9560 axum::body::to_bytes(response.into_body(), 64 * 1024),
9561 )
9562 .await
9563 .unwrap()
9564 .unwrap();
9565 let elapsed = started.elapsed();
9566 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9567 .await
9568 .unwrap();
9569 server.abort();
9570 let _ = server.await;
9571 let text = String::from_utf8(body.to_vec()).unwrap();
9572 assert_eq!(status, StatusCode::OK);
9573 assert!(elapsed > Duration::from_secs(1));
9574 assert_eq!(text.matches("chat exceeds MCP").count(), 6);
9575 assert!(text.contains("\"finish_reason\":\"stop\""));
9576 assert!(text.contains("data: [DONE]"));
9577 assert!(!text.contains("\"error\""));
9578 let session = state.sessions.get("chat-idle-boundary").unwrap();
9579 assert!(!session.capture_failed());
9580 assert_eq!(session.active_streams_count(), 0);
9581 assert_eq!(session.pending_jobs_count(), 0);
9582 }
9583
9584 struct SlowDripToolState {
9585 calls: std::sync::atomic::AtomicUsize,
9586 chunks: std::sync::atomic::AtomicUsize,
9587 dropped: std::sync::atomic::AtomicBool,
9588 }
9589
9590 struct SlowDripBodyGuard(Arc<SlowDripToolState>);
9591
9592 impl Drop for SlowDripBodyGuard {
9593 fn drop(&mut self) {
9594 self.0.dropped.store(true, std::sync::atomic::Ordering::Release);
9595 }
9596 }
9597
9598 async fn slow_drip_tool(State(state): State<Arc<SlowDripToolState>>) -> Response {
9599 state.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
9600 let chunks = futures::stream::unfold((0u8, SlowDripBodyGuard(state)), |(index, guard)| async move {
9604 if index == 16 {
9605 return None;
9606 }
9607 if index != 0 {
9608 tokio::time::sleep(Duration::from_millis(200)).await;
9609 }
9610 let bytes = match index {
9611 0 => Bytes::from_static(b"{\"ok\":"),
9612 15 => Bytes::from_static(b"true}"),
9613 _ => Bytes::from_static(b" "),
9614 };
9615 guard.0.chunks.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
9616 Some((Ok::<_, std::convert::Infallible>(bytes), (index + 1, guard)))
9617 });
9618 let mut response = Response::new(Body::from_stream(chunks));
9619 response.headers_mut().insert(
9620 axum::http::header::CONTENT_TYPE,
9621 HeaderValue::from_static("application/json"),
9622 );
9623 response
9624 }
9625
9626 async fn assert_mcp_slow_drip_deadline(cancel_caller: bool) {
9627 let directory = tempfile::tempdir().unwrap();
9628 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9629 let address = listener.local_addr().unwrap();
9630 let observed = Arc::new(SlowDripToolState {
9631 calls: std::sync::atomic::AtomicUsize::new(0),
9632 chunks: std::sync::atomic::AtomicUsize::new(0),
9633 dropped: std::sync::atomic::AtomicBool::new(false),
9634 });
9635 let tool = Router::new()
9636 .route("/mcp", post(slow_drip_tool))
9637 .with_state(Arc::clone(&observed));
9638 let server = tokio::spawn(async move { axum::serve(listener, tool).await.unwrap() });
9639 let mut config = crate::config::HarnessConfig::for_tests(
9640 &format!("http://{address}"),
9641 &directory.path().to_string_lossy(),
9642 &directory.path().to_string_lossy(),
9643 );
9644 config.tool_upstream_url = Some(format!("http://{address}/mcp"));
9645 config.upstream_read_timeout_s = Some(1);
9646 config.mcp_request_timeout_s = Some(1);
9647 config.mcp_concurrency = 1;
9648 let state = AppState::new(
9649 config,
9650 Arc::new(InMemoryStore::new()),
9651 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
9652 Arc::new(NullBus),
9653 None,
9654 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
9655 )
9656 .unwrap();
9657 let app = build_router(state.clone());
9658 let body = br#"{"jsonrpc":"2.0","id":"slow-drip","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
9659 let request = || {
9660 Request::builder()
9661 .method("POST")
9662 .uri("/v1/mcp")
9663 .header("content-type", "application/json")
9664 .header("x-av-session", "slow-drip")
9665 .body(Body::from(Bytes::from_static(body)))
9666 .unwrap()
9667 };
9668 let first = tokio::spawn(app.clone().oneshot(request()));
9669 tokio::time::timeout(Duration::from_secs(5), async {
9670 while observed.chunks.load(std::sync::atomic::Ordering::Acquire) < 2 {
9671 tokio::time::sleep(Duration::from_millis(10)).await;
9672 }
9673 })
9674 .await
9675 .unwrap();
9676 if cancel_caller {
9677 first.abort();
9678 }
9679 tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
9682 .await
9683 .unwrap();
9684 let first_status = if cancel_caller {
9685 assert!(first.await.unwrap_err().is_cancelled());
9686 None
9687 } else {
9688 Some(first.await.unwrap().unwrap().status())
9689 };
9690 let retry = app.oneshot(request()).await.unwrap();
9691 let retry_status = retry.status();
9692 let retry_body = axum::body::to_bytes(retry.into_body(), 64 * 1024).await.unwrap();
9693 tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9694 .await
9695 .unwrap();
9696 let session = state.sessions.get("slow-drip").unwrap();
9697 let mut headers = HeaderMap::new();
9698 headers.insert("x-av-session", HeaderValue::from_static("slow-drip"));
9699 let mut execution =
9700 ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
9701 .unwrap();
9702 execution.bind_principal(&session.current_identity()).unwrap();
9703 let audited = matches!(execution.load().await.unwrap(), ToolExecutionState::Completed(_));
9704 let upstream_dropped = tokio::time::timeout(Duration::from_secs(2), async {
9705 while !observed.dropped.load(std::sync::atomic::Ordering::Acquire) {
9706 tokio::time::sleep(Duration::from_millis(10)).await;
9707 }
9708 })
9709 .await
9710 .is_ok();
9711 server.abort();
9712 let _ = server.await;
9713
9714 assert_eq!(
9715 retry_status,
9716 StatusCode::BAD_GATEWAY,
9717 "the total deadline must terminate a response that never exceeds the idle timeout"
9718 );
9719 if let Some(status) = first_status {
9720 assert_eq!(status, retry_status, "the retry must replay the original failure");
9721 }
9722 assert!(String::from_utf8_lossy(&retry_body).contains("response could not be read"));
9723 assert_eq!(
9724 observed.calls.load(std::sync::atomic::Ordering::Acquire),
9725 1,
9726 "retry must not repeat tool effects"
9727 );
9728 assert!(
9729 observed.chunks.load(std::sync::atomic::Ordering::Acquire) < 16,
9730 "the upstream response must be dropped before its finite completion"
9731 );
9732 assert!(upstream_dropped, "timing out must release the upstream body");
9733 assert!(audited, "the timeout outcome must carry its durable audit marker");
9734 assert_eq!(state.mcp_inflight.count(), 0);
9735 assert_eq!(state.mcp_admission.available_permits(), 1);
9736 assert_eq!(session.active_streams_count(), 0);
9737 assert_eq!(session.pending_jobs_count(), 0);
9738 }
9739
9740 #[tokio::test]
9741 async fn mcp_slow_drip_total_deadline_records_failure_once() {
9742 assert_mcp_slow_drip_deadline(false).await;
9743 }
9744
9745 #[tokio::test]
9746 async fn mcp_slow_drip_total_deadline_survives_caller_cancellation() {
9747 assert_mcp_slow_drip_deadline(true).await;
9748 }
9749
9750 #[tokio::test]
9751 async fn tool_redirect_is_terminal_and_never_replays_post() {
9752 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9753 let tool_address = tool_listener.local_addr().unwrap();
9754 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
9755 let tool_router = Router::new()
9756 .route("/redirect", post(redirecting_tool))
9757 .route("/effect", post(redirected_effect))
9758 .with_state(Arc::clone(&calls));
9759 let tool_server = tokio::spawn(async move {
9760 axum::serve(tool_listener, tool_router).await.unwrap();
9761 });
9762 let directory = tempfile::tempdir().unwrap();
9763 let (mut state, provider) = test_state(directory.path()).await;
9764 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
9765 Some(format!("http://{tool_address}/redirect"));
9766 let response = build_router(state)
9767 .oneshot(
9768 Request::builder()
9769 .method("POST")
9770 .uri("/v1/mcp")
9771 .header("x-av-session", "tool-redirect")
9772 .body(Body::from(
9773 r#"{"jsonrpc":"2.0","id":"redirect","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
9774 ))
9775 .unwrap(),
9776 )
9777 .await
9778 .unwrap();
9779 assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
9780 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
9781 tool_server.abort();
9782 provider.abort();
9783 }
9784
9785 #[tokio::test]
9786 async fn concurrent_duplicate_tool_claims_conflict_without_leaking_io_detail() {
9787 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9788 let tool_address = tool_listener.local_addr().unwrap();
9789 let held = Arc::new(HeldToolState {
9790 arrived: std::sync::atomic::AtomicBool::new(false),
9791 release: tokio::sync::Notify::new(),
9792 });
9793 let tool_router = Router::new()
9794 .route("/mcp", post(held_tool))
9795 .with_state(Arc::clone(&held));
9796 let tool_server = tokio::spawn(async move {
9797 axum::serve(tool_listener, tool_router).await.unwrap();
9798 });
9799 let directory = tempfile::tempdir().unwrap();
9800 let (mut state, provider) = test_state(directory.path()).await;
9801 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
9802 Some(format!("http://{tool_address}/mcp"));
9803 let app = build_router(state);
9804 let request = || {
9805 Request::builder()
9806 .method("POST")
9807 .uri("/v1/mcp")
9808 .header("x-av-session", "claim-race")
9809 .body(Body::from(
9810 r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
9811 ))
9812 .unwrap()
9813 };
9814 let winner_app = app.clone();
9815 let winner = tokio::spawn(async move { winner_app.oneshot(request()).await.unwrap() });
9816 tokio::time::timeout(Duration::from_secs(1), async {
9817 while !held.arrived.load(std::sync::atomic::Ordering::Acquire) {
9818 tokio::task::yield_now().await;
9819 }
9820 })
9821 .await
9822 .unwrap();
9823 let loser = app.oneshot(request()).await.unwrap();
9827 assert_eq!(loser.status(), StatusCode::CONFLICT);
9828 let body = axum::body::to_bytes(loser.into_body(), 64 * 1024).await.unwrap();
9829 let text = String::from_utf8(body.to_vec()).unwrap();
9830 assert!(text.contains(TOOL_OUTCOME_UNCERTAIN), "{text}");
9831 assert!(!text.contains("os error"), "leaked io detail: {text}");
9832 held.release.notify_one();
9833 assert_eq!(winner.await.unwrap().status(), StatusCode::OK);
9834 tool_server.abort();
9835 provider.abort();
9836 }
9837
9838 #[tokio::test]
9839 async fn close_waits_for_tool_execution_and_completion_capture() {
9840 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9841 let tool_address = tool_listener.local_addr().unwrap();
9842 let held = Arc::new(HeldToolState {
9843 arrived: std::sync::atomic::AtomicBool::new(false),
9844 release: tokio::sync::Notify::new(),
9845 });
9846 let tool_router = Router::new()
9847 .route("/mcp", post(held_tool))
9848 .with_state(Arc::clone(&held));
9849 let tool_server = tokio::spawn(async move {
9850 axum::serve(tool_listener, tool_router).await.unwrap();
9851 });
9852 let directory = tempfile::tempdir().unwrap();
9853 let (mut state, provider) = test_state(directory.path()).await;
9854 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
9855 Some(format!("http://{tool_address}/mcp"));
9856 let app = build_router(state);
9857 let tool_app = app.clone();
9858 let tool_task = tokio::spawn(async move {
9859 tool_app
9860 .oneshot(
9861 Request::builder()
9862 .method("POST")
9863 .uri("/v1/mcp")
9864 .header("x-av-session", "held-tool")
9865 .body(Body::from(
9866 r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
9867 ))
9868 .unwrap(),
9869 )
9870 .await
9871 .unwrap()
9872 });
9873 tokio::time::timeout(Duration::from_secs(1), async {
9874 while !held.arrived.load(std::sync::atomic::Ordering::Acquire) {
9875 tokio::task::yield_now().await;
9876 }
9877 })
9878 .await
9879 .unwrap();
9880 let close_task = tokio::spawn(async move {
9881 app.oneshot(
9882 Request::builder()
9883 .method("POST")
9884 .uri("/v1/sessions/held-tool/close")
9885 .body(Body::empty())
9886 .unwrap(),
9887 )
9888 .await
9889 .unwrap()
9890 });
9891 tokio::task::yield_now().await;
9892 assert!(!close_task.is_finished(), "close overtook the executing tool");
9893 held.release.notify_one();
9894 assert_eq!(tool_task.await.unwrap().status(), StatusCode::OK);
9895 let close_response = close_task.await.unwrap();
9896 let close_status = close_response.status();
9897 let close_body = axum::body::to_bytes(close_response.into_body(), 64 * 1024)
9898 .await
9899 .unwrap();
9900 assert_eq!(
9901 close_status,
9902 StatusCode::OK,
9903 "close failed: {}",
9904 String::from_utf8_lossy(&close_body)
9905 );
9906 tool_server.abort();
9907 provider.abort();
9908 }
9909
9910 #[test]
9911 #[allow(clippy::cast_possible_truncation)] fn fuzz_parse_provider_chunk_is_total() {
9913 let mut state = 0x9E37_79B9_7F4A_7C15u64;
9917 let mut next = move || {
9918 state ^= state << 13;
9919 state ^= state >> 7;
9920 state ^= state << 17;
9921 state
9922 };
9923 let seeds: Vec<&str> = vec![
9924 "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"usage\":null}\n\n",
9925 "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"a\",\"reasoning_content\":\"r\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.001,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n",
9926 "data: {\"choices\":[{\"index\":1,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c\",\"function\":{\"name\":\"f\",\"arguments\":\"{}\"}}]}}]}\n\n",
9927 "data: [DONE]\n\n",
9928 "event: message\ndata: {\"choices\":[]}\n\n",
9929 "{\"choices\":[{\"message\":{\"content\":\"x\",\"tool_calls\":[{\"id\":\"a\",\"function\":{\"name\":\"n\",\"arguments\":\"{}\"}}]}}],\"usage\":{\"completion_tokens\":1}}",
9930 ];
9931 for _ in 0..200_000 {
9932 let seed = seeds[(next() as usize) % seeds.len()];
9933 let mut bytes = seed.as_bytes().to_vec();
9934 for _ in 0..=(next() % 8) {
9935 match next() % 3 {
9936 0 if !bytes.is_empty() => {
9937 let idx = (next() as usize) % bytes.len();
9938 bytes[idx] = (next() & 0xFF) as u8;
9939 }
9940 1 if !bytes.is_empty() => {
9941 let idx = (next() as usize) % bytes.len();
9942 bytes.truncate(idx);
9943 }
9944 _ => {
9945 let idx = if bytes.is_empty() {
9946 0
9947 } else {
9948 (next() as usize) % bytes.len()
9949 };
9950 bytes.insert(idx, (next() & 0xFF) as u8);
9951 }
9952 }
9953 }
9954 let raw = String::from_utf8_lossy(&bytes).into_owned();
9955 let _ = parse_provider_chunk(&raw);
9956 let _ = sse_frame_end(&bytes);
9957 }
9958 }
9959
9960 #[test]
9961 fn provider_usage_and_reasoning_are_preserved() {
9962 let raw = concat!(
9963 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{",
9964 "\"content\":\"answer\",\"reasoning_content\":\"thought\"},\"finish_reason\":\"stop\"}],",
9965 "\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.00125,",
9966 "\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n"
9967 );
9968 let parsed = parse_provider_chunk(raw).unwrap().unwrap();
9969 assert_eq!(parsed.message, "answer");
9970 assert_eq!(parsed.reasoning.as_deref(), Some("thought"));
9971 assert_eq!(parsed.model_name.as_deref(), Some("gpt-test"));
9972 assert_eq!(parsed.metrics.prompt_tokens, Some(10));
9973 assert_eq!(parsed.metrics.completion_tokens, Some(3));
9974 assert_eq!(parsed.metrics.cached_tokens, Some(4));
9975 assert!(parsed.usage_reported);
9976 assert_eq!(parsed.finish_reason.as_deref(), Some("stop"));
9977 assert_eq!(parsed.cost_usd_micros, 1250);
9978 assert!(parsed.tool_call_deltas.is_empty());
9979 }
9980
9981 #[test]
9982 fn null_usage_chunks_are_not_reported_usage() {
9983 let raw = r#"data: {"choices":[{"delta":{"content":"Hello"}}],"usage":null}"#;
9991 let parsed = parse_provider_chunk(raw).unwrap().unwrap();
9992 assert!(!parsed.usage_reported, "null usage must not count as reported");
9993 assert!(!parsed.completion_reported);
9994 assert_eq!(parsed.message, "Hello");
9995 assert_eq!(
9997 parsed.metrics.completion_tokens,
9998 Some(av_core::tokens::approx_tokens("Hello"))
9999 );
10000 let raw = r#"data: {"choices":[{"delta":{"content":"x"}}],"usage":{"prompt_tokens":7,"completion_tokens":null}}"#;
10003 let parsed = parse_provider_chunk(raw).unwrap().unwrap();
10004 assert!(parsed.usage_reported);
10005 assert!(
10006 !parsed.completion_reported,
10007 "a null completion count must not drive cumulative-usage math"
10008 );
10009 assert_eq!(parsed.metrics.prompt_tokens, Some(7));
10010 assert_eq!(
10011 parsed.metrics.completion_tokens,
10012 Some(av_core::tokens::approx_tokens("x"))
10013 );
10014 }
10015
10016 #[test]
10017 fn hostile_provider_metrics_are_rejected() {
10018 for raw in [
10019 r#"{"usage":{"completion_tokens":9007199254740993}}"#,
10020 r#"{"usage":{"prompt_tokens":-1}}"#,
10021 r#"{"usage":{"prompt_tokens_details":{"cached_tokens":"many"}}}"#,
10022 r#"{"usage":{"cost_usd":1e30}}"#,
10023 r#"{"usage":{"cost_usd":-1}}"#,
10024 ] {
10025 assert!(
10026 parse_provider_chunk(raw).is_err(),
10027 "accepted hostile metrics: {raw}"
10028 );
10029 }
10030 }
10031
10032 #[test]
10033 fn streaming_tool_call_arguments_reassemble() {
10034 let first = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"db_write","arguments":"{\"table\":"}}]}}]}"#;
10035 let second = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"items\",\"row\":{}}"}}]},"finish_reason":"tool_calls"}]}"#;
10036 let first_delta = parse_provider_chunk(first).unwrap().unwrap().tool_call_deltas;
10037 let second_delta = parse_provider_chunk(second).unwrap().unwrap().tool_call_deltas;
10038 assert_eq!(first_delta.len(), 1);
10039 assert_eq!(second_delta.len(), 1);
10040 let arguments = format!("{}{}", first_delta[0].arguments, second_delta[0].arguments);
10041 assert_eq!(
10042 serde_json::from_str::<Value>(&arguments).unwrap(),
10043 json!({"table": "items", "row": {}})
10044 );
10045 assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
10046 }
10047
10048 #[test]
10049 fn hostile_provider_tool_call_index_is_rejected() {
10050 let raw = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":18446744073709551615,"function":{"name":"n","arguments":"{}"}}]}}]}"#;
10051 let parsed = parse_provider_chunk(raw).unwrap().unwrap();
10052 assert_eq!(parsed.tool_call_deltas.len(), 1);
10053 assert_eq!(parsed.tool_call_deltas[0].index, u64::MAX);
10054 }
10055
10056 #[test]
10057 fn non_stream_tool_calls_receive_distinct_positional_indices() {
10058 let parsed = parse_provider_chunk(
10059 r#"{"choices":[{"message":{"tool_calls":[{"id":"a","function":{"name":"read","arguments":"{}"}},{"id":"b","function":{"name":"write","arguments":"{}"}}]}}]}"#,
10060 )
10061 .unwrap()
10062 .unwrap();
10063 assert_eq!(parsed.tool_call_deltas.len(), 2);
10064 assert_eq!(parsed.tool_call_deltas[0].index, 0);
10065 assert_eq!(parsed.tool_call_deltas[1].index, 1);
10066 }
10067
10068 #[test]
10069 fn sse_frame_boundary_accepts_crlf_and_cr() {
10070 assert_eq!(sse_frame_end(b"data: {}\r\n\r"), None);
10071 assert_eq!(sse_frame_end(b"data: {}\r\n\r\nrest"), Some(12));
10072 assert_eq!(sse_frame_end(b"data: {}\r\rrest"), Some(10));
10073 }
10074
10075 #[test]
10082 fn parse_provider_chunk_strips_leading_bom() {
10083 let raw = "\u{feff}data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
10084 let parsed = parse_provider_chunk(raw)
10085 .expect("BOM-prefixed SSE frame must parse")
10086 .expect("must yield a chunk");
10087 assert_eq!(parsed.message, "hi");
10088 }
10089
10090 #[test]
10101 fn parse_provider_chunk_refuses_non_message_sse_event_types() {
10102 for event in ["error", "ping", "custom_signal"] {
10103 let raw = format!(
10104 "event: {event}\ndata: {{\"choices\":[{{\"delta\":{{\"content\":\"forged\"}}}}]}}\n\n"
10105 );
10106 let err = match parse_provider_chunk(&raw) {
10107 Ok(_) => panic!("event type {event:?} must be refused"),
10108 Err(error) => error,
10109 };
10110 assert!(
10111 err.contains("unsupported event type") && err.contains(event),
10112 "expected diagnostic naming event type {event:?}, got: {err}"
10113 );
10114 }
10115 let ok_raw = "event: message\ndata: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
10118 let parsed = match parse_provider_chunk(ok_raw) {
10119 Ok(Some(chunk)) => chunk,
10120 Ok(None) => panic!("explicit event: message must yield a chunk"),
10121 Err(error) => panic!("explicit event: message must be accepted, got: {error}"),
10122 };
10123 assert_eq!(parsed.message, "hi");
10124 }
10125
10126 #[test]
10135 fn parse_provider_chunk_treats_dataless_named_events_as_keepalives() {
10136 for raw in [
10137 "event: ping\n\n",
10138 "event: heartbeat\n\n",
10139 "event: keep-alive\ndata:\n\n",
10140 "event: status\ndata: \n\n",
10141 ] {
10142 match parse_provider_chunk(raw) {
10143 Ok(None) => {}
10144 Ok(Some(_)) => panic!("dataless named event must not yield a chunk: {raw:?}"),
10145 Err(error) => panic!("dataless named event {raw:?} must not fail the stream (D7): {error}"),
10146 }
10147 }
10148 let poison = "event: error\ndata: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n";
10152 assert!(
10153 parse_provider_chunk(poison).is_err(),
10154 "named event with non-empty data must still be refused"
10155 );
10156 }
10157
10158 #[test]
10159 fn parse_provider_chunk_treats_done_variants_as_stream_end() {
10160 for raw in [
10161 "data: [DONE]\ndata: [DONE]\n\n",
10162 "data: [DONE] \n\n",
10163 "data:\ndata: [DONE]\n\n",
10164 ] {
10165 let result = parse_provider_chunk(raw);
10166 match result {
10167 Ok(None) => {}
10168 Ok(Some(_)) => panic!("[DONE] variant must terminate stream (None) for {raw:?}"),
10169 Err(error) => panic!("[DONE] variant must not fail parse: {raw:?}: {error}"),
10170 }
10171 }
10172 }
10173
10174 #[test]
10183 fn parse_provider_chunk_done_under_named_event_is_keepalive() {
10184 for raw in [
10185 "event: summary\ndata: [DONE]\n\n",
10186 "event: error\ndata: [DONE]\n\n",
10187 "event: custom\ndata:\ndata: [DONE]\n\n",
10188 "event: batched\ndata: [DONE]\ndata: [DONE]\n\n",
10189 ] {
10190 match parse_provider_chunk(raw) {
10191 Ok(None) => {}
10192 Ok(Some(_)) => panic!(
10193 "[DONE] under named event {raw:?} must not yield a chunk (no attributable content)"
10194 ),
10195 Err(error) => {
10196 panic!("[DONE] under named event {raw:?} must not fail-close the stream: {error}")
10197 }
10198 }
10199 }
10200 let mixed = "event: error\ndata: [DONE]\ndata: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n";
10204 assert!(
10205 parse_provider_chunk(mixed).is_err(),
10206 "named event with mixed [DONE] + real content must still refuse: {mixed:?}"
10207 );
10208 }
10209
10210 #[test]
10241 fn parse_provider_chunk_strips_exactly_one_leading_space() {
10242 let raw = "data: \thello\n\n";
10251 let result = parse_provider_chunk(raw);
10252 let error = match result {
10253 Ok(_) => panic!("bare hello must not parse as valid provider frame"),
10254 Err(error) => error,
10255 };
10256 assert!(
10257 error.contains("column 2") || error.contains("column 3"),
10258 "expected error to name a column past the retained leading space+tab; \
10259 got: {error}"
10260 );
10261 }
10262
10263 #[tokio::test]
10264 async fn split_non_sse_utf8_is_captured_as_one_json_document() {
10265 let directory = tempfile::tempdir().unwrap();
10266 let (state, provider) = test_state(directory.path()).await;
10267 let app = build_router(state.clone());
10268 let response = app
10269 .oneshot(chat_request_with_payload(
10270 "split-json",
10271 json!({
10272 "model": "split-json",
10273 "stream": false,
10274 "messages": [{"role": "user", "content": "hello"}]
10275 }),
10276 ))
10277 .await
10278 .unwrap();
10279 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10280 .await
10281 .unwrap();
10282 assert_eq!(
10283 serde_json::from_slice::<Value>(&body).unwrap()["choices"][0]["message"]["content"],
10284 "héllo"
10285 );
10286 let session = state.sessions.get("split-json").unwrap();
10287 session.wait_for_worker_jobs().await;
10288 let crate::reconciler::FinalizeOutcome::Atif { path } = state
10289 .finalizer
10290 .close_session(session, StopReason::SessionClosed)
10291 .await
10292 .unwrap()
10293 else {
10294 panic!("expected ATIF")
10295 };
10296 let trajectory: av_atif::Trajectory = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
10297 assert_eq!(trajectory.steps[1].message, Value::String("héllo".to_owned()));
10298 provider.abort();
10299 }
10300
10301 #[tokio::test]
10302 async fn malformed_non_sse_json_fails_capture_before_delivery() {
10303 let directory = tempfile::tempdir().unwrap();
10304 let (state, provider) = test_state(directory.path()).await;
10305 let app = build_router(state.clone());
10306 let response = app
10307 .oneshot(chat_request_with_payload(
10308 "malformed-json",
10309 json!({
10310 "model": "malformed-json",
10311 "stream": false,
10312 "messages": [{"role": "user", "content": "hello"}]
10313 }),
10314 ))
10315 .await
10316 .unwrap();
10317 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10321 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10322 .await
10323 .unwrap();
10324 {
10325 let parsed = serde_json::from_slice::<Value>(&body).unwrap();
10328 assert!(parsed["error"]["message"].is_string(), "{parsed}");
10329 assert!(parsed["error"]["type"].is_string(), "{parsed}");
10330 assert!(parsed["error"]["code"].is_number(), "{parsed}");
10331 }
10332 let session = state.sessions.get("malformed-json").unwrap();
10333 assert!(
10338 !session.capture_failed(),
10339 "a single malformed provider frame must not brick the session"
10340 );
10341 provider.abort();
10342 }
10343
10344 #[tokio::test]
10350 async fn unsupported_content_encoding_refusal_retires_marker_and_attempt() {
10351 let directory = tempfile::tempdir().unwrap();
10352 let (state, provider) = test_state(directory.path()).await;
10353 let response = build_router(state.clone())
10354 .oneshot(chat_request_with_payload(
10355 "gzip-encoded",
10356 json!({
10357 "model": "gzip-encoded",
10358 "stream": false,
10359 "messages": [{"role": "user", "content": "hello"}]
10360 }),
10361 ))
10362 .await
10363 .unwrap();
10364 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10365 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10366 .await
10367 .unwrap();
10368 let error = serde_json::from_slice::<Value>(&body).unwrap()["error"]["message"]
10369 .as_str()
10370 .unwrap()
10371 .to_owned();
10372 assert!(error.contains("Content-Encoding"), "unexpected error: {error}");
10373 let session = state.sessions.get("gzip-encoded").unwrap();
10374 session.wait_for_worker_jobs().await;
10375 crate::worker::ensure_no_inflight_responses(directory.path(), &state.journal_key)
10376 .await
10377 .expect("refusal must retire the in-flight response marker");
10378 let records = active_records(directory.path(), &state, "gzip-encoded");
10379 assert!(
10380 records
10381 .iter()
10382 .filter_map(|record| record.response_attempt.as_ref())
10383 .any(|attempt| attempt.terminal),
10384 "refusal must journal a terminal response attempt"
10385 );
10386 let event: av_events::OcsfEvent =
10387 serde_json::from_value(records.last().unwrap().event.clone()).unwrap();
10388 assert_eq!(event.class_name, av_events::EventClass::StopReason);
10389 assert_eq!(event.status_id, av_events::StatusId::Failure.id());
10390 assert_eq!(event.payload["reason"], "upstream_unsupported_content_encoding");
10391 provider.abort();
10392 }
10393
10394 #[tokio::test]
10395 async fn repeated_provider_responses_open_the_loop_breaker() {
10396 let directory = tempfile::tempdir().unwrap();
10397 let (mut state, provider) = test_state(directory.path()).await;
10398 let config = Arc::get_mut(&mut state.config).unwrap();
10399 config.breaker.min_tokens = 0;
10400 config.breaker.window = 3;
10401 let app = build_router(state.clone());
10402 for _ in 0..4 {
10403 let response = app.clone().oneshot(chat_request("response-loop")).await.unwrap();
10404 assert_eq!(response.status(), StatusCode::OK);
10405 axum::body::to_bytes(response.into_body(), 64 * 1024)
10406 .await
10407 .unwrap();
10408 state
10409 .sessions
10410 .get("response-loop")
10411 .unwrap()
10412 .wait_for_worker_jobs()
10413 .await;
10414 }
10415 let blocked = app.oneshot(chat_request("response-loop")).await.unwrap();
10416 assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
10421 provider.abort();
10422 }
10423
10424 #[tokio::test]
10425 async fn empty_and_failed_upstream_responses_are_audited_as_failures() {
10426 let directory = tempfile::tempdir().unwrap();
10427 let (state, provider) = test_state(directory.path()).await;
10428 let app = build_router(state.clone());
10429 for (session_id, model) in [("empty-error", "empty-error"), ("json-error", "json-error")] {
10430 let response = app
10431 .clone()
10432 .oneshot(chat_request_with_payload(
10433 session_id,
10434 json!({
10435 "model": model,
10436 "stream": false,
10437 "messages": [{"role": "user", "content": "hello"}]
10438 }),
10439 ))
10440 .await
10441 .unwrap();
10442 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
10443 axum::body::to_bytes(response.into_body(), 64 * 1024)
10444 .await
10445 .unwrap();
10446 let session = state.sessions.get(session_id).unwrap();
10447 session.wait_for_worker_jobs().await;
10448 assert!(!session.capture_failed());
10449 let records = active_records(directory.path(), &state, session_id);
10450 assert_eq!(records.len(), 2);
10451 let event: av_events::OcsfEvent =
10452 serde_json::from_value(records.last().unwrap().event.clone()).unwrap();
10453 assert_eq!(event.class_name, av_events::EventClass::StopReason);
10454 assert_eq!(event.status_id, av_events::StatusId::Failure.id());
10455 assert_eq!(event.payload["http_status"], 500);
10456 }
10457 provider.abort();
10458 }
10459
10460 #[tokio::test]
10461 async fn regressing_provider_usage_fails_capture_before_later_bytes() {
10462 let directory = tempfile::tempdir().unwrap();
10463 let (state, provider) = test_state(directory.path()).await;
10464 let response = build_router(state.clone())
10465 .oneshot(chat_request_with_payload(
10466 "regressive-usage",
10467 json!({
10468 "model": "regressive-usage",
10469 "stream": true,
10470 "messages": [{"role": "user", "content": "hello"}]
10471 }),
10472 ))
10473 .await
10474 .unwrap();
10475 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10476 .await
10477 .is_err());
10478 let session = state.sessions.get("regressive-usage").unwrap();
10479 assert!(
10483 !session.capture_failed(),
10484 "a regressive usage frame must be scoped to the response, not the session"
10485 );
10486 session.wait_for_worker_jobs().await;
10487 let records = active_records(directory.path(), &state, "regressive-usage");
10488 assert_eq!(records.len(), 2);
10492 assert!(records.iter().any(|record| record.response_attempt.is_some()));
10493 provider.abort();
10494 }
10495
10496 #[tokio::test]
10497 async fn successful_provider_response_requires_choices() {
10498 let directory = tempfile::tempdir().unwrap();
10499 let (state, provider) = test_state(directory.path()).await;
10500 let response = build_router(state.clone())
10501 .oneshot(chat_request_with_payload(
10502 "empty-success",
10503 json!({
10504 "model": "empty-success",
10505 "stream": false,
10506 "messages": [{"role": "user", "content": "hello"}]
10507 }),
10508 ))
10509 .await
10510 .unwrap();
10511 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10514 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10515 .await
10516 .unwrap();
10517 {
10518 let parsed = serde_json::from_slice::<Value>(&body).unwrap();
10521 assert!(parsed["error"]["message"].is_string(), "{parsed}");
10522 assert!(parsed["error"]["type"].is_string(), "{parsed}");
10523 assert!(parsed["error"]["code"].is_number(), "{parsed}");
10524 }
10525 assert!(
10528 !state.sessions.get("empty-success").unwrap().capture_failed(),
10529 "a choices-less response must not brick the session"
10530 );
10531 provider.abort();
10532 }
10533
10534 #[tokio::test]
10535 async fn cumulative_usage_settles_against_provisional_charge() {
10536 let directory = tempfile::tempdir().unwrap();
10537 let payload = json!({
10538 "model": "provisional-usage",
10539 "stream": true,
10540 "messages": [{"role": "user", "content": "hello"}]
10541 });
10542 let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
10543 let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens + 2)).await;
10544 let response = build_router(state.clone())
10545 .oneshot(chat_request_with_payload("provisional-usage", payload))
10546 .await
10547 .unwrap();
10548 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10549 .await
10550 .is_ok());
10551 let session = state.sessions.get("provisional-usage").unwrap();
10552 session.wait_for_worker_jobs().await;
10553 assert_eq!(
10554 session
10555 .totals
10556 .completion_tokens
10557 .load(std::sync::atomic::Ordering::Acquire),
10558 2
10559 );
10560 provider.abort();
10561 }
10562
10563 #[tokio::test]
10564 async fn provider_tool_call_index_out_of_range_fails_capture() {
10565 let directory = tempfile::tempdir().unwrap();
10566 let (state, provider) = test_state(directory.path()).await;
10567 let response = build_router(state.clone())
10568 .oneshot(chat_request_with_payload(
10569 "tool-oob-index",
10570 json!({
10571 "model": "tool-oob-index",
10572 "stream": true,
10573 "messages": [{"role": "user", "content": "hello"}]
10574 }),
10575 ))
10576 .await
10577 .unwrap();
10578 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10583 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10584 .await
10585 .is_ok());
10586 assert!(
10590 !state.sessions.get("tool-oob-index").unwrap().capture_failed(),
10591 "an out-of-range tool-call index must be scoped to the response"
10592 );
10593 provider.abort();
10594 }
10595
10596 #[tokio::test]
10603 async fn provider_tool_call_id_conflict_fails_capture() {
10604 let directory = tempfile::tempdir().unwrap();
10605 let (state, provider) = test_state(directory.path()).await;
10606 let response = build_router(state.clone())
10607 .oneshot(chat_request_with_payload(
10608 "tool-conflicting-id",
10609 json!({
10610 "model": "tool-conflicting-id",
10611 "stream": true,
10612 "messages": [{"role": "user", "content": "hello"}]
10613 }),
10614 ))
10615 .await
10616 .unwrap();
10617 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10620 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10621 .await
10622 .is_ok());
10623 assert!(
10624 !state
10625 .sessions
10626 .get("tool-conflicting-id")
10627 .unwrap()
10628 .capture_failed(),
10629 "a conflicting tool-call id must be scoped to the response"
10630 );
10631 let response = build_router(state.clone())
10635 .oneshot(chat_request_with_payload(
10636 "tool-conflicting-name",
10637 json!({
10638 "model": "tool-conflicting-name",
10639 "stream": true,
10640 "messages": [{"role": "user", "content": "hello"}]
10641 }),
10642 ))
10643 .await
10644 .unwrap();
10645 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10646 provider.abort();
10647 }
10648
10649 #[tokio::test]
10657 async fn completion_estimate_over_charge_is_refunded_after_authoritative_usage() {
10658 let directory = tempfile::tempdir().unwrap();
10659 let payload = json!({
10660 "model": "overcharged-completion",
10661 "stream": true,
10662 "messages": [{"role": "user", "content": "hello"}]
10663 });
10664 let estimate = av_core::tokens::approx_tokens(&"a".repeat(32));
10667 assert!(estimate > 1, "test premise: estimate must exceed usage");
10668 let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
10669 let cap = prompt_tokens + estimate + 8;
10670 let (state, provider) = test_state_with_token_cap(directory.path(), Some(cap)).await;
10671 let response = build_router(state.clone())
10672 .oneshot(chat_request_with_payload("overcharged-completion", payload))
10673 .await
10674 .unwrap();
10675 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10676 .await
10677 .is_ok());
10678 let session = state.sessions.get("overcharged-completion").unwrap();
10679 session.wait_for_worker_jobs().await;
10680 let ledger = av_state::ActionBudget::new(
10684 state.store.as_ref(),
10685 "overcharged-completion",
10686 &state.config.budget,
10687 );
10688 assert!(
10689 ledger.try_tokens(cap - 2).unwrap().is_allowed(),
10690 "the completion-estimate over-charge must be refunded once authoritative usage arrives"
10691 );
10692 assert!(
10693 !ledger.try_tokens(1).unwrap().is_allowed(),
10694 "refunds must settle to the provider-billed spend, not more"
10695 );
10696 provider.abort();
10697 }
10698
10699 #[tokio::test]
10705 async fn sse_frame_split_across_chunks_reassembles_before_capture() {
10706 let directory = tempfile::tempdir().unwrap();
10707 let (state, provider) = test_state(directory.path()).await;
10708 let response = build_router(state.clone())
10709 .oneshot(chat_request_with_payload(
10710 "split-sse",
10711 json!({
10712 "model": "split-sse",
10713 "stream": true,
10714 "messages": [{"role": "user", "content": "hello"}]
10715 }),
10716 ))
10717 .await
10718 .unwrap();
10719 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10720 .await
10721 .unwrap();
10722 assert!(
10724 String::from_utf8_lossy(&body).contains("héllo"),
10725 "client bytes truncated"
10726 );
10727 let session = state.sessions.get("split-sse").unwrap();
10728 session.wait_for_worker_jobs().await;
10729 assert!(
10730 !session.capture_failed(),
10731 "a chunk-split frame must not fail capture"
10732 );
10733 let crate::reconciler::FinalizeOutcome::Atif { path } = state
10734 .finalizer
10735 .close_session(session, StopReason::SessionClosed)
10736 .await
10737 .unwrap()
10738 else {
10739 panic!("expected ATIF")
10740 };
10741 let trajectory: av_atif::Trajectory = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
10742 assert_eq!(
10743 trajectory.steps[1].message,
10744 Value::String("héllo".to_owned()),
10745 "the audit trail must carry the reassembled multi-byte content"
10746 );
10747 provider.abort();
10748 }
10749
10750 #[tokio::test]
10758 async fn truncated_stream_is_not_attested_as_a_complete_success() {
10759 let directory = tempfile::tempdir().unwrap();
10760 let (state, provider) = test_state(directory.path()).await;
10761 let response = build_router(state.clone())
10762 .oneshot(chat_request_with_payload(
10763 "truncated-stream",
10764 json!({
10765 "model": "truncated-stream",
10766 "stream": true,
10767 "messages": [{"role": "user", "content": "hello"}]
10768 }),
10769 ))
10770 .await
10771 .unwrap();
10772 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10775 .await
10776 .is_ok());
10777 let session = state.sessions.get("truncated-stream").unwrap();
10778 session.wait_for_worker_jobs().await;
10779 let records = active_records(directory.path(), &state, "truncated-stream");
10780 let truncated = records.iter().any(|record| {
10781 record
10782 .event
10783 .get("payload")
10784 .is_some_and(|payload| payload.get("truncated").and_then(Value::as_bool) == Some(true))
10785 });
10786 assert!(
10787 truncated,
10788 "a finish_reason-less SSE end must be journalled with a truncated marker; records: {:?}",
10789 records
10790 .iter()
10791 .map(|r| r.event.get("payload").cloned())
10792 .collect::<Vec<_>>()
10793 );
10794 provider.abort();
10795 }
10796
10797 #[tokio::test]
10807 async fn severed_stream_is_attested_other_not_budget_exceeded() {
10808 let directory = tempfile::tempdir().unwrap();
10809 let (state, provider) = test_state(directory.path()).await;
10810 let response = build_router(state.clone())
10811 .oneshot(chat_request_with_payload(
10812 "severed-stream",
10813 json!({
10814 "model": "severed-stream",
10815 "stream": true,
10816 "messages": [{"role": "user", "content": "hello"}]
10817 }),
10818 ))
10819 .await
10820 .unwrap();
10821 let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
10824 let session = state.sessions.get("severed-stream").unwrap();
10825 session.wait_for_worker_jobs().await;
10826 let records = active_records(directory.path(), &state, "severed-stream");
10827 let stop_reasons: Vec<(u64, Option<String>)> = records
10828 .iter()
10829 .filter_map(|record| {
10830 let id = record.event.get("stop_reason_id").and_then(Value::as_u64)?;
10831 let reason = record
10832 .event
10833 .pointer("/payload/reason")
10834 .and_then(Value::as_str)
10835 .map(str::to_owned);
10836 Some((id, reason))
10837 })
10838 .collect();
10839 assert!(
10840 stop_reasons.iter().all(|(id, _)| *id != 92),
10841 "a provider cut must never be attested as Budget Exceeded (92); got {stop_reasons:?}"
10842 );
10843 assert!(
10844 stop_reasons
10845 .iter()
10846 .any(|(id, reason)| *id == 99 && reason.as_deref().is_some_and(|r| !r.is_empty())),
10847 "the cut turn must be attested Other (99) with a free-text failure reason; got {stop_reasons:?}"
10848 );
10849 provider.abort();
10850 }
10851
10852 #[tokio::test]
10853 async fn tool_arguments_without_usage_are_budgeted_before_delivery() {
10854 let directory = tempfile::tempdir().unwrap();
10855 let payload = json!({
10856 "model": "tool-no-usage",
10857 "stream": true,
10858 "messages": [{"role": "user", "content": "hello"}]
10859 });
10860 let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
10861 let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens)).await;
10862 let response = build_router(state)
10863 .oneshot(chat_request_with_payload("tool-no-usage", payload))
10864 .await
10865 .unwrap();
10866 assert_eq!(response.status(), StatusCode::FORBIDDEN);
10870 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10871 .await
10872 .is_ok());
10873 provider.abort();
10874 }
10875
10876 #[tokio::test(flavor = "current_thread")]
10877 async fn completion_budget_store_does_not_block_stream_runtime() {
10878 let directory = tempfile::tempdir().unwrap();
10879 let (mut state, provider) = test_state_with_token_cap(directory.path(), Some(10_000)).await;
10880 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
10881 state.store = Arc::new(SlowStore {
10882 inner: InMemoryStore::new(),
10883 calls: Arc::clone(&calls),
10884 });
10885 let response = build_router(state)
10886 .oneshot(chat_request("slow-budget"))
10887 .await
10888 .unwrap();
10889 let body = tokio::spawn(async move {
10890 axum::body::to_bytes(response.into_body(), 64 * 1024)
10891 .await
10892 .unwrap()
10893 });
10894
10895 let started = std::time::Instant::now();
10896 tokio::time::sleep(Duration::from_millis(10)).await;
10897 assert!(
10904 started.elapsed() < Duration::from_millis(250),
10905 "completion budget storage blocked the Tokio reactor"
10906 );
10907 assert!(!body.await.unwrap().is_empty());
10908 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 2);
10909 provider.abort();
10910 }
10911
10912 #[tokio::test]
10913 async fn completion_tokens_are_blocked_before_delivery() {
10914 let directory = tempfile::tempdir().unwrap();
10915 let prompt_tokens = av_core::tokens::approx_tokens(&chat_payload().to_string());
10916 let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens)).await;
10917 let app = build_router(state.clone());
10918 let response = app.oneshot(chat_request("completion-budget")).await.unwrap();
10919 assert_eq!(response.status(), StatusCode::OK);
10920 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10922 .await
10923 .is_err());
10924 let session = state.sessions.get("completion-budget").unwrap();
10930 session.wait_for_streams().await;
10931 assert!(
10932 !session.capture_failed(),
10933 "budget refusal must be scoped to the response, not the session"
10934 );
10935 assert!(
10936 !session.is_closed(),
10937 "budget refusal must not force-close the conversation"
10938 );
10939 provider.abort();
10940 }
10941
10942 #[test]
10960 fn upstream_response_headers_do_not_cross_proxy_trust_boundary() {
10961 use axum::http::HeaderName;
10962 let dangerous = [
10966 "set-cookie",
10967 "access-control-allow-origin",
10968 "access-control-allow-credentials",
10969 "access-control-allow-methods",
10970 "access-control-allow-headers",
10971 "access-control-expose-headers",
10972 "access-control-max-age",
10973 "connection",
10974 "keep-alive",
10975 "transfer-encoding",
10976 "upgrade",
10977 "te",
10978 "trailer",
10979 "proxy-authenticate",
10980 "proxy-authorization",
10981 "content-length",
10982 "server",
10983 "via",
10984 "x-powered-by",
10985 "x-request-id",
10986 "authorization",
10990 "api-key",
10991 "x-api-key",
10992 "x-goog-api-key",
10993 "anthropic-api-key",
10994 "openai-api-key",
10995 "x-auth-token",
10996 "x-amz-security-token",
10997 ];
10998 for name in dangerous {
10999 let header = HeaderName::from_static(name);
11000 assert!(
11001 !is_forwardable_upstream_header(&header, "authorization"),
11002 "dangerous header {name:?} must not be forwarded to the client"
11003 );
11004 }
11005
11006 let exotic = HeaderName::from_static("x-my-secret");
11010 assert!(
11011 !is_forwardable_upstream_header(&exotic, "x-my-secret"),
11012 "operator-configured upstream_auth_header must be refused",
11013 );
11014
11015 let benign = [
11016 "content-type",
11017 "content-encoding",
11018 "cache-control",
11019 "etag",
11020 "last-modified",
11021 "vary",
11022 "expires",
11023 ];
11024 for name in benign {
11025 let header = HeaderName::from_static(name);
11026 assert!(
11027 is_forwardable_upstream_header(&header, "authorization"),
11028 "benign header {name:?} must still be forwarded"
11029 );
11030 }
11031 }
11032
11033 #[tokio::test]
11046 async fn mcp_tool_upstream_failure_does_not_leak_configured_url() {
11047 let sentinel_host = "internal-mcp-sentinel-host.corp.example";
11049 let sentinel_url = format!("http://{sentinel_host}:65002/mcp");
11050 let directory = tempfile::tempdir().unwrap();
11051 let (mut state, provider) = test_state(directory.path()).await;
11052 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url = Some(sentinel_url);
11053 let response = build_router(state)
11054 .oneshot(
11055 Request::builder()
11056 .method("POST")
11057 .uri("/v1/mcp")
11058 .header("x-av-session", "mcp-cwe-209-check")
11059 .body(Body::from(
11060 r#"{"jsonrpc":"2.0","id":"leak","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
11061 ))
11062 .unwrap(),
11063 )
11064 .await
11065 .unwrap();
11066 assert_eq!(
11067 response.status(),
11068 StatusCode::BAD_GATEWAY,
11069 "tool-upstream faults must surface as 502 like the chat relay, not 500"
11070 );
11071 let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
11072 .await
11073 .unwrap();
11074 let body = String::from_utf8(bytes.to_vec()).unwrap();
11075 assert!(
11076 !body.contains(sentinel_host),
11077 "MCP forward-tool error body {body:?} leaks the configured tool-upstream host"
11078 );
11079 assert!(
11080 !body.contains("65002"),
11081 "MCP forward-tool error body {body:?} leaks the configured tool-upstream port"
11082 );
11083 assert!(
11084 !body.contains("corp.example"),
11085 "MCP forward-tool error body {body:?} leaks the configured tool-upstream domain"
11086 );
11087 provider.abort();
11088 }
11089
11090 #[tokio::test]
11091 async fn pipeline_error_carries_retry_after_and_www_authenticate() {
11092 use crate::pipeline::PipelineError;
11099
11100 let unavailable = pipeline_error(PipelineError::unavailable("worker queue full"));
11101 assert_eq!(unavailable.status(), StatusCode::SERVICE_UNAVAILABLE);
11102 assert_eq!(
11103 unavailable
11104 .headers()
11105 .get(axum::http::header::RETRY_AFTER)
11106 .and_then(|v| v.to_str().ok()),
11107 Some("5")
11108 );
11109
11110 let upstream = pipeline_error(PipelineError::upstream("bad gateway"));
11111 assert_eq!(upstream.status(), StatusCode::BAD_GATEWAY);
11112 assert!(upstream.headers().get(axum::http::header::RETRY_AFTER).is_some());
11113
11114 let unauthorized = pipeline_error(PipelineError::Unauthorized("bad token".into()));
11115 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
11116 let auth = unauthorized
11117 .headers()
11118 .get(axum::http::header::WWW_AUTHENTICATE)
11119 .and_then(|v| v.to_str().ok())
11120 .unwrap_or_default();
11121 assert!(
11122 auth.starts_with("Bearer"),
11123 "WWW-Authenticate must be a Bearer challenge, got {auth:?}"
11124 );
11125
11126 let blocked = pipeline_error(PipelineError::blocked("loop breaker"));
11129 assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
11130 assert!(blocked.headers().get(axum::http::header::RETRY_AFTER).is_none());
11131 let abort = pipeline_error(PipelineError::Abort("stop retrying".into()));
11132 assert_eq!(abort.status(), StatusCode::CONFLICT);
11133 assert!(abort.headers().get(axum::http::header::RETRY_AFTER).is_none());
11134 }
11135
11136 #[test]
11144 fn is_sse_content_type_is_case_insensitive_and_param_tolerant() {
11145 fn ct(value: &str) -> HeaderMap {
11146 let mut headers = HeaderMap::new();
11147 headers.insert(
11148 axum::http::header::CONTENT_TYPE,
11149 value.parse().expect("header value"),
11150 );
11151 headers
11152 }
11153 assert!(is_sse_content_type(&ct("text/event-stream")));
11154 assert!(is_sse_content_type(&ct("Text/Event-Stream")));
11155 assert!(is_sse_content_type(&ct("TEXT/EVENT-STREAM")));
11156 assert!(is_sse_content_type(&ct("text/event-stream; charset=utf-8")));
11157 assert!(is_sse_content_type(&ct("Text/Event-Stream ; charset=utf-8")));
11158 assert!(!is_sse_content_type(&ct("application/json")));
11160 assert!(!is_sse_content_type(&ct("text/event-stream-json")));
11161 assert!(!is_sse_content_type(&HeaderMap::new()));
11163 }
11164
11165 #[tokio::test]
11172 async fn options_returns_204_without_cors_headers() {
11173 let directory = tempfile::tempdir().unwrap();
11174 let (state, provider) = test_state(directory.path()).await;
11175 for path in [
11176 "/v1/chat/completions",
11177 "/v1/mcp",
11178 "/mcp",
11179 "/v1/sessions/some-id/close",
11180 "/v1/sessions/some-id/promote",
11181 ] {
11182 let response = build_router(state.clone())
11183 .oneshot(
11184 Request::builder()
11185 .method(axum::http::Method::OPTIONS)
11186 .uri(path)
11187 .header("origin", "http://attacker.example")
11188 .header("access-control-request-method", "POST")
11189 .body(Body::empty())
11190 .unwrap(),
11191 )
11192 .await
11193 .unwrap();
11194 assert_eq!(
11195 response.status(),
11196 StatusCode::NO_CONTENT,
11197 "OPTIONS {path} should return 204, got {}",
11198 response.status()
11199 );
11200 for header in [
11201 "access-control-allow-origin",
11202 "access-control-allow-methods",
11203 "access-control-allow-headers",
11204 "access-control-allow-credentials",
11205 ] {
11206 assert!(
11207 response.headers().get(header).is_none(),
11208 "OPTIONS {path} must not emit {header} — got {:?}",
11209 response.headers().get(header)
11210 );
11211 }
11212 }
11213 provider.abort();
11214 }
11215
11216 #[test]
11230 fn round_32_f2_tool_outcome_preserves_content_type() {
11231 let outcome = ToolOutcome {
11233 status: 200,
11234 body_hex: hex::encode(b"{\"jsonrpc\":\"2.0\"}"),
11235 content_type: Some("application/problem+json".to_owned()),
11236 };
11237 let response = outcome.into_response();
11238 assert_eq!(
11239 response
11240 .headers()
11241 .get(axum::http::header::CONTENT_TYPE)
11242 .and_then(|v| v.to_str().ok()),
11243 Some("application/problem+json")
11244 );
11245 let outcome_no_ct = ToolOutcome {
11247 status: 200,
11248 body_hex: hex::encode(b"{}"),
11249 content_type: None,
11250 };
11251 let response = outcome_no_ct.into_response();
11252 assert_eq!(
11253 response
11254 .headers()
11255 .get(axum::http::header::CONTENT_TYPE)
11256 .and_then(|v| v.to_str().ok()),
11257 Some("application/json")
11258 );
11259 let sealed = serde_json::to_string(&ToolOutcome {
11261 status: 429,
11262 body_hex: hex::encode(b"{}"),
11263 content_type: Some("text/plain".to_owned()),
11264 })
11265 .unwrap();
11266 let recovered: ToolOutcome = serde_json::from_str(&sealed).unwrap();
11267 assert_eq!(recovered.content_type.as_deref(), Some("text/plain"));
11268 let legacy = r#"{"status":200,"body_hex":"7b7d"}"#;
11270 let outcome: ToolOutcome = serde_json::from_str(legacy).unwrap();
11271 assert!(outcome.content_type.is_none());
11272 }
11273
11274 #[tokio::test]
11280 async fn livez_and_readyz_reflect_service_readiness() {
11281 let scratch = tempfile::tempdir().unwrap();
11282 let spool = scratch.path().join("spool");
11283 std::fs::create_dir_all(&spool).unwrap();
11284 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11285 let address = listener.local_addr().unwrap();
11286 let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
11287 let _server = tokio::spawn(async move {
11288 axum::serve(listener, provider).await.unwrap();
11289 });
11290 let config = crate::config::HarnessConfig::for_tests(
11291 &format!("http://{address}"),
11292 &spool.to_string_lossy(),
11293 &spool.to_string_lossy(),
11294 );
11295 let state = AppState::new(
11296 config,
11297 Arc::new(InMemoryStore::new()),
11298 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11299 Arc::new(NullBus),
11300 None,
11301 Arc::new(Ed25519Signer::from_seed(&[19; 32])),
11302 )
11303 .unwrap();
11304 let app = crate::build_router(state.clone());
11305
11306 let live = app
11309 .clone()
11310 .oneshot(Request::builder().uri("/livez").body(Body::empty()).unwrap())
11311 .await
11312 .unwrap();
11313 assert_eq!(live.status(), StatusCode::OK);
11314
11315 let ready = app
11317 .clone()
11318 .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11319 .await
11320 .unwrap();
11321 assert_eq!(ready.status(), StatusCode::OK);
11322
11323 state.draining.store(true, std::sync::atomic::Ordering::SeqCst);
11325 let not_ready = app
11326 .clone()
11327 .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11328 .await
11329 .unwrap();
11330 assert_eq!(not_ready.status(), StatusCode::SERVICE_UNAVAILABLE);
11331
11332 let still_live = app
11334 .clone()
11335 .oneshot(Request::builder().uri("/livez").body(Body::empty()).unwrap())
11336 .await
11337 .unwrap();
11338 assert_eq!(still_live.status(), StatusCode::OK);
11339 }
11340
11341 #[cfg(unix)]
11350 #[tokio::test]
11351 async fn readyz_fails_when_the_spool_is_not_writable() {
11352 use std::os::unix::fs::PermissionsExt as _;
11353 let scratch = tempfile::tempdir().unwrap();
11354 let spool = scratch.path().join("spool");
11355 std::fs::create_dir_all(&spool).unwrap();
11356 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11357 let address = listener.local_addr().unwrap();
11358 let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
11359 let _server = tokio::spawn(async move {
11360 axum::serve(listener, provider).await.unwrap();
11361 });
11362 let config = crate::config::HarnessConfig::for_tests(
11363 &format!("http://{address}"),
11364 &spool.to_string_lossy(),
11365 &spool.to_string_lossy(),
11366 );
11367 let state = AppState::new(
11368 config,
11369 Arc::new(InMemoryStore::new()),
11370 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11371 Arc::new(NullBus),
11372 None,
11373 Arc::new(Ed25519Signer::from_seed(&[23; 32])),
11374 )
11375 .unwrap();
11376 let app = crate::build_router(state);
11377
11378 std::fs::set_permissions(&spool, std::fs::Permissions::from_mode(0o555)).unwrap();
11380 if std::fs::write(spool.join("root-probe"), b"x").is_ok() {
11381 let _ = std::fs::remove_file(spool.join("root-probe"));
11383 std::fs::set_permissions(&spool, std::fs::Permissions::from_mode(0o755)).unwrap();
11384 return;
11385 }
11386 let not_ready = app
11387 .clone()
11388 .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11389 .await
11390 .unwrap();
11391 assert_eq!(
11392 not_ready.status(),
11393 StatusCode::SERVICE_UNAVAILABLE,
11394 "an unwritable (readable) spool must flip readiness — the full-disk incident shape"
11395 );
11396
11397 std::fs::set_permissions(&spool, std::fs::Permissions::from_mode(0o755)).unwrap();
11399 let ready = app
11400 .clone()
11401 .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11402 .await
11403 .unwrap();
11404 assert_eq!(ready.status(), StatusCode::OK);
11405 let residue = std::fs::read_dir(&spool)
11407 .unwrap()
11408 .filter_map(Result::ok)
11409 .any(|entry| {
11410 entry
11411 .file_name()
11412 .to_str()
11413 .is_some_and(|name| name.starts_with(".readyz-"))
11414 });
11415 assert!(!residue, "probe files must not accumulate in the spool");
11416 }
11417
11418 #[tokio::test]
11425 async fn allowed_hosts_refuses_rebound_host_headers() {
11426 let scratch = tempfile::tempdir().unwrap();
11427 let spool = scratch.path().join("spool");
11428 std::fs::create_dir_all(&spool).unwrap();
11429 let mut config = crate::config::HarnessConfig::for_tests(
11430 "http://127.0.0.1:9",
11431 &spool.to_string_lossy(),
11432 &spool.to_string_lossy(),
11433 );
11434 config.allowed_hosts = vec!["localhost".to_owned(), "[::1]".to_owned()];
11435 let state = AppState::new(
11436 config,
11437 Arc::new(InMemoryStore::new()),
11438 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11439 Arc::new(NullBus),
11440 None,
11441 Arc::new(Ed25519Signer::from_seed(&[23; 32])),
11442 )
11443 .unwrap();
11444 let app = crate::build_router(state);
11445
11446 let probe = |host: Option<&'static str>| {
11447 let mut builder = Request::builder().uri("/health");
11448 if let Some(host) = host {
11449 builder = builder.header("host", host);
11450 }
11451 builder.body(Body::empty()).unwrap()
11452 };
11453
11454 for host in ["localhost", "localhost:8484", "LOCALHOST", "[::1]:8484"] {
11456 let response = app.clone().oneshot(probe(Some(host))).await.unwrap();
11457 assert_eq!(response.status(), StatusCode::OK, "host {host:?} must be allowed");
11458 }
11459 for host in [
11461 Some("evil.attacker.example"),
11462 Some("localhost.attacker.example"),
11463 None,
11464 ] {
11465 let response = app.clone().oneshot(probe(host)).await.unwrap();
11466 assert_eq!(
11467 response.status(),
11468 StatusCode::FORBIDDEN,
11469 "host {host:?} must be refused"
11470 );
11471 }
11472 }
11473
11474 #[tokio::test]
11479 async fn metrics_scrape_emits_open_sessions_gauge_and_signing_key_info() {
11480 let scratch = tempfile::tempdir().unwrap();
11481 let spool = scratch.path().join("spool");
11482 std::fs::create_dir_all(&spool).unwrap();
11483 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11484 let address = listener.local_addr().unwrap();
11485 let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
11486 let _server = tokio::spawn(async move {
11487 axum::serve(listener, provider).await.unwrap();
11488 });
11489 let config = crate::config::HarnessConfig::for_tests(
11490 &format!("http://{address}"),
11491 &spool.to_string_lossy(),
11492 &spool.to_string_lossy(),
11493 );
11494 let signer = Ed25519Signer::from_seed(&[31; 32]);
11495 use av_receipts::Signer as _;
11496 let key_id = signer.key_id().to_owned();
11497 let public_key_hex = hex::encode(signer.public_key_bytes());
11498 let state = AppState::new(
11499 config,
11500 Arc::new(InMemoryStore::new()),
11501 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11502 Arc::new(NullBus),
11503 None,
11504 Arc::new(signer),
11505 )
11506 .unwrap();
11507 state
11510 .metrics
11511 .gauge(
11512 &format!("av_signing_key_info{{key_id=\"{key_id}\",public_key_hex=\"{public_key_hex}\"}}"),
11513 "test signing key info",
11514 )
11515 .set(1);
11516 let app = crate::build_router(state.clone());
11517
11518 let response = app
11520 .clone()
11521 .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
11522 .await
11523 .unwrap();
11524 let body = String::from_utf8_lossy(
11525 &axum::body::to_bytes(response.into_body(), 256 * 1024)
11526 .await
11527 .unwrap(),
11528 )
11529 .into_owned();
11530 assert!(body.contains("# TYPE av_open_sessions gauge"), "{body}");
11531 assert!(body.contains("av_open_sessions 0"), "{body}");
11532 assert!(body.contains("# TYPE av_worker_queue_depth gauge"), "{body}");
11534 assert!(body.contains("# TYPE av_spool_bytes gauge"), "{body}");
11535 assert!(body.contains("# TYPE av_requests_total counter"), "{body}");
11536 assert!(
11537 body.contains("av_requests_total{route=\"chat\",status_class=\"2xx\"}"),
11538 "{body}"
11539 );
11540 assert!(
11541 body.contains("av_upstream_errors_total{kind=\"timeout\"}"),
11542 "{body}"
11543 );
11544 assert!(body.contains("av_upstream_latency_seconds"), "{body}");
11545 assert!(
11546 body.contains(&format!("av_signing_key_info{{key_id=\"{key_id}\"")),
11547 "signing-key info gauge missing from scrape: {body}"
11548 );
11549
11550 state.sessions.insert_recovered(crate::session::Session::new(
11552 "gauge-test".to_owned(),
11553 crate::session::Workflow::Unsigned,
11554 av_events::AgentIdentity {
11555 version: "1".to_owned(),
11556 charter: "test".into(),
11557 instance_uid: "instance-1".to_owned(),
11558 ttl_remaining_s: Some(600),
11559 },
11560 Default::default(),
11561 ));
11562 let response = app
11563 .clone()
11564 .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
11565 .await
11566 .unwrap();
11567 let body = String::from_utf8_lossy(
11568 &axum::body::to_bytes(response.into_body(), 256 * 1024)
11569 .await
11570 .unwrap(),
11571 )
11572 .into_owned();
11573 assert!(body.contains("av_open_sessions 1"), "{body}");
11574 }
11575
11576 #[tokio::test]
11586 async fn decode_cached_tool_body_returns_stable_error_message() {
11587 let helper_err = decode_cached_tool_body("zz").expect_err("`zz` must not hex-decode");
11588 let body = String::from_utf8(
11589 axum::body::to_bytes((*helper_err).into_body(), 16 * 1024)
11590 .await
11591 .unwrap()
11592 .to_vec(),
11593 )
11594 .expect("lifecycle_error body is UTF-8");
11595 assert!(
11596 body.contains("cached tool outcome is corrupt"),
11597 "leak-free stable error message expected in body: {body}"
11598 );
11599 assert!(
11603 !body.contains("at 0"),
11604 "hex::FromHexError position must not leak: {body}"
11605 );
11606 let outcome_response = ToolOutcome {
11609 status: 200,
11610 body_hex: "zz".to_owned(),
11611 content_type: None,
11612 }
11613 .into_response();
11614 let outcome_body = String::from_utf8(
11615 axum::body::to_bytes(outcome_response.into_body(), 16 * 1024)
11616 .await
11617 .unwrap()
11618 .to_vec(),
11619 )
11620 .expect("into_response body is UTF-8");
11621 assert!(
11622 outcome_body.contains("cached tool outcome is corrupt"),
11623 "ToolOutcome::into_response must route through the shared helper: {outcome_body}"
11624 );
11625 }
11626}