1use crate::config::HarnessConfig;
4use crate::reconciler::Finalizer;
5use crate::session::{Session, SessionLease, SessionRegistry, Workflow};
6use crate::worker::{AtifCapture, ResponsePermit, WorkerHandle, WorkerJob};
7use axum::body::Bytes;
8use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
9use futures::StreamExt as _;
10use pv_bridge::EventBus;
11use pv_core::metrics::Registry;
12use pv_core::time::elapsed_us;
13use pv_events::{AgentIdentity, EventClass, EventMetrics, StatusId, StopReason};
14use pv_identity::IdentityValidator;
15use pv_loopdetect::{BreakerAction, BreakerState, Embedder, HashEmbedder, NoopVectorSink, VectorSink};
16use pv_receipts::Signer;
17use pv_sandbox::{Sandbox, ToolVerdict};
18use pv_state::{ActionBudget, BudgetDecision, StateStore};
19use serde_json::Value;
20use std::sync::Arc;
21use std::time::Instant;
22
23pub mod supervision_help {
31 pub const RECONCILER_PANICS: &str = "Reconciler tick body panicked and was caught; the reconciler \
33 continues on the next tick";
34 pub const BRIDGE_MAINTENANCE_PANICS: &str = "Bridge maintenance tick panicked and was caught; the loop \
36 continues on the next interval";
37 pub const BRIDGE_MAINTENANCE_ERRORS: &str =
39 "Bridge maintenance tick returned an error; the loop continues \
40 on the next interval";
41 pub const BRIDGE_MAINTENANCE_JOIN_ERRORS: &str =
43 "Bridge maintenance tick's spawned task failed to join (panic \
44 or cancellation); the loop continues on the next interval";
45 pub const ATIF_RETENTION_PANICS: &str = "ATIF retention sweep tick panicked and was caught; the loop \
47 continues on the next hour";
48 pub const ATIF_RETENTION_ERRORS: &str =
50 "ATIF retention sweep tick returned an error; the loop continues \
51 on the next hour";
52}
53
54pub(crate) const SESSION_HEADER: &str = "x-pv-session";
55const WORKFLOW_HEADER: &str = "x-pv-workflow";
56pub(crate) const MIDDLEWARE_US_HEADER: &str = "x-pv-middleware-us";
57
58pub const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
60
61pub const SESSION_CLOSE_TIMEOUTS_HELP: &str =
67 "Per-session close hit the shutdown-time per-session deadline (3 s) and \
68 was deferred to restart-time spool recovery. A sustained rate > 0 on \
69 every rollout indicates a class of sessions that regularly hang their \
70 close (leaked leases, dropped worker permits, unresponsive bridge \
71 publish) — the coincident session id in the shutdown warn log is the \
72 correlation key. Distinct from `pv_http_shutdown_drain_timeouts_total` \
73 which fires on the OUTER phase timeout.";
74
75pub const MCP_DRAIN_TIMEOUTS_HELP: &str =
79 "Shutdown MCP-inflight drain hit its 5 s deadline before every detached \
80 mcp_call_inner spawn completed. Downstream effects: some detached \
81 tool bodies were interrupted at close and their sessions may be \
82 quarantined at restart-time recovery. Any occurrence during a \
83 rollout indicates a class of tool calls that regularly outlive HTTP \
84 drain (long-running upstream tool, hung sandbox); check the \
85 coincident tracing warn line.";
86
87pub const IDLE_CLOSE_TIMEOUTS_HELP: &str =
91 "Idle-close reached the per-session deadline (90 s) and returned to \
92 the next tick — indicates a session with an active lease that never \
93 drops (stuck stream, hung worker) or a bridge publish stalled behind \
94 an unresponsive broker. A steady rate > 0 requires operator \
95 investigation of the coincident session id.";
96
97pub(crate) const TOOL_INVOKE_SCOPE: &str = "tool:invoke";
99
100pub(crate) fn tool_scope(tool: &str) -> String {
102 format!("tool:{tool}")
103}
104
105#[derive(Clone)]
107pub struct AppState {
108 pub config: Arc<HarnessConfig>,
110 pub store: Arc<dyn StateStore>,
112 pub sandbox: Arc<Sandbox>,
114 pub bridge: Arc<dyn EventBus>,
117 pub sessions: Arc<SessionRegistry>,
119 pub worker: WorkerHandle,
121 pub identity: Option<Arc<IdentityValidator>>,
123 pub exchanged_revocations: Arc<dyn pv_identity::RevocationStore>,
125 pub(crate) revocation_audit_window: Arc<parking_lot::Mutex<(Instant, u32)>>,
126 pub metrics: Arc<Registry>,
128 pub(crate) hot_metrics: Arc<HotMetrics>,
133 pub client: reqwest::Client,
135 pub(crate) backend_client: reqwest::Client,
138 pub(crate) upstream_auth: Option<(HeaderName, HeaderValue)>,
141 pub(crate) tool_auth: Option<HeaderValue>,
143 pub(crate) tool_audit_gates:
149 Arc<parking_lot::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
150 pub(crate) tool_audits_emitted: Arc<parking_lot::Mutex<std::collections::HashSet<String>>>,
154 pub(crate) provider: crate::provider::ProviderKind,
159 pub finalizer: Finalizer,
161 pub draining: Arc<std::sync::atomic::AtomicBool>,
166 pub(crate) identity_rejection_window: Arc<parking_lot::Mutex<(Instant, u32)>>,
171 pub(crate) journal_key: [u8; 32],
172 pub mcp_inflight: Arc<crate::inflight::InflightTracker>,
181 pub pushed_rates: Arc<crate::policy_push::RateLimits>,
185 pub mcp_admission: Arc<tokio::sync::Semaphore>,
201 pub(crate) mcp_admission_refusals_counter: Arc<pv_core::metrics::Counter>,
212 pub backend_router: Arc<crate::backend::BackendRouter>,
215 pub pdp: Arc<crate::authz::PolicyDecisionPoint>,
218 pub token_signer: Option<Arc<crate::authz::TokenSigner>>,
221 pub redaction: Option<Arc<pv_redact::RedactionEngine>>,
224 pub mcp_backends: Arc<crate::mcp_client::McpBackendClient>,
227 pub(crate) mcp_sessions: Arc<crate::mcp::SessionSigner>,
229 pub authzen: Option<Arc<crate::authzen::AuthzenClient>>,
231 pub(crate) content_sink: crate::content::SharedContentSink,
233 pub(crate) workload: crate::workload::SharedWorkloadIssuer,
235}
236
237pub(crate) struct HotMetrics {
247 pub(crate) stage_histograms: [Arc<pv_core::metrics::Histogram>; 5],
248 pub(crate) stage_strict_budget_counters: [Arc<pv_core::metrics::Counter>; 5],
249 pub(crate) strict_stage_budget: bool,
254 pub(crate) request_counters: [[Arc<pv_core::metrics::Counter>; 5]; 15],
263 pub(crate) request_duration_histograms: [Arc<pv_core::metrics::Histogram>; 15],
269 pub(crate) upstream_latency_histogram: Arc<pv_core::metrics::Histogram>,
275 pub(crate) upstream_errors_counters: [Arc<pv_core::metrics::Counter>; 4],
281 pub(crate) dashboard_request_counters: [[Arc<pv_core::metrics::Counter>; 2]; 3],
287 pub(crate) dashboard_request_duration_histograms: [Arc<pv_core::metrics::Histogram>; 3],
290}
291
292#[derive(Copy, Clone, Debug)]
293pub(crate) enum Stage {
294 Identity,
295 Quota,
296 Sanitize,
297 Compression,
298 Dispatch,
299}
300
301impl Stage {
302 pub(crate) const ORDER: [Stage; 5] = [
303 Stage::Identity,
304 Stage::Quota,
305 Stage::Sanitize,
306 Stage::Compression,
307 Stage::Dispatch,
308 ];
309
310 pub(crate) fn label(self) -> &'static str {
311 match self {
312 Stage::Identity => "identity",
313 Stage::Quota => "quota",
314 Stage::Sanitize => "sanitize",
315 Stage::Compression => "compression",
316 Stage::Dispatch => "dispatch",
317 }
318 }
319
320 pub(crate) fn index(self) -> usize {
321 match self {
322 Stage::Identity => 0,
323 Stage::Quota => 1,
324 Stage::Sanitize => 2,
325 Stage::Compression => 3,
326 Stage::Dispatch => 4,
327 }
328 }
329
330 fn from_label(label: &str) -> Option<Self> {
331 Stage::ORDER.into_iter().find(|s| s.label() == label)
332 }
333}
334
335#[derive(Copy, Clone, Debug)]
341pub(crate) enum Route {
342 Chat,
343 Mcp,
344 Health,
345 Livez,
346 Readyz,
347 Metrics,
348 SessionClose,
349 SessionPromote,
350 Dashboard,
351 TokenExchange,
352 Revoke,
353 Introspect,
354 AdminRevocation,
355 Jwks,
356 Other,
357}
358
359impl Route {
360 pub(crate) const ORDER: [Route; 15] = [
361 Route::Chat,
362 Route::Mcp,
363 Route::Health,
364 Route::Livez,
365 Route::Readyz,
366 Route::Metrics,
367 Route::SessionClose,
368 Route::SessionPromote,
369 Route::Dashboard,
370 Route::TokenExchange,
371 Route::Revoke,
372 Route::Introspect,
373 Route::AdminRevocation,
374 Route::Jwks,
375 Route::Other,
376 ];
377
378 pub(crate) fn label(self) -> &'static str {
379 match self {
380 Route::Chat => "chat",
381 Route::Mcp => "mcp",
382 Route::Health => "health",
383 Route::Livez => "livez",
384 Route::Readyz => "readyz",
385 Route::Metrics => "metrics",
386 Route::SessionClose => "session_close",
387 Route::SessionPromote => "session_promote",
388 Route::Dashboard => "dashboard",
389 Route::TokenExchange => "token_exchange",
390 Route::Revoke => "revoke",
391 Route::Introspect => "introspect",
392 Route::AdminRevocation => "admin_revocation",
393 Route::Jwks => "jwks",
394 Route::Other => "other",
395 }
396 }
397
398 pub(crate) fn index(self) -> usize {
399 match self {
400 Route::Chat => 0,
401 Route::Mcp => 1,
402 Route::Health => 2,
403 Route::Livez => 3,
404 Route::Readyz => 4,
405 Route::Metrics => 5,
406 Route::SessionClose => 6,
407 Route::SessionPromote => 7,
408 Route::Dashboard => 8,
409 Route::TokenExchange => 9,
410 Route::Revoke => 10,
411 Route::Introspect => 11,
412 Route::AdminRevocation => 12,
413 Route::Jwks => 13,
414 Route::Other => 14,
415 }
416 }
417}
418
419#[derive(Copy, Clone, Debug)]
424pub(crate) enum StatusClass {
425 Informational, Success, Redirection, ClientError, ServerError, }
431
432impl StatusClass {
433 pub(crate) const ORDER: [StatusClass; 5] = [
434 StatusClass::Informational,
435 StatusClass::Success,
436 StatusClass::Redirection,
437 StatusClass::ClientError,
438 StatusClass::ServerError,
439 ];
440
441 pub(crate) fn label(self) -> &'static str {
442 match self {
443 StatusClass::Informational => "1xx",
444 StatusClass::Success => "2xx",
445 StatusClass::Redirection => "3xx",
446 StatusClass::ClientError => "4xx",
447 StatusClass::ServerError => "5xx",
448 }
449 }
450
451 pub(crate) fn index(self) -> usize {
452 match self {
453 StatusClass::Informational => 0,
454 StatusClass::Success => 1,
455 StatusClass::Redirection => 2,
456 StatusClass::ClientError => 3,
457 StatusClass::ServerError => 4,
458 }
459 }
460
461 pub(crate) fn from_u16(status: u16) -> Self {
462 match status {
463 100..=199 => StatusClass::Informational,
464 200..=299 => StatusClass::Success,
465 300..=399 => StatusClass::Redirection,
466 400..=499 => StatusClass::ClientError,
467 _ => StatusClass::ServerError,
468 }
469 }
470}
471
472#[derive(Copy, Clone, Debug)]
476pub(crate) enum UpstreamErrorKind {
477 Timeout,
478 Connect,
479 Send,
480 Http5xx,
481}
482
483impl UpstreamErrorKind {
484 pub(crate) const ORDER: [UpstreamErrorKind; 4] = [
485 UpstreamErrorKind::Timeout,
486 UpstreamErrorKind::Connect,
487 UpstreamErrorKind::Send,
488 UpstreamErrorKind::Http5xx,
489 ];
490
491 pub(crate) fn label(self) -> &'static str {
492 match self {
493 UpstreamErrorKind::Timeout => "timeout",
494 UpstreamErrorKind::Connect => "connect",
495 UpstreamErrorKind::Send => "send",
496 UpstreamErrorKind::Http5xx => "http_5xx",
497 }
498 }
499
500 pub(crate) fn index(self) -> usize {
501 match self {
502 UpstreamErrorKind::Timeout => 0,
503 UpstreamErrorKind::Connect => 1,
504 UpstreamErrorKind::Send => 2,
505 UpstreamErrorKind::Http5xx => 3,
506 }
507 }
508
509 pub(crate) fn from_reqwest(error: &reqwest::Error) -> Self {
513 if error.is_timeout() {
514 UpstreamErrorKind::Timeout
515 } else if error.is_connect() {
516 UpstreamErrorKind::Connect
517 } else {
518 UpstreamErrorKind::Send
519 }
520 }
521}
522
523#[derive(Copy, Clone, Debug)]
526pub(crate) enum DashboardEndpoint {
527 Stats,
528 List,
529 Detail,
530}
531
532impl DashboardEndpoint {
533 pub(crate) const ORDER: [DashboardEndpoint; 3] = [
534 DashboardEndpoint::Stats,
535 DashboardEndpoint::List,
536 DashboardEndpoint::Detail,
537 ];
538
539 pub(crate) fn label(self) -> &'static str {
540 match self {
541 DashboardEndpoint::Stats => "stats",
542 DashboardEndpoint::List => "list",
543 DashboardEndpoint::Detail => "detail",
544 }
545 }
546
547 pub(crate) fn index(self) -> usize {
548 match self {
549 DashboardEndpoint::Stats => 0,
550 DashboardEndpoint::List => 1,
551 DashboardEndpoint::Detail => 2,
552 }
553 }
554}
555
556#[derive(Copy, Clone, Debug)]
559pub(crate) enum DashboardStatus {
560 Ok,
561 NotFound,
562}
563
564impl DashboardStatus {
565 pub(crate) const ORDER: [DashboardStatus; 2] = [DashboardStatus::Ok, DashboardStatus::NotFound];
566
567 pub(crate) fn label(self) -> &'static str {
568 match self {
569 DashboardStatus::Ok => "ok",
570 DashboardStatus::NotFound => "not_found",
571 }
572 }
573
574 pub(crate) fn index(self) -> usize {
575 match self {
576 DashboardStatus::Ok => 0,
577 DashboardStatus::NotFound => 1,
578 }
579 }
580}
581
582impl HotMetrics {
583 fn new(metrics: &Registry, config_strict_stage_budget: bool) -> Self {
584 let stage_histograms: [Arc<pv_core::metrics::Histogram>; 5] = Stage::ORDER.map(|stage| {
590 metrics.histogram_with_bounds(
591 &format!("pv_stage_duration_seconds{{stage=\"{}\"}}", stage.label()),
592 "Harness stage latency",
593 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
594 )
595 });
596 let stage_strict_budget_counters: [Arc<pv_core::metrics::Counter>; 5] = Stage::ORDER.map(|stage| {
597 metrics.counter(
598 &format!("pv_strict_budget_breaches_total{{stage=\"{}\"}}", stage.label()),
599 "Middleware stages that exceeded the strict per-stage budget",
600 )
601 });
602 let _ = metrics.histogram_with_bounds(
610 "pv_stage_duration_seconds{stage=\"quota_post_compression\"}",
611 "Harness stage latency",
612 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
613 );
614 let request_counters: [[Arc<pv_core::metrics::Counter>; 5]; 15] = Route::ORDER.map(|route| {
621 StatusClass::ORDER.map(|status| {
622 metrics.counter(
623 &format!(
624 "pv_requests_total{{route=\"{}\",status_class=\"{}\"}}",
625 route.label(),
626 status.label()
627 ),
628 "HTTP requests by route and status class",
629 )
630 })
631 });
632 let request_duration_histograms: [Arc<pv_core::metrics::Histogram>; 15] = Route::ORDER.map(|route| {
633 metrics.histogram_with_bounds(
634 &format!("pv_request_duration_seconds{{route=\"{}\"}}", route.label()),
635 "End-to-end HTTP request latency by route",
636 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
637 )
638 });
639 let upstream_latency_histogram = metrics.histogram_with_bounds(
642 "pv_upstream_latency_seconds",
643 "Time to upstream response headers",
644 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
645 );
646 let upstream_errors_counters: [Arc<pv_core::metrics::Counter>; 4] =
647 UpstreamErrorKind::ORDER.map(|kind| {
648 metrics.counter(
649 &format!("pv_upstream_errors_total{{kind=\"{}\"}}", kind.label()),
650 "Upstream failures by kind",
651 )
652 });
653 let dashboard_request_counters: [[Arc<pv_core::metrics::Counter>; 2]; 3] = DashboardEndpoint::ORDER
657 .map(|endpoint| {
658 DashboardStatus::ORDER.map(|status| {
659 metrics.counter(
660 &format!(
661 "pv_dashboard_requests_total{{endpoint=\"{}\",status=\"{}\"}}",
662 endpoint.label(),
663 status.label()
664 ),
665 "Dashboard endpoint requests, labeled by status",
666 )
667 })
668 });
669 let dashboard_request_duration_histograms: [Arc<pv_core::metrics::Histogram>; 3] =
670 DashboardEndpoint::ORDER.map(|endpoint| {
671 metrics.histogram(
672 &format!(
673 "pv_dashboard_request_duration_seconds{{endpoint=\"{}\"}}",
674 endpoint.label()
675 ),
676 "Dashboard endpoint latency",
677 )
678 });
679 Self {
680 stage_histograms,
681 stage_strict_budget_counters,
682 strict_stage_budget: config_strict_stage_budget || truthy_env("PV_STRICT_BUDGET"),
683 request_counters,
684 request_duration_histograms,
685 upstream_latency_histogram,
686 upstream_errors_counters,
687 dashboard_request_counters,
688 dashboard_request_duration_histograms,
689 }
690 }
691}
692
693pub fn effective_upstream_auth_header(config: &HarnessConfig) -> (String, String) {
703 let at_defaults = config.upstream_auth_header.eq_ignore_ascii_case("authorization")
704 && config.upstream_auth_scheme.eq_ignore_ascii_case("bearer");
705 let native = crate::provider::adapter_for(&config.provider)
706 .and_then(crate::provider::ProviderKind::native_auth_header);
707 match native {
708 Some(header) if at_defaults => (header.to_owned(), String::new()),
709 _ => (
710 config.upstream_auth_header.to_ascii_lowercase(),
711 config.upstream_auth_scheme.clone(),
712 ),
713 }
714}
715
716pub fn describe_upstream_auth(config: &HarnessConfig) -> String {
719 let (header, _) = effective_upstream_auth_header(config);
720 if config.upstream_authorization_passthrough {
721 "passthrough(client Authorization)".to_owned()
722 } else if let Some(env) = config.upstream_api_key_env.as_deref() {
723 format!("api-key from ${env} in header {header:?}")
724 } else if let Some(file) = config.upstream_api_key_file.as_deref() {
725 format!("api-key from file {file} in header {header:?}")
726 } else {
727 "none".to_owned()
728 }
729}
730
731pub(crate) fn resolve_upstream_auth(
737 config: &HarnessConfig,
738) -> Result<Option<(HeaderName, HeaderValue)>, PipelineError> {
739 let key = match read_secret(
740 config.upstream_api_key_env.as_deref(),
741 config.upstream_api_key_file.as_deref(),
742 "upstream API key",
743 )? {
744 Some(key) => key,
745 None => return Ok(None),
746 };
747 let (header, scheme) = effective_upstream_auth_header(config);
748 let name = HeaderName::try_from(header.as_str()).map_err(|_| {
749 PipelineError::upstream(format!(
750 "upstream_auth_header {header:?} is not a valid header name"
751 ))
752 })?;
753 let rendered = if scheme.is_empty() {
754 key
755 } else {
756 format!("{scheme} {key}")
757 };
758 let mut value = HeaderValue::from_str(&rendered).map_err(|_| {
759 PipelineError::upstream(
760 "upstream API key contains bytes that cannot appear in an HTTP header".to_owned(),
761 )
762 })?;
763 value.set_sensitive(true);
764 Ok(Some((name, value)))
765}
766
767pub(crate) fn resolve_tool_auth(config: &HarnessConfig) -> Result<Option<HeaderValue>, PipelineError> {
769 let token = match read_secret(
770 config.tool_upstream_bearer_env.as_deref(),
771 config.tool_upstream_bearer_file.as_deref(),
772 "tool upstream bearer token",
773 )? {
774 Some(token) => token,
775 None => return Ok(None),
776 };
777 let mut value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
778 PipelineError::upstream(
779 "tool upstream bearer token contains bytes that cannot appear in an HTTP header".to_owned(),
780 )
781 })?;
782 value.set_sensitive(true);
783 Ok(Some(value))
784}
785
786pub(crate) fn read_secret(
790 env_name: Option<&str>,
791 file_path: Option<&str>,
792 what: &str,
793) -> Result<Option<String>, PipelineError> {
794 read_secret_from(|name| std::env::var(name).ok(), env_name, file_path, what)
795}
796
797fn read_secret_from(
799 get_env: impl Fn(&str) -> Option<String>,
800 env_name: Option<&str>,
801 file_path: Option<&str>,
802 what: &str,
803) -> Result<Option<String>, PipelineError> {
804 if let Some(name) = env_name {
805 let value = get_env(name).ok_or_else(|| {
806 PipelineError::upstream(format!(
807 "{what}: environment variable {name} is not set (export it or update the config)"
808 ))
809 })?;
810 let value = value.trim().to_owned();
811 if value.is_empty() {
812 return Err(PipelineError::upstream(format!(
813 "{what}: environment variable {name} is set but empty"
814 )));
815 }
816 return Ok(Some(value));
817 }
818 if let Some(path) = file_path {
819 require_owner_only_secret(std::path::Path::new(path))
820 .map_err(|error| PipelineError::upstream(format!("{what}: {error}")))?;
821 let value = std::fs::read_to_string(path).map_err(|error| PipelineError::Upstream {
822 context: format!("{what}: read {path}: {error}"),
823 source: Some(Box::new(error)),
824 })?;
825 let value = value.trim().to_owned();
826 if value.is_empty() {
827 return Err(PipelineError::upstream(format!("{what}: file {path} is empty")));
828 }
829 return Ok(Some(value));
830 }
831 Ok(None)
832}
833
834pub(crate) fn require_owner_only_secret(path: &std::path::Path) -> Result<(), String> {
838 #[cfg(unix)]
839 {
840 use std::os::unix::fs::MetadataExt as _;
841 let metadata = std::fs::symlink_metadata(path)
842 .map_err(|error| format!("stat secret file {}: {error}", path.display()))?;
843 let file_type = metadata.file_type();
844 if file_type.is_symlink() {
845 return Err(format!(
846 "secret file {} is a symbolic link; refusing to follow",
847 path.display()
848 ));
849 }
850 if !file_type.is_file() {
851 return Err(format!("secret file {} is not a regular file", path.display()));
852 }
853 let mode = metadata.mode() & 0o777;
854 if mode & 0o077 != 0 {
855 return Err(format!(
856 "secret file {} has mode 0o{mode:03o}; must be owner-only (chmod 600 {})",
857 path.display(),
858 path.display()
859 ));
860 }
861 }
862 #[cfg(not(unix))]
863 {
864 let _ = path;
865 }
866 Ok(())
867}
868
869pub struct PreparedRequest {
871 pub session: Arc<Session>,
873 pub identity: AgentIdentity,
875 pub payload: Value,
877 pub middleware_us: u64,
879 admission_debit: AdmissionDebit,
885 capture_guard: ResponseCaptureGuard,
896 lease: SessionLease,
897 client_authorization: Option<HeaderValue>,
899 upstream_passthrough_headers: Vec<(axum::http::HeaderName, HeaderValue)>,
904}
905
906pub struct ForwardedResponse {
914 pub status: StatusCode,
916 pub headers: HeaderMap,
918 pub body: futures::stream::BoxStream<'static, Result<Bytes, std::io::Error>>,
920 pub(crate) billed_prompt_tokens: u64,
925 pub(crate) capture_guard: ResponseCaptureGuard,
930 pub(crate) lease: SessionLease,
931}
932
933struct UpstreamPlan {
935 url: String,
937 bodies: Vec<Vec<u8>>,
940 headers: Vec<(HeaderName, HeaderValue)>,
943 auth: Option<(HeaderName, HeaderValue)>,
945 translation: Option<Translation>,
947}
948
949#[derive(Clone, Copy)]
951enum NativeDialect {
952 Anthropic,
953 Gemini,
954}
955
956struct Translation {
959 dialect: NativeDialect,
960 stream: bool,
962 include_usage: bool,
964 id: String,
966 model: String,
968 created: u64,
970}
971
972const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
974
975const MAX_NATIVE_BODY_BYTES: usize = 16 * 1024 * 1024;
978
979async fn read_bounded(response: reqwest::Response, cap: usize) -> Result<Vec<u8>, std::io::Error> {
981 let mut body = Vec::new();
982 let mut stream = response.bytes_stream();
983 while let Some(chunk) = stream.next().await {
984 let chunk = chunk.map_err(std::io::Error::other)?;
985 if body.len().saturating_add(chunk.len()) > cap {
986 return Err(std::io::Error::other(format!(
987 "upstream response exceeds {cap} bytes"
988 )));
989 }
990 body.extend_from_slice(&chunk);
991 }
992 Ok(body)
993}
994
995pub(crate) struct ResponseCaptureGuard {
1009 worker: crate::worker::WorkerHandle,
1010 session: Arc<Session>,
1011 identity: AgentIdentity,
1012 marker: Option<String>,
1013 attempt_id: String,
1014 permit: Option<ResponsePermit>,
1022 armed: bool,
1023}
1024
1025impl ResponseCaptureGuard {
1026 fn new(
1027 worker: crate::worker::WorkerHandle,
1028 session: Arc<Session>,
1029 identity: AgentIdentity,
1030 attempt_id: String,
1031 permit: Option<ResponsePermit>,
1032 ) -> Self {
1033 Self {
1034 worker,
1035 session,
1036 identity,
1037 marker: None,
1038 attempt_id,
1039 permit,
1040 armed: true,
1041 }
1042 }
1043
1044 pub(crate) fn take_permit(&mut self) -> Option<ResponsePermit> {
1049 self.permit.take()
1050 }
1051
1052 pub(crate) fn set_marker(&mut self, marker: String) {
1054 self.marker = Some(marker);
1055 }
1056
1057 pub(crate) fn attempt_id(&self) -> &str {
1058 &self.attempt_id
1059 }
1060
1061 pub(crate) fn disarm(mut self) -> (Option<String>, String) {
1064 self.armed = false;
1065 (self.marker.take(), std::mem::take(&mut self.attempt_id))
1066 }
1067
1068 fn defuse(&mut self) -> Option<String> {
1071 self.armed = false;
1072 self.marker.take()
1073 }
1074}
1075
1076impl Drop for ResponseCaptureGuard {
1077 fn drop(&mut self) {
1078 if !self.armed {
1079 return;
1080 }
1081 let job = refused_response_failure_job(
1082 Arc::clone(&self.session),
1083 self.identity.clone(),
1084 "request_cancelled_before_response_capture".to_owned(),
1085 self.marker.take(),
1086 std::mem::take(&mut self.attempt_id),
1087 );
1088 let submitted = match self.permit.take() {
1095 Some(permit) => permit.submit(&self.worker, job),
1096 None => self.worker.try_submit(job),
1097 };
1098 if submitted.is_err() {
1099 self.session.mark_capture_failed();
1100 }
1101 }
1102}
1103
1104pub(crate) struct AdmissionDebit {
1123 store: Arc<dyn StateStore>,
1124 session_id: String,
1125 budget: pv_state::BudgetSpec,
1126 principal: Option<(String, pv_state::BudgetSpec)>,
1127 tokens: u64,
1128 metrics: Arc<Registry>,
1140}
1141
1142impl AdmissionDebit {
1143 fn take_for_dispatch(&mut self) -> u64 {
1147 std::mem::take(&mut self.tokens)
1148 }
1149
1150 fn refund_now(&mut self) {
1154 let tokens = std::mem::take(&mut self.tokens);
1155 if tokens == 0 {
1156 return;
1157 }
1158 ActionBudget::new(self.store.as_ref(), &self.session_id, &self.budget).refund_tokens(tokens);
1159 if let Some((principal_id, spec)) = self.principal.as_ref() {
1160 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec).refund_tokens(tokens);
1161 }
1162 }
1163}
1164
1165impl Drop for AdmissionDebit {
1166 fn drop(&mut self) {
1167 if self.tokens == 0 {
1168 return;
1169 }
1170 let tokens = std::mem::take(&mut self.tokens);
1178 let store = Arc::clone(&self.store);
1179 let session_id = std::mem::take(&mut self.session_id);
1180 let budget = std::mem::take(&mut self.budget);
1181 let principal = self.principal.take();
1182 let metrics = Arc::clone(&self.metrics);
1183 let sess_for_log = session_id.clone();
1200 let refund = move || {
1201 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1202 ActionBudget::new(store.as_ref(), &session_id, &budget).refund_tokens(tokens);
1203 if let Some((principal_id, spec)) = principal.as_ref() {
1204 ActionBudget::for_principal(store.as_ref(), principal_id, spec).refund_tokens(tokens);
1205 }
1206 }));
1207 if let Err(panic) = outcome {
1208 let msg = panic
1209 .downcast_ref::<&'static str>()
1210 .copied()
1211 .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
1212 .unwrap_or("panic payload was not a string");
1213 metrics
1214 .counter(
1215 "pv_admission_refund_panics_total",
1216 "Admission-debit refund closure panicked in Drop; the session \
1217 and principal ledgers retain the debit until operator repair",
1218 )
1219 .inc();
1220 tracing::error!(
1221 session = %sess_for_log,
1222 tokens,
1223 panic = %msg,
1224 "admission-debit refund PANICKED (caught); \
1225 principal ledger drain persists until operator repair"
1226 );
1227 }
1228 };
1229 match tokio::runtime::Handle::try_current() {
1230 Ok(handle) => {
1231 handle.spawn_blocking(refund);
1232 }
1233 Err(_) => refund(),
1234 }
1235 }
1236}
1237
1238pub(crate) fn refused_response_failure_job(
1245 session: Arc<Session>,
1246 identity: AgentIdentity,
1247 reason: String,
1248 response_marker: Option<String>,
1249 response_attempt_id: String,
1250) -> WorkerJob {
1251 let atif = (session.workflow == Workflow::Unsigned).then(|| AtifCapture {
1252 source: pv_atif::Source::Agent,
1253 message: Value::String(reason.clone()),
1254 reasoning_content: None,
1255 model_name: None,
1256 tool_calls: None,
1257 observation: None,
1258 llm_call_count: Some(0),
1259 });
1260 WorkerJob {
1261 session,
1262 identity,
1263 class: EventClass::StopReason,
1264 payload: serde_json::json!({"reason": reason, "direction": "upstream_response"}),
1265 text: String::new(),
1266 analyze_loop: false,
1267 status: StatusId::Failure,
1268 stop_reason: Some(StopReason::Other),
1269 native_stop_reason: None,
1270 metrics: EventMetrics::default(),
1271 cost_usd_micros: 0,
1272 prompt_token_correction: 0,
1273 atif,
1274 response_marker,
1275 response_attempt: Some(crate::worker::ResponseAttempt {
1276 id: response_attempt_id,
1277 terminal: true,
1278 }),
1279 }
1280}
1281
1282pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
1290
1291#[derive(Debug, thiserror::Error)]
1302#[non_exhaustive]
1303pub enum PipelineError {
1304 #[error("bad request: {context}")]
1306 BadRequest {
1307 context: String,
1309 #[source]
1311 source: Option<ErrorSource>,
1312 },
1313 #[error("unauthorized: {0}")]
1315 Unauthorized(String),
1316 #[error("request blocked: {context}")]
1318 Blocked {
1319 context: String,
1321 #[source]
1323 source: Option<ErrorSource>,
1324 },
1325 #[error("upstream request failed: {context}")]
1327 Upstream {
1328 context: String,
1330 #[source]
1332 source: Option<ErrorSource>,
1333 },
1334 #[error("upstream request timed out: {context}")]
1343 UpstreamTimeout {
1344 context: String,
1346 #[source]
1348 source: Option<ErrorSource>,
1349 },
1350 #[error("connection aborted: {0}")]
1352 Abort(String),
1353 #[error("service unavailable: {context}")]
1355 Unavailable {
1356 context: String,
1358 #[source]
1360 source: Option<ErrorSource>,
1361 },
1362}
1363
1364impl PipelineError {
1365 pub fn bad_request(context: impl Into<String>) -> Self {
1367 Self::BadRequest {
1368 context: context.into(),
1369 source: None,
1370 }
1371 }
1372
1373 pub fn bad_request_source<E>(error: E) -> Self
1377 where
1378 E: std::error::Error + Send + Sync + 'static,
1379 {
1380 Self::BadRequest {
1381 context: error.to_string(),
1382 source: Some(Box::new(error)),
1383 }
1384 }
1385
1386 pub fn blocked(context: impl Into<String>) -> Self {
1388 Self::Blocked {
1389 context: context.into(),
1390 source: None,
1391 }
1392 }
1393
1394 pub fn blocked_source<E>(error: E) -> Self
1396 where
1397 E: std::error::Error + Send + Sync + 'static,
1398 {
1399 Self::Blocked {
1400 context: error.to_string(),
1401 source: Some(Box::new(error)),
1402 }
1403 }
1404
1405 pub fn upstream(context: impl Into<String>) -> Self {
1407 Self::Upstream {
1408 context: context.into(),
1409 source: None,
1410 }
1411 }
1412
1413 pub fn upstream_source<E>(error: E) -> Self
1416 where
1417 E: std::error::Error + Send + Sync + 'static,
1418 {
1419 Self::Upstream {
1420 context: error.to_string(),
1421 source: Some(Box::new(error)),
1422 }
1423 }
1424
1425 pub fn upstream_timeout(context: impl Into<String>) -> Self {
1429 Self::UpstreamTimeout {
1430 context: context.into(),
1431 source: None,
1432 }
1433 }
1434
1435 pub fn unavailable(context: impl Into<String>) -> Self {
1437 Self::Unavailable {
1438 context: context.into(),
1439 source: None,
1440 }
1441 }
1442
1443 pub fn unavailable_source<E>(error: E) -> Self
1446 where
1447 E: std::error::Error + Send + Sync + 'static,
1448 {
1449 Self::Unavailable {
1450 context: error.to_string(),
1451 source: Some(Box::new(error)),
1452 }
1453 }
1454 pub fn status(&self) -> axum::http::StatusCode {
1466 match self {
1467 Self::BadRequest { .. } => axum::http::StatusCode::BAD_REQUEST,
1468 Self::Unauthorized(_) => axum::http::StatusCode::UNAUTHORIZED,
1469 Self::Blocked { .. } => axum::http::StatusCode::FORBIDDEN,
1470 Self::Upstream { .. } => axum::http::StatusCode::BAD_GATEWAY,
1471 Self::UpstreamTimeout { .. } => axum::http::StatusCode::GATEWAY_TIMEOUT,
1472 Self::Abort(_) => axum::http::StatusCode::CONFLICT,
1473 Self::Unavailable { .. } => axum::http::StatusCode::SERVICE_UNAVAILABLE,
1474 }
1475 }
1476}
1477
1478impl AppState {
1479 pub fn effective_budget(&self) -> pv_state::BudgetSpec {
1483 self.config.budget.tightened_by(&self.sandbox.pushed().budget)
1484 }
1485
1486 pub fn new(
1489 config: HarnessConfig,
1490 store: Arc<dyn StateStore>,
1491 sandbox: Arc<Sandbox>,
1492 bridge: Arc<dyn EventBus>,
1493 identity: Option<Arc<IdentityValidator>>,
1494 signer: Arc<dyn Signer>,
1495 ) -> Result<Self, PipelineError> {
1496 Self::new_with_embedder(
1497 config,
1498 store,
1499 sandbox,
1500 bridge,
1501 identity,
1502 signer,
1503 Arc::new(HashEmbedder::default()),
1504 )
1505 }
1506
1507 #[allow(clippy::too_many_arguments)]
1509 pub fn new_with_embedder(
1510 config: HarnessConfig,
1511 store: Arc<dyn StateStore>,
1512 sandbox: Arc<Sandbox>,
1513 bridge: Arc<dyn EventBus>,
1514 identity: Option<Arc<IdentityValidator>>,
1515 signer: Arc<dyn Signer>,
1516 embedder: Arc<dyn Embedder>,
1517 ) -> Result<Self, PipelineError> {
1518 Self::new_with_backends(
1519 config,
1520 store,
1521 sandbox,
1522 bridge,
1523 identity,
1524 signer,
1525 embedder,
1526 Arc::new(NoopVectorSink),
1527 )
1528 }
1529
1530 #[allow(clippy::too_many_arguments)]
1532 pub fn new_with_backends(
1533 config: HarnessConfig,
1534 store: Arc<dyn StateStore>,
1535 sandbox: Arc<Sandbox>,
1536 bridge: Arc<dyn EventBus>,
1537 identity: Option<Arc<IdentityValidator>>,
1538 signer: Arc<dyn Signer>,
1539 embedder: Arc<dyn Embedder>,
1540 vector_sink: Arc<dyn VectorSink>,
1541 ) -> Result<Self, PipelineError> {
1542 Self::new_with_backends_and_metrics(
1543 config,
1544 store,
1545 sandbox,
1546 bridge,
1547 identity,
1548 signer,
1549 embedder,
1550 vector_sink,
1551 Arc::new(Registry::new()),
1552 )
1553 }
1554
1555 #[allow(clippy::too_many_arguments)]
1562 pub fn new_with_backends_and_metrics(
1563 config: HarnessConfig,
1564 store: Arc<dyn StateStore>,
1565 sandbox: Arc<Sandbox>,
1566 bridge: Arc<dyn EventBus>,
1567 identity: Option<Arc<IdentityValidator>>,
1568 signer: Arc<dyn Signer>,
1569 embedder: Arc<dyn Embedder>,
1570 vector_sink: Arc<dyn VectorSink>,
1571 metrics: Arc<Registry>,
1572 ) -> Result<Self, PipelineError> {
1573 let config = Arc::new(config);
1574 for stage in ["identity", "quota", "sanitize", "compression", "dispatch"] {
1584 metrics.histogram_with_bounds(
1585 &format!("pv_stage_duration_seconds{{stage=\"{stage}\"}}"),
1586 "Harness stage latency",
1587 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
1588 );
1589 }
1590 metrics.counter(
1591 "pv_events_dropped_total{stage=\"worker_queue\"}",
1592 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
1593 );
1594 metrics.counter(
1599 "pv_events_dropped_total{stage=\"response_slot\"}",
1600 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
1601 );
1602 metrics.counter(
1606 "pv_incomplete_sessions_total",
1607 "Sessions refused due to incomplete capture",
1608 );
1609 metrics.counter(
1610 "pv_worker_panics_total",
1611 "Worker job panics isolated by supervisor",
1612 );
1613 metrics.counter("pv_worker_errors_total", "Worker jobs that failed");
1614 metrics.counter("pv_sessions_finalized_total", "Sessions finalized");
1615 metrics.counter("pv_sessions_promoted_total", "Unsigned sessions promoted");
1616 metrics.counter("pv_reconcile_errors_total", "Reconciliation errors");
1617 metrics.counter(
1624 "pv_signed_recovery_skipped_total",
1625 "Signed sessions skipped during recovery due to per-session errors",
1626 );
1627 metrics.counter(
1628 "pv_signed_recovery_quarantined_total",
1629 "Signed sessions re-adopted as capture-failed quarantines during recovery",
1630 );
1631 metrics.counter(
1632 "pv_unsigned_recovery_skipped_total",
1633 "Unsigned step-journal consolidations skipped during recovery due to per-session errors",
1634 );
1635 metrics.counter(
1636 "pv_atif_trajectory_recovery_skipped_total",
1637 "ATIF trajectories skipped during recovery due to per-session errors",
1638 );
1639 metrics.counter(
1640 "pv_pending_close_completion_failed_total",
1641 "Pending-close completions that failed to finish their tail",
1642 );
1643 for kind in ["timeout", "connect", "send", "http_5xx"] {
1658 metrics.counter(
1659 &format!("pv_upstream_errors_total{{kind=\"{kind}\"}}"),
1660 "Upstream failures by kind",
1661 );
1662 }
1663 metrics.histogram_with_bounds(
1664 "pv_upstream_latency_seconds",
1665 "Time to upstream response headers",
1666 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
1667 );
1668 for route in ["chat", "mcp", "session_close", "session_promote"] {
1669 for status_class in ["2xx", "4xx", "5xx"] {
1670 metrics.counter(
1671 &format!("pv_requests_total{{route=\"{route}\",status_class=\"{status_class}\"}}"),
1672 "HTTP requests by route and status class",
1673 );
1674 }
1675 metrics.histogram_with_bounds(
1676 &format!("pv_request_duration_seconds{{route=\"{route}\"}}"),
1677 "End-to-end HTTP request latency by route",
1678 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
1679 );
1680 }
1681 for reason in [
1682 "too_large",
1683 "read_error",
1684 "invalid_json",
1685 "nonconformant",
1686 "unauthenticated",
1687 "provenance",
1688 ] {
1689 metrics.counter(
1690 &format!("pv_atif_recovery_skipped_total{{reason=\"{reason}\"}}"),
1691 "ATIF spool files skipped during recovery",
1692 );
1693 }
1694 metrics.histogram("pv_receipt_sign_duration_seconds", "Receipt signing latency");
1695 metrics.counter(
1702 "pv_ephemeral_close_failures_total",
1703 "Auto-close of a completed ephemeral one-shot failed; \
1704 the session is left open until the idle sweeper reaps it",
1705 );
1706 metrics.counter(
1707 "pv_stream_abort_close_failures_total",
1708 "Background close after a stream abort failed; \
1709 the session is left open until the idle sweeper reaps it",
1710 );
1711 metrics.counter(
1712 "pv_stream_abort_no_runtime_total",
1713 "Stream abort observed no tokio runtime; capture marked failed for \
1714 reconciler retry",
1715 );
1716 metrics.counter("pv_reconciler_panics_total", supervision_help::RECONCILER_PANICS);
1734 metrics.counter(
1744 "pv_worker_shard_panics_total",
1745 "Worker shard driver panicked outside a job; supervised via catch_unwind",
1746 );
1747 metrics.counter(
1748 "pv_bridge_maintenance_panics_total",
1749 supervision_help::BRIDGE_MAINTENANCE_PANICS,
1750 );
1751 metrics.counter(
1752 "pv_bridge_maintenance_errors_total",
1753 supervision_help::BRIDGE_MAINTENANCE_ERRORS,
1754 );
1755 metrics.counter(
1756 "pv_bridge_maintenance_join_errors_total",
1757 supervision_help::BRIDGE_MAINTENANCE_JOIN_ERRORS,
1758 );
1759 metrics.counter(
1765 "pv_atif_retention_panics_total",
1766 supervision_help::ATIF_RETENTION_PANICS,
1767 );
1768 metrics.counter(
1769 "pv_atif_retention_errors_total",
1770 supervision_help::ATIF_RETENTION_ERRORS,
1771 );
1772 metrics.counter(
1788 "pv_tcp_nodelay_failures_total",
1789 "TCP_NODELAY setsockopt failed on an accepted connection; the \
1790 suboptimal-latency connection still serves. Rate-limited to a \
1791 single tracing warn per process to avoid a per-accept log storm \
1792 under half-open flood / SYN flood.",
1793 );
1794 metrics.counter(
1798 "pv_stream_abort_panics_total",
1799 "Background close after a stream abort panicked and was caught; \
1800 the session is left open until the idle sweeper reaps it",
1801 );
1802 metrics.counter(
1803 "pv_ephemeral_close_panics_total",
1804 "Auto-close of a completed ephemeral one-shot panicked and was \
1805 caught; the session is left open until the idle sweeper reaps it",
1806 );
1807 metrics.counter(
1808 "pv_admission_refund_panics_total",
1809 "Admission-debit refund closure panicked in Drop; the session \
1810 and principal ledgers retain the debit until operator repair",
1811 );
1812 metrics.counter("pv_idle_close_timeouts_total", IDLE_CLOSE_TIMEOUTS_HELP);
1819 metrics.counter(
1829 "pv_shutdown_session_close_timeouts_total",
1830 SESSION_CLOSE_TIMEOUTS_HELP,
1831 );
1832 metrics.counter("pv_shutdown_mcp_drain_timeouts_total", MCP_DRAIN_TIMEOUTS_HELP);
1842 for pass in [
1851 "adopt_strict_atif",
1852 "recover_signed_journals",
1853 "consolidate_step_journals",
1854 "retry_marked_promotions",
1855 "remove_acked_outboxes",
1856 "replay_lifecycle_outboxes",
1857 "quarantine_orphan_json",
1858 ] {
1859 metrics.counter(
1860 &format!("pv_recovery_scan_capped_total{{pass=\"{pass}\"}}"),
1861 "Recovery pass hit the per-tick entry-scan cap and returned \
1862 early; the next tick will re-open the directory and resume. \
1863 A steady rate > 0 indicates a poisoned spool (millions of \
1864 stale files) that will starve legitimate recovery work \
1865 until an operator quarantines it.",
1866 );
1867 }
1868 metrics.gauge(
1878 "pv_lifecycle_outbox_pending",
1879 crate::reconciler::LIFECYCLE_OUTBOX_PENDING_HELP,
1880 );
1881 metrics.histogram_with_bounds(
1885 "pv_reconcile_duration_seconds",
1886 "Idle reconciliation duration",
1887 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
1888 );
1889 metrics.histogram_with_bounds(
1890 "pv_session_finalize_duration_seconds",
1891 "Session finalization latency",
1892 pv_core::metrics::WIDE_LATENCY_BOUNDS_US,
1893 );
1894 for endpoint in ["stats", "list", "detail"] {
1895 metrics.histogram(
1896 &format!("pv_dashboard_request_duration_seconds{{endpoint=\"{endpoint}\"}}"),
1897 "Dashboard endpoint latency",
1898 );
1899 metrics.counter(
1900 &format!("pv_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"ok\"}}"),
1901 "Dashboard endpoint requests, labeled by status",
1902 );
1903 metrics.counter(
1904 &format!("pv_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"not_found\"}}"),
1905 "Dashboard endpoint requests, labeled by status",
1906 );
1907 }
1908 let sessions = Arc::new(SessionRegistry::new());
1909 let journal_key = crate::journal::key_from_signer(signer.as_ref());
1910 let redaction = if !config.redaction_patterns.is_empty() || !config.redaction_paths.is_empty() {
1911 let redact_config = pv_redact::RedactionConfig {
1912 regex_patterns: config.redaction_patterns.clone(),
1913 pointer_paths: config.redaction_paths.clone(),
1914 include_builtin_patterns: true,
1915 ..pv_redact::RedactionConfig::default()
1916 };
1917 let engine = pv_redact::RedactionEngine::new(redact_config)
1918 .map_err(|e| PipelineError::bad_request(format!("redaction engine: {e}")))?;
1919 Some(Arc::new(engine))
1920 } else {
1921 None
1922 };
1923 let content_sink = crate::content::SharedContentSink::default();
1924 let worker = crate::worker::spawn_worker_with_outputs(
1925 config.worker_channel_capacity,
1926 Arc::clone(&bridge),
1927 embedder,
1928 Arc::clone(&vector_sink),
1929 Some(std::path::PathBuf::from(&config.atif_spool_dir)),
1930 journal_key,
1931 Arc::clone(&metrics),
1932 crate::content::AuditOutputs {
1933 redaction: redaction.clone(),
1934 content: Arc::clone(&content_sink),
1935 },
1936 );
1937 let finalizer = Finalizer::with_bridge(
1938 signer.clone(),
1939 std::path::PathBuf::from(&config.atif_spool_dir),
1940 Arc::clone(&metrics),
1941 Arc::clone(&bridge),
1942 )
1943 .with_state_store(Arc::clone(&store))
1944 .with_vector_sink(Arc::clone(&vector_sink));
1947 let build_client = |private_only: bool| -> Result<reqwest::Client, PipelineError> {
1950 let mut client_builder = reqwest::Client::builder()
1951 .connect_timeout(HTTP_CONNECT_TIMEOUT)
1952 .redirect(reqwest::redirect::Policy::none())
1953 .user_agent(concat!("ProofVisor/", env!("CARGO_PKG_VERSION")))
1959 .tcp_keepalive(std::time::Duration::from_secs(30))
1965 .pool_idle_timeout(std::time::Duration::from_secs(60));
1970 let mut default_headers = reqwest::header::HeaderMap::new();
1981 default_headers.insert(
1982 reqwest::header::ACCEPT_ENCODING,
1983 reqwest::header::HeaderValue::from_static("identity"),
1984 );
1985 client_builder = client_builder.default_headers(default_headers);
1986 const DEFAULT_UPSTREAM_READ_TIMEOUT_S: u64 = crate::config::DEFAULT_UPSTREAM_READ_TIMEOUT_S;
1998 let read_timeout_s = config
1999 .upstream_read_timeout_s
2000 .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
2001 client_builder = client_builder.read_timeout(std::time::Duration::from_secs(read_timeout_s));
2002 if config.upstream_http2_prior_knowledge {
2003 client_builder = client_builder.http2_prior_knowledge();
2004 }
2005 if private_only {
2006 client_builder = client_builder.dns_resolver(Arc::new(crate::backend::PrivateOnlyResolver));
2007 }
2008 client_builder.build().map_err(PipelineError::upstream_source)
2009 };
2010 let client = build_client(false)?;
2011 let backend_client = if config.require_private_backends {
2015 build_client(true)?
2016 } else {
2017 client.clone()
2018 };
2019 let upstream_auth = resolve_upstream_auth(&config)?;
2020 let tool_auth = resolve_tool_auth(&config)?;
2021 let hot_metrics = Arc::new(HotMetrics::new(&metrics, config.strict_stage_budget));
2022 let provider = crate::provider::adapter_for(&config.provider).ok_or_else(|| {
2026 PipelineError::bad_request(format!("unsupported provider {:?}", config.provider))
2027 })?;
2028 tracing::info!(provider = provider.name(), "provider adapter selected");
2029 let mcp_admission = Arc::new(tokio::sync::Semaphore::new(config.mcp_concurrency));
2030 let mcp_admission_refusals_counter = metrics.counter(
2037 "pv_mcp_admission_refusals_total",
2038 "MCP admission was refused because `mcp_concurrency` was already at capacity — \
2039 each admitted call can buffer up to 16 MiB in `read_limited_tool_response`, \
2040 so the cap bounds worst-case MCP resident memory. Sustained > 0 indicates \
2041 tool-upstream slowdown, a burst-traffic event, or an undersized \
2042 `mcp_concurrency`.",
2043 );
2044 for pdp_code in ["UNMAPPED_TOOL", "MISSION_EXPIRED", "MISSION_DENIED", "PDP_DENIED"] {
2045 metrics.counter(
2046 &format!("pv_pdp_denials_total{{code=\"{pdp_code}\"}}"),
2047 "PDP denials by denial code",
2048 );
2049 }
2050 metrics.counter(
2051 crate::routes::TOKENS_REVOKED_METRIC,
2052 crate::routes::TOKENS_REVOKED_HELP,
2053 );
2054 let backend_router = crate::backend::BackendRouter::new(
2055 &config.backends,
2056 config.tool_upstream_url.as_deref(),
2057 tool_auth.clone(),
2058 )
2059 .map_err(|e| PipelineError::bad_request(format!("backend routing: {e}")))?
2060 .with_fallback_transport(config.tool_upstream_transport);
2061 let mcp_backends = Arc::new(crate::mcp_client::McpBackendClient::new(backend_client.clone()));
2062 let mcp_sessions = Arc::new(crate::mcp::SessionSigner::derive(&journal_key));
2063 let authzen = config
2064 .authzen
2065 .as_ref()
2066 .map(|authzen| crate::authzen::AuthzenClient::from_config(authzen, client.clone()).map(Arc::new))
2067 .transpose()
2068 .map_err(|e| PipelineError::bad_request(format!("authzen: {e}")))?;
2069 let workload = crate::workload::WorkloadIssuer::from_config(&config)
2070 .map_err(|e| PipelineError::bad_request(format!("workload identity: {e}")))?
2071 .map(Arc::new);
2072 let token_signer = if let Some(ref seed_path) = config.token_exchange_seed_file {
2073 let seed = load_exchange_seed(seed_path)
2074 .map_err(|e| PipelineError::bad_request(format!("exchange signing key: {e}")))?;
2075 let ts = Arc::new(crate::authz::TokenSigner::from_seed(&seed));
2076 if ts.kid() == signer.key_id() {
2077 return Err(PipelineError::bad_request(
2078 "token_exchange_seed_file must use a different key than the receipt signer \
2079 (both derive the same kid, which means the same seed)"
2080 .to_owned(),
2081 ));
2082 }
2083 Some(ts)
2084 } else {
2085 None
2086 };
2087 let pdp = crate::authz::PolicyDecisionPoint::from_config(&config, token_signer.clone());
2088 let exchanged_revocations: Arc<dyn pv_identity::RevocationStore> = if config.state_backend == "redis"
2089 {
2090 Arc::new(crate::revocation::GuardedRevocationStore::new(
2091 Arc::new(crate::revocation::StateRevocationStore::with_namespace(
2092 Arc::clone(&store),
2093 "exchanged",
2094 )),
2095 &metrics,
2096 "exchanged",
2097 ))
2098 } else {
2099 Arc::new(pv_identity::InMemoryRevocationStore::new())
2100 };
2101 Ok(Self {
2102 config,
2103 store,
2104 sandbox,
2105 bridge,
2106 sessions,
2107 worker,
2108 identity,
2109 exchanged_revocations,
2110 revocation_audit_window: Arc::new(parking_lot::Mutex::new((Instant::now(), 0))),
2111 hot_metrics,
2112 metrics,
2113 client,
2114 backend_client,
2115 upstream_auth,
2116 tool_auth,
2117 tool_audit_gates: Arc::default(),
2118 tool_audits_emitted: Arc::default(),
2119 provider,
2120 finalizer,
2121 draining: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2122 identity_rejection_window: Arc::new(parking_lot::Mutex::new((Instant::now(), 0))),
2123 journal_key,
2124 mcp_inflight: Arc::new(crate::inflight::InflightTracker::new()),
2125 pushed_rates: Arc::new(crate::policy_push::RateLimits::default()),
2126 mcp_admission,
2127 mcp_admission_refusals_counter,
2128 backend_router: Arc::new(backend_router),
2129 pdp: Arc::new(pdp),
2130 token_signer,
2131 redaction,
2132 mcp_backends,
2133 mcp_sessions,
2134 authzen,
2135 content_sink,
2136 workload,
2137 })
2138 }
2139
2140 pub fn set_content_sink(&self, sink: Arc<dyn crate::content::ContentSink>) -> std::io::Result<()> {
2146 let dispatcher = crate::content::ContentDispatcher::spawn(sink, &self.metrics)?;
2147 *self.content_sink.write() = Some(Arc::new(dispatcher));
2148 Ok(())
2149 }
2150
2151 pub fn prepare_chat(
2166 &self,
2167 headers: &HeaderMap,
2168 payload: Value,
2169 identity_hint: Option<AgentIdentity>,
2170 ) -> Result<PreparedRequest, PipelineError> {
2171 self.prepare_chat_with_caller(headers, payload, identity_hint, None)
2172 }
2173
2174 fn prepare_chat_with_caller(
2175 &self,
2176 headers: &HeaderMap,
2177 mut payload: Value,
2178 identity_hint: Option<AgentIdentity>,
2179 caller: Option<&AuthenticatedCaller>,
2180 ) -> Result<PreparedRequest, PipelineError> {
2181 let total_started = Instant::now();
2182 let session_id = session_id(headers)?;
2183 let workflow = workflow(headers, &self.config.default_workflow)?;
2184 crate::provider::choice_count(&payload).map_err(PipelineError::bad_request)?;
2194
2195 let client_authorization = if self.config.upstream_authorization_passthrough {
2203 single_header(headers, "authorization")?.cloned()
2204 } else {
2205 None
2206 };
2207 const UPSTREAM_HEADER_ALLOWLIST: [&str; 3] = ["openai-organization", "openai-project", "openai-beta"];
2211 let mut upstream_passthrough_headers = Vec::new();
2212 for name in UPSTREAM_HEADER_ALLOWLIST {
2213 if let Some(value) = single_header(headers, name)? {
2214 upstream_passthrough_headers.push((axum::http::HeaderName::from_static(name), value.clone()));
2215 }
2216 }
2217
2218 let stage = Instant::now();
2219 let identity = if let Some(pre_resolved) = identity_hint {
2220 pre_resolved
2227 } else {
2228 match self.resolve_identity(headers, Some(&self.config.chat_scope)) {
2229 Ok(identity) => identity,
2230 Err(error) => {
2231 self.audit_authentication_error(headers, &error)?;
2232 return Err(error);
2233 }
2234 }
2235 };
2236 self.observe_stage("identity", stage);
2237
2238 let session = self
2239 .sessions
2240 .get_or_open(&session_id, workflow, &identity, &self.config.breaker);
2241 let admission = session.admission_guard();
2242 validate_session_binding(&session, workflow, &identity)?;
2243 if let Some(caller) = caller {
2244 caller.bind_session(&session)?;
2245 }
2246 session.refresh_identity(&identity);
2247 if session.is_closed() {
2248 if session.capture_failed() {
2270 return Err(PipelineError::bad_request(
2271 "session is quarantined (audit capture failed); use a new session id".to_owned(),
2272 ));
2273 }
2274 if session.is_empty_unsigned_quarantine() {
2275 return Err(PipelineError::bad_request(
2276 "session is quarantined (closed with no captured steps); use a new session id".to_owned(),
2277 ));
2278 }
2279 if session.close_complete_flag() && session.enforcement_tripped() {
2287 return Err(PipelineError::blocked(
2288 "session was closed by budget or loop enforcement; use a new session id".to_owned(),
2289 ));
2290 }
2291 return Err(PipelineError::unavailable(
2292 "session close is completing; retry shortly or use a new session id".to_owned(),
2293 ));
2294 }
2295 if session.capture_failed() {
2296 return Err(PipelineError::unavailable(
2297 "session audit capture is incomplete".to_owned(),
2298 ));
2299 }
2300 session.touch();
2301 if session.loop_state.state() == BreakerState::Open {
2302 match session.loop_state.action() {
2303 BreakerAction::Reject => {
2304 session.latch_enforcement(StopReason::LoopDetected);
2305 return Err(PipelineError::blocked(
2306 "semantic loop circuit breaker is open".to_owned(),
2307 ));
2308 }
2309 BreakerAction::Abort => {
2310 session.latch_enforcement(StopReason::LoopDetected);
2311 return Err(PipelineError::Abort(
2312 "semantic loop circuit breaker requested connection close".to_owned(),
2313 ));
2314 }
2315 BreakerAction::Inject => {
2316 inject_corrective_message(&mut payload)?;
2317 session.loop_state.reset();
2318 }
2319 _ => {
2320 return Err(PipelineError::blocked(
2321 "unsupported semantic loop enforcement action".to_owned(),
2322 ));
2323 }
2324 }
2325 }
2326
2327 let permits = self
2335 .worker
2336 .try_reserve_pair(&session_id)
2337 .map_err(PipelineError::unavailable_source)?;
2338 let worker_permit = permits.worker;
2339 let response_permit = permits.response;
2340
2341 let stage = Instant::now();
2342 let raw_prompt_tokens = pv_core::tokens::approx_tokens_json(&payload);
2343 self.observe_stage("quota", stage);
2353
2354 let stage = Instant::now();
2355 if let Err(reason) = self.sandbox.sanitize("chat/completions", &payload) {
2356 let error = PipelineError::blocked(reason);
2357 worker_permit.submit(self.failure_job(
2361 Arc::clone(&session),
2362 identity.clone(),
2363 StopReason::PolicyBlocked,
2364 error.to_string(),
2365 ));
2366 return Err(error);
2367 }
2368 self.observe_stage("sanitize", stage);
2369
2370 let stage = Instant::now();
2371 let compression = if self.config.compression_enabled {
2372 pv_compress::compress(
2373 &payload,
2374 &pv_compress::CompressionConfig {
2375 marker_key: Some(pv_compress::derive_marker_key(&self.journal_key)),
2380 ..pv_compress::CompressionConfig::default()
2381 },
2382 )
2383 } else {
2384 pv_compress::CompressionOutcome {
2385 payload,
2386 tokens_before: raw_prompt_tokens,
2387 tokens_after: raw_prompt_tokens,
2388 changed: false,
2389 }
2390 };
2391 self.observe_stage("compression", stage);
2392
2393 if let Err(refusal) = self
2399 .pushed_rates
2400 .admit(&principal_id_for_budget(&identity), compression.tokens_after)
2401 {
2402 let error = PipelineError::blocked(refusal.message);
2403 worker_permit.submit(self.policy_failure_job(
2404 Arc::clone(&session),
2405 identity.clone(),
2406 StopReason::PolicyBlocked,
2407 error.to_string(),
2408 Some(refusal.policy),
2409 ));
2410 return Err(error);
2411 }
2412
2413 let stage = Instant::now();
2420 let billed_tokens = compression.tokens_after;
2421 let principal_billing_id = self
2422 .config
2423 .principal_budget
2424 .as_ref()
2425 .map(|_| principal_id_for_budget(&identity));
2426 if let (Some(spec), Some(principal_id)) = (
2427 self.config.principal_budget.as_ref(),
2428 principal_billing_id.as_deref(),
2429 ) {
2430 let quota = match ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2431 .try_tokens(billed_tokens)
2432 {
2433 Ok(quota) => quota,
2434 Err(error) => {
2435 let error = PipelineError::Blocked {
2436 context: format!("quota backend failed closed (principal): {error}"),
2437 source: Some(Box::new(error)),
2438 };
2439 worker_permit.submit(self.failure_job(
2440 Arc::clone(&session),
2441 identity.clone(),
2442 StopReason::BudgetExceeded,
2443 error.to_string(),
2444 ));
2445 return Err(error);
2446 }
2447 };
2448 if let BudgetDecision::Refused { limit, cap } = quota {
2449 session.latch_enforcement(StopReason::BudgetExceeded);
2453 let error = PipelineError::blocked(format!("principal.{limit} exceeded (cap {cap})"));
2454 worker_permit.submit(self.failure_job(
2455 Arc::clone(&session),
2456 identity.clone(),
2457 StopReason::BudgetExceeded,
2458 error.to_string(),
2459 ));
2460 return Err(error);
2461 }
2462 }
2463 let quota = match ActionBudget::new(self.store.as_ref(), &session_id, &self.effective_budget())
2464 .try_tokens(billed_tokens)
2465 {
2466 Ok(quota) => quota,
2467 Err(error) => {
2468 if let (Some(spec), Some(principal_id)) = (
2469 self.config.principal_budget.as_ref(),
2470 principal_billing_id.as_deref(),
2471 ) {
2472 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2473 .refund_tokens(billed_tokens);
2474 }
2475 let error = PipelineError::Blocked {
2476 context: format!("quota backend failed closed: {error}"),
2477 source: Some(Box::new(error)),
2478 };
2479 worker_permit.submit(self.failure_job(
2480 Arc::clone(&session),
2481 identity.clone(),
2482 StopReason::BudgetExceeded,
2483 error.to_string(),
2484 ));
2485 return Err(error);
2486 }
2487 };
2488 if let BudgetDecision::Refused { limit, cap } = quota {
2489 if let (Some(spec), Some(principal_id)) = (
2490 self.config.principal_budget.as_ref(),
2491 principal_billing_id.as_deref(),
2492 ) {
2493 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2494 .refund_tokens(billed_tokens);
2495 }
2496 session.latch_enforcement(StopReason::BudgetExceeded);
2498 let policy = self
2501 .sandbox
2502 .pushed()
2503 .budget_policy_for(&self.config.budget, &limit, cap);
2504 let error = PipelineError::blocked(match &policy {
2505 Some(name) => format!("policy {name:?}: {limit} exceeded (cap {cap})"),
2506 None => format!("{limit} exceeded (cap {cap})"),
2507 });
2508 worker_permit.submit(self.policy_failure_job(
2509 Arc::clone(&session),
2510 identity.clone(),
2511 StopReason::BudgetExceeded,
2512 error.to_string(),
2513 policy,
2514 ));
2515 return Err(error);
2516 }
2517 self.observe_stage("quota_post_compression", stage);
2518
2519 let stage = Instant::now();
2520 let text = last_message_text(&compression.payload);
2521 let atif = match atif_capture_from_request(&compression.payload) {
2522 Ok(atif) => atif,
2523 Err(error) => {
2524 ActionBudget::new(self.store.as_ref(), &session_id, &self.effective_budget())
2527 .refund_tokens(billed_tokens);
2528 if let (Some(spec), Some(principal_id)) = (
2529 self.config.principal_budget.as_ref(),
2530 principal_billing_id.as_deref(),
2531 ) {
2532 ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2533 .refund_tokens(billed_tokens);
2534 }
2535 worker_permit.submit(self.failure_job(
2536 Arc::clone(&session),
2537 identity.clone(),
2538 StopReason::Other,
2539 error.to_string(),
2540 ));
2541 return Err(error);
2542 }
2543 };
2544 let analyze_loop = atif.source == pv_atif::Source::Agent && !text.is_empty();
2545 let response_attempt_id = pv_core::new_event_uid();
2546 let job = WorkerJob {
2547 session: Arc::clone(&session),
2548 identity: identity.clone(),
2549 class: EventClass::Compression,
2550 payload: serde_json::json!({
2551 "changed": compression.changed,
2552 "tokens_before": compression.tokens_before,
2553 "tokens_after": compression.tokens_after,
2554 }),
2555 text,
2556 analyze_loop,
2557 status: StatusId::Success,
2558 stop_reason: None,
2559 native_stop_reason: None,
2560 metrics: EventMetrics {
2561 prompt_tokens: Some(compression.tokens_after),
2562 completion_tokens: Some(0),
2563 cached_tokens: Some(0),
2564 pruned_tokens: Some(compression.pruned_tokens()),
2565 pruning_ratio_millis: Some(compression.pruning_ratio_millis()),
2566 },
2567 cost_usd_micros: 0,
2568 prompt_token_correction: 0,
2569 atif: Some(atif),
2570 response_marker: None,
2571 response_attempt: Some(crate::worker::ResponseAttempt {
2572 id: response_attempt_id.clone(),
2573 terminal: false,
2574 }),
2575 };
2576 worker_permit.submit(job);
2577 self.observe_stage("dispatch", stage);
2578 let lease = SessionLease::new(Arc::clone(&session));
2579 let capture_guard = ResponseCaptureGuard::new(
2586 self.worker.clone(),
2587 Arc::clone(&session),
2588 identity.clone(),
2589 response_attempt_id,
2590 Some(response_permit),
2591 );
2592 drop(admission);
2593
2594 let admission_debit = AdmissionDebit {
2595 store: Arc::clone(&self.store),
2596 session_id: session.id.clone(),
2597 budget: self.effective_budget(),
2598 principal: self
2599 .config
2600 .principal_budget
2601 .as_ref()
2602 .map(|spec| (principal_id_for_budget(&identity), spec.clone())),
2603 tokens: billed_tokens,
2604 metrics: Arc::clone(&self.metrics),
2605 };
2606 Ok(PreparedRequest {
2607 session,
2608 identity,
2609 payload: compression.payload,
2610 middleware_us: elapsed_us(total_started),
2611 admission_debit,
2612 lease,
2613 capture_guard,
2614 client_authorization,
2615 upstream_passthrough_headers,
2616 })
2617 }
2618
2619 pub async fn prepare_chat_nonblocking(
2646 &self,
2647 headers: &HeaderMap,
2648 payload: Value,
2649 body_bytes: usize,
2650 identity_hint: Option<AgentIdentity>,
2651 ) -> Result<PreparedRequest, PipelineError> {
2652 if self.chat_preparation_can_run_inline(body_bytes)
2653 && (identity_hint.is_some() || self.identity.is_none())
2656 {
2657 return self.prepare_chat(headers, payload, identity_hint);
2658 }
2659 let state = self.clone();
2660 let headers = headers.clone();
2661 tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload, identity_hint))
2662 .await
2663 .map_err(PipelineError::unavailable_source)?
2664 }
2665
2666 fn chat_preparation_can_run_inline(&self, body_bytes: usize) -> bool {
2667 body_bytes <= 16 * 1024
2670 && self.effective_budget().max_tokens.is_none()
2671 && self.config.principal_budget.is_none()
2672 }
2673
2674 pub(crate) async fn authenticate_chat_nonblocking(
2675 &self,
2676 headers: &HeaderMap,
2677 ) -> Result<AuthenticatedCaller, PipelineError> {
2678 if self.identity.is_none() {
2679 let caller = self.authenticate(headers)?;
2682 caller.authorize(self, Some(&self.config.chat_scope))?;
2683 return Ok(caller);
2684 }
2685 let state = self.clone();
2686 let headers = headers.clone();
2687 tokio::task::spawn_blocking(move || {
2688 let caller = state.authenticate(&headers)?;
2689 caller.authorize(&state, Some(&state.config.chat_scope))?;
2690 Ok(caller)
2691 })
2692 .await
2693 .map_err(PipelineError::unavailable_source)?
2694 }
2695
2696 pub(crate) async fn prepare_authenticated_chat(
2697 &self,
2698 headers: &HeaderMap,
2699 payload: Value,
2700 body_bytes: usize,
2701 caller: AuthenticatedCaller,
2702 ) -> Result<PreparedRequest, PipelineError> {
2703 if self.chat_preparation_can_run_inline(body_bytes) {
2704 let identity = caller.authorize(self, Some(&self.config.chat_scope))?;
2707 return self.prepare_chat_with_caller(headers, payload, Some(identity), Some(&caller));
2708 }
2709 let state = self.clone();
2710 let headers = headers.clone();
2711 tokio::task::spawn_blocking(move || {
2712 let identity = caller.authorize(&state, Some(&state.config.chat_scope))?;
2713 state.prepare_chat_with_caller(&headers, payload, Some(identity), Some(&caller))
2714 })
2715 .await
2716 .map_err(PipelineError::unavailable_source)?
2717 }
2718
2719 pub async fn prepare_chat_durable(
2722 &self,
2723 headers: &HeaderMap,
2724 payload: Value,
2725 ) -> Result<PreparedRequest, PipelineError> {
2726 if let Ok(id) = session_id(headers) {
2727 if let Some(session) = self.sessions.get(&id) {
2728 session.wait_for_worker_jobs().await;
2729 if session.capture_failed() {
2730 return Err(PipelineError::unavailable(
2731 "session audit capture is incomplete".to_owned(),
2732 ));
2733 }
2734 }
2735 }
2736 let state = self.clone();
2737 let headers = headers.clone();
2738 let mut prepared = tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload, None))
2739 .await
2740 .map_err(PipelineError::unavailable_source)??;
2741 prepared.session.wait_for_worker_jobs().await;
2742 if prepared.session.capture_failed() {
2743 let error = PipelineError::unavailable(
2744 "request audit capture failed before provider dispatch".to_owned(),
2745 );
2746 self.abandon_prepared(&mut prepared, StopReason::Other, &error.to_string());
2747 return Err(error);
2748 }
2749 if prepared.session.loop_state.state() == BreakerState::Open {
2750 let action = prepared.session.loop_state.action();
2751 if matches!(action, BreakerAction::Abort | BreakerAction::Reject) {
2759 prepared.session.latch_enforcement(StopReason::LoopDetected);
2760 }
2761 let error = match action {
2762 BreakerAction::Abort => {
2763 PipelineError::Abort("semantic loop circuit breaker opened during audit".to_owned())
2764 }
2765 _ => PipelineError::blocked(
2766 "semantic loop circuit breaker opened during audit; retry required".to_owned(),
2767 ),
2768 };
2769 self.abandon_prepared(&mut prepared, StopReason::LoopDetected, &error.to_string());
2770 return Err(error);
2771 }
2772 Ok(prepared)
2773 }
2774
2775 fn abandon_prepared(&self, prepared: &mut PreparedRequest, stop_reason: StopReason, reason: &str) {
2785 prepared.admission_debit.refund_now();
2792 let Some(permit) = prepared.capture_guard.take_permit() else {
2793 return;
2797 };
2798 let response_marker = prepared.capture_guard.defuse();
2800 let mut job = self.failure_job(
2801 Arc::clone(&prepared.session),
2802 prepared.identity.clone(),
2803 stop_reason,
2804 reason.to_owned(),
2805 );
2806 job.response_marker = response_marker;
2807 job.response_attempt = Some(crate::worker::ResponseAttempt {
2808 id: prepared.capture_guard.attempt_id().to_owned(),
2809 terminal: true,
2810 });
2811 if permit.submit(&self.worker, job).is_err() {
2818 prepared.session.mark_capture_failed();
2819 }
2820 }
2821
2822 pub async fn forward_chat(
2832 &self,
2833 mut request: PreparedRequest,
2834 ) -> Result<ForwardedResponse, PipelineError> {
2835 let payload_bytes = match serde_json::to_vec(&request.payload) {
2851 Ok(bytes) => Some(bytes),
2852 Err(error) => {
2853 tracing::error!(
2854 %error,
2855 session = %request.session.id,
2856 "failed to serialise chat payload for request digest; falling back to session-derived digest"
2857 );
2858 None
2859 }
2860 };
2861 let request_digest = match &payload_bytes {
2862 Some(bytes) => pv_core::digest::sha256_hex(bytes),
2863 None => pv_core::digest::sha256_hex(request.session.id.as_bytes()),
2864 };
2865 let plan = match self.plan_upstream(
2870 &request.payload,
2871 payload_bytes,
2872 request.client_authorization.take(),
2873 std::mem::take(&mut request.upstream_passthrough_headers),
2874 ) {
2875 Ok(plan) => plan,
2876 Err(client_error) => {
2877 self.abandon_prepared(&mut request, StopReason::Other, &client_error.to_string());
2878 return Err(client_error);
2879 }
2880 };
2881 let attempt_id = crate::worker::reserve_response_attempt_id();
2892 request.capture_guard.set_marker(attempt_id.clone());
2893 if let Err(error) = crate::worker::write_response_marker(
2894 std::path::Path::new(&self.config.atif_spool_dir),
2895 &self.journal_key,
2896 &request.session.id,
2897 &attempt_id,
2898 request_digest,
2899 )
2900 .await
2901 {
2902 tracing::warn!(
2910 %error,
2911 session = %request.session.id,
2912 "could not write in-flight response marker; refusing upstream dispatch"
2913 );
2914 let client_error =
2915 PipelineError::unavailable("in-flight response marker could not be persisted".to_owned());
2916 self.abandon_prepared(&mut request, StopReason::Other, &client_error.to_string());
2917 return Err(client_error);
2918 }
2919 let PreparedRequest {
2920 session,
2921 identity,
2922 lease,
2923 capture_guard,
2924 mut admission_debit,
2925 ..
2926 } = request;
2927 let UpstreamPlan {
2928 url,
2929 bodies,
2930 headers,
2931 auth,
2932 translation,
2933 } = plan;
2934 let upstream_started = Instant::now();
2938 let debited_tokens = admission_debit.take_for_dispatch();
2945 let sends = bodies.into_iter().map(|body| {
2946 let mut upstream_request = self
2947 .client
2948 .post(&url)
2949 .header(reqwest::header::CONTENT_TYPE, "application/json")
2950 .body(body);
2951 for (name, value) in &headers {
2952 upstream_request = upstream_request.header(name.clone(), value.clone());
2953 }
2954 if let Some((name, value)) = &auth {
2955 upstream_request = upstream_request.header(name.clone(), value.clone());
2956 }
2957 upstream_request.send()
2958 });
2959 let results = futures::future::join_all(sends).await;
2962 self.hot_metrics
2963 .upstream_latency_histogram
2964 .observe_us(elapsed_us(upstream_started));
2965 let mut responses = Vec::with_capacity(results.len());
2966 let mut first_error = None;
2967 for result in results {
2968 match result {
2969 Ok(response) => responses.push(response),
2970 Err(error) => {
2971 if first_error.is_none() {
2972 first_error = Some(error);
2973 }
2974 }
2975 }
2976 }
2977 if let Some(error) = first_error {
2978 let reached_provider = !responses.is_empty();
2984 drop(responses);
2985 return Err(self.upstream_send_failed(
2986 error,
2987 session,
2988 identity,
2989 capture_guard,
2990 debited_tokens,
2991 reached_provider,
2992 ));
2993 }
2994 let Some(translation) = translation else {
2995 let Some(response) = responses.pop() else {
2996 drop(capture_guard);
3000 self.enqueue_failure(
3001 session,
3002 identity,
3003 StopReason::Other,
3004 "upstream_no_response".to_owned(),
3005 )?;
3006 return Err(PipelineError::upstream("no upstream response".to_owned()));
3007 };
3008 if response.status().is_server_error() {
3009 #[allow(clippy::indexing_slicing)]
3010 {
3011 self.hot_metrics.upstream_errors_counters[UpstreamErrorKind::Http5xx.index()].inc();
3012 }
3013 }
3014 return Ok(ForwardedResponse {
3015 status: response.status(),
3016 headers: response.headers().clone(),
3017 body: response
3018 .bytes_stream()
3019 .map(|chunk| chunk.map_err(std::io::Error::other))
3020 .boxed(),
3021 billed_prompt_tokens: debited_tokens,
3022 lease,
3023 capture_guard,
3024 });
3025 };
3026 Ok(self
3027 .translate_native_responses(translation, responses, debited_tokens, lease, capture_guard)
3028 .await)
3029 }
3030
3031 fn plan_upstream(
3036 &self,
3037 payload: &Value,
3038 payload_bytes: Option<Vec<u8>>,
3039 client_authorization: Option<HeaderValue>,
3040 upstream_passthrough_headers: Vec<(HeaderName, HeaderValue)>,
3041 ) -> Result<UpstreamPlan, PipelineError> {
3042 use crate::provider::{self, ProviderKind};
3043 let base = self.config.upstream_url.trim_end_matches('/');
3044 let auth = self.auth_for_dispatch(client_authorization)?;
3045 match self.provider {
3046 ProviderKind::OpenAi => {
3047 let body = match payload_bytes {
3048 Some(bytes) => bytes,
3049 None => serde_json::to_vec(payload).map_err(|error| {
3053 PipelineError::bad_request(format!("chat payload is not serializable: {error}"))
3054 })?,
3055 };
3056 Ok(UpstreamPlan {
3057 url: format!("{base}{}", self.config.upstream_chat_path),
3058 bodies: vec![body],
3059 headers: upstream_passthrough_headers,
3063 auth,
3064 translation: None,
3065 })
3066 }
3067 ProviderKind::Anthropic => {
3068 let body =
3069 provider::anthropic::build_request(payload, self.config.provider_default_max_tokens)
3070 .map_err(|reason| {
3071 PipelineError::bad_request(format!(
3072 "request cannot be expressed in the Anthropic Messages API: {reason}"
3073 ))
3074 })?;
3075 let bytes = serde_json::to_vec(&body).map_err(|error| {
3076 PipelineError::bad_request(format!("translated request is not serializable: {error}"))
3077 })?;
3078 let choices = provider::choice_count(payload).map_err(PipelineError::bad_request)?;
3079 let count = usize::try_from(choices).unwrap_or(1);
3080 Ok(UpstreamPlan {
3081 url: format!("{base}{}", provider::anthropic::MESSAGES_PATH),
3082 bodies: vec![bytes; count],
3083 headers: vec![(
3084 HeaderName::from_static("anthropic-version"),
3085 HeaderValue::from_static(provider::ANTHROPIC_VERSION),
3086 )],
3087 auth,
3088 translation: Some(Translation {
3089 dialect: NativeDialect::Anthropic,
3090 stream: provider::wants_stream(payload),
3091 include_usage: provider::wants_usage_chunk(payload),
3092 id: provider::fresh_completion_id(),
3093 model: payload
3094 .get("model")
3095 .and_then(Value::as_str)
3096 .unwrap_or_default()
3097 .to_owned(),
3098 created: provider::created_now(),
3099 }),
3100 })
3101 }
3102 ProviderKind::Gemini => {
3103 let model = payload
3104 .get("model")
3105 .and_then(Value::as_str)
3106 .filter(|model| !model.is_empty())
3107 .ok_or_else(|| PipelineError::bad_request("model is required".to_owned()))?;
3108 let stream = provider::wants_stream(payload);
3109 let path =
3110 provider::gemini::request_path(model, stream).map_err(PipelineError::bad_request)?;
3111 let body = provider::gemini::build_request(payload).map_err(|reason| {
3112 PipelineError::bad_request(format!(
3113 "request cannot be expressed in the Gemini generateContent API: {reason}"
3114 ))
3115 })?;
3116 let bytes = serde_json::to_vec(&body).map_err(|error| {
3117 PipelineError::bad_request(format!("translated request is not serializable: {error}"))
3118 })?;
3119 Ok(UpstreamPlan {
3120 url: format!("{base}{path}"),
3121 bodies: vec![bytes],
3122 headers: Vec::new(),
3123 auth,
3124 translation: Some(Translation {
3125 dialect: NativeDialect::Gemini,
3126 stream,
3127 include_usage: provider::wants_usage_chunk(payload),
3128 id: provider::fresh_completion_id(),
3129 model: model.to_owned(),
3130 created: provider::created_now(),
3131 }),
3132 })
3133 }
3134 }
3135 }
3136
3137 fn auth_for_dispatch(
3143 &self,
3144 client_authorization: Option<HeaderValue>,
3145 ) -> Result<Option<(HeaderName, HeaderValue)>, PipelineError> {
3146 if let Some(auth) = &self.upstream_auth {
3147 return Ok(Some(auth.clone()));
3148 }
3149 let Some(client) = client_authorization else {
3150 return Ok(None);
3151 };
3152 let Some(header) = self.provider.native_auth_header() else {
3156 return Ok(Some((axum::http::header::AUTHORIZATION, client)));
3157 };
3158 let raw = client
3159 .to_str()
3160 .map_err(|_| PipelineError::bad_request("authorization header is not valid text".to_owned()))?;
3161 let token = raw
3162 .strip_prefix("Bearer ")
3163 .or_else(|| raw.strip_prefix("bearer "))
3164 .unwrap_or(raw)
3165 .trim();
3166 let mut value = HeaderValue::from_str(token).map_err(|_| {
3167 PipelineError::bad_request("authorization token is not a valid header value".to_owned())
3168 })?;
3169 value.set_sensitive(true);
3170 Ok(Some((HeaderName::from_static(header), value)))
3171 }
3172
3173 async fn translate_native_responses(
3180 &self,
3181 translation: Translation,
3182 mut responses: Vec<reqwest::Response>,
3183 debited_tokens: u64,
3184 lease: SessionLease,
3185 capture_guard: ResponseCaptureGuard,
3186 ) -> ForwardedResponse {
3187 use crate::provider::{self, StreamTranslator};
3188 let Translation {
3189 dialect,
3190 stream,
3191 include_usage,
3192 id,
3193 model,
3194 created,
3195 } = translation;
3196 let provider_kind = match dialect {
3197 NativeDialect::Anthropic => provider::ProviderKind::Anthropic,
3198 NativeDialect::Gemini => provider::ProviderKind::Gemini,
3199 };
3200 if let Some(position) = responses
3201 .iter()
3202 .position(|response| !response.status().is_success())
3203 {
3204 let failed = responses.swap_remove(position);
3205 drop(responses);
3207 let status = failed.status();
3208 if status.is_server_error() {
3209 #[allow(clippy::indexing_slicing)]
3210 {
3211 self.hot_metrics.upstream_errors_counters[UpstreamErrorKind::Http5xx.index()].inc();
3212 }
3213 }
3214 let mut headers = HeaderMap::new();
3215 headers.insert(
3216 axum::http::header::CONTENT_TYPE,
3217 HeaderValue::from_static("application/json"),
3218 );
3219 if let Some(retry_after) = failed.headers().get(axum::http::header::RETRY_AFTER) {
3220 headers.insert(axum::http::header::RETRY_AFTER, retry_after.clone());
3221 }
3222 let body = read_bounded(failed, MAX_ERROR_BODY_BYTES)
3225 .await
3226 .unwrap_or_default();
3227 let envelope = provider::openai_error_envelope(provider_kind, status.as_u16(), &body);
3228 return ForwardedResponse {
3229 status,
3230 headers,
3231 body: futures::stream::once(async move { Ok(Bytes::from(envelope.to_string())) }).boxed(),
3232 billed_prompt_tokens: debited_tokens,
3233 lease,
3234 capture_guard,
3235 };
3236 }
3237 let mut headers = HeaderMap::new();
3238 let body: futures::stream::BoxStream<'static, Result<Bytes, std::io::Error>> = if stream {
3239 headers.insert(
3240 axum::http::header::CONTENT_TYPE,
3241 HeaderValue::from_static("text/event-stream"),
3242 );
3243 let streams = responses
3244 .into_iter()
3245 .enumerate()
3246 .map(|(position, response)| {
3247 let index = u64::try_from(position).unwrap_or(u64::MAX);
3248 let translator: Box<dyn StreamTranslator> = match dialect {
3249 NativeDialect::Anthropic => {
3250 Box::new(provider::anthropic::AnthropicStreamTranslator::new(
3251 index,
3252 id.clone(),
3253 model.clone(),
3254 created,
3255 ))
3256 }
3257 NativeDialect::Gemini => Box::new(provider::gemini::GeminiStreamTranslator::new(
3258 id.clone(),
3259 model.clone(),
3260 created,
3261 )),
3262 };
3263 provider::TranslatingStream::new(
3264 response
3265 .bytes_stream()
3266 .map(|chunk| chunk.map_err(std::io::Error::other))
3267 .boxed(),
3268 translator,
3269 )
3270 })
3271 .collect();
3272 provider::MergedOpenAiStream::new(streams, include_usage, id, model, created).boxed()
3273 } else {
3274 headers.insert(
3275 axum::http::header::CONTENT_TYPE,
3276 HeaderValue::from_static("application/json"),
3277 );
3278 futures::stream::once(async move {
3279 let mut completions = Vec::with_capacity(responses.len());
3280 for (position, response) in responses.into_iter().enumerate() {
3281 let bytes = read_bounded(response, MAX_NATIVE_BODY_BYTES).await?;
3282 let value: Value = serde_json::from_slice(&bytes).map_err(|error| {
3283 std::io::Error::other(format!("upstream body is not JSON: {error}"))
3284 })?;
3285 let index = u64::try_from(position).unwrap_or(u64::MAX);
3286 let completion = match dialect {
3287 NativeDialect::Anthropic => {
3288 provider::anthropic::translate_message(&value, &id, created, index)
3289 }
3290 NativeDialect::Gemini => {
3291 provider::gemini::translate_response(&value, &id, created, &model)
3292 }
3293 }
3294 .map_err(std::io::Error::other)?;
3295 completions.push(completion);
3296 }
3297 let merged = if completions.len() == 1 {
3298 completions
3299 .pop()
3300 .ok_or_else(|| std::io::Error::other("no upstream completion"))?
3301 } else {
3302 provider::merge_completions(completions).map_err(std::io::Error::other)?
3303 };
3304 Ok(Bytes::from(merged.to_string()))
3305 })
3306 .boxed()
3307 };
3308 ForwardedResponse {
3309 status: StatusCode::OK,
3310 headers,
3311 body,
3312 billed_prompt_tokens: debited_tokens,
3313 lease,
3314 capture_guard,
3315 }
3316 }
3317
3318 fn upstream_send_failed(
3322 &self,
3323 error: reqwest::Error,
3324 session: Arc<Session>,
3325 identity: AgentIdentity,
3326 mut capture_guard: ResponseCaptureGuard,
3327 debited_tokens: u64,
3328 reached_provider: bool,
3329 ) -> PipelineError {
3330 let kind = UpstreamErrorKind::from_reqwest(&error);
3331 #[allow(clippy::indexing_slicing)]
3332 {
3333 self.hot_metrics.upstream_errors_counters[kind.index()].inc();
3334 }
3335 let client_reason = classify_upstream_error(&error);
3356 if error.is_connect() && !reached_provider {
3367 ActionBudget::new(self.store.as_ref(), &session.id, &self.effective_budget())
3368 .refund_tokens(debited_tokens);
3369 if let Some(spec) = self.config.principal_budget.as_ref() {
3370 let principal_id = principal_id_for_budget(&identity);
3371 ActionBudget::for_principal(self.store.as_ref(), &principal_id, spec)
3372 .refund_tokens(debited_tokens);
3373 }
3374 }
3375 tracing::warn!(
3376 session = %session.id,
3377 category = client_reason,
3378 error.status = ?error.status(),
3379 error.is_timeout = error.is_timeout(),
3380 error.is_connect = error.is_connect(),
3381 error.is_body = error.is_body(),
3382 "upstream forwarding failed"
3383 );
3384 let client_error = if error.is_timeout() {
3391 PipelineError::upstream_timeout(client_reason.to_owned())
3392 } else {
3393 PipelineError::upstream(client_reason.to_owned())
3394 };
3395 let persisted_reason = format!("upstream_{client_reason}");
3396 if let Some(permit) = capture_guard.take_permit() {
3397 let (response_marker, response_attempt_id) = capture_guard.disarm();
3398 let capture_session = Arc::clone(&session);
3399 let mut job = self.failure_job(session, identity, StopReason::Other, persisted_reason);
3400 job.response_marker = response_marker;
3401 job.response_attempt = Some(crate::worker::ResponseAttempt {
3402 id: response_attempt_id,
3403 terminal: true,
3404 });
3405 if permit.submit(&self.worker, job).is_err() {
3414 capture_session.mark_capture_failed();
3415 }
3416 } else {
3417 drop(capture_guard);
3421 if let Err(error) = self.enqueue_failure(session, identity, StopReason::Other, persisted_reason) {
3422 return error;
3423 }
3424 }
3425 client_error
3426 }
3427
3428 pub fn intercept_tool(&self, headers: &HeaderMap, raw: &[u8]) -> Result<ToolVerdict, PipelineError> {
3431 self.intercept_tool_with_session(headers, raw)
3432 .map(|(verdict, _session)| verdict)
3433 }
3434
3435 pub(crate) fn refuse_unauthenticated_tool_call(&self, headers: &HeaderMap) -> Result<(), PipelineError> {
3449 if !self.config.require_identity {
3450 return Ok(());
3451 }
3452 if single_header(headers, "authorization")?.is_none() {
3453 let session_id = session_id(headers)?;
3456 let error = PipelineError::Unauthorized("missing bearer token".to_owned());
3457 self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
3458 return Err(error);
3459 }
3460 Ok(())
3461 }
3462
3463 fn intercept_tool_with_session(
3470 &self,
3471 headers: &HeaderMap,
3472 raw: &[u8],
3473 ) -> Result<(ToolVerdict, Arc<Session>), PipelineError> {
3474 let caller = match self.authenticate(headers) {
3475 Ok(caller) => caller,
3476 Err(error) => {
3477 self.audit_authentication_error(headers, &error)?;
3478 return Err(error);
3479 }
3480 };
3481 self.intercept_tool_authenticated(headers, raw, &caller, None)
3482 }
3483
3484 fn intercept_tool_authenticated(
3485 &self,
3486 headers: &HeaderMap,
3487 raw: &[u8],
3488 caller: &AuthenticatedCaller,
3489 preflight_denial: Option<ToolVerdict>,
3490 ) -> Result<(ToolVerdict, Arc<Session>), PipelineError> {
3491 let session_id = session_id(headers)?;
3492 let workflow = workflow(headers, &self.config.default_workflow)?;
3493 let parsed_call = pv_sandbox::parse_tool_call(raw).ok();
3494 let required_scope = parsed_call
3495 .as_ref()
3496 .map(|request| tool_scope(&request.tool))
3497 .unwrap_or_else(|| TOOL_INVOKE_SCOPE.to_owned());
3498 let identity = match caller.authorize(self, Some(&required_scope)) {
3499 Ok(identity) => identity,
3500 Err(error) => {
3501 self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
3502 return Err(error);
3503 }
3504 };
3505 let session =
3513 self.sessions
3514 .get_or_open_no_reopen(&session_id, workflow, &identity, &self.config.breaker);
3515 let admission = session.admission_guard();
3516 validate_session_binding(&session, workflow, &identity)?;
3517 caller.bind_session(&session)?;
3518 session.refresh_identity(&identity);
3519 if session.is_closed() {
3520 return Err(PipelineError::bad_request("session is already closed".to_owned()));
3521 }
3522 if session.capture_failed() {
3523 return Err(PipelineError::unavailable(
3524 "session audit capture is incomplete".to_owned(),
3525 ));
3526 }
3527 session.touch();
3528 if session.loop_state.state() == BreakerState::Open {
3540 match session.loop_state.action() {
3541 BreakerAction::Reject | BreakerAction::Abort => {
3542 session.latch_enforcement(StopReason::LoopDetected);
3543 return Err(PipelineError::blocked(
3544 "semantic loop circuit breaker is open".to_owned(),
3545 ));
3546 }
3547 _ => {}
3548 }
3549 }
3550 let worker_permit = self
3551 .worker
3552 .try_reserve(&session_id)
3553 .map_err(PipelineError::unavailable_source)?;
3554 let budgeted_check = || {
3555 let principal_binding = self
3556 .config
3557 .principal_budget
3558 .as_ref()
3559 .map(|spec| (principal_id_for_budget(&identity), spec));
3560 let principal_ref = principal_binding.as_ref().map(|(id, spec)| (id.as_str(), *spec));
3561 self.sandbox
3562 .check_with_principal(self.store.as_ref(), &session_id, principal_ref, raw)
3563 };
3564 let verdict = if let Some(denial) = preflight_denial {
3565 denial
3566 } else {
3567 match parsed_call.as_ref() {
3568 Some(request)
3571 if workflow == Workflow::Unsigned
3572 && self
3573 .config
3574 .consequential_tools
3575 .iter()
3576 .any(|required| required == &request.tool) =>
3577 {
3578 let reason = format!("tool {:?} requires a signed workflow", request.tool);
3579 ToolVerdict::Blocked {
3580 tool: request.tool.clone(),
3581 stage: "policy",
3582 policy: Some("workflow.signed_required".to_owned()),
3586 denial_code: pv_sandbox::DenialCode::PolicyDenied,
3587 reason: reason.clone(),
3588 response: pv_sandbox::rpc::denial_error(
3589 request.id.as_ref(),
3590 pv_sandbox::DenialCode::PolicyDenied,
3591 &reason,
3592 ),
3593 elapsed_us: 0,
3594 }
3595 }
3596 Some(request) => {
3601 match self
3602 .pdp
3603 .decide_for(&request.tool, pv_core::time::now_ms() / 1000, &caller.facts())
3604 {
3605 crate::authz::AuthzDecision::Deny { code, reason } => {
3606 self.metrics
3607 .counter(
3608 &format!("pv_pdp_denials_total{{code=\"{}\"}}", code.as_str()),
3609 "PDP denials by denial code",
3610 )
3611 .inc();
3612 ToolVerdict::Blocked {
3613 tool: request.tool.clone(),
3614 stage: "policy",
3615 policy: Some(pdp_policy_name(code).to_owned()),
3616 denial_code: code,
3617 response: pv_sandbox::rpc::denial_error(request.id.as_ref(), code, &reason),
3618 reason,
3619 elapsed_us: 0,
3620 }
3621 }
3622 crate::authz::AuthzDecision::Permit { .. } => budgeted_check(),
3623 }
3624 }
3625 None => budgeted_check(),
3626 }
3627 };
3628 let (status, payload) = match &verdict {
3629 ToolVerdict::Allowed {
3630 tool,
3631 budget_remaining,
3632 elapsed_us,
3633 ..
3634 } => (
3635 StatusId::Success,
3636 serde_json::json!({
3637 "tool": tool,
3638 "allowed": true,
3639 "budget_remaining": (*budget_remaining != u64::MAX)
3640 .then_some(*budget_remaining),
3641 "budget_unlimited": *budget_remaining == u64::MAX,
3642 "decision_us": elapsed_us,
3643 }),
3644 ),
3645 ToolVerdict::Blocked {
3646 tool,
3647 stage,
3648 policy,
3649 denial_code,
3650 reason,
3651 elapsed_us,
3652 ..
3653 } => (
3654 StatusId::Failure,
3655 serde_json::json!({
3656 "tool": tool,
3657 "allowed": false,
3658 "stage": stage,
3659 "policy": policy,
3663 "denial_code": denial_code.as_str(),
3664 "reason": reason,
3665 "decision_us": elapsed_us,
3666 }),
3667 ),
3668 _ => {
3678 tracing::error!(
3679 "unhandled ToolVerdict variant reached the harness matcher; \
3680 failing closed. This is a bug — extend the match to handle the new variant."
3681 );
3682 (
3683 StatusId::Failure,
3684 serde_json::json!({
3685 "tool": "<unknown>",
3686 "allowed": false,
3687 "stage": "unhandled_verdict",
3688 "reason": "unhandled ToolVerdict variant; fail-closed",
3689 }),
3690 )
3691 }
3692 };
3693 let tool_call_id = parsed_call
3694 .as_ref()
3695 .and_then(|request| request.id.as_ref())
3696 .map(|value| value.as_str().map_or_else(|| value.to_string(), str::to_owned))
3697 .unwrap_or_else(pv_core::new_event_uid);
3698 let tool_calls = parsed_call.as_ref().map(|request| {
3699 vec![pv_atif::ToolCall {
3700 tool_call_id: tool_call_id.clone(),
3701 function_name: request.tool.clone(),
3702 arguments: request.arguments.clone(),
3703 extra: None,
3704 }]
3705 });
3706 let observation = parsed_call.as_ref().map(|_| pv_atif::Observation {
3707 results: vec![pv_atif::ObservationResult {
3708 source_call_id: Some(tool_call_id),
3709 content: Some(Value::String(payload.to_string())),
3710 subagent_trajectory_ref: None,
3711 extra: None,
3712 }],
3713 });
3714 let stop_reason = match &verdict {
3715 ToolVerdict::Allowed { .. } => None,
3716 ToolVerdict::Blocked { stage: "budget", .. } => Some(StopReason::BudgetExceeded),
3717 ToolVerdict::Blocked { .. } => Some(StopReason::PolicyBlocked),
3718 _ => Some(StopReason::Other),
3731 };
3732 worker_permit.submit(WorkerJob {
3733 session: Arc::clone(&session),
3734 identity,
3735 class: EventClass::ToolCall,
3736 payload,
3737 text: String::from_utf8_lossy(raw).into_owned(),
3738 analyze_loop: false,
3739 status,
3740 stop_reason,
3741 native_stop_reason: None,
3742 metrics: EventMetrics::default(),
3743 cost_usd_micros: 0,
3744 prompt_token_correction: 0,
3745 atif: Some(AtifCapture {
3746 source: pv_atif::Source::Agent,
3747 message: Value::String("MCP tool authorization decision".to_owned()),
3748 reasoning_content: None,
3749 model_name: None,
3750 tool_calls,
3751 observation,
3752 llm_call_count: Some(0),
3753 }),
3754 response_marker: None,
3755 response_attempt: None,
3756 });
3757 drop(admission);
3758 Ok((verdict, session))
3759 }
3760
3761 pub async fn intercept_tool_durable(
3763 &self,
3764 headers: &HeaderMap,
3765 raw: &[u8],
3766 ) -> Result<ToolVerdict, PipelineError> {
3767 let state = self.clone();
3768 let owned_headers = headers.clone();
3769 let raw = raw.to_vec();
3770 let (verdict, session) =
3771 tokio::task::spawn_blocking(move || state.intercept_tool_with_session(&owned_headers, &raw))
3772 .await
3773 .map_err(PipelineError::unavailable_source)??;
3774 session.wait_for_worker_jobs().await;
3775 if session.capture_failed() {
3776 return Err(PipelineError::unavailable(
3777 "tool authorization audit capture failed".to_owned(),
3778 ));
3779 }
3780 Ok(verdict)
3781 }
3782
3783 pub async fn intercept_tool_nonblocking(
3786 &self,
3787 headers: &HeaderMap,
3788 raw: &[u8],
3789 ) -> Result<ToolVerdict, PipelineError> {
3790 let state = self.clone();
3791 let owned_headers = headers.clone();
3792 let raw = raw.to_vec();
3793 tokio::task::spawn_blocking(move || state.intercept_tool(&owned_headers, &raw))
3794 .await
3795 .map_err(PipelineError::unavailable_source)?
3796 }
3797
3798 pub(crate) async fn intercept_authenticated_nonblocking(
3799 &self,
3800 headers: &HeaderMap,
3801 raw: &[u8],
3802 caller: AuthenticatedCaller,
3803 preflight_denial: Option<ToolVerdict>,
3804 ) -> Result<ToolVerdict, PipelineError> {
3805 let state = self.clone();
3806 let headers = headers.clone();
3807 let raw = raw.to_vec();
3808 tokio::task::spawn_blocking(move || {
3809 state
3810 .intercept_tool_authenticated(&headers, &raw, &caller, preflight_denial)
3811 .map(|(verdict, _)| verdict)
3812 })
3813 .await
3814 .map_err(PipelineError::unavailable_source)?
3815 }
3816
3817 pub(crate) fn lease_session(&self, headers: &HeaderMap) -> Result<SessionLease, PipelineError> {
3818 let id = session_id(headers)?;
3819 let session = self
3820 .sessions
3821 .get(&id)
3822 .ok_or_else(|| PipelineError::bad_request("unknown session".to_owned()))?;
3823 session
3824 .try_lease()
3825 .ok_or_else(|| PipelineError::bad_request("session is already closed".to_owned()))
3826 }
3827
3828 pub(crate) fn enqueue_transient_failure(
3829 &self,
3830 session_id: &str,
3831 stop_reason: StopReason,
3832 reason: String,
3833 ) -> Result<(), PipelineError> {
3834 self.metrics
3846 .counter(
3847 "pv_identity_rejections_total",
3848 "Requests refused by identity validation",
3849 )
3850 .inc();
3851 const MAX_AUDITED_REJECTIONS_PER_MINUTE: u32 = 60;
3852 {
3853 let now = Instant::now();
3854 let mut window = self.identity_rejection_window.lock();
3855 if now.duration_since(window.0) >= std::time::Duration::from_secs(60) {
3856 *window = (now, 0);
3857 }
3858 if window.1 >= MAX_AUDITED_REJECTIONS_PER_MINUTE {
3859 self.metrics
3860 .counter(
3861 "pv_identity_rejections_unaudited_total",
3862 "Identity rejections beyond the per-minute audit-record budget (counted only)",
3863 )
3864 .inc();
3865 return Ok(());
3866 }
3867 window.1 += 1;
3868 }
3869 let audit_session_id = format!("identity-rejected-{}", pv_core::new_event_uid());
3870 let identity = AgentIdentity {
3871 version: "unknown".to_owned(),
3872 charter: "identity-rejected".into(),
3873 instance_uid: audit_session_id.clone(),
3874 ttl_remaining_s: None,
3875 };
3876 let session = Arc::new(Session::new(
3877 audit_session_id,
3878 Workflow::Signed,
3879 identity.clone(),
3880 self.config.breaker.clone(),
3881 ));
3882 const MAX_ECHO: usize = 64;
3888 let mut boundary = MAX_ECHO.min(session_id.len());
3889 while boundary < session_id.len() && !session_id.is_char_boundary(boundary) {
3890 boundary -= 1;
3891 }
3892 let truncated = boundary < session_id.len();
3893 let echo = &session_id[..boundary];
3894 let echo_marker = if truncated { "…(truncated)" } else { "" };
3895 self.enqueue_failure(
3896 session,
3897 identity,
3898 stop_reason,
3899 format!("requested session {echo:?}{echo_marker}: {reason}"),
3900 )
3901 }
3902
3903 fn enqueue_failure(
3904 &self,
3905 session: Arc<Session>,
3906 identity: AgentIdentity,
3907 stop_reason: StopReason,
3908 reason: String,
3909 ) -> Result<(), PipelineError> {
3910 self.worker
3911 .try_submit(self.failure_job(session, identity, stop_reason, reason))
3912 .map_err(PipelineError::unavailable_source)
3913 }
3914
3915 fn policy_failure_job(
3920 &self,
3921 session: Arc<Session>,
3922 identity: AgentIdentity,
3923 stop_reason: StopReason,
3924 reason: String,
3925 policy: Option<String>,
3926 ) -> WorkerJob {
3927 let mut job = self.failure_job(session, identity, stop_reason, reason);
3928 if let (Some(policy), Value::Object(payload)) = (policy, &mut job.payload) {
3929 payload.insert("policy".to_owned(), Value::String(policy));
3930 }
3931 job
3932 }
3933
3934 fn failure_job(
3935 &self,
3936 session: Arc<Session>,
3937 identity: AgentIdentity,
3938 stop_reason: StopReason,
3939 reason: String,
3940 ) -> WorkerJob {
3941 let atif = (session.workflow == Workflow::Unsigned).then(|| AtifCapture {
3942 source: pv_atif::Source::Agent,
3943 message: Value::String(reason.clone()),
3944 reasoning_content: None,
3945 model_name: None,
3946 tool_calls: None,
3947 observation: None,
3948 llm_call_count: Some(0),
3949 });
3950 WorkerJob {
3951 session,
3952 identity,
3953 class: EventClass::StopReason,
3954 payload: serde_json::json!({"reason": reason}),
3955 text: String::new(),
3956 analyze_loop: false,
3957 status: StatusId::Failure,
3958 stop_reason: Some(stop_reason),
3959 native_stop_reason: None,
3960 metrics: EventMetrics::default(),
3961 cost_usd_micros: 0,
3962 prompt_token_correction: 0,
3963 atif,
3964 response_marker: None,
3965 response_attempt: None,
3966 }
3967 }
3968
3969 #[cfg(test)]
3970 pub(crate) fn authorize_session(
3971 &self,
3972 headers: &HeaderMap,
3973 session: &Session,
3974 required_scope: &str,
3975 ) -> Result<(), PipelineError> {
3976 self.authenticate(headers)?
3977 .authorize_session(self, session, required_scope)
3978 }
3979
3980 pub(crate) fn resolve_identity(
3981 &self,
3982 headers: &HeaderMap,
3983 required_scope: Option<&str>,
3984 ) -> Result<AgentIdentity, PipelineError> {
3985 self.authenticate(headers)?.authorize(self, required_scope)
3986 }
3987
3988 pub(crate) fn authenticate(&self, headers: &HeaderMap) -> Result<AuthenticatedCaller, PipelineError> {
3989 let bearer = single_header(headers, "authorization")?
3990 .and_then(|value| value.to_str().ok())
3991 .and_then(strip_bearer_scheme);
3992 if let (Some(token), Some(validator)) = (bearer, &self.identity) {
3993 self.metrics
3994 .counter(
3995 "pv_identity_validations_total",
3996 "Full inbound identity validations",
3997 )
3998 .inc();
3999 let validated = validator.validate(token).map_err(identity_error_to_pipeline)?;
4000 return Ok(AuthenticatedCaller {
4001 identity: validated.agent_identity(),
4002 validated: Some(validated),
4003 });
4004 }
4005 Ok(AuthenticatedCaller {
4006 identity: self.resolve_anonymous_identity(headers, None)?,
4007 validated: None,
4008 })
4009 }
4010
4011 pub(crate) fn audit_authentication_error(
4012 &self,
4013 headers: &HeaderMap,
4014 error: &PipelineError,
4015 ) -> Result<(), PipelineError> {
4016 if matches!(
4017 error,
4018 PipelineError::Unauthorized(_) | PipelineError::Blocked { .. }
4019 ) {
4020 self.enqueue_transient_failure(
4021 &session_id(headers).unwrap_or_default(),
4022 StopReason::IdentityRejected,
4023 error.to_string(),
4024 )?;
4025 } else if matches!(error, PipelineError::Unavailable { .. }) {
4026 self.metrics
4027 .counter(
4028 "pv_identity_unavailable_total",
4029 "Identity validation dependency failures",
4030 )
4031 .inc();
4032 }
4033 Ok(())
4034 }
4035
4036 fn resolve_anonymous_identity(
4037 &self,
4038 headers: &HeaderMap,
4039 required_scope: Option<&str>,
4040 ) -> Result<AgentIdentity, PipelineError> {
4041 let bearer = single_header(headers, "authorization")?
4063 .and_then(|value| value.to_str().ok())
4064 .and_then(strip_bearer_scheme);
4065 match (bearer, &self.identity) {
4066 (Some(token), Some(validator)) => {
4067 let validated = validator.validate(token).map_err(identity_error_to_pipeline)?;
4068 if self.config.enforce_identity_scopes {
4069 if let Some(required) = required_scope {
4070 if !scope_allows(&validated.claims.scopes, required) {
4071 return Err(PipelineError::blocked(format!(
4078 "identity scope {required:?} is required"
4079 )));
4080 }
4081 }
4082 }
4083 Ok(validated.agent_identity())
4084 }
4085 (Some(_), None) => {
4112 let has_static_key =
4113 self.config.upstream_api_key_env.is_some() || self.config.upstream_api_key_file.is_some();
4114 if self.config.upstream_authorization_passthrough {
4115 Ok(AgentIdentity {
4116 version: "dev".to_owned(),
4117 charter: "anonymous".into(),
4118 instance_uid: "anonymous".to_owned(),
4119 ttl_remaining_s: None,
4120 })
4121 } else if has_static_key || self.config.ignore_client_authorization {
4122 tracing::debug!(
4146 "client bearer ignored: static upstream key posture (placeholder \
4147 SDK credential, not an identity claim)"
4148 );
4149 Ok(AgentIdentity {
4150 version: "dev".to_owned(),
4151 charter: "anonymous".into(),
4152 instance_uid: "anonymous".to_owned(),
4153 ttl_remaining_s: None,
4154 })
4155 } else {
4156 Err(PipelineError::Unauthorized(
4157 "bearer token presented but identity validator is not configured — \
4158 refusing to silently record the request as anonymous (either \
4159 configure identity_jwks_url / identity_hmac_secret_file, or set \
4160 upstream_authorization_passthrough=true if the bearer is meant \
4161 for the upstream provider)"
4162 .to_owned(),
4163 ))
4164 }
4165 }
4166 (None, _) if self.config.require_identity => {
4167 Err(PipelineError::Unauthorized("missing bearer token".to_owned()))
4168 }
4169 _ => Ok(AgentIdentity {
4170 version: "dev".to_owned(),
4171 charter: "anonymous".into(),
4172 instance_uid: "anonymous".to_owned(),
4173 ttl_remaining_s: None,
4174 }),
4175 }
4176 }
4177
4178 fn observe_stage(&self, stage: &str, started: Instant) {
4179 let elapsed = elapsed_us(started);
4180 if let Some(known) = Stage::from_label(stage) {
4188 if let Some(histogram) = self.hot_metrics.stage_histograms.get(known.index()) {
4193 histogram.observe_us(elapsed);
4194 }
4195 if self.hot_metrics.strict_stage_budget && elapsed > 2_000 {
4196 if let Some(counter) = self.hot_metrics.stage_strict_budget_counters.get(known.index()) {
4197 counter.inc();
4198 }
4199 tracing::warn!(stage, elapsed_us = elapsed, "strict stage budget exceeded");
4200 }
4201 return;
4202 }
4203 self.metrics
4204 .histogram(
4205 &format!("pv_stage_duration_seconds{{stage=\"{stage}\"}}"),
4206 "Harness stage latency",
4207 )
4208 .observe_us(elapsed);
4209 if self.hot_metrics.strict_stage_budget && elapsed > 2_000 {
4210 self.metrics
4211 .counter(
4212 &format!("pv_strict_budget_breaches_total{{stage=\"{stage}\"}}"),
4213 "Middleware stages that exceeded the strict per-stage budget",
4214 )
4215 .inc();
4216 tracing::warn!(stage, elapsed_us = elapsed, "strict stage budget exceeded");
4217 }
4218 }
4219}
4220
4221#[derive(Clone)]
4223pub(crate) struct AuthenticatedCaller {
4224 pub(crate) identity: AgentIdentity,
4225 pub(crate) validated: Option<pv_identity::ValidatedIdentity>,
4226}
4227
4228impl AuthenticatedCaller {
4229 pub(crate) fn authorize(
4230 &self,
4231 state: &AppState,
4232 scope: Option<&str>,
4233 ) -> Result<AgentIdentity, PipelineError> {
4234 if let Some(validated) = &self.validated {
4235 let now = pv_core::time::now_ms() / 1000;
4236 let leeway = state.identity.as_ref().map_or(0, |v| v.leeway_secs());
4237 if now > validated.claims.exp.saturating_add(leeway) {
4238 return Err(PipelineError::Unauthorized("identity validation failed".into()));
4239 }
4240 if state.config.enforce_identity_scopes {
4241 if let Some(required) = scope {
4242 if !scope_allows(&validated.claims.scopes, required) {
4243 return Err(PipelineError::blocked(format!(
4244 "identity scope {required:?} is required"
4245 )));
4246 }
4247 }
4248 }
4249 let mut identity = self.identity.clone();
4250 identity.ttl_remaining_s = Some(validated.claims.exp.saturating_sub(now));
4251 return Ok(identity);
4252 }
4253 Ok(self.identity.clone())
4254 }
4255
4256 pub(crate) fn authorize_session(
4257 &self,
4258 state: &AppState,
4259 session: &Session,
4260 scope: &str,
4261 ) -> Result<(), PipelineError> {
4262 let identity = self.authorize(state, Some(scope))?;
4263 validate_session_binding(session, session.workflow, &identity)?;
4264 self.bind_session(session)?;
4265 session.refresh_identity(&identity);
4266 Ok(())
4267 }
4268
4269 pub(crate) fn facts(&self) -> crate::authz::CallerFacts<'_> {
4271 match &self.validated {
4272 Some(validated) => crate::authz::CallerFacts {
4273 instance_uid: &validated.claims.instance_uid,
4274 charter: &validated.claims.charter,
4275 subject: Some(&validated.claims.sub),
4276 },
4277 None => crate::authz::CallerFacts {
4278 instance_uid: &self.identity.instance_uid,
4279 charter: "anonymous",
4280 subject: None,
4281 },
4282 }
4283 }
4284
4285 pub(crate) fn principal_digest(&self) -> Option<String> {
4286 self.validated.as_ref().map(|validated| {
4287 let pair = serde_json::json!([validated.claims.iss, validated.claims.sub]);
4288 pv_core::digest::sha256_hex(pair.to_string().as_bytes())
4289 })
4290 }
4291
4292 fn bind_session(&self, session: &Session) -> Result<(), PipelineError> {
4293 if !session.bind_principal(self.principal_digest().as_deref()) {
4294 return Err(PipelineError::Unauthorized(
4295 "session is bound to a different principal".into(),
4296 ));
4297 }
4298 Ok(())
4299 }
4300}
4301
4302fn truthy_env(name: &str) -> bool {
4303 match std::env::var(name) {
4304 Ok(value) => matches!(
4305 value.trim().to_ascii_lowercase().as_str(),
4306 "1" | "true" | "yes" | "on"
4307 ),
4308 Err(_) => false,
4309 }
4310}
4311
4312pub(crate) fn classify_upstream_error(error: &reqwest::Error) -> &'static str {
4318 if error.is_timeout() {
4319 "upstream timed out"
4320 } else if error.is_connect() {
4321 "upstream unreachable"
4322 } else if error.is_request() {
4323 "upstream request rejected"
4324 } else if error.is_body() || error.is_decode() {
4325 "upstream response malformed"
4326 } else if error.is_status() {
4327 "upstream returned error status"
4328 } else {
4329 "upstream forwarding failed"
4330 }
4331}
4332
4333pub(crate) fn identity_error_to_pipeline(error: pv_identity::IdentityError) -> PipelineError {
4353 if matches!(error, pv_identity::IdentityError::RevocationUnavailable(_)) {
4354 tracing::error!(error = %error, "identity revocation list unavailable; refusing the request");
4355 return PipelineError::unavailable(
4356 "identity revocation list is unavailable; retry shortly".to_owned(),
4357 );
4358 }
4359 tracing::warn!(error = %error, "identity validation failed");
4360 PipelineError::Unauthorized(classify_identity_error(&error).to_owned())
4361}
4362
4363pub(crate) fn classify_identity_error(error: &pv_identity::IdentityError) -> &'static str {
4364 match error {
4365 pv_identity::IdentityError::Jwks(_) => "identity validator misconfigured",
4370 _ => "identity validation failed",
4374 }
4375}
4376
4377pub(crate) fn single_header<'a>(
4389 headers: &'a HeaderMap,
4390 name: &'static str,
4391) -> Result<Option<&'a axum::http::HeaderValue>, PipelineError> {
4392 let mut iter = headers.get_all(name).into_iter();
4393 let first = iter.next();
4394 if iter.next().is_some() {
4395 return Err(PipelineError::bad_request(format!(
4396 "request carries more than one {name} header; provide exactly one"
4397 )));
4398 }
4399 Ok(first)
4400}
4401
4402pub(crate) fn strip_bearer_scheme(value: &str) -> Option<&str> {
4413 let (scheme, rest) = value.split_at_checked(6)?;
4414 if !scheme.eq_ignore_ascii_case("Bearer") {
4415 return None;
4416 }
4417 let rest = rest.trim_start_matches([' ', '\t']);
4418 if rest.len() == value.len() - scheme.len() {
4419 return None;
4424 }
4425 if rest.is_empty() {
4426 return None;
4428 }
4429 Some(rest)
4430}
4431
4432pub(crate) fn session_id(headers: &HeaderMap) -> Result<String, PipelineError> {
4433 match single_header(headers, SESSION_HEADER)? {
4434 Some(value) => {
4435 let value = value
4436 .to_str()
4437 .map_err(|_| PipelineError::bad_request("X-PV-Session is not valid text".to_owned()))?;
4438 pv_core::SessionId::parse(value)
4439 .map(|id| id.to_string())
4440 .map_err(PipelineError::bad_request_source)
4441 }
4442 None => Ok(pv_core::new_session_id().to_string()),
4443 }
4444}
4445
4446fn pdp_policy_name(code: pv_sandbox::DenialCode) -> &'static str {
4460 match code {
4461 pv_sandbox::DenialCode::UnmappedTool => "pdp.intent_map",
4462 pv_sandbox::DenialCode::MissionExpired | pv_sandbox::DenialCode::MissionDenied => "pdp.mission",
4463 pv_sandbox::DenialCode::PdpDenied => "pdp.authzen",
4464 _ => "pdp",
4465 }
4466}
4467
4468pub(crate) fn principal_id_for_budget(identity: &AgentIdentity) -> String {
4469 if identity.instance_uid.is_empty() {
4470 "anonymous".to_owned()
4471 } else {
4472 identity.instance_uid.clone()
4473 }
4474}
4475
4476fn workflow(headers: &HeaderMap, default: &str) -> Result<Workflow, PipelineError> {
4477 let value = single_header(headers, WORKFLOW_HEADER)?
4478 .map(|header| {
4479 header
4480 .to_str()
4481 .map_err(|_| PipelineError::bad_request("X-PV-Workflow is not valid text".to_owned()))
4482 })
4483 .transpose()?
4484 .unwrap_or(default);
4485 Workflow::parse(value).ok_or_else(|| {
4486 PipelineError::bad_request(format!("X-PV-Workflow must be signed or unsigned, got {value:?}"))
4487 })
4488}
4489
4490fn last_message_text(payload: &Value) -> String {
4491 let Some(message) = payload
4492 .get("messages")
4493 .and_then(Value::as_array)
4494 .and_then(|messages| messages.last())
4495 else {
4496 return String::new();
4497 };
4498 if let Some(text) = message.get("content").and_then(Value::as_str) {
4500 if !text.is_empty() {
4501 return text.to_owned();
4502 }
4503 }
4504 if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
4526 let synthesized: Vec<String> = tool_calls
4527 .iter()
4528 .filter_map(|call| {
4529 let name = call
4530 .get("function")
4531 .and_then(|f| f.get("name"))
4532 .and_then(Value::as_str)?;
4533 let arguments = call.get("function").and_then(|f| f.get("arguments"));
4534 let args_rendered = match arguments {
4535 Some(Value::String(s)) => s.clone(),
4536 Some(other) => serde_json::to_string(other).unwrap_or_default(),
4537 None => String::new(),
4538 };
4539 Some(format!("{name}({args_rendered})"))
4540 })
4541 .collect();
4542 if !synthesized.is_empty() {
4543 return synthesized.join("\n");
4544 }
4545 }
4546 if let Some(parts) = message.get("content").and_then(Value::as_array) {
4550 let text: String = parts
4551 .iter()
4552 .filter_map(|part| part.get("text").and_then(Value::as_str))
4553 .collect::<Vec<_>>()
4554 .join("\n");
4555 if !text.is_empty() {
4556 return text;
4557 }
4558 }
4559 String::new()
4560}
4561
4562fn json_type_name(value: &Value) -> &'static str {
4567 match value {
4568 Value::Null => "null",
4569 Value::Bool(_) => "boolean",
4570 Value::Number(_) => "number",
4571 Value::String(_) => "string",
4572 Value::Array(_) => "array",
4573 Value::Object(_) => "object",
4574 }
4575}
4576
4577fn atif_capture_from_request(payload: &Value) -> Result<AtifCapture, PipelineError> {
4578 let messages_value = payload
4586 .get("messages")
4587 .ok_or_else(|| PipelineError::bad_request("chat payload is missing 'messages'".to_owned()))?;
4588 let messages = messages_value.as_array().ok_or_else(|| {
4589 PipelineError::bad_request(format!(
4590 "'messages' must be a JSON array, got {}",
4591 json_type_name(messages_value)
4592 ))
4593 })?;
4594 let message = messages
4595 .last()
4596 .ok_or_else(|| PipelineError::bad_request("chat payload 'messages' is empty".to_owned()))?;
4597 let role = message.get("role").and_then(Value::as_str);
4598 let source = match role {
4599 Some("system" | "developer" | "tool" | "function") => pv_atif::Source::System,
4604 Some("user") => pv_atif::Source::User,
4605 Some("assistant") => pv_atif::Source::Agent,
4606 Some(other) => {
4607 return Err(PipelineError::bad_request(format!(
4608 "unsupported chat role {other:?}"
4609 )))
4610 }
4611 None => return Err(PipelineError::bad_request("chat message has no role".to_owned())),
4612 };
4613 let content = message
4614 .get("content")
4615 .filter(|value| value.is_string() || value.is_array())
4616 .cloned()
4617 .unwrap_or_else(|| Value::String(String::new()));
4618 let tool_calls = message.get("tool_calls").and_then(Value::as_array).map(|calls| {
4619 calls
4620 .iter()
4621 .filter_map(|call| {
4622 let function = call.get("function")?;
4623 let arguments = function
4624 .get("arguments")
4625 .cloned()
4626 .unwrap_or(Value::Object(Default::default()));
4627 let arguments = arguments
4628 .as_str()
4629 .and_then(|raw| serde_json::from_str(raw).ok())
4630 .unwrap_or(arguments);
4631 Some(pv_atif::ToolCall {
4632 tool_call_id: call
4633 .get("id")
4634 .and_then(Value::as_str)
4635 .map_or_else(pv_core::new_event_uid, str::to_owned),
4636 function_name: function.get("name")?.as_str()?.to_owned(),
4637 arguments,
4638 extra: None,
4639 })
4640 })
4641 .collect()
4642 });
4643 let observation = (role == Some("tool")).then(|| pv_atif::Observation {
4644 results: vec![pv_atif::ObservationResult {
4645 source_call_id: None,
4646 content: Some(content.clone()),
4647 subagent_trajectory_ref: None,
4648 extra: message
4649 .get("tool_call_id")
4650 .cloned()
4651 .map(|tool_call_id| serde_json::json!({"tool_call_id": tool_call_id})),
4652 }],
4653 });
4654 Ok(AtifCapture {
4655 source,
4656 message: content,
4657 reasoning_content: if source == pv_atif::Source::Agent {
4658 message
4659 .get("reasoning_content")
4660 .and_then(Value::as_str)
4661 .map(str::to_owned)
4662 } else {
4663 None
4664 },
4665 model_name: if source == pv_atif::Source::Agent {
4666 payload.get("model").and_then(Value::as_str).map(str::to_owned)
4667 } else {
4668 None
4669 },
4670 tool_calls: if source == pv_atif::Source::Agent {
4671 tool_calls
4672 } else {
4673 None
4674 },
4675 observation,
4676 llm_call_count: (source == pv_atif::Source::Agent).then_some(1),
4677 })
4678}
4679
4680fn validate_session_binding(
4681 session: &Session,
4682 workflow: Workflow,
4683 identity: &AgentIdentity,
4684) -> Result<(), PipelineError> {
4685 if session.workflow != workflow {
4686 return Err(PipelineError::bad_request(
4687 "session workflow cannot change after open".to_owned(),
4688 ));
4689 }
4690 if session.identity.instance_uid != identity.instance_uid
4691 || session.identity.charter != identity.charter
4692 || session.identity.version != identity.version
4693 {
4694 return Err(PipelineError::Unauthorized(
4695 "session is bound to a different agent identity".to_owned(),
4696 ));
4697 }
4698 Ok(())
4699}
4700
4701pub(crate) fn scope_allows(scopes: &[String], required: &str) -> bool {
4702 scopes.iter().any(|scope| {
4703 scope == "*"
4704 || scope == required
4705 || scope.strip_suffix(":*").is_some_and(|prefix| {
4711 required
4712 .strip_prefix(prefix)
4713 .is_some_and(|rest| rest.starts_with(':'))
4714 })
4715 })
4716}
4717
4718fn inject_corrective_message(payload: &mut Value) -> Result<(), PipelineError> {
4719 let messages = payload
4720 .get_mut("messages")
4721 .and_then(Value::as_array_mut)
4722 .ok_or_else(|| PipelineError::bad_request("chat payload has no messages array".to_owned()))?;
4723 messages.push(serde_json::json!({
4724 "role": "system",
4725 "content": "ProofVisor detected a semantic loop. Stop repeating the previous approach, identify new evidence, and choose a materially different next action."
4726 }));
4727 Ok(())
4728}
4729
4730pub(crate) fn load_exchange_seed(path: &str) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
4731 use zeroize::Zeroizing;
4732 require_owner_only_secret(std::path::Path::new(path)).map_err(|e| format!("exchange seed file: {e}"))?;
4733 let encoded = Zeroizing::new(
4734 pv_core::fsutil::read_capped_string(std::path::Path::new(path), pv_core::fsutil::MAX_CONTROL_BYTES)
4735 .map_err(|e| format!("read exchange seed {path}: {e}"))?,
4736 );
4737 let bytes =
4738 Zeroizing::new(hex::decode(encoded.trim()).map_err(|e| format!("decode exchange seed as hex: {e}"))?);
4739 let seed: Zeroizing<[u8; 32]> = Zeroizing::new(
4740 <[u8; 32]>::try_from(bytes.as_slice())
4741 .map_err(|_| "exchange seed must contain exactly 32 bytes".to_owned())?,
4742 );
4743 if *seed == [0u8; 32] || *seed == [0xFFu8; 32] {
4744 return Err(format!("exchange seed at {path} is a known-weak value; refusing"));
4745 }
4746 Ok(seed)
4747}
4748
4749#[cfg(test)]
4750mod tests {
4751 #![allow(
4752 clippy::expect_used,
4753 clippy::indexing_slicing,
4754 clippy::panic,
4755 clippy::unwrap_used
4756 )]
4757
4758 #[test]
4768 fn twice_registered_metrics_use_shared_help_consts() {
4769 let sources = [
4770 ("pipeline.rs", include_str!("pipeline.rs")),
4771 ("main.rs", include_str!("main.rs")),
4772 ("reconciler.rs", include_str!("reconciler.rs")),
4773 ];
4774 let shared = [
4775 ("pv_reconciler_panics_total", "RECONCILER_PANICS"),
4776 ("pv_bridge_maintenance_panics_total", "BRIDGE_MAINTENANCE_PANICS"),
4777 ("pv_bridge_maintenance_errors_total", "BRIDGE_MAINTENANCE_ERRORS"),
4778 (
4779 "pv_bridge_maintenance_join_errors_total",
4780 "BRIDGE_MAINTENANCE_JOIN_ERRORS",
4781 ),
4782 ("pv_atif_retention_panics_total", "ATIF_RETENTION_PANICS"),
4783 ("pv_atif_retention_errors_total", "ATIF_RETENTION_ERRORS"),
4784 ("pv_lifecycle_outbox_pending", "LIFECYCLE_OUTBOX_PENDING_HELP"),
4785 ];
4786 for (file, src) in sources {
4787 for (metric, const_name) in shared {
4788 let needle = format!("\"{metric}\"");
4789 let mut from = 0;
4790 while let Some(pos) = src[from..].find(&needle) {
4791 let at = from + pos;
4792 let window = &src[at..(at + 220).min(src.len())];
4797 let registers = src[..at]
4798 .rfind(|c: char| !c.is_whitespace())
4799 .map(|p| {
4800 src[p.saturating_sub(20)..at].contains(".counter(")
4801 || src[p.saturating_sub(20)..at].contains(".gauge(")
4802 })
4803 .unwrap_or(false);
4804 if registers {
4805 assert!(
4806 window.contains(const_name),
4807 "{file}: registration of {metric} does not use \
4808 supervision_help::{const_name} — string-literal HELP \
4809 reintroduces the divergent-HELP drift (R285)"
4810 );
4811 }
4812 from = at + needle.len();
4813 }
4814 }
4815 }
4816 }
4817
4818 use super::*;
4819 use axum::http::HeaderValue;
4820 use pv_bridge::{BusError, PublishAck, StoredEvent};
4821 use pv_receipts::Ed25519Signer;
4822 use pv_sandbox::SandboxConfig;
4823 use pv_state::InMemoryStore;
4824 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
4825
4826 #[test]
4833 fn last_message_text_tool_call_synthesis_varies_with_object_arguments() {
4834 let payload_string_args = serde_json::json!({
4835 "messages": [{
4836 "role": "assistant",
4837 "content": null,
4838 "tool_calls": [{
4839 "id": "c1",
4840 "type": "function",
4841 "function": {"name": "read", "arguments": "{\"path\":\"a\"}"}
4842 }]
4843 }]
4844 });
4845 let payload_object_args_a = serde_json::json!({
4846 "messages": [{
4847 "role": "assistant",
4848 "content": null,
4849 "tool_calls": [{
4850 "id": "c1",
4851 "type": "function",
4852 "function": {"name": "read", "arguments": {"path": "a"}}
4853 }]
4854 }]
4855 });
4856 let payload_object_args_b = serde_json::json!({
4857 "messages": [{
4858 "role": "assistant",
4859 "content": null,
4860 "tool_calls": [{
4861 "id": "c1",
4862 "type": "function",
4863 "function": {"name": "read", "arguments": {"path": "b"}}
4864 }]
4865 }]
4866 });
4867 let t_string = last_message_text(&payload_string_args);
4868 let t_obj_a = last_message_text(&payload_object_args_a);
4869 let t_obj_b = last_message_text(&payload_object_args_b);
4870 assert!(!t_string.is_empty(), "string-arg synthesis must not be empty");
4871 assert!(!t_obj_a.is_empty(), "object-arg synthesis must not be empty");
4872 assert_ne!(
4873 t_obj_a, t_obj_b,
4874 "object-shape args with different content must produce different text; \
4875 got identical {t_obj_a:?}"
4876 );
4877 assert!(
4878 t_obj_a.contains("\"path\":\"a\"") || t_obj_a.contains("path"),
4879 "object-arg synthesis must include the argument content, got {t_obj_a:?}"
4880 );
4881 }
4882
4883 #[test]
4888 fn last_message_text_tool_call_with_empty_arguments_still_synthesizes() {
4889 let payload = serde_json::json!({
4890 "messages": [{
4891 "role": "assistant",
4892 "content": null,
4893 "tool_calls": [{
4894 "id": "c1",
4895 "type": "function",
4896 "function": {"name": "ping"}
4897 }]
4898 }]
4899 });
4900 let text = last_message_text(&payload);
4901 assert_eq!(text, "ping()");
4902 }
4903
4904 struct NullBus;
4905
4906 struct BlockingSink {
4907 entered: AtomicBool,
4908 release: tokio::sync::Notify,
4909 }
4910
4911 impl VectorSink for BlockingSink {
4912 fn record<'a>(
4913 &'a self,
4914 _session_id: &'a str,
4915 _vector: &'a [f32],
4916 ) -> pv_loopdetect::VectorSinkFuture<'a> {
4917 Box::pin(async move {
4918 self.entered.store(true, AtomicOrdering::Release);
4919 self.release.notified().await;
4920 Ok(())
4921 })
4922 }
4923 }
4924
4925 impl EventBus for NullBus {
4926 fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
4927 Ok(PublishAck {
4928 topic: topic.to_owned(),
4929 partition: 0,
4930 offset: 0,
4931 })
4932 }
4933
4934 fn fetch(
4935 &self,
4936 _topic: &str,
4937 _partition: u32,
4938 _offset: u64,
4939 _max: usize,
4940 ) -> Result<Vec<StoredEvent>, BusError> {
4941 Ok(Vec::new())
4942 }
4943
4944 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
4945 Ok(1)
4946 }
4947
4948 fn topics(&self) -> Vec<String> {
4949 EventClass::all()
4950 .iter()
4951 .map(|class| class.topic().to_owned())
4952 .collect()
4953 }
4954 }
4955
4956 fn state(mut config: HarnessConfig) -> AppState {
4957 config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
4958 AppState::new(
4959 config,
4960 Arc::new(InMemoryStore::new()),
4961 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
4962 Arc::new(NullBus),
4963 None,
4964 Arc::new(Ed25519Signer::from_seed(&[9; 32])),
4965 )
4966 .unwrap()
4967 }
4968
4969 fn payload() -> Value {
4970 serde_json::json!({
4971 "model": "test",
4972 "messages": [{"role": "user", "content": "hello"}],
4973 })
4974 }
4975
4976 fn admission_test_runtime() -> tokio::runtime::Runtime {
4977 tokio::runtime::Builder::new_multi_thread()
4978 .worker_threads(2)
4979 .max_blocking_threads(1)
4980 .enable_all()
4981 .build()
4982 .unwrap()
4983 }
4984
4985 async fn occupy_admission_blocking_pool(
4986 ) -> (tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle<()>) {
4987 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4988 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
4989 let task = tokio::task::spawn_blocking(move || {
4990 let _ = started_tx.send(());
4991 let _ = release_rx.blocking_recv();
4993 });
4994 started_rx.await.unwrap();
4995 (release_tx, task)
4996 }
4997
4998 async fn poll_admission_once<F: std::future::Future>(
4999 mut future: std::pin::Pin<&mut F>,
5000 ) -> std::task::Poll<F::Output> {
5001 std::future::poll_fn(|context| std::task::Poll::Ready(future.as_mut().poll(context))).await
5002 }
5003
5004 #[test]
5005 fn small_chat_admission_bypasses_saturated_blocking_pool() {
5006 admission_test_runtime().block_on(async {
5007 for authenticated in [false, true] {
5008 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5009 config.require_identity = authenticated;
5010 config.enforce_identity_scopes = authenticated;
5011 let mut state = state(config);
5012 let mut headers = HeaderMap::new();
5013 headers.insert(SESSION_HEADER, HeaderValue::from_static("small-local-admission"));
5014 if authenticated {
5015 let validator = IdentityValidator::new("proofvisor");
5016 validator
5017 .add_key(
5018 "scope-key",
5019 pv_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
5020 )
5021 .unwrap();
5022 state.identity = Some(Arc::new(validator));
5023 headers.insert(
5024 axum::http::header::AUTHORIZATION,
5025 format!("Bearer {}", scoped_token(&["chat:write"]))
5026 .parse()
5027 .unwrap(),
5028 );
5029 }
5030 let caller = state.authenticate_chat_nonblocking(&headers).await.unwrap();
5031 let principal = caller.principal_digest();
5032 let mut other_principal = caller.clone();
5033 if let Some(validated) = &mut other_principal.validated {
5034 validated.claims.sub = "another-principal".into();
5035 }
5036 let (release, blocker) = occupy_admission_blocking_pool().await;
5037 if !authenticated {
5038 let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&headers));
5039 assert!(matches!(
5040 poll_admission_once(authenticate.as_mut()).await,
5041 std::task::Poll::Ready(Ok(_))
5042 ));
5043 let mut duplicated = headers.clone();
5044 duplicated.append(
5045 axum::http::header::AUTHORIZATION,
5046 HeaderValue::from_static("Bearer a"),
5047 );
5048 duplicated.append(
5049 axum::http::header::AUTHORIZATION,
5050 HeaderValue::from_static("Bearer b"),
5051 );
5052 let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&duplicated));
5053 assert!(matches!(
5054 poll_admission_once(authenticate.as_mut()).await,
5055 std::task::Poll::Ready(Err(PipelineError::BadRequest { .. }))
5056 ));
5057 }
5058 let body = payload();
5059 let body_bytes = body.to_string().len();
5060 let mut prepare =
5061 Box::pin(state.prepare_authenticated_chat(&headers, body, body_bytes, caller));
5062 let outcome = poll_admission_once(prepare.as_mut()).await;
5063 let completed_inline = outcome.is_ready();
5064 release.send(()).unwrap();
5065 blocker.await.unwrap();
5066 let prepared = match outcome {
5067 std::task::Poll::Ready(result) => result.unwrap(),
5068 std::task::Poll::Pending => prepare.await.unwrap(),
5069 };
5070 assert!(
5071 completed_inline,
5072 "small local admission queued behind unrelated blocking work"
5073 );
5074 assert_eq!(prepared.session.principal_binding(), principal);
5075 if authenticated {
5076 assert_eq!(
5077 state
5078 .metrics
5079 .counter(
5080 "pv_identity_validations_total",
5081 "Full inbound identity validations"
5082 )
5083 .get(),
5084 1
5085 );
5086 let refused = state
5089 .prepare_authenticated_chat(&headers, payload(), body_bytes, other_principal)
5090 .await;
5091 assert!(matches!(refused, Err(PipelineError::Unauthorized(_))));
5092 }
5093 drop(prepared);
5094 state.worker.wait_idle().await;
5095 }
5096 });
5097 }
5098
5099 #[test]
5100 fn chat_preparation_offloads_large_bodies_and_both_quota_classes() {
5101 admission_test_runtime().block_on(async {
5102 for (session_quota, principal_quota, large_body) in [(true, false, false), (false, true, false), (false, false, true)] {
5103 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5104 if session_quota {
5105 config.budget.max_tokens = Some(1_000_000);
5106 }
5107 if principal_quota {
5108 config.principal_budget = Some(pv_state::BudgetSpec { max_tokens: Some(1_000_000), ..Default::default() });
5109 }
5110 let state = state(config);
5111 let mut headers = HeaderMap::new();
5112 headers.insert(SESSION_HEADER, HeaderValue::from_static("offloaded-admission"));
5113 let caller = state.authenticate_chat_nonblocking(&headers).await.unwrap();
5114 let body = if large_body {
5115 serde_json::json!({"model":"test", "messages":[{"role":"user", "content":"x".repeat(32 * 1024)}]})
5116 } else {
5117 payload()
5118 };
5119 let body_bytes = body.to_string().len();
5120 let (release, blocker) = occupy_admission_blocking_pool().await;
5121 let mut prepare = Box::pin(state.prepare_authenticated_chat(&headers, body, body_bytes, caller));
5122 let outcome = poll_admission_once(prepare.as_mut()).await;
5123 let offloaded = outcome.is_pending();
5124 let sessions_before_release = state.sessions.len();
5125 release.send(()).unwrap();
5126 blocker.await.unwrap();
5127 let prepared = match outcome {
5128 std::task::Poll::Ready(result) => result.unwrap(),
5129 std::task::Poll::Pending => prepare.await.unwrap(),
5130 };
5131 assert!(offloaded, "large-body or quota preparation ran inline");
5132 assert_eq!(sessions_before_release, 0, "preparation ran before the blocking worker was available");
5133 drop(prepared);
5134 state.worker.wait_idle().await;
5135 }
5136 });
5137 }
5138
5139 #[test]
5140 fn chat_route_passes_actual_body_size_to_admission() {
5141 use tower::ServiceExt as _;
5142
5143 admission_test_runtime().block_on(async {
5144 let state = state(HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp"));
5145 let body = serde_json::json!({
5146 "model": "test",
5147 "messages": [{"role": "user", "content": "x".repeat(32 * 1024)}],
5148 })
5149 .to_string();
5150 let request = axum::http::Request::builder()
5151 .method("POST")
5152 .uri("/v1/chat/completions")
5153 .header("content-type", "application/json")
5154 .header(SESSION_HEADER, "large-route-admission")
5155 .body(axum::body::Body::from(body))
5156 .unwrap();
5157 let (release, blocker) = occupy_admission_blocking_pool().await;
5158 let mut request = Box::pin(crate::build_router(state.clone()).oneshot(request));
5159 let outcome = poll_admission_once(request.as_mut()).await;
5160 let offloaded = outcome.is_pending();
5161 let sessions_before_release = state.sessions.len();
5164 release.send(()).unwrap();
5165 blocker.await.unwrap();
5166 let response = match outcome {
5167 std::task::Poll::Ready(result) => result.unwrap(),
5168 std::task::Poll::Pending => request.await.unwrap(),
5169 };
5170 assert!(offloaded);
5171 assert_eq!(sessions_before_release, 0);
5172 assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY);
5173 assert_eq!(state.sessions.len(), 1);
5174 state.worker.wait_idle().await;
5175 });
5176 }
5177
5178 #[test]
5179 fn unresolved_identity_never_runs_revocation_on_the_admission_task() {
5180 struct CountRevocations(std::sync::atomic::AtomicUsize);
5181 impl pv_identity::RevocationStore for CountRevocations {
5182 fn revoke(&self, _jti: &str, _expires_at: u64) -> Result<(), String> {
5183 Ok(())
5184 }
5185 fn is_revoked(&self, _jti: &str) -> Result<bool, String> {
5186 self.0.fetch_add(1, AtomicOrdering::SeqCst);
5187 Ok(false)
5188 }
5189 }
5190 admission_test_runtime().block_on(async {
5191 let revocations = Arc::new(CountRevocations(std::sync::atomic::AtomicUsize::new(0)));
5192 let mut validator = IdentityValidator::new("proofvisor");
5193 validator
5194 .add_key(
5195 "scope-key",
5196 pv_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
5197 )
5198 .unwrap();
5199 validator.set_revocation_store(revocations.clone());
5200 let mut state = state(HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp"));
5201 state.identity = Some(Arc::new(validator));
5202 let mut headers = HeaderMap::new();
5203 headers.insert(SESSION_HEADER, HeaderValue::from_static("unresolved-admission"));
5204 headers.insert(
5205 axum::http::header::AUTHORIZATION,
5206 format!("Bearer {}", scoped_token(&["chat:write"]))
5207 .parse()
5208 .unwrap(),
5209 );
5210 let (release, blocker) = occupy_admission_blocking_pool().await;
5211 let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&headers));
5212 let outcome = poll_admission_once(authenticate.as_mut()).await;
5213 let offloaded = outcome.is_pending();
5214 let calls_before_release = revocations.0.load(AtomicOrdering::SeqCst);
5215 release.send(()).unwrap();
5216 blocker.await.unwrap();
5217 let _caller = match outcome {
5218 std::task::Poll::Ready(result) => result.unwrap(),
5219 std::task::Poll::Pending => authenticate.await.unwrap(),
5220 };
5221 assert!(offloaded);
5222 assert_eq!(calls_before_release, 0);
5223 assert_eq!(revocations.0.load(AtomicOrdering::SeqCst), 1);
5224
5225 let (release, blocker) = occupy_admission_blocking_pool().await;
5226 let body = payload();
5227 let body_bytes = body.to_string().len();
5228 let mut prepare = Box::pin(state.prepare_chat_nonblocking(&headers, body, body_bytes, None));
5229 let outcome = poll_admission_once(prepare.as_mut()).await;
5230 let offloaded = outcome.is_pending();
5231 let calls_before_release = revocations.0.load(AtomicOrdering::SeqCst);
5232 release.send(()).unwrap();
5233 blocker.await.unwrap();
5234 let prepared = match outcome {
5235 std::task::Poll::Ready(result) => result.unwrap(),
5236 std::task::Poll::Pending => prepare.await.unwrap(),
5237 };
5238 assert!(offloaded);
5239 assert_eq!(calls_before_release, 1);
5240 assert_eq!(revocations.0.load(AtomicOrdering::SeqCst), 2);
5241 drop(prepared);
5242 state.worker.wait_idle().await;
5243 });
5244 }
5245
5246 async fn trip_loop(state: &AppState, headers: &HeaderMap, repeated: &Value) {
5247 for expected in 1..=4u64 {
5248 let mut prepared = state.prepare_chat(headers, repeated.clone(), None).unwrap();
5249 prepared.capture_guard.defuse();
5254 drop(prepared);
5255 let session = state.sessions.get("loop-session").unwrap();
5256 tokio::time::timeout(std::time::Duration::from_secs(10), async {
5259 while session.chain.lock().count() < expected {
5260 tokio::task::yield_now().await;
5261 }
5262 })
5263 .await
5264 .unwrap();
5265 }
5266 }
5267
5268 #[tokio::test]
5269 async fn prepare_binds_session_and_stays_below_hot_path_budget() {
5270 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5271 let state = state(config);
5272 let mut headers = HeaderMap::new();
5273 headers.insert(SESSION_HEADER, HeaderValue::from_static("session-1"));
5274
5275 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5276 assert_eq!(prepared.session.id, "session-1");
5277 assert!(
5281 prepared.middleware_us < 100_000,
5282 "middleware took {}us",
5283 prepared.middleware_us
5284 );
5285 assert!(state.metrics.render().contains("pv_stage_duration_seconds"));
5286 }
5287
5288 #[tokio::test]
5289 async fn missing_identity_fails_closed_when_required() {
5290 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5291 config.require_identity = true;
5292 let error = match state(config).prepare_chat(&HeaderMap::new(), payload(), None) {
5293 Ok(_) => panic!("missing identity was accepted"),
5294 Err(error) => error,
5295 };
5296 assert!(matches!(error, PipelineError::Unauthorized(_)));
5297 }
5298
5299 #[tokio::test]
5300 async fn token_quota_blocks_before_dispatch() {
5301 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5302 config.budget.max_tokens = Some(1);
5303 let state = state(config);
5304 let mut headers = HeaderMap::new();
5305 headers.insert(SESSION_HEADER, HeaderValue::from_static("quota-failure"));
5306 let error = match state.prepare_chat(&headers, payload(), None) {
5307 Ok(_) => panic!("over-budget request was accepted"),
5308 Err(error) => error,
5309 };
5310 assert!(matches!(error, PipelineError::Blocked { .. }));
5311 assert_eq!(state.sessions.len(), 1);
5312 let session = state.sessions.get("quota-failure").unwrap();
5313 tokio::time::timeout(std::time::Duration::from_secs(10), async {
5314 while session.atif_steps_count() == 0 {
5315 tokio::task::yield_now().await;
5316 }
5317 })
5318 .await
5319 .unwrap();
5320 }
5321
5322 #[tokio::test]
5328 async fn principal_budget_binds_across_rotated_session_headers() {
5329 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5330 config.budget.max_tokens = Some(1_000_000);
5333 config.principal_budget = Some(pv_state::BudgetSpec {
5338 max_tokens: Some(40),
5339 ..pv_state::BudgetSpec::default()
5340 });
5341 let state = state(config);
5342
5343 let mut headers_a = HeaderMap::new();
5345 headers_a.insert(SESSION_HEADER, HeaderValue::from_static("rotated-a"));
5346 let first = state.prepare_chat(&headers_a, payload(), None);
5347 assert!(first.is_ok(), "first principal request must be admitted");
5348 if let Ok(mut prepared) = first {
5353 let _ = prepared.admission_debit.take_for_dispatch();
5354 }
5355
5356 let mut headers_b = HeaderMap::new();
5362 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("rotated-b"));
5363 match state.prepare_chat(&headers_b, payload(), None) {
5364 Ok(_) => panic!("header rotation defeated principal budget — the fix from §3.2 has regressed"),
5365 Err(PipelineError::Blocked { context: msg, .. }) => {
5366 assert!(
5367 msg.contains("principal."),
5368 "refusal must name the principal-scoped limit, got {msg:?}"
5369 );
5370 }
5371 Err(other) => panic!("unexpected refusal shape {other:?}"),
5372 }
5373 }
5374
5375 #[tokio::test]
5383 async fn abandon_prepared_refunds_the_principal_ledger_too() {
5384 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5385 config.budget.max_tokens = Some(1_000_000);
5386 config.principal_budget = Some(pv_state::BudgetSpec {
5389 max_tokens: Some(40),
5390 ..pv_state::BudgetSpec::default()
5391 });
5392 let state = state(config);
5393
5394 let mut headers = HeaderMap::new();
5395 headers.insert(SESSION_HEADER, HeaderValue::from_static("abandon-a"));
5396 let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5397 state.abandon_prepared(&mut prepared, StopReason::Other, "test abandon");
5398 drop(prepared);
5399
5400 let mut headers_b = HeaderMap::new();
5404 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("abandon-b"));
5405 let second = state.prepare_chat(&headers_b, payload(), None);
5406 assert!(
5407 second.is_ok(),
5408 "an abandoned request must not consume principal budget; got {:?}",
5409 second.err()
5410 );
5411 }
5412
5413 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5419 async fn dropping_a_prepared_request_refunds_both_ledgers() {
5420 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5421 config.budget.max_tokens = Some(1_000_000);
5422 config.principal_budget = Some(pv_state::BudgetSpec {
5425 max_tokens: Some(40),
5426 ..pv_state::BudgetSpec::default()
5427 });
5428 let state = state(config);
5429
5430 let mut headers = HeaderMap::new();
5431 headers.insert(SESSION_HEADER, HeaderValue::from_static("cancelled-a"));
5432 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5433 drop(prepared);
5435
5436 let mut headers_b = HeaderMap::new();
5438 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("cancelled-b"));
5439 let mut second = state.prepare_chat(&headers_b, payload(), None);
5440 for _ in 0..200 {
5441 if second.is_ok() {
5442 break;
5443 }
5444 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
5445 second = state.prepare_chat(&headers_b, payload(), None);
5446 }
5447 assert!(
5448 second.is_ok(),
5449 "a dropped (cancelled) prepared request must refund the principal ledger; got {:?}",
5450 second.err()
5451 );
5452 }
5453
5454 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5460 async fn dispatched_debit_stands_when_the_request_drops() {
5461 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5462 config.budget.max_tokens = Some(1_000_000);
5463 config.principal_budget = Some(pv_state::BudgetSpec {
5464 max_tokens: Some(40),
5465 ..pv_state::BudgetSpec::default()
5466 });
5467 let state = state(config);
5468
5469 let mut headers = HeaderMap::new();
5470 headers.insert(SESSION_HEADER, HeaderValue::from_static("dispatched-a"));
5471 let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5472 let debited = prepared.admission_debit.take_for_dispatch();
5473 assert!(debited > 0, "admission must have debited tokens");
5474 drop(prepared);
5475
5476 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5479 let mut headers_b = HeaderMap::new();
5480 headers_b.insert(SESSION_HEADER, HeaderValue::from_static("dispatched-b"));
5481 assert!(
5482 matches!(
5483 state.prepare_chat(&headers_b, payload(), None),
5484 Err(PipelineError::Blocked { .. })
5485 ),
5486 "a dispatched request's debit must stand after drop"
5487 );
5488 }
5489
5490 #[tokio::test]
5496 async fn duplicate_passthrough_header_is_refused_without_consuming_budget() {
5497 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5498 config.budget.max_tokens = Some(40);
5501 let state = state(config);
5502
5503 let mut headers = HeaderMap::new();
5504 headers.insert(SESSION_HEADER, HeaderValue::from_static("dup-header"));
5505 headers.append("openai-beta", HeaderValue::from_static("assistants=v1"));
5506 headers.append("openai-beta", HeaderValue::from_static("assistants=v2"));
5507 assert!(
5508 matches!(
5509 state.prepare_chat(&headers, payload(), None),
5510 Err(PipelineError::BadRequest { .. })
5511 ),
5512 "duplicate allowlisted headers must be refused"
5513 );
5514
5515 let mut clean = HeaderMap::new();
5517 clean.insert(SESSION_HEADER, HeaderValue::from_static("dup-header"));
5518 clean.insert("openai-beta", HeaderValue::from_static("assistants=v2"));
5519 let second = state.prepare_chat(&clean, payload(), None);
5520 assert!(
5521 second.is_ok(),
5522 "a header-refused request must not consume budget; got {:?}",
5523 second.err()
5524 );
5525 }
5526
5527 #[tokio::test]
5528 async fn invalid_workflow_is_rejected() {
5529 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5530 let state = state(config);
5531 let mut headers = HeaderMap::new();
5532 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("sometimes"));
5533 assert!(matches!(
5534 state.prepare_chat(&headers, payload(), None),
5535 Err(PipelineError::BadRequest { .. })
5536 ));
5537 }
5538
5539 #[tokio::test]
5540 async fn open_session_cannot_change_workflow() {
5541 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5542 let state = state(config);
5543 let mut headers = HeaderMap::new();
5544 headers.insert(SESSION_HEADER, HeaderValue::from_static("bound-session"));
5545 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5546 state.prepare_chat(&headers, payload(), None).unwrap();
5547 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
5548 assert!(matches!(
5549 state.prepare_chat(&headers, payload(), None),
5550 Err(PipelineError::BadRequest { .. })
5551 ));
5552 }
5553
5554 #[tokio::test]
5555 async fn asynchronous_loop_verdict_blocks_the_next_request() {
5556 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5557 config.breaker.min_tokens = 0;
5558 let state = state(config);
5559 let mut headers = HeaderMap::new();
5560 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5561 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5562 let repeated = serde_json::json!({
5563 "model": "test",
5564 "messages": [{
5565 "role": "assistant",
5566 "content": "I should check the database again for pending orders"
5567 }]
5568 });
5569 trip_loop(&state, &headers, &repeated).await;
5570 assert!(matches!(
5571 state.prepare_chat(&headers, repeated, None),
5572 Err(PipelineError::Blocked { .. })
5573 ));
5574 }
5575
5576 #[tokio::test]
5577 async fn loop_inject_action_adds_correction_and_resets_breaker() {
5578 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5579 config.breaker.min_tokens = 0;
5580 config.breaker.action = BreakerAction::Inject;
5581 let state = state(config);
5582 let mut headers = HeaderMap::new();
5583 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5584 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5585 let repeated = serde_json::json!({
5586 "messages": [{"role": "assistant", "content": "repeat the database query"}]
5587 });
5588 trip_loop(&state, &headers, &repeated).await;
5589 let prepared = state.prepare_chat(&headers, repeated, None).unwrap();
5590 let correction = prepared
5591 .payload
5592 .get("messages")
5593 .and_then(Value::as_array)
5594 .and_then(|messages| messages.last())
5595 .and_then(|message| message.get("content"))
5596 .and_then(Value::as_str)
5597 .unwrap();
5598 assert!(correction.contains("semantic loop"));
5599 assert_eq!(prepared.session.loop_state.state(), BreakerState::Closed);
5600 }
5601
5602 #[tokio::test]
5603 async fn loop_abort_action_returns_abort_error() {
5604 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5605 config.breaker.min_tokens = 0;
5606 config.breaker.action = BreakerAction::Abort;
5607 let state = state(config);
5608 let mut headers = HeaderMap::new();
5609 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5610 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5611 let repeated = serde_json::json!({
5612 "messages": [{"role": "assistant", "content": "repeat the database query"}]
5613 });
5614 trip_loop(&state, &headers, &repeated).await;
5615 assert!(matches!(
5616 state.prepare_chat(&headers, repeated, None),
5617 Err(PipelineError::Abort(_))
5618 ));
5619 }
5620
5621 #[tokio::test]
5628 async fn breaker_trip_does_not_drop_the_admissions_prompt_token_accounting() {
5629 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5630 config.breaker.min_tokens = 0;
5631 let state = state(config);
5632 let mut headers = HeaderMap::new();
5633 headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5634 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5635 let repeated = serde_json::json!({
5636 "model": "test",
5637 "messages": [{
5638 "role": "assistant",
5639 "content": "I should check the database again for pending orders"
5640 }]
5641 });
5642 let mut per_request = 0u64;
5646 for expected in 1..=4u64 {
5647 state.prepare_chat(&headers, repeated.clone(), None).unwrap();
5648 let session = state.sessions.get("loop-session").unwrap();
5649 tokio::time::timeout(std::time::Duration::from_secs(10), async {
5650 while session.chain.lock().count() < expected {
5651 tokio::task::yield_now().await;
5652 }
5653 })
5654 .await
5655 .unwrap();
5656 if expected == 1 {
5657 tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
5658 .await
5659 .unwrap();
5660 per_request = session
5661 .totals
5662 .prompt_tokens
5663 .load(std::sync::atomic::Ordering::Acquire);
5664 assert!(per_request > 0, "admission must account prompt tokens");
5665 }
5666 }
5667 let session = state.sessions.get("loop-session").unwrap();
5668 tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
5669 .await
5670 .unwrap();
5671 assert_eq!(
5672 session.loop_state.state(),
5673 BreakerState::Open,
5674 "precondition: the breaker must have tripped during the four admissions",
5675 );
5676 assert_eq!(
5677 session
5678 .totals
5679 .prompt_tokens
5680 .load(std::sync::atomic::Ordering::Acquire),
5681 per_request * 4,
5682 "every admission's prompt tokens must be accounted, including the one whose \
5683 worker job carried the breaker trip",
5684 );
5685 }
5686
5687 #[tokio::test]
5692 async fn finalization_clears_the_sessions_budget_counters() {
5693 let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
5694 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5695 config.budget.max_tokens = Some(1_000_000);
5696 config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
5697 let state = AppState::new(
5698 config,
5699 Arc::clone(&store),
5700 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
5701 Arc::new(NullBus),
5702 None,
5703 Arc::new(Ed25519Signer::from_seed(&[9; 32])),
5704 )
5705 .unwrap();
5706 let mut headers = HeaderMap::new();
5707 headers.insert(SESSION_HEADER, HeaderValue::from_static("budget-cleanup"));
5708 let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5709 let _ = prepared.admission_debit.take_for_dispatch();
5720 drop(prepared);
5721 let tokens_key = format!(
5722 "{}tokens",
5723 pv_state::ActionBudget::session_prefix("budget-cleanup")
5724 );
5725 let observed = store.get(&tokens_key);
5726 assert!(
5727 observed.as_ref().copied().unwrap_or(0) > 0,
5728 "precondition: admission must have spent from the token budget \
5729 (key {tokens_key:?}, observed {observed:?})",
5730 );
5731 let session = state.sessions.get("budget-cleanup").unwrap();
5732 session.wait_for_worker_jobs().await;
5733 state
5734 .finalizer
5735 .close_session(session, StopReason::SessionClosed)
5736 .await
5737 .unwrap();
5738 assert_eq!(
5739 store.get(&tokens_key).unwrap(),
5740 0,
5741 "finalization must remove the sealed session's budget counters",
5742 );
5743 }
5744
5745 #[tokio::test]
5746 async fn consequential_tools_require_signed_workflows() {
5747 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5748 let state = state(config);
5749 let raw = serde_json::to_vec(&serde_json::json!({
5750 "jsonrpc": "2.0",
5751 "id": 1,
5752 "method": "tools/call",
5753 "params": {"name": "db_write", "arguments": {}}
5754 }))
5755 .unwrap();
5756 let mut unsigned = HeaderMap::new();
5757 unsigned.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-write"));
5758 assert!(matches!(
5759 state.intercept_tool(&unsigned, &raw).unwrap(),
5760 ToolVerdict::Blocked { stage: "policy", .. }
5761 ));
5762
5763 let mut signed = HeaderMap::new();
5764 signed.insert(SESSION_HEADER, HeaderValue::from_static("signed-write"));
5765 signed.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5766 assert!(state.intercept_tool(&signed, &raw).unwrap().is_allowed());
5767 }
5768
5769 #[tokio::test]
5776 async fn open_breaker_hard_stop_refuses_tool_calls() {
5777 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5778 let state = state(config);
5779 let identity = pv_events::AgentIdentity {
5780 version: "dev".into(),
5781 charter: "anonymous".into(),
5782 instance_uid: "anonymous".into(),
5783 ttl_remaining_s: None,
5784 };
5785 let session = state.sessions.get_or_open(
5786 "breaker-tools",
5787 crate::session::Workflow::Unsigned,
5788 &identity,
5789 &state.config.breaker,
5790 );
5791 for _ in 0..4 {
5795 session.loop_state.observe_embedding(vec![1.0, 2.0, 3.0], 2_000);
5796 }
5797 assert_eq!(
5798 session.loop_state.state(),
5799 BreakerState::Open,
5800 "precondition: breaker must be open",
5801 );
5802
5803 let raw = serde_json::to_vec(&serde_json::json!({
5804 "jsonrpc": "2.0",
5805 "id": 1,
5806 "method": "tools/call",
5807 "params": {"name": "db_read", "arguments": {}}
5808 }))
5809 .unwrap();
5810 let mut headers = HeaderMap::new();
5811 headers.insert(SESSION_HEADER, HeaderValue::from_static("breaker-tools"));
5812 let refusal = state.intercept_tool(&headers, &raw);
5813 assert!(
5814 matches!(&refusal, Err(PipelineError::Blocked { context, .. }) if context.contains("circuit breaker")),
5815 "an open hard-stop breaker must refuse tool execution, got {refusal:?}",
5816 );
5817 assert!(
5818 session.enforcement_tripped(),
5819 "the tool-path refusal latches enforcement like the chat path",
5820 );
5821 }
5822
5823 #[tokio::test]
5828 async fn required_identity_tool_call_without_bearer_is_refused_before_parse() {
5829 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5830 config.require_identity = true;
5831 let state = state(config);
5832 let mut headers = HeaderMap::new();
5833 headers.insert(SESSION_HEADER, HeaderValue::from_static("anon-tools"));
5834 let refusal = state.intercept_tool(&headers, b"{this is not json");
5837 assert!(
5838 matches!(&refusal, Err(PipelineError::Unauthorized(reason)) if reason.contains("missing bearer token")),
5839 "missing bearer under require_identity must refuse pre-parse, got {refusal:?}",
5840 );
5841 }
5842
5843 #[tokio::test]
5850 async fn intercept_tool_durable_works_without_a_session_header() {
5851 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5852 let state = state(config);
5853 let raw = serde_json::to_vec(&serde_json::json!({
5854 "jsonrpc": "2.0",
5855 "id": 1,
5856 "method": "tools/call",
5857 "params": {"name": "safe_tool", "arguments": {}}
5858 }))
5859 .unwrap();
5860 let verdict = state
5861 .intercept_tool_durable(&HeaderMap::new(), &raw)
5862 .await
5863 .unwrap();
5864 assert!(
5865 verdict.is_allowed(),
5866 "durable interception must await the verdict's own session, not a re-derived id",
5867 );
5868 }
5869
5870 #[tokio::test]
5877 async fn durable_breaker_refusal_journals_a_terminal_response_attempt() {
5878 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5879 config.breaker.min_tokens = 0;
5880 let state = state(config);
5881 let mut headers = HeaderMap::new();
5882 headers.insert(SESSION_HEADER, HeaderValue::from_static("durable-loop"));
5883 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5884 let repeated = serde_json::json!({
5885 "model": "test",
5886 "messages": [{
5887 "role": "assistant",
5888 "content": "I should check the database again for pending orders"
5889 }],
5890 });
5891 let mut abandoned_by_test = std::collections::HashSet::new();
5895 let mut refusal = None;
5896 for _ in 0..8 {
5897 match state.prepare_chat_durable(&headers, repeated.clone()).await {
5898 Ok(prepared) => {
5899 abandoned_by_test.insert(prepared.capture_guard.attempt_id().to_owned());
5900 }
5901 Err(error) => {
5902 refusal = Some(error);
5903 break;
5904 }
5905 }
5906 }
5907 assert!(
5908 matches!(refusal, Some(PipelineError::Blocked { .. })),
5909 "the breaker must refuse during the audit wait, got {refusal:?}",
5910 );
5911 assert!(
5912 !abandoned_by_test.is_empty(),
5913 "at least one prepare must succeed first"
5914 );
5915 let session = state.sessions.get("durable-loop").unwrap();
5916 session.wait_for_worker_jobs().await;
5917
5918 let digest = pv_core::digest::sha256_hex("durable-loop".as_bytes());
5919 let stem = digest.get(..32).unwrap();
5920 let journal_path =
5921 std::path::Path::new(&state.config.atif_spool_dir).join(format!("{stem}.events.ndjson"));
5922 let journal = std::fs::read_to_string(&journal_path).unwrap();
5923 let mut dangling = std::collections::HashSet::new();
5924 for (index, line) in journal.lines().enumerate() {
5925 let record: crate::worker::ActiveJournalRecord = crate::journal::open(
5926 &state.journal_key,
5927 "durable-loop:active",
5928 index as u64,
5929 line.as_bytes(),
5930 )
5931 .unwrap();
5932 if let Some(attempt) = record.response_attempt {
5933 if attempt.terminal {
5934 assert!(
5935 dangling.remove(&attempt.id),
5936 "terminal record for attempt {} has no admission record",
5937 attempt.id,
5938 );
5939 } else {
5940 dangling.insert(attempt.id);
5941 }
5942 }
5943 }
5944 assert!(
5945 dangling.is_empty(),
5946 "every response attempt must be terminated: the refusal terminates its own \
5947 attempt, and dropping a PreparedRequest (client disconnect) terminates via \
5948 the capture guard; still dangling: {dangling:?} (test dropped: {abandoned_by_test:?})",
5949 );
5950 }
5951
5952 #[tokio::test]
5960 async fn forward_chat_fails_closed_when_marker_cannot_persist() {
5961 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5962 let state = state(config);
5963 let mut headers = HeaderMap::new();
5964 headers.insert(SESSION_HEADER, HeaderValue::from_static("marker-outage"));
5965 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5966 let attempt_id = prepared.capture_guard.attempt_id().to_owned();
5967 std::fs::write(
5971 std::path::Path::new(&state.config.atif_spool_dir).join(crate::spool::INFLIGHT_RESPONSES),
5972 b"",
5973 )
5974 .unwrap();
5975 let error = state.forward_chat(prepared).await.err().unwrap();
5976 assert!(
5979 matches!(error, PipelineError::Unavailable { .. }),
5980 "marker persist failure must map to Unavailable, got {error:?}",
5981 );
5982
5983 let session = state.sessions.get("marker-outage").unwrap();
5984 session.wait_for_worker_jobs().await;
5985 let digest = pv_core::digest::sha256_hex("marker-outage".as_bytes());
5986 let stem = digest.get(..32).unwrap();
5987 let journal_path =
5988 std::path::Path::new(&state.config.atif_spool_dir).join(format!("{stem}.events.ndjson"));
5989 let journal = std::fs::read_to_string(&journal_path).unwrap();
5990 let mut saw_admission = false;
5991 let mut saw_terminal = false;
5992 for (index, line) in journal.lines().enumerate() {
5993 let record: crate::worker::ActiveJournalRecord = crate::journal::open(
5994 &state.journal_key,
5995 "marker-outage:active",
5996 index as u64,
5997 line.as_bytes(),
5998 )
5999 .unwrap();
6000 if let Some(attempt) = record.response_attempt {
6001 if attempt.id == attempt_id {
6002 if attempt.terminal {
6003 saw_terminal = true;
6004 } else {
6005 saw_admission = true;
6006 }
6007 }
6008 }
6009 }
6010 assert!(saw_admission, "admission must have journaled the attempt");
6011 assert!(
6012 saw_terminal,
6013 "the refused request's response attempt must be terminated in the journal",
6014 );
6015 }
6016
6017 #[tokio::test]
6024 async fn intercept_tool_refreshes_session_activity() {
6025 use std::sync::atomic::Ordering as AtomicOrdering;
6026
6027 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6028 let state = state(config);
6029 let raw = serde_json::to_vec(&serde_json::json!({
6030 "jsonrpc": "2.0",
6031 "id": 1,
6032 "method": "tools/call",
6033 "params": {"name": "safe_tool", "arguments": {}}
6034 }))
6035 .unwrap();
6036 let mut headers = HeaderMap::new();
6037 headers.insert(SESSION_HEADER, HeaderValue::from_static("tool-only-session"));
6038 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6039
6040 state.intercept_tool(&headers, &raw).unwrap();
6041 let session = state.sessions.get("tool-only-session").unwrap();
6042 session.last_activity_ms.store(0, AtomicOrdering::Release);
6046
6047 state.intercept_tool(&headers, &raw).unwrap();
6048
6049 let refreshed = session.last_activity_ms.load(AtomicOrdering::Acquire);
6050 assert!(
6051 refreshed > 0,
6052 "intercept_tool must refresh last_activity_ms so a tool-only session is not force-closed by the idle sweeper — got {refreshed}",
6053 );
6054 }
6055
6056 #[tokio::test]
6066 async fn unsigned_consequential_veto_does_not_spend_budget() {
6067 let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
6068 let sandbox_config = SandboxConfig {
6069 budget: pv_state::BudgetSpec {
6070 max_total_tool_calls: Some(5),
6071 ..pv_state::BudgetSpec::default()
6072 },
6073 ..SandboxConfig::default()
6074 };
6075 let sandbox = Arc::new(Sandbox::new(sandbox_config, Vec::new()).unwrap());
6076 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6077 config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
6078 let state = AppState::new(
6079 config,
6080 Arc::clone(&store),
6081 sandbox,
6082 Arc::new(NullBus),
6083 None,
6084 Arc::new(Ed25519Signer::from_seed(&[9; 32])),
6085 )
6086 .unwrap();
6087
6088 let raw = serde_json::to_vec(&serde_json::json!({
6089 "jsonrpc": "2.0",
6090 "id": 1,
6091 "method": "tools/call",
6092 "params": {"name": "db_write", "arguments": {}}
6093 }))
6094 .unwrap();
6095 let mut headers = HeaderMap::new();
6096 headers.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-consequential"));
6097 let verdict = state.intercept_tool(&headers, &raw).unwrap();
6100 assert!(
6101 matches!(verdict, ToolVerdict::Blocked { stage: "policy", .. }),
6102 "consequential tool on unsigned workflow must block at the policy gate; got {verdict:?}",
6103 );
6104
6105 let session_digest = pv_core::digest::sha256_hex("unsigned-consequential".as_bytes());
6106 let key = format!("budget:{{{}}}:total_calls", session_digest.get(..32).unwrap());
6107 let count = store.get(&key).unwrap();
6108 assert_eq!(
6109 count, 0,
6110 "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",
6111 );
6112 }
6113
6114 #[tokio::test]
6115 async fn full_capture_queue_fails_closed_before_upstream() {
6116 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6117 config.worker_channel_capacity = 1;
6128 config.breaker.min_tokens = u64::MAX;
6129 let sink = Arc::new(BlockingSink {
6130 entered: AtomicBool::new(false),
6131 release: tokio::sync::Notify::new(),
6132 });
6133 let state = AppState::new_with_backends(
6134 config,
6135 Arc::new(InMemoryStore::new()),
6136 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6137 Arc::new(NullBus),
6138 None,
6139 Arc::new(Ed25519Signer::from_seed(&[10; 32])),
6140 Arc::new(HashEmbedder::default()),
6141 sink.clone(),
6142 )
6143 .unwrap();
6144 let mut headers = HeaderMap::new();
6145 headers.insert(SESSION_HEADER, HeaderValue::from_static("overload"));
6146 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6147 let mut agent_payload = payload();
6148 agent_payload["messages"][0]["role"] = Value::String("assistant".to_owned());
6149 let mut first = state.prepare_chat(&headers, agent_payload.clone(), None).unwrap();
6150 first.capture_guard.defuse();
6154 drop(first);
6155 tokio::time::timeout(std::time::Duration::from_secs(10), async {
6156 while !sink.entered.load(AtomicOrdering::Acquire) {
6157 tokio::task::yield_now().await;
6158 }
6159 })
6160 .await
6161 .unwrap();
6162 assert!(matches!(
6163 state.prepare_chat(&headers, agent_payload, None),
6164 Err(PipelineError::Unavailable { .. })
6165 ));
6166 sink.release.notify_waiters();
6167 }
6168
6169 #[test]
6170 fn identity_scope_matching_supports_exact_and_namespace_wildcards() {
6171 assert!(scope_allows(&["chat:write".into()], "chat:write"));
6172 assert!(scope_allows(&["tool:*".into()], "tool:db_write"));
6173 assert!(scope_allows(&["*".into()], "session:promote"));
6174 assert!(!scope_allows(&["chat:read".into()], "chat:write"));
6175 assert!(!scope_allows(&["tooling:*".into()], "tool:db_write"));
6176 }
6177
6178 #[test]
6179 fn developer_and_tool_roles_map_to_valid_system_steps() {
6180 for (role, expects_observation) in [("developer", false), ("tool", true)] {
6181 let capture = atif_capture_from_request(&serde_json::json!({
6182 "messages": [{
6183 "role": role,
6184 "content": "context",
6185 "tool_call_id": "call-1"
6186 }]
6187 }))
6188 .unwrap();
6189 assert_eq!(capture.source, pv_atif::Source::System);
6190 assert_eq!(capture.observation.is_some(), expects_observation);
6191 assert!(capture.model_name.is_none());
6192 }
6193 }
6194
6195 #[test]
6200 fn atif_capture_rejects_non_array_messages_with_precise_diagnostics() {
6201 fn bad_request_message(payload: serde_json::Value) -> String {
6202 match atif_capture_from_request(&payload) {
6203 Ok(_) => panic!("expected BadRequest, got Ok"),
6204 Err(PipelineError::BadRequest { context: msg, .. }) => msg,
6205 Err(other) => panic!("expected BadRequest, got {other:?}"),
6206 }
6207 }
6208 let msg = bad_request_message(serde_json::json!({
6211 "input": [{ "role": "user", "content": "hi" }]
6212 }));
6213 assert!(msg.contains("missing 'messages'"), "got {msg}");
6214 let msg = bad_request_message(serde_json::json!({
6216 "messages": { "0": { "role": "user", "content": "hi" } }
6217 }));
6218 assert!(msg.contains("must be a JSON array"), "got {msg}");
6219 assert!(msg.contains("object"), "got {msg}");
6220 let msg = bad_request_message(serde_json::json!({ "messages": [] }));
6222 assert!(msg.contains("empty"), "got {msg}");
6223 }
6224
6225 #[test]
6232 fn duplicate_x_pv_session_header_is_refused() {
6233 let mut headers = HeaderMap::new();
6234 headers.append(SESSION_HEADER, HeaderValue::from_static("sessA"));
6235 headers.append(SESSION_HEADER, HeaderValue::from_static("sessB"));
6236 match session_id(&headers) {
6237 Ok(id) => panic!("expected duplicate-header refusal, got session_id {id:?}"),
6238 Err(PipelineError::BadRequest { context: msg, .. }) => {
6239 assert!(msg.contains("more than one"), "got {msg}");
6240 assert!(msg.contains("x-pv-session"), "got {msg}");
6241 }
6242 Err(other) => panic!("expected BadRequest, got {other:?}"),
6243 }
6244 }
6245
6246 #[test]
6247 fn duplicate_x_pv_workflow_header_is_refused() {
6248 let mut headers = HeaderMap::new();
6249 headers.append(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6250 headers.append(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
6251 match workflow(&headers, "signed") {
6252 Ok(w) => panic!("expected duplicate-header refusal, got workflow {w:?}"),
6253 Err(PipelineError::BadRequest { context: msg, .. }) => {
6254 assert!(msg.contains("more than one"), "got {msg}");
6255 assert!(msg.contains("x-pv-workflow"), "got {msg}");
6256 }
6257 Err(other) => panic!("expected BadRequest, got {other:?}"),
6258 }
6259 }
6260
6261 #[test]
6264 fn single_x_pv_session_header_still_flows() {
6265 let mut headers = HeaderMap::new();
6266 headers.insert(SESSION_HEADER, HeaderValue::from_static("only-one"));
6267 assert_eq!(session_id(&headers).unwrap(), "only-one");
6268 }
6269
6270 #[tokio::test]
6278 async fn resolve_identity_refuses_duplicate_authorization_header() {
6279 let state = null_state();
6280 let mut headers = HeaderMap::new();
6281 headers.append(
6282 axum::http::header::AUTHORIZATION,
6283 HeaderValue::from_static("Bearer aaaa"),
6284 );
6285 headers.append(
6286 axum::http::header::AUTHORIZATION,
6287 HeaderValue::from_static("Bearer bbbb"),
6288 );
6289 match state.resolve_identity(&headers, None) {
6290 Ok(_) => panic!("expected duplicate-header refusal, got Ok"),
6291 Err(PipelineError::BadRequest { context: msg, .. }) => {
6292 assert!(msg.contains("more than one"), "got {msg}");
6293 assert!(msg.contains("authorization"), "got {msg}");
6294 }
6295 Err(other) => panic!("expected BadRequest, got {other:?}"),
6296 }
6297 }
6298
6299 #[tokio::test]
6302 async fn resolve_identity_accepts_single_authorization_header() {
6303 let state = null_state();
6304 let mut headers = HeaderMap::new();
6305 headers.insert(
6306 axum::http::header::AUTHORIZATION,
6307 HeaderValue::from_static("Bearer aaaa"),
6308 );
6309 let outcome = state.resolve_identity(&headers, None);
6313 assert!(
6314 !matches!(&outcome, Err(PipelineError::BadRequest { context: msg, .. }) if msg.contains("more than one")),
6315 "single Authorization header must not be refused as duplicate; got {outcome:?}"
6316 );
6317 }
6318
6319 #[tokio::test]
6325 async fn ignore_client_authorization_accepts_and_discards_sdk_bearer() {
6326 let mut headers = HeaderMap::new();
6327 headers.insert(
6328 axum::http::header::AUTHORIZATION,
6329 HeaderValue::from_static("Bearer sk-test-openai-sdk-key"),
6330 );
6331
6332 let refused = null_state().resolve_identity(&headers, None);
6334 assert!(
6335 matches!(&refused, Err(PipelineError::Unauthorized(_))),
6336 "bearer with no validator and no opt-in must stay 401; got {refused:?}"
6337 );
6338
6339 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6341 config.require_identity = false;
6342 config.ignore_client_authorization = true;
6343 let accepted = state(config).resolve_identity(&headers, None);
6344 match accepted {
6345 Ok(identity) => {
6346 assert_eq!(identity.instance_uid, "anonymous");
6347 }
6348 Err(error) => {
6349 panic!("ignore_client_authorization must accept the SDK bearer anonymously; got {error:?}")
6350 }
6351 }
6352 }
6353
6354 #[test]
6358 fn ignore_client_authorization_is_refused_with_identity_or_passthrough() {
6359 let mut with_identity = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6360 with_identity.ignore_client_authorization = true;
6361 with_identity.require_identity = true;
6362 with_identity.identity_hmac_secret_file = Some("/tmp/hmac".into());
6363 let err = with_identity.validate().unwrap_err();
6364 assert!(err.contains("ignore_client_authorization"), "{err}");
6365 assert!(err.contains("require_identity"), "{err}");
6366
6367 let mut with_passthrough = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6368 with_passthrough.ignore_client_authorization = true;
6369 with_passthrough.upstream_authorization_passthrough = true;
6370 let err = with_passthrough.validate().unwrap_err();
6371 assert!(err.contains("ignore_client_authorization"), "{err}");
6372 assert!(err.contains("passthrough"), "{err}");
6373 }
6374
6375 #[tokio::test]
6381 async fn identity_rejection_audit_records_are_bounded_per_minute() {
6382 let state = null_state();
6383 for i in 0..100u32 {
6384 state
6385 .enqueue_transient_failure(
6386 &format!("probe-{i}"),
6387 StopReason::IdentityRejected,
6388 "missing bearer token".to_owned(),
6389 )
6390 .unwrap();
6391 }
6392 let total = state
6393 .metrics
6394 .counter(
6395 "pv_identity_rejections_total",
6396 "Requests refused by identity validation",
6397 )
6398 .get();
6399 let unaudited = state
6400 .metrics
6401 .counter(
6402 "pv_identity_rejections_unaudited_total",
6403 "Identity rejections beyond the per-minute audit-record budget (counted only)",
6404 )
6405 .get();
6406 assert_eq!(total, 100, "every rejection must be counted");
6407 assert_eq!(
6408 unaudited, 40,
6409 "rejections beyond the 60/min budget must be count-only, not signed audit sessions"
6410 );
6411 }
6412
6413 #[test]
6419 fn strip_bearer_scheme_matches_case_insensitively() {
6420 for scheme in ["Bearer aaaa", "bearer aaaa", "BEARER aaaa", "BeArEr aaaa"] {
6421 assert_eq!(
6422 strip_bearer_scheme(scheme),
6423 Some("aaaa"),
6424 "scheme {scheme:?} did not parse to `aaaa`"
6425 );
6426 }
6427 assert_eq!(strip_bearer_scheme("Bearer\taaaa"), Some("aaaa"));
6429 assert_eq!(strip_bearer_scheme("Bearer aaaa"), Some("aaaa"));
6431 assert_eq!(strip_bearer_scheme("BearerXYZ"), None);
6433 assert_eq!(strip_bearer_scheme("Bearer "), None);
6434 assert_eq!(strip_bearer_scheme("Bearer"), None);
6435 assert_eq!(strip_bearer_scheme("Basic ZmY6bGFyZQ=="), None);
6437 assert_eq!(strip_bearer_scheme(""), None);
6439 }
6440
6441 fn null_state() -> AppState {
6442 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6443 config.require_identity = false;
6444 let sandbox = Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap();
6445 let store = Arc::new(InMemoryStore::new());
6446 let bridge: Arc<dyn EventBus> = Arc::new(NullBus);
6447 let signer: Arc<dyn Signer> = Arc::new(Ed25519Signer::generate());
6448 let embedder = Arc::new(HashEmbedder::default());
6449 let vector_sink: Arc<dyn VectorSink> = Arc::new(NoopVectorSink);
6450 AppState::new_with_backends(
6451 config,
6452 store,
6453 Arc::new(sandbox),
6454 bridge,
6455 None,
6456 signer,
6457 embedder,
6458 vector_sink,
6459 )
6460 .unwrap()
6461 }
6462
6463 fn scoped_token(scopes: &[&str]) -> String {
6464 let now = pv_core::time::now_ms() / pv_core::units::MS_PER_SEC;
6465 let claims = pv_identity::NhiClaims {
6466 sub: "agent:test".into(),
6467 iss: "https://idp.example".into(),
6468 aud: "proofvisor".into(),
6469 iat: now,
6470 nbf: None,
6471 exp: now + 600,
6472 jti: pv_core::new_event_uid(),
6473 azp: None,
6474 act: None,
6475 instance_uid: "scoped-instance".into(),
6476 charter: "scoped-charter".into(),
6477 version: "1".into(),
6478 scopes: scopes.iter().map(|scope| (*scope).to_owned()).collect(),
6479 parent_token: None,
6480 };
6481 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
6482 header.kid = Some("scope-key".into());
6483 jsonwebtoken::encode(
6484 &header,
6485 &claims,
6486 &jsonwebtoken::EncodingKey::from_secret(b"scope-secret"),
6487 )
6488 .unwrap()
6489 }
6490
6491 fn scoped_state() -> AppState {
6492 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6493 config.require_identity = true;
6494 config.enforce_identity_scopes = true;
6495 let validator = pv_identity::IdentityValidator::new("proofvisor");
6496 validator
6497 .add_key(
6498 "scope-key",
6499 pv_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
6500 )
6501 .unwrap();
6502 AppState::new(
6503 config,
6504 Arc::new(InMemoryStore::new()),
6505 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6506 Arc::new(NullBus),
6507 Some(Arc::new(validator)),
6508 Arc::new(Ed25519Signer::from_seed(&[12; 32])),
6509 )
6510 .unwrap()
6511 }
6512
6513 #[tokio::test]
6514 async fn real_jwt_scopes_gate_chat_and_lifecycle_operations() {
6515 let state = scoped_state();
6516 let mut headers = HeaderMap::new();
6517 headers.insert(SESSION_HEADER, HeaderValue::from_static("scoped-session"));
6518 headers.insert(
6519 axum::http::header::AUTHORIZATION,
6520 HeaderValue::from_str(&format!("Bearer {}", scoped_token(&["chat:write"]))).unwrap(),
6521 );
6522 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6523 assert!(matches!(
6528 state.authorize_session(&headers, &prepared.session, "session:close"),
6529 Err(PipelineError::Blocked { .. })
6530 ));
6531 headers.insert(
6532 axum::http::header::AUTHORIZATION,
6533 HeaderValue::from_str(&format!(
6534 "Bearer {}",
6535 scoped_token(&["chat:write", "session:close"])
6536 ))
6537 .unwrap(),
6538 );
6539 state
6540 .authorize_session(&headers, &prepared.session, "session:close")
6541 .unwrap();
6542 }
6543
6544 #[tokio::test]
6545 async fn upstream_failure_is_added_to_signed_chain() {
6546 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6547 let state = state(config);
6548 let mut headers = HeaderMap::new();
6549 headers.insert(SESSION_HEADER, HeaderValue::from_static("upstream-failure"));
6550 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6551 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6552 let session = Arc::clone(&prepared.session);
6553 assert!(matches!(
6554 state.forward_chat(prepared).await,
6555 Err(PipelineError::Upstream { .. })
6556 ));
6557 tokio::time::timeout(std::time::Duration::from_secs(10), async {
6558 while session.chain.lock().count() < 2 {
6559 tokio::task::yield_now().await;
6560 }
6561 })
6562 .await
6563 .unwrap();
6564 }
6565
6566 #[tokio::test]
6574 async fn upstream_failure_message_does_not_leak_configured_url() {
6575 let sentinel_url = "http://internal-sentinel-host.corp.example:65001";
6578 let config = HarnessConfig::for_tests(sentinel_url, "/tmp", "/tmp");
6579 let state = state(config);
6580 let mut headers = HeaderMap::new();
6581 headers.insert(SESSION_HEADER, HeaderValue::from_static("cwe-209-check"));
6582 headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6583 let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6584 let error = match state.forward_chat(prepared).await {
6585 Ok(_) => panic!("connect to unroutable host must fail"),
6586 Err(error) => error,
6587 };
6588 let message = error.to_string();
6589 assert!(
6590 !message.contains("internal-sentinel-host"),
6591 "upstream error message {message:?} leaks the configured URL"
6592 );
6593 assert!(
6594 !message.contains("65001"),
6595 "upstream error message {message:?} leaks the configured port"
6596 );
6597 assert!(
6598 !message.contains("corp.example"),
6599 "upstream error message {message:?} leaks the configured domain"
6600 );
6601 }
6602
6603 #[test]
6614 fn identity_validation_failure_does_not_leak_kid_or_issuer() {
6615 let sentinel_kid = "sentinel-attacker-kid";
6623 let sentinel_iss = "https://sentinel-attacker-iss.example.invalid";
6624 let sentinel_alg = "HS512";
6625 let cases: Vec<(&'static str, pv_identity::IdentityError)> = vec![
6626 (
6627 "Malformed",
6628 pv_identity::IdentityError::Malformed("jwt parse".into()),
6629 ),
6630 ("MissingKid", pv_identity::IdentityError::MissingKid),
6631 (
6632 "UnknownKid",
6633 pv_identity::IdentityError::UnknownKid(sentinel_kid.into()),
6634 ),
6635 (
6636 "AlgorithmRejected",
6637 pv_identity::IdentityError::AlgorithmRejected {
6638 alg: sentinel_alg.into(),
6639 kid: sentinel_kid.into(),
6640 },
6641 ),
6642 (
6643 "Verification",
6644 pv_identity::IdentityError::Verification("bad sig".into()),
6645 ),
6646 ("TtlTooLong", pv_identity::IdentityError::TtlTooLong(9999)),
6647 (
6648 "BadTimestamps",
6649 pv_identity::IdentityError::BadTimestamps { iat: 10, exp: 5 },
6650 ),
6651 (
6652 "FutureIat",
6653 pv_identity::IdentityError::FutureIat {
6654 iat: 999_999_999,
6655 now: 1,
6656 },
6657 ),
6658 ("EmptyField", pv_identity::IdentityError::EmptyField("charter")),
6659 (
6660 "SpoofingCharacter",
6661 pv_identity::IdentityError::SpoofingCharacter("charter"),
6662 ),
6663 (
6664 "BadIssuer",
6665 pv_identity::IdentityError::BadIssuer(sentinel_iss.into()),
6666 ),
6667 (
6668 "ScopeEscalation",
6669 pv_identity::IdentityError::ScopeEscalation {
6670 scope: "chat:write".into(),
6671 },
6672 ),
6673 (
6674 "ExpEscalation",
6675 pv_identity::IdentityError::ExpEscalation {
6676 child: 100,
6677 parent: 50,
6678 },
6679 ),
6680 ("ChainTooDeep", pv_identity::IdentityError::ChainTooDeep(5)),
6681 ];
6682
6683 let mut classifications: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
6684 for (name, error) in &cases {
6685 let client_msg = super::classify_identity_error(error);
6686 classifications.insert(client_msg);
6687 assert!(
6688 !client_msg.contains(sentinel_kid),
6689 "{name}: client message {client_msg:?} echoes attacker kid"
6690 );
6691 assert!(
6692 !client_msg.contains(sentinel_iss),
6693 "{name}: client message {client_msg:?} echoes attacker iss"
6694 );
6695 assert!(
6696 !client_msg.contains(sentinel_alg),
6697 "{name}: client message {client_msg:?} echoes attacker alg"
6698 );
6699 let wire = PipelineError::Unauthorized(client_msg.to_owned()).to_string();
6703 assert!(
6704 !wire.contains(sentinel_kid),
6705 "{name}: wire error {wire:?} echoes attacker kid"
6706 );
6707 assert!(
6708 !wire.contains(sentinel_iss),
6709 "{name}: wire error {wire:?} echoes attacker iss"
6710 );
6711 assert!(
6712 !wire.contains(sentinel_alg),
6713 "{name}: wire error {wire:?} echoes attacker alg"
6714 );
6715 }
6716 assert_eq!(
6720 classifications.len(),
6721 1,
6722 "attacker-reachable IdentityError variants classify to multiple messages {classifications:?}: this is an enumeration oracle"
6723 );
6724
6725 let jwks_msg = super::classify_identity_error(&pv_identity::IdentityError::Jwks("bad jwks".into()));
6727 assert_ne!(
6728 jwks_msg,
6729 *classifications.iter().next().unwrap(),
6730 "server-side misconfig should classify distinctly for operator dashboards"
6731 );
6732 }
6733
6734 #[test]
6738 fn read_secret_source_handling() {
6739 let env = |name: &str| -> Option<String> {
6740 match name {
6741 "GOOD_KEY" => Some("sk-live-123\n".into()),
6742 "EMPTY_KEY" => Some(" ".into()),
6743 _ => None,
6744 }
6745 };
6746 assert_eq!(
6747 super::read_secret_from(env, Some("GOOD_KEY"), None, "upstream API key")
6748 .unwrap()
6749 .as_deref(),
6750 Some("sk-live-123"),
6751 "value must be trimmed"
6752 );
6753 assert!(super::read_secret_from(env, Some("MISSING_KEY"), None, "upstream API key").is_err());
6754 assert!(super::read_secret_from(env, Some("EMPTY_KEY"), None, "upstream API key").is_err());
6755 assert!(super::read_secret_from(env, None, None, "upstream API key")
6756 .unwrap()
6757 .is_none());
6758
6759 let dir = tempfile::tempdir().unwrap();
6761 let path = dir.path().join("key");
6762 std::fs::write(&path, "sk-file-456\n").unwrap();
6763 #[cfg(unix)]
6764 {
6765 use std::os::unix::fs::PermissionsExt as _;
6766 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
6767 let error = super::read_secret_from(env, None, path.to_str(), "upstream API key").unwrap_err();
6768 assert!(error.to_string().contains("must be owner-only"), "{error}");
6769 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
6770 }
6771 assert_eq!(
6772 super::read_secret_from(env, None, path.to_str(), "upstream API key")
6773 .unwrap()
6774 .as_deref(),
6775 Some("sk-file-456")
6776 );
6777 assert!(
6779 super::read_secret_from(env, None, dir.path().join("absent").to_str(), "upstream API key")
6780 .is_err()
6781 );
6782 }
6783
6784 #[test]
6787 fn upstream_auth_resolution_and_description() {
6788 let dir = tempfile::tempdir().unwrap();
6789 let key_path = dir.path().join("azure.key");
6790 std::fs::write(&key_path, "azure-raw-key").unwrap();
6791 #[cfg(unix)]
6792 {
6793 use std::os::unix::fs::PermissionsExt as _;
6794 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
6795 }
6796
6797 let mut config = HarnessConfig::for_tests("http://up", "spool", "bridge");
6799 config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
6800 let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
6801 assert_eq!(name.as_str(), "authorization");
6802 assert_eq!(value.to_str().unwrap(), "Bearer azure-raw-key");
6803 assert!(value.is_sensitive(), "credential must redact in Debug output");
6804
6805 config.upstream_auth_header = "api-key".into();
6807 config.upstream_auth_scheme = String::new();
6808 let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
6809 assert_eq!(name.as_str(), "api-key");
6810 assert_eq!(value.to_str().unwrap(), "azure-raw-key");
6811
6812 let described = super::describe_upstream_auth(&config);
6814 assert!(described.contains("api-key from file"), "{described}");
6815 assert!(!described.contains("azure-raw-key"), "{described}");
6816
6817 let bare = HarnessConfig::for_tests("http://up", "spool", "bridge");
6819 assert!(super::resolve_upstream_auth(&bare).unwrap().is_none());
6820 assert_eq!(super::describe_upstream_auth(&bare), "none");
6821
6822 let mut tooling = HarnessConfig::for_tests("http://up", "spool", "bridge");
6824 tooling.tool_upstream_url = Some("http://tools/mcp".into());
6825 tooling.tool_upstream_bearer_file = Some(key_path.to_string_lossy().into_owned());
6826 let bearer = super::resolve_tool_auth(&tooling).unwrap().unwrap();
6827 assert_eq!(bearer.to_str().unwrap(), "Bearer azure-raw-key");
6828 assert!(bearer.is_sensitive());
6829 }
6830}