1use serde::{Deserialize, Serialize};
4
5pub const CONFIG_VERSION: u32 = 1;
7
8pub const MAX_WORKER_CHANNEL_CAPACITY: usize = 1_000_000;
15
16pub const MAX_MCP_CONCURRENCY: usize = 4_096;
26
27pub const MAX_REQUEST_BYTES_CAP: usize = 512 * 1024 * 1024;
31
32pub const MAX_ONNX_DIMENSION: usize = 16_384;
36
37pub const MAX_SECONDS_INTERVAL: u64 = 24 * 60 * 60;
41
42pub const WORKER_FINALIZE_PHASE_SECS: u64 = 30;
49
50pub const OTEL_FLUSH_SECS: u64 = 5;
54
55pub const DEFAULT_UPSTREAM_READ_TIMEOUT_S: u64 = 60;
58
59pub const DEFAULT_MCP_REQUEST_TIMEOUT_S: u64 = 60;
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct HarnessConfig {
71 #[serde(default = "default_config_version")]
73 pub config_version: u32,
74 #[serde(default = "default_listen")]
76 pub listen: String,
77 pub upstream_url: String,
79 #[serde(default)]
82 pub tool_upstream_url: Option<String>,
83 #[serde(default)]
85 pub upstream_http2_prior_knowledge: bool,
86 #[serde(default)]
90 pub upstream_read_timeout_s: Option<u64>,
91 #[serde(default)]
95 pub mcp_request_timeout_s: Option<u64>,
96 #[serde(default)]
104 pub shutdown_drain_timeout_s: Option<u64>,
105 #[serde(default)]
125 pub shutdown_ready_drain_s: u64,
126 #[serde(default = "default_chat_path")]
130 pub upstream_chat_path: String,
131 #[serde(default = "default_provider")]
137 pub provider: String,
138 #[serde(default = "default_provider_max_tokens")]
143 pub provider_default_max_tokens: u64,
144 #[serde(default)]
147 pub upstream_api_key_env: Option<String>,
148 #[serde(default)]
151 pub upstream_api_key_file: Option<String>,
152 #[serde(default = "default_auth_header")]
155 pub upstream_auth_header: String,
156 #[serde(default = "default_auth_scheme")]
159 pub upstream_auth_scheme: String,
160 #[serde(default)]
165 pub upstream_authorization_passthrough: bool,
166 #[serde(default)]
181 pub ignore_client_authorization: bool,
182 #[serde(default)]
185 pub tool_upstream_bearer_env: Option<String>,
186 #[serde(default)]
189 pub tool_upstream_bearer_file: Option<String>,
190 #[serde(default)]
193 pub require_identity: bool,
194 #[serde(default)]
202 pub allow_wildcard_bind: bool,
203 #[serde(default = "default_audience")]
205 pub audience: String,
206 #[serde(default)]
208 pub identity_jwks_url: Option<String>,
209 #[serde(default = "default_jwks_refresh")]
211 pub identity_jwks_refresh_s: u64,
212 #[serde(default)]
214 pub identity_allowed_issuers: Vec<String>,
215 #[serde(default)]
217 pub identity_hmac_secret_file: Option<String>,
218 #[serde(default = "default_hmac_kid")]
220 pub identity_hmac_kid: String,
221 #[serde(default = "default_max_delegation_depth")]
224 pub max_delegation_depth: usize,
225 #[serde(default)]
230 pub identity_human_subject_pattern: Option<String>,
231
232 #[serde(default)]
250 pub enforce_identity_scopes: bool,
251 #[serde(default = "default_chat_scope")]
253 pub chat_scope: String,
254 #[serde(default = "default_close_scope")]
256 pub session_close_scope: String,
257 #[serde(default = "default_promote_scope")]
259 pub session_promote_scope: String,
260 #[serde(default = "default_workflow")]
263 pub default_workflow: String,
264 #[serde(default = "default_consequential_tools")]
267 pub consequential_tools: Vec<String>,
268 #[serde(default = "default_tool_schema_dir")]
270 pub tool_schema_dir: Option<String>,
271 #[serde(default = "default_true")]
273 pub require_tool_schema: bool,
274 #[serde(default)]
283 pub policy_bundle_file: Option<String>,
284 #[serde(default = "default_policy_bundle_poll_s")]
286 pub policy_bundle_poll_s: u64,
287 #[serde(default = "default_payout_field")]
294 pub payout_field: String,
295 #[serde(default = "default_wasm_policies")]
297 pub wasm_policy_paths: Vec<String>,
298 #[serde(default = "default_idle")]
300 pub session_idle_close_s: u64,
301 #[serde(default = "default_spool")]
303 pub atif_spool_dir: String,
304 #[serde(default = "default_bridge")]
306 pub bridge_data_dir: String,
307 #[serde(default = "default_bridge_backend")]
309 pub bridge_backend: String,
310 #[serde(default = "default_bridge_manifest")]
312 pub bridge_manifest_path: String,
313 #[serde(default)]
321 pub bridge_endpoint: Option<String>,
322 #[serde(default = "default_state_backend")]
324 pub state_backend: String,
325 #[serde(default)]
328 pub state_endpoint: Option<String>,
329 #[serde(default = "default_embedder_backend")]
331 pub embedder_backend: String,
332 #[serde(default)]
334 pub onnx_model_path: Option<String>,
335 #[serde(default)]
337 pub onnx_tokenizer_path: Option<String>,
338 #[serde(default = "default_onnx_dimension")]
340 pub onnx_dimension: usize,
341 #[serde(default = "default_vector_backend")]
343 pub vector_backend: String,
344 #[serde(default)]
346 pub qdrant_url: Option<String>,
347 #[serde(default = "default_qdrant_collection")]
349 pub qdrant_collection: String,
350 #[serde(default = "default_channel_cap")]
352 pub worker_channel_capacity: usize,
353 #[serde(default = "default_mcp_concurrency")]
365 pub mcp_concurrency: usize,
366 #[serde(default)]
368 pub strict_stage_budget: bool,
369 #[serde(default)]
371 pub breaker: pv_loopdetect::BreakerConfig,
372 #[serde(default = "default_compression")]
374 pub compression_enabled: bool,
375 #[serde(default)]
377 pub budget: pv_state::BudgetSpec,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub principal_budget: Option<pv_state::BudgetSpec>,
397 #[serde(default)]
400 pub allow_anonymous_principal_budget: bool,
401 #[serde(default = "default_reconcile_tick")]
403 pub reconcile_tick_s: u64,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub atif_retention_days: Option<u32>,
414 #[serde(default = "default_max_request_bytes")]
423 pub max_request_bytes: usize,
424
425 #[serde(default = "default_dashboard_enabled")]
438 pub dashboard_enabled: bool,
439
440 #[serde(default)]
453 pub allowed_hosts: Vec<String>,
454
455 #[serde(default)]
460 pub backends: Vec<BackendConfig>,
461
462 #[serde(default)]
468 pub require_private_backends: bool,
469
470 #[serde(default = "default_tool_upstream_transport")]
476 pub tool_upstream_transport: BackendTransport,
477
478 #[serde(default)]
485 pub mcp_allowed_origins: Vec<String>,
486
487 #[serde(default)]
491 pub intent_map: std::collections::BTreeMap<String, String>,
492
493 #[serde(default)]
497 pub require_intent_mapping: bool,
498
499 #[serde(default)]
503 pub mission: Option<MissionConfig>,
504
505 #[serde(default)]
510 pub missions: Vec<MissionConfig>,
511
512 #[serde(default = "default_intent_token_ttl")]
515 pub intent_token_ttl_s: u64,
516
517 #[serde(default)]
521 pub workload_identity: Option<WorkloadIdentityConfig>,
522
523 #[serde(default)]
526 pub workload_identities: Vec<WorkloadMapping>,
527
528 #[serde(default)]
532 pub authzen: Option<AuthzenConfig>,
533
534 #[serde(default)]
539 pub token_exchange_enabled: bool,
540
541 #[serde(default)]
545 pub token_exchange_seed_file: Option<String>,
546
547 #[serde(default = "default_exchange_ttl")]
550 pub token_exchange_ttl_s: u64,
551
552 #[serde(default)]
555 pub operator_tokens: Vec<OperatorTokenConfig>,
556
557 #[serde(default)]
559 pub introspection_tokens: Vec<IntrospectionTokenConfig>,
560
561 #[serde(default = "default_revocation_audit_per_minute")]
563 pub revocation_audit_per_minute: u32,
564
565 #[serde(default)]
569 pub otel_tenant_endpoint: Option<String>,
570
571 #[serde(default)]
574 pub otel_tenant_auth_file: Option<String>,
575
576 #[serde(default)]
582 pub otel_tenant_content: TenantContent,
583
584 #[serde(default)]
596 pub redaction_patterns: Vec<String>,
597
598 #[serde(default)]
601 pub redaction_paths: Vec<String>,
602}
603
604pub const RECOMMENDED_REDACTION_PATTERNS: &[&str] = &[
619 r"(?-u:\b)(?:AKIA|ASIA)[0-9A-Z]{16}(?-u:\b)",
621 r"(?-u:\b)gh[opsur]_[A-Za-z0-9]{36,255}(?-u:\b)",
624 r"(?-u:\b)AIza[0-9A-Za-z_-]{35}(?-u:\b)",
626 r"(?-u:\b)xox[abeprs]-[A-Za-z0-9-]{10,255}",
628 r"(?-u:\b)eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}",
631];
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
635#[serde(deny_unknown_fields)]
636pub struct OperatorTokenConfig {
637 pub name: String,
639 pub sha256: String,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize)]
645#[serde(deny_unknown_fields)]
646pub struct IntrospectionTokenConfig {
647 pub backend: String,
649 pub sha256: String,
651}
652
653fn default_revocation_audit_per_minute() -> u32 {
654 600
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
659#[serde(deny_unknown_fields)]
660pub struct BackendConfig {
661 pub name: String,
663 pub url: String,
665 #[serde(default = "default_backend_auth")]
667 pub auth: BackendAuth,
668 #[serde(default)]
671 pub tools: Vec<String>,
672 #[serde(default)]
677 pub transport: BackendTransport,
678}
679
680#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case")]
683pub enum BackendTransport {
684 #[default]
686 Mcp,
687 JsonRpc,
689}
690
691fn default_tool_upstream_transport() -> BackendTransport {
692 BackendTransport::JsonRpc
693}
694
695pub(crate) fn validate_origin(origin: &str) -> Result<(), String> {
698 let parsed = reqwest::Url::parse(origin).map_err(|_| "not a valid origin".to_owned())?;
699 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none_or(str::is_empty) {
700 return Err("must be an http:// or https:// origin with a host".to_owned());
701 }
702 if !parsed.username().is_empty()
703 || parsed.password().is_some()
704 || parsed.query().is_some()
705 || parsed.fragment().is_some()
706 || origin.trim_end_matches('/') != parsed.origin().ascii_serialization()
707 {
708 return Err("must be exactly scheme://host[:port], without path, query, or credentials".to_owned());
709 }
710 Ok(())
711}
712
713#[derive(Debug, Clone, Default, Serialize, Deserialize)]
715#[serde(rename_all = "snake_case")]
716pub enum BackendAuth {
717 #[default]
719 None,
720 StaticEnv(String),
722 StaticFile(String),
724 Exchange,
728 ForwardToken,
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize)]
738#[serde(deny_unknown_fields)]
739pub struct MissionConfig {
740 pub id: String,
742 pub allowed_intents: Vec<String>,
745 pub expires_at: u64,
748 #[serde(default, skip_serializing_if = "Vec::is_empty")]
751 pub agents: Vec<String>,
752 #[serde(default, skip_serializing_if = "Vec::is_empty")]
754 pub charters: Vec<String>,
755 #[serde(default, skip_serializing_if = "Vec::is_empty")]
758 pub subjects: Vec<String>,
759}
760
761impl MissionConfig {
762 pub fn has_selectors(&self) -> bool {
764 !self.agents.is_empty() || !self.charters.is_empty() || !self.subjects.is_empty()
765 }
766
767 pub fn selects(&self, instance_uid: &str, charter: &str, subject: Option<&str>) -> bool {
771 self.has_selectors()
772 && (self.agents.is_empty() || self.agents.iter().any(|agent| agent == instance_uid))
773 && (self.charters.is_empty() || self.charters.iter().any(|name| name == charter))
774 && (self.subjects.is_empty() || subject.is_some_and(|sub| self.subjects.iter().any(|s| s == sub)))
775 }
776}
777
778#[derive(Debug, Clone, Serialize, Deserialize)]
780#[serde(deny_unknown_fields)]
781pub struct WorkloadIdentityConfig {
782 pub ca_file: String,
784 #[serde(default = "default_workload_token_ttl")]
786 pub token_ttl_s: u64,
787}
788
789fn default_workload_token_ttl() -> u64 {
790 300
791}
792
793#[derive(Debug, Clone, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct WorkloadMapping {
797 pub name: String,
799 #[serde(default)]
801 pub cf_app_guid: Option<String>,
802 #[serde(default)]
804 pub cf_space_guid: Option<String>,
805 #[serde(default)]
807 pub cf_org_guid: Option<String>,
808 pub sub: String,
810 pub charter: String,
812 #[serde(default = "default_workload_version")]
814 pub version: String,
815 pub scopes: Vec<String>,
817}
818
819fn default_workload_version() -> String {
820 "1".to_owned()
821}
822
823impl WorkloadMapping {
824 pub fn selects(&self, identity: &pv_identity::CfInstanceIdentity) -> bool {
826 let matches =
827 |expected: &Option<String>, actual: &str| expected.as_deref().is_none_or(|value| value == actual);
828 (self.cf_app_guid.is_some() || self.cf_space_guid.is_some() || self.cf_org_guid.is_some())
829 && matches(&self.cf_app_guid, &identity.app_guid)
830 && matches(&self.cf_space_guid, &identity.space_guid)
831 && matches(&self.cf_org_guid, &identity.org_guid)
832 }
833}
834
835#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
837#[serde(rename_all = "snake_case")]
838pub enum TenantContent {
839 #[default]
841 Redacted,
842 Off,
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize)]
849#[serde(deny_unknown_fields)]
850pub struct AuthzenConfig {
851 pub evaluation_endpoint: String,
854 #[serde(default)]
859 pub evaluations_endpoint: Option<String>,
860 #[serde(default)]
862 pub auth_file: Option<String>,
863 #[serde(default = "default_authzen_timeout_ms")]
866 pub timeout_ms: u64,
867}
868
869fn default_authzen_timeout_ms() -> u64 {
870 1_000
871}
872
873fn default_max_delegation_depth() -> usize {
874 4
875}
876fn default_intent_token_ttl() -> u64 {
877 60
878}
879fn default_exchange_ttl() -> u64 {
880 300
881}
882fn default_backend_auth() -> BackendAuth {
883 BackendAuth::None
884}
885fn default_config_version() -> u32 {
886 CONFIG_VERSION
887}
888fn default_listen() -> String {
889 "127.0.0.1:8484".to_owned()
890}
891fn default_audience() -> String {
896 "agentvisor-ai".to_owned()
897}
898fn default_chat_path() -> String {
899 "/v1/chat/completions".to_owned()
900}
901fn default_auth_header() -> String {
902 "authorization".to_owned()
903}
904fn default_auth_scheme() -> String {
905 "Bearer".to_owned()
906}
907fn default_jwks_refresh() -> u64 {
908 300
909}
910fn default_hmac_kid() -> String {
911 "dev-hmac".to_owned()
912}
913fn default_chat_scope() -> String {
914 "chat:write".to_owned()
915}
916fn default_close_scope() -> String {
917 "session:close".to_owned()
918}
919fn default_promote_scope() -> String {
920 "session:promote".to_owned()
921}
922fn default_workflow() -> String {
923 crate::session::Workflow::Unsigned.as_str().to_owned()
924}
925fn default_consequential_tools() -> Vec<String> {
926 ["db_write", "payout", "merge", "deploy"]
927 .into_iter()
928 .map(str::to_owned)
929 .collect()
930}
931fn default_tool_schema_dir() -> Option<String> {
932 Some("config/tool-schemas".to_owned())
933}
934fn default_true() -> bool {
935 true
936}
937fn default_policy_bundle_poll_s() -> u64 {
938 5
939}
940fn default_payout_field() -> String {
941 "amount_usd".to_owned()
942}
943fn default_wasm_policies() -> Vec<String> {
944 vec!["config/policies/payload_limit.wat".to_owned()]
945}
946fn default_idle() -> u64 {
947 900
948}
949fn default_spool() -> String {
950 "spool/atif".to_owned()
951}
952fn default_bridge() -> String {
953 "data/bridge".to_owned()
954}
955fn default_bridge_backend() -> String {
956 "embedded".to_owned()
957}
958fn default_provider() -> String {
959 "openai".to_owned()
960}
961fn default_provider_max_tokens() -> u64 {
962 4096
963}
964fn default_bridge_manifest() -> String {
965 "manifests/bridge.example.yaml".to_owned()
966}
967fn default_state_backend() -> String {
968 "memory".to_owned()
969}
970fn default_embedder_backend() -> String {
971 "hash".to_owned()
972}
973fn default_onnx_dimension() -> usize {
974 384
975}
976fn default_vector_backend() -> String {
977 "memory".to_owned()
978}
979fn default_qdrant_collection() -> String {
980 "agent_steps".to_owned()
981}
982fn default_channel_cap() -> usize {
983 32_768
984}
985fn default_mcp_concurrency() -> usize {
986 128
987}
988fn default_compression() -> bool {
989 true
990}
991fn default_reconcile_tick() -> u64 {
992 5
993}
994fn default_max_request_bytes() -> usize {
995 4 * 1024 * 1024
996}
997fn default_dashboard_enabled() -> bool {
998 true
999}
1000
1001#[derive(Debug, Clone, PartialEq, Eq)]
1017pub enum BridgeBackend {
1018 Embedded,
1020 Kafka {
1023 endpoint: String,
1025 },
1026 Nats {
1028 endpoint: String,
1030 },
1031}
1032
1033#[derive(Debug, Clone, PartialEq, Eq)]
1036pub enum StateBackend {
1037 Memory,
1039 Redis {
1042 endpoint: String,
1044 },
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Eq)]
1049pub enum EmbedderBackend {
1050 Hash,
1052 Onnx {
1054 model_path: String,
1056 tokenizer_path: String,
1058 },
1059}
1060
1061#[derive(Debug, Clone, PartialEq, Eq)]
1063pub enum VectorBackend {
1064 Memory,
1066 Qdrant {
1068 url: String,
1070 },
1071}
1072
1073#[derive(Debug, Clone, PartialEq, Eq)]
1075pub struct ResolvedBackends {
1076 pub bridge: BridgeBackend,
1078 pub state: StateBackend,
1080 pub embedder: EmbedderBackend,
1082 pub vector: VectorBackend,
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Eq)]
1088pub enum ConfigSource {
1089 File(std::path::PathBuf),
1091 BuiltIn,
1093}
1094
1095impl std::fmt::Display for ConfigSource {
1096 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1097 match self {
1098 Self::File(path) => write!(f, "{}", path.display()),
1099 Self::BuiltIn => f.write_str("built-in defaults (zero-config)"),
1100 }
1101 }
1102}
1103
1104pub const CONFIG_SEARCH_PATHS: [&str; 3] = ["proofvisor.toml", "config/harness.toml", "agentvisor.toml"];
1117
1118pub fn user_config_path_from(home: &std::path::Path) -> std::path::PathBuf {
1120 pv_core::legacy::existing_or_legacy(
1123 home.join(".proofvisor").join("proofvisor.toml"),
1124 &home.join(".agentvisor").join("agentvisor.toml"),
1125 )
1126}
1127
1128pub fn user_config_path() -> Option<std::path::PathBuf> {
1131 std::env::home_dir().map(|home| user_config_path_from(&home))
1132}
1133
1134pub fn resolve_config_source() -> Result<ConfigSource, String> {
1141 if let Some(path) = std::env::var_os("PV_CONFIG") {
1142 if !path.is_empty() {
1145 let path = std::path::PathBuf::from(path);
1146 if !path.is_file() {
1147 return Err(format!(
1148 "PV_CONFIG points to {} which does not exist or is not a file",
1149 path.display()
1150 ));
1151 }
1152 return Ok(ConfigSource::File(path));
1153 }
1154 }
1155 for candidate in CONFIG_SEARCH_PATHS {
1156 let path = std::path::Path::new(candidate);
1157 if path.is_file() {
1158 return Ok(ConfigSource::File(path.to_path_buf()));
1159 }
1160 }
1161 if let Some(path) = user_config_path() {
1162 if path.is_file() {
1163 return Ok(ConfigSource::File(path));
1164 }
1165 }
1166 Ok(ConfigSource::BuiltIn)
1167}
1168
1169pub fn load_config() -> Result<(HarnessConfig, ConfigSource), String> {
1171 load_config_with_override(None)
1172}
1173
1174pub fn load_config_with_override(
1179 explicit: Option<std::path::PathBuf>,
1180) -> Result<(HarnessConfig, ConfigSource), String> {
1181 let source = match explicit {
1182 Some(path) => {
1183 if !path.is_file() {
1184 return Err(format!(
1185 "--config points to {} which does not exist or is not a file",
1186 path.display()
1187 ));
1188 }
1189 ConfigSource::File(path)
1190 }
1191 None => resolve_config_source()?,
1192 };
1193 let mut config = match &source {
1194 ConfigSource::File(path) => {
1195 let text = pv_core::fsutil::read_capped_string(
1200 std::path::Path::new(path),
1201 pv_core::fsutil::MAX_CONTROL_BYTES,
1202 )
1203 .map_err(|error| format!("read harness config {}: {error}", path.display()))?;
1204 HarnessConfig::from_toml_unvalidated(&text)
1205 .map_err(|error| format!("{}: {error}", path.display()))?
1206 }
1207 ConfigSource::BuiltIn => HarnessConfig::builtin()?,
1208 };
1209 config.apply_env_overrides();
1210 if config.upstream_url.is_empty() && source == ConfigSource::BuiltIn {
1211 return Err(format!(
1212 "no configuration found and PV_UPSTREAM_URL is not set.\n\
1213 Quick start (pick one):\n\
1214 \x20 pvctl # guided setup\n\
1215 \x20 pvctl init --preset openai # write an annotated proofvisor.toml\n\
1216 \x20 PV_UPSTREAM_URL=http://127.0.0.1:11434 proofvisord # zero-config\n\
1217 Searched: $PV_CONFIG, {}, ~/.proofvisor/proofvisor.toml",
1218 CONFIG_SEARCH_PATHS.join(", ")
1219 ));
1220 }
1221 config.validate().map_err(|error| format!("{source}: {error}"))?;
1222 Ok((config, source))
1223}
1224
1225impl HarnessConfig {
1226 pub fn tenant_otel_authorization(&self) -> Result<Option<axum::http::HeaderValue>, String> {
1229 let token = crate::pipeline::read_secret(
1230 None,
1231 self.otel_tenant_auth_file.as_deref(),
1232 "tenant OTEL bearer token",
1233 )
1234 .map_err(|error| error.to_string())?;
1235 token
1236 .map(|token| {
1237 let mut header =
1238 axum::http::HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
1239 "tenant OTEL bearer token cannot be represented as an HTTP header".to_owned()
1240 })?;
1241 header.set_sensitive(true);
1242 Ok(header)
1243 })
1244 .transpose()
1245 }
1246
1247 pub fn from_toml(s: &str) -> Result<Self, String> {
1249 Self::check_declared_version(s)?;
1250 let cfg: Self = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1251 cfg.validate()?;
1252 Ok(cfg)
1253 }
1254
1255 pub fn from_toml_unvalidated(s: &str) -> Result<Self, String> {
1258 Self::check_declared_version(s)?;
1259 toml::from_str(s).map_err(|e| format!("config parse: {e}"))
1260 }
1261
1262 fn check_declared_version(s: &str) -> Result<(), String> {
1267 let loose: toml::Value = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1268 if let Some(version) = loose.get("config_version") {
1269 let declared = version.as_integer().unwrap_or(-1);
1270 if declared != i64::from(CONFIG_VERSION) {
1271 return Err(format!(
1272 "unsupported config_version {version} (this build supports {CONFIG_VERSION})",
1273 ));
1274 }
1275 }
1276 Ok(())
1277 }
1278
1279 pub fn builtin() -> Result<Self, String> {
1283 toml::from_str("upstream_url = \"\"").map_err(|e| format!("built-in config: {e}"))
1284 }
1285
1286 pub fn uses_default_manifest_path(&self) -> bool {
1291 self.bridge_manifest_path == default_bridge_manifest()
1292 }
1293
1294 pub fn uses_default_tool_schema_dir(&self) -> bool {
1297 self.tool_schema_dir == default_tool_schema_dir()
1298 }
1299
1300 pub fn is_default_policy_path(path: &str) -> bool {
1303 default_wasm_policies().iter().any(|entry| entry == path)
1304 }
1305
1306 pub fn effective_drain_timeout(&self) -> std::time::Duration {
1312 let read_timeout_s = self
1313 .upstream_read_timeout_s
1314 .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
1315 let mcp_timeout_s = self
1316 .mcp_request_timeout_s
1317 .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S);
1318 let seconds = self
1319 .shutdown_drain_timeout_s
1320 .unwrap_or_else(|| read_timeout_s.max(mcp_timeout_s).saturating_add(5).max(30));
1321 std::time::Duration::from_secs(seconds)
1322 }
1323
1324 pub fn duplicated_chat_path_segment(&self) -> Option<&str> {
1330 if !self.upstream_chat_path.is_empty()
1333 && self
1334 .upstream_url
1335 .trim_end_matches('/')
1336 .ends_with(&self.upstream_chat_path)
1337 {
1338 return Some(self.upstream_chat_path.trim_start_matches('/'));
1339 }
1340 let first_segment = self
1341 .upstream_chat_path
1342 .trim_start_matches('/')
1343 .split('/')
1344 .next()?;
1345 if first_segment.is_empty() {
1346 return None;
1347 }
1348 let base = self.upstream_url.trim_end_matches('/');
1349 let last_segment = base.rsplit('/').next()?;
1350 if last_segment.contains('.') || last_segment.contains(':') || base.ends_with("//") {
1352 return None;
1353 }
1354 (last_segment == first_segment).then_some(first_segment)
1355 }
1356
1357 pub fn apply_env_overrides(&mut self) {
1363 self.apply_env_overrides_from(|name| std::env::var(name).ok());
1364 }
1365
1366 pub fn apply_env_overrides_from(&mut self, get: impl Fn(&str) -> Option<String>) {
1368 let non_empty = |value: String| if value.is_empty() { None } else { Some(value) };
1369 if let Some(listen) = get("PV_LISTEN").and_then(non_empty) {
1370 self.listen = listen;
1371 }
1372 if let Some(url) = get("PV_UPSTREAM_URL").and_then(non_empty) {
1373 self.upstream_url = url;
1374 }
1375 if let Some(path) = get("PV_UPSTREAM_CHAT_PATH").and_then(non_empty) {
1376 self.upstream_chat_path = path;
1377 }
1378 if let Some(header) = get("PV_UPSTREAM_AUTH_HEADER").and_then(non_empty) {
1379 self.upstream_auth_header = header;
1380 }
1381 if let Some(scheme) = get("PV_UPSTREAM_AUTH_SCHEME") {
1383 self.upstream_auth_scheme = scheme;
1384 }
1385 if let Some(endpoint) = get("PV_STATE_ENDPOINT").and_then(non_empty) {
1386 self.state_endpoint = Some(endpoint);
1387 }
1388 if let Some(endpoint) = get("PV_BRIDGE_ENDPOINT").and_then(non_empty) {
1389 self.bridge_endpoint = Some(endpoint);
1390 }
1391 if let Some(url) = get("PV_QDRANT_URL").and_then(non_empty) {
1392 self.qdrant_url = Some(url);
1393 }
1394 if self.upstream_api_key_env.is_none()
1399 && self.upstream_api_key_file.is_none()
1400 && !self.upstream_authorization_passthrough
1401 {
1402 if let Some(path) = get("PV_UPSTREAM_KEY_FILE").and_then(non_empty) {
1403 self.upstream_api_key_file = Some(path);
1404 }
1405 }
1406 if self.upstream_api_key_env.is_none()
1411 && self.upstream_api_key_file.is_none()
1412 && !self.upstream_authorization_passthrough
1413 && get("PV_UPSTREAM_API_KEY").and_then(non_empty).is_some()
1414 {
1415 self.upstream_api_key_env = Some("PV_UPSTREAM_API_KEY".to_owned());
1416 }
1417 }
1418
1419 fn required_companion<'a>(value: Option<&'a str>, error: &str) -> Result<&'a str, String> {
1422 value.filter(|v| !v.is_empty()).ok_or_else(|| error.to_owned())
1423 }
1424
1425 pub fn bridge(&self) -> Result<BridgeBackend, String> {
1432 const ENDPOINT_REQUIRED: &str = "bridge_endpoint is required for kafka and nats backends";
1433 match self.bridge_backend.as_str() {
1434 "embedded" => Ok(BridgeBackend::Embedded),
1435 "kafka" => Ok(BridgeBackend::Kafka {
1436 endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1437 .to_owned(),
1438 }),
1439 "nats" => Ok(BridgeBackend::Nats {
1440 endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1441 .to_owned(),
1442 }),
1443 other => Err(format!(
1444 "bridge_backend must be embedded|kafka|nats, got {other:?}"
1445 )),
1446 }
1447 }
1448
1449 pub fn state(&self) -> Result<StateBackend, String> {
1453 match self.state_backend.as_str() {
1454 "memory" => Ok(StateBackend::Memory),
1455 "redis" => Ok(StateBackend::Redis {
1456 endpoint: Self::required_companion(
1457 self.state_endpoint.as_deref(),
1458 "state_endpoint is required for the redis backend",
1459 )?
1460 .to_owned(),
1461 }),
1462 other => Err(format!("state_backend must be memory|redis, got {other:?}")),
1463 }
1464 }
1465
1466 pub fn embedder(&self) -> Result<EmbedderBackend, String> {
1469 const PATHS_REQUIRED: &str =
1470 "onnx_model_path and onnx_tokenizer_path are required for the onnx backend";
1471 match self.embedder_backend.as_str() {
1472 "hash" => Ok(EmbedderBackend::Hash),
1473 "onnx" => Ok(EmbedderBackend::Onnx {
1474 model_path: Self::required_companion(self.onnx_model_path.as_deref(), PATHS_REQUIRED)?
1475 .to_owned(),
1476 tokenizer_path: Self::required_companion(
1477 self.onnx_tokenizer_path.as_deref(),
1478 PATHS_REQUIRED,
1479 )?
1480 .to_owned(),
1481 }),
1482 other => Err(format!("embedder_backend must be hash|onnx, got {other:?}")),
1483 }
1484 }
1485
1486 pub fn vector(&self) -> Result<VectorBackend, String> {
1489 match self.vector_backend.as_str() {
1490 "memory" => Ok(VectorBackend::Memory),
1491 "qdrant" => Ok(VectorBackend::Qdrant {
1492 url: Self::required_companion(
1493 self.qdrant_url.as_deref(),
1494 "qdrant_url is required for the qdrant vector backend",
1495 )?
1496 .to_owned(),
1497 }),
1498 other => Err(format!("vector_backend must be memory|qdrant, got {other:?}")),
1499 }
1500 }
1501
1502 pub fn backends(&self) -> Result<ResolvedBackends, Vec<String>> {
1506 let mut errors = Vec::new();
1507 let bridge = self.bridge().map_err(|error| errors.push(error)).ok();
1508 let state = self.state().map_err(|error| errors.push(error)).ok();
1509 let embedder = self.embedder().map_err(|error| errors.push(error)).ok();
1510 let vector = self.vector().map_err(|error| errors.push(error)).ok();
1511 match (bridge, state, embedder, vector) {
1512 (Some(bridge), Some(state), Some(embedder), Some(vector)) => Ok(ResolvedBackends {
1513 bridge,
1514 state,
1515 embedder,
1516 vector,
1517 }),
1518 _ => Err(errors),
1519 }
1520 }
1521
1522 #[must_use]
1537 pub fn unsupported_backend_requirements(&self) -> Vec<String> {
1538 let mut missing = Vec::new();
1539 let mut require = |enabled: bool, field: &str, value: &str, feature: &str| {
1540 if !enabled {
1541 missing.push(format!(
1542 "{field} = {value:?} requires the `{feature}` cargo feature, \
1543 which this build was compiled without (rebuild with \
1544 --features {feature} or full)"
1545 ));
1546 }
1547 };
1548 match self.bridge() {
1549 Ok(BridgeBackend::Kafka { .. }) => {
1550 require(cfg!(feature = "kafka"), "bridge_backend", "kafka", "kafka");
1551 }
1552 Ok(BridgeBackend::Nats { .. }) => {
1553 require(cfg!(feature = "nats"), "bridge_backend", "nats", "nats");
1554 }
1555 Ok(BridgeBackend::Embedded) | Err(_) => {}
1556 }
1557 if matches!(self.state(), Ok(StateBackend::Redis { .. })) {
1558 require(cfg!(feature = "redis"), "state_backend", "redis", "redis");
1559 }
1560 if matches!(self.embedder(), Ok(EmbedderBackend::Onnx { .. })) {
1561 require(cfg!(feature = "onnx"), "embedder_backend", "onnx", "onnx");
1562 }
1563 if matches!(self.vector(), Ok(VectorBackend::Qdrant { .. })) {
1564 require(cfg!(feature = "qdrant"), "vector_backend", "qdrant", "qdrant");
1565 }
1566 if self.otel_tenant_endpoint.is_some() {
1567 require(cfg!(feature = "otel"), "otel_tenant_endpoint", "<set>", "otel");
1568 }
1569 missing
1570 }
1571
1572 pub fn validate(&self) -> Result<(), String> {
1576 if self.config_version != CONFIG_VERSION {
1577 return Err(format!(
1580 "unsupported config_version {} (this build supports {CONFIG_VERSION})",
1581 self.config_version
1582 ));
1583 }
1584 let mut errors = Vec::new();
1585 self.collect_validation_errors(&mut errors);
1586 if errors.len() > 1 {
1587 return Err(format!(
1588 "{} config errors:\n - {}",
1589 errors.len(),
1590 errors.join("\n - ")
1591 ));
1592 }
1593 match errors.pop() {
1594 None => Ok(()),
1595 Some(error) => Err(error),
1596 }
1597 }
1598
1599 fn collect_validation_errors(&self, errors: &mut Vec<String>) {
1607 if self.listen.is_empty() {
1608 errors.push("listen is required (host:port, e.g. 127.0.0.1:8484)".into());
1609 }
1610 match self.listen.rsplit_once(':') {
1614 Some((host, port)) if !host.is_empty() && port.parse::<u16>().is_ok() => {}
1615 _ => {
1616 errors.push(format!(
1617 "listen {:?} must be host:port with a port in 0-65535",
1618 self.listen
1619 ));
1620 }
1621 }
1622 if !self.require_identity && !self.allow_wildcard_bind {
1632 let host = self
1633 .listen
1634 .rsplit_once(':')
1635 .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1636 .unwrap_or("");
1637 let is_wildcard = matches!(host, "*" | "")
1647 || host
1648 .parse::<std::net::IpAddr>()
1649 .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1650 if is_wildcard {
1651 errors.push(format!(
1652 "listen {:?} binds every interface while require_identity = false: any \
1653 peer reaching this host would speak to the proxy anonymously with the \
1654 operator's provider credentials. Either pin listen to a loopback / \
1655 private-network address, set require_identity = true with an \
1656 identity_jwks_url / identity_hmac_secret_file, or set \
1657 allow_wildcard_bind = true if an outer network layer (container port \
1658 map, service mesh, ingress ACL) controls who reaches the listener.",
1659 self.listen
1660 ));
1661 }
1662 }
1663 if self.dashboard_enabled && self.require_identity && !self.allow_wildcard_bind {
1673 let host = self
1674 .listen
1675 .rsplit_once(':')
1676 .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1677 .unwrap_or("");
1678 let is_wildcard = matches!(host, "*" | "")
1679 || host
1680 .parse::<std::net::IpAddr>()
1681 .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1682 if is_wildcard {
1683 errors.push(format!(
1684 "listen {:?} binds every interface with dashboard_enabled = true: the \
1685 dashboard has no identity gate (require_identity covers only the proxy \
1686 API), so any peer can enumerate sessions and download receipts. Set \
1687 dashboard_enabled = false, pin listen, or set allow_wildcard_bind = \
1688 true if an outer network layer controls who reaches the listener.",
1689 self.listen
1690 ));
1691 }
1692 }
1693 if self.reconcile_tick_s == 0 {
1694 errors.push("reconcile_tick_s must be greater than zero".into());
1695 }
1696 if self.session_idle_close_s == 0 {
1697 errors.push(
1698 "session_idle_close_s must be greater than zero (0 would close every open session at each reconcile tick)"
1699 .into(),
1700 );
1701 }
1702 if self.atif_retention_days == Some(0) {
1712 errors.push(
1713 "atif_retention_days = 0 would delete every sealed ATIF pair on the first hourly sweep; \
1714 omit the field to disable retention, or set >= 1 to enable an N-day window"
1715 .into(),
1716 );
1717 }
1718 Self::validate_budget_spec(&self.budget, "budget", errors);
1740 if let Some(pb) = &self.principal_budget {
1741 Self::validate_budget_spec(pb, "principal_budget", errors);
1742 }
1743 if self.upstream_url.is_empty() {
1744 errors.push("upstream_url is required".into());
1745 }
1746 if !self.upstream_chat_path.starts_with('/') {
1747 errors.push(format!(
1748 "upstream_chat_path must start with '/', got {:?}",
1749 self.upstream_chat_path
1750 ));
1751 }
1752 if self.upstream_api_key_env.as_deref().is_some_and(str::is_empty) {
1753 errors.push("upstream_api_key_env must not be empty when set".into());
1754 }
1755 if self.upstream_api_key_file.as_deref().is_some_and(str::is_empty) {
1756 errors.push("upstream_api_key_file must not be empty when set".into());
1757 }
1758 if self.upstream_api_key_env.is_some() && self.upstream_api_key_file.is_some() {
1759 errors.push(
1760 "set only one of upstream_api_key_env or upstream_api_key_file (ambiguous key source)".into(),
1761 );
1762 }
1763 let has_static_key = self.upstream_api_key_env.is_some() || self.upstream_api_key_file.is_some();
1764 if self.upstream_authorization_passthrough && has_static_key {
1765 errors.push(
1766 "upstream_authorization_passthrough conflicts with upstream_api_key_env/file: choose one auth mode"
1767 .into(),
1768 );
1769 }
1770 if self.upstream_authorization_passthrough && self.require_identity {
1771 errors.push(
1772 "upstream_authorization_passthrough cannot be combined with require_identity: the \
1773 Authorization header carries the NHI token, which must never be sent upstream"
1774 .into(),
1775 );
1776 }
1777 if self.ignore_client_authorization && self.require_identity {
1778 errors.push(
1779 "ignore_client_authorization cannot be combined with require_identity: the \
1780 identity validator must see the Authorization header, not discard it"
1781 .into(),
1782 );
1783 }
1784 if self.ignore_client_authorization && self.upstream_authorization_passthrough {
1785 errors.push(
1786 "ignore_client_authorization conflicts with upstream_authorization_passthrough: \
1787 a client Authorization header cannot be both discarded and forwarded upstream"
1788 .into(),
1789 );
1790 }
1791 if axum::http::HeaderName::try_from(self.upstream_auth_header.as_str()).is_err() {
1792 errors.push(format!(
1793 "upstream_auth_header {:?} is not a valid HTTP header name",
1794 self.upstream_auth_header
1795 ));
1796 }
1797 for entry in &self.allowed_hosts {
1798 let unbracketed = entry.trim_start_matches('[').trim_end_matches(']');
1805 let has_port = entry.rsplit_once(':').is_some_and(|(head, port)| {
1806 !head.is_empty()
1807 && !port.is_empty()
1808 && port.chars().all(|c| c.is_ascii_digit())
1809 && !entry.ends_with(']')
1810 });
1811 if entry.trim().is_empty() {
1812 errors.push("allowed_hosts contains an empty entry".into());
1813 } else if entry != entry.trim() {
1814 errors.push(format!(
1815 "allowed_hosts entry {entry:?} has leading/trailing whitespace and would never match a Host header"
1816 ));
1817 } else if has_port && unbracketed.parse::<std::net::Ipv6Addr>().is_err() {
1818 errors.push(format!(
1819 "allowed_hosts entry {entry:?} includes a port; the Host check compares \
1820 hostnames with the port stripped, so this entry would never match — drop the port"
1821 ));
1822 }
1823 }
1824 if self
1825 .upstream_auth_scheme
1826 .bytes()
1827 .any(|byte| !(0x21..=0x7e).contains(&byte))
1828 {
1829 errors.push(
1830 "upstream_auth_scheme must contain only visible ASCII with no spaces (use \"\" for a raw key)"
1831 .into(),
1832 );
1833 }
1834 if self.tool_upstream_bearer_env.is_some() && self.tool_upstream_bearer_file.is_some() {
1835 errors.push(
1836 "set only one of tool_upstream_bearer_env or tool_upstream_bearer_file (ambiguous token source)"
1837 .into(),
1838 );
1839 }
1840 if self
1851 .tool_upstream_bearer_env
1852 .as_deref()
1853 .is_some_and(str::is_empty)
1854 {
1855 errors.push("tool_upstream_bearer_env must not be empty when set".into());
1856 }
1857 if self
1858 .tool_upstream_bearer_file
1859 .as_deref()
1860 .is_some_and(str::is_empty)
1861 {
1862 errors.push("tool_upstream_bearer_file must not be empty when set".into());
1863 }
1864 if (self.tool_upstream_bearer_env.is_some() || self.tool_upstream_bearer_file.is_some())
1865 && self.tool_upstream_url.as_deref().is_none_or(str::is_empty)
1866 {
1867 errors.push("tool_upstream_bearer_env/file requires tool_upstream_url to be set".into());
1868 }
1869 if crate::session::Workflow::parse(&self.default_workflow).is_none() {
1870 errors.push(format!(
1871 "default_workflow must be signed|unsigned, got {:?}",
1872 self.default_workflow
1873 ));
1874 }
1875 if self.principal_budget.is_some() && !self.require_identity && !self.allow_anonymous_principal_budget
1883 {
1884 errors.push(
1885 "principal_budget was set but require_identity = false: every unauthenticated \
1886 request would share a single `anonymous` principal ledger, making the budget \
1887 a fleet-wide bucket instead of the per-principal cap it appears to be. Either \
1888 set require_identity = true, or set allow_anonymous_principal_budget = true to \
1889 acknowledge single-tenant / trusted-network semantics."
1890 .into(),
1891 );
1892 }
1893 if self.require_identity
1894 && self.identity_jwks_url.as_deref().is_none_or(str::is_empty)
1895 && self
1896 .identity_hmac_secret_file
1897 .as_deref()
1898 .is_none_or(str::is_empty)
1899 {
1900 errors.push("require_identity=true needs identity_jwks_url or identity_hmac_secret_file".into());
1901 }
1902 if self.enforce_identity_scopes && !self.require_identity {
1919 errors.push(
1920 "enforce_identity_scopes=true has no effect while require_identity=false: \
1921 unauthenticated requests fall through to the anonymous identity and bypass \
1922 the scope gate entirely. Either set require_identity=true, or set \
1923 enforce_identity_scopes=false to make the posture explicit."
1924 .into(),
1925 );
1926 }
1927 if self.identity_jwks_refresh_s == 0 {
1928 errors.push("identity_jwks_refresh_s must be greater than zero".into());
1929 }
1930 if self.identity_jwks_refresh_s < 30 {
1936 errors.push(format!(
1937 "identity_jwks_refresh_s {} is too aggressive; a value below 30 s hammers the IdP \
1938 without benefit given NHI TTLs measured in minutes",
1939 self.identity_jwks_refresh_s
1940 ));
1941 }
1942 if self.identity_hmac_kid.is_empty() {
1943 errors.push("identity_hmac_kid must not be empty".into());
1944 }
1945 for (field, value) in [
1955 ("chat_scope", &self.chat_scope),
1956 ("session_close_scope", &self.session_close_scope),
1957 ("session_promote_scope", &self.session_promote_scope),
1958 ] {
1959 if value.is_empty() {
1960 errors.push(format!("{field} must not be empty"));
1961 }
1962 if value.bytes().any(|byte| !(0x21..=0x7e).contains(&byte)) {
1963 errors.push(format!(
1964 "{field} {value:?} must be visible ASCII with no whitespace or control bytes"
1965 ));
1966 }
1967 }
1968 if self.worker_channel_capacity == 0 {
1969 errors.push("worker_channel_capacity must be > 0".into());
1970 }
1971 if self.mcp_concurrency == 0 {
1976 errors.push("mcp_concurrency must be > 0 (0 refuses every MCP call at admission)".into());
1977 }
1978 if self.mcp_concurrency > MAX_MCP_CONCURRENCY {
1979 errors.push(format!(
1980 "mcp_concurrency {} exceeds the safety cap of {} — each admitted MCP call \
1981 can buffer up to 16 MiB, so oversized values silently commit multi-GB of \
1982 resident memory to a slow-upstream attack surface",
1983 self.mcp_concurrency, MAX_MCP_CONCURRENCY
1984 ));
1985 }
1986 if crate::provider::adapter_for(&self.provider).is_none() {
1987 errors.push(format!(
1988 "provider must be one of {:?}, got {:?}",
1989 crate::provider::SUPPORTED_PROVIDERS,
1990 self.provider
1991 ));
1992 }
1993 if self
1994 .policy_bundle_file
1995 .as_deref()
1996 .is_some_and(|path| path.trim().is_empty())
1997 {
1998 errors.push("policy_bundle_file must not be empty when set".into());
1999 }
2000 if !(1..=3600).contains(&self.policy_bundle_poll_s) {
2001 errors.push("policy_bundle_poll_s must be between 1 and 3600".into());
2002 }
2003 if self.provider_default_max_tokens == 0 {
2004 errors.push(
2005 "provider_default_max_tokens must be at least 1 (Anthropic requires max_tokens)".into(),
2006 );
2007 }
2008 if self.bridge_manifest_path.is_empty() {
2009 errors.push("bridge_manifest_path is required".into());
2010 }
2011 if self.atif_spool_dir.is_empty() {
2020 errors.push("atif_spool_dir must not be empty".into());
2021 }
2022 if self.bridge_data_dir.is_empty() {
2023 errors.push("bridge_data_dir must not be empty".into());
2024 }
2025 if self.payout_field.is_empty() || !self.payout_field.chars().all(|c| c.is_ascii_graphic()) {
2045 errors.push(
2046 "payout_field must be non-empty visible ASCII (letters, digits, and \
2047 common punctuation only). An empty, whitespace, or invisible-format \
2048 (BOM / zero-width) value would match no argument key, silently disabling \
2049 max_payout_usd_micros — the very silent-bypass class this field exists \
2050 to prevent. Omit the key to use the default, or set it to your tool \
2051 schema's payout argument name."
2052 .into(),
2053 );
2054 }
2055 match self.bridge() {
2064 Err(error) => errors.push(error),
2065 Ok(BridgeBackend::Nats { endpoint }) => {
2066 if !(endpoint.starts_with("nats://") || endpoint.starts_with("tls://")) {
2067 errors.push(format!(
2068 "bridge_endpoint (nats backend) must be nats:// or tls://, got {endpoint:?}"
2069 ));
2070 }
2071 }
2072 Ok(BridgeBackend::Embedded | BridgeBackend::Kafka { .. }) => {}
2076 }
2077 match self.state() {
2078 Err(error) => errors.push(error),
2079 Ok(StateBackend::Redis { endpoint }) => {
2080 let mut previous_tls = None;
2093 for (index, member) in endpoint.split(',').map(str::trim).enumerate() {
2094 let ok = member.starts_with("redis://")
2095 || member.starts_with("rediss://")
2096 || member.starts_with("unix:")
2097 || member.starts_with("redis+unix:");
2098 if !ok {
2099 errors.push(format!(
2100 "state_endpoint (redis backend) member {} must be \
2101 redis://, rediss://, unix:, or redis+unix:",
2102 index + 1
2103 ));
2104 }
2105 let tls = member.starts_with("rediss://");
2106 if tls
2107 && member
2108 .split_once('#')
2109 .is_some_and(|(_, fragment)| fragment == "insecure")
2110 {
2111 errors
2112 .push("state_endpoint cannot disable Redis TLS certificate verification".into());
2113 }
2114 if previous_tls.is_some_and(|previous| previous != tls) {
2115 errors.push("state_endpoint cluster members must all use the same transport".into());
2116 }
2117 previous_tls = Some(tls);
2118 }
2119 }
2120 Ok(StateBackend::Memory) => {}
2121 }
2122 if let Err(error) = self.embedder() {
2123 errors.push(error);
2124 }
2125 match self.vector() {
2126 Err(error) => errors.push(error),
2127 Ok(VectorBackend::Qdrant { url }) => {
2128 if !(url.starts_with("http://") || url.starts_with("https://")) {
2129 errors.push(format!("qdrant_url must be http:// or https://, got {url:?}"));
2130 }
2131 }
2132 Ok(VectorBackend::Memory) => {}
2133 }
2134 if self.onnx_dimension == 0 {
2135 errors.push("onnx_dimension must be greater than zero".into());
2136 }
2137 if self.qdrant_collection.is_empty() {
2138 errors.push("qdrant_collection must not be empty".into());
2139 }
2140 if self.breaker.window == 0 {
2141 errors.push(
2142 "breaker.window must be greater than zero (0 trips on token count alone, ignoring semantic similarity)"
2143 .into(),
2144 );
2145 }
2146 if !self.breaker.delta_epsilon.is_finite() || self.breaker.delta_epsilon <= 0.0 {
2147 errors.push(format!(
2148 "breaker.delta_epsilon must be a finite number greater than zero, got {}",
2149 self.breaker.delta_epsilon
2150 ));
2151 }
2152 if self.worker_channel_capacity > MAX_WORKER_CHANNEL_CAPACITY {
2156 errors.push(format!(
2157 "worker_channel_capacity {} exceeds the safety cap of {} — oversized channels hide real backpressure and let per-shard buffers grow unboundedly under overload",
2158 self.worker_channel_capacity, MAX_WORKER_CHANNEL_CAPACITY
2159 ));
2160 }
2161 if self.max_request_bytes == 0 {
2162 errors.push(
2163 "max_request_bytes must be > 0 — a 0 cap forwards to DefaultBodyLimit::max(0), \
2164 rejecting every non-empty POST body silently"
2165 .into(),
2166 );
2167 }
2168 if self.max_request_bytes > MAX_REQUEST_BYTES_CAP {
2169 errors.push(format!(
2170 "max_request_bytes {} exceeds the safety cap of {} (512 MiB) — a single request should never legitimately need more, and lifting this defeats the sandbox payload guard",
2171 self.max_request_bytes, MAX_REQUEST_BYTES_CAP
2172 ));
2173 }
2174 if self.onnx_dimension > MAX_ONNX_DIMENSION {
2175 errors.push(format!(
2176 "onnx_dimension {} exceeds the safety cap of {} — most sentence-transformer models are <= 4096",
2177 self.onnx_dimension, MAX_ONNX_DIMENSION
2178 ));
2179 }
2180 let mut interval_fields: Vec<(&'static str, u64)> = vec![
2184 ("reconcile_tick_s", self.reconcile_tick_s),
2185 ("session_idle_close_s", self.session_idle_close_s),
2186 ("identity_jwks_refresh_s", self.identity_jwks_refresh_s),
2187 ];
2188 if let Some(read_timeout) = self.upstream_read_timeout_s {
2189 if read_timeout == 0 {
2193 errors.push(
2194 "upstream_read_timeout_s = 0 would time out every upstream read immediately — omit the key to use the built-in 60 s default".into(),
2195 );
2196 }
2197 interval_fields.push(("upstream_read_timeout_s", read_timeout));
2198 }
2199 if let Some(timeout) = self.mcp_request_timeout_s {
2200 if timeout == 0 {
2201 errors.push(
2202 "mcp_request_timeout_s = 0 would time out every outbound MCP request immediately — omit the key to use the built-in 60 s default".into(),
2203 );
2204 }
2205 interval_fields.push(("mcp_request_timeout_s", timeout));
2206 }
2207 if let Some(drain) = self.shutdown_drain_timeout_s {
2208 if drain == 0 {
2209 errors.push(
2210 "shutdown_drain_timeout_s = 0 would abandon every in-flight request at shutdown — omit the key to derive it from the upstream read-idle and MCP request timeouts".into(),
2211 );
2212 }
2213 interval_fields.push(("shutdown_drain_timeout_s", drain));
2214 }
2215 if self.shutdown_ready_drain_s > 0 {
2216 interval_fields.push(("shutdown_ready_drain_s", self.shutdown_ready_drain_s));
2217 }
2218 for (field, value) in interval_fields {
2219 if value > MAX_SECONDS_INTERVAL {
2220 errors.push(format!(
2221 "{field} = {value} exceeds the safety cap of {MAX_SECONDS_INTERVAL} seconds (1 day) — did you mean milliseconds?"
2222 ));
2223 }
2224 }
2225 if !(self.upstream_url.starts_with("http://") || self.upstream_url.starts_with("https://")) {
2242 errors.push(format!(
2243 "upstream_url must be http:// or https://, got {:?}",
2244 self.upstream_url
2245 ));
2246 }
2247 {
2252 let rest = self
2253 .upstream_url
2254 .split_once("://")
2255 .map(|(_, rest)| rest)
2256 .unwrap_or_default();
2257 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2258 if host.is_empty() {
2259 errors.push(format!("upstream_url has no host, got {:?}", self.upstream_url));
2260 }
2261 }
2262 if self.upstream_url.contains(['?', '#']) {
2271 errors.push(format!(
2272 "upstream_url must not carry a query or fragment (request paths are \
2273 appended to it), got {:?}",
2274 self.upstream_url
2275 ));
2276 }
2277 if let Some(tool_upstream) = &self.tool_upstream_url {
2278 if tool_upstream.is_empty() {
2284 errors.push(
2285 "tool_upstream_url must not be empty; omit the field to disable tool forwarding"
2286 .to_owned(),
2287 );
2288 }
2289 if !(tool_upstream.starts_with("http://") || tool_upstream.starts_with("https://")) {
2290 errors.push(format!(
2291 "tool_upstream_url must be http:// or https://, got {tool_upstream:?}"
2292 ));
2293 }
2294 let rest = tool_upstream
2300 .split_once("://")
2301 .map(|(_, rest)| rest)
2302 .unwrap_or_default();
2303 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2304 if !tool_upstream.is_empty() && host.is_empty() {
2305 errors.push(format!("tool_upstream_url has no host, got {tool_upstream:?}"));
2306 }
2307 }
2308 if let Some(url) = self
2320 .identity_jwks_url
2321 .as_deref()
2322 .filter(|value| !value.is_empty())
2323 {
2324 if !(url.starts_with("http://") || url.starts_with("https://")) {
2325 errors.push(format!(
2326 "identity_jwks_url must be http:// or https://, got {url:?}"
2327 ));
2328 }
2329 }
2330 if self.require_intent_mapping && self.intent_map.is_empty() {
2336 errors.push(
2337 "require_intent_mapping is true but intent_map is empty; every tool call \
2338 would be denied with UNMAPPED_TOOL"
2339 .into(),
2340 );
2341 }
2342 if self.intent_token_ttl_s == 0 {
2343 errors.push("intent_token_ttl_s must be > 0".into());
2344 }
2345 if self.intent_token_ttl_s > 900 {
2346 errors.push(
2347 "intent_token_ttl_s exceeds the 900 s ceiling; intent tokens must be short-lived".into(),
2348 );
2349 }
2350 let mut mission_ids = std::collections::HashSet::new();
2351 for mission in self.mission.iter().chain(&self.missions) {
2352 if !mission_ids.insert(mission.id.as_str()) {
2353 errors.push(format!("mission id {:?} is used more than once", mission.id));
2354 }
2355 }
2356 if self.mission.as_ref().is_some_and(MissionConfig::has_selectors) {
2357 errors.push(
2358 "[mission] applies to every caller and takes no agents/charters/subjects; \
2359 put per-agent missions in [[missions]]"
2360 .into(),
2361 );
2362 }
2363 for mission in &self.missions {
2364 if !mission.has_selectors() {
2365 errors.push(format!(
2366 "[[missions]] entry {:?} has no agents, charters, or subjects; use [mission] \
2367 for a mission that applies to every caller",
2368 mission.id
2369 ));
2370 }
2371 if mission.allowed_intents.is_empty() {
2372 errors.push(format!(
2373 "mission {:?} has no allowed_intents; every call would be denied",
2374 mission.id
2375 ));
2376 }
2377 }
2378 if let Some(workload) = &self.workload_identity {
2379 if workload.ca_file.trim().is_empty() {
2380 errors.push("workload_identity.ca_file must not be empty".into());
2381 }
2382 if !(60..=900).contains(&workload.token_ttl_s) {
2383 errors.push("workload_identity.token_ttl_s must be between 60 and 900".into());
2384 }
2385 if self.token_exchange_seed_file.is_none() {
2386 errors.push(
2387 "[workload_identity] requires token_exchange_seed_file: the gateway signs agent \
2388 tokens with that key"
2389 .into(),
2390 );
2391 }
2392 if self.workload_identities.is_empty() {
2393 errors
2394 .push("[workload_identity] is set but no [[workload_identities]] are registered".into());
2395 }
2396 } else if !self.workload_identities.is_empty() {
2397 errors.push("[[workload_identities]] requires [workload_identity]".into());
2398 }
2399 let human = self
2401 .identity_human_subject_pattern
2402 .as_deref()
2403 .and_then(|pattern| {
2404 let mut validator = pv_identity::IdentityValidator::new(&self.audience);
2405 validator
2406 .set_human_subject_pattern(pattern)
2407 .ok()
2408 .map(|()| validator)
2409 });
2410 let mut workload_names = std::collections::HashSet::new();
2411 for workload in &self.workload_identities {
2412 if !workload_names.insert(workload.name.as_str()) {
2413 errors.push(format!(
2414 "workload name {:?} is used more than once",
2415 workload.name
2416 ));
2417 }
2418 if workload.name.is_empty()
2419 || !workload
2420 .name
2421 .bytes()
2422 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
2423 {
2424 errors.push(format!(
2425 "workload name {:?} must be ASCII letters, digits, '-', '_', or '.'",
2426 workload.name
2427 ));
2428 }
2429 if workload.cf_app_guid.is_none()
2430 && workload.cf_space_guid.is_none()
2431 && workload.cf_org_guid.is_none()
2432 {
2433 errors.push(format!(
2434 "workload {:?} needs cf_app_guid, cf_space_guid, or cf_org_guid",
2435 workload.name
2436 ));
2437 }
2438 if workload.sub.trim().is_empty() {
2439 errors.push(format!(
2440 "workload {:?} needs `sub`: the human principal its agents act for",
2441 workload.name
2442 ));
2443 } else if human
2444 .as_ref()
2445 .is_some_and(|validator| !validator.subject_is_human(&workload.sub))
2446 {
2447 errors.push(format!(
2448 "workload {:?} sub {:?} does not match identity_human_subject_pattern",
2449 workload.name, workload.sub
2450 ));
2451 }
2452 if workload.charter.trim().is_empty() || workload.scopes.is_empty() {
2453 errors.push(format!(
2454 "workload {:?} needs a charter and at least one scope",
2455 workload.name
2456 ));
2457 }
2458 }
2459 if let Some(authzen) = &self.authzen {
2460 for (field, endpoint) in
2461 std::iter::once(("authzen.evaluation_endpoint", Some(&authzen.evaluation_endpoint))).chain(
2462 std::iter::once((
2463 "authzen.evaluations_endpoint",
2464 authzen.evaluations_endpoint.as_ref(),
2465 )),
2466 )
2467 {
2468 if let Some(endpoint) = endpoint {
2469 if let Err(error) = crate::backend::validate_http_endpoint(endpoint, field) {
2470 errors.push(error);
2471 }
2472 }
2473 }
2474 if !(1..=10_000).contains(&authzen.timeout_ms) {
2475 errors.push("authzen.timeout_ms must be between 1 and 10000".into());
2476 }
2477 if authzen
2478 .auth_file
2479 .as_deref()
2480 .is_some_and(|path| path.trim().is_empty())
2481 {
2482 errors.push("authzen.auth_file must not be empty".into());
2483 }
2484 }
2485 if let Some(ref mission) = self.mission {
2486 let now_s = pv_core::time::now_ms() / 1000;
2487 if mission.expires_at <= now_s {
2488 tracing::warn!(
2489 mission_id = %mission.id,
2490 expires_at = mission.expires_at,
2491 now = now_s,
2492 "mission is already expired; the PDP will deny every call \
2493 until the mission is removed or updated"
2494 );
2495 }
2496 if mission.allowed_intents.is_empty() {
2497 errors.push(format!(
2498 "mission {:?} has no allowed_intents; every call would be denied",
2499 mission.id
2500 ));
2501 }
2502 }
2503
2504 if self
2506 .token_exchange_seed_file
2507 .as_ref()
2508 .is_some_and(|path| path.trim().is_empty())
2509 {
2510 errors.push(
2511 "token_exchange_seed_file must not be empty; omit it when token signing is disabled".into(),
2512 );
2513 }
2514 if self.token_exchange_enabled && self.token_exchange_seed_file.is_none() {
2515 errors.push(
2516 "token_exchange_enabled is true but token_exchange_seed_file is not set; \
2517 the /v1/token endpoint has no key to sign exchanged tokens with"
2518 .into(),
2519 );
2520 }
2521 let has_identity_source = self
2522 .identity_jwks_url
2523 .as_deref()
2524 .is_some_and(|url| !url.is_empty())
2525 || self
2526 .identity_hmac_secret_file
2527 .as_deref()
2528 .is_some_and(|path| !path.is_empty());
2529 if self.token_exchange_enabled && !has_identity_source {
2530 errors.push(
2531 "token_exchange_enabled is true but neither identity_jwks_url nor \
2532 identity_hmac_secret_file is set; the /v1/token endpoint cannot validate \
2533 subject tokens"
2534 .into(),
2535 );
2536 }
2537 if self.token_exchange_enabled && self.backends.is_empty() {
2538 errors.push(
2539 "token_exchange_enabled is true but no [[backends]] are configured; the \
2540 gateway signs exchanged tokens only for configured backend names, so every \
2541 exchange would be refused with invalid_target"
2542 .into(),
2543 );
2544 }
2545 if self.token_exchange_ttl_s == 0 {
2546 errors.push("token_exchange_ttl_s must be > 0".into());
2547 }
2548 if self.token_exchange_ttl_s > 900 {
2549 errors.push(format!(
2550 "token_exchange_ttl_s = {} exceeds the 900 s ceiling; \
2551 exchanged tokens must be short-lived",
2552 self.token_exchange_ttl_s
2553 ));
2554 }
2555 let has_exchange_backend = self
2556 .backends
2557 .iter()
2558 .any(|b| matches!(b.auth, BackendAuth::Exchange));
2559 if has_exchange_backend && self.token_exchange_seed_file.is_none() {
2560 errors.push(
2561 "a backend uses auth = \"exchange\" but token_exchange_seed_file is not set; \
2562 the gateway cannot sign exchanged tokens without a seed"
2563 .into(),
2564 );
2565 }
2566 if has_exchange_backend && !self.require_identity {
2567 errors.push(
2568 "a backend uses auth = \"exchange\" but require_identity is false; \
2569 the gateway needs a validated caller identity to perform token exchange"
2570 .into(),
2571 );
2572 }
2573 if self
2574 .backends
2575 .iter()
2576 .any(|b| matches!(b.auth, BackendAuth::ForwardToken))
2577 && !self.require_identity
2578 {
2579 errors.push(
2580 "a backend uses auth = \"forward_token\" but require_identity is false; \
2581 only a validated caller token may be forwarded to a backend"
2582 .into(),
2583 );
2584 }
2585 if let Some(pattern) = &self.identity_human_subject_pattern {
2586 if let Err(error) =
2587 pv_identity::IdentityValidator::new(&self.audience).set_human_subject_pattern(pattern)
2588 {
2589 errors.push(format!("identity_human_subject_pattern: {error}"));
2590 }
2591 }
2592 for origin in &self.mcp_allowed_origins {
2593 if let Err(error) = validate_origin(origin) {
2594 errors.push(format!("mcp_allowed_origins entry {origin:?}: {error}"));
2595 }
2596 }
2597 errors.extend(crate::backend::validate_configs(&self.backends));
2598 if self.require_private_backends {
2599 for (field, url) in self
2600 .backends
2601 .iter()
2602 .map(|backend| (format!("backend {:?} url", backend.name), backend.url.as_str()))
2603 .chain(
2604 self.tool_upstream_url
2605 .iter()
2606 .map(|url| ("tool_upstream_url".to_owned(), url.as_str())),
2607 )
2608 {
2609 if let Err(error) = crate::backend::validate_private_endpoint(url, &field) {
2610 errors.push(error);
2611 }
2612 }
2613 }
2614 if self.backends.iter().any(|backend| backend.name == self.audience) {
2615 errors.push(format!(
2616 "a backend is named {:?}, the same as `audience`; tokens this gateway signs for that \
2617 backend would also be valid inbound identities, so choose another name",
2618 self.audience
2619 ));
2620 }
2621 if !self.redaction_patterns.is_empty() || !self.redaction_paths.is_empty() {
2622 if let Err(error) = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
2623 regex_patterns: self.redaction_patterns.clone(),
2624 pointer_paths: self.redaction_paths.clone(),
2625 ..pv_redact::RedactionConfig::default()
2626 }) {
2627 errors.push(format!("invalid redaction configuration: {error}"));
2628 }
2629 }
2630 if let Some(endpoint) = &self.otel_tenant_endpoint {
2631 if let Err(error) = crate::backend::validate_http_endpoint(endpoint, "otel_tenant_endpoint") {
2632 errors.push(error);
2633 }
2634 }
2635 if let Some(path) = &self.otel_tenant_auth_file {
2636 if path.trim().is_empty() {
2637 errors.push("otel_tenant_auth_file must not be empty".into());
2638 }
2639 if self.otel_tenant_endpoint.is_none() {
2640 errors.push("otel_tenant_auth_file requires otel_tenant_endpoint".into());
2641 }
2642 }
2643 if self.revocation_audit_per_minute == 0 {
2644 errors.push("revocation_audit_per_minute must be > 0".into());
2645 }
2646 let valid_digest = |digest: &str| digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit());
2647 let mut operator_names = std::collections::HashSet::new();
2648 let mut introspection_backends = std::collections::HashSet::new();
2649 let mut credential_digests = std::collections::HashSet::new();
2650 if !self.operator_tokens.is_empty() && !has_identity_source {
2651 errors.push("operator_tokens requires identity_jwks_url or identity_hmac_secret_file".into());
2652 }
2653 if !self.introspection_tokens.is_empty() {
2654 if !has_identity_source {
2655 errors.push(
2656 "introspection_tokens requires identity_jwks_url or identity_hmac_secret_file".into(),
2657 );
2658 }
2659 if self
2660 .token_exchange_seed_file
2661 .as_deref()
2662 .is_none_or(|path| path.trim().is_empty())
2663 {
2664 errors.push("introspection_tokens requires token_exchange_seed_file".into());
2665 }
2666 }
2667 for token in &self.operator_tokens {
2668 if token.name.trim().is_empty() || !operator_names.insert(&token.name) {
2669 errors.push("operator_tokens names must be non-empty and unique".into());
2670 }
2671 if !valid_digest(&token.sha256) {
2672 errors.push(format!(
2673 "operator_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2674 token.name
2675 ));
2676 }
2677 if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2678 errors.push(
2679 "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2680 .into(),
2681 );
2682 }
2683 }
2684 for token in &self.introspection_tokens {
2685 if !introspection_backends.insert(&token.backend) {
2686 errors.push(format!(
2687 "duplicate introspection_tokens backend {:?}",
2688 token.backend
2689 ));
2690 }
2691 let known_backend = self.backends.iter().any(|backend| backend.name == token.backend)
2692 || (token.backend == crate::backend::DEFAULT_BACKEND_NAME
2693 && self.backends.is_empty()
2694 && self.tool_upstream_url.is_some());
2695 if !known_backend {
2696 errors.push(format!(
2697 "introspection_tokens references unknown backend {:?}",
2698 token.backend
2699 ));
2700 }
2701 if !valid_digest(&token.sha256) {
2702 errors.push(format!(
2703 "introspection_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2704 token.backend
2705 ));
2706 }
2707 if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2708 errors.push(
2709 "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2710 .into(),
2711 );
2712 }
2713 }
2714 }
2715
2716 fn validate_budget_spec(spec: &pv_state::BudgetSpec, scope: &str, errors: &mut Vec<String>) {
2722 if spec.max_tokens == Some(0) {
2723 errors.push(format!(
2724 "{scope}.max_tokens must be > 0 (Some(0) refuses every non-zero spend, 429-storming \
2725 every non-empty chat request); omit the field to disable the cap"
2726 ));
2727 }
2728 if spec.max_total_tool_calls == Some(0) {
2729 errors.push(format!(
2730 "{scope}.max_total_tool_calls must be > 0 (Some(0) refuses every tool call); \
2731 omit the field to disable the cap"
2732 ));
2733 }
2734 if spec.max_payout_usd_micros == Some(0) {
2735 errors.push(format!(
2736 "{scope}.max_payout_usd_micros must be > 0 (Some(0) refuses every non-zero payout); \
2737 omit the field to disable the cap"
2738 ));
2739 }
2740 for (tool, cap) in &spec.max_tool_calls {
2741 if *cap == 0 {
2742 errors.push(format!(
2743 "{scope}.max_tool_calls[{tool:?}] must be > 0 (0 silently disables the tool with \
2744 no error surface); omit the entry to leave the tool uncapped, or set >= 1"
2745 ));
2746 }
2747 }
2748 }
2749}
2750
2751#[cfg(test)]
2752mod tests {
2753 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2754
2755 #[test]
2756 fn security_config_rejects_backend_routing_and_redaction_errors_structurally() {
2757 let base = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2758 base.validate().unwrap();
2759 for (name, url, tools, expected) in [
2760 ("", "http://backend", vec!["read"], "backend name"),
2761 ("default", "http://backend", vec!["read"], "reserved"),
2762 ("tools", "https:///mcp", vec!["read"], "host"),
2763 ("tools", "file:///tmp/mcp", vec!["read"], "http"),
2764 ("tools", "http://backend:bad", vec!["read"], "valid URL"),
2765 ("tools", "http://backend", vec!["Read"], "lowercase ASCII"),
2766 ("tools", "http://backend", vec!["read", "read"], "repeated"),
2767 ] {
2768 let mut config = base.clone();
2769 config.backends = vec![BackendConfig {
2770 name: name.into(),
2771 url: url.into(),
2772 auth: BackendAuth::None,
2773 tools: tools.into_iter().map(str::to_owned).collect(),
2774 transport: Default::default(),
2775 }];
2776 assert!(config.validate().unwrap_err().contains(expected));
2777 }
2778 for (patterns, paths) in [
2779 (vec!["["], vec![]),
2780 (vec!["REDACTED"], vec![]),
2781 (vec![], vec!["password"]),
2782 ] {
2783 let mut config = base.clone();
2784 config.redaction_patterns = patterns.into_iter().map(str::to_owned).collect();
2785 config.redaction_paths = paths.into_iter().map(str::to_owned).collect();
2786 assert!(config.validate().unwrap_err().contains("redaction"));
2787 }
2788 for (ttl, expected) in [(0, false), (900, true), (901, false), (u64::MAX, false)] {
2789 let mut config = base.clone();
2790 config.intent_token_ttl_s = ttl;
2791 assert_eq!(config.validate().is_ok(), expected);
2792 }
2793 let mut empty_seed = base;
2794 empty_seed.token_exchange_seed_file = Some(String::new());
2795 assert!(empty_seed
2796 .validate()
2797 .unwrap_err()
2798 .contains("token_exchange_seed_file"));
2799 }
2800
2801 #[test]
2802 fn security_config_separates_structural_otel_checks_from_build_features() {
2803 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2804 config.otel_tenant_endpoint = Some("https://tenant.example/v1/traces".into());
2805 config.validate().unwrap();
2806 assert_eq!(
2807 config
2808 .unsupported_backend_requirements()
2809 .iter()
2810 .any(|error| error.contains("otel")),
2811 !cfg!(feature = "otel")
2812 );
2813 config.otel_tenant_endpoint = Some("tenant.example/traces".into());
2814 assert!(config.validate().unwrap_err().contains("otel_tenant_endpoint"));
2815 config.otel_tenant_endpoint = None;
2816 config.otel_tenant_auth_file = Some("/run/secrets/tenant".into());
2817 assert!(config
2818 .validate()
2819 .unwrap_err()
2820 .contains("requires otel_tenant_endpoint"));
2821 }
2822
2823 #[test]
2824 fn security_config_validates_operator_and_introspection_credentials() {
2825 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2826 config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2827 config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2828 config.backends = vec![BackendConfig {
2829 name: "db".into(),
2830 url: "http://db".into(),
2831 auth: BackendAuth::None,
2832 tools: vec![],
2833 transport: Default::default(),
2834 }];
2835 config.operator_tokens = vec![OperatorTokenConfig {
2836 name: "operator".into(),
2837 sha256: "ab".repeat(32),
2838 }];
2839 config.introspection_tokens = vec![IntrospectionTokenConfig {
2840 backend: "db".into(),
2841 sha256: "cd".repeat(32),
2842 }];
2843 config.validate().unwrap();
2844 let good = config.clone();
2845 config
2846 .operator_tokens
2847 .push(config.operator_tokens.first().unwrap().clone());
2848 assert!(config.validate().unwrap_err().contains("unique"));
2849 config = good.clone();
2850 config
2851 .introspection_tokens
2852 .push(config.introspection_tokens.first().unwrap().clone());
2853 assert!(config
2854 .validate()
2855 .unwrap_err()
2856 .contains("duplicate introspection_tokens"));
2857 config = good.clone();
2858 config.introspection_tokens.first_mut().unwrap().backend = "unknown".into();
2859 assert!(config.validate().unwrap_err().contains("unknown backend"));
2860 for digest in ["f".repeat(63), "g".repeat(64), "f".repeat(65)] {
2861 config = good.clone();
2862 config.operator_tokens.first_mut().unwrap().sha256 = digest.clone();
2863 config.introspection_tokens.first_mut().unwrap().sha256 = digest;
2864 let errors = config.validate().unwrap_err();
2865 assert!(errors.contains("operator_tokens"));
2866 assert!(errors.contains("introspection_tokens"));
2867 }
2868 config = good;
2869 config.revocation_audit_per_minute = 0;
2870 assert!(config
2871 .validate()
2872 .unwrap_err()
2873 .contains("revocation_audit_per_minute"));
2874 }
2875
2876 #[test]
2877 fn security_config_rejects_reused_control_credentials_and_missing_identity_sources() {
2878 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2879 config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2880 config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2881 config.backends = ["db", "search"]
2882 .into_iter()
2883 .map(|name| BackendConfig {
2884 name: name.into(),
2885 url: format!("http://{name}"),
2886 auth: BackendAuth::None,
2887 tools: vec![name.into()],
2888 transport: Default::default(),
2889 })
2890 .collect();
2891 config.operator_tokens = vec![OperatorTokenConfig {
2892 name: "operator".into(),
2893 sha256: "ab".repeat(32),
2894 }];
2895 config.introspection_tokens = vec![IntrospectionTokenConfig {
2896 backend: "db".into(),
2897 sha256: "cd".repeat(32),
2898 }];
2899 config.validate().unwrap();
2900 let valid = config.clone();
2901
2902 config.operator_tokens.push(OperatorTokenConfig {
2903 name: "second-operator".into(),
2904 sha256: "AB".repeat(32),
2905 });
2906 assert!(config.validate().unwrap_err().contains("distinct sha256"));
2907 config = valid.clone();
2908 config.introspection_tokens.push(IntrospectionTokenConfig {
2909 backend: "search".into(),
2910 sha256: "CD".repeat(32),
2911 });
2912 assert!(config.validate().unwrap_err().contains("distinct sha256"));
2913 config = valid.clone();
2914 config.introspection_tokens.first_mut().unwrap().sha256 = config
2915 .operator_tokens
2916 .first()
2917 .unwrap()
2918 .sha256
2919 .to_ascii_uppercase();
2920 assert!(config.validate().unwrap_err().contains("distinct sha256"));
2921
2922 config = valid.clone();
2923 config.identity_hmac_secret_file = None;
2924 let errors = config.validate().unwrap_err();
2925 assert!(errors.contains("operator_tokens requires identity"));
2926 assert!(errors.contains("introspection_tokens requires identity"));
2927 config = valid;
2928 config.token_exchange_seed_file = None;
2929 assert!(config
2930 .validate()
2931 .unwrap_err()
2932 .contains("introspection_tokens requires token_exchange_seed_file"));
2933 config.introspection_tokens.clear();
2934 config.validate().unwrap();
2935 }
2936
2937 #[cfg(unix)]
2938 #[test]
2939 fn security_config_loads_tenant_auth_with_owner_only_permissions() {
2940 use std::os::unix::fs::PermissionsExt;
2941 let directory = tempfile::tempdir().unwrap();
2942 let path = directory.path().join("tenant-token");
2943 std::fs::write(&path, "tenant-secret\n").unwrap();
2944 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2945 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2946 config.otel_tenant_auth_file = Some(path.to_string_lossy().into_owned());
2947 let header = config.tenant_otel_authorization().unwrap().unwrap();
2948 assert_eq!(header, "Bearer tenant-secret");
2949 assert!(header.is_sensitive());
2950 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2951 assert!(config.tenant_otel_authorization().is_err());
2952 }
2953
2954 #[test]
2962 fn configuration_reference_documents_every_config_field() {
2963 let source = include_str!("config.rs");
2964 let reference = include_str!("../../../docs/reference/CONFIGURATION.md");
2965 let body = source
2966 .split_once("pub struct HarnessConfig {")
2967 .map(|(_, rest)| rest)
2968 .and_then(|rest| rest.split_once("\n}"))
2969 .map(|(body, _)| body)
2970 .unwrap();
2971 let fields: Vec<&str> = body
2972 .lines()
2973 .filter_map(|line| line.trim().strip_prefix("pub "))
2974 .filter_map(|rest| rest.split_once(':'))
2975 .map(|(name, _)| name.trim())
2976 .collect();
2977 assert!(
2978 fields.len() >= 60,
2979 "field extraction broke — found only {} fields",
2980 fields.len()
2981 );
2982 for field in fields {
2983 assert!(
2984 reference.contains(&format!("`{field}`")) || reference.contains(&format!("`[{field}]`")),
2985 "config field `{field}` is not documented in docs/reference/CONFIGURATION.md"
2986 );
2987 }
2988 }
2989
2990 use super::*;
2991
2992 #[test]
2993 fn minimal_toml_parses_with_defaults() {
2994 let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2995 assert_eq!(cfg.config_version, CONFIG_VERSION);
2996 assert_eq!(cfg.default_workflow, "unsigned");
2997 assert_eq!(cfg.session_idle_close_s, 900);
2998 assert!(cfg.compression_enabled);
2999 assert_eq!(cfg.upstream_chat_path, "/v1/chat/completions");
3000 assert_eq!(cfg.upstream_auth_header, "authorization");
3001 assert_eq!(cfg.upstream_auth_scheme, "Bearer");
3002 assert!(cfg.upstream_api_key_env.is_none());
3003 assert!(!cfg.upstream_authorization_passthrough);
3004 }
3005
3006 #[test]
3013 fn validate_refuses_unmatchable_allowed_hosts_entries() {
3014 for (entry, needle) in [
3015 ("localhost:8484", "includes a port"),
3016 ("[::1]:8484", "includes a port"),
3017 (" localhost", "whitespace"),
3018 ("", "empty"),
3019 ] {
3020 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3021 cfg.allowed_hosts = vec![entry.to_owned()];
3022 let error = cfg.validate().unwrap_err();
3023 assert!(
3024 error.contains(needle),
3025 "entry {entry:?}: expected {needle:?} in: {error}"
3026 );
3027 }
3028 for entry in [
3031 "localhost",
3032 "127.0.0.1",
3033 "::1",
3034 "2001:db8::1",
3035 "[::1]",
3036 "proofvisor.internal",
3037 ] {
3038 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3039 cfg.allowed_hosts = vec![entry.to_owned()];
3040 cfg.validate()
3041 .unwrap_or_else(|e| panic!("entry {entry:?} must pass: {e}"));
3042 }
3043 }
3044
3045 #[test]
3046 fn validate_reports_all_errors_at_once() {
3047 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3048 cfg.listen = String::new();
3049 cfg.bridge_backend = "carrier-pigeon".to_owned();
3050 cfg.state_backend = "abacus".to_owned();
3051 let error = cfg.validate().unwrap_err();
3052 assert!(
3053 error.contains("config errors"),
3054 "aggregate header missing: {error}"
3055 );
3056 for needle in ["listen", "bridge_backend", "state_backend"] {
3057 assert!(error.contains(needle), "missing {needle} in: {error}");
3058 }
3059 }
3060
3061 #[test]
3065 fn validate_single_error_keeps_bare_message() {
3066 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3067 cfg.state_backend = "abacus".to_owned();
3068 let error = cfg.validate().unwrap_err();
3069 assert!(
3070 !error.contains("config errors"),
3071 "single error must not aggregate: {error}"
3072 );
3073 assert!(error.contains("state_backend"), "must name the field: {error}");
3074 }
3075
3076 #[test]
3079 fn unsupported_provider_is_refused_at_validation() {
3080 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3081 assert_eq!(cfg.provider, "openai");
3082 cfg.provider = "google".to_owned();
3083 let error = cfg.validate().unwrap_err();
3084 assert!(error.contains("provider"), "must name the field: {error}");
3085 assert!(error.contains("openai"), "must name the supported set: {error}");
3086 cfg.provider = "anthropic".to_owned();
3087 assert!(
3088 cfg.validate().is_ok(),
3089 "anthropic is a supported dialect (S3 step 2)"
3090 );
3091 }
3092
3093 #[test]
3096 fn default_backends_never_report_unsupported_requirements() {
3097 let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3098 assert!(cfg.unsupported_backend_requirements().is_empty());
3099 }
3100
3101 #[test]
3108 fn unsupported_backend_requirements_track_build_features() {
3109 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3110 cfg.bridge_backend = "kafka".to_owned();
3111 cfg.bridge_endpoint = Some("broker:9092".to_owned());
3112 cfg.state_backend = "redis".to_owned();
3113 cfg.state_endpoint = Some("redis://cache:6379".to_owned());
3114 cfg.embedder_backend = "onnx".to_owned();
3115 cfg.onnx_model_path = Some("model.onnx".to_owned());
3116 cfg.onnx_tokenizer_path = Some("tokenizer.json".to_owned());
3117 cfg.vector_backend = "qdrant".to_owned();
3118 cfg.qdrant_url = Some("http://vectors:6333".to_owned());
3119 let missing = cfg.unsupported_backend_requirements();
3120 for (enabled, feature) in [
3121 (cfg!(feature = "kafka"), "kafka"),
3122 (cfg!(feature = "redis"), "redis"),
3123 (cfg!(feature = "onnx"), "onnx"),
3124 (cfg!(feature = "qdrant"), "qdrant"),
3125 ] {
3126 let reported = missing
3127 .iter()
3128 .any(|message| message.contains(&format!("`{feature}`")));
3129 assert_eq!(
3130 reported, !enabled,
3131 "feature {feature}: enabled={enabled} but reported={reported} in {missing:?}"
3132 );
3133 }
3134 }
3135
3136 #[test]
3140 fn backend_missing_companion_fails_with_named_error() {
3141 for (extra, companion) in [
3142 ("bridge_backend = \"kafka\"", "bridge_endpoint"),
3143 ("bridge_backend = \"nats\"", "bridge_endpoint"),
3144 (
3145 "bridge_backend = \"kafka\"\nbridge_endpoint = \"\"",
3146 "bridge_endpoint",
3147 ),
3148 ("state_backend = \"redis\"", "state_endpoint"),
3149 ("vector_backend = \"qdrant\"", "qdrant_url"),
3150 ("embedder_backend = \"onnx\"", "onnx_model_path"),
3151 (
3152 "embedder_backend = \"onnx\"\nonnx_model_path = \"model.onnx\"",
3154 "onnx_tokenizer_path",
3155 ),
3156 ] {
3157 let err =
3158 HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{extra}")).unwrap_err();
3159 assert!(
3160 err.contains(companion) && err.contains("required"),
3161 "{extra}: error must name {companion}: {err}"
3162 );
3163 }
3164 }
3165
3166 #[test]
3170 fn unknown_backend_value_names_legal_set() {
3171 for (field, bogus, legal_set) in [
3172 ("bridge_backend", "carrier-pigeon", "embedded|kafka|nats"),
3173 ("state_backend", "abacus", "memory|redis"),
3174 ("embedder_backend", "vibes", "hash|onnx"),
3175 ("vector_backend", "faiss", "memory|qdrant"),
3176 ] {
3177 let err =
3178 HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{field} = \"{bogus}\""))
3179 .unwrap_err();
3180 assert!(err.contains(field), "must name the field: {err}");
3181 assert!(err.contains(legal_set), "must name the legal set: {err}");
3182 assert!(err.contains(bogus), "must echo the typo: {err}");
3183 }
3184 }
3185
3186 #[test]
3189 fn full_feature_config_resolves_to_typed_backends() {
3190 let cfg = HarnessConfig::from_toml(
3191 r#"upstream_url = "https://api"
3192 bridge_backend = "kafka"
3193 bridge_endpoint = "broker-1:9092,broker-2:9092"
3194 state_backend = "redis"
3195 state_endpoint = "redis://cache:6379"
3196 embedder_backend = "onnx"
3197 onnx_model_path = "models/model.onnx"
3198 onnx_tokenizer_path = "models/tokenizer.json"
3199 vector_backend = "qdrant"
3200 qdrant_url = "http://vectors:6333""#,
3201 )
3202 .unwrap();
3203 let backends = cfg.backends().unwrap();
3204 assert_eq!(
3205 backends.bridge,
3206 BridgeBackend::Kafka {
3207 endpoint: "broker-1:9092,broker-2:9092".to_owned()
3208 }
3209 );
3210 assert_eq!(
3211 backends.state,
3212 StateBackend::Redis {
3213 endpoint: "redis://cache:6379".to_owned()
3214 }
3215 );
3216 assert_eq!(
3217 backends.embedder,
3218 EmbedderBackend::Onnx {
3219 model_path: "models/model.onnx".to_owned(),
3220 tokenizer_path: "models/tokenizer.json".to_owned()
3221 }
3222 );
3223 assert_eq!(
3224 backends.vector,
3225 VectorBackend::Qdrant {
3226 url: "http://vectors:6333".to_owned()
3227 }
3228 );
3229 let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3231 let backends = cfg.backends().unwrap();
3232 assert_eq!(backends.bridge, BridgeBackend::Embedded);
3233 assert_eq!(backends.state, StateBackend::Memory);
3234 assert_eq!(backends.embedder, EmbedderBackend::Hash);
3235 assert_eq!(backends.vector, VectorBackend::Memory);
3236 }
3237
3238 #[test]
3241 fn backends_accumulates_all_resolution_errors() {
3242 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3243 cfg.bridge_backend = "carrier-pigeon".to_owned();
3244 cfg.state_backend = "redis".to_owned(); cfg.vector_backend = "faiss".to_owned();
3246 let errors = cfg.backends().unwrap_err();
3247 assert_eq!(errors.len(), 3, "{errors:?}");
3248 let joined = errors.join("\n");
3249 for needle in ["bridge_backend", "state_endpoint", "vector_backend"] {
3250 assert!(joined.contains(needle), "missing {needle} in {errors:?}");
3251 }
3252 }
3253
3254 #[test]
3257 fn unknown_config_key_is_rejected_with_its_name() {
3258 let err = HarnessConfig::from_toml(
3259 r#"upstream_url = "https://api.openai.com"
3260 idel_timeout_s = 10"#,
3261 )
3262 .unwrap_err();
3263 assert!(err.contains("idel_timeout_s"), "error must name the key: {err}");
3264 }
3265
3266 #[test]
3269 fn upstream_auth_invariants_enforced() {
3270 assert!(HarnessConfig::from_toml(
3272 r#"upstream_url = "https://api.openai.com"
3273 upstream_api_key_env = "OPENAI_API_KEY"
3274 upstream_api_key_file = "/etc/key""#
3275 )
3276 .is_err());
3277 assert!(HarnessConfig::from_toml(
3279 r#"upstream_url = "https://api.openai.com"
3280 upstream_api_key_env = "OPENAI_API_KEY"
3281 upstream_authorization_passthrough = true"#
3282 )
3283 .is_err());
3284 assert!(HarnessConfig::from_toml(
3286 r#"upstream_url = "https://api.openai.com"
3287 upstream_authorization_passthrough = true
3288 require_identity = true
3289 identity_hmac_secret_file = "/run/secrets/hmac""#
3290 )
3291 .is_err());
3292 assert!(HarnessConfig::from_toml(
3294 r#"upstream_url = "https://api.openai.com"
3295 upstream_auth_header = "not a header""#
3296 )
3297 .is_err());
3298 assert!(HarnessConfig::from_toml(
3300 r#"upstream_url = "https://api.openai.com"
3301 upstream_auth_scheme = "Bearer extra""#
3302 )
3303 .is_err());
3304 assert!(HarnessConfig::from_toml(
3306 r#"upstream_url = "https://api.openai.com"
3307 upstream_chat_path = "v1/chat/completions""#
3308 )
3309 .is_err());
3310 assert!(HarnessConfig::from_toml(
3312 r#"upstream_url = "https://api.openai.com"
3313 tool_upstream_bearer_env = "MCP_TOKEN""#
3314 )
3315 .is_err());
3316 let azure = HarnessConfig::from_toml(
3318 r#"upstream_url = "https://res.openai.azure.com"
3319 upstream_chat_path = "/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
3320 upstream_api_key_env = "AZURE_OPENAI_API_KEY"
3321 upstream_auth_header = "api-key"
3322 upstream_auth_scheme = """#,
3323 )
3324 .unwrap();
3325 assert_eq!(azure.upstream_auth_scheme, "");
3326 }
3327
3328 #[test]
3329 fn env_overrides_apply_with_documented_precedence() {
3330 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3331 let env = |name: &str| -> Option<String> {
3332 match name {
3333 "PV_LISTEN" => Some("0.0.0.0:9999".into()),
3334 "PV_UPSTREAM_URL" => Some("http://env-upstream".into()),
3335 "PV_UPSTREAM_CHAT_PATH" => Some("/openai/v1/chat/completions".into()),
3336 "PV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3337 "PV_STATE_ENDPOINT" => Some("redis://redis:6379".into()),
3338 _ => None,
3339 }
3340 };
3341 cfg.apply_env_overrides_from(env);
3342 assert_eq!(cfg.listen, "0.0.0.0:9999");
3343 assert_eq!(cfg.upstream_url, "http://env-upstream");
3344 assert_eq!(cfg.upstream_chat_path, "/openai/v1/chat/completions");
3345 assert_eq!(cfg.state_endpoint.as_deref(), Some("redis://redis:6379"));
3346 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("PV_UPSTREAM_API_KEY"));
3348
3349 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3351 cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3352 cfg.apply_env_overrides_from(env);
3353 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3354
3355 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3357 cfg.apply_env_overrides_from(|name| (name == "PV_UPSTREAM_URL").then(String::new));
3358 assert_eq!(cfg.upstream_url, "http://file-upstream");
3359 }
3360
3361 #[test]
3362 fn env_key_file_override_precedence() {
3363 let env = |name: &str| -> Option<String> {
3365 match name {
3366 "PV_UPSTREAM_KEY_FILE" => Some("/run/secrets/api_key".into()),
3367 "PV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3368 _ => None,
3369 }
3370 };
3371 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3372 cfg.apply_env_overrides_from(env);
3373 assert_eq!(cfg.upstream_api_key_file.as_deref(), Some("/run/secrets/api_key"));
3374 assert_eq!(cfg.upstream_api_key_env, None);
3376
3377 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3379 cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3380 cfg.apply_env_overrides_from(env);
3381 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3382 assert_eq!(cfg.upstream_api_key_file, None);
3383
3384 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3386 cfg.upstream_authorization_passthrough = true;
3387 cfg.apply_env_overrides_from(env);
3388 assert_eq!(cfg.upstream_api_key_file, None);
3389 assert_eq!(cfg.upstream_api_key_env, None);
3390 }
3391
3392 #[test]
3393 fn builtin_config_validates_once_upstream_is_set() {
3394 let mut cfg = HarnessConfig::builtin().unwrap();
3395 assert!(cfg.validate().is_err(), "must not pass without an upstream");
3396 cfg.apply_env_overrides_from(|name| {
3397 (name == "PV_UPSTREAM_URL").then(|| "http://127.0.0.1:11434".to_owned())
3398 });
3399 cfg.validate().unwrap();
3400 assert_eq!(cfg.bridge_backend, "embedded");
3401 assert_eq!(cfg.state_backend, "memory");
3402 assert_eq!(cfg.embedder_backend, "hash");
3403 assert_eq!(cfg.vector_backend, "memory");
3404 }
3405
3406 #[test]
3407 fn validate_rejects_unbindable_listen_and_zero_intervals() {
3408 let base = || HarnessConfig::for_tests("http://u", "spool", "bridge");
3409
3410 let mut cfg = base();
3411 cfg.listen = String::new();
3412 assert!(cfg.validate().unwrap_err().contains("listen is required"));
3413
3414 cfg = base();
3415 cfg.listen = "no-port-here".into();
3416 assert!(cfg.validate().unwrap_err().contains("host:port"));
3417
3418 cfg = base();
3419 cfg.listen = "127.0.0.1:70000".into();
3420 assert!(cfg.validate().unwrap_err().contains("host:port"));
3421
3422 cfg = base();
3424 cfg.listen = "localhost:8484".into();
3425 cfg.validate().unwrap();
3426 cfg.listen = "[::1]:0".into();
3427 cfg.validate().unwrap();
3428
3429 cfg = base();
3430 cfg.reconcile_tick_s = 0;
3431 assert!(cfg.validate().unwrap_err().contains("reconcile_tick_s"));
3432
3433 cfg = base();
3434 cfg.session_idle_close_s = 0;
3435 assert!(cfg.validate().unwrap_err().contains("session_idle_close_s"));
3436
3437 cfg = base();
3442 cfg.atif_retention_days = Some(0);
3443 assert!(
3444 cfg.validate().unwrap_err().contains("atif_retention_days = 0"),
3445 "atif_retention_days = 0 should be rejected"
3446 );
3447 cfg.atif_retention_days = None;
3448 cfg.validate().unwrap();
3449 cfg.atif_retention_days = Some(1);
3450 cfg.validate().unwrap();
3451
3452 cfg = base();
3457 cfg.budget.max_tokens = Some(0);
3458 assert!(
3459 cfg.validate().unwrap_err().contains("budget.max_tokens"),
3460 "budget.max_tokens = 0 should be rejected"
3461 );
3462 cfg = base();
3463 cfg.budget.max_total_tool_calls = Some(0);
3464 assert!(
3465 cfg.validate()
3466 .unwrap_err()
3467 .contains("budget.max_total_tool_calls"),
3468 "budget.max_total_tool_calls = 0 should be rejected"
3469 );
3470 cfg = base();
3471 cfg.budget.max_payout_usd_micros = Some(0);
3472 assert!(
3473 cfg.validate()
3474 .unwrap_err()
3475 .contains("budget.max_payout_usd_micros"),
3476 "budget.max_payout_usd_micros = 0 should be rejected"
3477 );
3478 cfg = base();
3479 cfg.budget.max_tool_calls = std::collections::BTreeMap::from([("db_write".to_owned(), 0u64)]);
3480 assert!(
3481 cfg.validate()
3482 .unwrap_err()
3483 .contains("max_tool_calls[\"db_write\"]"),
3484 "budget.max_tool_calls[db_write] = 0 should be rejected"
3485 );
3486 cfg = base();
3488 cfg.require_identity = true;
3489 cfg.identity_jwks_url = Some("https://example/.well-known/jwks.json".to_owned());
3490 cfg.principal_budget = Some(pv_state::BudgetSpec {
3491 max_tokens: Some(0),
3492 ..pv_state::BudgetSpec::default()
3493 });
3494 assert!(
3495 cfg.validate()
3496 .unwrap_err()
3497 .contains("principal_budget.max_tokens"),
3498 "principal_budget.max_tokens = 0 should be rejected"
3499 );
3500 cfg = base();
3502 cfg.budget = pv_state::BudgetSpec::default();
3503 cfg.validate().unwrap();
3504
3505 cfg = base();
3506 cfg.breaker.window = 0;
3507 assert!(cfg.validate().unwrap_err().contains("breaker.window"));
3508
3509 cfg = base();
3510 cfg.breaker.delta_epsilon = f32::NAN;
3511 assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3512 cfg.breaker.delta_epsilon = -0.5;
3513 assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3514 }
3515
3516 #[test]
3526 fn operator_env_vars_are_documented() {
3527 let refs = [
3528 include_str!("../../../docs/reference/CONFIGURATION.md"),
3529 include_str!("../../../docs/reference/OPERATIONS.md"),
3530 include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md"),
3531 include_str!("../../../docs/reference/LIMITS.md"),
3532 ]
3533 .concat();
3534 for var in [
3535 "PV_CONFIG",
3537 "PV_UPSTREAM_URL",
3538 "PV_SIGNING_SEED_FILE",
3540 "PV_BEARER_TOKEN_FILE",
3542 ] {
3543 assert!(
3544 refs.contains(var),
3545 "operator-facing env var `{var}` is not documented in any \
3546 docs/reference/*.md — register item 25 explicitly names the \
3547 undocumented PV_ env var class as the drift signature."
3548 );
3549 }
3550 }
3551
3552 #[test]
3553 fn user_config_path_is_stable() {
3554 let path = user_config_path_from(std::path::Path::new("/home/pat"));
3555 assert_eq!(
3556 path,
3557 std::path::Path::new("/home/pat/.proofvisor/proofvisor.toml")
3558 );
3559 }
3560
3561 #[test]
3572 fn config_search_paths_never_include_the_example_config() {
3573 for path in CONFIG_SEARCH_PATHS {
3574 assert!(
3575 !path.contains("example"),
3576 "CONFIG_SEARCH_PATHS must not auto-discover a `*example*` file: {path:?}. \
3577 See the doc-comment on CONFIG_SEARCH_PATHS — the example is a template, \
3578 not a config, and auto-loading it defeats the wizard-written per-user file."
3579 );
3580 }
3581 assert_eq!(
3586 user_config_path_from(std::path::Path::new("/h")),
3587 std::path::PathBuf::from("/h/.proofvisor/proofvisor.toml"),
3588 "the wizard-written path is contract; changing it breaks every operator \
3589 whose home already has this file"
3590 );
3591 }
3592
3593 #[test]
3597 fn duplicated_chat_path_segment_detection() {
3598 let cfg = |url: &str| HarnessConfig::for_tests(url, "spool", "bridge");
3599 assert_eq!(
3600 cfg("https://api.openai.com/v1").duplicated_chat_path_segment(),
3601 Some("v1")
3602 );
3603 assert_eq!(
3604 cfg("https://api.openai.com/v1/").duplicated_chat_path_segment(),
3605 Some("v1")
3606 );
3607 assert_eq!(
3608 cfg("http://localhost:8080/v1").duplicated_chat_path_segment(),
3609 Some("v1")
3610 );
3611 assert_eq!(cfg("https://api.openai.com").duplicated_chat_path_segment(), None);
3612 assert_eq!(cfg("http://127.0.0.1:11434").duplicated_chat_path_segment(), None);
3613 let mut gemini = cfg("https://generativelanguage.googleapis.com/v1beta/openai");
3615 gemini.upstream_chat_path = "/chat/completions".into();
3616 assert_eq!(gemini.duplicated_chat_path_segment(), None);
3617 let mut azure = cfg("https://r.openai.azure.com/openai");
3619 azure.upstream_chat_path = "/openai/deployments/d/chat/completions".into();
3620 assert_eq!(azure.duplicated_chat_path_segment(), Some("openai"));
3621 assert_eq!(
3623 cfg("http://10.0.0.5:8000/v1/chat/completions").duplicated_chat_path_segment(),
3624 Some("v1/chat/completions")
3625 );
3626 assert_eq!(
3627 cfg("http://10.0.0.5:8000/v1/chat/completions/").duplicated_chat_path_segment(),
3628 Some("v1/chat/completions")
3629 );
3630 }
3631
3632 #[test]
3633 fn bad_configs_rejected() {
3634 assert!(HarnessConfig::from_toml("").is_err()); assert!(HarnessConfig::from_toml(
3636 r#"upstream_url = "https://api"
3637 config_version = 99"#
3638 )
3639 .is_err());
3640 let bad_workflow = HarnessConfig::from_toml(
3646 r#"upstream_url = "https://api"
3647 default_workflow = "sometimes""#,
3648 )
3649 .unwrap_err();
3650 assert!(
3651 bad_workflow.contains("default_workflow"),
3652 "the default_workflow allowlist must be what refuses this config, \
3653 not the upstream_url scheme check via fallthrough; got {bad_workflow}"
3654 );
3655 let bad_capacity = HarnessConfig::from_toml(
3656 r#"upstream_url = "https://api"
3657 worker_channel_capacity = 0"#,
3658 )
3659 .unwrap_err();
3660 assert!(
3661 bad_capacity.contains("worker_channel_capacity"),
3662 "the worker_channel_capacity bounds check must be what refuses this \
3663 config, not the upstream_url scheme check via fallthrough; got {bad_capacity}"
3664 );
3665 }
3666
3667 #[test]
3671 fn future_version_config_reports_version_not_unknown_field() {
3672 let err = HarnessConfig::from_toml(
3673 r#"config_version = 2
3674 upstream_url = "https://api"
3675 future_option_from_v2 = true"#,
3676 )
3677 .unwrap_err();
3678 assert!(err.contains("unsupported config_version 2"), "{err}");
3679 assert!(!err.contains("future_option_from_v2"), "{err}");
3680 }
3681
3682 #[test]
3686 fn worker_channel_capacity_cap_rejects_oversized_values() {
3687 let err = HarnessConfig::from_toml(&format!(
3688 "upstream_url = \"https://api\"\nworker_channel_capacity = {}",
3689 MAX_WORKER_CHANNEL_CAPACITY + 1
3690 ))
3691 .unwrap_err();
3692 assert!(
3693 err.contains("worker_channel_capacity"),
3694 "err should name the offending field: {err}"
3695 );
3696 }
3697
3698 #[test]
3704 fn upstream_url_without_scheme_is_rejected() {
3705 let err = HarnessConfig::from_toml(r#"upstream_url = "openai.internal""#).unwrap_err();
3706 assert!(err.contains("upstream_url"), "{err}");
3707 assert!(err.contains("http"), "{err}");
3708 }
3709
3710 #[test]
3716 fn upstream_url_non_http_scheme_is_rejected() {
3717 let err = HarnessConfig::from_toml(r#"upstream_url = "file:///etc/passwd""#).unwrap_err();
3718 assert!(err.contains("upstream_url"), "{err}");
3719 let err = HarnessConfig::from_toml(r#"upstream_url = "gopher://x""#).unwrap_err();
3720 assert!(err.contains("upstream_url"), "{err}");
3721 assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
3723 assert!(HarnessConfig::from_toml(r#"upstream_url = "http://gw.local""#).is_ok());
3724 }
3725
3726 #[test]
3731 fn upstream_url_without_host_is_rejected() {
3732 for url in [r#"upstream_url = "http://""#, r#"upstream_url = "https:///v1""#] {
3733 let err = HarnessConfig::from_toml(url).unwrap_err();
3734 assert!(err.contains("no host"), "{err}");
3735 }
3736 }
3737
3738 #[test]
3750 fn shipped_example_config_parses_and_pins_safe_posture() {
3751 let raw = include_str!("../../../config/harness.example.toml");
3752 let config = HarnessConfig::from_toml(raw)
3753 .unwrap_or_else(|error| panic!("shipped example config must validate: {error}"));
3754 assert_eq!(config.listen, "127.0.0.1:8484", "example must bind loopback");
3755 assert!(!config.dashboard_enabled, "example must ship dashboard-off");
3756 assert_eq!(
3757 config.budget.max_tokens,
3758 Some(200_000),
3759 "example must bind a token budget"
3760 );
3761 assert_eq!(config.default_workflow, "signed");
3762 assert_eq!(
3763 config.redaction_patterns, RECOMMENDED_REDACTION_PATTERNS,
3764 "example config must turn redaction on with the recommended patterns"
3765 );
3766 for (name, raw) in [
3767 (
3768 "container",
3769 include_str!("../../../config/harness.container.toml"),
3770 ),
3771 ("docker", include_str!("../../../config/harness.docker.toml")),
3772 ] {
3773 let config = HarnessConfig::from_toml(raw)
3774 .unwrap_or_else(|error| panic!("shipped {name} config must validate: {error}"));
3775 assert_eq!(
3776 config.default_workflow, "signed",
3777 "{name} config must opt into the signed workflow"
3778 );
3779 assert_eq!(
3785 config.redaction_patterns, RECOMMENDED_REDACTION_PATTERNS,
3786 "{name} config must turn redaction on with the recommended patterns"
3787 );
3788 }
3789 }
3790
3791 #[test]
3796 fn recommended_redaction_patterns_compile_and_match_their_targets() {
3797 for pattern in RECOMMENDED_REDACTION_PATTERNS {
3798 assert!(
3799 !pattern.contains('\''),
3800 "{pattern:?} cannot be written as a TOML literal string"
3801 );
3802 }
3803 let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
3804 regex_patterns: RECOMMENDED_REDACTION_PATTERNS
3805 .iter()
3806 .map(|pattern| (*pattern).to_owned())
3807 .collect(),
3808 ..pv_redact::RedactionConfig::default()
3809 })
3810 .unwrap();
3811 for (input, expected) in [
3812 ("aws AKIAIOSFODNN7EXAMPLE here", "aws [REDACTED] here"),
3813 (
3814 "gh ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij done",
3815 "gh [REDACTED] done",
3816 ),
3817 (
3818 "g AIzaSyA-1234567890abcdefghijklmnopqrstu end",
3819 "g [REDACTED] end",
3820 ),
3821 ("slack xoxb-1234567890-abcdef end", "slack [REDACTED] end"),
3822 (
3823 "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdefghijklmnop end",
3824 "jwt [REDACTED] end",
3825 ),
3826 ("mail alice@example.org", "mail [REDACTED]"),
3828 ] {
3829 let mut value = serde_json::Value::String(input.to_owned());
3830 engine.redact_value(&mut value);
3831 assert_eq!(value, serde_json::Value::String(expected.to_owned()), "{input}");
3832 }
3833 for benign in [
3834 "ghost_stories are fun",
3835 "xoxo",
3836 "AKIA is a prefix",
3837 "eyJ is how base64 JSON starts",
3838 "the AIza prefix",
3839 ] {
3840 let mut value = serde_json::Value::String(benign.to_owned());
3841 engine.redact_value(&mut value);
3842 assert_eq!(value, serde_json::Value::String(benign.to_owned()));
3843 }
3844 }
3845
3846 #[test]
3861 fn shipped_tool_schemas_parse_as_json() {
3862 for (name, raw) in [
3863 (
3864 "payout.json",
3865 include_str!("../../../config/tool-schemas/payout.json"),
3866 ),
3867 (
3868 "db_write.json",
3869 include_str!("../../../config/tool-schemas/db_write.json"),
3870 ),
3871 (
3872 "deploy.json",
3873 include_str!("../../../config/tool-schemas/deploy.json"),
3874 ),
3875 (
3876 "merge.json",
3877 include_str!("../../../config/tool-schemas/merge.json"),
3878 ),
3879 ] {
3880 let value: serde_json::Value = serde_json::from_str(raw).unwrap_or_else(|error| {
3881 panic!(
3882 "shipped tool schema {name} must parse as JSON — the daemon calls \
3883 serde_json::from_slice on each .json file under tool_schema_dir at \
3884 boot; a parse failure here CrashLoopBackOffs the pod. Error: {error}"
3885 )
3886 });
3887 assert!(
3893 value.is_object(),
3894 "shipped tool schema {name} must be a JSON object at the top level \
3895 (JSON Schema convention); a bare array/scalar would silently accept \
3896 every tool call"
3897 );
3898 }
3899 }
3900
3901 #[test]
3919 fn shipped_kubernetes_configmap_toml_parses_and_pins_safe_posture() {
3920 let yaml = include_str!("../../../deploy/kubernetes/proofvisor.yaml");
3921 let block_header = "proofvisor.toml: |";
3927 let block_start = yaml
3928 .find(block_header)
3929 .unwrap_or_else(|| panic!("K8s manifest must embed a `{block_header}` block"))
3930 + block_header.len();
3931 let after = &yaml[block_start..];
3932 let after = after.trim_start_matches('\n');
3933 let mut toml = String::new();
3937 for line in after.lines() {
3938 if line.is_empty() || line.trim().is_empty() {
3939 toml.push('\n');
3940 continue;
3941 }
3942 if let Some(stripped) = line.strip_prefix(" ") {
3943 toml.push_str(stripped);
3944 toml.push('\n');
3945 } else {
3946 break;
3947 }
3948 }
3949 assert!(
3950 toml.contains("config_version"),
3951 "extracted ConfigMap TOML looks empty; did the block indent change?"
3952 );
3953
3954 let config = HarnessConfig::from_toml(&toml).unwrap_or_else(|error| {
3955 panic!(
3956 "K8s ConfigMap proofvisor.toml block must parse and validate under \
3957 the same strict loader proofvisord uses; a rename/typo here \
3958 CrashLoopBackOffs every pod at boot. Error: {error}\n\n\
3959 Extracted TOML:\n{toml}"
3960 )
3961 });
3962
3963 assert_eq!(
3967 config.default_workflow, "signed",
3968 "K8s ConfigMap must opt into the signed workflow (register item 19); \
3969 the serde default `unsigned` silently mints zero receipts on clean \
3970 traffic"
3971 );
3972 assert!(
3973 !config.dashboard_enabled,
3974 "K8s ConfigMap must ship dashboard-off — the pod binds 0.0.0.0 for \
3975 cluster network reachability, so any workload with Service access \
3976 could otherwise enumerate sessions/costs/receipts (register item 4)"
3977 );
3978 assert!(config.require_identity, "K8s workloads must authenticate callers");
3979 assert!(
3980 !config.allow_wildcard_bind,
3981 "anonymous wildcard binding must not be enabled"
3982 );
3983 assert_eq!(config.audience, "agentvisor-ai");
3984 assert_eq!(
3985 config.identity_hmac_secret_file.as_deref(),
3986 Some("/etc/proofvisor/identity.hmac")
3987 );
3988 assert_eq!(
3989 config.state_backend, "redis",
3990 "production revocations must survive restart"
3991 );
3992 assert_eq!(
3993 config.state_endpoint.as_deref(),
3994 Some("rediss://redis.example.invalid:6379")
3995 );
3996 assert!(yaml.contains("name: PV_STATE_ENDPOINT"));
3997 assert!(yaml.contains("name: proofvisor-state"));
3998 assert!(yaml.contains("key: redis-url"));
3999 }
4000
4001 #[test]
4002 fn shipped_cloudfoundry_config_requires_identity_and_durable_revocations() {
4003 let config = HarnessConfig::from_toml(include_str!("../../../deploy/cloudfoundry/harness.toml"))
4004 .expect("Cloud Foundry configuration must validate");
4005 assert!(config.require_identity);
4006 assert!(!config.dashboard_enabled);
4007 assert!(!config.allow_wildcard_bind);
4008 assert_eq!(config.audience, "agentvisor-ai");
4009 assert_eq!(config.state_backend, "redis");
4010 assert_eq!(
4011 config.state_endpoint.as_deref(),
4012 Some("rediss://redis.example.invalid:6379")
4013 );
4014 let manifest = include_str!("../../../deploy/cloudfoundry/manifest.yml");
4015 assert!(manifest.contains("PV_STATE_ENDPOINT: ((redis_url))"));
4016 }
4017
4018 #[test]
4028 fn shipped_kubernetes_configmap_total_shutdown_fits_termination_grace() {
4029 let yaml = include_str!("../../../deploy/kubernetes/proofvisor.yaml");
4030 let block_header = "proofvisor.toml: |";
4032 let block_start = yaml.find(block_header).unwrap() + block_header.len();
4033 let after = yaml[block_start..].trim_start_matches('\n');
4034 let mut toml = String::new();
4035 for line in after.lines() {
4036 if line.trim().is_empty() {
4037 toml.push('\n');
4038 continue;
4039 }
4040 if let Some(stripped) = line.strip_prefix(" ") {
4041 toml.push_str(stripped);
4042 toml.push('\n');
4043 } else {
4044 break;
4045 }
4046 }
4047 let config = HarnessConfig::from_toml(&toml).unwrap();
4048 assert_total_shutdown_fits(
4049 "K8s manifest ConfigMap",
4050 &config,
4051 extract_yaml_int(yaml, "terminationGracePeriodSeconds:").unwrap(),
4052 require_prestop_sleep_seconds(yaml),
4060 );
4061 }
4062
4063 #[test]
4070 fn shipped_docker_compose_stop_grace_fits_harness_docker_shutdown() {
4071 let compose = include_str!("../../../docker/docker-compose.yml");
4072 let toml = include_str!("../../../config/harness.docker.toml");
4073 let config = HarnessConfig::from_toml(toml).unwrap();
4074 assert_total_shutdown_fits(
4075 "docker-compose.yml / harness.docker.toml",
4076 &config,
4077 extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
4078 0,
4080 );
4081 }
4082
4083 #[test]
4084 fn shipped_full_docker_config_disables_unauthenticated_dashboard() {
4085 let config = HarnessConfig::from_toml(include_str!("../../../config/harness.docker.toml")).unwrap();
4086 assert!(config.require_identity, "full-stack callers must authenticate");
4087 assert!(
4088 config.enforce_identity_scopes,
4089 "full-stack callers need operation scopes"
4090 );
4091 assert!(
4092 !config.dashboard_enabled,
4093 "Compose peers can reach the listener without the host loopback port mapping"
4094 );
4095 assert!(
4096 !config.allow_wildcard_bind,
4097 "the authenticated full stack must not bypass the wildcard safety guards"
4098 );
4099 }
4100
4101 #[test]
4106 fn shipped_docker_compose_minimal_stop_grace_fits_container_shutdown() {
4107 let compose = include_str!("../../../docker/docker-compose.minimal.yml");
4108 let toml = include_str!("../../../config/harness.container.toml");
4109 let config = HarnessConfig::from_toml(toml).unwrap();
4110 assert_total_shutdown_fits(
4111 "docker-compose.minimal.yml / harness.container.toml",
4112 &config,
4113 extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
4114 0,
4115 );
4116 }
4117
4118 fn assert_total_shutdown_fits(
4136 label: &str,
4137 config: &HarnessConfig,
4138 grace_seconds: u64,
4139 pre_signal_seconds: u64,
4140 ) {
4141 let drain = config.effective_drain_timeout().as_secs();
4142 let total = drain
4143 .saturating_add(WORKER_FINALIZE_PHASE_SECS)
4144 .saturating_add(WORKER_FINALIZE_PHASE_SECS)
4145 .saturating_add(OTEL_FLUSH_SECS)
4146 .saturating_add(config.shutdown_ready_drain_s)
4147 .saturating_add(pre_signal_seconds);
4148 assert!(
4149 total <= grace_seconds,
4150 "{label}: worst-case shutdown budget {total}s (drain {drain}s + \
4151 worker {WORKER_FINALIZE_PHASE_SECS}s + finalize {WORKER_FINALIZE_PHASE_SECS}s + \
4152 OTel {OTEL_FLUSH_SECS}s + shutdown_ready_drain_s {}s + pre-signal {}s) \
4153 exceeds the grace period {grace_seconds}s — \
4154 kubelet/dockerd will SIGKILL mid-finalize and drop receipts on \
4155 rolling deploy. Either lower shutdown_drain_timeout_s / \
4156 shutdown_ready_drain_s / the preStop sleep in the config or raise \
4157 the grace period in the deployment manifest.",
4158 config.shutdown_ready_drain_s,
4159 pre_signal_seconds,
4160 );
4161 }
4162
4163 fn extract_yaml_int(yaml: &str, key: &str) -> Option<u64> {
4164 for line in yaml.lines() {
4165 let trimmed = line.trim_start();
4166 if let Some(rest) = trimmed.strip_prefix(key) {
4167 return rest.trim().parse().ok();
4168 }
4169 }
4170 None
4171 }
4172
4173 fn extract_prestop_sleep_seconds(yaml: &str) -> Option<u64> {
4190 let (prestop_line_idx, prestop_indent) = yaml.lines().enumerate().find_map(|(i, line)| {
4195 let trimmed = line.trim_start();
4196 if trimmed.starts_with('#') {
4197 return None;
4198 }
4199 if trimmed.starts_with("preStop:") {
4200 Some((i, line.len() - trimmed.len()))
4201 } else {
4202 None
4203 }
4204 })?;
4205 let mut scope = String::new();
4216 for (i, line) in yaml.lines().enumerate().skip(prestop_line_idx) {
4217 if i == prestop_line_idx {
4218 scope.push_str(line);
4219 scope.push('\n');
4220 continue;
4221 }
4222 let trimmed = line.trim_start();
4223 if trimmed.is_empty() || trimmed.starts_with('#') {
4224 scope.push_str(line);
4225 scope.push('\n');
4226 continue;
4227 }
4228 let indent = line.len() - trimmed.len();
4229 if indent <= prestop_indent {
4230 break;
4231 }
4232 scope.push_str(line);
4233 scope.push('\n');
4234 }
4235 for quote in ['"', '\''] {
4238 let marker = format!("{quote}sleep ");
4239 if let Some(hit) = scope.find(&marker) {
4240 let rest = &scope[hit + marker.len()..];
4241 let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4242 if let Ok(seconds) = n.parse() {
4243 return Some(seconds);
4244 }
4245 }
4246 }
4247 for line in scope.lines() {
4251 let stripped = line.trim_start().trim_start_matches('-').trim();
4252 if let Some(rest) = stripped.strip_prefix("sleep ") {
4253 let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4254 if let Ok(seconds) = n.parse() {
4255 return Some(seconds);
4256 }
4257 }
4258 }
4259 None
4260 }
4261
4262 fn require_prestop_sleep_seconds(yaml: &str) -> u64 {
4271 let has_prestop = yaml.lines().any(|line| {
4276 let trimmed = line.trim_start();
4277 !trimmed.starts_with('#') && trimmed.starts_with("preStop:")
4278 });
4279 if !has_prestop {
4280 return 0;
4281 }
4282 extract_prestop_sleep_seconds(yaml).expect(
4283 "shipped K8s manifest has a preStop hook but extract_prestop_sleep_seconds \
4284 could not parse its sleep duration — either update the parser to handle \
4285 the new shape (inline vs block YAML, quoting style), or if preStop no \
4286 longer uses `sleep`, delete this test and its helper. Silently returning \
4287 0 would defeat the pin's own purpose: catching a preStop-time bump that \
4288 pushes total shutdown past the grace period.",
4289 )
4290 }
4291
4292 #[test]
4297 fn extract_prestop_scope_ignores_downstream_sleep_after_the_block() {
4298 let yaml = "\
4304apiVersion: apps/v1
4305kind: Deployment
4306spec:
4307 template:
4308 spec:
4309 containers:
4310 - name: main
4311 lifecycle:
4312 preStop:
4313 exec:
4314 command:
4315 - sh
4316 - -c
4317 - sleep 60
4318 resources:
4319 limits:
4320 cpu: \"2\"
4321 - name: sidecar
4322 command: [\"sh\", \"-c\", \"sleep 3\"]
4323";
4324 assert_eq!(
4325 extract_prestop_sleep_seconds(yaml),
4326 Some(60),
4327 "the parser must scope to the preStop block and ignore \
4328 downstream containers' inline sleep commands"
4329 );
4330 }
4331
4332 #[test]
4333 fn extract_prestop_scope_ignores_downstream_probe_sleep() {
4334 let yaml = "\
4350spec:
4351 containers:
4352 - name: main
4353 lifecycle:
4354 preStop:
4355 exec:
4356 command: ['sh', '-c', 'sleep 3']
4357 livenessProbe:
4358 exec:
4359 command: [\"sh\", \"-c\", \"sleep 300\"]
4360";
4361 assert_eq!(extract_prestop_sleep_seconds(yaml), Some(3));
4362 }
4363
4364 #[test]
4365 fn require_prestop_skips_commented_out_prestop_line() {
4366 let yaml = "\
4370spec:
4371 containers:
4372 - name: main
4373 # NOTE: do not add preStop: to this container, see runbook.
4374 livenessProbe:
4375 exec:
4376 command: [\"sh\", \"-c\", \"sleep 90\"]
4377";
4378 assert_eq!(
4379 require_prestop_sleep_seconds(yaml),
4380 0,
4381 "a comment mentioning preStop: is NOT a real hook; require_prestop \
4382 must return 0, not the downstream probe's sleep"
4383 );
4384 }
4385
4386 fn extract_yaml_grace_seconds(yaml: &str, key: &str) -> Option<u64> {
4390 for line in yaml.lines() {
4391 let trimmed = line.trim_start();
4392 if let Some(rest) = trimmed.strip_prefix(key) {
4393 let raw = rest.trim().trim_end_matches('s');
4394 return raw.parse().ok();
4395 }
4396 }
4397 None
4398 }
4399
4400 #[test]
4409 fn shipped_systemd_unit_stop_timeout_fits_default_shutdown() {
4410 let unit = include_str!("../../../deploy/systemd/proofvisor.service");
4411 let stop_timeout = unit
4412 .lines()
4413 .find_map(|line| line.trim().strip_prefix("TimeoutStopSec="))
4414 .and_then(|raw| raw.trim().trim_end_matches('s').parse::<u64>().ok())
4415 .expect("unit file must set TimeoutStopSec explicitly");
4416 let config =
4418 HarnessConfig::from_toml("config_version = 1\nupstream_url = \"https://api.openai.com\"")
4419 .unwrap();
4420 assert_total_shutdown_fits(
4421 "deploy/systemd/proofvisor.service / pvctl-init defaults",
4422 &config,
4423 stop_timeout,
4424 0,
4427 );
4428 }
4429
4430 #[test]
4435 fn empty_tool_upstream_url_is_rejected() {
4436 let err =
4437 HarnessConfig::from_toml("upstream_url = \"https://api\"\ntool_upstream_url = \"\"").unwrap_err();
4438 assert!(err.contains("tool_upstream_url"), "{err}");
4439 assert!(err.contains("omit"), "should point at omitting the field: {err}");
4440 assert!(HarnessConfig::from_toml(
4441 "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp\"",
4442 )
4443 .is_ok());
4444 }
4445
4446 #[test]
4459 fn empty_or_whitespace_payout_field_is_rejected() {
4460 for hostile in [
4461 "",
4462 " ",
4463 " ",
4464 "\t",
4465 " amount_usd",
4466 "amount_usd ",
4467 "\u{FEFF}amount_usd", "amount_usd\u{FEFF}", "\u{200B}amount_usd", "amount\u{200B}_usd", "\u{2060}amount_usd", "amount_usd\u{00A0}", "amount_usd\u{201D}", "amóunt_usd", ] {
4480 let toml = format!("upstream_url = \"https://api.openai.com\"\npayout_field = \"{hostile}\"\n");
4496 let err = match HarnessConfig::from_toml(&toml) {
4497 Err(e) => e,
4498 Ok(_) => panic!("must refuse payout_field = {hostile:?}"),
4499 };
4500 assert!(err.contains("payout_field"), "{hostile:?} => {err}");
4501 }
4502 assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
4504 assert!(HarnessConfig::from_toml(
4506 r#"upstream_url = "https://api.openai.com"
4507 payout_field = "amount_usd""#,
4508 )
4509 .is_ok());
4510 }
4511
4512 #[test]
4517 fn hostless_tool_upstream_url_is_rejected() {
4518 for url in ["http://", "https:///mcp", "http://?x=1"] {
4519 let err = HarnessConfig::from_toml(&format!(
4520 "upstream_url = \"https://api\"\ntool_upstream_url = \"{url}\""
4521 ))
4522 .unwrap_err();
4523 assert!(
4524 err.contains("tool_upstream_url") && err.contains("no host"),
4525 "{url}: {err}"
4526 );
4527 }
4528 }
4529
4530 #[test]
4538 fn upstream_url_rejects_query_and_fragment() {
4539 for url in ["http://127.0.0.1:9000/#oops", "https://api.example.com/?tenant=1"] {
4540 let err = HarnessConfig::from_toml(&format!("upstream_url = \"{url}\"")).unwrap_err();
4541 assert!(
4542 err.contains("upstream_url") && err.contains("query or fragment"),
4543 "{url}: {err}"
4544 );
4545 }
4546 HarnessConfig::from_toml(
4548 "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp?fmt=json\"",
4549 )
4550 .unwrap();
4551 }
4552
4553 #[test]
4559 fn wildcard_bind_with_dashboard_requires_explicit_acknowledgment() {
4560 let err = HarnessConfig::from_toml(
4561 r#"upstream_url = "https://api"
4562 listen = "0.0.0.0:8484"
4563 require_identity = true
4564 identity_hmac_secret_file = "/etc/pv/hmac"
4565 dashboard_enabled = true"#,
4566 )
4567 .unwrap_err();
4568 assert!(
4569 err.contains("dashboard") && err.contains("every interface"),
4570 "wildcard + dashboard must refuse: {err}"
4571 );
4572 HarnessConfig::from_toml(
4574 r#"upstream_url = "https://api"
4575 listen = "0.0.0.0:8484"
4576 require_identity = true
4577 identity_hmac_secret_file = "/etc/pv/hmac"
4578 dashboard_enabled = true
4579 allow_wildcard_bind = true"#,
4580 )
4581 .unwrap();
4582 HarnessConfig::from_toml(
4583 r#"upstream_url = "https://api"
4584 listen = "0.0.0.0:8484"
4585 require_identity = true
4586 identity_hmac_secret_file = "/etc/pv/hmac"
4587 dashboard_enabled = false"#,
4588 )
4589 .unwrap();
4590 }
4591
4592 #[test]
4595 fn seconds_intervals_reject_absurdly_large_values() {
4596 let err = HarnessConfig::from_toml(&format!(
4597 "upstream_url = \"https://api\"\nsession_idle_close_s = {}",
4598 MAX_SECONDS_INTERVAL + 1
4599 ))
4600 .unwrap_err();
4601 assert!(
4602 err.contains("session_idle_close_s"),
4603 "err should name the offending field: {err}"
4604 );
4605 assert!(err.contains("milliseconds"), "hint should mention ms: {err}");
4606 }
4607
4608 #[test]
4612 fn upstream_read_timeout_rejects_zero() {
4613 let err = HarnessConfig::from_toml(
4614 r#"upstream_url = "https://api"
4615 upstream_read_timeout_s = 0"#,
4616 )
4617 .unwrap_err();
4618 assert!(
4619 err.contains("upstream_read_timeout_s"),
4620 "err should name the offending field: {err}"
4621 );
4622 assert!(err.contains("omit"), "err should point at the default: {err}");
4623 assert!(HarnessConfig::from_toml(
4625 r#"upstream_url = "https://api"
4626 upstream_read_timeout_s = 1"#,
4627 )
4628 .is_ok());
4629 }
4630
4631 #[test]
4632 fn mcp_request_timeout_is_positive_and_bounded() {
4633 let default = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
4634 assert_eq!(
4635 default
4636 .mcp_request_timeout_s
4637 .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S),
4638 60
4639 );
4640 for invalid in [0, MAX_SECONDS_INTERVAL + 1] {
4641 let error = HarnessConfig::from_toml(&format!(
4642 "upstream_url = \"https://api\"\nmcp_request_timeout_s = {invalid}"
4643 ))
4644 .unwrap_err();
4645 assert!(error.contains("mcp_request_timeout_s"), "{error}");
4646 }
4647 for valid in [1, DEFAULT_MCP_REQUEST_TIMEOUT_S, MAX_SECONDS_INTERVAL] {
4648 let config = HarnessConfig::from_toml(&format!(
4649 "upstream_url = \"https://api\"\nmcp_request_timeout_s = {valid}"
4650 ))
4651 .unwrap();
4652 assert_eq!(config.mcp_request_timeout_s, Some(valid));
4653 }
4654 }
4655
4656 #[test]
4661 fn round_30_f1_scope_enforcement_requires_identity_requirement() {
4662 let err = HarnessConfig::from_toml(
4663 r#"upstream_url = "https://api.openai.com"
4664 require_identity = false
4665 enforce_identity_scopes = true"#,
4666 )
4667 .unwrap_err();
4668 assert!(
4669 err.contains("enforce_identity_scopes"),
4670 "err should name the flag: {err}"
4671 );
4672 assert!(
4673 err.contains("require_identity"),
4674 "err should name the correlate: {err}"
4675 );
4676 assert!(HarnessConfig::from_toml(
4678 r#"upstream_url = "https://api.openai.com"
4679 require_identity = false
4680 enforce_identity_scopes = false"#,
4681 )
4682 .is_ok());
4683 }
4684
4685 #[test]
4688 fn drain_timeout_derives_from_upstream_read_timeout() {
4689 let base = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4690 assert_eq!(base.effective_drain_timeout().as_secs(), 65);
4691 let mut derived = base.clone();
4692 derived.upstream_read_timeout_s = Some(300);
4693 assert_eq!(derived.effective_drain_timeout().as_secs(), 305);
4694 derived.mcp_request_timeout_s = Some(600);
4695 assert_eq!(derived.effective_drain_timeout().as_secs(), 605);
4696 let mut short = base.clone();
4698 short.upstream_read_timeout_s = Some(10);
4699 assert_eq!(short.effective_drain_timeout().as_secs(), 65);
4700 short.mcp_request_timeout_s = Some(10);
4701 assert_eq!(short.effective_drain_timeout().as_secs(), 30);
4702 let mut explicit = derived;
4704 explicit.shutdown_drain_timeout_s = Some(110);
4705 assert_eq!(explicit.effective_drain_timeout().as_secs(), 110);
4706 let mut zero = base;
4707 zero.shutdown_drain_timeout_s = Some(0);
4708 assert!(zero.validate().unwrap_err().contains("shutdown_drain_timeout_s"));
4709 }
4710
4711 #[test]
4718 fn wildcard_bind_without_identity_is_refused_unless_explicitly_allowed() {
4719 let err = HarnessConfig::from_toml(
4721 r#"upstream_url = "https://api.openai.com"
4722 listen = "0.0.0.0:8484"
4723 require_identity = false"#,
4724 )
4725 .unwrap_err();
4726 assert!(err.contains("every interface"), "err should name the risk: {err}");
4727 assert!(
4728 err.contains("allow_wildcard_bind"),
4729 "err should name the escape hatch: {err}"
4730 );
4731
4732 let err = HarnessConfig::from_toml(
4734 r#"upstream_url = "https://api.openai.com"
4735 listen = "[::]:8484"
4736 require_identity = false"#,
4737 )
4738 .unwrap_err();
4739 assert!(err.contains("every interface"), "IPv6 wildcard: {err}");
4740
4741 for listen in [
4745 "[::0]:8484",
4746 "[0:0:0:0:0:0:0:0]:8484",
4747 "[0000::]:8484",
4748 "[::ffff:0.0.0.0]:8484",
4751 "[::ffff:0:0]:8484",
4752 ] {
4753 let err = HarnessConfig::from_toml(&format!(
4754 r#"upstream_url = "https://api.openai.com"
4755 listen = "{listen}"
4756 require_identity = false"#,
4757 ))
4758 .unwrap_err();
4759 assert!(
4760 err.contains("every interface"),
4761 "non-canonical wildcard {listen}: {err}"
4762 );
4763 }
4764
4765 HarnessConfig::from_toml(
4767 r#"upstream_url = "https://api.openai.com"
4768 listen = "127.0.0.1:8484"
4769 require_identity = false"#,
4770 )
4771 .unwrap()
4772 .validate()
4773 .unwrap();
4774
4775 HarnessConfig::from_toml(
4777 r#"upstream_url = "https://api.openai.com"
4778 listen = "0.0.0.0:8484"
4779 require_identity = false
4780 allow_wildcard_bind = true"#,
4781 )
4782 .unwrap()
4783 .validate()
4784 .unwrap();
4785
4786 HarnessConfig::from_toml(
4793 r#"upstream_url = "https://api.openai.com"
4794 listen = "0.0.0.0:8484"
4795 require_identity = true
4796 dashboard_enabled = false
4797 identity_jwks_url = "https://idp/keys.json""#,
4798 )
4799 .unwrap()
4800 .validate()
4801 .unwrap();
4802 }
4803
4804 #[test]
4808 fn round_30_f2_url_scheme_allowlist_enforced() {
4809 let err = HarnessConfig::from_toml(
4811 r#"upstream_url = "https://api"
4812 identity_jwks_url = "auth.internal/jwks""#,
4813 )
4814 .unwrap_err();
4815 assert!(err.contains("identity_jwks_url"), "{err}");
4816 assert!(err.contains("http://") || err.contains("https://"), "{err}");
4817 let err = HarnessConfig::from_toml(
4819 r#"upstream_url = "https://api"
4820 identity_jwks_url = "file:///etc/passwd""#,
4821 )
4822 .unwrap_err();
4823 assert!(err.contains("identity_jwks_url"), "{err}");
4824 let err = HarnessConfig::from_toml(
4826 r#"upstream_url = "https://api"
4827 state_backend = "redis"
4828 state_endpoint = "http://cache""#,
4829 )
4830 .unwrap_err();
4831 assert!(err.contains("state_endpoint"), "{err}");
4832 let err = HarnessConfig::from_toml(
4837 r#"upstream_url = "https://api"
4838 state_backend = "redis"
4839 state_endpoint = "redis://a:6379,http://b:6379""#,
4840 )
4841 .unwrap_err();
4842 assert!(err.contains("state_endpoint"), "{err}");
4843 HarnessConfig::from_toml(
4845 r#"upstream_url = "https://api"
4846 state_backend = "redis"
4847 state_endpoint = "rediss://a:6379,rediss://b:6380""#,
4848 )
4849 .unwrap();
4850 for endpoint in [
4851 "redis://a:6379,rediss://b:6380",
4852 "rediss://a:6379/#insecure",
4853 "redis://a:6379,",
4854 "",
4855 "http://user:fixture-secret@cache:6379",
4856 ] {
4857 let err = HarnessConfig::from_toml(&format!(
4858 "upstream_url = \"https://api\"\nstate_backend = \"redis\"\nstate_endpoint = {endpoint:?}"
4859 ))
4860 .unwrap_err();
4861 assert!(err.contains("state_endpoint"), "{err}");
4862 assert!(
4863 !err.contains("fixture-secret"),
4864 "configuration errors must not expose credentials"
4865 );
4866 }
4867 HarnessConfig::from_toml(
4870 r#"upstream_url = "https://api"
4871 state_backend = "redis"
4872 state_endpoint = "redis+unix:/tmp/redis.sock""#,
4873 )
4874 .unwrap();
4875 let err = HarnessConfig::from_toml(
4879 r#"upstream_url = "https://api"
4880 max_request_bytes = 0"#,
4881 )
4882 .unwrap_err();
4883 assert!(err.contains("max_request_bytes"), "{err}");
4884 let err = HarnessConfig::from_toml(
4886 r#"upstream_url = "https://api"
4887 bridge_backend = "nats"
4888 bridge_endpoint = "http://bus""#,
4889 )
4890 .unwrap_err();
4891 assert!(err.contains("bridge_endpoint"), "{err}");
4892 let err = HarnessConfig::from_toml(
4894 r#"upstream_url = "https://api"
4895 vector_backend = "qdrant"
4896 qdrant_url = "vectors.internal:6333""#,
4897 )
4898 .unwrap_err();
4899 assert!(err.contains("qdrant_url"), "{err}");
4900 assert!(HarnessConfig::from_toml(
4902 r#"upstream_url = "https://api"
4903 identity_jwks_url = "https://auth.internal/jwks"
4904 state_backend = "redis"
4905 state_endpoint = "redis://cache:6379"
4906 bridge_backend = "nats"
4907 bridge_endpoint = "nats://bus:4222"
4908 vector_backend = "qdrant"
4909 qdrant_url = "https://vectors.internal:6333""#,
4910 )
4911 .is_ok());
4912 }
4913
4914 #[test]
4919 fn round_31_f1_local_fs_paths_empty_rejected() {
4920 let err = HarnessConfig::from_toml(
4921 r#"upstream_url = "https://api"
4922 atif_spool_dir = """#,
4923 )
4924 .unwrap_err();
4925 assert!(err.contains("atif_spool_dir"), "{err}");
4926 let err = HarnessConfig::from_toml(
4927 r#"upstream_url = "https://api"
4928 bridge_data_dir = """#,
4929 )
4930 .unwrap_err();
4931 assert!(err.contains("bridge_data_dir"), "{err}");
4932 }
4933
4934 #[test]
4939 fn round_31_f2_scope_names_rejected_empty_or_whitespaced() {
4940 let err = HarnessConfig::from_toml(
4942 r#"upstream_url = "https://api"
4943 require_identity = true
4944 identity_hmac_secret_file = "/tmp/secret"
4945 enforce_identity_scopes = true
4946 chat_scope = """#,
4947 )
4948 .unwrap_err();
4949 assert!(err.contains("chat_scope"), "{err}");
4950 let err = HarnessConfig::from_toml(
4952 r#"upstream_url = "https://api"
4953 require_identity = true
4954 identity_hmac_secret_file = "/tmp/secret"
4955 enforce_identity_scopes = true
4956 chat_scope = "chat write""#,
4957 )
4958 .unwrap_err();
4959 assert!(err.contains("chat_scope"), "{err}");
4960 let err = HarnessConfig::from_toml(
4962 r#"upstream_url = "https://api"
4963 require_identity = true
4964 identity_hmac_secret_file = "/tmp/secret"
4965 enforce_identity_scopes = true
4966 session_close_scope = "close\tsession""#,
4967 )
4968 .unwrap_err();
4969 assert!(err.contains("session_close_scope"), "{err}");
4970 assert!(HarnessConfig::from_toml(
4972 r#"upstream_url = "https://api"
4973 require_identity = true
4974 identity_hmac_secret_file = "/tmp/secret"
4975 enforce_identity_scopes = true
4976 chat_scope = "chat:write"
4977 session_close_scope = "session:close"
4978 session_promote_scope = "session:promote""#,
4979 )
4980 .is_ok());
4981 }
4982}
4983
4984#[cfg(any(test, feature = "test-support"))]
4995#[path = "config_testkit.rs"]
4996mod testkit;