1use crate::config::HarnessConfig;
4use crate::reconciler::Finalizer;
5use crate::session::{Session, SessionLease, SessionRegistry, Workflow};
6use crate::worker::{AtifCapture, ResponsePermit, WorkerHandle, WorkerJob};
7use av_bridge::EventBus;
8use av_core::metrics::Registry;
9use av_core::time::elapsed_us;
10use av_events::{AgentIdentity, EventClass, EventMetrics, StatusId, StopReason};
11use av_identity::IdentityValidator;
12use av_loopdetect::{BreakerAction, BreakerState, Embedder, HashEmbedder, NoopVectorSink, VectorSink};
13use av_receipts::Signer;
14use av_sandbox::{Sandbox, ToolVerdict};
15use av_state::{ActionBudget, BudgetDecision, StateStore};
16use axum::http::{HeaderMap, HeaderName, HeaderValue};
17use serde_json::Value;
18use std::sync::Arc;
19use std::time::Instant;
20
21pub mod supervision_help {
29 pub const RECONCILER_PANICS: &str = "Reconciler tick body panicked and was caught; the reconciler \
31 continues on the next tick";
32 pub const BRIDGE_MAINTENANCE_PANICS: &str = "Bridge maintenance tick panicked and was caught; the loop \
34 continues on the next interval";
35 pub const BRIDGE_MAINTENANCE_ERRORS: &str =
37 "Bridge maintenance tick returned an error; the loop continues \
38 on the next interval";
39 pub const BRIDGE_MAINTENANCE_JOIN_ERRORS: &str =
41 "Bridge maintenance tick's spawned task failed to join (panic \
42 or cancellation); the loop continues on the next interval";
43 pub const ATIF_RETENTION_PANICS: &str = "ATIF retention sweep tick panicked and was caught; the loop \
45 continues on the next hour";
46 pub const ATIF_RETENTION_ERRORS: &str =
48 "ATIF retention sweep tick returned an error; the loop continues \
49 on the next hour";
50}
51
52pub(crate) const SESSION_HEADER: &str = "x-av-session";
53const WORKFLOW_HEADER: &str = "x-av-workflow";
54pub(crate) const MIDDLEWARE_US_HEADER: &str = "x-av-middleware-us";
55
56pub const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
58
59pub const SESSION_CLOSE_TIMEOUTS_HELP: &str =
65 "Per-session close hit the shutdown-time per-session deadline (3 s) and \
66 was deferred to restart-time spool recovery. A sustained rate > 0 on \
67 every rollout indicates a class of sessions that regularly hang their \
68 close (leaked leases, dropped worker permits, unresponsive bridge \
69 publish) — the coincident session id in the shutdown warn log is the \
70 correlation key. Distinct from `av_http_shutdown_drain_timeouts_total` \
71 which fires on the OUTER phase timeout.";
72
73pub const MCP_DRAIN_TIMEOUTS_HELP: &str =
77 "Shutdown MCP-inflight drain hit its 5 s deadline before every detached \
78 mcp_call_inner spawn completed. Downstream effects: some detached \
79 tool bodies were interrupted at close and their sessions may be \
80 quarantined at restart-time recovery. Any occurrence during a \
81 rollout indicates a class of tool calls that regularly outlive HTTP \
82 drain (long-running upstream tool, hung sandbox); check the \
83 coincident tracing warn line.";
84
85pub const IDLE_CLOSE_TIMEOUTS_HELP: &str =
89 "Idle-close reached the per-session deadline (90 s) and returned to \
90 the next tick — indicates a session with an active lease that never \
91 drops (stuck stream, hung worker) or a bridge publish stalled behind \
92 an unresponsive broker. A steady rate > 0 requires operator \
93 investigation of the coincident session id.";
94
95pub(crate) const TOOL_INVOKE_SCOPE: &str = "tool:invoke";
97
98pub(crate) fn tool_scope(tool: &str) -> String {
100 format!("tool:{tool}")
101}
102
103#[derive(Clone)]
105pub struct AppState {
106 pub config: Arc<HarnessConfig>,
108 pub store: Arc<dyn StateStore>,
110 pub sandbox: Arc<Sandbox>,
112 pub bridge: Arc<dyn EventBus>,
115 pub sessions: Arc<SessionRegistry>,
117 pub worker: WorkerHandle,
119 pub identity: Option<Arc<IdentityValidator>>,
121 pub exchanged_revocations: Arc<dyn av_identity::RevocationStore>,
123 pub(crate) revocation_audit_window: Arc<parking_lot::Mutex<(Instant, u32)>>,
124 pub metrics: Arc<Registry>,
126 pub(crate) hot_metrics: Arc<HotMetrics>,
131 pub client: reqwest::Client,
133 pub(crate) backend_client: reqwest::Client,
136 pub(crate) upstream_auth: Option<(HeaderName, HeaderValue)>,
139 pub(crate) tool_auth: Option<HeaderValue>,
141 pub(crate) tool_audit_gates:
147 Arc<parking_lot::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
148 pub(crate) tool_audits_emitted: Arc<parking_lot::Mutex<std::collections::HashSet<String>>>,
152 pub(crate) provider_adapter: Arc<dyn crate::provider::ProviderAdapter>,
156 pub finalizer: Finalizer,
158 pub draining: Arc<std::sync::atomic::AtomicBool>,
163 pub(crate) identity_rejection_window: Arc<parking_lot::Mutex<(Instant, u32)>>,
168 pub(crate) journal_key: [u8; 32],
169 pub mcp_inflight: Arc<crate::inflight::InflightTracker>,
178 pub mcp_admission: Arc<tokio::sync::Semaphore>,
194 pub(crate) mcp_admission_refusals_counter: Arc<av_core::metrics::Counter>,
205 pub backend_router: Arc<crate::backend::BackendRouter>,
208 pub pdp: Arc<crate::authz::PolicyDecisionPoint>,
211 pub token_signer: Option<Arc<crate::authz::TokenSigner>>,
214 pub redaction: Option<Arc<av_redact::RedactionEngine>>,
217 pub mcp_backends: Arc<crate::mcp_client::McpBackendClient>,
220 pub(crate) mcp_sessions: Arc<crate::mcp::SessionSigner>,
222 pub authzen: Option<Arc<crate::authzen::AuthzenClient>>,
224 pub(crate) content_sink: crate::content::SharedContentSink,
226 pub(crate) workload: crate::workload::SharedWorkloadIssuer,
228}
229
230pub(crate) struct HotMetrics {
240 pub(crate) stage_histograms: [Arc<av_core::metrics::Histogram>; 5],
241 pub(crate) stage_strict_budget_counters: [Arc<av_core::metrics::Counter>; 5],
242 pub(crate) strict_stage_budget: bool,
247 pub(crate) request_counters: [[Arc<av_core::metrics::Counter>; 5]; 15],
256 pub(crate) request_duration_histograms: [Arc<av_core::metrics::Histogram>; 15],
262 pub(crate) upstream_latency_histogram: Arc<av_core::metrics::Histogram>,
268 pub(crate) upstream_errors_counters: [Arc<av_core::metrics::Counter>; 4],
274 pub(crate) dashboard_request_counters: [[Arc<av_core::metrics::Counter>; 2]; 3],
280 pub(crate) dashboard_request_duration_histograms: [Arc<av_core::metrics::Histogram>; 3],
283}
284
285#[derive(Copy, Clone, Debug)]
286pub(crate) enum Stage {
287 Identity,
288 Quota,
289 Sanitize,
290 Compression,
291 Dispatch,
292}
293
294impl Stage {
295 pub(crate) const ORDER: [Stage; 5] = [
296 Stage::Identity,
297 Stage::Quota,
298 Stage::Sanitize,
299 Stage::Compression,
300 Stage::Dispatch,
301 ];
302
303 pub(crate) fn label(self) -> &'static str {
304 match self {
305 Stage::Identity => "identity",
306 Stage::Quota => "quota",
307 Stage::Sanitize => "sanitize",
308 Stage::Compression => "compression",
309 Stage::Dispatch => "dispatch",
310 }
311 }
312
313 pub(crate) fn index(self) -> usize {
314 match self {
315 Stage::Identity => 0,
316 Stage::Quota => 1,
317 Stage::Sanitize => 2,
318 Stage::Compression => 3,
319 Stage::Dispatch => 4,
320 }
321 }
322
323 fn from_label(label: &str) -> Option<Self> {
324 Stage::ORDER.into_iter().find(|s| s.label() == label)
325 }
326}
327
328#[derive(Copy, Clone, Debug)]
334pub(crate) enum Route {
335 Chat,
336 Mcp,
337 Health,
338 Livez,
339 Readyz,
340 Metrics,
341 SessionClose,
342 SessionPromote,
343 Dashboard,
344 TokenExchange,
345 Revoke,
346 Introspect,
347 AdminRevocation,
348 Jwks,
349 Other,
350}
351
352impl Route {
353 pub(crate) const ORDER: [Route; 15] = [
354 Route::Chat,
355 Route::Mcp,
356 Route::Health,
357 Route::Livez,
358 Route::Readyz,
359 Route::Metrics,
360 Route::SessionClose,
361 Route::SessionPromote,
362 Route::Dashboard,
363 Route::TokenExchange,
364 Route::Revoke,
365 Route::Introspect,
366 Route::AdminRevocation,
367 Route::Jwks,
368 Route::Other,
369 ];
370
371 pub(crate) fn label(self) -> &'static str {
372 match self {
373 Route::Chat => "chat",
374 Route::Mcp => "mcp",
375 Route::Health => "health",
376 Route::Livez => "livez",
377 Route::Readyz => "readyz",
378 Route::Metrics => "metrics",
379 Route::SessionClose => "session_close",
380 Route::SessionPromote => "session_promote",
381 Route::Dashboard => "dashboard",
382 Route::TokenExchange => "token_exchange",
383 Route::Revoke => "revoke",
384 Route::Introspect => "introspect",
385 Route::AdminRevocation => "admin_revocation",
386 Route::Jwks => "jwks",
387 Route::Other => "other",
388 }
389 }
390
391 pub(crate) fn index(self) -> usize {
392 match self {
393 Route::Chat => 0,
394 Route::Mcp => 1,
395 Route::Health => 2,
396 Route::Livez => 3,
397 Route::Readyz => 4,
398 Route::Metrics => 5,
399 Route::SessionClose => 6,
400 Route::SessionPromote => 7,
401 Route::Dashboard => 8,
402 Route::TokenExchange => 9,
403 Route::Revoke => 10,
404 Route::Introspect => 11,
405 Route::AdminRevocation => 12,
406 Route::Jwks => 13,
407 Route::Other => 14,
408 }
409 }
410}
411
412#[derive(Copy, Clone, Debug)]
417pub(crate) enum StatusClass {
418 Informational, Success, Redirection, ClientError, ServerError, }
424
425impl StatusClass {
426 pub(crate) const ORDER: [StatusClass; 5] = [
427 StatusClass::Informational,
428 StatusClass::Success,
429 StatusClass::Redirection,
430 StatusClass::ClientError,
431 StatusClass::ServerError,
432 ];
433
434 pub(crate) fn label(self) -> &'static str {
435 match self {
436 StatusClass::Informational => "1xx",
437 StatusClass::Success => "2xx",
438 StatusClass::Redirection => "3xx",
439 StatusClass::ClientError => "4xx",
440 StatusClass::ServerError => "5xx",
441 }
442 }
443
444 pub(crate) fn index(self) -> usize {
445 match self {
446 StatusClass::Informational => 0,
447 StatusClass::Success => 1,
448 StatusClass::Redirection => 2,
449 StatusClass::ClientError => 3,
450 StatusClass::ServerError => 4,
451 }
452 }
453
454 pub(crate) fn from_u16(status: u16) -> Self {
455 match status {
456 100..=199 => StatusClass::Informational,
457 200..=299 => StatusClass::Success,
458 300..=399 => StatusClass::Redirection,
459 400..=499 => StatusClass::ClientError,
460 _ => StatusClass::ServerError,
461 }
462 }
463}
464
465#[derive(Copy, Clone, Debug)]
469pub(crate) enum UpstreamErrorKind {
470 Timeout,
471 Connect,
472 Send,
473 Http5xx,
474}
475
476impl UpstreamErrorKind {
477 pub(crate) const ORDER: [UpstreamErrorKind; 4] = [
478 UpstreamErrorKind::Timeout,
479 UpstreamErrorKind::Connect,
480 UpstreamErrorKind::Send,
481 UpstreamErrorKind::Http5xx,
482 ];
483
484 pub(crate) fn label(self) -> &'static str {
485 match self {
486 UpstreamErrorKind::Timeout => "timeout",
487 UpstreamErrorKind::Connect => "connect",
488 UpstreamErrorKind::Send => "send",
489 UpstreamErrorKind::Http5xx => "http_5xx",
490 }
491 }
492
493 pub(crate) fn index(self) -> usize {
494 match self {
495 UpstreamErrorKind::Timeout => 0,
496 UpstreamErrorKind::Connect => 1,
497 UpstreamErrorKind::Send => 2,
498 UpstreamErrorKind::Http5xx => 3,
499 }
500 }
501
502 pub(crate) fn from_reqwest(error: &reqwest::Error) -> Self {
506 if error.is_timeout() {
507 UpstreamErrorKind::Timeout
508 } else if error.is_connect() {
509 UpstreamErrorKind::Connect
510 } else {
511 UpstreamErrorKind::Send
512 }
513 }
514}
515
516#[derive(Copy, Clone, Debug)]
519pub(crate) enum DashboardEndpoint {
520 Stats,
521 List,
522 Detail,
523}
524
525impl DashboardEndpoint {
526 pub(crate) const ORDER: [DashboardEndpoint; 3] = [
527 DashboardEndpoint::Stats,
528 DashboardEndpoint::List,
529 DashboardEndpoint::Detail,
530 ];
531
532 pub(crate) fn label(self) -> &'static str {
533 match self {
534 DashboardEndpoint::Stats => "stats",
535 DashboardEndpoint::List => "list",
536 DashboardEndpoint::Detail => "detail",
537 }
538 }
539
540 pub(crate) fn index(self) -> usize {
541 match self {
542 DashboardEndpoint::Stats => 0,
543 DashboardEndpoint::List => 1,
544 DashboardEndpoint::Detail => 2,
545 }
546 }
547}
548
549#[derive(Copy, Clone, Debug)]
552pub(crate) enum DashboardStatus {
553 Ok,
554 NotFound,
555}
556
557impl DashboardStatus {
558 pub(crate) const ORDER: [DashboardStatus; 2] = [DashboardStatus::Ok, DashboardStatus::NotFound];
559
560 pub(crate) fn label(self) -> &'static str {
561 match self {
562 DashboardStatus::Ok => "ok",
563 DashboardStatus::NotFound => "not_found",
564 }
565 }
566
567 pub(crate) fn index(self) -> usize {
568 match self {
569 DashboardStatus::Ok => 0,
570 DashboardStatus::NotFound => 1,
571 }
572 }
573}
574
575impl HotMetrics {
576 fn new(metrics: &Registry, config_strict_stage_budget: bool) -> Self {
577 let stage_histograms: [Arc<av_core::metrics::Histogram>; 5] = Stage::ORDER.map(|stage| {
583 metrics.histogram_with_bounds(
584 &format!("av_stage_duration_seconds{{stage=\"{}\"}}", stage.label()),
585 "Harness stage latency",
586 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
587 )
588 });
589 let stage_strict_budget_counters: [Arc<av_core::metrics::Counter>; 5] = Stage::ORDER.map(|stage| {
590 metrics.counter(
591 &format!("av_strict_budget_breaches_total{{stage=\"{}\"}}", stage.label()),
592 "Middleware stages that exceeded the strict per-stage budget",
593 )
594 });
595 let _ = metrics.histogram_with_bounds(
603 "av_stage_duration_seconds{stage=\"quota_post_compression\"}",
604 "Harness stage latency",
605 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
606 );
607 let request_counters: [[Arc<av_core::metrics::Counter>; 5]; 15] = Route::ORDER.map(|route| {
614 StatusClass::ORDER.map(|status| {
615 metrics.counter(
616 &format!(
617 "av_requests_total{{route=\"{}\",status_class=\"{}\"}}",
618 route.label(),
619 status.label()
620 ),
621 "HTTP requests by route and status class",
622 )
623 })
624 });
625 let request_duration_histograms: [Arc<av_core::metrics::Histogram>; 15] = Route::ORDER.map(|route| {
626 metrics.histogram_with_bounds(
627 &format!("av_request_duration_seconds{{route=\"{}\"}}", route.label()),
628 "End-to-end HTTP request latency by route",
629 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
630 )
631 });
632 let upstream_latency_histogram = metrics.histogram_with_bounds(
635 "av_upstream_latency_seconds",
636 "Time to upstream response headers",
637 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
638 );
639 let upstream_errors_counters: [Arc<av_core::metrics::Counter>; 4] =
640 UpstreamErrorKind::ORDER.map(|kind| {
641 metrics.counter(
642 &format!("av_upstream_errors_total{{kind=\"{}\"}}", kind.label()),
643 "Upstream failures by kind",
644 )
645 });
646 let dashboard_request_counters: [[Arc<av_core::metrics::Counter>; 2]; 3] = DashboardEndpoint::ORDER
650 .map(|endpoint| {
651 DashboardStatus::ORDER.map(|status| {
652 metrics.counter(
653 &format!(
654 "av_dashboard_requests_total{{endpoint=\"{}\",status=\"{}\"}}",
655 endpoint.label(),
656 status.label()
657 ),
658 "Dashboard endpoint requests, labeled by status",
659 )
660 })
661 });
662 let dashboard_request_duration_histograms: [Arc<av_core::metrics::Histogram>; 3] =
663 DashboardEndpoint::ORDER.map(|endpoint| {
664 metrics.histogram(
665 &format!(
666 "av_dashboard_request_duration_seconds{{endpoint=\"{}\"}}",
667 endpoint.label()
668 ),
669 "Dashboard endpoint latency",
670 )
671 });
672 Self {
673 stage_histograms,
674 stage_strict_budget_counters,
675 strict_stage_budget: config_strict_stage_budget || truthy_env("AV_STRICT_BUDGET"),
676 request_counters,
677 request_duration_histograms,
678 upstream_latency_histogram,
679 upstream_errors_counters,
680 dashboard_request_counters,
681 dashboard_request_duration_histograms,
682 }
683 }
684}
685
686pub fn describe_upstream_auth(config: &HarnessConfig) -> String {
689 if config.upstream_authorization_passthrough {
690 "passthrough(client Authorization)".to_owned()
691 } else if let Some(env) = config.upstream_api_key_env.as_deref() {
692 format!("api-key from ${env} in header {:?}", config.upstream_auth_header)
693 } else if let Some(file) = config.upstream_api_key_file.as_deref() {
694 format!(
695 "api-key from file {file} in header {:?}",
696 config.upstream_auth_header
697 )
698 } else {
699 "none".to_owned()
700 }
701}
702
703pub(crate) fn resolve_upstream_auth(
709 config: &HarnessConfig,
710) -> Result<Option<(HeaderName, HeaderValue)>, PipelineError> {
711 let key = match read_secret(
712 config.upstream_api_key_env.as_deref(),
713 config.upstream_api_key_file.as_deref(),
714 "upstream API key",
715 )? {
716 Some(key) => key,
717 None => return Ok(None),
718 };
719 let name = HeaderName::try_from(config.upstream_auth_header.as_str()).map_err(|_| {
720 PipelineError::upstream(format!(
721 "upstream_auth_header {:?} is not a valid header name",
722 config.upstream_auth_header
723 ))
724 })?;
725 let rendered = if config.upstream_auth_scheme.is_empty() {
726 key
727 } else {
728 format!("{} {key}", config.upstream_auth_scheme)
729 };
730 let mut value = HeaderValue::from_str(&rendered).map_err(|_| {
731 PipelineError::upstream(
732 "upstream API key contains bytes that cannot appear in an HTTP header".to_owned(),
733 )
734 })?;
735 value.set_sensitive(true);
736 Ok(Some((name, value)))
737}
738
739pub(crate) fn resolve_tool_auth(config: &HarnessConfig) -> Result<Option<HeaderValue>, PipelineError> {
741 let token = match read_secret(
742 config.tool_upstream_bearer_env.as_deref(),
743 config.tool_upstream_bearer_file.as_deref(),
744 "tool upstream bearer token",
745 )? {
746 Some(token) => token,
747 None => return Ok(None),
748 };
749 let mut value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
750 PipelineError::upstream(
751 "tool upstream bearer token contains bytes that cannot appear in an HTTP header".to_owned(),
752 )
753 })?;
754 value.set_sensitive(true);
755 Ok(Some(value))
756}
757
758pub(crate) fn read_secret(
762 env_name: Option<&str>,
763 file_path: Option<&str>,
764 what: &str,
765) -> Result<Option<String>, PipelineError> {
766 read_secret_from(|name| std::env::var(name).ok(), env_name, file_path, what)
767}
768
769fn read_secret_from(
771 get_env: impl Fn(&str) -> Option<String>,
772 env_name: Option<&str>,
773 file_path: Option<&str>,
774 what: &str,
775) -> Result<Option<String>, PipelineError> {
776 if let Some(name) = env_name {
777 let value = get_env(name).ok_or_else(|| {
778 PipelineError::upstream(format!(
779 "{what}: environment variable {name} is not set (export it or update the config)"
780 ))
781 })?;
782 let value = value.trim().to_owned();
783 if value.is_empty() {
784 return Err(PipelineError::upstream(format!(
785 "{what}: environment variable {name} is set but empty"
786 )));
787 }
788 return Ok(Some(value));
789 }
790 if let Some(path) = file_path {
791 require_owner_only_secret(std::path::Path::new(path))
792 .map_err(|error| PipelineError::upstream(format!("{what}: {error}")))?;
793 let value = std::fs::read_to_string(path).map_err(|error| PipelineError::Upstream {
794 context: format!("{what}: read {path}: {error}"),
795 source: Some(Box::new(error)),
796 })?;
797 let value = value.trim().to_owned();
798 if value.is_empty() {
799 return Err(PipelineError::upstream(format!("{what}: file {path} is empty")));
800 }
801 return Ok(Some(value));
802 }
803 Ok(None)
804}
805
806pub(crate) fn require_owner_only_secret(path: &std::path::Path) -> Result<(), String> {
810 #[cfg(unix)]
811 {
812 use std::os::unix::fs::MetadataExt as _;
813 let metadata = std::fs::symlink_metadata(path)
814 .map_err(|error| format!("stat secret file {}: {error}", path.display()))?;
815 let file_type = metadata.file_type();
816 if file_type.is_symlink() {
817 return Err(format!(
818 "secret file {} is a symbolic link; refusing to follow",
819 path.display()
820 ));
821 }
822 if !file_type.is_file() {
823 return Err(format!("secret file {} is not a regular file", path.display()));
824 }
825 let mode = metadata.mode() & 0o777;
826 if mode & 0o077 != 0 {
827 return Err(format!(
828 "secret file {} has mode 0o{mode:03o}; must be owner-only (chmod 600 {})",
829 path.display(),
830 path.display()
831 ));
832 }
833 }
834 #[cfg(not(unix))]
835 {
836 let _ = path;
837 }
838 Ok(())
839}
840
841pub struct PreparedRequest {
843 pub session: Arc<Session>,
845 pub identity: AgentIdentity,
847 pub payload: Value,
849 pub middleware_us: u64,
851 admission_debit: AdmissionDebit,
857 capture_guard: ResponseCaptureGuard,
868 lease: SessionLease,
869 client_authorization: Option<HeaderValue>,
871 upstream_passthrough_headers: Vec<(axum::http::HeaderName, HeaderValue)>,
876}
877
878pub struct ForwardedResponse {
880 pub response: reqwest::Response,
882 pub(crate) billed_prompt_tokens: u64,
887 pub(crate) capture_guard: ResponseCaptureGuard,
892 pub(crate) lease: SessionLease,
893}
894
895pub(crate) struct ResponseCaptureGuard {
909 worker: crate::worker::WorkerHandle,
910 session: Arc<Session>,
911 identity: AgentIdentity,
912 marker: Option<String>,
913 attempt_id: String,
914 permit: Option<ResponsePermit>,
922 armed: bool,
923}
924
925impl ResponseCaptureGuard {
926 fn new(
927 worker: crate::worker::WorkerHandle,
928 session: Arc<Session>,
929 identity: AgentIdentity,
930 attempt_id: String,
931 permit: Option<ResponsePermit>,
932 ) -> Self {
933 Self {
934 worker,
935 session,
936 identity,
937 marker: None,
938 attempt_id,
939 permit,
940 armed: true,
941 }
942 }
943
944 pub(crate) fn take_permit(&mut self) -> Option<ResponsePermit> {
949 self.permit.take()
950 }
951
952 pub(crate) fn set_marker(&mut self, marker: String) {
954 self.marker = Some(marker);
955 }
956
957 pub(crate) fn attempt_id(&self) -> &str {
958 &self.attempt_id
959 }
960
961 pub(crate) fn disarm(mut self) -> (Option<String>, String) {
964 self.armed = false;
965 (self.marker.take(), std::mem::take(&mut self.attempt_id))
966 }
967
968 fn defuse(&mut self) -> Option<String> {
971 self.armed = false;
972 self.marker.take()
973 }
974}
975
976impl Drop for ResponseCaptureGuard {
977 fn drop(&mut self) {
978 if !self.armed {
979 return;
980 }
981 let job = refused_response_failure_job(
982 Arc::clone(&self.session),
983 self.identity.clone(),
984 "request_cancelled_before_response_capture".to_owned(),
985 self.marker.take(),
986 std::mem::take(&mut self.attempt_id),
987 );
988 let submitted = match self.permit.take() {
995 Some(permit) => permit.submit(&self.worker, job),
996 None => self.worker.try_submit(job),
997 };
998 if submitted.is_err() {
999 self.session.mark_capture_failed();
1000 }
1001 }
1002}
1003
1004pub(crate) struct AdmissionDebit {
1023 store: Arc<dyn StateStore>,
1024 session_id: String,
1025 budget: av_state::BudgetSpec,
1026 principal: Option<(String, av_state::BudgetSpec)>,
1027 tokens: u64,
1028 metrics: Arc<Registry>,
1040}
1041
1042impl AdmissionDebit {
1043 fn take_for_dispatch(&mut self) -> u64 {
1047 std::mem::take(&mut self.tokens)
1048 }
1049
1050 fn refund_now(&mut self) {
1054 let tokens = std::mem::take(&mut self.tokens);
1055 if tokens == 0 {
1056 return;
1057 }
1058 ActionBudget::new(self.store.as_ref(), &self.session_id, &self.budget).refund_tokens(tokens);
1059 if let Some((principal_id, spec)) = self.principal.as_ref() {
1060 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec).refund_tokens(tokens);
1061 }
1062 }
1063}
1064
1065impl Drop for AdmissionDebit {
1066 fn drop(&mut self) {
1067 if self.tokens == 0 {
1068 return;
1069 }
1070 let tokens = std::mem::take(&mut self.tokens);
1078 let store = Arc::clone(&self.store);
1079 let session_id = std::mem::take(&mut self.session_id);
1080 let budget = std::mem::take(&mut self.budget);
1081 let principal = self.principal.take();
1082 let metrics = Arc::clone(&self.metrics);
1083 let sess_for_log = session_id.clone();
1100 let refund = move || {
1101 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1102 ActionBudget::new(store.as_ref(), &session_id, &budget).refund_tokens(tokens);
1103 if let Some((principal_id, spec)) = principal.as_ref() {
1104 ActionBudget::for_principal(store.as_ref(), principal_id, spec).refund_tokens(tokens);
1105 }
1106 }));
1107 if let Err(panic) = outcome {
1108 let msg = panic
1109 .downcast_ref::<&'static str>()
1110 .copied()
1111 .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
1112 .unwrap_or("panic payload was not a string");
1113 metrics
1114 .counter(
1115 "av_admission_refund_panics_total",
1116 "Admission-debit refund closure panicked in Drop; the session \
1117 and principal ledgers retain the debit until operator repair",
1118 )
1119 .inc();
1120 tracing::error!(
1121 session = %sess_for_log,
1122 tokens,
1123 panic = %msg,
1124 "admission-debit refund PANICKED (caught); \
1125 principal ledger drain persists until operator repair"
1126 );
1127 }
1128 };
1129 match tokio::runtime::Handle::try_current() {
1130 Ok(handle) => {
1131 handle.spawn_blocking(refund);
1132 }
1133 Err(_) => refund(),
1134 }
1135 }
1136}
1137
1138pub(crate) fn refused_response_failure_job(
1145 session: Arc<Session>,
1146 identity: AgentIdentity,
1147 reason: String,
1148 response_marker: Option<String>,
1149 response_attempt_id: String,
1150) -> WorkerJob {
1151 let atif = (session.workflow == Workflow::Unsigned).then(|| AtifCapture {
1152 source: av_atif::Source::Agent,
1153 message: Value::String(reason.clone()),
1154 reasoning_content: None,
1155 model_name: None,
1156 tool_calls: None,
1157 observation: None,
1158 llm_call_count: Some(0),
1159 });
1160 WorkerJob {
1161 session,
1162 identity,
1163 class: EventClass::StopReason,
1164 payload: serde_json::json!({"reason": reason, "direction": "upstream_response"}),
1165 text: String::new(),
1166 analyze_loop: false,
1167 status: StatusId::Failure,
1168 stop_reason: Some(StopReason::Other),
1169 native_stop_reason: None,
1170 metrics: EventMetrics::default(),
1171 cost_usd_micros: 0,
1172 prompt_token_correction: 0,
1173 atif,
1174 response_marker,
1175 response_attempt: Some(crate::worker::ResponseAttempt {
1176 id: response_attempt_id,
1177 terminal: true,
1178 }),
1179 }
1180}
1181
1182pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
1190
1191#[derive(Debug, thiserror::Error)]
1202#[non_exhaustive]
1203pub enum PipelineError {
1204 #[error("bad request: {context}")]
1206 BadRequest {
1207 context: String,
1209 #[source]
1211 source: Option<ErrorSource>,
1212 },
1213 #[error("unauthorized: {0}")]
1215 Unauthorized(String),
1216 #[error("request blocked: {context}")]
1218 Blocked {
1219 context: String,
1221 #[source]
1223 source: Option<ErrorSource>,
1224 },
1225 #[error("upstream request failed: {context}")]
1227 Upstream {
1228 context: String,
1230 #[source]
1232 source: Option<ErrorSource>,
1233 },
1234 #[error("upstream request timed out: {context}")]
1243 UpstreamTimeout {
1244 context: String,
1246 #[source]
1248 source: Option<ErrorSource>,
1249 },
1250 #[error("connection aborted: {0}")]
1252 Abort(String),
1253 #[error("service unavailable: {context}")]
1255 Unavailable {
1256 context: String,
1258 #[source]
1260 source: Option<ErrorSource>,
1261 },
1262}
1263
1264impl PipelineError {
1265 pub fn bad_request(context: impl Into<String>) -> Self {
1267 Self::BadRequest {
1268 context: context.into(),
1269 source: None,
1270 }
1271 }
1272
1273 pub fn bad_request_source<E>(error: E) -> Self
1277 where
1278 E: std::error::Error + Send + Sync + 'static,
1279 {
1280 Self::BadRequest {
1281 context: error.to_string(),
1282 source: Some(Box::new(error)),
1283 }
1284 }
1285
1286 pub fn blocked(context: impl Into<String>) -> Self {
1288 Self::Blocked {
1289 context: context.into(),
1290 source: None,
1291 }
1292 }
1293
1294 pub fn blocked_source<E>(error: E) -> Self
1296 where
1297 E: std::error::Error + Send + Sync + 'static,
1298 {
1299 Self::Blocked {
1300 context: error.to_string(),
1301 source: Some(Box::new(error)),
1302 }
1303 }
1304
1305 pub fn upstream(context: impl Into<String>) -> Self {
1307 Self::Upstream {
1308 context: context.into(),
1309 source: None,
1310 }
1311 }
1312
1313 pub fn upstream_source<E>(error: E) -> Self
1316 where
1317 E: std::error::Error + Send + Sync + 'static,
1318 {
1319 Self::Upstream {
1320 context: error.to_string(),
1321 source: Some(Box::new(error)),
1322 }
1323 }
1324
1325 pub fn upstream_timeout(context: impl Into<String>) -> Self {
1329 Self::UpstreamTimeout {
1330 context: context.into(),
1331 source: None,
1332 }
1333 }
1334
1335 pub fn unavailable(context: impl Into<String>) -> Self {
1337 Self::Unavailable {
1338 context: context.into(),
1339 source: None,
1340 }
1341 }
1342
1343 pub fn unavailable_source<E>(error: E) -> Self
1346 where
1347 E: std::error::Error + Send + Sync + 'static,
1348 {
1349 Self::Unavailable {
1350 context: error.to_string(),
1351 source: Some(Box::new(error)),
1352 }
1353 }
1354 pub fn status(&self) -> axum::http::StatusCode {
1366 match self {
1367 Self::BadRequest { .. } => axum::http::StatusCode::BAD_REQUEST,
1368 Self::Unauthorized(_) => axum::http::StatusCode::UNAUTHORIZED,
1369 Self::Blocked { .. } => axum::http::StatusCode::FORBIDDEN,
1370 Self::Upstream { .. } => axum::http::StatusCode::BAD_GATEWAY,
1371 Self::UpstreamTimeout { .. } => axum::http::StatusCode::GATEWAY_TIMEOUT,
1372 Self::Abort(_) => axum::http::StatusCode::CONFLICT,
1373 Self::Unavailable { .. } => axum::http::StatusCode::SERVICE_UNAVAILABLE,
1374 }
1375 }
1376}
1377
1378impl AppState {
1379 pub fn new(
1382 config: HarnessConfig,
1383 store: Arc<dyn StateStore>,
1384 sandbox: Arc<Sandbox>,
1385 bridge: Arc<dyn EventBus>,
1386 identity: Option<Arc<IdentityValidator>>,
1387 signer: Arc<dyn Signer>,
1388 ) -> Result<Self, PipelineError> {
1389 Self::new_with_embedder(
1390 config,
1391 store,
1392 sandbox,
1393 bridge,
1394 identity,
1395 signer,
1396 Arc::new(HashEmbedder::default()),
1397 )
1398 }
1399
1400 #[allow(clippy::too_many_arguments)]
1402 pub fn new_with_embedder(
1403 config: HarnessConfig,
1404 store: Arc<dyn StateStore>,
1405 sandbox: Arc<Sandbox>,
1406 bridge: Arc<dyn EventBus>,
1407 identity: Option<Arc<IdentityValidator>>,
1408 signer: Arc<dyn Signer>,
1409 embedder: Arc<dyn Embedder>,
1410 ) -> Result<Self, PipelineError> {
1411 Self::new_with_backends(
1412 config,
1413 store,
1414 sandbox,
1415 bridge,
1416 identity,
1417 signer,
1418 embedder,
1419 Arc::new(NoopVectorSink),
1420 )
1421 }
1422
1423 #[allow(clippy::too_many_arguments)]
1425 pub fn new_with_backends(
1426 config: HarnessConfig,
1427 store: Arc<dyn StateStore>,
1428 sandbox: Arc<Sandbox>,
1429 bridge: Arc<dyn EventBus>,
1430 identity: Option<Arc<IdentityValidator>>,
1431 signer: Arc<dyn Signer>,
1432 embedder: Arc<dyn Embedder>,
1433 vector_sink: Arc<dyn VectorSink>,
1434 ) -> Result<Self, PipelineError> {
1435 Self::new_with_backends_and_metrics(
1436 config,
1437 store,
1438 sandbox,
1439 bridge,
1440 identity,
1441 signer,
1442 embedder,
1443 vector_sink,
1444 Arc::new(Registry::new()),
1445 )
1446 }
1447
1448 #[allow(clippy::too_many_arguments)]
1455 pub fn new_with_backends_and_metrics(
1456 config: HarnessConfig,
1457 store: Arc<dyn StateStore>,
1458 sandbox: Arc<Sandbox>,
1459 bridge: Arc<dyn EventBus>,
1460 identity: Option<Arc<IdentityValidator>>,
1461 signer: Arc<dyn Signer>,
1462 embedder: Arc<dyn Embedder>,
1463 vector_sink: Arc<dyn VectorSink>,
1464 metrics: Arc<Registry>,
1465 ) -> Result<Self, PipelineError> {
1466 let config = Arc::new(config);
1467 for stage in ["identity", "quota", "sanitize", "compression", "dispatch"] {
1477 metrics.histogram_with_bounds(
1478 &format!("av_stage_duration_seconds{{stage=\"{stage}\"}}"),
1479 "Harness stage latency",
1480 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1481 );
1482 }
1483 metrics.counter(
1484 "av_events_dropped_total{stage=\"worker_queue\"}",
1485 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
1486 );
1487 metrics.counter(
1492 "av_events_dropped_total{stage=\"response_slot\"}",
1493 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
1494 );
1495 metrics.counter(
1499 "av_incomplete_sessions_total",
1500 "Sessions refused due to incomplete capture",
1501 );
1502 metrics.counter(
1503 "av_worker_panics_total",
1504 "Worker job panics isolated by supervisor",
1505 );
1506 metrics.counter("av_worker_errors_total", "Worker jobs that failed");
1507 metrics.counter("av_sessions_finalized_total", "Sessions finalized");
1508 metrics.counter("av_sessions_promoted_total", "Unsigned sessions promoted");
1509 metrics.counter("av_reconcile_errors_total", "Reconciliation errors");
1510 metrics.counter(
1517 "av_signed_recovery_skipped_total",
1518 "Signed sessions skipped during recovery due to per-session errors",
1519 );
1520 metrics.counter(
1521 "av_signed_recovery_quarantined_total",
1522 "Signed sessions re-adopted as capture-failed quarantines during recovery",
1523 );
1524 metrics.counter(
1525 "av_unsigned_recovery_skipped_total",
1526 "Unsigned step-journal consolidations skipped during recovery due to per-session errors",
1527 );
1528 metrics.counter(
1529 "av_atif_trajectory_recovery_skipped_total",
1530 "ATIF trajectories skipped during recovery due to per-session errors",
1531 );
1532 metrics.counter(
1533 "av_pending_close_completion_failed_total",
1534 "Pending-close completions that failed to finish their tail",
1535 );
1536 for kind in ["timeout", "connect", "send", "http_5xx"] {
1551 metrics.counter(
1552 &format!("av_upstream_errors_total{{kind=\"{kind}\"}}"),
1553 "Upstream failures by kind",
1554 );
1555 }
1556 metrics.histogram_with_bounds(
1557 "av_upstream_latency_seconds",
1558 "Time to upstream response headers",
1559 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1560 );
1561 for route in ["chat", "mcp", "session_close", "session_promote"] {
1562 for status_class in ["2xx", "4xx", "5xx"] {
1563 metrics.counter(
1564 &format!("av_requests_total{{route=\"{route}\",status_class=\"{status_class}\"}}"),
1565 "HTTP requests by route and status class",
1566 );
1567 }
1568 metrics.histogram_with_bounds(
1569 &format!("av_request_duration_seconds{{route=\"{route}\"}}"),
1570 "End-to-end HTTP request latency by route",
1571 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1572 );
1573 }
1574 for reason in [
1575 "too_large",
1576 "read_error",
1577 "invalid_json",
1578 "nonconformant",
1579 "unauthenticated",
1580 "provenance",
1581 ] {
1582 metrics.counter(
1583 &format!("av_atif_recovery_skipped_total{{reason=\"{reason}\"}}"),
1584 "ATIF spool files skipped during recovery",
1585 );
1586 }
1587 metrics.histogram("av_receipt_sign_duration_seconds", "Receipt signing latency");
1588 metrics.counter(
1595 "av_ephemeral_close_failures_total",
1596 "Auto-close of a completed ephemeral one-shot failed; \
1597 the session is left open until the idle sweeper reaps it",
1598 );
1599 metrics.counter(
1600 "av_stream_abort_close_failures_total",
1601 "Background close after a stream abort failed; \
1602 the session is left open until the idle sweeper reaps it",
1603 );
1604 metrics.counter(
1605 "av_stream_abort_no_runtime_total",
1606 "Stream abort observed no tokio runtime; capture marked failed for \
1607 reconciler retry",
1608 );
1609 metrics.counter("av_reconciler_panics_total", supervision_help::RECONCILER_PANICS);
1627 metrics.counter(
1637 "av_worker_shard_panics_total",
1638 "Worker shard driver panicked outside a job; supervised via catch_unwind",
1639 );
1640 metrics.counter(
1641 "av_bridge_maintenance_panics_total",
1642 supervision_help::BRIDGE_MAINTENANCE_PANICS,
1643 );
1644 metrics.counter(
1645 "av_bridge_maintenance_errors_total",
1646 supervision_help::BRIDGE_MAINTENANCE_ERRORS,
1647 );
1648 metrics.counter(
1649 "av_bridge_maintenance_join_errors_total",
1650 supervision_help::BRIDGE_MAINTENANCE_JOIN_ERRORS,
1651 );
1652 metrics.counter(
1658 "av_atif_retention_panics_total",
1659 supervision_help::ATIF_RETENTION_PANICS,
1660 );
1661 metrics.counter(
1662 "av_atif_retention_errors_total",
1663 supervision_help::ATIF_RETENTION_ERRORS,
1664 );
1665 metrics.counter(
1681 "av_tcp_nodelay_failures_total",
1682 "TCP_NODELAY setsockopt failed on an accepted connection; the \
1683 suboptimal-latency connection still serves. Rate-limited to a \
1684 single tracing warn per process to avoid a per-accept log storm \
1685 under half-open flood / SYN flood.",
1686 );
1687 metrics.counter(
1691 "av_stream_abort_panics_total",
1692 "Background close after a stream abort panicked and was caught; \
1693 the session is left open until the idle sweeper reaps it",
1694 );
1695 metrics.counter(
1696 "av_ephemeral_close_panics_total",
1697 "Auto-close of a completed ephemeral one-shot panicked and was \
1698 caught; the session is left open until the idle sweeper reaps it",
1699 );
1700 metrics.counter(
1701 "av_admission_refund_panics_total",
1702 "Admission-debit refund closure panicked in Drop; the session \
1703 and principal ledgers retain the debit until operator repair",
1704 );
1705 metrics.counter("av_idle_close_timeouts_total", IDLE_CLOSE_TIMEOUTS_HELP);
1712 metrics.counter(
1722 "av_shutdown_session_close_timeouts_total",
1723 SESSION_CLOSE_TIMEOUTS_HELP,
1724 );
1725 metrics.counter("av_shutdown_mcp_drain_timeouts_total", MCP_DRAIN_TIMEOUTS_HELP);
1735 for pass in [
1744 "adopt_strict_atif",
1745 "recover_signed_journals",
1746 "consolidate_step_journals",
1747 "retry_marked_promotions",
1748 "remove_acked_outboxes",
1749 "replay_lifecycle_outboxes",
1750 "quarantine_orphan_json",
1751 ] {
1752 metrics.counter(
1753 &format!("av_recovery_scan_capped_total{{pass=\"{pass}\"}}"),
1754 "Recovery pass hit the per-tick entry-scan cap and returned \
1755 early; the next tick will re-open the directory and resume. \
1756 A steady rate > 0 indicates a poisoned spool (millions of \
1757 stale files) that will starve legitimate recovery work \
1758 until an operator quarantines it.",
1759 );
1760 }
1761 metrics.gauge(
1771 "av_lifecycle_outbox_pending",
1772 crate::reconciler::LIFECYCLE_OUTBOX_PENDING_HELP,
1773 );
1774 metrics.histogram_with_bounds(
1778 "av_reconcile_duration_seconds",
1779 "Idle reconciliation duration",
1780 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1781 );
1782 metrics.histogram_with_bounds(
1783 "av_session_finalize_duration_seconds",
1784 "Session finalization latency",
1785 av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1786 );
1787 for endpoint in ["stats", "list", "detail"] {
1788 metrics.histogram(
1789 &format!("av_dashboard_request_duration_seconds{{endpoint=\"{endpoint}\"}}"),
1790 "Dashboard endpoint latency",
1791 );
1792 metrics.counter(
1793 &format!("av_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"ok\"}}"),
1794 "Dashboard endpoint requests, labeled by status",
1795 );
1796 metrics.counter(
1797 &format!("av_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"not_found\"}}"),
1798 "Dashboard endpoint requests, labeled by status",
1799 );
1800 }
1801 let sessions = Arc::new(SessionRegistry::new());
1802 let journal_key = crate::journal::key_from_signer(signer.as_ref());
1803 let redaction = if !config.redaction_patterns.is_empty() || !config.redaction_paths.is_empty() {
1804 let redact_config = av_redact::RedactionConfig {
1805 regex_patterns: config.redaction_patterns.clone(),
1806 pointer_paths: config.redaction_paths.clone(),
1807 include_builtin_patterns: true,
1808 ..av_redact::RedactionConfig::default()
1809 };
1810 let engine = av_redact::RedactionEngine::new(redact_config)
1811 .map_err(|e| PipelineError::bad_request(format!("redaction engine: {e}")))?;
1812 Some(Arc::new(engine))
1813 } else {
1814 None
1815 };
1816 let content_sink = crate::content::SharedContentSink::default();
1817 let worker = crate::worker::spawn_worker_with_outputs(
1818 config.worker_channel_capacity,
1819 Arc::clone(&bridge),
1820 embedder,
1821 Arc::clone(&vector_sink),
1822 Some(std::path::PathBuf::from(&config.atif_spool_dir)),
1823 journal_key,
1824 Arc::clone(&metrics),
1825 crate::content::AuditOutputs {
1826 redaction: redaction.clone(),
1827 content: Arc::clone(&content_sink),
1828 },
1829 );
1830 let finalizer = Finalizer::with_bridge(
1831 signer.clone(),
1832 std::path::PathBuf::from(&config.atif_spool_dir),
1833 Arc::clone(&metrics),
1834 Arc::clone(&bridge),
1835 )
1836 .with_state_store(Arc::clone(&store))
1837 .with_vector_sink(Arc::clone(&vector_sink));
1840 let build_client = |private_only: bool| -> Result<reqwest::Client, PipelineError> {
1843 let mut client_builder = reqwest::Client::builder()
1844 .connect_timeout(HTTP_CONNECT_TIMEOUT)
1845 .redirect(reqwest::redirect::Policy::none())
1846 .user_agent(concat!("AgentVisor AI/", env!("CARGO_PKG_VERSION")))
1852 .tcp_keepalive(std::time::Duration::from_secs(30))
1858 .pool_idle_timeout(std::time::Duration::from_secs(60));
1863 let mut default_headers = reqwest::header::HeaderMap::new();
1874 default_headers.insert(
1875 reqwest::header::ACCEPT_ENCODING,
1876 reqwest::header::HeaderValue::from_static("identity"),
1877 );
1878 client_builder = client_builder.default_headers(default_headers);
1879 const DEFAULT_UPSTREAM_READ_TIMEOUT_S: u64 = crate::config::DEFAULT_UPSTREAM_READ_TIMEOUT_S;
1891 let read_timeout_s = config
1892 .upstream_read_timeout_s
1893 .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
1894 client_builder = client_builder.read_timeout(std::time::Duration::from_secs(read_timeout_s));
1895 if config.upstream_http2_prior_knowledge {
1896 client_builder = client_builder.http2_prior_knowledge();
1897 }
1898 if private_only {
1899 client_builder = client_builder.dns_resolver(Arc::new(crate::backend::PrivateOnlyResolver));
1900 }
1901 client_builder.build().map_err(PipelineError::upstream_source)
1902 };
1903 let client = build_client(false)?;
1904 let backend_client = if config.require_private_backends {
1908 build_client(true)?
1909 } else {
1910 client.clone()
1911 };
1912 let upstream_auth = resolve_upstream_auth(&config)?;
1913 let tool_auth = resolve_tool_auth(&config)?;
1914 let hot_metrics = Arc::new(HotMetrics::new(&metrics, config.strict_stage_budget));
1915 let provider_adapter = crate::provider::adapter_for(&config.provider).ok_or_else(|| {
1919 PipelineError::bad_request(format!("unsupported provider {:?}", config.provider))
1920 })?;
1921 tracing::info!(provider = provider_adapter.name(), "provider adapter selected");
1922 let mcp_admission = Arc::new(tokio::sync::Semaphore::new(config.mcp_concurrency));
1923 let mcp_admission_refusals_counter = metrics.counter(
1930 "av_mcp_admission_refusals_total",
1931 "MCP admission was refused because `mcp_concurrency` was already at capacity — \
1932 each admitted call can buffer up to 16 MiB in `read_limited_tool_response`, \
1933 so the cap bounds worst-case MCP resident memory. Sustained > 0 indicates \
1934 tool-upstream slowdown, a burst-traffic event, or an undersized \
1935 `mcp_concurrency`.",
1936 );
1937 for pdp_code in ["UNMAPPED_TOOL", "MISSION_EXPIRED", "MISSION_DENIED", "PDP_DENIED"] {
1938 metrics.counter(
1939 &format!("av_pdp_denials_total{{code=\"{pdp_code}\"}}"),
1940 "PDP denials by denial code",
1941 );
1942 }
1943 metrics.counter(
1944 crate::routes::TOKENS_REVOKED_METRIC,
1945 crate::routes::TOKENS_REVOKED_HELP,
1946 );
1947 let backend_router = crate::backend::BackendRouter::new(
1948 &config.backends,
1949 config.tool_upstream_url.as_deref(),
1950 tool_auth.clone(),
1951 )
1952 .map_err(|e| PipelineError::bad_request(format!("backend routing: {e}")))?
1953 .with_fallback_transport(config.tool_upstream_transport);
1954 let mcp_backends = Arc::new(crate::mcp_client::McpBackendClient::new(backend_client.clone()));
1955 let mcp_sessions = Arc::new(crate::mcp::SessionSigner::derive(&journal_key));
1956 let authzen = config
1957 .authzen
1958 .as_ref()
1959 .map(|authzen| crate::authzen::AuthzenClient::from_config(authzen, client.clone()).map(Arc::new))
1960 .transpose()
1961 .map_err(|e| PipelineError::bad_request(format!("authzen: {e}")))?;
1962 let workload = crate::workload::WorkloadIssuer::from_config(&config)
1963 .map_err(|e| PipelineError::bad_request(format!("workload identity: {e}")))?
1964 .map(Arc::new);
1965 let token_signer = if let Some(ref seed_path) = config.token_exchange_seed_file {
1966 let seed = load_exchange_seed(seed_path)
1967 .map_err(|e| PipelineError::bad_request(format!("exchange signing key: {e}")))?;
1968 let ts = Arc::new(crate::authz::TokenSigner::from_seed(&seed));
1969 if ts.kid() == signer.key_id() {
1970 return Err(PipelineError::bad_request(
1971 "token_exchange_seed_file must use a different key than the receipt signer \
1972 (both derive the same kid, which means the same seed)"
1973 .to_owned(),
1974 ));
1975 }
1976 Some(ts)
1977 } else {
1978 None
1979 };
1980 let pdp = crate::authz::PolicyDecisionPoint::from_config(&config, token_signer.clone());
1981 let exchanged_revocations: Arc<dyn av_identity::RevocationStore> = if config.state_backend == "redis"
1982 {
1983 Arc::new(crate::revocation::GuardedRevocationStore::new(
1984 Arc::new(crate::revocation::StateRevocationStore::with_namespace(
1985 Arc::clone(&store),
1986 "exchanged",
1987 )),
1988 &metrics,
1989 "exchanged",
1990 ))
1991 } else {
1992 Arc::new(av_identity::InMemoryRevocationStore::new())
1993 };
1994 Ok(Self {
1995 config,
1996 store,
1997 sandbox,
1998 bridge,
1999 sessions,
2000 worker,
2001 identity,
2002 exchanged_revocations,
2003 revocation_audit_window: Arc::new(parking_lot::Mutex::new((Instant::now(), 0))),
2004 hot_metrics,
2005 metrics,
2006 client,
2007 backend_client,
2008 upstream_auth,
2009 tool_auth,
2010 tool_audit_gates: Arc::default(),
2011 tool_audits_emitted: Arc::default(),
2012 provider_adapter,
2013 finalizer,
2014 draining: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2015 identity_rejection_window: Arc::new(parking_lot::Mutex::new((Instant::now(), 0))),
2016 journal_key,
2017 mcp_inflight: Arc::new(crate::inflight::InflightTracker::new()),
2018 mcp_admission,
2019 mcp_admission_refusals_counter,
2020 backend_router: Arc::new(backend_router),
2021 pdp: Arc::new(pdp),
2022 token_signer,
2023 redaction,
2024 mcp_backends,
2025 mcp_sessions,
2026 authzen,
2027 content_sink,
2028 workload,
2029 })
2030 }
2031
2032 pub fn set_content_sink(&self, sink: Arc<dyn crate::content::ContentSink>) -> std::io::Result<()> {
2038 let dispatcher = crate::content::ContentDispatcher::spawn(sink, &self.metrics)?;
2039 *self.content_sink.write() = Some(Arc::new(dispatcher));
2040 Ok(())
2041 }
2042
2043 pub fn prepare_chat(
2058 &self,
2059 headers: &HeaderMap,
2060 payload: Value,
2061 identity_hint: Option<AgentIdentity>,
2062 ) -> Result<PreparedRequest, PipelineError> {
2063 self.prepare_chat_with_caller(headers, payload, identity_hint, None)
2064 }
2065
2066 fn prepare_chat_with_caller(
2067 &self,
2068 headers: &HeaderMap,
2069 mut payload: Value,
2070 identity_hint: Option<AgentIdentity>,
2071 caller: Option<&AuthenticatedCaller>,
2072 ) -> Result<PreparedRequest, PipelineError> {
2073 let total_started = Instant::now();
2074 let session_id = session_id(headers)?;
2075 let workflow = workflow(headers, &self.config.default_workflow)?;
2076 match payload.get("n") {
2088 None | Some(serde_json::Value::Null) => {}
2089 Some(n) if n.as_u64() == Some(1) => {}
2090 Some(_) => {
2091 return Err(PipelineError::bad_request(
2092 "multi-choice completions (n != 1) are not supported: the audit capture attests a single response message per request".to_owned(),
2093 ));
2094 }
2095 }
2096
2097 let client_authorization = if self.config.upstream_authorization_passthrough {
2105 single_header(headers, "authorization")?.cloned()
2106 } else {
2107 None
2108 };
2109 const UPSTREAM_HEADER_ALLOWLIST: [&str; 3] = ["openai-organization", "openai-project", "openai-beta"];
2113 let mut upstream_passthrough_headers = Vec::new();
2114 for name in UPSTREAM_HEADER_ALLOWLIST {
2115 if let Some(value) = single_header(headers, name)? {
2116 upstream_passthrough_headers.push((axum::http::HeaderName::from_static(name), value.clone()));
2117 }
2118 }
2119
2120 let stage = Instant::now();
2121 let identity = if let Some(pre_resolved) = identity_hint {
2122 pre_resolved
2129 } else {
2130 match self.resolve_identity(headers, Some(&self.config.chat_scope)) {
2131 Ok(identity) => identity,
2132 Err(error) => {
2133 self.audit_authentication_error(headers, &error)?;
2134 return Err(error);
2135 }
2136 }
2137 };
2138 self.observe_stage("identity", stage);
2139
2140 let session = self
2141 .sessions
2142 .get_or_open(&session_id, workflow, &identity, &self.config.breaker);
2143 let admission = session.admission_guard();
2144 validate_session_binding(&session, workflow, &identity)?;
2145 if let Some(caller) = caller {
2146 caller.bind_session(&session)?;
2147 }
2148 session.refresh_identity(&identity);
2149 if session.is_closed() {
2150 if session.capture_failed() {
2172 return Err(PipelineError::bad_request(
2173 "session is quarantined (audit capture failed); use a new session id".to_owned(),
2174 ));
2175 }
2176 if session.is_empty_unsigned_quarantine() {
2177 return Err(PipelineError::bad_request(
2178 "session is quarantined (closed with no captured steps); use a new session id".to_owned(),
2179 ));
2180 }
2181 if session.close_complete_flag() && session.enforcement_tripped() {
2189 return Err(PipelineError::blocked(
2190 "session was closed by budget or loop enforcement; use a new session id".to_owned(),
2191 ));
2192 }
2193 return Err(PipelineError::unavailable(
2194 "session close is completing; retry shortly or use a new session id".to_owned(),
2195 ));
2196 }
2197 if session.capture_failed() {
2198 return Err(PipelineError::unavailable(
2199 "session audit capture is incomplete".to_owned(),
2200 ));
2201 }
2202 session.touch();
2203 if session.loop_state.state() == BreakerState::Open {
2204 match session.loop_state.action() {
2205 BreakerAction::Reject => {
2206 session.latch_enforcement(StopReason::LoopDetected);
2207 return Err(PipelineError::blocked(
2208 "semantic loop circuit breaker is open".to_owned(),
2209 ));
2210 }
2211 BreakerAction::Abort => {
2212 session.latch_enforcement(StopReason::LoopDetected);
2213 return Err(PipelineError::Abort(
2214 "semantic loop circuit breaker requested connection close".to_owned(),
2215 ));
2216 }
2217 BreakerAction::Inject => {
2218 inject_corrective_message(&mut payload)?;
2219 session.loop_state.reset();
2220 }
2221 _ => {
2222 return Err(PipelineError::blocked(
2223 "unsupported semantic loop enforcement action".to_owned(),
2224 ));
2225 }
2226 }
2227 }
2228
2229 let permits = self
2237 .worker
2238 .try_reserve_pair(&session_id)
2239 .map_err(PipelineError::unavailable_source)?;
2240 let worker_permit = permits.worker;
2241 let response_permit = permits.response;
2242
2243 let stage = Instant::now();
2244 let raw_prompt_tokens = av_core::tokens::approx_tokens_json(&payload);
2245 self.observe_stage("quota", stage);
2255
2256 let stage = Instant::now();
2257 if let Err(reason) = self.sandbox.sanitize("chat/completions", &payload) {
2258 let error = PipelineError::blocked(reason);
2259 worker_permit.submit(self.failure_job(
2263 Arc::clone(&session),
2264 identity.clone(),
2265 StopReason::PolicyBlocked,
2266 error.to_string(),
2267 ));
2268 return Err(error);
2269 }
2270 self.observe_stage("sanitize", stage);
2271
2272 let stage = Instant::now();
2273 let compression = if self.config.compression_enabled {
2274 av_compress::compress(
2275 &payload,
2276 &av_compress::CompressionConfig {
2277 marker_key: Some(av_compress::derive_marker_key(&self.journal_key)),
2282 ..av_compress::CompressionConfig::default()
2283 },
2284 )
2285 } else {
2286 av_compress::CompressionOutcome {
2287 payload,
2288 tokens_before: raw_prompt_tokens,
2289 tokens_after: raw_prompt_tokens,
2290 changed: false,
2291 }
2292 };
2293 self.observe_stage("compression", stage);
2294
2295 let stage = Instant::now();
2302 let billed_tokens = compression.tokens_after;
2303 let principal_billing_id = self
2304 .config
2305 .principal_budget
2306 .as_ref()
2307 .map(|_| principal_id_for_budget(&identity));
2308 if let (Some(spec), Some(principal_id)) = (
2309 self.config.principal_budget.as_ref(),
2310 principal_billing_id.as_deref(),
2311 ) {
2312 let quota = match ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2313 .try_tokens(billed_tokens)
2314 {
2315 Ok(quota) => quota,
2316 Err(error) => {
2317 let error = PipelineError::Blocked {
2318 context: format!("quota backend failed closed (principal): {error}"),
2319 source: Some(Box::new(error)),
2320 };
2321 worker_permit.submit(self.failure_job(
2322 Arc::clone(&session),
2323 identity.clone(),
2324 StopReason::BudgetExceeded,
2325 error.to_string(),
2326 ));
2327 return Err(error);
2328 }
2329 };
2330 if let BudgetDecision::Refused { limit, cap } = quota {
2331 session.latch_enforcement(StopReason::BudgetExceeded);
2335 let error = PipelineError::blocked(format!("principal.{limit} exceeded (cap {cap})"));
2336 worker_permit.submit(self.failure_job(
2337 Arc::clone(&session),
2338 identity.clone(),
2339 StopReason::BudgetExceeded,
2340 error.to_string(),
2341 ));
2342 return Err(error);
2343 }
2344 }
2345 let quota = match ActionBudget::new(self.store.as_ref(), &session_id, &self.config.budget)
2346 .try_tokens(billed_tokens)
2347 {
2348 Ok(quota) => quota,
2349 Err(error) => {
2350 if let (Some(spec), Some(principal_id)) = (
2351 self.config.principal_budget.as_ref(),
2352 principal_billing_id.as_deref(),
2353 ) {
2354 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2355 .refund_tokens(billed_tokens);
2356 }
2357 let error = PipelineError::Blocked {
2358 context: format!("quota backend failed closed: {error}"),
2359 source: Some(Box::new(error)),
2360 };
2361 worker_permit.submit(self.failure_job(
2362 Arc::clone(&session),
2363 identity.clone(),
2364 StopReason::BudgetExceeded,
2365 error.to_string(),
2366 ));
2367 return Err(error);
2368 }
2369 };
2370 if let BudgetDecision::Refused { limit, cap } = quota {
2371 if let (Some(spec), Some(principal_id)) = (
2372 self.config.principal_budget.as_ref(),
2373 principal_billing_id.as_deref(),
2374 ) {
2375 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2376 .refund_tokens(billed_tokens);
2377 }
2378 session.latch_enforcement(StopReason::BudgetExceeded);
2380 let error = PipelineError::blocked(format!("{limit} exceeded (cap {cap})"));
2381 worker_permit.submit(self.failure_job(
2382 Arc::clone(&session),
2383 identity.clone(),
2384 StopReason::BudgetExceeded,
2385 error.to_string(),
2386 ));
2387 return Err(error);
2388 }
2389 self.observe_stage("quota_post_compression", stage);
2390
2391 let stage = Instant::now();
2392 let text = last_message_text(&compression.payload);
2393 let atif = match atif_capture_from_request(&compression.payload) {
2394 Ok(atif) => atif,
2395 Err(error) => {
2396 ActionBudget::new(self.store.as_ref(), &session_id, &self.config.budget)
2399 .refund_tokens(billed_tokens);
2400 if let (Some(spec), Some(principal_id)) = (
2401 self.config.principal_budget.as_ref(),
2402 principal_billing_id.as_deref(),
2403 ) {
2404 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2405 .refund_tokens(billed_tokens);
2406 }
2407 worker_permit.submit(self.failure_job(
2408 Arc::clone(&session),
2409 identity.clone(),
2410 StopReason::Other,
2411 error.to_string(),
2412 ));
2413 return Err(error);
2414 }
2415 };
2416 let analyze_loop = atif.source == av_atif::Source::Agent && !text.is_empty();
2417 let response_attempt_id = av_core::new_event_uid();
2418 let job = WorkerJob {
2419 session: Arc::clone(&session),
2420 identity: identity.clone(),
2421 class: EventClass::Compression,
2422 payload: serde_json::json!({
2423 "changed": compression.changed,
2424 "tokens_before": compression.tokens_before,
2425 "tokens_after": compression.tokens_after,
2426 }),
2427 text,
2428 analyze_loop,
2429 status: StatusId::Success,
2430 stop_reason: None,
2431 native_stop_reason: None,
2432 metrics: EventMetrics {
2433 prompt_tokens: Some(compression.tokens_after),
2434 completion_tokens: Some(0),
2435 cached_tokens: Some(0),
2436 pruned_tokens: Some(compression.pruned_tokens()),
2437 pruning_ratio_millis: Some(compression.pruning_ratio_millis()),
2438 },
2439 cost_usd_micros: 0,
2440 prompt_token_correction: 0,
2441 atif: Some(atif),
2442 response_marker: None,
2443 response_attempt: Some(crate::worker::ResponseAttempt {
2444 id: response_attempt_id.clone(),
2445 terminal: false,
2446 }),
2447 };
2448 worker_permit.submit(job);
2449 self.observe_stage("dispatch", stage);
2450 let lease = SessionLease::new(Arc::clone(&session));
2451 let capture_guard = ResponseCaptureGuard::new(
2458 self.worker.clone(),
2459 Arc::clone(&session),
2460 identity.clone(),
2461 response_attempt_id,
2462 Some(response_permit),
2463 );
2464 drop(admission);
2465
2466 let admission_debit = AdmissionDebit {
2467 store: Arc::clone(&self.store),
2468 session_id: session.id.clone(),
2469 budget: self.config.budget.clone(),
2470 principal: self
2471 .config
2472 .principal_budget
2473 .as_ref()
2474 .map(|spec| (principal_id_for_budget(&identity), spec.clone())),
2475 tokens: billed_tokens,
2476 metrics: Arc::clone(&self.metrics),
2477 };
2478 Ok(PreparedRequest {
2479 session,
2480 identity,
2481 payload: compression.payload,
2482 middleware_us: elapsed_us(total_started),
2483 admission_debit,
2484 lease,
2485 capture_guard,
2486 client_authorization,
2487 upstream_passthrough_headers,
2488 })
2489 }
2490
2491 pub async fn prepare_chat_nonblocking(
2518 &self,
2519 headers: &HeaderMap,
2520 payload: Value,
2521 body_bytes: usize,
2522 identity_hint: Option<AgentIdentity>,
2523 ) -> Result<PreparedRequest, PipelineError> {
2524 if self.chat_preparation_can_run_inline(body_bytes)
2525 && (identity_hint.is_some() || self.identity.is_none())
2528 {
2529 return self.prepare_chat(headers, payload, identity_hint);
2530 }
2531 let state = self.clone();
2532 let headers = headers.clone();
2533 tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload, identity_hint))
2534 .await
2535 .map_err(PipelineError::unavailable_source)?
2536 }
2537
2538 fn chat_preparation_can_run_inline(&self, body_bytes: usize) -> bool {
2539 body_bytes <= 16 * 1024
2542 && self.config.budget.max_tokens.is_none()
2543 && self.config.principal_budget.is_none()
2544 }
2545
2546 pub(crate) async fn authenticate_chat_nonblocking(
2547 &self,
2548 headers: &HeaderMap,
2549 ) -> Result<AuthenticatedCaller, PipelineError> {
2550 if self.identity.is_none() {
2551 let caller = self.authenticate(headers)?;
2554 caller.authorize(self, Some(&self.config.chat_scope))?;
2555 return Ok(caller);
2556 }
2557 let state = self.clone();
2558 let headers = headers.clone();
2559 tokio::task::spawn_blocking(move || {
2560 let caller = state.authenticate(&headers)?;
2561 caller.authorize(&state, Some(&state.config.chat_scope))?;
2562 Ok(caller)
2563 })
2564 .await
2565 .map_err(PipelineError::unavailable_source)?
2566 }
2567
2568 pub(crate) async fn prepare_authenticated_chat(
2569 &self,
2570 headers: &HeaderMap,
2571 payload: Value,
2572 body_bytes: usize,
2573 caller: AuthenticatedCaller,
2574 ) -> Result<PreparedRequest, PipelineError> {
2575 if self.chat_preparation_can_run_inline(body_bytes) {
2576 let identity = caller.authorize(self, Some(&self.config.chat_scope))?;
2579 return self.prepare_chat_with_caller(headers, payload, Some(identity), Some(&caller));
2580 }
2581 let state = self.clone();
2582 let headers = headers.clone();
2583 tokio::task::spawn_blocking(move || {
2584 let identity = caller.authorize(&state, Some(&state.config.chat_scope))?;
2585 state.prepare_chat_with_caller(&headers, payload, Some(identity), Some(&caller))
2586 })
2587 .await
2588 .map_err(PipelineError::unavailable_source)?
2589 }
2590
2591 pub async fn prepare_chat_durable(
2594 &self,
2595 headers: &HeaderMap,
2596 payload: Value,
2597 ) -> Result<PreparedRequest, PipelineError> {
2598 if let Ok(id) = session_id(headers) {
2599 if let Some(session) = self.sessions.get(&id) {
2600 session.wait_for_worker_jobs().await;
2601 if session.capture_failed() {
2602 return Err(PipelineError::unavailable(
2603 "session audit capture is incomplete".to_owned(),
2604 ));
2605 }
2606 }
2607 }
2608 let state = self.clone();
2609 let headers = headers.clone();
2610 let mut prepared = tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload, None))
2611 .await
2612 .map_err(PipelineError::unavailable_source)??;
2613 prepared.session.wait_for_worker_jobs().await;
2614 if prepared.session.capture_failed() {
2615 let error = PipelineError::unavailable(
2616 "request audit capture failed before provider dispatch".to_owned(),
2617 );
2618 self.abandon_prepared(&mut prepared, StopReason::Other, &error.to_string());
2619 return Err(error);
2620 }
2621 if prepared.session.loop_state.state() == BreakerState::Open {
2622 let action = prepared.session.loop_state.action();
2623 if matches!(action, BreakerAction::Abort | BreakerAction::Reject) {
2631 prepared.session.latch_enforcement(StopReason::LoopDetected);
2632 }
2633 let error = match action {
2634 BreakerAction::Abort => {
2635 PipelineError::Abort("semantic loop circuit breaker opened during audit".to_owned())
2636 }
2637 _ => PipelineError::blocked(
2638 "semantic loop circuit breaker opened during audit; retry required".to_owned(),
2639 ),
2640 };
2641 self.abandon_prepared(&mut prepared, StopReason::LoopDetected, &error.to_string());
2642 return Err(error);
2643 }
2644 Ok(prepared)
2645 }
2646
2647 fn abandon_prepared(&self, prepared: &mut PreparedRequest, stop_reason: StopReason, reason: &str) {
2657 prepared.admission_debit.refund_now();
2664 let Some(permit) = prepared.capture_guard.take_permit() else {
2665 return;
2669 };
2670 let response_marker = prepared.capture_guard.defuse();
2672 let mut job = self.failure_job(
2673 Arc::clone(&prepared.session),
2674 prepared.identity.clone(),
2675 stop_reason,
2676 reason.to_owned(),
2677 );
2678 job.response_marker = response_marker;
2679 job.response_attempt = Some(crate::worker::ResponseAttempt {
2680 id: prepared.capture_guard.attempt_id().to_owned(),
2681 terminal: true,
2682 });
2683 if permit.submit(&self.worker, job).is_err() {
2690 prepared.session.mark_capture_failed();
2691 }
2692 }
2693
2694 pub async fn forward_chat(
2696 &self,
2697 mut request: PreparedRequest,
2698 ) -> Result<ForwardedResponse, PipelineError> {
2699 let payload_bytes = match serde_json::to_vec(&request.payload) {
2717 Ok(bytes) => Some(bytes),
2718 Err(error) => {
2719 tracing::error!(
2720 %error,
2721 session = %request.session.id,
2722 "failed to serialise chat payload for request digest; falling back to session-derived digest"
2723 );
2724 None
2725 }
2726 };
2727 let request_digest = match &payload_bytes {
2728 Some(bytes) => av_core::digest::sha256_hex(bytes),
2729 None => av_core::digest::sha256_hex(request.session.id.as_bytes()),
2730 };
2731 let attempt_id = crate::worker::reserve_response_attempt_id();
2742 request.capture_guard.set_marker(attempt_id.clone());
2743 if let Err(error) = crate::worker::write_response_marker(
2744 std::path::Path::new(&self.config.atif_spool_dir),
2745 &self.journal_key,
2746 &request.session.id,
2747 &attempt_id,
2748 request_digest,
2749 )
2750 .await
2751 {
2752 tracing::warn!(
2760 %error,
2761 session = %request.session.id,
2762 "could not write in-flight response marker; refusing upstream dispatch"
2763 );
2764 let client_error =
2765 PipelineError::unavailable("in-flight response marker could not be persisted".to_owned());
2766 self.abandon_prepared(&mut request, StopReason::Other, &client_error.to_string());
2767 return Err(client_error);
2768 }
2769 let PreparedRequest {
2770 session,
2771 identity,
2772 payload,
2773 lease,
2774 mut capture_guard,
2775 client_authorization,
2776 upstream_passthrough_headers,
2777 mut admission_debit,
2778 ..
2779 } = request;
2780 let url = format!(
2781 "{}{}",
2782 self.config.upstream_url.trim_end_matches('/'),
2783 self.config.upstream_chat_path
2784 );
2785 let mut upstream_request = match payload_bytes {
2789 Some(bytes) => self
2790 .client
2791 .post(url)
2792 .header(reqwest::header::CONTENT_TYPE, "application/json")
2793 .body(bytes),
2794 None => self.client.post(url).json(&payload),
2795 };
2796 for (name, value) in upstream_passthrough_headers {
2800 upstream_request = upstream_request.header(name, value);
2801 }
2802 if let Some((name, value)) = &self.upstream_auth {
2803 upstream_request = upstream_request.header(name.clone(), value.clone());
2804 } else if let Some(authorization) = client_authorization {
2805 upstream_request = upstream_request.header(reqwest::header::AUTHORIZATION, authorization);
2809 }
2810 let upstream_started = Instant::now();
2814 let debited_tokens = admission_debit.take_for_dispatch();
2820 match upstream_request.send().await {
2821 Ok(response) => {
2822 self.hot_metrics
2823 .upstream_latency_histogram
2824 .observe_us(elapsed_us(upstream_started));
2825 if response.status().is_server_error() {
2826 #[allow(clippy::indexing_slicing)]
2827 {
2828 self.hot_metrics.upstream_errors_counters[UpstreamErrorKind::Http5xx.index()].inc();
2829 }
2830 }
2831 Ok(ForwardedResponse {
2832 response,
2833 billed_prompt_tokens: debited_tokens,
2834 lease,
2835 capture_guard,
2836 })
2837 }
2838 Err(error) => {
2839 let kind = UpstreamErrorKind::from_reqwest(&error);
2840 #[allow(clippy::indexing_slicing)]
2841 {
2842 self.hot_metrics.upstream_errors_counters[kind.index()].inc();
2843 }
2844 let client_reason = classify_upstream_error(&error);
2865 if error.is_connect() {
2874 ActionBudget::new(self.store.as_ref(), &session.id, &self.config.budget)
2875 .refund_tokens(debited_tokens);
2876 if let Some(spec) = self.config.principal_budget.as_ref() {
2877 let principal_id = principal_id_for_budget(&identity);
2878 ActionBudget::for_principal(self.store.as_ref(), &principal_id, spec)
2879 .refund_tokens(debited_tokens);
2880 }
2881 }
2882 tracing::warn!(
2883 session = %session.id,
2884 category = client_reason,
2885 error.status = ?error.status(),
2886 error.is_timeout = error.is_timeout(),
2887 error.is_connect = error.is_connect(),
2888 error.is_body = error.is_body(),
2889 "upstream forwarding failed"
2890 );
2891 let client_error = if error.is_timeout() {
2898 PipelineError::upstream_timeout(client_reason.to_owned())
2899 } else {
2900 PipelineError::upstream(client_reason.to_owned())
2901 };
2902 let persisted_reason = format!("upstream_{client_reason}");
2903 if let Some(permit) = capture_guard.take_permit() {
2904 let (response_marker, response_attempt_id) = capture_guard.disarm();
2905 let capture_session = Arc::clone(&session);
2906 let mut job =
2907 self.failure_job(session, identity, StopReason::Other, persisted_reason.clone());
2908 job.response_marker = response_marker;
2909 job.response_attempt = Some(crate::worker::ResponseAttempt {
2910 id: response_attempt_id,
2911 terminal: true,
2912 });
2913 if permit.submit(&self.worker, job).is_err() {
2922 capture_session.mark_capture_failed();
2923 }
2924 } else {
2925 drop(capture_guard);
2929 self.enqueue_failure(session, identity, StopReason::Other, persisted_reason)?;
2930 }
2931 Err(client_error)
2932 }
2933 }
2934 }
2935
2936 pub fn intercept_tool(&self, headers: &HeaderMap, raw: &[u8]) -> Result<ToolVerdict, PipelineError> {
2939 self.intercept_tool_with_session(headers, raw)
2940 .map(|(verdict, _session)| verdict)
2941 }
2942
2943 pub(crate) fn refuse_unauthenticated_tool_call(&self, headers: &HeaderMap) -> Result<(), PipelineError> {
2957 if !self.config.require_identity {
2958 return Ok(());
2959 }
2960 if single_header(headers, "authorization")?.is_none() {
2961 let session_id = session_id(headers)?;
2964 let error = PipelineError::Unauthorized("missing bearer token".to_owned());
2965 self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
2966 return Err(error);
2967 }
2968 Ok(())
2969 }
2970
2971 fn intercept_tool_with_session(
2978 &self,
2979 headers: &HeaderMap,
2980 raw: &[u8],
2981 ) -> Result<(ToolVerdict, Arc<Session>), PipelineError> {
2982 let caller = match self.authenticate(headers) {
2983 Ok(caller) => caller,
2984 Err(error) => {
2985 self.audit_authentication_error(headers, &error)?;
2986 return Err(error);
2987 }
2988 };
2989 self.intercept_tool_authenticated(headers, raw, &caller, None)
2990 }
2991
2992 fn intercept_tool_authenticated(
2993 &self,
2994 headers: &HeaderMap,
2995 raw: &[u8],
2996 caller: &AuthenticatedCaller,
2997 preflight_denial: Option<ToolVerdict>,
2998 ) -> Result<(ToolVerdict, Arc<Session>), PipelineError> {
2999 let session_id = session_id(headers)?;
3000 let workflow = workflow(headers, &self.config.default_workflow)?;
3001 let parsed_call = av_sandbox::parse_tool_call(raw).ok();
3002 let required_scope = parsed_call
3003 .as_ref()
3004 .map(|request| tool_scope(&request.tool))
3005 .unwrap_or_else(|| TOOL_INVOKE_SCOPE.to_owned());
3006 let identity = match caller.authorize(self, Some(&required_scope)) {
3007 Ok(identity) => identity,
3008 Err(error) => {
3009 self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
3010 return Err(error);
3011 }
3012 };
3013 let session =
3021 self.sessions
3022 .get_or_open_no_reopen(&session_id, workflow, &identity, &self.config.breaker);
3023 let admission = session.admission_guard();
3024 validate_session_binding(&session, workflow, &identity)?;
3025 caller.bind_session(&session)?;
3026 session.refresh_identity(&identity);
3027 if session.is_closed() {
3028 return Err(PipelineError::bad_request("session is already closed".to_owned()));
3029 }
3030 if session.capture_failed() {
3031 return Err(PipelineError::unavailable(
3032 "session audit capture is incomplete".to_owned(),
3033 ));
3034 }
3035 session.touch();
3036 if session.loop_state.state() == BreakerState::Open {
3048 match session.loop_state.action() {
3049 BreakerAction::Reject | BreakerAction::Abort => {
3050 session.latch_enforcement(StopReason::LoopDetected);
3051 return Err(PipelineError::blocked(
3052 "semantic loop circuit breaker is open".to_owned(),
3053 ));
3054 }
3055 _ => {}
3056 }
3057 }
3058 let worker_permit = self
3059 .worker
3060 .try_reserve(&session_id)
3061 .map_err(PipelineError::unavailable_source)?;
3062 let budgeted_check = || {
3063 let principal_binding = self
3064 .config
3065 .principal_budget
3066 .as_ref()
3067 .map(|spec| (principal_id_for_budget(&identity), spec));
3068 let principal_ref = principal_binding.as_ref().map(|(id, spec)| (id.as_str(), *spec));
3069 self.sandbox
3070 .check_with_principal(self.store.as_ref(), &session_id, principal_ref, raw)
3071 };
3072 let verdict = if let Some(denial) = preflight_denial {
3073 denial
3074 } else {
3075 match parsed_call.as_ref() {
3076 Some(request)
3079 if workflow == Workflow::Unsigned
3080 && self
3081 .config
3082 .consequential_tools
3083 .iter()
3084 .any(|required| required == &request.tool) =>
3085 {
3086 let reason = format!("tool {:?} requires a signed workflow", request.tool);
3087 ToolVerdict::Blocked {
3088 tool: request.tool.clone(),
3089 stage: "policy",
3090 policy: Some("workflow.signed_required".to_owned()),
3094 denial_code: av_sandbox::DenialCode::PolicyDenied,
3095 reason: reason.clone(),
3096 response: av_sandbox::rpc::denial_error(
3097 request.id.as_ref(),
3098 av_sandbox::DenialCode::PolicyDenied,
3099 &reason,
3100 ),
3101 elapsed_us: 0,
3102 }
3103 }
3104 Some(request) => {
3109 match self
3110 .pdp
3111 .decide_for(&request.tool, av_core::time::now_ms() / 1000, &caller.facts())
3112 {
3113 crate::authz::AuthzDecision::Deny { code, reason } => {
3114 self.metrics
3115 .counter(
3116 &format!("av_pdp_denials_total{{code=\"{}\"}}", code.as_str()),
3117 "PDP denials by denial code",
3118 )
3119 .inc();
3120 ToolVerdict::Blocked {
3121 tool: request.tool.clone(),
3122 stage: "policy",
3123 policy: Some(pdp_policy_name(code).to_owned()),
3124 denial_code: code,
3125 response: av_sandbox::rpc::denial_error(request.id.as_ref(), code, &reason),
3126 reason,
3127 elapsed_us: 0,
3128 }
3129 }
3130 crate::authz::AuthzDecision::Permit { .. } => budgeted_check(),
3131 }
3132 }
3133 None => budgeted_check(),
3134 }
3135 };
3136 let (status, payload) = match &verdict {
3137 ToolVerdict::Allowed {
3138 tool,
3139 budget_remaining,
3140 elapsed_us,
3141 ..
3142 } => (
3143 StatusId::Success,
3144 serde_json::json!({
3145 "tool": tool,
3146 "allowed": true,
3147 "budget_remaining": (*budget_remaining != u64::MAX)
3148 .then_some(*budget_remaining),
3149 "budget_unlimited": *budget_remaining == u64::MAX,
3150 "decision_us": elapsed_us,
3151 }),
3152 ),
3153 ToolVerdict::Blocked {
3154 tool,
3155 stage,
3156 policy,
3157 denial_code,
3158 reason,
3159 elapsed_us,
3160 ..
3161 } => (
3162 StatusId::Failure,
3163 serde_json::json!({
3164 "tool": tool,
3165 "allowed": false,
3166 "stage": stage,
3167 "policy": policy,
3171 "denial_code": denial_code.as_str(),
3172 "reason": reason,
3173 "decision_us": elapsed_us,
3174 }),
3175 ),
3176 _ => {
3186 tracing::error!(
3187 "unhandled ToolVerdict variant reached the harness matcher; \
3188 failing closed. This is a bug — extend the match to handle the new variant."
3189 );
3190 (
3191 StatusId::Failure,
3192 serde_json::json!({
3193 "tool": "<unknown>",
3194 "allowed": false,
3195 "stage": "unhandled_verdict",
3196 "reason": "unhandled ToolVerdict variant; fail-closed",
3197 }),
3198 )
3199 }
3200 };
3201 let tool_call_id = parsed_call
3202 .as_ref()
3203 .and_then(|request| request.id.as_ref())
3204 .map(|value| value.as_str().map_or_else(|| value.to_string(), str::to_owned))
3205 .unwrap_or_else(av_core::new_event_uid);
3206 let tool_calls = parsed_call.as_ref().map(|request| {
3207 vec![av_atif::ToolCall {
3208 tool_call_id: tool_call_id.clone(),
3209 function_name: request.tool.clone(),
3210 arguments: request.arguments.clone(),
3211 extra: None,
3212 }]
3213 });
3214 let observation = parsed_call.as_ref().map(|_| av_atif::Observation {
3215 results: vec![av_atif::ObservationResult {
3216 source_call_id: Some(tool_call_id),
3217 content: Some(Value::String(payload.to_string())),
3218 subagent_trajectory_ref: None,
3219 extra: None,
3220 }],
3221 });
3222 let stop_reason = match &verdict {
3223 ToolVerdict::Allowed { .. } => None,
3224 ToolVerdict::Blocked { stage: "budget", .. } => Some(StopReason::BudgetExceeded),
3225 ToolVerdict::Blocked { .. } => Some(StopReason::PolicyBlocked),
3226 _ => Some(StopReason::Other),
3239 };
3240 worker_permit.submit(WorkerJob {
3241 session: Arc::clone(&session),
3242 identity,
3243 class: EventClass::ToolCall,
3244 payload,
3245 text: String::from_utf8_lossy(raw).into_owned(),
3246 analyze_loop: false,
3247 status,
3248 stop_reason,
3249 native_stop_reason: None,
3250 metrics: EventMetrics::default(),
3251 cost_usd_micros: 0,
3252 prompt_token_correction: 0,
3253 atif: Some(AtifCapture {
3254 source: av_atif::Source::Agent,
3255 message: Value::String("MCP tool authorization decision".to_owned()),
3256 reasoning_content: None,
3257 model_name: None,
3258 tool_calls,
3259 observation,
3260 llm_call_count: Some(0),
3261 }),
3262 response_marker: None,
3263 response_attempt: None,
3264 });
3265 drop(admission);
3266 Ok((verdict, session))
3267 }
3268
3269 pub async fn intercept_tool_durable(
3271 &self,
3272 headers: &HeaderMap,
3273 raw: &[u8],
3274 ) -> Result<ToolVerdict, PipelineError> {
3275 let state = self.clone();
3276 let owned_headers = headers.clone();
3277 let raw = raw.to_vec();
3278 let (verdict, session) =
3279 tokio::task::spawn_blocking(move || state.intercept_tool_with_session(&owned_headers, &raw))
3280 .await
3281 .map_err(PipelineError::unavailable_source)??;
3282 session.wait_for_worker_jobs().await;
3283 if session.capture_failed() {
3284 return Err(PipelineError::unavailable(
3285 "tool authorization audit capture failed".to_owned(),
3286 ));
3287 }
3288 Ok(verdict)
3289 }
3290
3291 pub async fn intercept_tool_nonblocking(
3294 &self,
3295 headers: &HeaderMap,
3296 raw: &[u8],
3297 ) -> Result<ToolVerdict, PipelineError> {
3298 let state = self.clone();
3299 let owned_headers = headers.clone();
3300 let raw = raw.to_vec();
3301 tokio::task::spawn_blocking(move || state.intercept_tool(&owned_headers, &raw))
3302 .await
3303 .map_err(PipelineError::unavailable_source)?
3304 }
3305
3306 pub(crate) async fn intercept_authenticated_nonblocking(
3307 &self,
3308 headers: &HeaderMap,
3309 raw: &[u8],
3310 caller: AuthenticatedCaller,
3311 preflight_denial: Option<ToolVerdict>,
3312 ) -> Result<ToolVerdict, PipelineError> {
3313 let state = self.clone();
3314 let headers = headers.clone();
3315 let raw = raw.to_vec();
3316 tokio::task::spawn_blocking(move || {
3317 state
3318 .intercept_tool_authenticated(&headers, &raw, &caller, preflight_denial)
3319 .map(|(verdict, _)| verdict)
3320 })
3321 .await
3322 .map_err(PipelineError::unavailable_source)?
3323 }
3324
3325 pub(crate) fn lease_session(&self, headers: &HeaderMap) -> Result<SessionLease, PipelineError> {
3326 let id = session_id(headers)?;
3327 let session = self
3328 .sessions
3329 .get(&id)
3330 .ok_or_else(|| PipelineError::bad_request("unknown session".to_owned()))?;
3331 session
3332 .try_lease()
3333 .ok_or_else(|| PipelineError::bad_request("session is already closed".to_owned()))
3334 }
3335
3336 pub(crate) fn enqueue_transient_failure(
3337 &self,
3338 session_id: &str,
3339 stop_reason: StopReason,
3340 reason: String,
3341 ) -> Result<(), PipelineError> {
3342 self.metrics
3354 .counter(
3355 "av_identity_rejections_total",
3356 "Requests refused by identity validation",
3357 )
3358 .inc();
3359 const MAX_AUDITED_REJECTIONS_PER_MINUTE: u32 = 60;
3360 {
3361 let now = Instant::now();
3362 let mut window = self.identity_rejection_window.lock();
3363 if now.duration_since(window.0) >= std::time::Duration::from_secs(60) {
3364 *window = (now, 0);
3365 }
3366 if window.1 >= MAX_AUDITED_REJECTIONS_PER_MINUTE {
3367 self.metrics
3368 .counter(
3369 "av_identity_rejections_unaudited_total",
3370 "Identity rejections beyond the per-minute audit-record budget (counted only)",
3371 )
3372 .inc();
3373 return Ok(());
3374 }
3375 window.1 += 1;
3376 }
3377 let audit_session_id = format!("identity-rejected-{}", av_core::new_event_uid());
3378 let identity = AgentIdentity {
3379 version: "unknown".to_owned(),
3380 charter: "identity-rejected".into(),
3381 instance_uid: audit_session_id.clone(),
3382 ttl_remaining_s: None,
3383 };
3384 let session = Arc::new(Session::new(
3385 audit_session_id,
3386 Workflow::Signed,
3387 identity.clone(),
3388 self.config.breaker.clone(),
3389 ));
3390 const MAX_ECHO: usize = 64;
3396 let mut boundary = MAX_ECHO.min(session_id.len());
3397 while boundary < session_id.len() && !session_id.is_char_boundary(boundary) {
3398 boundary -= 1;
3399 }
3400 let truncated = boundary < session_id.len();
3401 let echo = &session_id[..boundary];
3402 let echo_marker = if truncated { "…(truncated)" } else { "" };
3403 self.enqueue_failure(
3404 session,
3405 identity,
3406 stop_reason,
3407 format!("requested session {echo:?}{echo_marker}: {reason}"),
3408 )
3409 }
3410
3411 fn enqueue_failure(
3412 &self,
3413 session: Arc<Session>,
3414 identity: AgentIdentity,
3415 stop_reason: StopReason,
3416 reason: String,
3417 ) -> Result<(), PipelineError> {
3418 self.worker
3419 .try_submit(self.failure_job(session, identity, stop_reason, reason))
3420 .map_err(PipelineError::unavailable_source)
3421 }
3422
3423 fn failure_job(
3424 &self,
3425 session: Arc<Session>,
3426 identity: AgentIdentity,
3427 stop_reason: StopReason,
3428 reason: String,
3429 ) -> WorkerJob {
3430 let atif = (session.workflow == Workflow::Unsigned).then(|| AtifCapture {
3431 source: av_atif::Source::Agent,
3432 message: Value::String(reason.clone()),
3433 reasoning_content: None,
3434 model_name: None,
3435 tool_calls: None,
3436 observation: None,
3437 llm_call_count: Some(0),
3438 });
3439 WorkerJob {
3440 session,
3441 identity,
3442 class: EventClass::StopReason,
3443 payload: serde_json::json!({"reason": reason}),
3444 text: String::new(),
3445 analyze_loop: false,
3446 status: StatusId::Failure,
3447 stop_reason: Some(stop_reason),
3448 native_stop_reason: None,
3449 metrics: EventMetrics::default(),
3450 cost_usd_micros: 0,
3451 prompt_token_correction: 0,
3452 atif,
3453 response_marker: None,
3454 response_attempt: None,
3455 }
3456 }
3457
3458 #[cfg(test)]
3459 pub(crate) fn authorize_session(
3460 &self,
3461 headers: &HeaderMap,
3462 session: &Session,
3463 required_scope: &str,
3464 ) -> Result<(), PipelineError> {
3465 self.authenticate(headers)?
3466 .authorize_session(self, session, required_scope)
3467 }
3468
3469 pub(crate) fn resolve_identity(
3470 &self,
3471 headers: &HeaderMap,
3472 required_scope: Option<&str>,
3473 ) -> Result<AgentIdentity, PipelineError> {
3474 self.authenticate(headers)?.authorize(self, required_scope)
3475 }
3476
3477 pub(crate) fn authenticate(&self, headers: &HeaderMap) -> Result<AuthenticatedCaller, PipelineError> {
3478 let bearer = single_header(headers, "authorization")?
3479 .and_then(|value| value.to_str().ok())
3480 .and_then(strip_bearer_scheme);
3481 if let (Some(token), Some(validator)) = (bearer, &self.identity) {
3482 self.metrics
3483 .counter(
3484 "av_identity_validations_total",
3485 "Full inbound identity validations",
3486 )
3487 .inc();
3488 let validated = validator.validate(token).map_err(identity_error_to_pipeline)?;
3489 return Ok(AuthenticatedCaller {
3490 identity: validated.agent_identity(),
3491 validated: Some(validated),
3492 });
3493 }
3494 Ok(AuthenticatedCaller {
3495 identity: self.resolve_anonymous_identity(headers, None)?,
3496 validated: None,
3497 })
3498 }
3499
3500 pub(crate) fn audit_authentication_error(
3501 &self,
3502 headers: &HeaderMap,
3503 error: &PipelineError,
3504 ) -> Result<(), PipelineError> {
3505 if matches!(
3506 error,
3507 PipelineError::Unauthorized(_) | PipelineError::Blocked { .. }
3508 ) {
3509 self.enqueue_transient_failure(
3510 &session_id(headers).unwrap_or_default(),
3511 StopReason::IdentityRejected,
3512 error.to_string(),
3513 )?;
3514 } else if matches!(error, PipelineError::Unavailable { .. }) {
3515 self.metrics
3516 .counter(
3517 "av_identity_unavailable_total",
3518 "Identity validation dependency failures",
3519 )
3520 .inc();
3521 }
3522 Ok(())
3523 }
3524
3525 fn resolve_anonymous_identity(
3526 &self,
3527 headers: &HeaderMap,
3528 required_scope: Option<&str>,
3529 ) -> Result<AgentIdentity, PipelineError> {
3530 let bearer = single_header(headers, "authorization")?
3552 .and_then(|value| value.to_str().ok())
3553 .and_then(strip_bearer_scheme);
3554 match (bearer, &self.identity) {
3555 (Some(token), Some(validator)) => {
3556 let validated = validator.validate(token).map_err(identity_error_to_pipeline)?;
3557 if self.config.enforce_identity_scopes {
3558 if let Some(required) = required_scope {
3559 if !scope_allows(&validated.claims.scopes, required) {
3560 return Err(PipelineError::blocked(format!(
3567 "identity scope {required:?} is required"
3568 )));
3569 }
3570 }
3571 }
3572 Ok(validated.agent_identity())
3573 }
3574 (Some(_), None) => {
3601 let has_static_key =
3602 self.config.upstream_api_key_env.is_some() || self.config.upstream_api_key_file.is_some();
3603 if self.config.upstream_authorization_passthrough {
3604 Ok(AgentIdentity {
3605 version: "dev".to_owned(),
3606 charter: "anonymous".into(),
3607 instance_uid: "anonymous".to_owned(),
3608 ttl_remaining_s: None,
3609 })
3610 } else if has_static_key || self.config.ignore_client_authorization {
3611 tracing::debug!(
3635 "client bearer ignored: static upstream key posture (placeholder \
3636 SDK credential, not an identity claim)"
3637 );
3638 Ok(AgentIdentity {
3639 version: "dev".to_owned(),
3640 charter: "anonymous".into(),
3641 instance_uid: "anonymous".to_owned(),
3642 ttl_remaining_s: None,
3643 })
3644 } else {
3645 Err(PipelineError::Unauthorized(
3646 "bearer token presented but identity validator is not configured — \
3647 refusing to silently record the request as anonymous (either \
3648 configure identity_jwks_url / identity_hmac_secret_file, or set \
3649 upstream_authorization_passthrough=true if the bearer is meant \
3650 for the upstream provider)"
3651 .to_owned(),
3652 ))
3653 }
3654 }
3655 (None, _) if self.config.require_identity => {
3656 Err(PipelineError::Unauthorized("missing bearer token".to_owned()))
3657 }
3658 _ => Ok(AgentIdentity {
3659 version: "dev".to_owned(),
3660 charter: "anonymous".into(),
3661 instance_uid: "anonymous".to_owned(),
3662 ttl_remaining_s: None,
3663 }),
3664 }
3665 }
3666
3667 fn observe_stage(&self, stage: &str, started: Instant) {
3668 let elapsed = elapsed_us(started);
3669 if let Some(known) = Stage::from_label(stage) {
3677 if let Some(histogram) = self.hot_metrics.stage_histograms.get(known.index()) {
3682 histogram.observe_us(elapsed);
3683 }
3684 if self.hot_metrics.strict_stage_budget && elapsed > 2_000 {
3685 if let Some(counter) = self.hot_metrics.stage_strict_budget_counters.get(known.index()) {
3686 counter.inc();
3687 }
3688 tracing::warn!(stage, elapsed_us = elapsed, "strict stage budget exceeded");
3689 }
3690 return;
3691 }
3692 self.metrics
3693 .histogram(
3694 &format!("av_stage_duration_seconds{{stage=\"{stage}\"}}"),
3695 "Harness stage latency",
3696 )
3697 .observe_us(elapsed);
3698 if self.hot_metrics.strict_stage_budget && elapsed > 2_000 {
3699 self.metrics
3700 .counter(
3701 &format!("av_strict_budget_breaches_total{{stage=\"{stage}\"}}"),
3702 "Middleware stages that exceeded the strict per-stage budget",
3703 )
3704 .inc();
3705 tracing::warn!(stage, elapsed_us = elapsed, "strict stage budget exceeded");
3706 }
3707 }
3708}
3709
3710#[derive(Clone)]
3712pub(crate) struct AuthenticatedCaller {
3713 pub(crate) identity: AgentIdentity,
3714 pub(crate) validated: Option<av_identity::ValidatedIdentity>,
3715}
3716
3717impl AuthenticatedCaller {
3718 pub(crate) fn authorize(
3719 &self,
3720 state: &AppState,
3721 scope: Option<&str>,
3722 ) -> Result<AgentIdentity, PipelineError> {
3723 if let Some(validated) = &self.validated {
3724 let now = av_core::time::now_ms() / 1000;
3725 let leeway = state.identity.as_ref().map_or(0, |v| v.leeway_secs());
3726 if now > validated.claims.exp.saturating_add(leeway) {
3727 return Err(PipelineError::Unauthorized("identity validation failed".into()));
3728 }
3729 if state.config.enforce_identity_scopes {
3730 if let Some(required) = scope {
3731 if !scope_allows(&validated.claims.scopes, required) {
3732 return Err(PipelineError::blocked(format!(
3733 "identity scope {required:?} is required"
3734 )));
3735 }
3736 }
3737 }
3738 let mut identity = self.identity.clone();
3739 identity.ttl_remaining_s = Some(validated.claims.exp.saturating_sub(now));
3740 return Ok(identity);
3741 }
3742 Ok(self.identity.clone())
3743 }
3744
3745 pub(crate) fn authorize_session(
3746 &self,
3747 state: &AppState,
3748 session: &Session,
3749 scope: &str,
3750 ) -> Result<(), PipelineError> {
3751 let identity = self.authorize(state, Some(scope))?;
3752 validate_session_binding(session, session.workflow, &identity)?;
3753 self.bind_session(session)?;
3754 session.refresh_identity(&identity);
3755 Ok(())
3756 }
3757
3758 pub(crate) fn facts(&self) -> crate::authz::CallerFacts<'_> {
3760 match &self.validated {
3761 Some(validated) => crate::authz::CallerFacts {
3762 instance_uid: &validated.claims.instance_uid,
3763 charter: &validated.claims.charter,
3764 subject: Some(&validated.claims.sub),
3765 },
3766 None => crate::authz::CallerFacts {
3767 instance_uid: &self.identity.instance_uid,
3768 charter: "anonymous",
3769 subject: None,
3770 },
3771 }
3772 }
3773
3774 pub(crate) fn principal_digest(&self) -> Option<String> {
3775 self.validated.as_ref().map(|validated| {
3776 let pair = serde_json::json!([validated.claims.iss, validated.claims.sub]);
3777 av_core::digest::sha256_hex(pair.to_string().as_bytes())
3778 })
3779 }
3780
3781 fn bind_session(&self, session: &Session) -> Result<(), PipelineError> {
3782 if !session.bind_principal(self.principal_digest().as_deref()) {
3783 return Err(PipelineError::Unauthorized(
3784 "session is bound to a different principal".into(),
3785 ));
3786 }
3787 Ok(())
3788 }
3789}
3790
3791fn truthy_env(name: &str) -> bool {
3792 match std::env::var(name) {
3793 Ok(value) => matches!(
3794 value.trim().to_ascii_lowercase().as_str(),
3795 "1" | "true" | "yes" | "on"
3796 ),
3797 Err(_) => false,
3798 }
3799}
3800
3801pub(crate) fn classify_upstream_error(error: &reqwest::Error) -> &'static str {
3807 if error.is_timeout() {
3808 "upstream timed out"
3809 } else if error.is_connect() {
3810 "upstream unreachable"
3811 } else if error.is_request() {
3812 "upstream request rejected"
3813 } else if error.is_body() || error.is_decode() {
3814 "upstream response malformed"
3815 } else if error.is_status() {
3816 "upstream returned error status"
3817 } else {
3818 "upstream forwarding failed"
3819 }
3820}
3821
3822pub(crate) fn identity_error_to_pipeline(error: av_identity::IdentityError) -> PipelineError {
3842 if matches!(error, av_identity::IdentityError::RevocationUnavailable(_)) {
3843 tracing::error!(error = %error, "identity revocation list unavailable; refusing the request");
3844 return PipelineError::unavailable(
3845 "identity revocation list is unavailable; retry shortly".to_owned(),
3846 );
3847 }
3848 tracing::warn!(error = %error, "identity validation failed");
3849 PipelineError::Unauthorized(classify_identity_error(&error).to_owned())
3850}
3851
3852pub(crate) fn classify_identity_error(error: &av_identity::IdentityError) -> &'static str {
3853 match error {
3854 av_identity::IdentityError::Jwks(_) => "identity validator misconfigured",
3859 _ => "identity validation failed",
3863 }
3864}
3865
3866pub(crate) fn single_header<'a>(
3878 headers: &'a HeaderMap,
3879 name: &'static str,
3880) -> Result<Option<&'a axum::http::HeaderValue>, PipelineError> {
3881 let mut iter = headers.get_all(name).into_iter();
3882 let first = iter.next();
3883 if iter.next().is_some() {
3884 return Err(PipelineError::bad_request(format!(
3885 "request carries more than one {name} header; provide exactly one"
3886 )));
3887 }
3888 Ok(first)
3889}
3890
3891pub(crate) fn strip_bearer_scheme(value: &str) -> Option<&str> {
3902 let (scheme, rest) = value.split_at_checked(6)?;
3903 if !scheme.eq_ignore_ascii_case("Bearer") {
3904 return None;
3905 }
3906 let rest = rest.trim_start_matches([' ', '\t']);
3907 if rest.len() == value.len() - scheme.len() {
3908 return None;
3913 }
3914 if rest.is_empty() {
3915 return None;
3917 }
3918 Some(rest)
3919}
3920
3921pub(crate) fn session_id(headers: &HeaderMap) -> Result<String, PipelineError> {
3922 match single_header(headers, SESSION_HEADER)? {
3923 Some(value) => {
3924 let value = value
3925 .to_str()
3926 .map_err(|_| PipelineError::bad_request("X-AV-Session is not valid text".to_owned()))?;
3927 av_core::SessionId::parse(value)
3928 .map(|id| id.to_string())
3929 .map_err(PipelineError::bad_request_source)
3930 }
3931 None => Ok(av_core::new_session_id().to_string()),
3932 }
3933}
3934
3935fn pdp_policy_name(code: av_sandbox::DenialCode) -> &'static str {
3949 match code {
3950 av_sandbox::DenialCode::UnmappedTool => "pdp.intent_map",
3951 av_sandbox::DenialCode::MissionExpired | av_sandbox::DenialCode::MissionDenied => "pdp.mission",
3952 av_sandbox::DenialCode::PdpDenied => "pdp.authzen",
3953 _ => "pdp",
3954 }
3955}
3956
3957pub(crate) fn principal_id_for_budget(identity: &AgentIdentity) -> String {
3958 if identity.instance_uid.is_empty() {
3959 "anonymous".to_owned()
3960 } else {
3961 identity.instance_uid.clone()
3962 }
3963}
3964
3965fn workflow(headers: &HeaderMap, default: &str) -> Result<Workflow, PipelineError> {
3966 let value = single_header(headers, WORKFLOW_HEADER)?
3967 .map(|header| {
3968 header
3969 .to_str()
3970 .map_err(|_| PipelineError::bad_request("X-AV-Workflow is not valid text".to_owned()))
3971 })
3972 .transpose()?
3973 .unwrap_or(default);
3974 Workflow::parse(value).ok_or_else(|| {
3975 PipelineError::bad_request(format!("X-AV-Workflow must be signed or unsigned, got {value:?}"))
3976 })
3977}
3978
3979fn last_message_text(payload: &Value) -> String {
3980 let Some(message) = payload
3981 .get("messages")
3982 .and_then(Value::as_array)
3983 .and_then(|messages| messages.last())
3984 else {
3985 return String::new();
3986 };
3987 if let Some(text) = message.get("content").and_then(Value::as_str) {
3989 if !text.is_empty() {
3990 return text.to_owned();
3991 }
3992 }
3993 if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
4015 let synthesized: Vec<String> = tool_calls
4016 .iter()
4017 .filter_map(|call| {
4018 let name = call
4019 .get("function")
4020 .and_then(|f| f.get("name"))
4021 .and_then(Value::as_str)?;
4022 let arguments = call.get("function").and_then(|f| f.get("arguments"));
4023 let args_rendered = match arguments {
4024 Some(Value::String(s)) => s.clone(),
4025 Some(other) => serde_json::to_string(other).unwrap_or_default(),
4026 None => String::new(),
4027 };
4028 Some(format!("{name}({args_rendered})"))
4029 })
4030 .collect();
4031 if !synthesized.is_empty() {
4032 return synthesized.join("\n");
4033 }
4034 }
4035 if let Some(parts) = message.get("content").and_then(Value::as_array) {
4039 let text: String = parts
4040 .iter()
4041 .filter_map(|part| part.get("text").and_then(Value::as_str))
4042 .collect::<Vec<_>>()
4043 .join("\n");
4044 if !text.is_empty() {
4045 return text;
4046 }
4047 }
4048 String::new()
4049}
4050
4051fn json_type_name(value: &Value) -> &'static str {
4056 match value {
4057 Value::Null => "null",
4058 Value::Bool(_) => "boolean",
4059 Value::Number(_) => "number",
4060 Value::String(_) => "string",
4061 Value::Array(_) => "array",
4062 Value::Object(_) => "object",
4063 }
4064}
4065
4066fn atif_capture_from_request(payload: &Value) -> Result<AtifCapture, PipelineError> {
4067 let messages_value = payload
4075 .get("messages")
4076 .ok_or_else(|| PipelineError::bad_request("chat payload is missing 'messages'".to_owned()))?;
4077 let messages = messages_value.as_array().ok_or_else(|| {
4078 PipelineError::bad_request(format!(
4079 "'messages' must be a JSON array, got {}",
4080 json_type_name(messages_value)
4081 ))
4082 })?;
4083 let message = messages
4084 .last()
4085 .ok_or_else(|| PipelineError::bad_request("chat payload 'messages' is empty".to_owned()))?;
4086 let role = message.get("role").and_then(Value::as_str);
4087 let source = match role {
4088 Some("system" | "developer" | "tool" | "function") => av_atif::Source::System,
4093 Some("user") => av_atif::Source::User,
4094 Some("assistant") => av_atif::Source::Agent,
4095 Some(other) => {
4096 return Err(PipelineError::bad_request(format!(
4097 "unsupported chat role {other:?}"
4098 )))
4099 }
4100 None => return Err(PipelineError::bad_request("chat message has no role".to_owned())),
4101 };
4102 let content = message
4103 .get("content")
4104 .filter(|value| value.is_string() || value.is_array())
4105 .cloned()
4106 .unwrap_or_else(|| Value::String(String::new()));
4107 let tool_calls = message.get("tool_calls").and_then(Value::as_array).map(|calls| {
4108 calls
4109 .iter()
4110 .filter_map(|call| {
4111 let function = call.get("function")?;
4112 let arguments = function
4113 .get("arguments")
4114 .cloned()
4115 .unwrap_or(Value::Object(Default::default()));
4116 let arguments = arguments
4117 .as_str()
4118 .and_then(|raw| serde_json::from_str(raw).ok())
4119 .unwrap_or(arguments);
4120 Some(av_atif::ToolCall {
4121 tool_call_id: call
4122 .get("id")
4123 .and_then(Value::as_str)
4124 .map_or_else(av_core::new_event_uid, str::to_owned),
4125 function_name: function.get("name")?.as_str()?.to_owned(),
4126 arguments,
4127 extra: None,
4128 })
4129 })
4130 .collect()
4131 });
4132 let observation = (role == Some("tool")).then(|| av_atif::Observation {
4133 results: vec![av_atif::ObservationResult {
4134 source_call_id: None,
4135 content: Some(content.clone()),
4136 subagent_trajectory_ref: None,
4137 extra: message
4138 .get("tool_call_id")
4139 .cloned()
4140 .map(|tool_call_id| serde_json::json!({"tool_call_id": tool_call_id})),
4141 }],
4142 });
4143 Ok(AtifCapture {
4144 source,
4145 message: content,
4146 reasoning_content: if source == av_atif::Source::Agent {
4147 message
4148 .get("reasoning_content")
4149 .and_then(Value::as_str)
4150 .map(str::to_owned)
4151 } else {
4152 None
4153 },
4154 model_name: if source == av_atif::Source::Agent {
4155 payload.get("model").and_then(Value::as_str).map(str::to_owned)
4156 } else {
4157 None
4158 },
4159 tool_calls: if source == av_atif::Source::Agent {
4160 tool_calls
4161 } else {
4162 None
4163 },
4164 observation,
4165 llm_call_count: (source == av_atif::Source::Agent).then_some(1),
4166 })
4167}
4168
4169fn validate_session_binding(
4170 session: &Session,
4171 workflow: Workflow,
4172 identity: &AgentIdentity,
4173) -> Result<(), PipelineError> {
4174 if session.workflow != workflow {
4175 return Err(PipelineError::bad_request(
4176 "session workflow cannot change after open".to_owned(),
4177 ));
4178 }
4179 if session.identity.instance_uid != identity.instance_uid
4180 || session.identity.charter != identity.charter
4181 || session.identity.version != identity.version
4182 {
4183 return Err(PipelineError::Unauthorized(
4184 "session is bound to a different agent identity".to_owned(),
4185 ));
4186 }
4187 Ok(())
4188}
4189
4190pub(crate) fn scope_allows(scopes: &[String], required: &str) -> bool {
4191 scopes.iter().any(|scope| {
4192 scope == "*"
4193 || scope == required
4194 || scope.strip_suffix(":*").is_some_and(|prefix| {
4200 required
4201 .strip_prefix(prefix)
4202 .is_some_and(|rest| rest.starts_with(':'))
4203 })
4204 })
4205}
4206
4207fn inject_corrective_message(payload: &mut Value) -> Result<(), PipelineError> {
4208 let messages = payload
4209 .get_mut("messages")
4210 .and_then(Value::as_array_mut)
4211 .ok_or_else(|| PipelineError::bad_request("chat payload has no messages array".to_owned()))?;
4212 messages.push(serde_json::json!({
4213 "role": "system",
4214 "content": "AgentVisor AI detected a semantic loop. Stop repeating the previous approach, identify new evidence, and choose a materially different next action."
4215 }));
4216 Ok(())
4217}
4218
4219pub(crate) fn load_exchange_seed(path: &str) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
4220 use zeroize::Zeroizing;
4221 require_owner_only_secret(std::path::Path::new(path)).map_err(|e| format!("exchange seed file: {e}"))?;
4222 let encoded = Zeroizing::new(
4223 av_core::fsutil::read_capped_string(std::path::Path::new(path), av_core::fsutil::MAX_CONTROL_BYTES)
4224 .map_err(|e| format!("read exchange seed {path}: {e}"))?,
4225 );
4226 let bytes =
4227 Zeroizing::new(hex::decode(encoded.trim()).map_err(|e| format!("decode exchange seed as hex: {e}"))?);
4228 let seed: Zeroizing<[u8; 32]> = Zeroizing::new(
4229 <[u8; 32]>::try_from(bytes.as_slice())
4230 .map_err(|_| "exchange seed must contain exactly 32 bytes".to_owned())?,
4231 );
4232 if *seed == [0u8; 32] || *seed == [0xFFu8; 32] {
4233 return Err(format!("exchange seed at {path} is a known-weak value; refusing"));
4234 }
4235 Ok(seed)
4236}
4237
4238#[cfg(test)]
4239mod tests {
4240 #![allow(
4241 clippy::expect_used,
4242 clippy::indexing_slicing,
4243 clippy::panic,
4244 clippy::unwrap_used
4245 )]
4246
4247 #[test]
4257 fn twice_registered_metrics_use_shared_help_consts() {
4258 let sources = [
4259 ("pipeline.rs", include_str!("pipeline.rs")),
4260 ("main.rs", include_str!("main.rs")),
4261 ("reconciler.rs", include_str!("reconciler.rs")),
4262 ];
4263 let shared = [
4264 ("av_reconciler_panics_total", "RECONCILER_PANICS"),
4265 ("av_bridge_maintenance_panics_total", "BRIDGE_MAINTENANCE_PANICS"),
4266 ("av_bridge_maintenance_errors_total", "BRIDGE_MAINTENANCE_ERRORS"),
4267 (
4268 "av_bridge_maintenance_join_errors_total",
4269 "BRIDGE_MAINTENANCE_JOIN_ERRORS",
4270 ),
4271 ("av_atif_retention_panics_total", "ATIF_RETENTION_PANICS"),
4272 ("av_atif_retention_errors_total", "ATIF_RETENTION_ERRORS"),
4273 ("av_lifecycle_outbox_pending", "LIFECYCLE_OUTBOX_PENDING_HELP"),
4274 ];
4275 for (file, src) in sources {
4276 for (metric, const_name) in shared {
4277 let needle = format!("\"{metric}\"");
4278 let mut from = 0;
4279 while let Some(pos) = src[from..].find(&needle) {
4280 let at = from + pos;
4281 let window = &src[at..(at + 220).min(src.len())];
4286 let registers = src[..at]
4287 .rfind(|c: char| !c.is_whitespace())
4288 .map(|p| {
4289 src[p.saturating_sub(20)..at].contains(".counter(")
4290 || src[p.saturating_sub(20)..at].contains(".gauge(")
4291 })
4292 .unwrap_or(false);
4293 if registers {
4294 assert!(
4295 window.contains(const_name),
4296 "{file}: registration of {metric} does not use \
4297 supervision_help::{const_name} — string-literal HELP \
4298 reintroduces the divergent-HELP drift (R285)"
4299 );
4300 }
4301 from = at + needle.len();
4302 }
4303 }
4304 }
4305 }
4306
4307 use super::*;
4308 use av_bridge::{BusError, PublishAck, StoredEvent};
4309 use av_receipts::Ed25519Signer;
4310 use av_sandbox::SandboxConfig;
4311 use av_state::InMemoryStore;
4312 use axum::http::HeaderValue;
4313 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
4314
4315 #[test]
4322 fn last_message_text_tool_call_synthesis_varies_with_object_arguments() {
4323 let payload_string_args = serde_json::json!({
4324 "messages": [{
4325 "role": "assistant",
4326 "content": null,
4327 "tool_calls": [{
4328 "id": "c1",
4329 "type": "function",
4330 "function": {"name": "read", "arguments": "{\"path\":\"a\"}"}
4331 }]
4332 }]
4333 });
4334 let payload_object_args_a = serde_json::json!({
4335 "messages": [{
4336 "role": "assistant",
4337 "content": null,
4338 "tool_calls": [{
4339 "id": "c1",
4340 "type": "function",
4341 "function": {"name": "read", "arguments": {"path": "a"}}
4342 }]
4343 }]
4344 });
4345 let payload_object_args_b = serde_json::json!({
4346 "messages": [{
4347 "role": "assistant",
4348 "content": null,
4349 "tool_calls": [{
4350 "id": "c1",
4351 "type": "function",
4352 "function": {"name": "read", "arguments": {"path": "b"}}
4353 }]
4354 }]
4355 });
4356 let t_string = last_message_text(&payload_string_args);
4357 let t_obj_a = last_message_text(&payload_object_args_a);
4358 let t_obj_b = last_message_text(&payload_object_args_b);
4359 assert!(!t_string.is_empty(), "string-arg synthesis must not be empty");
4360 assert!(!t_obj_a.is_empty(), "object-arg synthesis must not be empty");
4361 assert_ne!(
4362 t_obj_a, t_obj_b,
4363 "object-shape args with different content must produce different text; \
4364 got identical {t_obj_a:?}"
4365 );
4366 assert!(
4367 t_obj_a.contains("\"path\":\"a\"") || t_obj_a.contains("path"),
4368 "object-arg synthesis must include the argument content, got {t_obj_a:?}"
4369 );
4370 }
4371
4372 #[test]
4377 fn last_message_text_tool_call_with_empty_arguments_still_synthesizes() {
4378 let payload = serde_json::json!({
4379 "messages": [{
4380 "role": "assistant",
4381 "content": null,
4382 "tool_calls": [{
4383 "id": "c1",
4384 "type": "function",
4385 "function": {"name": "ping"}
4386 }]
4387 }]
4388 });
4389 let text = last_message_text(&payload);
4390 assert_eq!(text, "ping()");
4391 }
4392
4393 struct NullBus;
4394
4395 struct BlockingSink {
4396 entered: AtomicBool,
4397 release: tokio::sync::Notify,
4398 }
4399
4400 impl VectorSink for BlockingSink {
4401 fn record<'a>(
4402 &'a self,
4403 _session_id: &'a str,
4404 _vector: &'a [f32],
4405 ) -> av_loopdetect::VectorSinkFuture<'a> {
4406 Box::pin(async move {
4407 self.entered.store(true, AtomicOrdering::Release);
4408 self.release.notified().await;
4409 Ok(())
4410 })
4411 }
4412 }
4413
4414 impl EventBus for NullBus {
4415 fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
4416 Ok(PublishAck {
4417 topic: topic.to_owned(),
4418 partition: 0,
4419 offset: 0,
4420 })
4421 }
4422
4423 fn fetch(
4424 &self,
4425 _topic: &str,
4426 _partition: u32,
4427 _offset: u64,
4428 _max: usize,
4429 ) -> Result<Vec<StoredEvent>, BusError> {
4430 Ok(Vec::new())
4431 }
4432
4433 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
4434 Ok(1)
4435 }
4436
4437 fn topics(&self) -> Vec<String> {
4438 EventClass::all()
4439 .iter()
4440 .map(|class| class.topic().to_owned())
4441 .collect()
4442 }
4443 }
4444
4445 fn state(mut config: HarnessConfig) -> AppState {
4446 config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
4447 AppState::new(
4448 config,
4449 Arc::new(InMemoryStore::new()),
4450 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
4451 Arc::new(NullBus),
4452 None,
4453 Arc::new(Ed25519Signer::from_seed(&[9; 32])),
4454 )
4455 .unwrap()
4456 }
4457
4458 fn payload() -> Value {
4459 serde_json::json!({
4460 "model": "test",
4461 "messages": [{"role": "user", "content": "hello"}],
4462 })
4463 }
4464
4465 fn admission_test_runtime() -> tokio::runtime::Runtime {
4466 tokio::runtime::Builder::new_multi_thread()
4467 .worker_threads(2)
4468 .max_blocking_threads(1)
4469 .enable_all()
4470 .build()
4471 .unwrap()
4472 }
4473
4474 async fn occupy_admission_blocking_pool(
4475 ) -> (tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle<()>) {
4476 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4477 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
4478 let task = tokio::task::spawn_blocking(move || {
4479 let _ = started_tx.send(());
4480 let _ = release_rx.blocking_recv();
4482 });
4483 started_rx.await.unwrap();
4484 (release_tx, task)
4485 }
4486
4487 async fn poll_admission_once<F: std::future::Future>(
4488 mut future: std::pin::Pin<&mut F>,
4489 ) -> std::task::Poll<F::Output> {
4490 std::future::poll_fn(|context| std::task::Poll::Ready(future.as_mut().poll(context))).await
4491 }
4492
4493 #[test]
4494 fn small_chat_admission_bypasses_saturated_blocking_pool() {
4495 admission_test_runtime().block_on(async {
4496 for authenticated in [false, true] {
4497 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4498 config.require_identity = authenticated;
4499 config.enforce_identity_scopes = authenticated;
4500 let mut state = state(config);
4501 let mut headers = HeaderMap::new();
4502 headers.insert(SESSION_HEADER, HeaderValue::from_static("small-local-admission"));
4503 if authenticated {
4504 let validator = IdentityValidator::new("agentvisor-ai");
4505 validator
4506 .add_key(
4507 "scope-key",
4508 av_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
4509 )
4510 .unwrap();
4511 state.identity = Some(Arc::new(validator));
4512 headers.insert(
4513 axum::http::header::AUTHORIZATION,
4514 format!("Bearer {}", scoped_token(&["chat:write"]))
4515 .parse()
4516 .unwrap(),
4517 );
4518 }
4519 let caller = state.authenticate_chat_nonblocking(&headers).await.unwrap();
4520 let principal = caller.principal_digest();
4521 let mut other_principal = caller.clone();
4522 if let Some(validated) = &mut other_principal.validated {
4523 validated.claims.sub = "another-principal".into();
4524 }
4525 let (release, blocker) = occupy_admission_blocking_pool().await;
4526 if !authenticated {
4527 let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&headers));
4528 assert!(matches!(
4529 poll_admission_once(authenticate.as_mut()).await,
4530 std::task::Poll::Ready(Ok(_))
4531 ));
4532 let mut duplicated = headers.clone();
4533 duplicated.append(
4534 axum::http::header::AUTHORIZATION,
4535 HeaderValue::from_static("Bearer a"),
4536 );
4537 duplicated.append(
4538 axum::http::header::AUTHORIZATION,
4539 HeaderValue::from_static("Bearer b"),
4540 );
4541 let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&duplicated));
4542 assert!(matches!(
4543 poll_admission_once(authenticate.as_mut()).await,
4544 std::task::Poll::Ready(Err(PipelineError::BadRequest { .. }))
4545 ));
4546 }
4547 let body = payload();
4548 let body_bytes = body.to_string().len();
4549 let mut prepare =
4550 Box::pin(state.prepare_authenticated_chat(&headers, body, body_bytes, caller));
4551 let outcome = poll_admission_once(prepare.as_mut()).await;
4552 let completed_inline = outcome.is_ready();
4553 release.send(()).unwrap();
4554 blocker.await.unwrap();
4555 let prepared = match outcome {
4556 std::task::Poll::Ready(result) => result.unwrap(),
4557 std::task::Poll::Pending => prepare.await.unwrap(),
4558 };
4559 assert!(
4560 completed_inline,
4561 "small local admission queued behind unrelated blocking work"
4562 );
4563 assert_eq!(prepared.session.principal_binding(), principal);
4564 if authenticated {
4565 assert_eq!(
4566 state
4567 .metrics
4568 .counter(
4569 "av_identity_validations_total",
4570 "Full inbound identity validations"
4571 )
4572 .get(),
4573 1
4574 );
4575 let refused = state
4578 .prepare_authenticated_chat(&headers, payload(), body_bytes, other_principal)
4579 .await;
4580 assert!(matches!(refused, Err(PipelineError::Unauthorized(_))));
4581 }
4582 drop(prepared);
4583 state.worker.wait_idle().await;
4584 }
4585 });
4586 }
4587
4588 #[test]
4589 fn chat_preparation_offloads_large_bodies_and_both_quota_classes() {
4590 admission_test_runtime().block_on(async {
4591 for (session_quota, principal_quota, large_body) in [(true, false, false), (false, true, false), (false, false, true)] {
4592 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4593 if session_quota {
4594 config.budget.max_tokens = Some(1_000_000);
4595 }
4596 if principal_quota {
4597 config.principal_budget = Some(av_state::BudgetSpec { max_tokens: Some(1_000_000), ..Default::default() });
4598 }
4599 let state = state(config);
4600 let mut headers = HeaderMap::new();
4601 headers.insert(SESSION_HEADER, HeaderValue::from_static("offloaded-admission"));
4602 let caller = state.authenticate_chat_nonblocking(&headers).await.unwrap();
4603 let body = if large_body {
4604 serde_json::json!({"model":"test", "messages":[{"role":"user", "content":"x".repeat(32 * 1024)}]})
4605 } else {
4606 payload()
4607 };
4608 let body_bytes = body.to_string().len();
4609 let (release, blocker) = occupy_admission_blocking_pool().await;
4610 let mut prepare = Box::pin(state.prepare_authenticated_chat(&headers, body, body_bytes, caller));
4611 let outcome = poll_admission_once(prepare.as_mut()).await;
4612 let offloaded = outcome.is_pending();
4613 let sessions_before_release = state.sessions.len();
4614 release.send(()).unwrap();
4615 blocker.await.unwrap();
4616 let prepared = match outcome {
4617 std::task::Poll::Ready(result) => result.unwrap(),
4618 std::task::Poll::Pending => prepare.await.unwrap(),
4619 };
4620 assert!(offloaded, "large-body or quota preparation ran inline");
4621 assert_eq!(sessions_before_release, 0, "preparation ran before the blocking worker was available");
4622 drop(prepared);
4623 state.worker.wait_idle().await;
4624 }
4625 });
4626 }
4627
4628 #[test]
4629 fn chat_route_passes_actual_body_size_to_admission() {
4630 use tower::ServiceExt as _;
4631
4632 admission_test_runtime().block_on(async {
4633 let state = state(HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp"));
4634 let body = serde_json::json!({
4635 "model": "test",
4636 "messages": [{"role": "user", "content": "x".repeat(32 * 1024)}],
4637 })
4638 .to_string();
4639 let request = axum::http::Request::builder()
4640 .method("POST")
4641 .uri("/v1/chat/completions")
4642 .header("content-type", "application/json")
4643 .header(SESSION_HEADER, "large-route-admission")
4644 .body(axum::body::Body::from(body))
4645 .unwrap();
4646 let (release, blocker) = occupy_admission_blocking_pool().await;
4647 let mut request = Box::pin(crate::build_router(state.clone()).oneshot(request));
4648 let outcome = poll_admission_once(request.as_mut()).await;
4649 let offloaded = outcome.is_pending();
4650 let sessions_before_release = state.sessions.len();
4653 release.send(()).unwrap();
4654 blocker.await.unwrap();
4655 let response = match outcome {
4656 std::task::Poll::Ready(result) => result.unwrap(),
4657 std::task::Poll::Pending => request.await.unwrap(),
4658 };
4659 assert!(offloaded);
4660 assert_eq!(sessions_before_release, 0);
4661 assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY);
4662 assert_eq!(state.sessions.len(), 1);
4663 state.worker.wait_idle().await;
4664 });
4665 }
4666
4667 #[test]
4668 fn unresolved_identity_never_runs_revocation_on_the_admission_task() {
4669 struct CountRevocations(std::sync::atomic::AtomicUsize);
4670 impl av_identity::RevocationStore for CountRevocations {
4671 fn revoke(&self, _jti: &str, _expires_at: u64) -> Result<(), String> {
4672 Ok(())
4673 }
4674 fn is_revoked(&self, _jti: &str) -> Result<bool, String> {
4675 self.0.fetch_add(1, AtomicOrdering::SeqCst);
4676 Ok(false)
4677 }
4678 }
4679 admission_test_runtime().block_on(async {
4680 let revocations = Arc::new(CountRevocations(std::sync::atomic::AtomicUsize::new(0)));
4681 let mut validator = IdentityValidator::new("agentvisor-ai");
4682 validator
4683 .add_key(
4684 "scope-key",
4685 av_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
4686 )
4687 .unwrap();
4688 validator.set_revocation_store(revocations.clone());
4689 let mut state = state(HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp"));
4690 state.identity = Some(Arc::new(validator));
4691 let mut headers = HeaderMap::new();
4692 headers.insert(SESSION_HEADER, HeaderValue::from_static("unresolved-admission"));
4693 headers.insert(
4694 axum::http::header::AUTHORIZATION,
4695 format!("Bearer {}", scoped_token(&["chat:write"]))
4696 .parse()
4697 .unwrap(),
4698 );
4699 let (release, blocker) = occupy_admission_blocking_pool().await;
4700 let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&headers));
4701 let outcome = poll_admission_once(authenticate.as_mut()).await;
4702 let offloaded = outcome.is_pending();
4703 let calls_before_release = revocations.0.load(AtomicOrdering::SeqCst);
4704 release.send(()).unwrap();
4705 blocker.await.unwrap();
4706 let _caller = match outcome {
4707 std::task::Poll::Ready(result) => result.unwrap(),
4708 std::task::Poll::Pending => authenticate.await.unwrap(),
4709 };
4710 assert!(offloaded);
4711 assert_eq!(calls_before_release, 0);
4712 assert_eq!(revocations.0.load(AtomicOrdering::SeqCst), 1);
4713
4714 let (release, blocker) = occupy_admission_blocking_pool().await;
4715 let body = payload();
4716 let body_bytes = body.to_string().len();
4717 let mut prepare = Box::pin(state.prepare_chat_nonblocking(&headers, body, body_bytes, None));
4718 let outcome = poll_admission_once(prepare.as_mut()).await;
4719 let offloaded = outcome.is_pending();
4720 let calls_before_release = revocations.0.load(AtomicOrdering::SeqCst);
4721 release.send(()).unwrap();
4722 blocker.await.unwrap();
4723 let prepared = match outcome {
4724 std::task::Poll::Ready(result) => result.unwrap(),
4725 std::task::Poll::Pending => prepare.await.unwrap(),
4726 };
4727 assert!(offloaded);
4728 assert_eq!(calls_before_release, 1);
4729 assert_eq!(revocations.0.load(AtomicOrdering::SeqCst), 2);
4730 drop(prepared);
4731 state.worker.wait_idle().await;
4732 });
4733 }
4734
4735 async fn trip_loop(state: &AppState, headers: &HeaderMap, repeated: &Value) {
4736 for expected in 1..=4u64 {
4737 let mut prepared = state.prepare_chat(headers, repeated.clone(), None).unwrap();
4738 prepared.capture_guard.defuse();
4743 drop(prepared);
4744 let session = state.sessions.get("loop-session").unwrap();
4745 tokio::time::timeout(std::time::Duration::from_secs(10), async {
4748 while session.chain.lock().count() < expected {
4749 tokio::task::yield_now().await;
4750 }
4751 })
4752 .await
4753 .unwrap();
4754 }
4755 }
4756
4757 #[tokio::test]
4758 async fn prepare_binds_session_and_stays_below_hot_path_budget() {
4759 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4760 let state = state(config);
4761 let mut headers = HeaderMap::new();
4762 headers.insert(SESSION_HEADER, HeaderValue::from_static("session-1"));
4763
4764 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4765 assert_eq!(prepared.session.id, "session-1");
4766 assert!(
4770 prepared.middleware_us < 100_000,
4771 "middleware took {}us",
4772 prepared.middleware_us
4773 );
4774 assert!(state.metrics.render().contains("av_stage_duration_seconds"));
4775 }
4776
4777 #[tokio::test]
4778 async fn missing_identity_fails_closed_when_required() {
4779 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4780 config.require_identity = true;
4781 let error = match state(config).prepare_chat(&HeaderMap::new(), payload(), None) {
4782 Ok(_) => panic!("missing identity was accepted"),
4783 Err(error) => error,
4784 };
4785 assert!(matches!(error, PipelineError::Unauthorized(_)));
4786 }
4787
4788 #[tokio::test]
4789 async fn token_quota_blocks_before_dispatch() {
4790 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4791 config.budget.max_tokens = Some(1);
4792 let state = state(config);
4793 let mut headers = HeaderMap::new();
4794 headers.insert(SESSION_HEADER, HeaderValue::from_static("quota-failure"));
4795 let error = match state.prepare_chat(&headers, payload(), None) {
4796 Ok(_) => panic!("over-budget request was accepted"),
4797 Err(error) => error,
4798 };
4799 assert!(matches!(error, PipelineError::Blocked { .. }));
4800 assert_eq!(state.sessions.len(), 1);
4801 let session = state.sessions.get("quota-failure").unwrap();
4802 tokio::time::timeout(std::time::Duration::from_secs(10), async {
4803 while session.atif_steps_count() == 0 {
4804 tokio::task::yield_now().await;
4805 }
4806 })
4807 .await
4808 .unwrap();
4809 }
4810
4811 #[tokio::test]
4817 async fn principal_budget_binds_across_rotated_session_headers() {
4818 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4819 config.budget.max_tokens = Some(1_000_000);
4822 config.principal_budget = Some(av_state::BudgetSpec {
4827 max_tokens: Some(40),
4828 ..av_state::BudgetSpec::default()
4829 });
4830 let state = state(config);
4831
4832 let mut headers_a = HeaderMap::new();
4834 headers_a.insert(SESSION_HEADER, HeaderValue::from_static("rotated-a"));
4835 let first = state.prepare_chat(&headers_a, payload(), None);
4836 assert!(first.is_ok(), "first principal request must be admitted");
4837 if let Ok(mut prepared) = first {
4842 let _ = prepared.admission_debit.take_for_dispatch();
4843 }
4844
4845 let mut headers_b = HeaderMap::new();
4851 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("rotated-b"));
4852 match state.prepare_chat(&headers_b, payload(), None) {
4853 Ok(_) => panic!("header rotation defeated principal budget — the fix from §3.2 has regressed"),
4854 Err(PipelineError::Blocked { context: msg, .. }) => {
4855 assert!(
4856 msg.contains("principal."),
4857 "refusal must name the principal-scoped limit, got {msg:?}"
4858 );
4859 }
4860 Err(other) => panic!("unexpected refusal shape {other:?}"),
4861 }
4862 }
4863
4864 #[tokio::test]
4872 async fn abandon_prepared_refunds_the_principal_ledger_too() {
4873 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4874 config.budget.max_tokens = Some(1_000_000);
4875 config.principal_budget = Some(av_state::BudgetSpec {
4878 max_tokens: Some(40),
4879 ..av_state::BudgetSpec::default()
4880 });
4881 let state = state(config);
4882
4883 let mut headers = HeaderMap::new();
4884 headers.insert(SESSION_HEADER, HeaderValue::from_static("abandon-a"));
4885 let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4886 state.abandon_prepared(&mut prepared, StopReason::Other, "test abandon");
4887 drop(prepared);
4888
4889 let mut headers_b = HeaderMap::new();
4893 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("abandon-b"));
4894 let second = state.prepare_chat(&headers_b, payload(), None);
4895 assert!(
4896 second.is_ok(),
4897 "an abandoned request must not consume principal budget; got {:?}",
4898 second.err()
4899 );
4900 }
4901
4902 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4908 async fn dropping_a_prepared_request_refunds_both_ledgers() {
4909 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4910 config.budget.max_tokens = Some(1_000_000);
4911 config.principal_budget = Some(av_state::BudgetSpec {
4914 max_tokens: Some(40),
4915 ..av_state::BudgetSpec::default()
4916 });
4917 let state = state(config);
4918
4919 let mut headers = HeaderMap::new();
4920 headers.insert(SESSION_HEADER, HeaderValue::from_static("cancelled-a"));
4921 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4922 drop(prepared);
4924
4925 let mut headers_b = HeaderMap::new();
4927 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("cancelled-b"));
4928 let mut second = state.prepare_chat(&headers_b, payload(), None);
4929 for _ in 0..200 {
4930 if second.is_ok() {
4931 break;
4932 }
4933 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4934 second = state.prepare_chat(&headers_b, payload(), None);
4935 }
4936 assert!(
4937 second.is_ok(),
4938 "a dropped (cancelled) prepared request must refund the principal ledger; got {:?}",
4939 second.err()
4940 );
4941 }
4942
4943 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4949 async fn dispatched_debit_stands_when_the_request_drops() {
4950 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4951 config.budget.max_tokens = Some(1_000_000);
4952 config.principal_budget = Some(av_state::BudgetSpec {
4953 max_tokens: Some(40),
4954 ..av_state::BudgetSpec::default()
4955 });
4956 let state = state(config);
4957
4958 let mut headers = HeaderMap::new();
4959 headers.insert(SESSION_HEADER, HeaderValue::from_static("dispatched-a"));
4960 let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4961 let debited = prepared.admission_debit.take_for_dispatch();
4962 assert!(debited > 0, "admission must have debited tokens");
4963 drop(prepared);
4964
4965 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4968 let mut headers_b = HeaderMap::new();
4969 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("dispatched-b"));
4970 assert!(
4971 matches!(
4972 state.prepare_chat(&headers_b, payload(), None),
4973 Err(PipelineError::Blocked { .. })
4974 ),
4975 "a dispatched request's debit must stand after drop"
4976 );
4977 }
4978
4979 #[tokio::test]
4985 async fn duplicate_passthrough_header_is_refused_without_consuming_budget() {
4986 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4987 config.budget.max_tokens = Some(40);
4990 let state = state(config);
4991
4992 let mut headers = HeaderMap::new();
4993 headers.insert(SESSION_HEADER, HeaderValue::from_static("dup-header"));
4994 headers.append("openai-beta", HeaderValue::from_static("assistants=v1"));
4995 headers.append("openai-beta", HeaderValue::from_static("assistants=v2"));
4996 assert!(
4997 matches!(
4998 state.prepare_chat(&headers, payload(), None),
4999 Err(PipelineError::BadRequest { .. })
5000 ),
5001 "duplicate allowlisted headers must be refused"
5002 );
5003
5004 let mut clean = HeaderMap::new();
5006 clean.insert(SESSION_HEADER, HeaderValue::from_static("dup-header"));
5007 clean.insert("openai-beta", HeaderValue::from_static("assistants=v2"));
5008 let second = state.prepare_chat(&clean, payload(), None);
5009 assert!(
5010 second.is_ok(),
5011 "a header-refused request must not consume budget; got {:?}",
5012 second.err()
5013 );
5014 }
5015
5016 #[tokio::test]
5017 async fn invalid_workflow_is_rejected() {
5018 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5019 let state = state(config);
5020 let mut headers = HeaderMap::new();
5021 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("sometimes"));
5022 assert!(matches!(
5023 state.prepare_chat(&headers, payload(), None),
5024 Err(PipelineError::BadRequest { .. })
5025 ));
5026 }
5027
5028 #[tokio::test]
5029 async fn open_session_cannot_change_workflow() {
5030 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5031 let state = state(config);
5032 let mut headers = HeaderMap::new();
5033 headers.insert(SESSION_HEADER, HeaderValue::from_static("bound-session"));
5034 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5035 state.prepare_chat(&headers, payload(), None).unwrap();
5036 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
5037 assert!(matches!(
5038 state.prepare_chat(&headers, payload(), None),
5039 Err(PipelineError::BadRequest { .. })
5040 ));
5041 }
5042
5043 #[tokio::test]
5044 async fn asynchronous_loop_verdict_blocks_the_next_request() {
5045 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5046 config.breaker.min_tokens = 0;
5047 let state = state(config);
5048 let mut headers = HeaderMap::new();
5049 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5050 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5051 let repeated = serde_json::json!({
5052 "model": "test",
5053 "messages": [{
5054 "role": "assistant",
5055 "content": "I should check the database again for pending orders"
5056 }]
5057 });
5058 trip_loop(&state, &headers, &repeated).await;
5059 assert!(matches!(
5060 state.prepare_chat(&headers, repeated, None),
5061 Err(PipelineError::Blocked { .. })
5062 ));
5063 }
5064
5065 #[tokio::test]
5066 async fn loop_inject_action_adds_correction_and_resets_breaker() {
5067 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5068 config.breaker.min_tokens = 0;
5069 config.breaker.action = BreakerAction::Inject;
5070 let state = state(config);
5071 let mut headers = HeaderMap::new();
5072 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5073 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5074 let repeated = serde_json::json!({
5075 "messages": [{"role": "assistant", "content": "repeat the database query"}]
5076 });
5077 trip_loop(&state, &headers, &repeated).await;
5078 let prepared = state.prepare_chat(&headers, repeated, None).unwrap();
5079 let correction = prepared
5080 .payload
5081 .get("messages")
5082 .and_then(Value::as_array)
5083 .and_then(|messages| messages.last())
5084 .and_then(|message| message.get("content"))
5085 .and_then(Value::as_str)
5086 .unwrap();
5087 assert!(correction.contains("semantic loop"));
5088 assert_eq!(prepared.session.loop_state.state(), BreakerState::Closed);
5089 }
5090
5091 #[tokio::test]
5092 async fn loop_abort_action_returns_abort_error() {
5093 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5094 config.breaker.min_tokens = 0;
5095 config.breaker.action = BreakerAction::Abort;
5096 let state = state(config);
5097 let mut headers = HeaderMap::new();
5098 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5099 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5100 let repeated = serde_json::json!({
5101 "messages": [{"role": "assistant", "content": "repeat the database query"}]
5102 });
5103 trip_loop(&state, &headers, &repeated).await;
5104 assert!(matches!(
5105 state.prepare_chat(&headers, repeated, None),
5106 Err(PipelineError::Abort(_))
5107 ));
5108 }
5109
5110 #[tokio::test]
5117 async fn breaker_trip_does_not_drop_the_admissions_prompt_token_accounting() {
5118 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5119 config.breaker.min_tokens = 0;
5120 let state = state(config);
5121 let mut headers = HeaderMap::new();
5122 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5123 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5124 let repeated = serde_json::json!({
5125 "model": "test",
5126 "messages": [{
5127 "role": "assistant",
5128 "content": "I should check the database again for pending orders"
5129 }]
5130 });
5131 let mut per_request = 0u64;
5135 for expected in 1..=4u64 {
5136 state.prepare_chat(&headers, repeated.clone(), None).unwrap();
5137 let session = state.sessions.get("loop-session").unwrap();
5138 tokio::time::timeout(std::time::Duration::from_secs(10), async {
5139 while session.chain.lock().count() < expected {
5140 tokio::task::yield_now().await;
5141 }
5142 })
5143 .await
5144 .unwrap();
5145 if expected == 1 {
5146 tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
5147 .await
5148 .unwrap();
5149 per_request = session
5150 .totals
5151 .prompt_tokens
5152 .load(std::sync::atomic::Ordering::Acquire);
5153 assert!(per_request > 0, "admission must account prompt tokens");
5154 }
5155 }
5156 let session = state.sessions.get("loop-session").unwrap();
5157 tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
5158 .await
5159 .unwrap();
5160 assert_eq!(
5161 session.loop_state.state(),
5162 BreakerState::Open,
5163 "precondition: the breaker must have tripped during the four admissions",
5164 );
5165 assert_eq!(
5166 session
5167 .totals
5168 .prompt_tokens
5169 .load(std::sync::atomic::Ordering::Acquire),
5170 per_request * 4,
5171 "every admission's prompt tokens must be accounted, including the one whose \
5172 worker job carried the breaker trip",
5173 );
5174 }
5175
5176 #[tokio::test]
5181 async fn finalization_clears_the_sessions_budget_counters() {
5182 let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
5183 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5184 config.budget.max_tokens = Some(1_000_000);
5185 config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
5186 let state = AppState::new(
5187 config,
5188 Arc::clone(&store),
5189 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
5190 Arc::new(NullBus),
5191 None,
5192 Arc::new(Ed25519Signer::from_seed(&[9; 32])),
5193 )
5194 .unwrap();
5195 let mut headers = HeaderMap::new();
5196 headers.insert(SESSION_HEADER, HeaderValue::from_static("budget-cleanup"));
5197 let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5198 let _ = prepared.admission_debit.take_for_dispatch();
5209 drop(prepared);
5210 let tokens_key = format!(
5211 "{}tokens",
5212 av_state::ActionBudget::session_prefix("budget-cleanup")
5213 );
5214 let observed = store.get(&tokens_key);
5215 assert!(
5216 observed.as_ref().copied().unwrap_or(0) > 0,
5217 "precondition: admission must have spent from the token budget \
5218 (key {tokens_key:?}, observed {observed:?})",
5219 );
5220 let session = state.sessions.get("budget-cleanup").unwrap();
5221 session.wait_for_worker_jobs().await;
5222 state
5223 .finalizer
5224 .close_session(session, StopReason::SessionClosed)
5225 .await
5226 .unwrap();
5227 assert_eq!(
5228 store.get(&tokens_key).unwrap(),
5229 0,
5230 "finalization must remove the sealed session's budget counters",
5231 );
5232 }
5233
5234 #[tokio::test]
5235 async fn consequential_tools_require_signed_workflows() {
5236 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5237 let state = state(config);
5238 let raw = serde_json::to_vec(&serde_json::json!({
5239 "jsonrpc": "2.0",
5240 "id": 1,
5241 "method": "tools/call",
5242 "params": {"name": "db_write", "arguments": {}}
5243 }))
5244 .unwrap();
5245 let mut unsigned = HeaderMap::new();
5246 unsigned.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-write"));
5247 assert!(matches!(
5248 state.intercept_tool(&unsigned, &raw).unwrap(),
5249 ToolVerdict::Blocked { stage: "policy", .. }
5250 ));
5251
5252 let mut signed = HeaderMap::new();
5253 signed.insert(SESSION_HEADER, HeaderValue::from_static("signed-write"));
5254 signed.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5255 assert!(state.intercept_tool(&signed, &raw).unwrap().is_allowed());
5256 }
5257
5258 #[tokio::test]
5265 async fn open_breaker_hard_stop_refuses_tool_calls() {
5266 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5267 let state = state(config);
5268 let identity = av_events::AgentIdentity {
5269 version: "dev".into(),
5270 charter: "anonymous".into(),
5271 instance_uid: "anonymous".into(),
5272 ttl_remaining_s: None,
5273 };
5274 let session = state.sessions.get_or_open(
5275 "breaker-tools",
5276 crate::session::Workflow::Unsigned,
5277 &identity,
5278 &state.config.breaker,
5279 );
5280 for _ in 0..4 {
5284 session.loop_state.observe_embedding(vec![1.0, 2.0, 3.0], 2_000);
5285 }
5286 assert_eq!(
5287 session.loop_state.state(),
5288 BreakerState::Open,
5289 "precondition: breaker must be open",
5290 );
5291
5292 let raw = serde_json::to_vec(&serde_json::json!({
5293 "jsonrpc": "2.0",
5294 "id": 1,
5295 "method": "tools/call",
5296 "params": {"name": "db_read", "arguments": {}}
5297 }))
5298 .unwrap();
5299 let mut headers = HeaderMap::new();
5300 headers.insert(SESSION_HEADER, HeaderValue::from_static("breaker-tools"));
5301 let refusal = state.intercept_tool(&headers, &raw);
5302 assert!(
5303 matches!(&refusal, Err(PipelineError::Blocked { context, .. }) if context.contains("circuit breaker")),
5304 "an open hard-stop breaker must refuse tool execution, got {refusal:?}",
5305 );
5306 assert!(
5307 session.enforcement_tripped(),
5308 "the tool-path refusal latches enforcement like the chat path",
5309 );
5310 }
5311
5312 #[tokio::test]
5317 async fn required_identity_tool_call_without_bearer_is_refused_before_parse() {
5318 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5319 config.require_identity = true;
5320 let state = state(config);
5321 let mut headers = HeaderMap::new();
5322 headers.insert(SESSION_HEADER, HeaderValue::from_static("anon-tools"));
5323 let refusal = state.intercept_tool(&headers, b"{this is not json");
5326 assert!(
5327 matches!(&refusal, Err(PipelineError::Unauthorized(reason)) if reason.contains("missing bearer token")),
5328 "missing bearer under require_identity must refuse pre-parse, got {refusal:?}",
5329 );
5330 }
5331
5332 #[tokio::test]
5339 async fn intercept_tool_durable_works_without_a_session_header() {
5340 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5341 let state = state(config);
5342 let raw = serde_json::to_vec(&serde_json::json!({
5343 "jsonrpc": "2.0",
5344 "id": 1,
5345 "method": "tools/call",
5346 "params": {"name": "safe_tool", "arguments": {}}
5347 }))
5348 .unwrap();
5349 let verdict = state
5350 .intercept_tool_durable(&HeaderMap::new(), &raw)
5351 .await
5352 .unwrap();
5353 assert!(
5354 verdict.is_allowed(),
5355 "durable interception must await the verdict's own session, not a re-derived id",
5356 );
5357 }
5358
5359 #[tokio::test]
5366 async fn durable_breaker_refusal_journals_a_terminal_response_attempt() {
5367 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5368 config.breaker.min_tokens = 0;
5369 let state = state(config);
5370 let mut headers = HeaderMap::new();
5371 headers.insert(SESSION_HEADER, HeaderValue::from_static("durable-loop"));
5372 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5373 let repeated = serde_json::json!({
5374 "model": "test",
5375 "messages": [{
5376 "role": "assistant",
5377 "content": "I should check the database again for pending orders"
5378 }],
5379 });
5380 let mut abandoned_by_test = std::collections::HashSet::new();
5384 let mut refusal = None;
5385 for _ in 0..8 {
5386 match state.prepare_chat_durable(&headers, repeated.clone()).await {
5387 Ok(prepared) => {
5388 abandoned_by_test.insert(prepared.capture_guard.attempt_id().to_owned());
5389 }
5390 Err(error) => {
5391 refusal = Some(error);
5392 break;
5393 }
5394 }
5395 }
5396 assert!(
5397 matches!(refusal, Some(PipelineError::Blocked { .. })),
5398 "the breaker must refuse during the audit wait, got {refusal:?}",
5399 );
5400 assert!(
5401 !abandoned_by_test.is_empty(),
5402 "at least one prepare must succeed first"
5403 );
5404 let session = state.sessions.get("durable-loop").unwrap();
5405 session.wait_for_worker_jobs().await;
5406
5407 let digest = av_core::digest::sha256_hex("durable-loop".as_bytes());
5408 let stem = digest.get(..32).unwrap();
5409 let journal_path =
5410 std::path::Path::new(&state.config.atif_spool_dir).join(format!("{stem}.events.ndjson"));
5411 let journal = std::fs::read_to_string(&journal_path).unwrap();
5412 let mut dangling = std::collections::HashSet::new();
5413 for (index, line) in journal.lines().enumerate() {
5414 let record: crate::worker::ActiveJournalRecord = crate::journal::open(
5415 &state.journal_key,
5416 "durable-loop:active",
5417 index as u64,
5418 line.as_bytes(),
5419 )
5420 .unwrap();
5421 if let Some(attempt) = record.response_attempt {
5422 if attempt.terminal {
5423 assert!(
5424 dangling.remove(&attempt.id),
5425 "terminal record for attempt {} has no admission record",
5426 attempt.id,
5427 );
5428 } else {
5429 dangling.insert(attempt.id);
5430 }
5431 }
5432 }
5433 assert!(
5434 dangling.is_empty(),
5435 "every response attempt must be terminated: the refusal terminates its own \
5436 attempt, and dropping a PreparedRequest (client disconnect) terminates via \
5437 the capture guard; still dangling: {dangling:?} (test dropped: {abandoned_by_test:?})",
5438 );
5439 }
5440
5441 #[tokio::test]
5449 async fn forward_chat_fails_closed_when_marker_cannot_persist() {
5450 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5451 let state = state(config);
5452 let mut headers = HeaderMap::new();
5453 headers.insert(SESSION_HEADER, HeaderValue::from_static("marker-outage"));
5454 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5455 let attempt_id = prepared.capture_guard.attempt_id().to_owned();
5456 std::fs::write(
5460 std::path::Path::new(&state.config.atif_spool_dir).join(crate::spool::INFLIGHT_RESPONSES),
5461 b"",
5462 )
5463 .unwrap();
5464 let error = state.forward_chat(prepared).await.err().unwrap();
5465 assert!(
5468 matches!(error, PipelineError::Unavailable { .. }),
5469 "marker persist failure must map to Unavailable, got {error:?}",
5470 );
5471
5472 let session = state.sessions.get("marker-outage").unwrap();
5473 session.wait_for_worker_jobs().await;
5474 let digest = av_core::digest::sha256_hex("marker-outage".as_bytes());
5475 let stem = digest.get(..32).unwrap();
5476 let journal_path =
5477 std::path::Path::new(&state.config.atif_spool_dir).join(format!("{stem}.events.ndjson"));
5478 let journal = std::fs::read_to_string(&journal_path).unwrap();
5479 let mut saw_admission = false;
5480 let mut saw_terminal = false;
5481 for (index, line) in journal.lines().enumerate() {
5482 let record: crate::worker::ActiveJournalRecord = crate::journal::open(
5483 &state.journal_key,
5484 "marker-outage:active",
5485 index as u64,
5486 line.as_bytes(),
5487 )
5488 .unwrap();
5489 if let Some(attempt) = record.response_attempt {
5490 if attempt.id == attempt_id {
5491 if attempt.terminal {
5492 saw_terminal = true;
5493 } else {
5494 saw_admission = true;
5495 }
5496 }
5497 }
5498 }
5499 assert!(saw_admission, "admission must have journaled the attempt");
5500 assert!(
5501 saw_terminal,
5502 "the refused request's response attempt must be terminated in the journal",
5503 );
5504 }
5505
5506 #[tokio::test]
5513 async fn intercept_tool_refreshes_session_activity() {
5514 use std::sync::atomic::Ordering as AtomicOrdering;
5515
5516 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5517 let state = state(config);
5518 let raw = serde_json::to_vec(&serde_json::json!({
5519 "jsonrpc": "2.0",
5520 "id": 1,
5521 "method": "tools/call",
5522 "params": {"name": "safe_tool", "arguments": {}}
5523 }))
5524 .unwrap();
5525 let mut headers = HeaderMap::new();
5526 headers.insert(SESSION_HEADER, HeaderValue::from_static("tool-only-session"));
5527 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5528
5529 state.intercept_tool(&headers, &raw).unwrap();
5530 let session = state.sessions.get("tool-only-session").unwrap();
5531 session.last_activity_ms.store(0, AtomicOrdering::Release);
5535
5536 state.intercept_tool(&headers, &raw).unwrap();
5537
5538 let refreshed = session.last_activity_ms.load(AtomicOrdering::Acquire);
5539 assert!(
5540 refreshed > 0,
5541 "intercept_tool must refresh last_activity_ms so a tool-only session is not force-closed by the idle sweeper — got {refreshed}",
5542 );
5543 }
5544
5545 #[tokio::test]
5555 async fn unsigned_consequential_veto_does_not_spend_budget() {
5556 let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
5557 let sandbox_config = SandboxConfig {
5558 budget: av_state::BudgetSpec {
5559 max_total_tool_calls: Some(5),
5560 ..av_state::BudgetSpec::default()
5561 },
5562 ..SandboxConfig::default()
5563 };
5564 let sandbox = Arc::new(Sandbox::new(sandbox_config, Vec::new()).unwrap());
5565 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5566 config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
5567 let state = AppState::new(
5568 config,
5569 Arc::clone(&store),
5570 sandbox,
5571 Arc::new(NullBus),
5572 None,
5573 Arc::new(Ed25519Signer::from_seed(&[9; 32])),
5574 )
5575 .unwrap();
5576
5577 let raw = serde_json::to_vec(&serde_json::json!({
5578 "jsonrpc": "2.0",
5579 "id": 1,
5580 "method": "tools/call",
5581 "params": {"name": "db_write", "arguments": {}}
5582 }))
5583 .unwrap();
5584 let mut headers = HeaderMap::new();
5585 headers.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-consequential"));
5586 let verdict = state.intercept_tool(&headers, &raw).unwrap();
5589 assert!(
5590 matches!(verdict, ToolVerdict::Blocked { stage: "policy", .. }),
5591 "consequential tool on unsigned workflow must block at the policy gate; got {verdict:?}",
5592 );
5593
5594 let session_digest = av_core::digest::sha256_hex("unsigned-consequential".as_bytes());
5595 let key = format!("budget:{{{}}}:total_calls", session_digest.get(..32).unwrap());
5596 let count = store.get(&key).unwrap();
5597 assert_eq!(
5598 count, 0,
5599 "sandbox.check must not spend the budget for a call the workflow gate vetoes — otherwise a legitimate client that upgrades to signed and retries hits the cap short of the configured limit",
5600 );
5601 }
5602
5603 #[tokio::test]
5604 async fn full_capture_queue_fails_closed_before_upstream() {
5605 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5606 config.worker_channel_capacity = 1;
5617 config.breaker.min_tokens = u64::MAX;
5618 let sink = Arc::new(BlockingSink {
5619 entered: AtomicBool::new(false),
5620 release: tokio::sync::Notify::new(),
5621 });
5622 let state = AppState::new_with_backends(
5623 config,
5624 Arc::new(InMemoryStore::new()),
5625 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
5626 Arc::new(NullBus),
5627 None,
5628 Arc::new(Ed25519Signer::from_seed(&[10; 32])),
5629 Arc::new(HashEmbedder::default()),
5630 sink.clone(),
5631 )
5632 .unwrap();
5633 let mut headers = HeaderMap::new();
5634 headers.insert(SESSION_HEADER, HeaderValue::from_static("overload"));
5635 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5636 let mut agent_payload = payload();
5637 agent_payload["messages"][0]["role"] = Value::String("assistant".to_owned());
5638 let mut first = state.prepare_chat(&headers, agent_payload.clone(), None).unwrap();
5639 first.capture_guard.defuse();
5643 drop(first);
5644 tokio::time::timeout(std::time::Duration::from_secs(10), async {
5645 while !sink.entered.load(AtomicOrdering::Acquire) {
5646 tokio::task::yield_now().await;
5647 }
5648 })
5649 .await
5650 .unwrap();
5651 assert!(matches!(
5652 state.prepare_chat(&headers, agent_payload, None),
5653 Err(PipelineError::Unavailable { .. })
5654 ));
5655 sink.release.notify_waiters();
5656 }
5657
5658 #[test]
5659 fn identity_scope_matching_supports_exact_and_namespace_wildcards() {
5660 assert!(scope_allows(&["chat:write".into()], "chat:write"));
5661 assert!(scope_allows(&["tool:*".into()], "tool:db_write"));
5662 assert!(scope_allows(&["*".into()], "session:promote"));
5663 assert!(!scope_allows(&["chat:read".into()], "chat:write"));
5664 assert!(!scope_allows(&["tooling:*".into()], "tool:db_write"));
5665 }
5666
5667 #[test]
5668 fn developer_and_tool_roles_map_to_valid_system_steps() {
5669 for (role, expects_observation) in [("developer", false), ("tool", true)] {
5670 let capture = atif_capture_from_request(&serde_json::json!({
5671 "messages": [{
5672 "role": role,
5673 "content": "context",
5674 "tool_call_id": "call-1"
5675 }]
5676 }))
5677 .unwrap();
5678 assert_eq!(capture.source, av_atif::Source::System);
5679 assert_eq!(capture.observation.is_some(), expects_observation);
5680 assert!(capture.model_name.is_none());
5681 }
5682 }
5683
5684 #[test]
5689 fn atif_capture_rejects_non_array_messages_with_precise_diagnostics() {
5690 fn bad_request_message(payload: serde_json::Value) -> String {
5691 match atif_capture_from_request(&payload) {
5692 Ok(_) => panic!("expected BadRequest, got Ok"),
5693 Err(PipelineError::BadRequest { context: msg, .. }) => msg,
5694 Err(other) => panic!("expected BadRequest, got {other:?}"),
5695 }
5696 }
5697 let msg = bad_request_message(serde_json::json!({
5700 "input": [{ "role": "user", "content": "hi" }]
5701 }));
5702 assert!(msg.contains("missing 'messages'"), "got {msg}");
5703 let msg = bad_request_message(serde_json::json!({
5705 "messages": { "0": { "role": "user", "content": "hi" } }
5706 }));
5707 assert!(msg.contains("must be a JSON array"), "got {msg}");
5708 assert!(msg.contains("object"), "got {msg}");
5709 let msg = bad_request_message(serde_json::json!({ "messages": [] }));
5711 assert!(msg.contains("empty"), "got {msg}");
5712 }
5713
5714 #[test]
5721 fn duplicate_x_av_session_header_is_refused() {
5722 let mut headers = HeaderMap::new();
5723 headers.append(SESSION_HEADER, HeaderValue::from_static("sessA"));
5724 headers.append(SESSION_HEADER, HeaderValue::from_static("sessB"));
5725 match session_id(&headers) {
5726 Ok(id) => panic!("expected duplicate-header refusal, got session_id {id:?}"),
5727 Err(PipelineError::BadRequest { context: msg, .. }) => {
5728 assert!(msg.contains("more than one"), "got {msg}");
5729 assert!(msg.contains("x-av-session"), "got {msg}");
5730 }
5731 Err(other) => panic!("expected BadRequest, got {other:?}"),
5732 }
5733 }
5734
5735 #[test]
5736 fn duplicate_x_av_workflow_header_is_refused() {
5737 let mut headers = HeaderMap::new();
5738 headers.append(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5739 headers.append(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
5740 match workflow(&headers, "signed") {
5741 Ok(w) => panic!("expected duplicate-header refusal, got workflow {w:?}"),
5742 Err(PipelineError::BadRequest { context: msg, .. }) => {
5743 assert!(msg.contains("more than one"), "got {msg}");
5744 assert!(msg.contains("x-av-workflow"), "got {msg}");
5745 }
5746 Err(other) => panic!("expected BadRequest, got {other:?}"),
5747 }
5748 }
5749
5750 #[test]
5753 fn single_x_av_session_header_still_flows() {
5754 let mut headers = HeaderMap::new();
5755 headers.insert(SESSION_HEADER, HeaderValue::from_static("only-one"));
5756 assert_eq!(session_id(&headers).unwrap(), "only-one");
5757 }
5758
5759 #[tokio::test]
5767 async fn resolve_identity_refuses_duplicate_authorization_header() {
5768 let state = null_state();
5769 let mut headers = HeaderMap::new();
5770 headers.append(
5771 axum::http::header::AUTHORIZATION,
5772 HeaderValue::from_static("Bearer aaaa"),
5773 );
5774 headers.append(
5775 axum::http::header::AUTHORIZATION,
5776 HeaderValue::from_static("Bearer bbbb"),
5777 );
5778 match state.resolve_identity(&headers, None) {
5779 Ok(_) => panic!("expected duplicate-header refusal, got Ok"),
5780 Err(PipelineError::BadRequest { context: msg, .. }) => {
5781 assert!(msg.contains("more than one"), "got {msg}");
5782 assert!(msg.contains("authorization"), "got {msg}");
5783 }
5784 Err(other) => panic!("expected BadRequest, got {other:?}"),
5785 }
5786 }
5787
5788 #[tokio::test]
5791 async fn resolve_identity_accepts_single_authorization_header() {
5792 let state = null_state();
5793 let mut headers = HeaderMap::new();
5794 headers.insert(
5795 axum::http::header::AUTHORIZATION,
5796 HeaderValue::from_static("Bearer aaaa"),
5797 );
5798 let outcome = state.resolve_identity(&headers, None);
5802 assert!(
5803 !matches!(&outcome, Err(PipelineError::BadRequest { context: msg, .. }) if msg.contains("more than one")),
5804 "single Authorization header must not be refused as duplicate; got {outcome:?}"
5805 );
5806 }
5807
5808 #[tokio::test]
5814 async fn ignore_client_authorization_accepts_and_discards_sdk_bearer() {
5815 let mut headers = HeaderMap::new();
5816 headers.insert(
5817 axum::http::header::AUTHORIZATION,
5818 HeaderValue::from_static("Bearer sk-test-openai-sdk-key"),
5819 );
5820
5821 let refused = null_state().resolve_identity(&headers, None);
5823 assert!(
5824 matches!(&refused, Err(PipelineError::Unauthorized(_))),
5825 "bearer with no validator and no opt-in must stay 401; got {refused:?}"
5826 );
5827
5828 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5830 config.require_identity = false;
5831 config.ignore_client_authorization = true;
5832 let accepted = state(config).resolve_identity(&headers, None);
5833 match accepted {
5834 Ok(identity) => {
5835 assert_eq!(identity.instance_uid, "anonymous");
5836 }
5837 Err(error) => {
5838 panic!("ignore_client_authorization must accept the SDK bearer anonymously; got {error:?}")
5839 }
5840 }
5841 }
5842
5843 #[test]
5847 fn ignore_client_authorization_is_refused_with_identity_or_passthrough() {
5848 let mut with_identity = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5849 with_identity.ignore_client_authorization = true;
5850 with_identity.require_identity = true;
5851 with_identity.identity_hmac_secret_file = Some("/tmp/hmac".into());
5852 let err = with_identity.validate().unwrap_err();
5853 assert!(err.contains("ignore_client_authorization"), "{err}");
5854 assert!(err.contains("require_identity"), "{err}");
5855
5856 let mut with_passthrough = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5857 with_passthrough.ignore_client_authorization = true;
5858 with_passthrough.upstream_authorization_passthrough = true;
5859 let err = with_passthrough.validate().unwrap_err();
5860 assert!(err.contains("ignore_client_authorization"), "{err}");
5861 assert!(err.contains("passthrough"), "{err}");
5862 }
5863
5864 #[tokio::test]
5870 async fn identity_rejection_audit_records_are_bounded_per_minute() {
5871 let state = null_state();
5872 for i in 0..100u32 {
5873 state
5874 .enqueue_transient_failure(
5875 &format!("probe-{i}"),
5876 StopReason::IdentityRejected,
5877 "missing bearer token".to_owned(),
5878 )
5879 .unwrap();
5880 }
5881 let total = state
5882 .metrics
5883 .counter(
5884 "av_identity_rejections_total",
5885 "Requests refused by identity validation",
5886 )
5887 .get();
5888 let unaudited = state
5889 .metrics
5890 .counter(
5891 "av_identity_rejections_unaudited_total",
5892 "Identity rejections beyond the per-minute audit-record budget (counted only)",
5893 )
5894 .get();
5895 assert_eq!(total, 100, "every rejection must be counted");
5896 assert_eq!(
5897 unaudited, 40,
5898 "rejections beyond the 60/min budget must be count-only, not signed audit sessions"
5899 );
5900 }
5901
5902 #[test]
5908 fn strip_bearer_scheme_matches_case_insensitively() {
5909 for scheme in ["Bearer aaaa", "bearer aaaa", "BEARER aaaa", "BeArEr aaaa"] {
5910 assert_eq!(
5911 strip_bearer_scheme(scheme),
5912 Some("aaaa"),
5913 "scheme {scheme:?} did not parse to `aaaa`"
5914 );
5915 }
5916 assert_eq!(strip_bearer_scheme("Bearer\taaaa"), Some("aaaa"));
5918 assert_eq!(strip_bearer_scheme("Bearer aaaa"), Some("aaaa"));
5920 assert_eq!(strip_bearer_scheme("BearerXYZ"), None);
5922 assert_eq!(strip_bearer_scheme("Bearer "), None);
5923 assert_eq!(strip_bearer_scheme("Bearer"), None);
5924 assert_eq!(strip_bearer_scheme("Basic ZmY6bGFyZQ=="), None);
5926 assert_eq!(strip_bearer_scheme(""), None);
5928 }
5929
5930 fn null_state() -> AppState {
5931 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5932 config.require_identity = false;
5933 let sandbox = Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap();
5934 let store = Arc::new(InMemoryStore::new());
5935 let bridge: Arc<dyn EventBus> = Arc::new(NullBus);
5936 let signer: Arc<dyn Signer> = Arc::new(Ed25519Signer::generate());
5937 let embedder = Arc::new(HashEmbedder::default());
5938 let vector_sink: Arc<dyn VectorSink> = Arc::new(NoopVectorSink);
5939 AppState::new_with_backends(
5940 config,
5941 store,
5942 Arc::new(sandbox),
5943 bridge,
5944 None,
5945 signer,
5946 embedder,
5947 vector_sink,
5948 )
5949 .unwrap()
5950 }
5951
5952 fn scoped_token(scopes: &[&str]) -> String {
5953 let now = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
5954 let claims = av_identity::NhiClaims {
5955 sub: "agent:test".into(),
5956 iss: "https://idp.example".into(),
5957 aud: "agentvisor-ai".into(),
5958 iat: now,
5959 nbf: None,
5960 exp: now + 600,
5961 jti: av_core::new_event_uid(),
5962 azp: None,
5963 act: None,
5964 instance_uid: "scoped-instance".into(),
5965 charter: "scoped-charter".into(),
5966 version: "1".into(),
5967 scopes: scopes.iter().map(|scope| (*scope).to_owned()).collect(),
5968 parent_token: None,
5969 };
5970 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
5971 header.kid = Some("scope-key".into());
5972 jsonwebtoken::encode(
5973 &header,
5974 &claims,
5975 &jsonwebtoken::EncodingKey::from_secret(b"scope-secret"),
5976 )
5977 .unwrap()
5978 }
5979
5980 fn scoped_state() -> AppState {
5981 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5982 config.require_identity = true;
5983 config.enforce_identity_scopes = true;
5984 let validator = av_identity::IdentityValidator::new("agentvisor-ai");
5985 validator
5986 .add_key(
5987 "scope-key",
5988 av_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
5989 )
5990 .unwrap();
5991 AppState::new(
5992 config,
5993 Arc::new(InMemoryStore::new()),
5994 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
5995 Arc::new(NullBus),
5996 Some(Arc::new(validator)),
5997 Arc::new(Ed25519Signer::from_seed(&[12; 32])),
5998 )
5999 .unwrap()
6000 }
6001
6002 #[tokio::test]
6003 async fn real_jwt_scopes_gate_chat_and_lifecycle_operations() {
6004 let state = scoped_state();
6005 let mut headers = HeaderMap::new();
6006 headers.insert(SESSION_HEADER, HeaderValue::from_static("scoped-session"));
6007 headers.insert(
6008 axum::http::header::AUTHORIZATION,
6009 HeaderValue::from_str(&format!("Bearer {}", scoped_token(&["chat:write"]))).unwrap(),
6010 );
6011 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6012 assert!(matches!(
6017 state.authorize_session(&headers, &prepared.session, "session:close"),
6018 Err(PipelineError::Blocked { .. })
6019 ));
6020 headers.insert(
6021 axum::http::header::AUTHORIZATION,
6022 HeaderValue::from_str(&format!(
6023 "Bearer {}",
6024 scoped_token(&["chat:write", "session:close"])
6025 ))
6026 .unwrap(),
6027 );
6028 state
6029 .authorize_session(&headers, &prepared.session, "session:close")
6030 .unwrap();
6031 }
6032
6033 #[tokio::test]
6034 async fn upstream_failure_is_added_to_signed_chain() {
6035 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6036 let state = state(config);
6037 let mut headers = HeaderMap::new();
6038 headers.insert(SESSION_HEADER, HeaderValue::from_static("upstream-failure"));
6039 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6040 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6041 let session = Arc::clone(&prepared.session);
6042 assert!(matches!(
6043 state.forward_chat(prepared).await,
6044 Err(PipelineError::Upstream { .. })
6045 ));
6046 tokio::time::timeout(std::time::Duration::from_secs(10), async {
6047 while session.chain.lock().count() < 2 {
6048 tokio::task::yield_now().await;
6049 }
6050 })
6051 .await
6052 .unwrap();
6053 }
6054
6055 #[tokio::test]
6063 async fn upstream_failure_message_does_not_leak_configured_url() {
6064 let sentinel_url = "http://internal-sentinel-host.corp.example:65001";
6067 let config = HarnessConfig::for_tests(sentinel_url, "/tmp", "/tmp");
6068 let state = state(config);
6069 let mut headers = HeaderMap::new();
6070 headers.insert(SESSION_HEADER, HeaderValue::from_static("cwe-209-check"));
6071 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6072 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6073 let error = match state.forward_chat(prepared).await {
6074 Ok(_) => panic!("connect to unroutable host must fail"),
6075 Err(error) => error,
6076 };
6077 let message = error.to_string();
6078 assert!(
6079 !message.contains("internal-sentinel-host"),
6080 "upstream error message {message:?} leaks the configured URL"
6081 );
6082 assert!(
6083 !message.contains("65001"),
6084 "upstream error message {message:?} leaks the configured port"
6085 );
6086 assert!(
6087 !message.contains("corp.example"),
6088 "upstream error message {message:?} leaks the configured domain"
6089 );
6090 }
6091
6092 #[test]
6103 fn identity_validation_failure_does_not_leak_kid_or_issuer() {
6104 let sentinel_kid = "sentinel-attacker-kid";
6112 let sentinel_iss = "https://sentinel-attacker-iss.example.invalid";
6113 let sentinel_alg = "HS512";
6114 let cases: Vec<(&'static str, av_identity::IdentityError)> = vec![
6115 (
6116 "Malformed",
6117 av_identity::IdentityError::Malformed("jwt parse".into()),
6118 ),
6119 ("MissingKid", av_identity::IdentityError::MissingKid),
6120 (
6121 "UnknownKid",
6122 av_identity::IdentityError::UnknownKid(sentinel_kid.into()),
6123 ),
6124 (
6125 "AlgorithmRejected",
6126 av_identity::IdentityError::AlgorithmRejected {
6127 alg: sentinel_alg.into(),
6128 kid: sentinel_kid.into(),
6129 },
6130 ),
6131 (
6132 "Verification",
6133 av_identity::IdentityError::Verification("bad sig".into()),
6134 ),
6135 ("TtlTooLong", av_identity::IdentityError::TtlTooLong(9999)),
6136 (
6137 "BadTimestamps",
6138 av_identity::IdentityError::BadTimestamps { iat: 10, exp: 5 },
6139 ),
6140 (
6141 "FutureIat",
6142 av_identity::IdentityError::FutureIat {
6143 iat: 999_999_999,
6144 now: 1,
6145 },
6146 ),
6147 ("EmptyField", av_identity::IdentityError::EmptyField("charter")),
6148 (
6149 "SpoofingCharacter",
6150 av_identity::IdentityError::SpoofingCharacter("charter"),
6151 ),
6152 (
6153 "BadIssuer",
6154 av_identity::IdentityError::BadIssuer(sentinel_iss.into()),
6155 ),
6156 (
6157 "ScopeEscalation",
6158 av_identity::IdentityError::ScopeEscalation {
6159 scope: "chat:write".into(),
6160 },
6161 ),
6162 (
6163 "ExpEscalation",
6164 av_identity::IdentityError::ExpEscalation {
6165 child: 100,
6166 parent: 50,
6167 },
6168 ),
6169 ("ChainTooDeep", av_identity::IdentityError::ChainTooDeep(5)),
6170 ];
6171
6172 let mut classifications: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
6173 for (name, error) in &cases {
6174 let client_msg = super::classify_identity_error(error);
6175 classifications.insert(client_msg);
6176 assert!(
6177 !client_msg.contains(sentinel_kid),
6178 "{name}: client message {client_msg:?} echoes attacker kid"
6179 );
6180 assert!(
6181 !client_msg.contains(sentinel_iss),
6182 "{name}: client message {client_msg:?} echoes attacker iss"
6183 );
6184 assert!(
6185 !client_msg.contains(sentinel_alg),
6186 "{name}: client message {client_msg:?} echoes attacker alg"
6187 );
6188 let wire = PipelineError::Unauthorized(client_msg.to_owned()).to_string();
6192 assert!(
6193 !wire.contains(sentinel_kid),
6194 "{name}: wire error {wire:?} echoes attacker kid"
6195 );
6196 assert!(
6197 !wire.contains(sentinel_iss),
6198 "{name}: wire error {wire:?} echoes attacker iss"
6199 );
6200 assert!(
6201 !wire.contains(sentinel_alg),
6202 "{name}: wire error {wire:?} echoes attacker alg"
6203 );
6204 }
6205 assert_eq!(
6209 classifications.len(),
6210 1,
6211 "attacker-reachable IdentityError variants classify to multiple messages {classifications:?}: this is an enumeration oracle"
6212 );
6213
6214 let jwks_msg = super::classify_identity_error(&av_identity::IdentityError::Jwks("bad jwks".into()));
6216 assert_ne!(
6217 jwks_msg,
6218 *classifications.iter().next().unwrap(),
6219 "server-side misconfig should classify distinctly for operator dashboards"
6220 );
6221 }
6222
6223 #[test]
6227 fn read_secret_source_handling() {
6228 let env = |name: &str| -> Option<String> {
6229 match name {
6230 "GOOD_KEY" => Some("sk-live-123\n".into()),
6231 "EMPTY_KEY" => Some(" ".into()),
6232 _ => None,
6233 }
6234 };
6235 assert_eq!(
6236 super::read_secret_from(env, Some("GOOD_KEY"), None, "upstream API key")
6237 .unwrap()
6238 .as_deref(),
6239 Some("sk-live-123"),
6240 "value must be trimmed"
6241 );
6242 assert!(super::read_secret_from(env, Some("MISSING_KEY"), None, "upstream API key").is_err());
6243 assert!(super::read_secret_from(env, Some("EMPTY_KEY"), None, "upstream API key").is_err());
6244 assert!(super::read_secret_from(env, None, None, "upstream API key")
6245 .unwrap()
6246 .is_none());
6247
6248 let dir = tempfile::tempdir().unwrap();
6250 let path = dir.path().join("key");
6251 std::fs::write(&path, "sk-file-456\n").unwrap();
6252 #[cfg(unix)]
6253 {
6254 use std::os::unix::fs::PermissionsExt as _;
6255 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
6256 let error = super::read_secret_from(env, None, path.to_str(), "upstream API key").unwrap_err();
6257 assert!(error.to_string().contains("must be owner-only"), "{error}");
6258 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
6259 }
6260 assert_eq!(
6261 super::read_secret_from(env, None, path.to_str(), "upstream API key")
6262 .unwrap()
6263 .as_deref(),
6264 Some("sk-file-456")
6265 );
6266 assert!(
6268 super::read_secret_from(env, None, dir.path().join("absent").to_str(), "upstream API key")
6269 .is_err()
6270 );
6271 }
6272
6273 #[test]
6276 fn upstream_auth_resolution_and_description() {
6277 let dir = tempfile::tempdir().unwrap();
6278 let key_path = dir.path().join("azure.key");
6279 std::fs::write(&key_path, "azure-raw-key").unwrap();
6280 #[cfg(unix)]
6281 {
6282 use std::os::unix::fs::PermissionsExt as _;
6283 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
6284 }
6285
6286 let mut config = HarnessConfig::for_tests("http://up", "spool", "bridge");
6288 config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
6289 let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
6290 assert_eq!(name.as_str(), "authorization");
6291 assert_eq!(value.to_str().unwrap(), "Bearer azure-raw-key");
6292 assert!(value.is_sensitive(), "credential must redact in Debug output");
6293
6294 config.upstream_auth_header = "api-key".into();
6296 config.upstream_auth_scheme = String::new();
6297 let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
6298 assert_eq!(name.as_str(), "api-key");
6299 assert_eq!(value.to_str().unwrap(), "azure-raw-key");
6300
6301 let described = super::describe_upstream_auth(&config);
6303 assert!(described.contains("api-key from file"), "{described}");
6304 assert!(!described.contains("azure-raw-key"), "{described}");
6305
6306 let bare = HarnessConfig::for_tests("http://up", "spool", "bridge");
6308 assert!(super::resolve_upstream_auth(&bare).unwrap().is_none());
6309 assert_eq!(super::describe_upstream_auth(&bare), "none");
6310
6311 let mut tooling = HarnessConfig::for_tests("http://up", "spool", "bridge");
6313 tooling.tool_upstream_url = Some("http://tools/mcp".into());
6314 tooling.tool_upstream_bearer_file = Some(key_path.to_string_lossy().into_owned());
6315 let bearer = super::resolve_tool_auth(&tooling).unwrap().unwrap();
6316 assert_eq!(bearer.to_str().unwrap(), "Bearer azure-raw-key");
6317 assert!(bearer.is_sensitive());
6318 }
6319}